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.
Files changed (41) hide show
  1. package/README.md +78 -192
  2. package/dist/cli/cli-args.js +29 -3
  3. package/dist/cli/index.js +10 -15
  4. package/dist/cli/login-service.js +16 -0
  5. package/dist/cli/mcp/studio-tools.js +20 -7
  6. package/dist/cli/studio-items.js +5 -2
  7. package/dist/core/api/contract.js +19 -1
  8. package/dist/core/config/studio-config.js +6 -0
  9. package/dist/core/doctor/platform-commands.js +5 -2
  10. package/dist/core/ports/direct-url.js +7 -0
  11. package/dist/core/ports/docker-container.js +85 -0
  12. package/dist/core/routes/route.js +14 -2
  13. package/dist/core/updates/version.js +62 -0
  14. package/dist/server/application/auto-namer.js +14 -0
  15. package/dist/server/application/container-source.js +19 -0
  16. package/dist/server/application/doctor.js +225 -9
  17. package/dist/server/application/port-discovery.js +49 -6
  18. package/dist/server/application/proxy-start-check.js +83 -0
  19. package/dist/server/application/short-urls.js +136 -0
  20. package/dist/server/application/studio-runtime.js +46 -7
  21. package/dist/server/application/terminals.js +142 -15
  22. package/dist/server/application/update-check.js +66 -0
  23. package/dist/server/infrastructure/config/json-update-cache.js +35 -0
  24. package/dist/server/infrastructure/portless/portless-registry.js +31 -4
  25. package/dist/server/infrastructure/portless/state-files.js +23 -7
  26. package/dist/server/infrastructure/system/docker-containers.js +64 -0
  27. package/dist/server/infrastructure/system/doctor-facts.js +44 -1
  28. package/dist/server/infrastructure/system/npm-registry.js +20 -0
  29. package/dist/server/infrastructure/system/port-forwarder.js +78 -0
  30. package/dist/server/infrastructure/system/process-runner.js +2 -1
  31. package/dist/server/infrastructure/terminal/process-tree.js +71 -0
  32. package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
  33. package/dist/server/interface/http/http-server.js +43 -2
  34. package/dist/server/interface/http/json-io.js +1 -0
  35. package/dist/server/main.js +62 -8
  36. package/dist/web/assets/index-BDEwEBPS.js +15 -0
  37. package/dist/web/assets/index-C9wkL1J8.css +1 -0
  38. package/dist/web/index.html +2 -2
  39. package/package.json +2 -2
  40. package/dist/web/assets/index-CA7HpYbk.css +0 -1
  41. 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
- void Promise.all([this.#reloadConfig(), this.#loadAutoNamed()]).then(() => {
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
- return this.#withIcons(snapshot, config);
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 excludedPorts = [this.#studioPort, state.proxy.port, ...scan.proxyPorts].filter((port) => port !== null);
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
- return () => this.#watchers.delete(onChange);
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 title = (request.title?.trim() || command.value || "shell").slice(0, MAX_TITLE_CHARS);
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: await this.#projectOf(cwd.value),
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.#updateNamingWatch();
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 = { name: item.project.name, length: path.length };
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.#updateNamingWatch();
449
+ this.#updateWorkspaceWatch();
334
450
  }
335
451
  #waitingEntries() {
336
452
  return [...this.#entries.values()].filter((entry) => entry.info.naming?.state === "waiting");
337
453
  }
338
- /** Watches the workspace, which keeps Studio scanning, only while a terminal waits for a port. */
339
- #updateNamingWatch() {
340
- const waiting = this.#waitingEntries().length > 0;
341
- if (waiting && !this.#stopWorkspaceWatch) {
342
- this.#stopWorkspaceWatch = this.#workspace.watch(() => void this.#nameNewPorts());
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 (!waiting && this.#stopWorkspaceWatch) {
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.#updateNamingWatch();
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
+ }