portless-studio 0.1.3 → 0.1.5
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/mcp/studio-tools.js +36 -2
- package/dist/cli/studio-items.js +2 -0
- package/dist/core/api/contract.js +6 -1
- package/dist/core/config/studio-config.js +83 -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 +1 -0
- package/dist/server/application/stop-process.js +44 -3
- package/dist/server/application/studio-runtime.js +10 -0
- package/dist/server/application/terminals.js +267 -53
- package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
- package/dist/server/infrastructure/system/dev-process-tree.js +70 -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/port-closed.js +15 -0
- package/dist/server/infrastructure/system/system-port-scanner.js +7 -4
- package/dist/server/infrastructure/system/system-process-control.js +44 -0
- package/dist/server/infrastructure/system/windows-ports.js +9 -1
- package/dist/server/interface/http/http-server.js +5 -1
- package/dist/server/interface/http/static-files.js +6 -2
- package/dist/server/main.js +14 -2
- package/dist/web/assets/index-DhggsQyU.js +23 -0
- package/dist/web/assets/index-Q29k6Jdf.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-9WPxv6pu.js +0 -17
- package/dist/web/assets/index-Bckc4cIr.css +0 -1
|
@@ -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)
|
|
@@ -164,29 +208,86 @@ export class TerminalManager {
|
|
|
164
208
|
const entry = this.#entries.get(id);
|
|
165
209
|
if (!entry)
|
|
166
210
|
return notFound(id);
|
|
211
|
+
entry.restartGeneration += 1;
|
|
167
212
|
if (!entry.process || entry.info.status !== "running")
|
|
168
213
|
return ok({ ...entry.info });
|
|
214
|
+
entry.stoppedOnPurpose = true;
|
|
169
215
|
this.#escalate(entry, entry.process);
|
|
170
216
|
return ok({ ...entry.info });
|
|
171
217
|
}
|
|
172
218
|
/** Runs the same command again; stops the current one first and waits for it to end. */
|
|
173
|
-
|
|
219
|
+
restart(id) {
|
|
174
220
|
const entry = this.#entries.get(id);
|
|
175
221
|
if (!entry)
|
|
176
|
-
return notFound(id);
|
|
222
|
+
return Promise.resolve(notFound(id));
|
|
223
|
+
if (this.#closing)
|
|
224
|
+
return Promise.resolve(err(appError("command_failed", "Studio is stopping.")));
|
|
225
|
+
if (entry.restartPromise)
|
|
226
|
+
return entry.restartPromise;
|
|
227
|
+
let resolve;
|
|
228
|
+
let reject;
|
|
229
|
+
const pending = new Promise((done, failed) => {
|
|
230
|
+
resolve = done;
|
|
231
|
+
reject = failed;
|
|
232
|
+
});
|
|
233
|
+
// Register before interrupting: an exit callback can synchronously request another restart.
|
|
234
|
+
entry.restartPromise = pending;
|
|
235
|
+
void this.#restartEntry(entry, entry.restartGeneration).then((result) => {
|
|
236
|
+
entry.restartPromise = null;
|
|
237
|
+
resolve(result);
|
|
238
|
+
}, (error) => {
|
|
239
|
+
entry.restartPromise = null;
|
|
240
|
+
reject(error);
|
|
241
|
+
});
|
|
242
|
+
return pending;
|
|
243
|
+
}
|
|
244
|
+
async #restartEntry(entry, generation) {
|
|
245
|
+
const stillWanted = () => !this.#closing &&
|
|
246
|
+
this.#entries.get(entry.info.id) === entry &&
|
|
247
|
+
entry.restartGeneration === generation;
|
|
248
|
+
const cancelled = () => err(appError("terminal_not_running", "The restart was cancelled."));
|
|
177
249
|
const current = entry.process;
|
|
178
250
|
if (current && entry.info.status === "running") {
|
|
179
251
|
const ended = new Promise((resolve) => current.onExit(() => resolve()));
|
|
252
|
+
entry.stoppedOnPurpose = true;
|
|
180
253
|
this.#escalate(entry, current);
|
|
181
254
|
await ended;
|
|
182
255
|
}
|
|
256
|
+
if (!stillWanted())
|
|
257
|
+
return cancelled();
|
|
183
258
|
this.#append(entry, `\r\n[Studio] restarted: ${entry.info.command || "shell"}\r\n`);
|
|
184
|
-
|
|
259
|
+
if (entry.info.project === null) {
|
|
260
|
+
const project = await this.#projectOf(entry.info.cwd);
|
|
261
|
+
if (project) {
|
|
262
|
+
entry.info = {
|
|
263
|
+
...entry.info,
|
|
264
|
+
project: project.name,
|
|
265
|
+
worktree: project.worktree ?? null,
|
|
266
|
+
branch: project.branch ?? null,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!stillWanted())
|
|
271
|
+
return cancelled();
|
|
272
|
+
const started = await this.#spawn(entry, stillWanted);
|
|
185
273
|
if (!started.ok)
|
|
186
274
|
return started;
|
|
187
|
-
|
|
275
|
+
if (!stillWanted()) {
|
|
276
|
+
entry.process?.kill();
|
|
277
|
+
entry.process = null;
|
|
278
|
+
return cancelled();
|
|
279
|
+
}
|
|
280
|
+
entry.stoppedOnPurpose = false;
|
|
281
|
+
entry.info = {
|
|
282
|
+
...entry.info,
|
|
283
|
+
status: "running",
|
|
284
|
+
exitCode: null,
|
|
285
|
+
endedAt: null,
|
|
286
|
+
stoppedByRestart: false,
|
|
287
|
+
};
|
|
188
288
|
entry.info.startedAt = this.#now();
|
|
189
289
|
this.#publishStatus(entry);
|
|
290
|
+
this.#schedulePersist();
|
|
190
291
|
this.#updateWorkspaceWatch();
|
|
191
292
|
if (this.#watchers.size > 0)
|
|
192
293
|
void this.#refreshPorts();
|
|
@@ -205,6 +306,7 @@ export class TerminalManager {
|
|
|
205
306
|
entry.watchers.clear();
|
|
206
307
|
this.#updateWorkspaceWatch();
|
|
207
308
|
this.#changed();
|
|
309
|
+
this.#schedulePersist();
|
|
208
310
|
return ok(undefined);
|
|
209
311
|
}
|
|
210
312
|
read(id, from, maxChars) {
|
|
@@ -225,8 +327,16 @@ export class TerminalManager {
|
|
|
225
327
|
entry.watchers.add(watcher);
|
|
226
328
|
return ok(() => entry.watchers.delete(watcher));
|
|
227
329
|
}
|
|
228
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* Studio is stopping: the terminals still running are written down for the next start, then
|
|
332
|
+
* every command gets SIGTERM and SIGKILL right away.
|
|
333
|
+
*/
|
|
229
334
|
closeAll() {
|
|
335
|
+
if (!this.#closing) {
|
|
336
|
+
this.#persistNow(true);
|
|
337
|
+
this.#closing = true;
|
|
338
|
+
clearTimeout(this.#persistTimer);
|
|
339
|
+
}
|
|
230
340
|
for (const entry of this.#entries.values()) {
|
|
231
341
|
this.#clearTimers(entry);
|
|
232
342
|
if (entry.process && entry.info.status === "running") {
|
|
@@ -237,7 +347,7 @@ export class TerminalManager {
|
|
|
237
347
|
this.#stopWorkspaceWatch?.();
|
|
238
348
|
this.#stopWorkspaceWatch = undefined;
|
|
239
349
|
}
|
|
240
|
-
async #spawn(entry) {
|
|
350
|
+
async #spawn(entry, stillWanted) {
|
|
241
351
|
let child;
|
|
242
352
|
try {
|
|
243
353
|
child = await this.#spawner.spawn(entry.request);
|
|
@@ -245,6 +355,10 @@ export class TerminalManager {
|
|
|
245
355
|
catch (error) {
|
|
246
356
|
return err(appError("command_failed", `Could not start it: ${error.message}`));
|
|
247
357
|
}
|
|
358
|
+
if (stillWanted && !stillWanted()) {
|
|
359
|
+
child.kill();
|
|
360
|
+
return err(appError("terminal_not_running", "The restart was cancelled."));
|
|
361
|
+
}
|
|
248
362
|
entry.process = child;
|
|
249
363
|
entry.info.pid = child.pid;
|
|
250
364
|
entry.info.mode = this.#spawner.mode;
|
|
@@ -267,10 +381,110 @@ export class TerminalManager {
|
|
|
267
381
|
}
|
|
268
382
|
entry.info.ports = [];
|
|
269
383
|
this.#publishStatus(entry);
|
|
384
|
+
this.#schedulePersist();
|
|
270
385
|
this.#updateWorkspaceWatch();
|
|
271
386
|
});
|
|
272
387
|
return ok(undefined);
|
|
273
388
|
}
|
|
389
|
+
#newEntry(fields) {
|
|
390
|
+
const defaultTitle = (fields.command || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
|
|
391
|
+
const title = fields.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
|
|
392
|
+
const { id, cols, rows } = fields;
|
|
393
|
+
return {
|
|
394
|
+
defaultTitle,
|
|
395
|
+
info: {
|
|
396
|
+
id,
|
|
397
|
+
title,
|
|
398
|
+
cwd: fields.cwd,
|
|
399
|
+
command: fields.command ?? "",
|
|
400
|
+
project: fields.project?.name ?? null,
|
|
401
|
+
worktree: fields.project?.worktree ?? null,
|
|
402
|
+
branch: fields.project?.branch ?? null,
|
|
403
|
+
ports: [],
|
|
404
|
+
status: "running",
|
|
405
|
+
exitCode: null,
|
|
406
|
+
startedAt: fields.startedAt,
|
|
407
|
+
endedAt: null,
|
|
408
|
+
pid: null,
|
|
409
|
+
mode: this.#spawner.mode,
|
|
410
|
+
cols,
|
|
411
|
+
rows,
|
|
412
|
+
nextSeq: 0,
|
|
413
|
+
naming: fields.naming,
|
|
414
|
+
startedBy: fields.startedBy,
|
|
415
|
+
stoppedByRestart: false,
|
|
416
|
+
},
|
|
417
|
+
request: {
|
|
418
|
+
command: fields.command,
|
|
419
|
+
cwd: fields.cwd,
|
|
420
|
+
cols,
|
|
421
|
+
rows,
|
|
422
|
+
env: {
|
|
423
|
+
TERM: "xterm-256color",
|
|
424
|
+
COLUMNS: String(cols),
|
|
425
|
+
LINES: String(rows),
|
|
426
|
+
PORTLESS_STUDIO_TERMINAL: id,
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
buffer: new OutputBuffer(this.#maxOutput),
|
|
430
|
+
process: null,
|
|
431
|
+
stopTimers: [],
|
|
432
|
+
watchers: new Set(),
|
|
433
|
+
baseline: null,
|
|
434
|
+
namingTimer: undefined,
|
|
435
|
+
stoppedOnPurpose: false,
|
|
436
|
+
restartPromise: null,
|
|
437
|
+
restartGeneration: 0,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
#recentCommands() {
|
|
441
|
+
this.#recent ??= this.#history?.read().recentCommands ?? {};
|
|
442
|
+
return this.#recent;
|
|
443
|
+
}
|
|
444
|
+
#schedulePersist() {
|
|
445
|
+
if (!this.#history || this.#closing || this.#persistTimer)
|
|
446
|
+
return;
|
|
447
|
+
this.#persistTimer = setTimeout(() => {
|
|
448
|
+
this.#persistTimer = undefined;
|
|
449
|
+
this.#persistNow();
|
|
450
|
+
}, this.#persistDelayMs);
|
|
451
|
+
this.#persistTimer.unref?.();
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Writes the terminals that run and the recent commands. When Studio stops, commands that just
|
|
455
|
+
* ended without anyone asking count as running too.
|
|
456
|
+
*/
|
|
457
|
+
#persistNow(stopping = false) {
|
|
458
|
+
const history = this.#history;
|
|
459
|
+
if (!history || this.#closing)
|
|
460
|
+
return;
|
|
461
|
+
clearTimeout(this.#persistTimer);
|
|
462
|
+
this.#persistTimer = undefined;
|
|
463
|
+
const now = this.#now();
|
|
464
|
+
const running = [...this.#entries.values()]
|
|
465
|
+
.filter((entry) => entry.info.status === "running" ||
|
|
466
|
+
entry.info.stoppedByRestart === true ||
|
|
467
|
+
(stopping &&
|
|
468
|
+
!entry.stoppedOnPurpose &&
|
|
469
|
+
entry.info.endedAt !== null &&
|
|
470
|
+
now - entry.info.endedAt <= RECENT_EXIT_MS))
|
|
471
|
+
.map(({ info }) => ({
|
|
472
|
+
command: info.command,
|
|
473
|
+
cwd: info.cwd,
|
|
474
|
+
title: info.title,
|
|
475
|
+
startedAt: info.startedAt,
|
|
476
|
+
startedBy: info.startedBy ?? "person",
|
|
477
|
+
project: info.project,
|
|
478
|
+
worktree: info.worktree,
|
|
479
|
+
branch: info.branch,
|
|
480
|
+
}));
|
|
481
|
+
try {
|
|
482
|
+
history.write({ running, recentCommands: this.#recentCommands() });
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// A full disk or a read-only config folder: terminals keep working, they just are not kept.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
274
488
|
#escalate(entry, child) {
|
|
275
489
|
this.#clearTimers(entry);
|
|
276
490
|
child.interrupt();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parseTerminalHistory, } from "../../../core/terminals/terminal-history.js";
|
|
4
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
5
|
+
const FILE_NAME = "terminals.json";
|
|
6
|
+
/** `terminals.json` in the config file's folder. */
|
|
7
|
+
export function terminalHistoryPathFor(configPath) {
|
|
8
|
+
return path.join(path.dirname(configPath), FILE_NAME);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The terminals running when Studio stopped and the recent commands per folder, as
|
|
12
|
+
* `{ "running": [...], "recentCommands": { "<folder>": [...] } }`. Studio writes it on its own.
|
|
13
|
+
*/
|
|
14
|
+
export class JsonTerminalHistoryStore {
|
|
15
|
+
path;
|
|
16
|
+
constructor(filePath) {
|
|
17
|
+
this.path = filePath;
|
|
18
|
+
}
|
|
19
|
+
read() {
|
|
20
|
+
try {
|
|
21
|
+
return parseTerminalHistory(JSON.parse(fs.readFileSync(this.path, "utf8")));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return parseTerminalHistory(null);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
write(history) {
|
|
28
|
+
writeFileAtomic(this.path, `${JSON.stringify(history, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function devRunner(command) {
|
|
2
|
+
const words = command.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
|
|
3
|
+
const basename = (word) => word
|
|
4
|
+
.replace(/^['"]|['"]$/g, "")
|
|
5
|
+
.split(/[/\\]/)
|
|
6
|
+
.pop() ?? "";
|
|
7
|
+
const executable = basename(words[0] ?? "");
|
|
8
|
+
const script = /^(?:node|node.exe|bun|bun.exe)$/.test(executable)
|
|
9
|
+
? basename(words[1] ?? "")
|
|
10
|
+
: executable;
|
|
11
|
+
return /^(?:pnpm|npm(?:-cli)?|npx(?:-cli)?|yarn|bun|bunx|turbo|vite|next|astro|wrangler|ng|nuxt|remix|nodemon|tsx)(?:\.(?:c?js|mjs|exe|cmd))?$/i.test(script);
|
|
12
|
+
}
|
|
13
|
+
function commandShell(command) {
|
|
14
|
+
const words = command.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
|
|
15
|
+
const name = (words[0] ?? "")
|
|
16
|
+
.replace(/^['"]|['"]$/g, "")
|
|
17
|
+
.split(/[/\\]/)
|
|
18
|
+
.pop()
|
|
19
|
+
?.toLowerCase();
|
|
20
|
+
if (name === "cmd.exe" || name === "cmd") {
|
|
21
|
+
const commandAt = words.findIndex((word) => /^\/c$/i.test(word));
|
|
22
|
+
return (commandAt > 0 &&
|
|
23
|
+
commandAt < words.length - 1 &&
|
|
24
|
+
words.slice(1, commandAt).every((word) => /^\/[dsq]$/i.test(word)));
|
|
25
|
+
}
|
|
26
|
+
return /^(?:sh|bash|zsh|dash)$/.test(name ?? "") && words[1] === "-c";
|
|
27
|
+
}
|
|
28
|
+
/** Root first prevents supervisors restarting children; the remaining children are deepest first. */
|
|
29
|
+
export function devProcessTree(pid, table) {
|
|
30
|
+
const byPid = new Map(table.map((entry) => [entry.pid, entry]));
|
|
31
|
+
let root = byPid.get(pid);
|
|
32
|
+
if (!root)
|
|
33
|
+
return [];
|
|
34
|
+
const seen = new Set([pid]);
|
|
35
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
36
|
+
let parent = byPid.get(root.parent);
|
|
37
|
+
if (!parent || parent.pid <= 1 || seen.has(parent.pid))
|
|
38
|
+
break;
|
|
39
|
+
seen.add(parent.pid);
|
|
40
|
+
if (commandShell(parent.command)) {
|
|
41
|
+
parent = byPid.get(parent.parent);
|
|
42
|
+
if (!parent || parent.pid <= 1 || seen.has(parent.pid))
|
|
43
|
+
break;
|
|
44
|
+
seen.add(parent.pid);
|
|
45
|
+
}
|
|
46
|
+
if (!devRunner(parent.command))
|
|
47
|
+
break;
|
|
48
|
+
root = parent;
|
|
49
|
+
}
|
|
50
|
+
const descendants = new Set([root.pid]);
|
|
51
|
+
const ordered = [root.pid];
|
|
52
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
53
|
+
const added = table.filter((entry) => !descendants.has(entry.pid) && descendants.has(entry.parent));
|
|
54
|
+
if (!added.length)
|
|
55
|
+
break;
|
|
56
|
+
for (const entry of added) {
|
|
57
|
+
descendants.add(entry.pid);
|
|
58
|
+
ordered.push(entry.pid);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [root.pid, ...ordered.slice(1).reverse()];
|
|
62
|
+
}
|
|
63
|
+
export function parseProcessTable(output) {
|
|
64
|
+
return output.split(/\r?\n/).flatMap((line) => {
|
|
65
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
|
|
66
|
+
return match
|
|
67
|
+
? [{ pid: Number(match[1]), parent: Number(match[2]), command: match[3] ?? "" }]
|
|
68
|
+
: [];
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -19,22 +19,51 @@ export function parseProcNetTcp(text) {
|
|
|
19
19
|
}
|
|
20
20
|
return sockets;
|
|
21
21
|
}
|
|
22
|
+
/** Clock ticks per second, the unit of `/proc/<pid>/stat` times; 100 on every common kernel. */
|
|
23
|
+
export const DEFAULT_CLOCK_TICKS = 100;
|
|
24
|
+
/**
|
|
25
|
+
* When a process started, in epoch milliseconds, from field 22 of `/proc/<pid>/stat` (clock ticks
|
|
26
|
+
* since boot) and `btime` in `/proc/stat` (boot time in epoch seconds). Null when unreadable.
|
|
27
|
+
*/
|
|
28
|
+
export function processStartTime(stat, procStat, ticksPerSecond) {
|
|
29
|
+
if (stat === null || procStat === null || ticksPerSecond <= 0)
|
|
30
|
+
return null;
|
|
31
|
+
// The command name in field 2 may hold spaces and parentheses, so count fields after the last ")".
|
|
32
|
+
const fields = stat
|
|
33
|
+
.slice(stat.lastIndexOf(")") + 1)
|
|
34
|
+
.trim()
|
|
35
|
+
.split(/\s+/);
|
|
36
|
+
const ticks = Number(fields[19]);
|
|
37
|
+
const boot = Number(/^btime\s+(\d+)$/m.exec(procStat)?.[1]);
|
|
38
|
+
if (!Number.isFinite(ticks) || !Number.isFinite(boot) || boot <= 0)
|
|
39
|
+
return null;
|
|
40
|
+
return Math.round(boot * 1000 + (ticks * 1000) / ticksPerSecond);
|
|
41
|
+
}
|
|
42
|
+
/** `getconf CLK_TCK`, or the usual 100 when it cannot run. */
|
|
43
|
+
export async function readClockTicks(io) {
|
|
44
|
+
const answer = await io.run("getconf", ["CLK_TCK"]).catch(() => null);
|
|
45
|
+
const ticks = Number(answer?.stdout.trim());
|
|
46
|
+
return Number.isInteger(ticks) && ticks > 0 ? ticks : DEFAULT_CLOCK_TICKS;
|
|
47
|
+
}
|
|
22
48
|
/**
|
|
23
49
|
* Lists listening ports from `/proc`. A socket is linked to its process through the
|
|
24
50
|
* `/proc/<pid>/fd/*` links; processes of other users cannot be read and stay unknown.
|
|
25
51
|
*/
|
|
26
|
-
export async function listLinuxPorts(io) {
|
|
52
|
+
export async function listLinuxPorts(io, ticksPerSecond = async () => DEFAULT_CLOCK_TICKS) {
|
|
27
53
|
const sockets = [
|
|
28
54
|
...parseProcNetTcp(io.readFile("/proc/net/tcp") ?? ""),
|
|
29
55
|
...parseProcNetTcp(io.readFile("/proc/net/tcp6") ?? ""),
|
|
30
56
|
];
|
|
31
57
|
const pids = pidsByInode(io, new Set(sockets.map((socket) => socket.inode)));
|
|
58
|
+
const ticks = pids.size > 0 ? await ticksPerSecond() : DEFAULT_CLOCK_TICKS;
|
|
59
|
+
const procStat = pids.size > 0 ? io.readFile("/proc/stat") : null;
|
|
32
60
|
const byPort = new Map();
|
|
33
61
|
for (const socket of sockets) {
|
|
34
62
|
const known = byPort.get(socket.port);
|
|
35
63
|
if (known?.pid != null)
|
|
36
64
|
continue;
|
|
37
65
|
const pid = pids.get(socket.inode) ?? null;
|
|
66
|
+
const startedAt = pid === null ? null : processStartTime(io.readFile(`/proc/${pid}/stat`), procStat, ticks);
|
|
38
67
|
byPort.set(socket.port, {
|
|
39
68
|
port: socket.port,
|
|
40
69
|
pid,
|
|
@@ -43,6 +72,7 @@ export async function listLinuxPorts(io) {
|
|
|
43
72
|
executable: pid === null
|
|
44
73
|
? null
|
|
45
74
|
: (io.readLink(`/proc/${pid}/exe`)?.replace(DELETED_SUFFIX, "") ?? null),
|
|
75
|
+
...(startedAt === null ? {} : { startedAt }),
|
|
46
76
|
});
|
|
47
77
|
}
|
|
48
78
|
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
@@ -48,28 +48,57 @@ export function parsePsExecutables(text) {
|
|
|
48
48
|
}
|
|
49
49
|
return executables;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Reads `ps -o pid=,etime= -p ...` into pid -> start in epoch milliseconds. `etime` is the time
|
|
53
|
+
* since the process started as `[[dd-]hh:]mm:ss`, which unlike `lstart` reads the same in every
|
|
54
|
+
* locale.
|
|
55
|
+
*/
|
|
56
|
+
export function parsePsStartTimes(text, now) {
|
|
57
|
+
const starts = new Map();
|
|
58
|
+
for (const line of text.split("\n")) {
|
|
59
|
+
const match = /^\s*(\d+)\s+(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s*$/.exec(line);
|
|
60
|
+
if (!match)
|
|
61
|
+
continue;
|
|
62
|
+
const [, pid, days, hours, minutes, seconds] = match;
|
|
63
|
+
const elapsed = ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds);
|
|
64
|
+
starts.set(Number(pid), now - elapsed * 1000);
|
|
65
|
+
}
|
|
66
|
+
return starts;
|
|
67
|
+
}
|
|
51
68
|
/** Lists listening ports with `lsof` and `ps`, which ship with macOS. */
|
|
52
|
-
export async function listMacosPorts(io) {
|
|
69
|
+
export async function listMacosPorts(io, starts = new Map(), now = Date.now) {
|
|
53
70
|
const listen = await io.run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"]);
|
|
54
71
|
const rows = parseLsofListen(listen.stdout);
|
|
55
72
|
if (rows.length === 0)
|
|
56
73
|
return [];
|
|
57
74
|
const pids = [...new Set(rows.map((row) => row.pid))];
|
|
58
|
-
const
|
|
75
|
+
for (const pid of starts.keys())
|
|
76
|
+
if (!pids.includes(pid))
|
|
77
|
+
starts.delete(pid);
|
|
78
|
+
const unknownStarts = pids.filter((pid) => !starts.has(pid));
|
|
79
|
+
const [cwd, ps, elapsed] = await Promise.all([
|
|
59
80
|
io.run("lsof", ["-a", "-d", "cwd", "-p", pids.join(","), "-F", "pn"]),
|
|
60
81
|
// -ww: without it ps cuts long paths at the terminal width.
|
|
61
82
|
io.run("ps", ["-ww", "-o", "pid=,comm=", "-p", pids.join(",")]),
|
|
83
|
+
unknownStarts.length === 0
|
|
84
|
+
? null
|
|
85
|
+
: io.run("ps", ["-o", "pid=,etime=", "-p", unknownStarts.join(",")]),
|
|
62
86
|
]);
|
|
87
|
+
const read = parsePsStartTimes(elapsed?.stdout ?? "", now());
|
|
88
|
+
for (const pid of unknownStarts)
|
|
89
|
+
starts.set(pid, read.get(pid) ?? null);
|
|
63
90
|
const cwds = parseLsofCwds(cwd.stdout);
|
|
64
91
|
const executables = parsePsExecutables(ps.stdout);
|
|
65
92
|
const byPort = new Map();
|
|
66
93
|
for (const row of rows) {
|
|
67
94
|
if (byPort.has(row.port))
|
|
68
95
|
continue;
|
|
96
|
+
const startedAt = starts.get(row.pid) ?? null;
|
|
69
97
|
byPort.set(row.port, {
|
|
70
98
|
...row,
|
|
71
99
|
cwd: cwds.get(row.pid) ?? null,
|
|
72
100
|
executable: executables.get(row.pid) ?? null,
|
|
101
|
+
...(startedAt === null ? {} : { startedAt }),
|
|
73
102
|
});
|
|
74
103
|
}
|
|
75
104
|
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createConnection } from "node:net";
|
|
2
|
+
/** A refused TCP connection confirms closure; timeouts and unexpected errors do not. */
|
|
3
|
+
export async function portClosed(port) {
|
|
4
|
+
const answers = await Promise.all(["127.0.0.1", "::1"].map((host) => new Promise((resolve) => {
|
|
5
|
+
const socket = createConnection({ host, port });
|
|
6
|
+
const finish = (closed) => {
|
|
7
|
+
socket.destroy();
|
|
8
|
+
resolve(closed);
|
|
9
|
+
};
|
|
10
|
+
socket.once("connect", () => finish(false));
|
|
11
|
+
socket.once("error", (error) => finish(error.code === "ECONNREFUSED" || error.code === "EAFNOSUPPORT"));
|
|
12
|
+
socket.setTimeout(800, () => finish(false));
|
|
13
|
+
})));
|
|
14
|
+
return answers.every(Boolean);
|
|
15
|
+
}
|