portless-studio 0.1.0 → 0.1.1

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # portless studio
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/portless-studio)](https://www.npmjs.com/package/portless-studio)
4
+ [![CI](https://github.com/keven-bardales/portless-studio/actions/workflows/ci.yml/badge.svg)](https://github.com/keven-bardales/portless-studio/actions/workflows/ci.yml)
5
+
3
6
  A local web dashboard for [portless](https://github.com/vercel-labs/portless): see every dev server
4
7
  on your machine, give each one a name like `web.myapp.localhost`, and open it from one page.
5
8
 
@@ -13,18 +16,45 @@ portless replaces port numbers with names: `portless api pnpm dev` serves your a
13
16
  with ten dev servers across several projects you have to remember which names exist and which
14
17
  ports have none. Studio shows both, live, and names a port in one click.
15
18
 
16
- ![Naming port 8000 as notebook.ml, searching routes with Ctrl+K and running pnpm dev in a Studio terminal](docs/images/demo.gif)
19
+ ![Naming port 8000, searching routes with Ctrl+K, filtering with the Filter popover and running pnpm dev in a Studio terminal](docs/images/demo.gif)
17
20
 
18
21
  The screenshots use the built-in sample data (`?demo`), not a real machine.
19
22
 
20
- ## Quick start
23
+ ## Install
24
+
25
+ Requires Node 22 or later.
21
26
 
22
- Requires Node 24 or later.
27
+ To try it, run it straight from npm:
23
28
 
24
29
  ```bash
25
30
  npx portless-studio init
26
31
  ```
27
32
 
33
+ For everyday use, and always for "start at login", install it globally:
34
+
35
+ ```bash
36
+ npm i -g portless-studio
37
+ portless-studio init
38
+ ```
39
+
40
+ The login service starts Studio from the files npm installed. Run through `npx`, those files sit in
41
+ npm's cache, which npm can clear at any time, and then Studio stops starting at login.
42
+
43
+ To update, install the latest version and restart Studio:
44
+
45
+ ```bash
46
+ npm i -g portless-studio@latest
47
+ ```
48
+
49
+ To remove it, undo the setup first, then remove the package:
50
+
51
+ ```bash
52
+ portless-studio uninstall
53
+ npm rm -g portless-studio
54
+ ```
55
+
56
+ ## Quick start
57
+
28
58
  `init` asks a few questions and leaves everything working:
29
59
 
30
60
  - Checks that portless is installed and offers to install it with `npm install -g portless`.
@@ -33,7 +63,7 @@ npx portless-studio init
33
63
  - Starts the portless proxy on port 1355 over HTTP, which needs no password, certificate or extra
34
64
  tool on macOS, Windows or Linux, and can set Studio to start when you log in.
35
65
 
36
- `init --yes` accepts every default without asking. After that, `npx portless-studio` starts
66
+ `init --yes` accepts every default without asking. After that, `portless-studio` starts
37
67
  Studio at `http://127.0.0.1:4949`, registers it in portless as `studio` and opens your browser.
38
68
  Your apps open at names like `http://web.myapp.localhost:1355`: Chrome, Edge and Firefox send
39
69
  every `*.localhost` name to your own machine, with no hosts file and no DNS setup.
@@ -47,8 +77,9 @@ data, or `?demo&noportless` to walk through the install flow with a fake install
47
77
  or Cloudflare tunnel. It updates as soon as a route appears or goes away.
48
78
  - **Detected ports:** listening ports that answer HTTP but have no name yet, with the process, the
49
79
  project folder and a suggested name. Name one, name them all, or ignore one.
50
- - **Auto mode** (off by default): every new dev server gets its suggested name by itself. Names
51
- auto mode gave are removed 10 minutes after their port closes.
80
+ - **Auto mode** (off by default): every new dev server gets its suggested name by itself, once
81
+ it has shown up unnamed in two scans in a row, so a server started with `portless <name>` keeps
82
+ the name portless gives it. Names auto mode gave are removed 10 minutes after their port closes.
52
83
  - **Name a port** that portless did not start, such as a database in Docker (`db` gives
53
84
  `db.localhost`). Rename or remove those names later.
54
85
  - **Where each server runs:** every row shows the git branch, the linked worktree and the project
@@ -120,7 +151,7 @@ names the first port it opens and stops it on Ctrl+C. The terminal stays visible
120
151
 
121
152
  ## Platforms
122
153
 
123
- Studio runs on Linux, macOS and Windows with Node 24 and installs nothing beyond portless. Each
154
+ Studio runs on Linux, macOS and Windows with Node 22 or later and installs nothing beyond portless. Each
124
155
  feature uses tools the system already has (`/proc` on Linux, `lsof` on macOS, `netstat` and
125
156
  PowerShell on Windows). [docs/platforms.md](docs/platforms.md) says what each system uses and what
126
157
  does not work the same everywhere.
@@ -157,6 +188,7 @@ does not work the same everywhere.
157
188
  | `PORTLESS_STATE_DIR` | portless state folder. Default `~/.portless`. |
158
189
  | `PORTLESS_BIN` | portless binary used to create and remove names. Default `portless`. |
159
190
  | `PORTLESS_STUDIO_URL` | Studio that `mcp`, `list`, `run` and `open` talk to. Default `http://127.0.0.1:<port>`. |
191
+ | `PORTLESS_STUDIO_NO_UPDATE_CHECK` | `1` turns the daily update check off, whatever the config says. |
160
192
  | `EDITOR` | Editor that `config --edit` opens. |
161
193
 
162
194
  ## Config file
@@ -186,10 +218,22 @@ changes without a restart. Missing fields take their default.
186
218
  | `systemNotifications` | `false` | Asks the operating system to notify when no Studio tab is open. |
187
219
  | `nameTemplate` | `"{work}.{package}.{repo}"` | How suggested names are built. See [docs/naming.md](docs/naming.md). |
188
220
  | `projectIcons` | `{}` | Icon per project: an emoji, an http(s) image URL or a `data:image` URL. |
221
+ | `checkForUpdates` | `true` | Once a day, asks npm whether a newer portless-studio exists. See "Update notice" below. |
189
222
 
190
223
  The `--port`, `--no-open` and `--no-register` flags win over the file for one run. Apps you add to
191
224
  the Open with menu live in `open-with.json` next to the config file.
192
225
 
226
+ ### Update notice
227
+
228
+ Once a day Studio asks the npm registry for the latest version of portless-studio. When a newer
229
+ one exists, it prints one line on start and shows a banner in the page with the command to update;
230
+ dismissing the banner hides it until the next version. The request is a plain
231
+ `GET https://registry.npmjs.org/portless-studio/latest`: Studio sends nothing about your machine,
232
+ your projects or your routes. It gives up after 3 seconds and stays quiet when you are offline,
233
+ and it keeps the time of the last check in `update-check.json` next to the config file. It never
234
+ runs when the `CI` variable is set. Turn it off with `checkForUpdates: false`, the Settings
235
+ switch, or `PORTLESS_STUDIO_NO_UPDATE_CHECK=1`.
236
+
193
237
  ## Keyboard shortcuts
194
238
 
195
239
  | Keys | Action |
@@ -237,7 +281,7 @@ that needs sudo once to start the proxy. For HTTPS, set `proxyHttps` to `true` a
237
281
  portless certificate: `portless-studio doctor` prints the exact command for your system, and the
238
282
  Health panel shows it too.
239
283
 
240
- **Does it work on Windows?** Yes, with Node 24 and PowerShell 5 (included in Windows 10 and 11).
284
+ **Does it work on Windows?** Yes, with Node 22 or later and PowerShell 5 (included in Windows 10 and 11).
241
285
  Ports come from `netstat`, process details from PowerShell, and start at login uses a hidden
242
286
  script in the Startup folder. Windows has no service manager Studio can ask, so the page can stop
243
287
  Studio but not restart it, and the doctor only sees the proxy portless records.
@@ -248,10 +292,10 @@ made by its authors. It reads the state portless keeps in `~/.portless` and name
248
292
  the `portless` command, so anything you or an agent do with portless shows up in Studio, and
249
293
  Studio never changes how portless works.
250
294
 
251
- **How do I remove it?** Run `npx portless-studio uninstall`. It removes the login service, the
252
- names auto mode gave and the `studio` name, stops the proxy if Studio started it, and asks before
253
- deleting the settings folder. If you installed it globally, also run
254
- `npm uninstall -g portless-studio`. portless itself stays installed; remove it with
295
+ **How do I remove it?** Run `portless-studio uninstall` (or `npx portless-studio uninstall` if you
296
+ never installed it globally). It removes the login service, the names auto mode gave and the
297
+ `studio` name, stops the proxy if Studio started it, and asks before deleting the settings folder.
298
+ Then remove the package with `npm rm -g portless-studio`. portless itself stays installed; remove it with
255
299
  `npm uninstall -g portless`.
256
300
 
257
301
  ## Development
@@ -162,14 +162,40 @@ function parseFlags(args, subcommand) {
162
162
  open: { type: "boolean" },
163
163
  register: { type: "boolean" },
164
164
  };
165
- const { values, positionals } = parseArgs({
166
- args,
165
+ // parseArgs learned `allowNegative` in Node 22.4; reading `--no-<flag>` here keeps every Node 22.
166
+ const { args: positive, negated } = splitNegations(args, options);
167
+ const { values, positionals, tokens } = parseArgs({
168
+ args: positive,
167
169
  options,
168
- allowNegative: true,
170
+ tokens: true,
169
171
  allowPositionals: subcommand === "service" || subcommand === "run" || subcommand === "open",
170
172
  });
173
+ for (const token of tokens) {
174
+ if (token.kind === "option" && options[token.name]?.type === "boolean") {
175
+ values[token.name] = !negated.has(token.index);
176
+ }
177
+ }
171
178
  return {
172
179
  positionals,
173
180
  values: values,
174
181
  };
175
182
  }
183
+ /**
184
+ * Turns each `--no-<flag>` of a boolean option into `--<flag>` and remembers its position, so the
185
+ * last of `--open` and `--no-open` wins. Arguments after `--` are left alone.
186
+ */
187
+ function splitNegations(args, options) {
188
+ const negated = new Set();
189
+ const end = args.indexOf("--");
190
+ const positive = args.map((arg, index) => {
191
+ if (end !== -1 && index > end)
192
+ return arg;
193
+ const match = /^--no-([^=]+)(=.*)?$/.exec(arg);
194
+ const name = match?.[1];
195
+ if (name === undefined || options[name]?.type !== "boolean")
196
+ return arg;
197
+ negated.add(index);
198
+ return `--${name}${match?.[2] ?? ""}`;
199
+ });
200
+ return { args: positive, negated };
201
+ }
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { API_PATHS } from "../core/api/contract.js";
7
7
  import { DEFAULT_CONFIG } from "../core/config/studio-config.js";
8
+ import { updateMessage } from "../core/updates/version.js";
8
9
  import { createStudioServices, createUninstallServices, startServer, } from "../server/main.js";
9
10
  import { HELP, parseCliArgs, resolveStartSettings, } from "./cli-args.js";
10
11
  import { runCommand } from "./command-runner.js";
@@ -13,7 +14,7 @@ import { runDoctorCommand } from "./doctor-command.js";
13
14
  import { runOpenCommand } from "./focus-command.js";
14
15
  import { runInit } from "./init.js";
15
16
  import { runListCommand } from "./list-command.js";
16
- import { installService, resolveServiceEntry, serviceStatus, uninstallService, } from "./login-service.js";
17
+ import { describeServiceInstall, installService, resolveServiceEntry, serviceStatus, uninstallService, } from "./login-service.js";
17
18
  import { createMcpServer, serveStdio } from "./mcp/mcp-server.js";
18
19
  import { createStudioClient, resolveStudioUrl } from "./mcp/studio-client.js";
19
20
  import { AGENT_INSTRUCTIONS, createStudioTools } from "./mcp/studio-tools.js";
@@ -162,6 +163,7 @@ async function start(overrides) {
162
163
  studioPort: settings.port,
163
164
  studioService,
164
165
  openUrl: openBrowser,
166
+ version: readVersion(),
165
167
  });
166
168
  const webDir = resolveWebDir();
167
169
  let server;
@@ -190,6 +192,11 @@ async function start(overrides) {
190
192
  print(" API only: the UI runs through `pnpm dev:web`.");
191
193
  if (settings.open && webDir)
192
194
  openBrowser(alias.url ?? server.url);
195
+ // Never holds up the start: the line shows up once npm answers, or never when it does not.
196
+ void services.updates?.status().then((update) => {
197
+ if (update.available && update.latest)
198
+ print(updateMessage(update.current, update.latest));
199
+ });
193
200
  let stopping = false;
194
201
  const stop = async () => {
195
202
  if (stopping)
@@ -220,7 +227,7 @@ async function init(yes) {
220
227
  const env = await serviceEnv();
221
228
  if (!env.ok)
222
229
  return env;
223
- return describeInstall(await installService(env.value), env.value);
230
+ return describeServiceInstall(await installService(env.value), env.value);
224
231
  },
225
232
  });
226
233
  }
@@ -272,7 +279,7 @@ async function service(action) {
272
279
  process.exit(1);
273
280
  }
274
281
  if (action === "install") {
275
- const described = describeInstall(await installService(env.value), env.value);
282
+ const described = describeServiceInstall(await installService(env.value), env.value);
276
283
  if (!described.ok) {
277
284
  console.error(`Error: ${described.error}`);
278
285
  process.exit(1);
@@ -295,18 +302,6 @@ async function service(action) {
295
302
  print(`Start at login: ${state.installed ? "installed" : "not installed"} (${state.file})`);
296
303
  print(`Running: ${state.running ? "yes" : "no"}`);
297
304
  }
298
- function describeInstall(installed, env) {
299
- if (!installed.ok)
300
- return installed;
301
- const lines = [`Studio will start when you log in. Service file: ${installed.value.file}`];
302
- if (env.platform === "win32")
303
- lines.push("Start it now with: portless-studio");
304
- if (env.entryPath.includes(`${path.sep}_npx${path.sep}`)) {
305
- lines.push("Note: this runs from the npx cache, which npm can clear. Install Studio globally with " +
306
- "`npm install -g portless-studio` and run `portless-studio service install` again.");
307
- }
308
- return { ok: true, value: lines.join("\n") };
309
- }
310
305
  async function serviceEnv() {
311
306
  const entry = resolveServiceEntry(import.meta.url);
312
307
  if (!entry.ok)
@@ -38,6 +38,22 @@ export function resolveServiceEntry(moduleUrl) {
38
38
  }
39
39
  return ok(fileURLToPath(moduleUrl));
40
40
  }
41
+ /**
42
+ * What `service install` and `init` print after installing. A service started from the npx cache
43
+ * breaks once npm clears it, so that case ends with the commands that move it to a global install.
44
+ */
45
+ export function describeServiceInstall(installed, env) {
46
+ if (!installed.ok)
47
+ return installed;
48
+ const lines = [`Studio will start when you log in. Service file: ${installed.value.file}`];
49
+ if (env.platform === "win32")
50
+ lines.push("Start it now with: portless-studio");
51
+ if (/[\\/]_npx[\\/]/.test(env.entryPath)) {
52
+ lines.push("Note: this runs Studio from the npx cache, which npm can clear, and then Studio no longer " +
53
+ "starts at login. Install Studio globally and set up the service again:", " npm i -g portless-studio", " portless-studio service install");
54
+ }
55
+ return ok(lines.join("\n"));
56
+ }
41
57
  export async function installService(env) {
42
58
  const file = serviceFile(env);
43
59
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -62,6 +62,8 @@ export const API_PATHS = {
62
62
  focus: "/api/focus",
63
63
  /** POST `FocusAck`: a page says whether it got the window to the front. */
64
64
  focusAck: "/api/focus/ack",
65
+ /** GET returns `UpdateStatus`: whether a newer portless-studio is on npm. Read-only. */
66
+ update: "/api/update",
65
67
  };
66
68
  /**
67
69
  * Query parameter that carries the token where a header cannot: the browser's EventSource sends
@@ -35,6 +35,7 @@ export const DEFAULT_CONFIG = {
35
35
  systemNotifications: false,
36
36
  projectIcons: {},
37
37
  nameTemplate: DEFAULT_NAME_TEMPLATE,
38
+ checkForUpdates: true,
38
39
  };
39
40
  const MIN_SCAN_INTERVAL_MS = 1000;
40
41
  /**
@@ -55,6 +56,7 @@ export function parseConfig(input) {
55
56
  "proxyHttps",
56
57
  "desktopNotifications",
57
58
  "systemNotifications",
59
+ "checkForUpdates",
58
60
  ]) {
59
61
  const value = raw[key];
60
62
  if (value === undefined)
@@ -0,0 +1,62 @@
1
+ /** The command that installs the latest release globally, shown with every update notice. */
2
+ export const UPDATE_COMMAND = "npm i -g portless-studio";
3
+ const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
4
+ /**
5
+ * Compares two semver versions: negative when `a` is older, positive when newer, 0 when equal.
6
+ * A prerelease sorts before its release. Returns null when either is not a version.
7
+ */
8
+ export function compareVersions(a, b) {
9
+ const left = VERSION.exec(a.trim());
10
+ const right = VERSION.exec(b.trim());
11
+ if (!left || !right)
12
+ return null;
13
+ for (let index = 1; index <= 3; index++) {
14
+ const difference = Number(left[index]) - Number(right[index]);
15
+ if (difference !== 0)
16
+ return Math.sign(difference);
17
+ }
18
+ return comparePrereleases(left[4], right[4]);
19
+ }
20
+ /** True when `latest` is a newer version than `current`; false for anything unreadable. */
21
+ export function isNewerVersion(latest, current) {
22
+ const order = compareVersions(latest, current);
23
+ return order !== null && order > 0;
24
+ }
25
+ /** The line Studio prints on start when a newer release exists. */
26
+ export function updateMessage(current, latest) {
27
+ return (`A new version of portless-studio is available: ${current} -> ${latest}. ` +
28
+ `Update with: ${UPDATE_COMMAND}`);
29
+ }
30
+ function comparePrereleases(a, b) {
31
+ if (a === b)
32
+ return 0;
33
+ if (a === undefined)
34
+ return 1;
35
+ if (b === undefined)
36
+ return -1;
37
+ const left = a.split(".");
38
+ const right = b.split(".");
39
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
40
+ const x = left[index];
41
+ const y = right[index];
42
+ if (x === undefined)
43
+ return -1;
44
+ if (y === undefined)
45
+ return 1;
46
+ const xNumeric = /^\d+$/.test(x);
47
+ const yNumeric = /^\d+$/.test(y);
48
+ if (xNumeric && yNumeric) {
49
+ const difference = Number(x) - Number(y);
50
+ if (difference !== 0)
51
+ return Math.sign(difference);
52
+ }
53
+ else if (xNumeric !== yNumeric) {
54
+ // Numeric identifiers sort before alphanumeric ones.
55
+ return xNumeric ? -1 : 1;
56
+ }
57
+ else if (x !== y) {
58
+ return x < y ? -1 : 1;
59
+ }
60
+ }
61
+ return 0;
62
+ }
@@ -8,6 +8,8 @@ export class AutoNamer {
8
8
  #log;
9
9
  #hooks;
10
10
  #attempted = new Set();
11
+ /** In how many scans in a row each port and process pair showed up unnamed. */
12
+ #streaks = new Map();
11
13
  constructor(registry, log, hooks = {}) {
12
14
  this.#registry = registry;
13
15
  this.#log = log;
@@ -15,6 +17,8 @@ export class AutoNamer {
15
17
  }
16
18
  /** Returns how many ports got a name. */
17
19
  async name(detected) {
20
+ const stableScans = this.#hooks.stableScans ?? 2;
21
+ const streaks = new Map();
18
22
  let named = 0;
19
23
  for (const port of detected) {
20
24
  if (port.suggestedName === null)
@@ -22,13 +26,23 @@ export class AutoNamer {
22
26
  const key = `${port.port}:${port.pid}`;
23
27
  if (this.#attempted.has(key))
24
28
  continue;
29
+ const streak = (this.#streaks.get(key) ?? 0) + 1;
30
+ streaks.set(key, streak);
31
+ if (streak < stableScans)
32
+ continue;
25
33
  this.#attempted.add(key);
26
34
  if (await this.#nameOne(port.port, port.suggestedName))
27
35
  named += 1;
28
36
  }
37
+ // A port missing from this scan starts counting again when it comes back.
38
+ this.#streaks = streaks;
29
39
  return named;
30
40
  }
31
41
  async #nameOne(port, suggested) {
42
+ // The scan is a few seconds old; portless may have registered the port since.
43
+ const { routes } = await this.#registry.readSnapshot();
44
+ if (routes.some((route) => route.port === port))
45
+ return false;
32
46
  let lastError = "";
33
47
  // The second name keeps two servers of the same project apart: `web.acme-4321`.
34
48
  for (const candidate of [suggested, `${suggested}-${port}`]) {
@@ -9,6 +9,7 @@ export function diagnose(facts) {
9
9
  pushIf(checks, proxyOwnerCheck(facts));
10
10
  pushIf(checks, proxyCountCheck(facts));
11
11
  pushIf(checks, httpsTrustCheck(facts));
12
+ pushIf(checks, routesFileCheck(facts));
12
13
  }
13
14
  checks.push(studioPortCheck(facts));
14
15
  pushIf(checks, loginServiceCheck(facts));
@@ -145,6 +146,31 @@ function proxyOwnerCheck({ proxy, proxyPid, proxyOwner, platform, }) {
145
146
  },
146
147
  };
147
148
  }
149
+ /** portless is before 1.0 and may change its state files; Studio says so instead of hiding routes. */
150
+ function routesFileCheck({ routesFile, portless }) {
151
+ if (!routesFile)
152
+ return null;
153
+ const version = portless.version ? `portless ${portless.version}` : "This portless";
154
+ const advice = "Update portless-studio, or report it with the portless version.";
155
+ if (routesFile.state === "unexpected") {
156
+ return {
157
+ id: "routes-file",
158
+ status: "warn",
159
+ title: "Studio cannot read routes.json",
160
+ detail: `${version} saved it in a format this version of Studio does not know, so no route is ` +
161
+ `shown. ${advice}`,
162
+ };
163
+ }
164
+ if (routesFile.state !== "ok" || routesFile.unreadable === 0)
165
+ return null;
166
+ return {
167
+ id: "routes-file",
168
+ status: "warn",
169
+ title: `Studio cannot read ${routesFile.unreadable} of the ${routesFile.entries} routes portless saved`,
170
+ detail: `${version} may have changed the format of routes.json, so those routes are not shown. ` +
171
+ advice,
172
+ };
173
+ }
148
174
  function proxyCountCheck({ proxyProcesses, platform }) {
149
175
  if (proxyProcesses === null || proxyProcesses.length < 2)
150
176
  return null;
@@ -0,0 +1,83 @@
1
+ import { err, ok } from "../../core/result.js";
2
+ import { appError } from "./app-error.js";
3
+ const DEFAULT_ATTEMPTS = 12;
4
+ const DEFAULT_INTERVAL_MS = 250;
5
+ /**
6
+ * The same registry, but a proxy start counts only once the proxy portless recorded is alive and
7
+ * the port listens. `portless proxy start` can exit 0 while another proxy, with another state
8
+ * directory, already holds the port; without this check Studio would report a start that never
9
+ * happened.
10
+ */
11
+ export function confirmProxyStarts(registry, deps) {
12
+ return {
13
+ readSnapshot: () => registry.readSnapshot(),
14
+ watch: (onChange) => registry.watch(onChange),
15
+ createAlias: (name, port, force) => registry.createAlias(name, port, force),
16
+ removeAlias: (name) => registry.removeAlias(name),
17
+ installPortless: () => registry.installPortless(),
18
+ stopProxy: () => registry.stopProxy(),
19
+ proxyPid: () => registry.proxyPid(),
20
+ startProxy: (port, https) => startConfirmed(registry, deps, port, https),
21
+ };
22
+ }
23
+ async function startConfirmed(registry, deps, port, https) {
24
+ const ownPid = await registry.proxyPid().catch(() => null);
25
+ const before = await holderOf(deps, port);
26
+ // Our own proxy may still hold the port for a moment after a stop, during a restart.
27
+ if (before !== null && before !== "unknown" && (before.pid === null || before.pid !== ownPid)) {
28
+ return err(await inUse(deps, port, before));
29
+ }
30
+ const started = await registry.startProxy(port, https);
31
+ if (!started.ok)
32
+ return started;
33
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
34
+ const attempts = deps.attempts ?? DEFAULT_ATTEMPTS;
35
+ let holder = null;
36
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
37
+ if (attempt > 0)
38
+ await sleep(deps.intervalMs ?? DEFAULT_INTERVAL_MS);
39
+ holder = await holderOf(deps, port);
40
+ const { proxy } = await registry.readSnapshot();
41
+ if (!proxy.running)
42
+ continue;
43
+ // Without a port list, a live recorded pid is all there is to go on.
44
+ if (holder === "unknown")
45
+ return ok(undefined);
46
+ if (holder !== null && (await isOurs(registry, deps, holder)))
47
+ return ok(undefined);
48
+ }
49
+ if (holder !== null && holder !== "unknown")
50
+ return err(await inUse(deps, port, holder));
51
+ return err(appError("command_failed", `portless reported a start, but no proxy listens on port ${port}. Run \`portless proxy ` +
52
+ "start` in a terminal to see why."));
53
+ }
54
+ /** The listener is the proxy portless recorded, or a portless proxy it forked. */
55
+ async function isOurs(registry, deps, holder) {
56
+ if (holder.pid === null)
57
+ return true;
58
+ if (holder.pid === (await registry.proxyPid().catch(() => null)))
59
+ return true;
60
+ return (await proxyPidsAmong(deps, [holder.pid])).has(holder.pid);
61
+ }
62
+ async function holderOf(deps, port) {
63
+ try {
64
+ return (await deps.scan()).find((item) => item.port === port) ?? null;
65
+ }
66
+ catch {
67
+ return "unknown";
68
+ }
69
+ }
70
+ async function inUse(deps, port, holder) {
71
+ const { pid } = holder;
72
+ if (pid !== null && (await proxyPidsAmong(deps, [pid])).has(pid)) {
73
+ return appError("port_in_use", `port ${port} is already used by another portless proxy (pid ${pid}) that keeps its state ` +
74
+ "in a different folder. Stop that proxy, or set proxyPort to a free port.");
75
+ }
76
+ const who = [holder.processName, pid === null ? "" : `pid ${pid}`].filter(Boolean).join(", ");
77
+ return appError("port_in_use", `port ${port} is in use by another process${who === "" ? "" : ` (${who})`}.`);
78
+ }
79
+ async function proxyPidsAmong(deps, pids) {
80
+ if (!deps.proxyPids)
81
+ return new Set();
82
+ return deps.proxyPids(pids).catch(() => new Set());
83
+ }
@@ -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
+ }