portless-studio 0.1.2 → 0.1.4
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 +1 -1
- package/dist/cli/cli-args.js +41 -12
- package/dist/cli/env-command.js +34 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/mcp/studio-tools.js +86 -2
- package/dist/cli/studio-items.js +2 -0
- package/dist/core/api/contract.js +6 -1
- package/dist/core/config/studio-config.js +85 -0
- package/dist/core/ports/helper-ports.js +86 -0
- package/dist/core/routes/env-lines.js +72 -0
- package/dist/core/terminals/terminal-history.js +53 -0
- package/dist/core/terminals/terminal.js +0 -4
- package/dist/server/application/port-discovery.js +37 -4
- package/dist/server/application/studio-runtime.js +37 -17
- package/dist/server/application/terminals.js +221 -49
- package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
- package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
- package/dist/server/infrastructure/system/linux-ports.js +31 -1
- package/dist/server/infrastructure/system/macos-ports.js +31 -2
- package/dist/server/infrastructure/system/system-port-scanner.js +71 -8
- package/dist/server/infrastructure/system/windows-ports.js +9 -1
- package/dist/server/interface/http/http-server.js +4 -0
- package/dist/server/interface/http/static-files.js +6 -2
- package/dist/server/main.js +10 -0
- package/dist/web/assets/index-CZVCj1cX.css +1 -0
- package/dist/web/assets/index-DODZ4DTe.js +23 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BDEwEBPS.js +0 -15
- package/dist/web/assets/index-C9wkL1J8.css +0 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Studio keeps about terminals between runs, in `terminals.json` next to the config: the
|
|
3
|
+
* terminals that were running when it stopped, so they can run again, and the last commands run
|
|
4
|
+
* in each folder, offered when a new terminal opens there. See docs/terminals.md.
|
|
5
|
+
*/
|
|
6
|
+
export const EMPTY_TERMINAL_HISTORY = { running: [], recentCommands: {} };
|
|
7
|
+
/** Commands remembered per folder. */
|
|
8
|
+
export const MAX_RECENT_COMMANDS = 20;
|
|
9
|
+
/** Reads `terminals.json`; anything it cannot read is left out rather than failing. */
|
|
10
|
+
export function parseTerminalHistory(input) {
|
|
11
|
+
if (typeof input !== "object" || input === null)
|
|
12
|
+
return { running: [], recentCommands: {} };
|
|
13
|
+
const raw = input;
|
|
14
|
+
const running = Array.isArray(raw.running) ? raw.running.filter(isSavedTerminal) : [];
|
|
15
|
+
const recentCommands = {};
|
|
16
|
+
if (typeof raw.recentCommands === "object" && raw.recentCommands !== null) {
|
|
17
|
+
for (const [folder, list] of Object.entries(raw.recentCommands)) {
|
|
18
|
+
if (folder === "__proto__" || !Array.isArray(list))
|
|
19
|
+
continue;
|
|
20
|
+
const commands = list.filter((item) => typeof item === "string" && item.trim() !== "");
|
|
21
|
+
if (commands.length > 0)
|
|
22
|
+
recentCommands[folder] = commands.slice(0, MAX_RECENT_COMMANDS);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { running, recentCommands };
|
|
26
|
+
}
|
|
27
|
+
/** Puts `command` first in the folder's list, once, and keeps the newest `MAX_RECENT_COMMANDS`. */
|
|
28
|
+
export function rememberCommand(recent, cwd, command) {
|
|
29
|
+
const wanted = command.trim();
|
|
30
|
+
const copy = {};
|
|
31
|
+
for (const [folder, list] of Object.entries(recent))
|
|
32
|
+
copy[folder] = [...list];
|
|
33
|
+
if (wanted === "")
|
|
34
|
+
return copy;
|
|
35
|
+
const list = (copy[cwd] ?? []).filter((item) => item !== wanted);
|
|
36
|
+
copy[cwd] = [wanted, ...list].slice(0, MAX_RECENT_COMMANDS);
|
|
37
|
+
return copy;
|
|
38
|
+
}
|
|
39
|
+
function isSavedTerminal(value) {
|
|
40
|
+
if (typeof value !== "object" || value === null)
|
|
41
|
+
return false;
|
|
42
|
+
const item = value;
|
|
43
|
+
const optionalText = (field) => field === null || typeof field === "string";
|
|
44
|
+
return (typeof item.command === "string" &&
|
|
45
|
+
typeof item.cwd === "string" &&
|
|
46
|
+
item.cwd !== "" &&
|
|
47
|
+
typeof item.title === "string" &&
|
|
48
|
+
typeof item.startedAt === "number" &&
|
|
49
|
+
(item.startedBy === "program" || item.startedBy === "person") &&
|
|
50
|
+
optionalText(item.project) &&
|
|
51
|
+
optionalText(item.worktree) &&
|
|
52
|
+
optionalText(item.branch));
|
|
53
|
+
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A terminal Studio runs: one command in one project folder, with its output kept so the page,
|
|
3
|
-
* the CLI and agents can read it. See docs/terminals.md.
|
|
4
|
-
*/
|
|
5
1
|
// Control sequences: CSI (colors, cursor), OSC (window title) and two-character escapes.
|
|
6
2
|
const ESCAPES = new RegExp([
|
|
7
3
|
String.raw `\u001b\[[0-?]*[ -/]*[@-~]`,
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { projectOf, suggestName, } from "../../core/ports/detected-port.js";
|
|
2
2
|
import { containerProjectInfo } from "../../core/ports/docker-container.js";
|
|
3
|
+
import { arrangeDetected, } from "../../core/ports/helper-ports.js";
|
|
4
|
+
/** Only the main Detected list of `discoverPortGroups`. */
|
|
5
|
+
export async function discoverPorts(scanner, projects, context) {
|
|
6
|
+
return (await discoverPortGroups(scanner, projects, context)).detected;
|
|
7
|
+
}
|
|
3
8
|
/**
|
|
4
9
|
* Listening ports a person would want to name: they answer HTTP, have no portless route, and are
|
|
5
|
-
* not Studio, the proxy or something the config ignores.
|
|
10
|
+
* not Studio, the proxy or something the config ignores. Debuggers, ephemeral ports and ports of a
|
|
11
|
+
* server that already has a name go to `otherPorts` (see `arrangeDetected`). Sorted by port.
|
|
6
12
|
*/
|
|
7
|
-
export async function
|
|
13
|
+
export async function discoverPortGroups(scanner, projects, context) {
|
|
8
14
|
const skippedPorts = new Set([
|
|
9
15
|
...context.routes.map((route) => route.port),
|
|
10
16
|
...context.excludedPorts,
|
|
@@ -14,7 +20,8 @@ export async function discoverPorts(scanner, projects, context) {
|
|
|
14
20
|
const published = containerPorts(context.containers ?? [], skippedPorts, ignored);
|
|
15
21
|
// A port whose process we cannot read belongs to another user or the system (printing, DNS),
|
|
16
22
|
// never to a dev server this person started. A container's port is shown as the container.
|
|
17
|
-
const
|
|
23
|
+
const scanned = await scanner.scan();
|
|
24
|
+
const candidates = scanned.filter((port) => port.pid !== null &&
|
|
18
25
|
!skippedPorts.has(port.port) &&
|
|
19
26
|
!published.has(port.port) &&
|
|
20
27
|
!isIgnoredProcess(port.processName, ignored));
|
|
@@ -25,7 +32,7 @@ export async function discoverPorts(scanner, projects, context) {
|
|
|
25
32
|
Promise.all(fromContainers.map(([port]) => scanner.answersHttp(port))),
|
|
26
33
|
]);
|
|
27
34
|
const template = context.config.nameTemplate;
|
|
28
|
-
|
|
35
|
+
const found = [
|
|
29
36
|
...candidates
|
|
30
37
|
.filter((port, index) => answers[index] && isDevServer(port, projects))
|
|
31
38
|
.map((port) => toDetected(port, projects, template)),
|
|
@@ -33,6 +40,31 @@ export async function discoverPorts(scanner, projects, context) {
|
|
|
33
40
|
.filter((_, index) => containerAnswers[index])
|
|
34
41
|
.map(([port, container]) => containerDetected(port, container, template)),
|
|
35
42
|
].sort((a, b) => a.port - b.port);
|
|
43
|
+
return arrangeDetected({
|
|
44
|
+
detected: found,
|
|
45
|
+
debuggers: await debuggerProbes(scanner, found),
|
|
46
|
+
ephemeralRange: context.ephemeralRange ?? null,
|
|
47
|
+
showEphemeral: context.config.showEphemeralPorts,
|
|
48
|
+
routes: listeningRoutes(context.routes, scanned),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const UNKNOWN = "unknown";
|
|
52
|
+
async function debuggerProbes(scanner, found) {
|
|
53
|
+
const probe = scanner.probeDebugger?.bind(scanner);
|
|
54
|
+
if (!probe)
|
|
55
|
+
return new Map();
|
|
56
|
+
const answers = await Promise.all(found.map((item) => probe(item.port).catch(() => UNKNOWN)));
|
|
57
|
+
return new Map(found.map((item, index) => [item.port, answers[index] ?? UNKNOWN]));
|
|
58
|
+
}
|
|
59
|
+
/** Routes whose port a readable process listens on now; a closed route claims no siblings. */
|
|
60
|
+
function listeningRoutes(routes, scanned) {
|
|
61
|
+
const byPort = new Map(scanned.map((port) => [port.port, port]));
|
|
62
|
+
return routes.flatMap((route) => {
|
|
63
|
+
const listener = byPort.get(route.port);
|
|
64
|
+
if (!listener || listener.pid === null)
|
|
65
|
+
return [];
|
|
66
|
+
return [{ name: route.name, port: route.port, pid: listener.pid, cwd: listener.cwd }];
|
|
67
|
+
});
|
|
36
68
|
}
|
|
37
69
|
/** Published port -> container, without the ports the filters skip. */
|
|
38
70
|
function containerPorts(containers, skippedPorts, ignored) {
|
|
@@ -111,5 +143,6 @@ function toDetected(port, projects, nameTemplate) {
|
|
|
111
143
|
cwd: port.cwd,
|
|
112
144
|
suggestedName: info === null ? null : suggestName(info, nameTemplate, port.port),
|
|
113
145
|
...(project === null ? {} : { project }),
|
|
146
|
+
...(port.startedAt === undefined ? {} : { startedAt: port.startedAt }),
|
|
114
147
|
};
|
|
115
148
|
}
|
|
@@ -8,7 +8,7 @@ import { appError } from "./app-error.js";
|
|
|
8
8
|
import { memoryAutoNamedStore, } from "./auto-named-store.js";
|
|
9
9
|
import { AutoNamer } from "./auto-namer.js";
|
|
10
10
|
import { IconResolver } from "./icon-resolver.js";
|
|
11
|
-
import {
|
|
11
|
+
import { discoverPortGroups } from "./port-discovery.js";
|
|
12
12
|
import { EventLog, eventsBetween } from "./studio-events.js";
|
|
13
13
|
import { SYSTEM_NOTIFICATION_KINDS } from "./system-notifier.js";
|
|
14
14
|
/** Keeps the projects in memory only; for tests and for `init`, which runs once. */
|
|
@@ -43,6 +43,7 @@ export class StudioRuntime {
|
|
|
43
43
|
#icons;
|
|
44
44
|
#routeProjectStore;
|
|
45
45
|
#containers;
|
|
46
|
+
#ephemeralRange;
|
|
46
47
|
#shortUrls;
|
|
47
48
|
#ready = Promise.resolve();
|
|
48
49
|
/** Last project seen per route name; null until read from the store. */
|
|
@@ -85,6 +86,7 @@ export class StudioRuntime {
|
|
|
85
86
|
this.#notifier = options.notifier ?? null;
|
|
86
87
|
this.#routeProjectStore = options.routeProjectStore ?? memoryRouteProjectStore();
|
|
87
88
|
this.#containers = options.containers ?? null;
|
|
89
|
+
this.#ephemeralRange = options.ephemeralRange ?? null;
|
|
88
90
|
this.#shortUrls = options.shortUrls ?? null;
|
|
89
91
|
this.#shortUrls?.onChange(() => this.#notify());
|
|
90
92
|
const noIcons = {
|
|
@@ -147,14 +149,15 @@ export class StudioRuntime {
|
|
|
147
149
|
async readSnapshot(options = {}) {
|
|
148
150
|
const state = await this.#registry.readSnapshot();
|
|
149
151
|
const config = await this.#currentConfig();
|
|
150
|
-
const
|
|
151
|
-
this.#lastDetected = JSON.stringify(
|
|
152
|
+
const found = await this.#detect(state, config, false);
|
|
153
|
+
this.#lastDetected = JSON.stringify(found);
|
|
152
154
|
const routes = await this.#detailedRoutes(state.routes);
|
|
153
155
|
const snapshot = {
|
|
154
156
|
proxy: state.proxy,
|
|
155
157
|
portless: state.portless,
|
|
156
158
|
routes,
|
|
157
|
-
detected,
|
|
159
|
+
detected: found.detected,
|
|
160
|
+
otherPorts: found.otherPorts,
|
|
158
161
|
autoName: config.autoName,
|
|
159
162
|
readAt: state.readAt,
|
|
160
163
|
};
|
|
@@ -183,7 +186,7 @@ export class StudioRuntime {
|
|
|
183
186
|
/** One fresh scan with the current config, filtered the same way as the snapshot. */
|
|
184
187
|
async detectPorts() {
|
|
185
188
|
const state = await this.#registry.readSnapshot();
|
|
186
|
-
return this.#detect(state, await this.#currentConfig(), true);
|
|
189
|
+
return (await this.#detect(state, await this.#currentConfig(), true)).detected;
|
|
187
190
|
}
|
|
188
191
|
/** The config in effect: the file as last read, or the defaults while it is broken. */
|
|
189
192
|
settings() {
|
|
@@ -195,11 +198,12 @@ export class StudioRuntime {
|
|
|
195
198
|
*/
|
|
196
199
|
async stoppablePids() {
|
|
197
200
|
const state = await this.#registry.readSnapshot();
|
|
198
|
-
const
|
|
201
|
+
const found = await this.#detect(state, await this.#currentConfig(), true);
|
|
199
202
|
const pids = new Map();
|
|
200
|
-
for (const item of detected)
|
|
203
|
+
for (const item of [...found.detected, ...found.otherPorts]) {
|
|
201
204
|
if (item.pid !== null)
|
|
202
205
|
pids.set(item.pid, item.port);
|
|
206
|
+
}
|
|
203
207
|
const pidByPort = this.#pidByPort();
|
|
204
208
|
for (const route of state.routes) {
|
|
205
209
|
const pid = pidByPort.get(route.port);
|
|
@@ -215,7 +219,8 @@ export class StudioRuntime {
|
|
|
215
219
|
*/
|
|
216
220
|
async openableFolders() {
|
|
217
221
|
const state = await this.#registry.readSnapshot();
|
|
218
|
-
const
|
|
222
|
+
const found = await this.#detect(state, await this.#currentConfig(), false);
|
|
223
|
+
const detected = [...found.detected, ...found.otherPorts];
|
|
219
224
|
const routes = await this.#detailedRoutes(state.routes);
|
|
220
225
|
const folders = new Set();
|
|
221
226
|
for (const item of [...routes, ...detected]) {
|
|
@@ -278,9 +283,11 @@ export class StudioRuntime {
|
|
|
278
283
|
}
|
|
279
284
|
async #detect(state, config, fresh) {
|
|
280
285
|
const scan = await this.#scanPorts(fresh ? 0 : config.scanIntervalMs);
|
|
286
|
+
const probeDebugger = this.#scanner.probeDebugger?.bind(this.#scanner);
|
|
281
287
|
const cached = {
|
|
282
288
|
scan: async () => scan.ports,
|
|
283
289
|
answersHttp: (port) => this.#scanner.answersHttp(port),
|
|
290
|
+
...(probeDebugger ? { probeDebugger } : {}),
|
|
284
291
|
};
|
|
285
292
|
const forwarderPort = this.#shortUrls?.publicPort() ?? null;
|
|
286
293
|
const excludedPorts = [
|
|
@@ -289,18 +296,23 @@ export class StudioRuntime {
|
|
|
289
296
|
state.proxy.port,
|
|
290
297
|
...scan.proxyPorts,
|
|
291
298
|
].filter((port) => port !== null);
|
|
292
|
-
const found = await
|
|
299
|
+
const found = await discoverPortGroups(cached, this.#projects, {
|
|
293
300
|
config,
|
|
294
301
|
routes: state.routes,
|
|
295
302
|
excludedPorts,
|
|
296
303
|
containers: scan.containers,
|
|
304
|
+
ephemeralRange: await this.#readEphemeralRange(),
|
|
297
305
|
});
|
|
298
|
-
|
|
306
|
+
const withProject = (item) => {
|
|
299
307
|
if (item.cwd === null || !item.project)
|
|
300
308
|
return item;
|
|
301
309
|
const project = itemProjectAt(this.#projects.read(item.cwd), item.cwd);
|
|
302
310
|
return project === null ? item : { ...item, project };
|
|
303
|
-
}
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
detected: found.detected.map(withProject),
|
|
314
|
+
otherPorts: found.otherPorts.map(withProject),
|
|
315
|
+
};
|
|
304
316
|
}
|
|
305
317
|
/** Reuses a scan younger than `maxAgeMs`, and shares one scan between concurrent callers. */
|
|
306
318
|
#scanPorts(maxAgeMs) {
|
|
@@ -314,6 +326,12 @@ export class StudioRuntime {
|
|
|
314
326
|
});
|
|
315
327
|
return this.#scanning;
|
|
316
328
|
}
|
|
329
|
+
/** A range that cannot be read turns the ephemeral rule off rather than failing the scan. */
|
|
330
|
+
async #readEphemeralRange() {
|
|
331
|
+
if (!this.#ephemeralRange)
|
|
332
|
+
return null;
|
|
333
|
+
return this.#ephemeralRange().catch(() => null);
|
|
334
|
+
}
|
|
317
335
|
/** Docker is optional: any failure to read it means no containers, never a failed scan. */
|
|
318
336
|
async #readContainers() {
|
|
319
337
|
if (!this.#containers)
|
|
@@ -340,19 +358,20 @@ export class StudioRuntime {
|
|
|
340
358
|
try {
|
|
341
359
|
let state = await this.#registry.readSnapshot();
|
|
342
360
|
const config = await this.#currentConfig();
|
|
343
|
-
|
|
344
|
-
// Each step reads the routes again after the previous one changed them.
|
|
345
|
-
|
|
361
|
+
const first = await this.#detect(state, config, true);
|
|
362
|
+
// Each step reads the routes again after the previous one changed them. Other ports are
|
|
363
|
+
// helpers of a named server, debuggers or temporary ports: auto mode never names them.
|
|
364
|
+
if (config.autoName && (await this.#namer.name(first.detected)) > 0) {
|
|
346
365
|
state = await this.#registry.readSnapshot();
|
|
347
366
|
}
|
|
348
367
|
if (await this.#removeAbandonedAutoNames(state, config)) {
|
|
349
368
|
state = await this.#registry.readSnapshot();
|
|
350
369
|
}
|
|
351
|
-
|
|
370
|
+
const found = await this.#detect(state, config, false);
|
|
352
371
|
const healthChanged = await this.#probeRoutes(state.routes);
|
|
353
372
|
const routes = await this.#detailedRoutes(state.routes);
|
|
354
|
-
this.#observe({ ...state, routes, detected }, config);
|
|
355
|
-
if (healthChanged || JSON.stringify(
|
|
373
|
+
this.#observe({ ...state, routes, detected: found.detected }, config);
|
|
374
|
+
if (healthChanged || JSON.stringify(found) !== this.#lastDetected)
|
|
356
375
|
this.#notify();
|
|
357
376
|
}
|
|
358
377
|
catch (error) {
|
|
@@ -384,6 +403,7 @@ export class StudioRuntime {
|
|
|
384
403
|
return {
|
|
385
404
|
...route,
|
|
386
405
|
...(listener?.pid == null ? {} : { listenerPid: listener.pid }),
|
|
406
|
+
...(listener?.startedAt === undefined ? {} : { startedAt: listener.startedAt }),
|
|
387
407
|
...(project === null ? {} : { project }),
|
|
388
408
|
...(health === undefined ? {} : { health }),
|
|
389
409
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { err, ok } from "../../core/result.js";
|
|
2
2
|
import { OutputBuffer } from "../../core/terminals/output-buffer.js";
|
|
3
|
+
import { rememberCommand, } from "../../core/terminals/terminal-history.js";
|
|
3
4
|
import { appError } from "./app-error.js";
|
|
4
5
|
const DEFAULT_MAX_OUTPUT = 2 * 1024 * 1024;
|
|
5
6
|
const DEFAULT_GRACE_MS = 3_000;
|
|
@@ -9,6 +10,13 @@ const DEFAULT_COLS = 120;
|
|
|
9
10
|
const DEFAULT_ROWS = 30;
|
|
10
11
|
const MAX_COMMAND_CHARS = 8 * 1024;
|
|
11
12
|
const MAX_TITLE_CHARS = 60;
|
|
13
|
+
const DEFAULT_PERSIST_DELAY_MS = 300;
|
|
14
|
+
/**
|
|
15
|
+
* A command that ended this shortly before Studio stopped counts as running then: a service
|
|
16
|
+
* manager stops Studio's commands together with Studio, and their exits can arrive first.
|
|
17
|
+
*/
|
|
18
|
+
const RECENT_EXIT_MS = 2_000;
|
|
19
|
+
const RESTART_NOTE = "\r\n[Studio] Stopped by a Studio restart. Run it again to start it.\r\n";
|
|
12
20
|
/**
|
|
13
21
|
* The terminals Studio runs. Each keeps its last `maxOutputChars` of output, numbered so a client
|
|
14
22
|
* can resume where it stopped. From a page, commands run only in folders Studio shows; a local
|
|
@@ -25,6 +33,11 @@ export class TerminalManager {
|
|
|
25
33
|
#namingTimeoutMs;
|
|
26
34
|
#processTree;
|
|
27
35
|
#homeDir;
|
|
36
|
+
#history;
|
|
37
|
+
#persistDelayMs;
|
|
38
|
+
#recent = null;
|
|
39
|
+
#persistTimer;
|
|
40
|
+
#closing = false;
|
|
28
41
|
#entries = new Map();
|
|
29
42
|
#watchers = new Set();
|
|
30
43
|
/** Folders a local program ran a terminal in. */
|
|
@@ -45,6 +58,8 @@ export class TerminalManager {
|
|
|
45
58
|
this.#namingTimeoutMs = options.namingTimeoutMs ?? DEFAULT_NAMING_TIMEOUT_MS;
|
|
46
59
|
this.#processTree = options.processTree;
|
|
47
60
|
this.#homeDir = options.homeDir;
|
|
61
|
+
this.#history = options.history;
|
|
62
|
+
this.#persistDelayMs = options.persistDelayMs ?? DEFAULT_PERSIST_DELAY_MS;
|
|
48
63
|
}
|
|
49
64
|
get mode() {
|
|
50
65
|
return this.#spawner.mode;
|
|
@@ -73,6 +88,7 @@ export class TerminalManager {
|
|
|
73
88
|
const wanted = title.trim().slice(0, MAX_TITLE_CHARS);
|
|
74
89
|
entry.info = { ...entry.info, title: wanted || entry.defaultTitle };
|
|
75
90
|
this.#publishStatus(entry);
|
|
91
|
+
this.#schedulePersist();
|
|
76
92
|
return ok({ ...entry.info });
|
|
77
93
|
}
|
|
78
94
|
async create(request, origin) {
|
|
@@ -87,69 +103,97 @@ export class TerminalManager {
|
|
|
87
103
|
return cwd;
|
|
88
104
|
if (origin.trusted)
|
|
89
105
|
this.#trustedFolders.add(cwd.value);
|
|
90
|
-
const id = `t${this.#nextId++}`;
|
|
91
|
-
const cols = readSize(request.cols, DEFAULT_COLS);
|
|
92
|
-
const rows = readSize(request.rows, DEFAULT_ROWS);
|
|
93
106
|
const naming = readNaming(request.name);
|
|
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
107
|
const project = await this.#projectOf(cwd.value);
|
|
97
|
-
const entry = {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
exitCode: null,
|
|
110
|
-
startedAt: this.#now(),
|
|
111
|
-
endedAt: null,
|
|
112
|
-
pid: null,
|
|
113
|
-
mode: this.#spawner.mode,
|
|
114
|
-
cols,
|
|
115
|
-
rows,
|
|
116
|
-
nextSeq: 0,
|
|
117
|
-
naming,
|
|
118
|
-
},
|
|
119
|
-
request: {
|
|
120
|
-
command: command.value,
|
|
121
|
-
cwd: cwd.value,
|
|
122
|
-
cols,
|
|
123
|
-
rows,
|
|
124
|
-
env: {
|
|
125
|
-
TERM: "xterm-256color",
|
|
126
|
-
COLUMNS: String(cols),
|
|
127
|
-
LINES: String(rows),
|
|
128
|
-
PORTLESS_STUDIO_TERMINAL: id,
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
buffer: new OutputBuffer(this.#maxOutput),
|
|
132
|
-
process: null,
|
|
133
|
-
stopTimers: [],
|
|
134
|
-
watchers: new Set(),
|
|
135
|
-
baseline: null,
|
|
136
|
-
namingTimer: undefined,
|
|
137
|
-
};
|
|
108
|
+
const entry = this.#newEntry({
|
|
109
|
+
id: `t${this.#nextId++}`,
|
|
110
|
+
command: command.value,
|
|
111
|
+
cwd: cwd.value,
|
|
112
|
+
title: request.title,
|
|
113
|
+
cols: readSize(request.cols, DEFAULT_COLS),
|
|
114
|
+
rows: readSize(request.rows, DEFAULT_ROWS),
|
|
115
|
+
naming,
|
|
116
|
+
project,
|
|
117
|
+
startedBy: origin.trusted ? "program" : "person",
|
|
118
|
+
startedAt: this.#now(),
|
|
119
|
+
});
|
|
138
120
|
if (naming)
|
|
139
121
|
entry.baseline = await this.#portsInUse();
|
|
140
122
|
const started = await this.#spawn(entry);
|
|
141
123
|
if (!started.ok)
|
|
142
124
|
return started;
|
|
143
|
-
this.#entries.set(id, entry);
|
|
125
|
+
this.#entries.set(entry.info.id, entry);
|
|
126
|
+
if (command.value) {
|
|
127
|
+
this.#recent = rememberCommand(this.#recentCommands(), cwd.value, command.value);
|
|
128
|
+
}
|
|
144
129
|
if (naming)
|
|
145
130
|
this.#startNaming(entry);
|
|
146
131
|
else
|
|
147
132
|
this.#updateWorkspaceWatch();
|
|
148
133
|
this.#changed();
|
|
134
|
+
this.#schedulePersist();
|
|
149
135
|
if (this.#watchers.size > 0)
|
|
150
136
|
void this.#refreshPorts();
|
|
151
137
|
return ok({ ...entry.info });
|
|
152
138
|
}
|
|
139
|
+
/** The last commands run in each folder, newest first. */
|
|
140
|
+
recentCommands() {
|
|
141
|
+
return Object.fromEntries(Object.entries(this.#recentCommands()).map(([folder, list]) => [folder, [...list]]));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Brings back the terminals that were running when Studio last stopped: listed as stopped by a
|
|
145
|
+
* restart (`ask`), started again unless they are a plain shell (`always`), or forgotten
|
|
146
|
+
* (`never`). Folders that no longer exist are skipped. Call it once, on start.
|
|
147
|
+
*/
|
|
148
|
+
async restore(mode) {
|
|
149
|
+
if (this.#closing)
|
|
150
|
+
return;
|
|
151
|
+
const saved = this.#history?.read().running ?? [];
|
|
152
|
+
if (mode === "never" || saved.length === 0) {
|
|
153
|
+
if (saved.length > 0)
|
|
154
|
+
this.#persistNow();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const restored = [];
|
|
158
|
+
for (const item of saved) {
|
|
159
|
+
if (this.#entries.size >= this.#maxTerminals)
|
|
160
|
+
break;
|
|
161
|
+
const cwd = this.#folders.directory(item.cwd);
|
|
162
|
+
if (cwd === null)
|
|
163
|
+
continue;
|
|
164
|
+
if (item.startedBy === "program")
|
|
165
|
+
this.#trustedFolders.add(cwd);
|
|
166
|
+
const entry = this.#newEntry({
|
|
167
|
+
id: `t${this.#nextId++}`,
|
|
168
|
+
command: item.command || null,
|
|
169
|
+
cwd,
|
|
170
|
+
title: item.title,
|
|
171
|
+
cols: DEFAULT_COLS,
|
|
172
|
+
rows: DEFAULT_ROWS,
|
|
173
|
+
naming: null,
|
|
174
|
+
project: { name: item.project ?? "", worktree: item.worktree, branch: item.branch },
|
|
175
|
+
startedBy: item.startedBy,
|
|
176
|
+
startedAt: item.startedAt,
|
|
177
|
+
});
|
|
178
|
+
if (item.project === null) {
|
|
179
|
+
entry.info = { ...entry.info, project: null, worktree: null, branch: null };
|
|
180
|
+
}
|
|
181
|
+
entry.info = { ...entry.info, status: "exited", stoppedByRestart: true };
|
|
182
|
+
this.#append(entry, RESTART_NOTE);
|
|
183
|
+
this.#entries.set(entry.info.id, entry);
|
|
184
|
+
restored.push(entry);
|
|
185
|
+
}
|
|
186
|
+
this.#changed();
|
|
187
|
+
this.#persistNow();
|
|
188
|
+
if (mode !== "always")
|
|
189
|
+
return;
|
|
190
|
+
for (const entry of restored) {
|
|
191
|
+
if (this.#closing)
|
|
192
|
+
return;
|
|
193
|
+
if (entry.info.command !== "")
|
|
194
|
+
await this.restart(entry.info.id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
153
197
|
input(id, text) {
|
|
154
198
|
const entry = this.#entries.get(id);
|
|
155
199
|
if (!entry)
|
|
@@ -166,6 +210,7 @@ export class TerminalManager {
|
|
|
166
210
|
return notFound(id);
|
|
167
211
|
if (!entry.process || entry.info.status !== "running")
|
|
168
212
|
return ok({ ...entry.info });
|
|
213
|
+
entry.stoppedOnPurpose = true;
|
|
169
214
|
this.#escalate(entry, entry.process);
|
|
170
215
|
return ok({ ...entry.info });
|
|
171
216
|
}
|
|
@@ -177,16 +222,36 @@ export class TerminalManager {
|
|
|
177
222
|
const current = entry.process;
|
|
178
223
|
if (current && entry.info.status === "running") {
|
|
179
224
|
const ended = new Promise((resolve) => current.onExit(() => resolve()));
|
|
225
|
+
entry.stoppedOnPurpose = true;
|
|
180
226
|
this.#escalate(entry, current);
|
|
181
227
|
await ended;
|
|
182
228
|
}
|
|
183
229
|
this.#append(entry, `\r\n[Studio] restarted: ${entry.info.command || "shell"}\r\n`);
|
|
230
|
+
if (entry.info.project === null) {
|
|
231
|
+
const project = await this.#projectOf(entry.info.cwd);
|
|
232
|
+
if (project) {
|
|
233
|
+
entry.info = {
|
|
234
|
+
...entry.info,
|
|
235
|
+
project: project.name,
|
|
236
|
+
worktree: project.worktree ?? null,
|
|
237
|
+
branch: project.branch ?? null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
184
241
|
const started = await this.#spawn(entry);
|
|
185
242
|
if (!started.ok)
|
|
186
243
|
return started;
|
|
187
|
-
entry.
|
|
244
|
+
entry.stoppedOnPurpose = false;
|
|
245
|
+
entry.info = {
|
|
246
|
+
...entry.info,
|
|
247
|
+
status: "running",
|
|
248
|
+
exitCode: null,
|
|
249
|
+
endedAt: null,
|
|
250
|
+
stoppedByRestart: false,
|
|
251
|
+
};
|
|
188
252
|
entry.info.startedAt = this.#now();
|
|
189
253
|
this.#publishStatus(entry);
|
|
254
|
+
this.#schedulePersist();
|
|
190
255
|
this.#updateWorkspaceWatch();
|
|
191
256
|
if (this.#watchers.size > 0)
|
|
192
257
|
void this.#refreshPorts();
|
|
@@ -205,6 +270,7 @@ export class TerminalManager {
|
|
|
205
270
|
entry.watchers.clear();
|
|
206
271
|
this.#updateWorkspaceWatch();
|
|
207
272
|
this.#changed();
|
|
273
|
+
this.#schedulePersist();
|
|
208
274
|
return ok(undefined);
|
|
209
275
|
}
|
|
210
276
|
read(id, from, maxChars) {
|
|
@@ -225,8 +291,16 @@ export class TerminalManager {
|
|
|
225
291
|
entry.watchers.add(watcher);
|
|
226
292
|
return ok(() => entry.watchers.delete(watcher));
|
|
227
293
|
}
|
|
228
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* Studio is stopping: the terminals still running are written down for the next start, then
|
|
296
|
+
* every command gets SIGTERM and SIGKILL right away.
|
|
297
|
+
*/
|
|
229
298
|
closeAll() {
|
|
299
|
+
if (!this.#closing) {
|
|
300
|
+
this.#persistNow(true);
|
|
301
|
+
this.#closing = true;
|
|
302
|
+
clearTimeout(this.#persistTimer);
|
|
303
|
+
}
|
|
230
304
|
for (const entry of this.#entries.values()) {
|
|
231
305
|
this.#clearTimers(entry);
|
|
232
306
|
if (entry.process && entry.info.status === "running") {
|
|
@@ -267,10 +341,108 @@ export class TerminalManager {
|
|
|
267
341
|
}
|
|
268
342
|
entry.info.ports = [];
|
|
269
343
|
this.#publishStatus(entry);
|
|
344
|
+
this.#schedulePersist();
|
|
270
345
|
this.#updateWorkspaceWatch();
|
|
271
346
|
});
|
|
272
347
|
return ok(undefined);
|
|
273
348
|
}
|
|
349
|
+
#newEntry(fields) {
|
|
350
|
+
const defaultTitle = (fields.command || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
|
|
351
|
+
const title = fields.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
|
|
352
|
+
const { id, cols, rows } = fields;
|
|
353
|
+
return {
|
|
354
|
+
defaultTitle,
|
|
355
|
+
info: {
|
|
356
|
+
id,
|
|
357
|
+
title,
|
|
358
|
+
cwd: fields.cwd,
|
|
359
|
+
command: fields.command ?? "",
|
|
360
|
+
project: fields.project?.name ?? null,
|
|
361
|
+
worktree: fields.project?.worktree ?? null,
|
|
362
|
+
branch: fields.project?.branch ?? null,
|
|
363
|
+
ports: [],
|
|
364
|
+
status: "running",
|
|
365
|
+
exitCode: null,
|
|
366
|
+
startedAt: fields.startedAt,
|
|
367
|
+
endedAt: null,
|
|
368
|
+
pid: null,
|
|
369
|
+
mode: this.#spawner.mode,
|
|
370
|
+
cols,
|
|
371
|
+
rows,
|
|
372
|
+
nextSeq: 0,
|
|
373
|
+
naming: fields.naming,
|
|
374
|
+
startedBy: fields.startedBy,
|
|
375
|
+
stoppedByRestart: false,
|
|
376
|
+
},
|
|
377
|
+
request: {
|
|
378
|
+
command: fields.command,
|
|
379
|
+
cwd: fields.cwd,
|
|
380
|
+
cols,
|
|
381
|
+
rows,
|
|
382
|
+
env: {
|
|
383
|
+
TERM: "xterm-256color",
|
|
384
|
+
COLUMNS: String(cols),
|
|
385
|
+
LINES: String(rows),
|
|
386
|
+
PORTLESS_STUDIO_TERMINAL: id,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
buffer: new OutputBuffer(this.#maxOutput),
|
|
390
|
+
process: null,
|
|
391
|
+
stopTimers: [],
|
|
392
|
+
watchers: new Set(),
|
|
393
|
+
baseline: null,
|
|
394
|
+
namingTimer: undefined,
|
|
395
|
+
stoppedOnPurpose: false,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
#recentCommands() {
|
|
399
|
+
this.#recent ??= this.#history?.read().recentCommands ?? {};
|
|
400
|
+
return this.#recent;
|
|
401
|
+
}
|
|
402
|
+
#schedulePersist() {
|
|
403
|
+
if (!this.#history || this.#closing || this.#persistTimer)
|
|
404
|
+
return;
|
|
405
|
+
this.#persistTimer = setTimeout(() => {
|
|
406
|
+
this.#persistTimer = undefined;
|
|
407
|
+
this.#persistNow();
|
|
408
|
+
}, this.#persistDelayMs);
|
|
409
|
+
this.#persistTimer.unref?.();
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Writes the terminals that run and the recent commands. When Studio stops, commands that just
|
|
413
|
+
* ended without anyone asking count as running too.
|
|
414
|
+
*/
|
|
415
|
+
#persistNow(stopping = false) {
|
|
416
|
+
const history = this.#history;
|
|
417
|
+
if (!history || this.#closing)
|
|
418
|
+
return;
|
|
419
|
+
clearTimeout(this.#persistTimer);
|
|
420
|
+
this.#persistTimer = undefined;
|
|
421
|
+
const now = this.#now();
|
|
422
|
+
const running = [...this.#entries.values()]
|
|
423
|
+
.filter((entry) => entry.info.status === "running" ||
|
|
424
|
+
entry.info.stoppedByRestart === true ||
|
|
425
|
+
(stopping &&
|
|
426
|
+
!entry.stoppedOnPurpose &&
|
|
427
|
+
entry.info.endedAt !== null &&
|
|
428
|
+
now - entry.info.endedAt <= RECENT_EXIT_MS))
|
|
429
|
+
.map(({ info }) => ({
|
|
430
|
+
command: info.command,
|
|
431
|
+
cwd: info.cwd,
|
|
432
|
+
title: info.title,
|
|
433
|
+
startedAt: info.startedAt,
|
|
434
|
+
startedBy: info.startedBy ?? "person",
|
|
435
|
+
project: info.project,
|
|
436
|
+
worktree: info.worktree,
|
|
437
|
+
branch: info.branch,
|
|
438
|
+
}));
|
|
439
|
+
try {
|
|
440
|
+
history.write({ running, recentCommands: this.#recentCommands() });
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// A full disk or a read-only config folder: terminals keep working, they just are not kept.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
274
446
|
#escalate(entry, child) {
|
|
275
447
|
this.#clearTimers(entry);
|
|
276
448
|
child.interrupt();
|