portless-studio 0.1.0 → 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 +78 -192
- package/dist/cli/cli-args.js +29 -3
- package/dist/cli/index.js +10 -15
- package/dist/cli/login-service.js +16 -0
- package/dist/cli/mcp/studio-tools.js +20 -7
- package/dist/cli/studio-items.js +5 -2
- package/dist/core/api/contract.js +19 -1
- package/dist/core/config/studio-config.js +6 -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/core/updates/version.js +62 -0
- package/dist/server/application/auto-namer.js +14 -0
- package/dist/server/application/container-source.js +19 -0
- package/dist/server/application/doctor.js +225 -9
- package/dist/server/application/port-discovery.js +49 -6
- package/dist/server/application/proxy-start-check.js +83 -0
- 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/application/update-check.js +66 -0
- package/dist/server/infrastructure/config/json-update-cache.js +35 -0
- package/dist/server/infrastructure/portless/portless-registry.js +31 -4
- package/dist/server/infrastructure/portless/state-files.js +23 -7
- package/dist/server/infrastructure/system/docker-containers.js +64 -0
- package/dist/server/infrastructure/system/doctor-facts.js +44 -1
- package/dist/server/infrastructure/system/npm-registry.js +20 -0
- 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 +43 -2
- package/dist/server/interface/http/json-io.js +1 -0
- package/dist/server/main.js +62 -8
- 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 +2 -2
- package/dist/web/assets/index-CA7HpYbk.css +0 -1
- package/dist/web/assets/index-CC6BquZf.js +0 -15
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { withPublicPort } from "../../core/routes/route.js";
|
|
2
|
+
/** The port browsers use when a URL has none. */
|
|
3
|
+
export const SHORT_URL_PORT = 80;
|
|
4
|
+
/**
|
|
5
|
+
* Keeps Studio's port-80 forwarder in line with the config. portless asks for sudo to start its
|
|
6
|
+
* proxy below port 1024 on Linux and macOS even where the system allows the bind, so the proxy
|
|
7
|
+
* stays on `proxyPort` and Studio listens on port 80 itself, as the current user, never with sudo.
|
|
8
|
+
*/
|
|
9
|
+
export class ShortUrls {
|
|
10
|
+
#forwarding;
|
|
11
|
+
#port;
|
|
12
|
+
#log;
|
|
13
|
+
#reachesProxy;
|
|
14
|
+
#watchers = new Set();
|
|
15
|
+
#running = null;
|
|
16
|
+
#status = { state: "off" };
|
|
17
|
+
#queue = Promise.resolve();
|
|
18
|
+
#synced = false;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.#forwarding = options.forwarding;
|
|
21
|
+
this.#port = options.port ?? SHORT_URL_PORT;
|
|
22
|
+
this.#log = options.log;
|
|
23
|
+
this.#reachesProxy = options.reachesProxy ?? null;
|
|
24
|
+
}
|
|
25
|
+
/** The port Studio listens on for short URLs, whether or not it listens right now. */
|
|
26
|
+
get port() {
|
|
27
|
+
return this.#port;
|
|
28
|
+
}
|
|
29
|
+
/** True once a sync ran in this process; a CLI command that never syncs has to ask elsewhere. */
|
|
30
|
+
get synced() {
|
|
31
|
+
return this.#synced;
|
|
32
|
+
}
|
|
33
|
+
/** The port in route URLs while the forwarder listens; null keeps the proxy port. */
|
|
34
|
+
publicPort() {
|
|
35
|
+
return this.#running?.port ?? null;
|
|
36
|
+
}
|
|
37
|
+
status() {
|
|
38
|
+
return this.#status;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The status for the doctor. In the server it is this process's own; elsewhere Studio may run
|
|
42
|
+
* in another process, so port 80 is asked whether it reaches the proxy. Undefined when unknown.
|
|
43
|
+
*/
|
|
44
|
+
async inspect(config) {
|
|
45
|
+
if (this.#synced)
|
|
46
|
+
return this.#status;
|
|
47
|
+
const wanted = this.#wanted(config);
|
|
48
|
+
if (wanted.state !== "listening")
|
|
49
|
+
return wanted;
|
|
50
|
+
const reached = await (this.#reachesProxy?.(this.#port) ?? Promise.resolve(false));
|
|
51
|
+
return reached ? wanted : undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Calls `onChange` when the public port changes. Returns an unsubscribe. */
|
|
54
|
+
onChange(onChange) {
|
|
55
|
+
this.#watchers.add(onChange);
|
|
56
|
+
return () => this.#watchers.delete(onChange);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Listens, moves or stops to match the config, one sync at a time. A failed listen is tried
|
|
60
|
+
* again on the next sync, so a doctor fix or a config save after `sysctl` picks it up.
|
|
61
|
+
*/
|
|
62
|
+
sync(config) {
|
|
63
|
+
const next = this.#queue.then(() => this.#sync(config));
|
|
64
|
+
this.#queue = next.catch(() => { });
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
close() {
|
|
68
|
+
const next = this.#queue.then(async () => {
|
|
69
|
+
await this.#stop();
|
|
70
|
+
this.#status = { state: "off" };
|
|
71
|
+
});
|
|
72
|
+
this.#queue = next.catch(() => { });
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
async #sync(config) {
|
|
76
|
+
this.#synced = true;
|
|
77
|
+
const wanted = this.#wanted(config);
|
|
78
|
+
if (wanted.state !== "listening") {
|
|
79
|
+
await this.#stop();
|
|
80
|
+
this.#status = wanted;
|
|
81
|
+
return wanted;
|
|
82
|
+
}
|
|
83
|
+
const target = config.proxyPort;
|
|
84
|
+
if (this.#running?.target === target)
|
|
85
|
+
return this.#status;
|
|
86
|
+
await this.#stop();
|
|
87
|
+
const started = await this.#forwarding.listen(this.#port, target);
|
|
88
|
+
if (!started.ok) {
|
|
89
|
+
this.#status = { state: "failed", port: this.#port, ...started.error };
|
|
90
|
+
this.#log(`short URLs are off: ${started.error.message} Names keep :${target}.`);
|
|
91
|
+
return this.#status;
|
|
92
|
+
}
|
|
93
|
+
this.#running = { target, port: started.value.port, close: started.value.close };
|
|
94
|
+
this.#status = { state: "listening", port: started.value.port, target };
|
|
95
|
+
this.#log(`short URLs: port ${started.value.port} forwards to the proxy on port ${target}`);
|
|
96
|
+
this.#changed();
|
|
97
|
+
return this.#status;
|
|
98
|
+
}
|
|
99
|
+
#wanted(config) {
|
|
100
|
+
if (!config.shortUrls)
|
|
101
|
+
return { state: "off" };
|
|
102
|
+
if (config.proxyHttps)
|
|
103
|
+
return { state: "https" };
|
|
104
|
+
if (config.proxyPort === this.#port)
|
|
105
|
+
return { state: "proxy-on-port" };
|
|
106
|
+
return { state: "listening", port: this.#port, target: config.proxyPort };
|
|
107
|
+
}
|
|
108
|
+
async #stop() {
|
|
109
|
+
const running = this.#running;
|
|
110
|
+
if (!running)
|
|
111
|
+
return;
|
|
112
|
+
this.#running = null;
|
|
113
|
+
await running.close();
|
|
114
|
+
this.#changed();
|
|
115
|
+
}
|
|
116
|
+
#changed() {
|
|
117
|
+
for (const watcher of this.#watchers)
|
|
118
|
+
watcher();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The registry the rest of Studio reads: the same routes, with URLs on the public port while the
|
|
123
|
+
* forwarder listens, so the page, the CLI, MCP and notifications all show the URL that opens.
|
|
124
|
+
*/
|
|
125
|
+
export function publicUrls(registry, publicPort) {
|
|
126
|
+
return {
|
|
127
|
+
readSnapshot: async () => withPublicPort(await registry.readSnapshot(), publicPort()),
|
|
128
|
+
watch: (onChange) => registry.watch(onChange),
|
|
129
|
+
createAlias: (name, port, force) => registry.createAlias(name, port, force),
|
|
130
|
+
removeAlias: (name) => registry.removeAlias(name),
|
|
131
|
+
installPortless: () => registry.installPortless(),
|
|
132
|
+
startProxy: (port, https) => registry.startProxy(port, https),
|
|
133
|
+
stopProxy: () => registry.stopProxy(),
|
|
134
|
+
proxyPid: () => registry.proxyPid(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -42,6 +42,9 @@ export class StudioRuntime {
|
|
|
42
42
|
#events = new EventLog();
|
|
43
43
|
#icons;
|
|
44
44
|
#routeProjectStore;
|
|
45
|
+
#containers;
|
|
46
|
+
#shortUrls;
|
|
47
|
+
#ready = Promise.resolve();
|
|
45
48
|
/** Last project seen per route name; null until read from the store. */
|
|
46
49
|
#routeProjects = null;
|
|
47
50
|
/** What the store last got, so an unchanged map is not written again. */
|
|
@@ -81,6 +84,9 @@ export class StudioRuntime {
|
|
|
81
84
|
this.#autoNamedStore = options.autoNamedStore ?? memoryAutoNamedStore();
|
|
82
85
|
this.#notifier = options.notifier ?? null;
|
|
83
86
|
this.#routeProjectStore = options.routeProjectStore ?? memoryRouteProjectStore();
|
|
87
|
+
this.#containers = options.containers ?? null;
|
|
88
|
+
this.#shortUrls = options.shortUrls ?? null;
|
|
89
|
+
this.#shortUrls?.onChange(() => this.#notify());
|
|
84
90
|
const noIcons = {
|
|
85
91
|
served: async () => null,
|
|
86
92
|
remote: async () => null,
|
|
@@ -98,11 +104,16 @@ export class StudioRuntime {
|
|
|
98
104
|
return;
|
|
99
105
|
this.#started = true;
|
|
100
106
|
this.#stopConfigWatch = this.#store.watch(() => void this.#reloadConfig());
|
|
101
|
-
|
|
107
|
+
this.#ready = Promise.all([this.#reloadConfig(), this.#loadAutoNamed()]).then(async () => {
|
|
102
108
|
this.#reschedule();
|
|
103
109
|
void this.ensureProxy();
|
|
110
|
+
await this.#shortUrls?.sync(this.#config);
|
|
104
111
|
});
|
|
105
112
|
}
|
|
113
|
+
/** Resolves once `start` read the config and the short-URL forwarder settled. */
|
|
114
|
+
ready() {
|
|
115
|
+
return this.#ready;
|
|
116
|
+
}
|
|
106
117
|
/**
|
|
107
118
|
* Starts the portless proxy when the config asks for it and it is not running, so names work
|
|
108
119
|
* without anyone running portless by hand. Called at start and after portless gets installed.
|
|
@@ -128,11 +139,12 @@ export class StudioRuntime {
|
|
|
128
139
|
this.#stopRegistryWatch?.();
|
|
129
140
|
this.#stopRegistryWatch = undefined;
|
|
130
141
|
this.#watchers.clear();
|
|
142
|
+
void this.#shortUrls?.close();
|
|
131
143
|
}
|
|
132
144
|
setStudioPort(port) {
|
|
133
145
|
this.#studioPort = port;
|
|
134
146
|
}
|
|
135
|
-
async readSnapshot() {
|
|
147
|
+
async readSnapshot(options = {}) {
|
|
136
148
|
const state = await this.#registry.readSnapshot();
|
|
137
149
|
const config = await this.#currentConfig();
|
|
138
150
|
const detected = await this.#detect(state, config, false);
|
|
@@ -147,7 +159,18 @@ export class StudioRuntime {
|
|
|
147
159
|
readAt: state.readAt,
|
|
148
160
|
};
|
|
149
161
|
this.#observe(snapshot, config);
|
|
150
|
-
|
|
162
|
+
const shown = options.includeStudio ? routes : this.#listedRoutes(routes, config);
|
|
163
|
+
return this.#withIcons({ ...snapshot, routes: shown }, config);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The routes people see: without Studio's own (`studio`, on Studio's port or its short-URL
|
|
167
|
+
* port) unless `showStudioRoute` is on. It only takes room, and the page is already open.
|
|
168
|
+
*/
|
|
169
|
+
#listedRoutes(routes, config) {
|
|
170
|
+
if (config.showStudioRoute)
|
|
171
|
+
return routes;
|
|
172
|
+
const own = new Set([this.#studioPort, this.#shortUrls?.publicPort() ?? null]);
|
|
173
|
+
return routes.filter((route) => !own.has(route.port));
|
|
151
174
|
}
|
|
152
175
|
/** The last notable changes of this run, oldest first, for a page that connects later. */
|
|
153
176
|
recentEvents() {
|
|
@@ -259,11 +282,18 @@ export class StudioRuntime {
|
|
|
259
282
|
scan: async () => scan.ports,
|
|
260
283
|
answersHttp: (port) => this.#scanner.answersHttp(port),
|
|
261
284
|
};
|
|
262
|
-
const
|
|
285
|
+
const forwarderPort = this.#shortUrls?.publicPort() ?? null;
|
|
286
|
+
const excludedPorts = [
|
|
287
|
+
this.#studioPort,
|
|
288
|
+
forwarderPort,
|
|
289
|
+
state.proxy.port,
|
|
290
|
+
...scan.proxyPorts,
|
|
291
|
+
].filter((port) => port !== null);
|
|
263
292
|
const found = await discoverPorts(cached, this.#projects, {
|
|
264
293
|
config,
|
|
265
294
|
routes: state.routes,
|
|
266
295
|
excludedPorts,
|
|
296
|
+
containers: scan.containers,
|
|
267
297
|
});
|
|
268
298
|
return found.map((item) => {
|
|
269
299
|
if (item.cwd === null || !item.project)
|
|
@@ -276,14 +306,21 @@ export class StudioRuntime {
|
|
|
276
306
|
#scanPorts(maxAgeMs) {
|
|
277
307
|
if (this.#scan && Date.now() - this.#scan.at < maxAgeMs)
|
|
278
308
|
return Promise.resolve(this.#scan);
|
|
279
|
-
this.#scanning ??= this.#scanner.scan().then(async (ports) => {
|
|
309
|
+
this.#scanning ??= Promise.all([this.#scanner.scan(), this.#readContainers()]).then(async ([ports, containers]) => {
|
|
280
310
|
const proxyPorts = await this.#proxyPortsAmong(ports).catch(() => []);
|
|
281
|
-
this.#scan = { ports, proxyPorts, at: Date.now() };
|
|
311
|
+
this.#scan = { ports, proxyPorts, containers, at: Date.now() };
|
|
282
312
|
this.#scanning = null;
|
|
283
313
|
return this.#scan;
|
|
284
314
|
});
|
|
285
315
|
return this.#scanning;
|
|
286
316
|
}
|
|
317
|
+
/** Docker is optional: any failure to read it means no containers, never a failed scan. */
|
|
318
|
+
async #readContainers() {
|
|
319
|
+
if (!this.#containers)
|
|
320
|
+
return [];
|
|
321
|
+
const reading = await this.#containers.read().catch(() => null);
|
|
322
|
+
return reading?.state === "ok" ? reading.containers : [];
|
|
323
|
+
}
|
|
287
324
|
#reschedule() {
|
|
288
325
|
clearTimeout(this.#timer);
|
|
289
326
|
this.#timer = undefined;
|
|
@@ -444,7 +481,7 @@ export class StudioRuntime {
|
|
|
444
481
|
/** Turns what changed since the last look into events, and tells the OS when nobody watches. */
|
|
445
482
|
#observe(snapshot, config) {
|
|
446
483
|
const next = {
|
|
447
|
-
routes: snapshot.routes,
|
|
484
|
+
routes: this.#listedRoutes(snapshot.routes, config),
|
|
448
485
|
detected: snapshot.detected,
|
|
449
486
|
proxy: snapshot.proxy,
|
|
450
487
|
};
|
|
@@ -573,6 +610,8 @@ export class StudioRuntime {
|
|
|
573
610
|
this.#config = config;
|
|
574
611
|
this.#reschedule();
|
|
575
612
|
this.#notify();
|
|
613
|
+
if (this.#started)
|
|
614
|
+
void this.#shortUrls?.sync(config);
|
|
576
615
|
}
|
|
577
616
|
#notify() {
|
|
578
617
|
for (const watcher of this.#watchers)
|
|
@@ -23,6 +23,8 @@ export class TerminalManager {
|
|
|
23
23
|
#graceMs;
|
|
24
24
|
#maxTerminals;
|
|
25
25
|
#namingTimeoutMs;
|
|
26
|
+
#processTree;
|
|
27
|
+
#homeDir;
|
|
26
28
|
#entries = new Map();
|
|
27
29
|
#watchers = new Set();
|
|
28
30
|
/** Folders a local program ran a terminal in. */
|
|
@@ -30,6 +32,8 @@ export class TerminalManager {
|
|
|
30
32
|
#nextId = 1;
|
|
31
33
|
#stopWorkspaceWatch;
|
|
32
34
|
#naming = false;
|
|
35
|
+
#refreshingPorts = false;
|
|
36
|
+
#portsAgain = false;
|
|
33
37
|
constructor(options) {
|
|
34
38
|
this.#spawner = options.spawner;
|
|
35
39
|
this.#workspace = options.workspace;
|
|
@@ -39,6 +43,8 @@ export class TerminalManager {
|
|
|
39
43
|
this.#graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
|
|
40
44
|
this.#maxTerminals = options.maxTerminals ?? DEFAULT_MAX_TERMINALS;
|
|
41
45
|
this.#namingTimeoutMs = options.namingTimeoutMs ?? DEFAULT_NAMING_TIMEOUT_MS;
|
|
46
|
+
this.#processTree = options.processTree;
|
|
47
|
+
this.#homeDir = options.homeDir;
|
|
42
48
|
}
|
|
43
49
|
get mode() {
|
|
44
50
|
return this.#spawner.mode;
|
|
@@ -53,7 +59,21 @@ export class TerminalManager {
|
|
|
53
59
|
/** Calls `onChange` when a terminal starts, ends, is named or closes. */
|
|
54
60
|
watch(onChange) {
|
|
55
61
|
this.#watchers.add(onChange);
|
|
56
|
-
|
|
62
|
+
this.#updateWorkspaceWatch();
|
|
63
|
+
return () => {
|
|
64
|
+
this.#watchers.delete(onChange);
|
|
65
|
+
this.#updateWorkspaceWatch();
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Gives the terminal a name of its own; an empty one gives back the default title. */
|
|
69
|
+
rename(id, title) {
|
|
70
|
+
const entry = this.#entries.get(id);
|
|
71
|
+
if (!entry)
|
|
72
|
+
return notFound(id);
|
|
73
|
+
const wanted = title.trim().slice(0, MAX_TITLE_CHARS);
|
|
74
|
+
entry.info = { ...entry.info, title: wanted || entry.defaultTitle };
|
|
75
|
+
this.#publishStatus(entry);
|
|
76
|
+
return ok({ ...entry.info });
|
|
57
77
|
}
|
|
58
78
|
async create(request, origin) {
|
|
59
79
|
if (this.#entries.size >= this.#maxTerminals) {
|
|
@@ -71,14 +91,20 @@ export class TerminalManager {
|
|
|
71
91
|
const cols = readSize(request.cols, DEFAULT_COLS);
|
|
72
92
|
const rows = readSize(request.rows, DEFAULT_ROWS);
|
|
73
93
|
const naming = readNaming(request.name);
|
|
74
|
-
const
|
|
94
|
+
const defaultTitle = (command.value || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
|
|
95
|
+
const title = request.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
|
|
96
|
+
const project = await this.#projectOf(cwd.value);
|
|
75
97
|
const entry = {
|
|
98
|
+
defaultTitle,
|
|
76
99
|
info: {
|
|
77
100
|
id,
|
|
78
101
|
title,
|
|
79
102
|
cwd: cwd.value,
|
|
80
103
|
command: command.value ?? "",
|
|
81
|
-
project:
|
|
104
|
+
project: project?.name ?? null,
|
|
105
|
+
worktree: project?.worktree ?? null,
|
|
106
|
+
branch: project?.branch ?? null,
|
|
107
|
+
ports: [],
|
|
82
108
|
status: "running",
|
|
83
109
|
exitCode: null,
|
|
84
110
|
startedAt: this.#now(),
|
|
@@ -117,7 +143,11 @@ export class TerminalManager {
|
|
|
117
143
|
this.#entries.set(id, entry);
|
|
118
144
|
if (naming)
|
|
119
145
|
this.#startNaming(entry);
|
|
146
|
+
else
|
|
147
|
+
this.#updateWorkspaceWatch();
|
|
120
148
|
this.#changed();
|
|
149
|
+
if (this.#watchers.size > 0)
|
|
150
|
+
void this.#refreshPorts();
|
|
121
151
|
return ok({ ...entry.info });
|
|
122
152
|
}
|
|
123
153
|
input(id, text) {
|
|
@@ -157,6 +187,9 @@ export class TerminalManager {
|
|
|
157
187
|
entry.info = { ...entry.info, status: "running", exitCode: null, endedAt: null };
|
|
158
188
|
entry.info.startedAt = this.#now();
|
|
159
189
|
this.#publishStatus(entry);
|
|
190
|
+
this.#updateWorkspaceWatch();
|
|
191
|
+
if (this.#watchers.size > 0)
|
|
192
|
+
void this.#refreshPorts();
|
|
160
193
|
return ok({ ...entry.info });
|
|
161
194
|
}
|
|
162
195
|
/** Closes the terminal; its command, if still running, is killed. */
|
|
@@ -170,7 +203,7 @@ export class TerminalManager {
|
|
|
170
203
|
entry.process.kill();
|
|
171
204
|
entry.process = null;
|
|
172
205
|
entry.watchers.clear();
|
|
173
|
-
this.#
|
|
206
|
+
this.#updateWorkspaceWatch();
|
|
174
207
|
this.#changed();
|
|
175
208
|
return ok(undefined);
|
|
176
209
|
}
|
|
@@ -232,7 +265,9 @@ export class TerminalManager {
|
|
|
232
265
|
message: "The command ended before it opened a port.",
|
|
233
266
|
});
|
|
234
267
|
}
|
|
268
|
+
entry.info.ports = [];
|
|
235
269
|
this.#publishStatus(entry);
|
|
270
|
+
this.#updateWorkspaceWatch();
|
|
236
271
|
});
|
|
237
272
|
return ok(undefined);
|
|
238
273
|
}
|
|
@@ -270,6 +305,8 @@ export class TerminalManager {
|
|
|
270
305
|
watcher();
|
|
271
306
|
}
|
|
272
307
|
async #resolveFolder(request, origin) {
|
|
308
|
+
if (origin.picked)
|
|
309
|
+
return this.#pickedFolder(request.cwd);
|
|
273
310
|
if (typeof request.project === "string" && request.project !== "" && !request.cwd) {
|
|
274
311
|
const folder = await this.#projectFolder(request.project);
|
|
275
312
|
if (folder === null) {
|
|
@@ -294,6 +331,24 @@ export class TerminalManager {
|
|
|
294
331
|
return ok(folder);
|
|
295
332
|
return err(appError("path_not_allowed", "Terminals run only in project folders Studio shows. Start it from the CLI to use another folder."));
|
|
296
333
|
}
|
|
334
|
+
/** A folder typed in the page's picker: any existing folder, with `~` as the home folder. */
|
|
335
|
+
#pickedFolder(typed) {
|
|
336
|
+
if (typeof typed !== "string" || typed.trim() === "") {
|
|
337
|
+
return err(appError("invalid_request", "Give the folder to open."));
|
|
338
|
+
}
|
|
339
|
+
let wanted = typed.trim();
|
|
340
|
+
if (this.#homeDir && (wanted === "~" || /^~[\\/]/.test(wanted))) {
|
|
341
|
+
wanted = `${this.#homeDir}${wanted.slice(1)}`;
|
|
342
|
+
}
|
|
343
|
+
if (!isAbsolute(wanted)) {
|
|
344
|
+
return err(appError("invalid_request", "The folder must be an absolute path or start with ~."));
|
|
345
|
+
}
|
|
346
|
+
const folder = this.#folders.directory(wanted);
|
|
347
|
+
if (folder === null) {
|
|
348
|
+
return err(appError("path_not_allowed", `${wanted} is not a folder on this machine.`));
|
|
349
|
+
}
|
|
350
|
+
return ok(folder);
|
|
351
|
+
}
|
|
297
352
|
async #projectFolder(name) {
|
|
298
353
|
const { routes, detected } = await this.#workspace.snapshot();
|
|
299
354
|
for (const item of [...routes, ...detected]) {
|
|
@@ -303,6 +358,7 @@ export class TerminalManager {
|
|
|
303
358
|
}
|
|
304
359
|
return null;
|
|
305
360
|
}
|
|
361
|
+
/** The project whose folder holds `cwd`, the innermost one when several do. */
|
|
306
362
|
async #projectOf(cwd) {
|
|
307
363
|
const { routes, detected } = await this.#workspace.snapshot();
|
|
308
364
|
let best = null;
|
|
@@ -310,10 +366,70 @@ export class TerminalManager {
|
|
|
310
366
|
const path = item.project?.path;
|
|
311
367
|
if (!item.project || !path || !this.#folders.contains(path, cwd))
|
|
312
368
|
continue;
|
|
313
|
-
if (!best || path.length > best.length)
|
|
314
|
-
best =
|
|
369
|
+
if (!best?.path || path.length > best.path.length)
|
|
370
|
+
best = item.project;
|
|
371
|
+
}
|
|
372
|
+
return best;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Links every listening process to the running terminal it descends from, and publishes the
|
|
376
|
+
* terminals whose ports changed. Calls that arrive while one runs make it run once more.
|
|
377
|
+
*/
|
|
378
|
+
async #refreshPorts() {
|
|
379
|
+
const tree = this.#processTree;
|
|
380
|
+
if (!tree)
|
|
381
|
+
return;
|
|
382
|
+
if (this.#refreshingPorts) {
|
|
383
|
+
this.#portsAgain = true;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.#refreshingPorts = true;
|
|
387
|
+
try {
|
|
388
|
+
do {
|
|
389
|
+
this.#portsAgain = false;
|
|
390
|
+
await this.#linkPorts(tree);
|
|
391
|
+
} while (this.#portsAgain);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
this.#refreshingPorts = false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async #linkPorts(tree) {
|
|
398
|
+
const running = [...this.#entries.values()].filter((entry) => entry.info.status === "running" && entry.info.pid !== null);
|
|
399
|
+
if (running.length === 0)
|
|
400
|
+
return;
|
|
401
|
+
const { routes, detected } = await this.#workspace.snapshot();
|
|
402
|
+
const listeners = new Map();
|
|
403
|
+
for (const item of detected) {
|
|
404
|
+
if (item.pid !== null)
|
|
405
|
+
listeners.set(item.port, { port: item.port, route: null, url: null });
|
|
406
|
+
}
|
|
407
|
+
const pids = new Map();
|
|
408
|
+
for (const item of detected)
|
|
409
|
+
if (item.pid !== null)
|
|
410
|
+
pids.set(item.port, item.pid);
|
|
411
|
+
for (const item of routes) {
|
|
412
|
+
if (item.listenerPid === undefined)
|
|
413
|
+
continue;
|
|
414
|
+
pids.set(item.port, item.listenerPid);
|
|
415
|
+
listeners.set(item.port, { port: item.port, route: item.name, url: item.url });
|
|
416
|
+
}
|
|
417
|
+
const ancestry = new Map();
|
|
418
|
+
for (const pid of new Set(pids.values())) {
|
|
419
|
+
ancestry.set(pid, await tree.ancestors(pid).catch(() => []));
|
|
420
|
+
}
|
|
421
|
+
for (const entry of running) {
|
|
422
|
+
const pid = entry.info.pid;
|
|
423
|
+
const ports = [...pids]
|
|
424
|
+
.filter(([, listener]) => listener === pid || ancestry.get(listener)?.includes(pid ?? -1))
|
|
425
|
+
.map(([port]) => listeners.get(port))
|
|
426
|
+
.filter((port) => port !== undefined)
|
|
427
|
+
.sort((a, b) => a.port - b.port);
|
|
428
|
+
if (JSON.stringify(ports) === JSON.stringify(entry.info.ports))
|
|
429
|
+
continue;
|
|
430
|
+
entry.info = { ...entry.info, ports };
|
|
431
|
+
this.#publishStatus(entry);
|
|
315
432
|
}
|
|
316
|
-
return best?.name ?? null;
|
|
317
433
|
}
|
|
318
434
|
async #portsInUse() {
|
|
319
435
|
const { routes, detected } = await this.#workspace.snapshot();
|
|
@@ -330,18 +446,29 @@ export class TerminalManager {
|
|
|
330
446
|
this.#publishStatus(entry);
|
|
331
447
|
}, this.#namingTimeoutMs);
|
|
332
448
|
entry.namingTimer.unref?.();
|
|
333
|
-
this.#
|
|
449
|
+
this.#updateWorkspaceWatch();
|
|
334
450
|
}
|
|
335
451
|
#waitingEntries() {
|
|
336
452
|
return [...this.#entries.values()].filter((entry) => entry.info.naming?.state === "waiting");
|
|
337
453
|
}
|
|
338
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Watches the workspace, which keeps Studio scanning, only while a terminal waits for a port,
|
|
456
|
+
* or while someone watches the list and a terminal runs, so the ports it opened stay current.
|
|
457
|
+
*/
|
|
458
|
+
#updateWorkspaceWatch() {
|
|
459
|
+
const linking = this.#processTree !== undefined &&
|
|
460
|
+
this.#watchers.size > 0 &&
|
|
461
|
+
[...this.#entries.values()].some((entry) => entry.info.status === "running");
|
|
462
|
+
const needed = linking || this.#waitingEntries().length > 0;
|
|
463
|
+
if (needed && !this.#stopWorkspaceWatch) {
|
|
464
|
+
this.#stopWorkspaceWatch = this.#workspace.watch(() => {
|
|
465
|
+
if (this.#waitingEntries().length > 0)
|
|
466
|
+
void this.#nameNewPorts();
|
|
467
|
+
if (this.#watchers.size > 0)
|
|
468
|
+
void this.#refreshPorts();
|
|
469
|
+
});
|
|
343
470
|
}
|
|
344
|
-
else if (!
|
|
471
|
+
else if (!needed && this.#stopWorkspaceWatch) {
|
|
345
472
|
this.#stopWorkspaceWatch();
|
|
346
473
|
this.#stopWorkspaceWatch = undefined;
|
|
347
474
|
}
|
|
@@ -357,7 +484,7 @@ export class TerminalManager {
|
|
|
357
484
|
}
|
|
358
485
|
finally {
|
|
359
486
|
this.#naming = false;
|
|
360
|
-
this.#
|
|
487
|
+
this.#updateWorkspaceWatch();
|
|
361
488
|
}
|
|
362
489
|
}
|
|
363
490
|
async #nameFor(entry, routes, detected) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isNewerVersion, UPDATE_COMMAND } from "../../core/updates/version.js";
|
|
2
|
+
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
/** Says whether a newer portless-studio is on npm, asking the registry at most once a day. */
|
|
4
|
+
export class UpdateChecker {
|
|
5
|
+
#options;
|
|
6
|
+
#now;
|
|
7
|
+
#pending = null;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.#options = options;
|
|
10
|
+
this.#now = options.now ?? Date.now;
|
|
11
|
+
}
|
|
12
|
+
async status() {
|
|
13
|
+
const { current } = this.#options;
|
|
14
|
+
const latest = (await this.#options.enabled()) ? await this.#latest() : null;
|
|
15
|
+
return {
|
|
16
|
+
current,
|
|
17
|
+
latest,
|
|
18
|
+
available: latest !== null && isNewerVersion(latest, current),
|
|
19
|
+
command: UPDATE_COMMAND,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
#latest() {
|
|
23
|
+
const cached = this.#readCache();
|
|
24
|
+
const now = this.#now();
|
|
25
|
+
if (cached && cached.checkedAt <= now && now - cached.checkedAt < UPDATE_CHECK_INTERVAL_MS) {
|
|
26
|
+
return Promise.resolve(cached.latest);
|
|
27
|
+
}
|
|
28
|
+
this.#pending ??= this.#check(cached).finally(() => {
|
|
29
|
+
this.#pending = null;
|
|
30
|
+
});
|
|
31
|
+
return this.#pending;
|
|
32
|
+
}
|
|
33
|
+
async #check(cached) {
|
|
34
|
+
const latest = await this.#options.fetchLatest().catch(() => null);
|
|
35
|
+
// A failed request is written down too, so an offline machine does not retry on every start.
|
|
36
|
+
const entry = { checkedAt: this.#now(), latest: latest ?? cached?.latest ?? null };
|
|
37
|
+
try {
|
|
38
|
+
this.#options.cache.write(entry);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// The notice is optional; a read-only config folder only means asking again next time.
|
|
42
|
+
}
|
|
43
|
+
return entry.latest;
|
|
44
|
+
}
|
|
45
|
+
#readCache() {
|
|
46
|
+
try {
|
|
47
|
+
return this.#options.cache.read();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Update checks never run in CI or tests, and `PORTLESS_STUDIO_NO_UPDATE_CHECK=1` turns them off
|
|
56
|
+
* whatever the config says.
|
|
57
|
+
*/
|
|
58
|
+
export function updateChecksAllowed(env) {
|
|
59
|
+
if (env.PORTLESS_STUDIO_NO_UPDATE_CHECK === "1")
|
|
60
|
+
return false;
|
|
61
|
+
if (env.CI)
|
|
62
|
+
return false;
|
|
63
|
+
if (env.VITEST)
|
|
64
|
+
return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
4
|
+
const FILE_NAME = "update-check.json";
|
|
5
|
+
/** `update-check.json` in the config file's folder. */
|
|
6
|
+
export function updateCheckPathFor(configPath) {
|
|
7
|
+
return path.join(path.dirname(configPath), FILE_NAME);
|
|
8
|
+
}
|
|
9
|
+
/** The last update check as `{ "checkedAt": <epoch ms>, "latest": "<version>" | null }`. */
|
|
10
|
+
export class JsonUpdateCache {
|
|
11
|
+
#file;
|
|
12
|
+
constructor(file) {
|
|
13
|
+
this.#file = file;
|
|
14
|
+
}
|
|
15
|
+
read() {
|
|
16
|
+
try {
|
|
17
|
+
const json = JSON.parse(fs.readFileSync(this.#file, "utf8"));
|
|
18
|
+
if (typeof json !== "object" || json === null)
|
|
19
|
+
return null;
|
|
20
|
+
const checkedAt = Reflect.get(json, "checkedAt");
|
|
21
|
+
const latest = Reflect.get(json, "latest");
|
|
22
|
+
if (typeof checkedAt !== "number" || !Number.isFinite(checkedAt))
|
|
23
|
+
return null;
|
|
24
|
+
if (latest !== null && typeof latest !== "string")
|
|
25
|
+
return null;
|
|
26
|
+
return { checkedAt, latest };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
write(cache) {
|
|
33
|
+
writeFileAtomic(this.#file, `${JSON.stringify(cache, null, 2)}\n`);
|
|
34
|
+
}
|
|
35
|
+
}
|