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
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,36 @@ 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
+ Requirements: Node 22 or later on Linux, macOS or Windows. portless is installed for you if it is
26
+ missing.
21
27
 
22
- Requires Node 24 or later.
28
+ To try it, run it straight from npm:
23
29
 
24
30
  ```bash
25
31
  npx portless-studio init
26
32
  ```
27
33
 
34
+ For everyday use, and always for "start at login", install it globally:
35
+
36
+ ```bash
37
+ npm i -g portless-studio
38
+ portless-studio init
39
+ ```
40
+
41
+ The login service starts Studio from the files npm installed. Run through `npx`, those files sit in
42
+ npm's cache, which npm can clear at any time, and then Studio stops starting at login.
43
+
44
+ To update, install the latest version and restart Studio with `npm i -g portless-studio@latest`.
45
+ To remove it, run `portless-studio uninstall`, then `npm rm -g portless-studio`.
46
+
47
+ ## Quick start
48
+
28
49
  `init` asks a few questions and leaves everything working:
29
50
 
30
51
  - Checks that portless is installed and offers to install it with `npm install -g portless`.
@@ -33,60 +54,52 @@ npx portless-studio init
33
54
  - Starts the portless proxy on port 1355 over HTTP, which needs no password, certificate or extra
34
55
  tool on macOS, Windows or Linux, and can set Studio to start when you log in.
35
56
 
