portless-studio 0.0.0-stage → 0.1.0

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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -2
  3. package/dist/cli/cli-args.js +175 -0
  4. package/dist/cli/command-runner.js +10 -0
  5. package/dist/cli/config-command.js +46 -0
  6. package/dist/cli/doctor-command.js +36 -0
  7. package/dist/cli/focus-command.js +15 -0
  8. package/dist/cli/index.js +383 -0
  9. package/dist/cli/init.js +200 -0
  10. package/dist/cli/list-command.js +51 -0
  11. package/dist/cli/login-service.js +232 -0
  12. package/dist/cli/mcp/mcp-server.js +125 -0
  13. package/dist/cli/mcp/studio-client.js +201 -0
  14. package/dist/cli/mcp/studio-tools.js +610 -0
  15. package/dist/cli/open-browser.js +24 -0
  16. package/dist/cli/prompter.js +59 -0
  17. package/dist/cli/run-command.js +97 -0
  18. package/dist/cli/shutdown.js +14 -0
  19. package/dist/cli/studio-alias.js +56 -0
  20. package/dist/cli/studio-items.js +31 -0
  21. package/dist/cli/studio-service-control.js +56 -0
  22. package/dist/cli/uninstall.js +155 -0
  23. package/dist/core/api/contract.js +83 -0
  24. package/dist/core/config/studio-config.js +164 -0
  25. package/dist/core/doctor/doctor-check.js +8 -0
  26. package/dist/core/doctor/platform-commands.js +42 -0
  27. package/dist/core/ports/detected-port.js +64 -0
  28. package/dist/core/ports/name-template.js +114 -0
  29. package/dist/core/projects/custom-app.js +105 -0
  30. package/dist/core/projects/item-project.js +11 -0
  31. package/dist/core/projects/project-icon.js +20 -0
  32. package/dist/core/result.js +2 -0
  33. package/dist/core/routes/route-name.js +24 -0
  34. package/dist/core/routes/route.js +28 -0
  35. package/dist/core/terminals/output-buffer.js +56 -0
  36. package/dist/core/terminals/terminal.js +28 -0
  37. package/dist/core/views/arrange-routes.js +104 -0
  38. package/dist/core/views/view.js +165 -0
  39. package/dist/server/application/app-error.js +1 -0
  40. package/dist/server/application/auto-named-store.js +10 -0
  41. package/dist/server/application/auto-namer.js +56 -0
  42. package/dist/server/application/config-store.js +1 -0
  43. package/dist/server/application/doctor.js +311 -0
  44. package/dist/server/application/icon-resolver.js +67 -0
  45. package/dist/server/application/open-with.js +77 -0
  46. package/dist/server/application/port-discovery.js +72 -0
  47. package/dist/server/application/port-scanner.js +1 -0
  48. package/dist/server/application/process-control.js +1 -0
  49. package/dist/server/application/proxy-control.js +47 -0
  50. package/dist/server/application/route-registry.js +1 -0
  51. package/dist/server/application/route-use-cases.js +66 -0
  52. package/dist/server/application/stop-process.js +24 -0
  53. package/dist/server/application/studio-events.js +127 -0
  54. package/dist/server/application/studio-runtime.js +581 -0
  55. package/dist/server/application/studio-service.js +1 -0
  56. package/dist/server/application/system-notifier.js +2 -0
  57. package/dist/server/application/terminals.js +446 -0
  58. package/dist/server/infrastructure/apps/app-catalog.js +153 -0
  59. package/dist/server/infrastructure/apps/detached-launcher.js +62 -0
  60. package/dist/server/infrastructure/apps/json-stores.js +83 -0
  61. package/dist/server/infrastructure/config/atomic-write.js +41 -0
  62. package/dist/server/infrastructure/config/json-auto-named-store.js +40 -0
  63. package/dist/server/infrastructure/config/json-config-store.js +97 -0
  64. package/dist/server/infrastructure/config/proxy-start-record.js +54 -0
  65. package/dist/server/infrastructure/portless/portless-cli.js +110 -0
  66. package/dist/server/infrastructure/portless/portless-registry.js +154 -0
  67. package/dist/server/infrastructure/portless/state-files.js +130 -0
  68. package/dist/server/infrastructure/system/doctor-facts.js +213 -0
  69. package/dist/server/infrastructure/system/favicon-fetcher.js +145 -0
  70. package/dist/server/infrastructure/system/linux-ports.js +84 -0
  71. package/dist/server/infrastructure/system/macos-ports.js +76 -0
  72. package/dist/server/infrastructure/system/os-notifier.js +143 -0
  73. package/dist/server/infrastructure/system/powershell.js +20 -0
  74. package/dist/server/infrastructure/system/process-runner.js +191 -0
  75. package/dist/server/infrastructure/system/project-info.js +127 -0
  76. package/dist/server/infrastructure/system/system-io.js +38 -0
  77. package/dist/server/infrastructure/system/system-port-scanner.js +103 -0
  78. package/dist/server/infrastructure/system/system-process-control.js +129 -0
  79. package/dist/server/infrastructure/system/windows-ports.js +193 -0
  80. package/dist/server/infrastructure/terminal/terminal-spawner.js +177 -0
  81. package/dist/server/interface/http/focus-broker.js +98 -0
  82. package/dist/server/interface/http/http-server.js +390 -0
  83. package/dist/server/interface/http/json-io.js +83 -0
  84. package/dist/server/interface/http/request-guards.js +40 -0
  85. package/dist/server/interface/http/snapshot-events.js +83 -0
  86. package/dist/server/interface/http/static-files.js +140 -0
  87. package/dist/server/interface/http/terminal-streams.js +69 -0
  88. package/dist/server/main.js +221 -0
  89. package/dist/web/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  90. package/dist/web/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  91. package/dist/web/assets/addon-fit-DIOBYJe3.js +1 -0
  92. package/dist/web/assets/index-CA7HpYbk.css +1 -0
  93. package/dist/web/assets/index-CC6BquZf.js +15 -0
  94. package/dist/web/assets/xterm-B1njb2Cr.css +1 -0
  95. package/dist/web/assets/xterm-CerOhKYV.js +16 -0
  96. package/dist/web/index.html +19 -0
  97. package/dist/web/theme-init.js +6 -0
  98. package/package.json +67 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keven Bardales
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,278 @@
1
- # Temporary Holding Version
1
+ # portless studio
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local web dashboard for [portless](https://github.com/vercel-labs/portless): see every dev server
4
+ on your machine, give each one a name like `web.myapp.localhost`, and open it from one page.
5
+
6
+ <picture>
7
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/dashboard-dark.png">
8
+ <img alt="The Studio dashboard: detected ports without a name at the top, named routes grouped by project below, each with its branch, folder, port and uptime" src="docs/images/dashboard-light.png" width="1280">
9
+ </picture>
10
+
11
+ portless replaces port numbers with names: `portless api pnpm dev` serves your app at
12
+ `http://api.localhost:1355` instead of `http://localhost:3000`. It has no interface of its own, so
13
+ with ten dev servers across several projects you have to remember which names exist and which
14
+ ports have none. Studio shows both, live, and names a port in one click.
15
+
16
+ ![Naming port 8000 as notebook.ml, searching routes with Ctrl+K and running pnpm dev in a Studio terminal](docs/images/demo.gif)
17
+
18
+ The screenshots use the built-in sample data (`?demo`), not a real machine.
19
+
20
+ ## Quick start
21
+
22
+ Requires Node 24 or later.
23
+
24
+ ```bash
25
+ npx portless-studio init
26
+ ```
27
+
28
+ `init` asks a few questions and leaves everything working:
29
+
30
+ - Checks that portless is installed and offers to install it with `npm install -g portless`.
31
+ - Asks for the basic settings (auto mode, start at login, port, open the browser) and saves them.
32
+ - Finds the dev servers already running, suggests a name for each and names them in portless.
33
+ - Starts the portless proxy on port 1355 over HTTP, which needs no password, certificate or extra
34
+ tool on macOS, Windows or Linux, and can set Studio to start when you log in.
35
+
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.
40
+
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.
43
+
44
+ ## What it does
45
+
46
+ - **Live list** of portless routes: name, URL, port, app or alias, uptime and any Tailscale, ngrok
47
+ or Cloudflare tunnel. It updates as soon as a route appears or goes away.
48
+ - **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.
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.
52
+ - **Name a port** that portless did not start, such as a database in Docker (`db` gives
53
+ `db.localhost`). Rename or remove those names later.
54
+ - **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.
59
+ - **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).
61
+ - **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.
78
+ - **Keyboard first:** every action has a key, `?` lists them and `Ctrl+K` (`Cmd+K`) runs any of
79
+ them by name.
80
+
81
+ ## How naming works
82
+
83
+ A suggested name says what the work is, then the app, then the repository. The package
84
+ `@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.
90
+
91
+ Anything that names a route through the portless CLI also shows up in Studio:
92
+
93
+ ```bash
94
+ portless web.myapp pnpm dev # a dev server, at web.myapp.localhost
95
+ portless alias db.myapp 5432 # a port something else opened, at db.myapp.localhost
96
+ ```
97
+
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:
102
+
103
+ ```bash
104
+ claude mcp add --scope user portless-studio -- npx -y portless-studio mcp
105
+ ```
106
+
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.
110
+
111
+ ## Terminals
112
+
113
+ ```bash
114
+ portless-studio run -- pnpm dev
115
+ ```
116
+
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 |
215
+
216
+ ## Security
217
+
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`.
256
+
257
+ ## Development
258
+
259
+ ```bash
260
+ git clone https://github.com/keven-bardales/portless-studio
261
+ cd portless-studio
262
+ pnpm install
263
+ pnpm dev # API on :4949 and the web app with hot reload on :5173
264
+ 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
+ ```
270
+
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.
275
+
276
+ ## License
277
+
278
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,175 @@
1
+ import { parseArgs } from "node:util";
2
+ import { err, ok } from "../core/result.js";
3
+ import { parsePort } from "../core/routes/route-name.js";
4
+ export const DEFAULT_PORT = 4949;
5
+ export const HELP = `portless-studio: a local web dashboard for portless routes
6
+
7
+ Usage:
8
+ portless-studio [options] Start Studio
9
+ portless-studio init [--yes] Set everything up: portless, settings, names, proxy
10
+ portless-studio uninstall [--yes] Undo the setup: login service, names Studio gave, the
11
+ proxy it started; asks before deleting the settings
12
+ portless-studio service install Start Studio when you log in
13
+ portless-studio service uninstall Stop starting Studio when you log in
14
+ portless-studio service status Say whether the login service is installed and running
15
+ portless-studio config [--edit] Print the config file, or open it in $EDITOR
16
+ portless-studio doctor Check portless, the proxy and Studio, and say how to fix
17
+ what is wrong (exit code 1 when something fails)
18
+ portless-studio list [--json] List named routes and ports without a name, from the running
19
+ Studio (or read directly when it is not running)
20
+ portless-studio mcp Run an MCP server on stdio so AI agents can list, name and
21
+ stop dev servers through the running Studio (docs/mcp.md)
22
+ portless-studio run [--name <name>] [--cwd <dir>] -- <command...>
23
+ Run a command in a Studio terminal, name the port it
24
+ opens and print its output here until Ctrl+C, which stops
25
+ it (docs/terminals.md)
26
+ portless-studio open [target] Bring Studio to the front showing a route, project,
27
+ terminal id or port:<n>
28
+
29
+ Options:
30
+ --port <n> Port for the dashboard, on 127.0.0.1 (default from the config, ${DEFAULT_PORT};
31
+ 0 picks a free one)
32
+ --no-open Do not open the browser
33
+ --no-register Do not register the "studio" portless alias for the dashboard
34
+ -y, --yes With init and uninstall: accept every default without asking
35
+ --json With doctor, config and list: print JSON for scripts and agents
36
+ --name <name> With run: name for the port the command opens (default: the suggested one)
37
+ --cwd <dir> With run: folder to run in (default: the current folder)
38
+ -h, --help Show this help
39
+ -v, --version Show the version
40
+
41
+ Environment:
42
+ PORTLESS_STATE_DIR portless state directory (default ~/.portless)
43
+ PORTLESS_BIN portless binary used to create and remove aliases (default: portless)
44
+ EDITOR Editor that \`config --edit\` opens
45
+ PORTLESS_STUDIO_URL Studio that \`mcp\`, \`list\`, \`run\` and \`open\` talk to
46
+ (default http://127.0.0.1:<port>)
47
+ `;
48
+ const SUBCOMMANDS = new Set([
49
+ "init",
50
+ "uninstall",
51
+ "service",
52
+ "config",
53
+ "doctor",
54
+ "list",
55
+ "mcp",
56
+ "run",
57
+ "open",
58
+ ]);
59
+ const SERVICE_ACTIONS = new Set(["install", "uninstall", "status"]);
60
+ /** Turns command-line arguments into options, or a message saying what is wrong with them. */
61
+ export function parseCliArgs(argv) {
62
+ const first = argv[0];
63
+ const subcommand = first !== undefined && !first.startsWith("-") ? first : null;
64
+ if (subcommand !== null && !SUBCOMMANDS.has(subcommand)) {
65
+ return err(`Unknown command "${subcommand}".`);
66
+ }
67
+ const rest = subcommand === null ? argv : argv.slice(1);
68
+ let parsed;
69
+ try {
70
+ parsed = parseFlags(rest, subcommand);
71
+ }
72
+ catch (error) {
73
+ return err(error.message);
74
+ }
75
+ const { values, positionals } = parsed;
76
+ if (values.help)
77
+ return ok({ command: "help" });
78
+ if (values.version)
79
+ return ok({ command: "version" });
80
+ if (subcommand === "init")
81
+ return ok({ command: "init", yes: values.yes === true });
82
+ if (subcommand === "uninstall")
83
+ return ok({ command: "uninstall", yes: values.yes === true });
84
+ const json = values.json === true;
85
+ if (subcommand === "config") {
86
+ if (values.edit && json)
87
+ return err("Use either --edit or --json, not both.");
88
+ return ok({ command: "config", edit: values.edit === true, json });
89
+ }
90
+ if (subcommand === "doctor")
91
+ return ok({ command: "doctor", json });
92
+ if (subcommand === "list")
93
+ return ok({ command: "list", json });
94
+ if (subcommand === "mcp")
95
+ return ok({ command: "mcp" });
96
+ if (subcommand === "run") {
97
+ if (positionals.length === 0) {
98
+ return err('Give the command to run after "--", e.g. portless-studio run -- pnpm dev');
99
+ }
100
+ if (values.name !== undefined && values.name.trim() === "")
101
+ return err("--name needs a name.");
102
+ return ok({
103
+ command: "run",
104
+ cwd: values.cwd ?? null,
105
+ name: values.name?.trim() ?? null,
106
+ argv: positionals,
107
+ });
108
+ }
109
+ if (subcommand === "open") {
110
+ if (positionals.length > 1)
111
+ return err('Give one target to "open", e.g. web.myapp or t2.');
112
+ return ok({ command: "open", target: positionals[0] ?? null });
113
+ }
114
+ if (subcommand === "service") {
115
+ const action = positionals[0];
116
+ if (action === undefined || !SERVICE_ACTIONS.has(action) || positionals.length > 1) {
117
+ return err('Use "service install", "service uninstall" or "service status".');
118
+ }
119
+ return ok({ command: "service", action: action });
120
+ }
121
+ const overrides = {};
122
+ if (values.port !== undefined) {
123
+ const port = values.port.trim() === "0" ? ok(0) : parsePort(values.port);
124
+ if (!port.ok)
125
+ return err(`Invalid --port "${values.port}": use a number from 0 to 65535.`);
126
+ overrides.port = port.value;
127
+ }
128
+ if (values.open !== undefined)
129
+ overrides.open = values.open;
130
+ if (values.register !== undefined)
131
+ overrides.register = values.register;
132
+ return ok({ command: "start", overrides });
133
+ }
134
+ /** Settings for starting the server: the config, with any flag taking its place. */
135
+ export function resolveStartSettings(overrides, config) {
136
+ return {
137
+ port: overrides.port ?? config.port,
138
+ open: overrides.open ?? config.openBrowser,
139
+ register: overrides.register ?? config.registerStudioAlias,
140
+ };
141
+ }
142
+ /** Each subcommand accepts only its own flags, so a typo fails instead of being ignored. */
143
+ function parseFlags(args, subcommand) {
144
+ const common = {
145
+ help: { type: "boolean", short: "h" },
146
+ version: { type: "boolean", short: "v" },
147
+ };
148
+ const json = { type: "boolean" };
149
+ const options = subcommand === "init" || subcommand === "uninstall"
150
+ ? { ...common, yes: { type: "boolean", short: "y" } }
151
+ : subcommand === "config"
152
+ ? { ...common, edit: { type: "boolean" }, json }
153
+ : subcommand === "doctor" || subcommand === "list"
154
+ ? { ...common, json }
155
+ : subcommand === "service" || subcommand === "mcp" || subcommand === "open"
156
+ ? common
157
+ : subcommand === "run"
158
+ ? { ...common, name: { type: "string" }, cwd: { type: "string" } }
159
+ : {
160
+ ...common,
161
+ port: { type: "string" },
162
+ open: { type: "boolean" },
163
+ register: { type: "boolean" },
164
+ };
165
+ const { values, positionals } = parseArgs({
166
+ args,
167
+ options,
168
+ allowNegative: true,
169
+ allowPositionals: subcommand === "service" || subcommand === "run" || subcommand === "open",
170
+ });
171
+ return {
172
+ positionals,
173
+ values: values,
174
+ };
175
+ }
@@ -0,0 +1,10 @@
1
+ import { runProcess } from "../server/infrastructure/system/process-runner.js";
2
+ /** The server's process runner, so Windows quoting and UTF-8 output work the same in the CLI. */
3
+ export const runCommand = async (command, args, options = {}) => {
4
+ const outcome = await runProcess(command, args, { inherit: options.inherit === true });
5
+ return {
6
+ exitCode: outcome.exitCode,
7
+ output: outcome.error ?? outcome.combined.trim(),
8
+ missing: outcome.missing,
9
+ };
10
+ };
@@ -0,0 +1,46 @@
1
+ import * as fs from "node:fs";
2
+ /** `portless-studio config`: where the file is and what Studio reads from it. */
3
+ export async function printConfig(store, print) {
4
+ print(`Config file: ${store.path}`);
5
+ const read = await store.read();
6
+ if (!read.ok) {
7
+ print(`The file has a problem: ${read.error.message}`);
8
+ return;
9
+ }
10
+ print(JSON.stringify(read.value, null, 2));
11
+ if (!fs.existsSync(store.path))
12
+ print("(The file is not created yet; these are the defaults.)");
13
+ }
14
+ /** `portless-studio config --json`: the same `{ config, path }` shape `GET /api/config` answers. */
15
+ export async function printConfigJson(store, print) {
16
+ const read = await store.read();
17
+ if (!read.ok) {
18
+ print(JSON.stringify({ error: read.error.message, path: store.path }, null, 2));
19
+ return 1;
20
+ }
21
+ const response = { config: read.value, path: store.path };
22
+ print(JSON.stringify(response, null, 2));
23
+ return 0;
24
+ }
25
+ /**
26
+ * `portless-studio config --edit`: opens the file in `editor` (`$EDITOR`, which may carry flags
27
+ * such as `code -w`). The file is created with the current values first so there is something to
28
+ * edit. Without an editor it prints the path.
29
+ */
30
+ export async function editConfig(store, editor, run, print) {
31
+ if (!fs.existsSync(store.path)) {
32
+ const read = await store.read();
33
+ if (read.ok)
34
+ await store.write(read.value);
35
+ }
36
+ const [command, ...flags] = (editor ?? "").trim().split(/\s+/).filter(Boolean);
37
+ if (command === undefined) {
38
+ print(`Set $EDITOR to open it from here, or edit it directly: ${store.path}`);
39
+ return;
40
+ }
41
+ const result = await run(command, [...flags, store.path], { inherit: true });
42
+ if (result.exitCode !== 0)
43
+ print(`${command} did not open the file. Edit it at: ${store.path}`);
44
+ else
45
+ print("Saved changes reach a running Studio without a restart.");
46
+ }
@@ -0,0 +1,36 @@
1
+ import { overallStatus } from "../core/doctor/doctor-check.js";
2
+ const MARKS = { ok: "✓", warn: "!", fail: "✗" };
3
+ /** A readable checklist: one line per check, then what was found and the fix for each problem. */
4
+ export function formatDoctorReport(checks) {
5
+ const lines = [];
6
+ for (const check of checks) {
7
+ lines.push(` ${MARKS[check.status]} ${check.title}`);
8
+ if (check.status === "ok")
9
+ continue;
10
+ lines.push(` ${check.detail}`);
11
+ if (check.fix?.command)
12
+ lines.push(` Fix: ${check.fix.command}`);
13
+ else if (check.fix)
14
+ lines.push(` Fix: "${check.fix.label}" in Studio's Health panel`);
15
+ }
16
+ lines.push("", summary(checks));
17
+ return lines.join("\n");
18
+ }
19
+ /** Prints the report; the exit code is 1 when any check fails, so scripts can rely on it. */
20
+ export async function runDoctorCommand(check, print, options = {}) {
21
+ const checks = await check();
22
+ const status = overallStatus(checks);
23
+ print(options.json ? JSON.stringify({ status, checks }, null, 2) : formatDoctorReport(checks));
24
+ return status === "fail" ? 1 : 0;
25
+ }
26
+ function summary(checks) {
27
+ const failures = checks.filter((check) => check.status === "fail").length;
28
+ const warnings = checks.filter((check) => check.status === "warn").length;
29
+ if (failures === 0 && warnings === 0)
30
+ return "Everything looks good.";
31
+ const parts = [
32
+ failures > 0 ? `${failures} ${failures === 1 ? "problem" : "problems"}` : null,
33
+ warnings > 0 ? `${warnings} ${warnings === 1 ? "warning" : "warnings"}` : null,
34
+ ].filter(Boolean);
35
+ return `${parts.join(" and ")}.`;
36
+ }
@@ -0,0 +1,15 @@
1
+ import { API_PATHS } from "../core/api/contract.js";
2
+ import { describeFocus } from "./mcp/studio-tools.js";
3
+ /**
4
+ * `portless-studio open [target]`: brings the Studio page to the front showing a route, a
5
+ * project, a terminal or a port. Returns the exit code.
6
+ */
7
+ export async function runOpenCommand(client, target, print, printError) {
8
+ const result = await client.write("POST", API_PATHS.focus, target ? { target } : {});
9
+ if (!result.ok) {
10
+ printError(`Error: ${result.error.message}`);
11
+ return 1;
12
+ }
13
+ print(describeFocus(result.value));
14
+ return 0;
15
+ }