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 +56 -12
- package/dist/cli/cli-args.js +29 -3
- package/dist/cli/index.js +10 -15
- package/dist/cli/login-service.js +16 -0
- package/dist/core/api/contract.js +2 -0
- package/dist/core/config/studio-config.js +2 -0
- package/dist/core/updates/version.js +62 -0
- package/dist/server/application/auto-namer.js +14 -0
- package/dist/server/application/doctor.js +26 -0
- package/dist/server/application/proxy-start-check.js +83 -0
- package/dist/server/application/update-check.js +66 -0
- package/dist/server/infrastructure/config/json-update-cache.js +35 -0
- package/dist/server/infrastructure/portless/portless-registry.js +31 -4
- package/dist/server/infrastructure/portless/state-files.js +23 -7
- package/dist/server/infrastructure/system/doctor-facts.js +1 -0
- package/dist/server/infrastructure/system/npm-registry.js +20 -0
- package/dist/server/interface/http/http-server.js +5 -0
- package/dist/server/interface/http/json-io.js +1 -0
- package/dist/server/main.js +23 -4
- package/dist/web/assets/index-B3KMqQ-l.js +15 -0
- package/dist/web/assets/{index-CA7HpYbk.css → index-CWujmTe4.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -2
- package/dist/web/assets/index-CC6BquZf.js +0 -15
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# portless studio
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/portless-studio)
|
|
4
|
+
[](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
|
-

|
|
17
20
|
|
|
18
21
|
The screenshots use the built-in sample data (`?demo`), not a real machine.
|
|
19
22
|
|
|
20
|
-
##
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Requires Node 22 or later.
|
|
21
26
|
|
|
22
|
-
|
|
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, `
|
|
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
|
|
51
|
-
|
|
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
|
|
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
|
|
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 `
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
`npm
|
|
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
|
package/dist/cli/cli-args.js
CHANGED
|
@@ -162,14 +162,40 @@ function parseFlags(args, subcommand) {
|
|
|
162
162
|
open: { type: "boolean" },
|
|
163
163
|
register: { type: "boolean" },
|
|
164
164
|
};
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
+
}
|