36
- `init --yes` accepts every default without asking. After that, `npx portless-studio` starts
37
- Studio at `http://127.0.0.1:4949`, registers it in portless as `studio` and opens your browser.
38
- Your apps open at names like `http://web.myapp.localhost:1355`: Chrome, Edge and Firefox send
39
- every `*.localhost` name to your own machine, with no hosts file and no DNS setup.
57
+ Then `portless-studio` starts Studio at `http://127.0.0.1:4949`, registers it in portless as
58
+ `studio` and opens your browser. Your apps open at names like `http://web.myapp.localhost:1355`:
59
+ Chrome, Edge and Firefox send every `*.localhost` name to your own machine, with no hosts file and
60
+ no DNS setup. Turn on Short URLs in Settings to drop the `:1355` (`http://web.myapp.localhost`)
61
+ where your system lets you listen on port 80; see
62
+ [troubleshooting](docs/troubleshooting.md#names-have-1355-and-i-want-port-80).
40
63
 
41
- To try the page without portless, start Studio and open `http://127.0.0.1:4949/?demo` for sample
42
- data, or `?demo&noportless` to walk through the install flow with a fake install.
64
+ [Getting started](docs/getting-started.md) walks through the first five minutes: naming a dev
65
+ server, naming a port by hand, running a command in a Studio terminal and turning on auto mode.
66
+ To try the page without portless, open `http://127.0.0.1:4949/?demo` for sample data.
43
67
 
44
68
  ## What it does
45
69
 
46
70
  - **Live list** of portless routes: name, URL, port, app or alias, uptime and any Tailscale, ngrok
47
71
  or Cloudflare tunnel. It updates as soon as a route appears or goes away.
48
72
  - **Detected ports:** listening ports that answer HTTP but have no name yet, with the process, the
49
- project folder and a suggested name. Name one, name them all, or ignore one.
73
+ project folder and a suggested name. Name one, name them all, or ignore one. Ports a running
74
+ Docker container publishes show here too.
50
75
  - **Auto mode** (off by default): every new dev server gets its suggested name by itself. Names
51
76
  auto mode gave are removed 10 minutes after their port closes.
52
77
  - **Name a port** that portless did not start, such as a database in Docker (`db` gives
53
78
  `db.localhost`). Rename or remove those names later.
54
79
  - **Where each server runs:** every row shows the git branch, the linked worktree and the project
55
- folder. A route whose server is down keeps the last place it ran.
56
- - **Views:** group by project, worktree, kind or status, or into your own groups; sort, and
57
- filter by kind, status or project from the Filter button; save the combination as a named view
58
- and pick the one that opens by default.
80
+ folder.
81
+ - **Views:** group by project, worktree, kind or status, or into your own groups; filter, sort and
82
+ save the combination as a named view.
59
83
  - **Terminals:** run a command per project inside Studio and watch its output in a panel. You, the
60
- CLI and your AI agents drive the same terminals. See [docs/terminals.md](docs/terminals.md).
84
+ CLI and your AI agents drive the same terminals.
61
85
  - **Agents:** an MCP server lets Claude Code, Cursor and other agents list, name and stop dev
62
- servers and run commands in Studio terminals. See [docs/mcp.md](docs/mcp.md).
63
- - **Open with:** right-click a row to open its folder in an editor found on your machine (VS Code,
64
- Cursor, Zed, WebStorm, IntelliJ IDEA, Sublime Text), reveal it in the file manager or copy its
65
- path. Add your own apps in Settings.
66
- - **Health:** each route's dot says whether its port answers. The Health panel and
67
- `portless-studio doctor` find a proxy left running as root, two proxies at once, an untrusted
68
- certificate or a taken port, and give the fix.
69
- - **Proxy and Studio control:** start, stop or restart the portless proxy, and stop or restart
70
- Studio, from the page.
71
- - **Stop a dev server** from its row, after a confirmation. Only your own processes on a port
72
- Studio shows.
73
- - **Project icons:** the favicon the server serves, else one in the project folder, else the
74
- project's initials. Set an emoji or an image per project in Settings.
75
- - **Notifications** in the page when a server is detected or named, and optionally from the
76
- operating system.
77
- - **Install portless** from the page when the `portless` command is missing.
86
+ servers and run commands in Studio terminals.
87
+ - **Health:** each route's dot says whether its port answers, and `portless-studio doctor` finds a
88
+ proxy left running as root, two proxies at once, an untrusted certificate or a taken port, and
89
+ gives the fix.
78
90
  - **Keyboard first:** every action has a key, `?` lists them and `Ctrl+K` (`Cmd+K`) runs any of
79
91
  them by name.
80
92
 
81
- ## How naming works
93
+ Also: start, stop or restart the proxy and Studio from the page, stop a dev server from its row,
94
+ open a project in your editor, project icons, and notifications in the page or from the operating
95
+ system.
96
+
97
+ ## Naming
82
98
 
83
99
  A suggested name says what the work is, then the app, then the repository. The package
84
100
  `@acme/web` in the `acme-monorepo` folder on the branch `feat/ED-1234-add-x` gets
85
- `ed-1234.web.acme`, so two worktrees of one repository get different names. That is the order
86
- portless uses for worktrees, `<branch>.<name>.localhost`.
87
-
88
- Change it with `nameTemplate`, for example `{ticket}.{repo}` or `{package}-{port}`.
89
- [docs/naming.md](docs/naming.md) lists every token and how empty or repeated parts are dropped.
101
+ `ed-1234.web.acme`, so two worktrees of one repository get different names. Change the pattern
102
+ with `nameTemplate`; [docs/naming.md](docs/naming.md) lists every token.
90
103
 
91
104
  Anything that names a route through the portless CLI also shows up in Studio:
92
105
 
@@ -95,164 +108,42 @@ portless web.myapp pnpm dev # a dev server, at web.myapp.localhost
95
108
  portless alias db.myapp 5432 # a port something else opened, at db.myapp.localhost
96
109
  ```
97
110
 
98
- ## Agents and MCP
99
-
100
- `portless-studio mcp` is an [MCP](https://modelcontextprotocol.io) server on stdio. It talks to the
101
- Studio already running, so whatever an agent changes shows up in the page at once. For Claude Code:
111
+ ## Agents and terminals
102
112
 
103
113
  ```bash
104
114
  claude mcp add --scope user portless-studio -- npx -y portless-studio mcp
115
+ portless-studio run -- pnpm dev
105
116
  ```
106
117
 
107
- [docs/mcp.md](docs/mcp.md) has the setup for Cursor and other clients, the list of tools, and
108
- instructions to paste into your agent so it names dev servers after the ticket or pull request.
109
- `portless-studio list --json` and `doctor --json` give the same data to scripts.
118
+ The first line connects Claude Code to Studio's MCP server. The second runs a dev server in a
119
+ Studio terminal, prints its output in your shell and names the port it opens.
120
+ [docs/agents.md](docs/agents.md) shows how to keep agents in sync with Studio, including
121
+ instructions to paste and Claude Code hooks; [docs/mcp.md](docs/mcp.md) has the setup for other
122
+ clients and [docs/terminals.md](docs/terminals.md) covers the terminal panel.
110
123
 
111
- ## Terminals
124
+ ## Documentation
112
125
 
113
- ```bash
114
- portless-studio run -- pnpm dev
115
- ```
126
+ - [Getting started](docs/getting-started.md) and [Troubleshooting](docs/troubleshooting.md)
127
+ - [Command line](docs/cli.md), [Configuration](docs/configuration.md) and
128
+ [Keyboard shortcuts](docs/keyboard-shortcuts.md)
129
+ - [Naming](docs/naming.md), [Terminals](docs/terminals.md) and
130
+ [Linux, macOS and Windows](docs/platforms.md)
131
+ - [Agents](docs/agents.md) and [MCP server](docs/mcp.md)
132
+ - [Every page, with one line each](docs/README.md)
133
+
134
+ ## How it relates to portless
116
135
 
117
- `run` starts the command in a Studio terminal in the current folder, prints its output here,
118
- names the first port it opens and stops it on Ctrl+C. The terminal stays visible in the page.
119
- [docs/terminals.md](docs/terminals.md) covers the panel, the CLI and the security rules.
120
-
121
- ## Platforms
122
-
123
- Studio runs on Linux, macOS and Windows with Node 24 and installs nothing beyond portless. Each
124
- feature uses tools the system already has (`/proc` on Linux, `lsof` on macOS, `netstat` and
125
- PowerShell on Windows). [docs/platforms.md](docs/platforms.md) says what each system uses and what
126
- does not work the same everywhere.
127
-
128
- ## Commands
129
-
130
- | Command | What it does |
131
- | --- | --- |
132
- | `portless-studio` | Starts Studio with the settings from the config file. |
133
- | `portless-studio init [--yes]` | The setup above. Safe to run again. |
134
- | `portless-studio uninstall [--yes]` | Undoes the setup: removes the login service, the names auto mode gave and the `studio` name, and stops the proxy if Studio started it. Names you or an agent gave stay. Asks before deleting the settings folder. |
135
- | `portless-studio service install` | Starts Studio when you log in: a systemd user unit on Linux, a LaunchAgent on macOS, a script in the Startup folder on Windows. |
136
- | `portless-studio service uninstall` | Undoes `service install`. |
137
- | `portless-studio service status` | Says whether the login service is installed and running. |
138
- | `portless-studio config [--edit] [--json]` | Prints the config file path and contents, or opens it in `$EDITOR`. |
139
- | `portless-studio doctor [--json]` | Checks portless, the proxy, the certificate, Studio's port and the login service, and prints the fix for each problem. Exits with 1 when something fails. |
140
- | `portless-studio list [--json]` | Lists named routes and ports without a name. |
141
- | `portless-studio mcp` | Runs the MCP server on stdio for AI agents. |
142
- | `portless-studio run [--name <name>] [--cwd <dir>] -- <command...>` | Runs a command in a Studio terminal and names the port it opens. |
143
- | `portless-studio open [target]` | Brings Studio to the front showing a route, project, terminal id or `port:<n>`. |
144
-
145
- | Flag | Effect |
146
- | --- | --- |
147
- | `--port <n>` | Port for the dashboard on 127.0.0.1. Default: `port` in the config, 4949. `0` picks a free one. |
148
- | `--no-open` | Do not open the browser. |
149
- | `--no-register` | Do not register the `studio` name in portless. |
150
- | `-y`, `--yes` | With `init` and `uninstall`: accept every default. |
151
- | `-h`, `--help` | Show the help. |
152
- | `-v`, `--version` | Show the version. |
153
-
154
- | Environment variable | What it does |
155
- | --- | --- |
156
- | `PORTLESS_STUDIO_CONFIG` | Path of the config file. |
157
- | `PORTLESS_STATE_DIR` | portless state folder. Default `~/.portless`. |
158
- | `PORTLESS_BIN` | portless binary used to create and remove names. Default `portless`. |
159
- | `PORTLESS_STUDIO_URL` | Studio that `mcp`, `list`, `run` and `open` talk to. Default `http://127.0.0.1:<port>`. |
160
- | `EDITOR` | Editor that `config --edit` opens. |
161
-
162
- ## Config file
163
-
164
- Settings live in one JSON file: `~/.config/portless-studio/config.json` on Linux and macOS (or
165
- under `$XDG_CONFIG_HOME`), `%APPDATA%\portless-studio\config.json` on Windows. `portless-studio
166
- config` prints the path. Edit it by hand or from the Settings panel; a running Studio picks up the
167
- changes without a restart. Missing fields take their default.
168
-
169
- | Field | Default | What it does |
170
- | --- | --- | --- |
171
- | `port` | `4949` | Port Studio listens on, on 127.0.0.1. |
172
- | `autoName` | `false` | Gives every new dev server a portless name automatically. |
173
- | `autoRemoveAfterMinutes` | `10` | Minutes a name auto mode gave survives after its port closes. `0` keeps them. |
174
- | `openBrowser` | `true` | Opens the browser when Studio starts. |
175
- | `registerStudioAlias` | `true` | Registers Studio itself in portless as `studio`. |
176
- | `startProxy` | `true` | Starts the portless proxy when Studio starts and it is not running. |
177
- | `proxyPort` | `1355` | Port of that proxy. 1355 needs no administrator rights; 443 removes the port from URLs. |
178
- | `proxyHttps` | `false` | Serves the names over HTTPS. See the FAQ. |
179
- | `scanIntervalMs` | `3000` | How often Studio looks for new listening ports, in milliseconds (at least 1000). |
180
- | `ignoredPorts` | `[]` | Ports never shown as detected and never named automatically. |
181
- | `ignoredProcesses` | editors, chat apps, browsers | Process names whose ports are never shown. |
182
- | `views` | one view, `All`, grouped by project | Saved views: grouping, sort, filters and custom groups. |
183
- | `defaultViewId` | `"all"` | The view the page opens with. |
184
- | `favorites` | `[]` | Routes pinned above every group, by name or as `"port:3000"`. |
185
- | `desktopNotifications` | `false` | Notifies from an open Studio tab while it is in the background. |
186
- | `systemNotifications` | `false` | Asks the operating system to notify when no Studio tab is open. |
187
- | `nameTemplate` | `"{work}.{package}.{repo}"` | How suggested names are built. See [docs/naming.md](docs/naming.md). |
188
- | `projectIcons` | `{}` | Icon per project: an emoji, an http(s) image URL or a `data:image` URL. |
189
-
190
- The `--port`, `--no-open` and `--no-register` flags win over the file for one run. Apps you add to
191
- the Open with menu live in `open-with.json` next to the config file.
192
-
193
- ## Keyboard shortcuts
194
-
195
- | Keys | Action |
196
- | --- | --- |
197
- | `j` / `↓`, `k` / `↑` | Next and previous row |
198
- | `Home`, `End` | First and last row |
199
- | `/` | Search routes |
200
- | `Enter` or `o` | Open the selected route |
201
- | `c` | Copy its URL |
202
- | `r` | Rename an alias |
203
- | `Delete` or `Backspace` | Remove an alias |
204
- | `f` | Pin or unpin as favorite |
205
- | `.` or `Shift+F10` | Open with, copy path and more |
206
- | `t` | Open a terminal in its folder |
207
- | `n` | Name a port |
208
- | `v` / `Shift+V` | Next view / open the view menu |
209
- | `Shift+F` | Open the filters |
210
- | `[`, `]` | Collapse or expand all groups |
211
- | `Ctrl+K` / `Cmd+K` | Command menu |
212
- | `,` | Settings |
213
- | `?` | All keyboard shortcuts |
214
- | `Esc` | Close a dialog or cancel |
136
+ Studio is an independent project, not part of portless and not made by its authors. It reads the
137
+ state portless keeps in `~/.portless` and names routes through the `portless` command, so anything
138
+ you or an agent do with portless shows up in Studio, and Studio never changes how portless works.
215
139
 
216
140
  ## Security
217
141
 
218
- Studio is a local tool and only listens on `127.0.0.1`. It rejects requests whose `Host` or
219
- `Origin` is not local, so other websites open in your browser cannot read your routes or call the
220
- API. Every change (naming, removing, installing portless, controlling the proxy or Studio, stopping
221
- a dev server, running terminals) also needs a token that changes on every run. The page reads it from
222
- Studio, and browsers only let pages served by Studio itself read it. The portless install always runs the same fixed command.
223
- A dev server can be stopped only when it belongs to your user and listens on a port Studio shows,
224
- and Studio never runs a command that needs sudo. See [SECURITY.md](SECURITY.md) to report a
225
- problem.
226
-
227
- ## FAQ
228
-
229
- **Safari does not open `*.localhost` names.** Safari does not always send `.localhost` names to
230
- your machine. Open Studio at `http://127.0.0.1:4949` and each app at `http://127.0.0.1:<its port>`,
231
- or use Chrome, Edge or Firefox.
232
-
233
- **Why HTTP on port 1355, and how do I get HTTPS or port 443?** Port 1355 needs no administrator
234
- rights on any system, and browsers treat plain HTTP on a `.localhost` name as a secure context, so
235
- most apps work as they would on HTTPS. For URLs without a port, set `proxyPort` to `443`; on Linux
236
- that needs sudo once to start the proxy. For HTTPS, set `proxyHttps` to `true` and trust the
237
- portless certificate: `portless-studio doctor` prints the exact command for your system, and the
238
- Health panel shows it too.
239
-
240
- **Does it work on Windows?** Yes, with Node 24 and PowerShell 5 (included in Windows 10 and 11).
241
- Ports come from `netstat`, process details from PowerShell, and start at login uses a hidden
242
- script in the Startup folder. Windows has no service manager Studio can ask, so the page can stop
243
- Studio but not restart it, and the doctor only sees the proxy portless records.
244
- [docs/platforms.md](docs/platforms.md) lists every difference.
245
-
246
- **How does it relate to portless?** Studio is an independent project, not part of portless and not
247
- made by its authors. It reads the state portless keeps in `~/.portless` and names routes through
248
- the `portless` command, so anything you or an agent do with portless shows up in Studio, and
249
- Studio never changes how portless works.
250
-
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
255
- `npm uninstall -g portless`.
142
+ Studio only listens on `127.0.0.1` and rejects requests whose `Host` or `Origin` is not local, so
143
+ other websites open in your browser cannot read your routes or call the API. Every change also
144
+ needs a token that changes on every run and that only Studio's own page can read. A dev server can
145
+ be stopped only when it belongs to your user, and Studio never runs a command that needs sudo.
146
+ See [SECURITY.md](SECURITY.md) to report a problem.
256
147
 
257
148
  ## Development
258
149
 
@@ -262,16 +153,11 @@ cd portless-studio
262
153
  pnpm install
263
154
  pnpm dev # API on :4949 and the web app with hot reload on :5173
264
155
  pnpm test # unit and component tests
265
- pnpm typecheck
266
- pnpm check # lint and format check
267
- pnpm build # builds dist/, then `pnpm start` runs it
268
- pnpm smoke # starts the built CLI on a free port and checks the API answers
269
156
  ```
270
157
 
271
- The code is split so the web app can talk to another backend later: see
272
- [docs/architecture.md](docs/architecture.md). Conventions are in
273
- [docs/conventions.md](docs/conventions.md), and [CONTRIBUTING.md](CONTRIBUTING.md) covers pull
274
- requests.
158
+ [CONTRIBUTING.md](CONTRIBUTING.md) covers the checks to run and pull requests,
159
+ [docs/architecture.md](docs/architecture.md) the layers and
160
+ [docs/conventions.md](docs/conventions.md) the code conventions.
275
161
 
276
162
  ## License
277
163
 
@@ -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 });
@@ -1,4 +1,4 @@
1
- import { API_PATHS, } from "../../core/api/contract.js";
1
+ import { API_PATHS, INCLUDE_STUDIO_PARAM, } from "../../core/api/contract.js";
2
2
  import { err, ok } from "../../core/result.js";
3
3
  import { plainText } from "../../core/terminals/terminal.js";
4
4
  import { describeProxy, detectedItem, proxyItem, routeItem } from "../studio-items.js";
@@ -37,10 +37,20 @@ export function createStudioTools(client) {
37
37
  title: "List named routes",
38
38
  description: "Lists every portless route Studio knows: name, browser URL, local port, kind (app or alias), " +
39
39
  "health (up, error, down), project and git worktree it runs from, and pid. Use it to find the " +
40
- "URL of a running app or which ports a worktree uses.",
41
- inputSchema: NO_INPUT,
42
- call: async () => {
43
- const snapshot = await readSnapshot(client);
40
+ "URL of a running app or which ports a worktree uses. Studio's own route is left out unless " +
41
+ "include_studio is true.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: {
45
+ include_studio: {
46
+ type: "boolean",
47
+ description: "Also list Studio's own route (studio), hidden by default.",
48
+ },
49
+ },
50
+ additionalProperties: false,
51
+ },
52
+ call: async (args) => {
53
+ const snapshot = await readSnapshot(client, args.include_studio === true);
44
54
  if (!snapshot.ok)
45
55
  return snapshot.error;
46
56
  const { routes, proxy } = snapshot.value;
@@ -553,8 +563,11 @@ function terminalItem(terminal) {
553
563
  port: terminal.naming?.port ?? null,
554
564
  };
555
565
  }
556
- async function readSnapshot(client) {
557
- const result = await client.read(API_PATHS.snapshot);
566
+ async function readSnapshot(client, includeStudio = false) {
567
+ const path = includeStudio
568
+ ? `${API_PATHS.snapshot}?${INCLUDE_STUDIO_PARAM}=1`
569
+ : API_PATHS.snapshot;
570
+ const result = await client.read(path);
558
571
  if (!result.ok)
559
572
  return err(failure(result.error));
560
573
  return ok(result.value);
@@ -22,10 +22,13 @@ export function detectedItem(port) {
22
22
  };
23
23
  }
24
24
  export function proxyItem(proxy) {
25
- return { running: proxy.running, port: proxy.port };
25
+ const item = { running: proxy.running, port: proxy.port };
26
+ return proxy.publicPort == null ? item : { ...item, publicPort: proxy.publicPort };
26
27
  }
28
+ /** `publicPort` is set while Studio forwards it to the proxy, for URLs without a port. */
27
29
  export function describeProxy(proxy) {
28
30
  if (!proxy.running)
29
31
  return "proxy stopped";
30
- return proxy.port === null ? "proxy running" : `proxy running on port ${proxy.port}`;
32
+ const running = proxy.port === null ? "proxy running" : `proxy running on port ${proxy.port}`;
33
+ return proxy.publicPort == null ? running : `${running}, names open on port ${proxy.publicPort}`;
31
34
  }
@@ -29,6 +29,8 @@ export const API_PATHS = {
29
29
  studio: (action) => `/api/studio/${action}`,
30
30
  /** POST: stops a dev server listening on a detected or routed port. */
31
31
  stopProcess: (pid) => `/api/processes/${pid}/stop`,
32
+ /** POST: runs `docker stop` for a container the last `docker ps` listed. */
33
+ stopContainer: (name) => `/api/containers/${encodeURIComponent(name)}/stop`,
32
34
  /** GET: the editors found on this machine, the file manager and the custom apps. */
33
35
  openWithApps: "/api/open-with/apps",
34
36
  /** POST `OpenWithRequest`: opens a folder Studio knows with one of those apps. */
@@ -43,7 +45,16 @@ export const API_PATHS = {
43
45
  terminals: "/api/terminals",
44
46
  /** Server-sent events: `terminals` with the whole list on connect and after every change. */
45
47
  terminalsEvents: "/api/terminals/events",
46
- /** GET returns `TerminalInfo`; DELETE stops the command if it runs and closes the terminal. */
48
+ /**
49
+ * POST `CreateTerminalRequest` from the page's folder picker: `cwd` may be any existing folder,
50
+ * typed by the person (`~` is the home folder). Every other page request runs only in folders
51
+ * Studio shows.
52
+ */
53
+ terminalsInFolder: "/api/terminals/in-folder",
54
+ /**
55
+ * GET returns `TerminalInfo`; PATCH `RenameTerminalRequest` renames it; DELETE stops the command
56
+ * if it runs and closes the terminal.
57
+ */
47
58
  terminal: (id) => `/api/terminals/${encodeURIComponent(id)}`,
48
59
  /** GET `?from=<seq>&max=<chars>` returns `TerminalOutputResponse`. */
49
60
  terminalOutput: (id) => `/api/terminals/${encodeURIComponent(id)}/output`,
@@ -62,12 +73,19 @@ export const API_PATHS = {
62
73
  focus: "/api/focus",
63
74
  /** POST `FocusAck`: a page says whether it got the window to the front. */
64
75
  focusAck: "/api/focus/ack",
76
+ /** GET returns `UpdateStatus`: whether a newer portless-studio is on npm. Read-only. */
77
+ update: "/api/update",
65
78
  };
66
79
  /**
67
80
  * Query parameter that carries the token where a header cannot: the browser's EventSource sends
68
81
  * no custom headers.
69
82
  */
70
83
  export const TOKEN_PARAM = "token";
84
+ /**
85
+ * `GET /api/snapshot?includeStudio=1` keeps Studio's own route in `routes`, which the snapshot
86
+ * leaves out unless `showStudioRoute` is on.
87
+ */
88
+ export const INCLUDE_STUDIO_PARAM = "includeStudio";
71
89
  export const SNAPSHOT_EVENT = "snapshot";
72
90
  /** Server-sent event type that carries one `StudioEvent`. */
73
91
  export const STUDIO_EVENT = "event";
@@ -35,6 +35,9 @@ export const DEFAULT_CONFIG = {
35
35
  systemNotifications: false,
36
36
  projectIcons: {},
37
37
  nameTemplate: DEFAULT_NAME_TEMPLATE,
38
+ checkForUpdates: true,
39
+ shortUrls: false,
40
+ showStudioRoute: false,
38
41
  };
39
42
  const MIN_SCAN_INTERVAL_MS = 1000;
40
43
  /**
@@ -55,6 +58,9 @@ export function parseConfig(input) {
55
58
  "proxyHttps",
56
59
  "desktopNotifications",
57
60
  "systemNotifications",
61
+ "checkForUpdates",
62
+ "shortUrls",
63
+ "showStudioRoute",
58
64
  ]) {
59
65
  const value = raw[key];
60
66
  if (value === undefined)
@@ -27,9 +27,12 @@ export function killCommand(platform, pids, admin) {
27
27
  export function adminWords(platform) {
28
28
  return platform === "win32" ? "from a terminal opened with Run as Administrator" : "with sudo";
29
29
  }
30
- /** Whether binding this port needs root. Windows and macOS (10.14 and later) let anyone bind it. */
30
+ /**
31
+ * Whether portless asks for sudo to start its proxy on this port. It does on Linux and macOS for
32
+ * every port below 1024, even where the system would let this user bind it; Windows never asks.
33
+ */
31
34
  export function portNeedsRoot(platform, port) {
32
- return port < 1024 && platform !== "win32" && platform !== "darwin";
35
+ return port < 1024 && platform !== "win32";
33
36
  }
34
37
  /** Trusts the portless certificate authority in the store this system's browsers read. */
35
38
  export function trustCertificateCommand(platform, caFile) {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The address a dev server answers on without the portless proxy, e.g. `http://localhost:3000`.
3
+ * For a Docker container it is the host port the container publishes.
4
+ */
5
+ export function directUrl(port) {
6
+ return `http://localhost:${port}`;
7
+ }