server-studio 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,47 @@
1
1
  # Server Studio
2
2
 
3
- A small dashboard for the local dev servers you actually run. Every project gets a card with
4
- its run command, folder and URL, so starting one is a click instead of a hunt through terminal
5
- history. It also keeps one permanent port per project, so a saved entry never goes stale.
3
+ [![test](https://github.com/mehdi214designer/server-studio/actions/workflows/test.yml/badge.svg)](https://github.com/mehdi214designer/server-studio/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/server-studio)](https://www.npmjs.com/package/server-studio)
5
+
6
+ A small dashboard for the local dev servers you actually run.
7
+
8
+ If you work on more than three or four projects, you know the routine. You want to show someone the
9
+ staging build, and first you have to remember which folder it lives in, then whether it starts with
10
+ `npm run dev` or `yarn start` or `php -S`, then which port it landed on last time, then why that port
11
+ is now busy because something else grabbed it a week ago.
12
+
13
+ Server Studio gives every project a card holding its folder, its run command and its URL. Click
14
+ **Run** and it opens a real terminal window in the right directory with the right command. Click the
15
+ URL and the browser opens. The card also shows a live dot for whether that port is currently up, so
16
+ you can see what is running without checking.
17
+
18
+ The other half is the port rule: each project keeps one permanent port that nothing else is allowed
19
+ to take. A saved card is worthless if the port drifts, so the app hands out a unique one and you bake
20
+ it into the project config. Six months later the card still works.
6
21
 
7
22
  Node.js is the only requirement. No npm install inside the app, no Electron, no background daemon.
8
23
 
24
+ ![Server Studio](https://raw.githubusercontent.com/mehdi214designer/server-studio/main/docs/screenshot.png)
25
+
9
26
  ## Install
10
27
 
11
- **macOS**
28
+ Installing the package only puts the `server-studio` command on your PATH. Setting up the app and
29
+ the skill is a second, explicit step, because it writes outside the package. Either of these works:
12
30
 
13
31
  ```bash
14
32
  npx server-studio install
15
33
  ```
16
34
 
17
- That copies the app to `/Applications` and installs the Claude Code skill. Then open it from
35
+ ```bash
36
+ npm install -g server-studio && server-studio install
37
+ ```
38
+
39
+ Running `server-studio` with no arguments does the same thing as `server-studio install`, so if you
40
+ installed globally and typed the bare command, you are already set up.
41
+
42
+ **macOS**
43
+
44
+ `install` copies the app to `/Applications` and installs the Claude Code skill. Then open it from
18
45
  Applications, or:
19
46
 
20
47
  ```bash
@@ -29,7 +56,7 @@ There is no app bundle to install, so run the dashboard directly:
29
56
  npx server-studio start
30
57
  ```
31
58
 
32
- It serves the same dashboard at `http://localhost:4587` and opens your browser. Add
59
+ It serves the same dashboard at `http://localhost:4587` and opens your browser. Run
33
60
  `npx server-studio install --no-app` first if you also want the Claude Code skill.
34
61
 
35
62
  To remove it again:
@@ -47,16 +74,102 @@ Your saved servers are kept. Add `--purge` if you want those deleted too.
47
74
  | `--dry-run` | Print what would happen, change nothing |
48
75
  | `--purge` | Uninstall only: also delete your saved server list |
49
76
 
77
+ ## Using it
78
+
79
+ ### Add your first server
80
+
81
+ Click **Add server**. Only the name matters, everything else can be filled in later.
82
+
83
+ | Field | What it does | Example |
84
+ |---|---|---|
85
+ | Name | Card title, and what you search by | `Acme Dashboard` |
86
+ | Project | A line of context for future you | `Client analytics UI` |
87
+ | Category | Groups cards into filter tabs along the top | `Web app` |
88
+ | Project folder | Where the run command executes. **Browse** opens a folder picker | `~/Sites/acme` |
89
+ | Run command | Exactly what you would type to start it | `npm run dev` |
90
+ | URL / Address | Where it serves. A bare port works and becomes `localhost:PORT` | `5181` |
91
+ | Tag | Small label on the card, usually the stack | `Next.js` |
92
+ | Notes | Anything you will forget | `Seed data resets on restart` |
93
+
94
+ ### Run, stop, open
95
+
96
+ Each card has five controls:
97
+
98
+ | Control | What happens |
99
+ |---|---|
100
+ | **Run** | Opens a terminal window, `cd`s into the folder, runs the command. Real terminal, so you see the output and can Ctrl-C it |
101
+ | Open in browser | Opens the card's URL |
102
+ | Stop | Kills whatever process is holding that port. Useful when something is stuck |
103
+ | Edit | Change any field |
104
+ | Delete | Removes the card. Does not touch the project itself |
105
+
106
+ The icon inside the URL box copies the address to your clipboard.
107
+
108
+ The dot next to the URL is live status: green means that port is currently accepting connections,
109
+ red means nothing is there. It re-checks every 12 seconds, and **Refresh status** forces it.
110
+
111
+ Click the star to pin a card to the top. The search box matches name, project, URL, command, tag,
112
+ category and notes at once, so searching `wordpress` or `5181` or `vite` all find the right card.
113
+
114
+ ### The one permanent port rule
115
+
116
+ This is the part that makes saved cards stay useful, and it needs one step from you.
117
+
118
+ When you add a project, give it a port nothing else uses, then **force the dev server to always use
119
+ that exact port**. Most tools will silently pick a different one if the port is busy, which is what
120
+ makes a saved entry go stale. Use the strict option so it fails loudly instead:
121
+
122
+ | Tool | How to pin the port |
123
+ |---|---|
124
+ | Vite | `server: { port: 5181, strictPort: true }` in `vite.config`, or `vite --port 5181 --strictPort` |
125
+ | Next.js | `next dev -p 5181` |
126
+ | Astro | `astro dev --port 5181` |
127
+ | create-react-app | `PORT=5181 react-scripts start` |
128
+ | Express / plain Node | `process.env.PORT || 5181` |
129
+ | PHP | `php -S localhost:5181` |
130
+
131
+ Then put that same port in both the run command and the URL on the card.
132
+
133
+ ### Let Claude do it for you
134
+
135
+ If you installed the skill, you never have to add cards by hand. Ask Claude Code to build something
136
+ with a dev server and it registers the project itself, picks a port no other project is using, and
137
+ writes that port into the config with the strict flag set.
138
+
139
+ You can also just ask:
140
+
141
+ > save this server to Server Studio
142
+
143
+ To do it manually, or from a script:
144
+
145
+ ```bash
146
+ node ~/.claude/skills/server-studio/register-server.js \
147
+ --name "Acme Dashboard" \
148
+ --cwd "$PWD" \
149
+ --command "npm run dev" \
150
+ --assign
151
+ ```
152
+
153
+ `--assign` picks a free port and prints it, so you know what to write into the config. Re-running it
154
+ for a project that already exists updates the other fields but keeps the port locked, which is the
155
+ whole point.
156
+
157
+ ### Backups
158
+
159
+ **Export** saves your whole list to `server-studio-backup.json`. **Import** loads one back.
160
+
161
+ Note that Import **replaces** your entire list rather than merging into it, so export first if you
162
+ have anything you care about.
163
+
50
164
  ## What's in the box
51
165
 
52
- **The app.** A local dashboard at `127.0.0.1:4587`. Run a server (it opens in a real terminal
53
- window), stop it, open its URL, reveal its folder, and see at a glance which ports are live.
166
+ **The app**, a dashboard served at `127.0.0.1:4587`.
54
167
 
55
- **The Claude Code skill.** Ask Claude to build you something with a dev server and it registers the
56
- server for you, picking a free port and baking it into the project config so the port never drifts.
168
+ **The Claude Code skill**, so Claude can register servers for you.
57
169
 
58
- **The Cowork plugin.** The same skill as a `/server-studio` command. Build it with
59
- `npm run build:plugin`, then open `dist/server-studio.plugin`.
170
+ **The Cowork plugin**, the same skill as a `/server-studio` command. The installer copies it next to
171
+ your data file and prints the path, then you open that file to install it. To rebuild it from source,
172
+ run `npm run build:plugin`.
60
173
 
61
174
  ## Platform support
62
175
 
@@ -68,8 +181,16 @@ server for you, picking a free port and baking it into the project config so the
68
181
  | Stop a port | `lsof` + `kill` | `netstat` + `taskkill` | `lsof` + `kill` |
69
182
  | Browse for a folder | native dialog | native dialog | needs `zenity`, otherwise type the path |
70
183
 
71
- Windows and Linux support is written but has only been exercised on macOS, so treat the first run
72
- on those platforms as unproven. Bug reports welcome.
184
+ The suite runs on macOS, Linux and Windows in CI, on Node 18 and 22. That covers the installer, the
185
+ data paths, the dashboard, the security checks and the plugin build on all three.
186
+
187
+ Opening a terminal window is tested for real too, not just in theory: CI calls it, then checks the
188
+ command actually ran in the right folder. Linux gets a desktop from Xvfb with xterm, Windows runs it
189
+ directly, and macOS is verified by hand since its runners have no scriptable Terminal.
190
+
191
+ The one thing left uncovered is the native folder picker, because a modal dialog cannot be
192
+ automated. Its fallback is tested, so a machine with no dialog available reports cancelled rather
193
+ than hanging, but the dialog itself has only been opened by hand on macOS.
73
194
 
74
195
  ## Your data
75
196
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "server-studio",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "A tiny dashboard for your local dev servers. One click to run, stop and open them, with one permanent port per project.",
5
5
  "author": "Md Mahdi Hasan",
6
6
  "license": "MIT",
@@ -18,15 +18,17 @@
18
18
  "scripts": {
19
19
  "build:plugin": "node scripts/build-plugin.js",
20
20
  "prepack": "node scripts/build-plugin.js",
21
- "test": "node scripts/test.js"
21
+ "test": "node scripts/test.js",
22
+ "test:terminal": "node scripts/test-terminal.js",
23
+ "postinstall": "node scripts/postinstall.js"
22
24
  },
23
25
  "files": [
24
26
  "bin",
27
+ "scripts/postinstall.js",
25
28
  "app",
26
29
  "src",
27
30
  "skill",
28
31
  "plugin",
29
- "scripts",
30
32
  "dist",
31
33
  "README.md",
32
34
  "LICENSE"
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // npm only puts the command on PATH. Setting up the app and the skill writes outside
3
+ // the package, so it stays an explicit step rather than a silent side effect of install.
4
+ // This just makes sure nobody is left wondering why nothing appeared.
5
+ 'use strict';
6
+
7
+ const path = require('path');
8
+
9
+ // Quiet in CI, and quiet while developing this repo itself.
10
+ if (process.env.CI) process.exit(0);
11
+ if (process.env.INIT_CWD && path.resolve(process.env.INIT_CWD) === path.resolve(__dirname, '..')) {
12
+ process.exit(0);
13
+ }
14
+ // npx runs the command straight after installing, so the hint would be noise.
15
+ if ((process.env.npm_command === 'exec') || /_npx/.test(__dirname)) process.exit(0);
16
+
17
+ console.log(`
18
+ Server Studio: the command is installed, but nothing is set up yet.
19
+
20
+ Finish with:
21
+ server-studio install
22
+
23
+ That adds the app and the Claude Code skill. See what it will do first with:
24
+ server-studio install --dry-run
25
+ `);
package/src/platform.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Per-OS shims. Everything the dashboard does that is not plain Node lives here.
2
2
  'use strict';
3
3
 
4
+ const fs = require('fs');
4
5
  const os = require('os');
5
6
  const path = require('path');
6
7
  const { execFile, exec } = require('child_process');
@@ -46,9 +47,21 @@ function runInTerminal(cwd, command, cb) {
46
47
  ], cb);
47
48
  }
48
49
  if (WIN) {
49
- // /k keeps the window open so the dev server keeps running and its output stays readable.
50
- const line = (cwd ? 'cd /d "' + cwd + '" && ' : '') + command;
51
- return execFile('cmd', ['/c', 'start', '', 'cmd', '/k', line], cb);
50
+ // The command goes into a .bat rather than onto cmd's command line. Node escapes
51
+ // embedded quotes as \" which cmd.exe does not accept as an escape, so passing a
52
+ // quoted path through `start cmd /k "..."` mangles it. A file has no such problem.
53
+ let bat;
54
+ try {
55
+ bat = path.join(os.tmpdir(), 'server-studio-' + Date.now() + '-' + process.pid + '.bat');
56
+ fs.writeFileSync(bat, [
57
+ '@echo off',
58
+ cwd ? 'cd /d "' + cwd + '"' : '',
59
+ command,
60
+ ].filter(Boolean).join('\r\n') + '\r\n');
61
+ } catch (e) { return cb(e); }
62
+ // The empty string is start's window-title argument, and /k keeps the window open
63
+ // so a dev server keeps running and its output stays readable.
64
+ return execFile('cmd', ['/c', 'start', '', 'cmd', '/k', bat], cb);
52
65
  }
53
66
  // Linux has no standard terminal, so try the common ones in turn.
54
67
  const line = (cwd ? 'cd ' + shQuote(cwd) + ' && ' : '') + command + '; exec $SHELL';
@@ -1,28 +0,0 @@
1
- #!/usr/bin/env node
2
- // Builds dist/server-studio.plugin from skill/ + plugin/, so the plugin copy of the
3
- // skill can never drift from the source in skill/.
4
- 'use strict';
5
-
6
- const fs = require('fs');
7
- const os = require('os');
8
- const path = require('path');
9
- const { execFileSync } = require('child_process');
10
-
11
- const ROOT = path.join(__dirname, '..');
12
- const DIST = path.join(ROOT, 'dist');
13
- const OUT = path.join(DIST, 'server-studio.plugin');
14
-
15
- const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'ss-plugin-'));
16
- const skillDir = path.join(stage, 'skills', 'server-studio');
17
- fs.mkdirSync(skillDir, { recursive: true });
18
-
19
- fs.cpSync(path.join(ROOT, 'skill'), skillDir, { recursive: true });
20
- fs.cpSync(path.join(ROOT, 'plugin'), stage, { recursive: true });
21
-
22
- fs.mkdirSync(DIST, { recursive: true });
23
- fs.rmSync(OUT, { force: true });
24
- // -r zips the staged tree; the plugin format is a plain zip.
25
- execFileSync('zip', ['-qr', OUT, '.', '-x', '.DS_Store'], { cwd: stage });
26
- fs.rmSync(stage, { recursive: true, force: true });
27
-
28
- console.log('built ' + path.relative(ROOT, OUT));
package/scripts/test.js DELETED
@@ -1,125 +0,0 @@
1
- #!/usr/bin/env node
2
- // End to end check: installs into a temp folder and attacks a temp server.
3
- // Never touches /Applications, ~/.claude or your real server list.
4
- 'use strict';
5
-
6
- const fs = require('fs');
7
- const os = require('os');
8
- const path = require('path');
9
- const http = require('http');
10
- const { execFileSync, spawn } = require('child_process');
11
-
12
- const ROOT = path.join(__dirname, '..');
13
- const SB = fs.mkdtempSync(path.join(os.tmpdir(), 'ss-test-'));
14
- const PORT = 4400 + Math.floor(process.pid % 300);
15
- const env = {
16
- ...process.env,
17
- SERVER_STUDIO_APP_DEST: path.join(SB, 'Server Studio.app'),
18
- SERVER_STUDIO_SKILL_DEST: path.join(SB, 'skill'),
19
- SERVER_STUDIO_DATA_DIR: path.join(SB, 'data'),
20
- };
21
-
22
- let failed = 0;
23
- function check(name, pass, detail) {
24
- console.log((pass ? ' pass ' : ' FAIL ') + name + (detail && !pass ? ' -> ' + detail : ''));
25
- if (!pass) failed++;
26
- }
27
-
28
- function req(opts, body) {
29
- return new Promise(resolve => {
30
- const r = http.request({ host: '127.0.0.1', port: PORT, ...opts }, res => {
31
- let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ code: res.statusCode, body: b }));
32
- });
33
- r.on('error', e => resolve({ code: 0, body: String(e) }));
34
- if (body) r.write(body);
35
- r.end();
36
- });
37
- }
38
-
39
- (async () => {
40
- console.log('sandbox: ' + SB + '\n');
41
-
42
- // ---- installer ----
43
- execFileSync('node', [path.join(ROOT, 'bin', 'cli.js'), 'install'], { env, stdio: 'ignore' });
44
- check('app installed', fs.existsSync(path.join(env.SERVER_STUDIO_APP_DEST, 'Contents', 'Resources', 'server.js')));
45
- check('shared src copied into bundle', fs.existsSync(path.join(env.SERVER_STUDIO_APP_DEST, 'Contents', 'Resources', 'platform.js')));
46
- check('launcher is executable', (fs.statSync(path.join(env.SERVER_STUDIO_APP_DEST, 'Contents', 'MacOS', 'ServerStudio')).mode & 0o111) !== 0);
47
- check('skill installed', fs.existsSync(path.join(env.SERVER_STUDIO_SKILL_DEST, 'SKILL.md')));
48
- check('empty data file created', fs.readFileSync(path.join(env.SERVER_STUDIO_DATA_DIR, 'data.json'), 'utf8').trim() === '[]');
49
- // The path printed for the Cowork plugin must survive npx clearing its cache.
50
- check('plugin copied out of the package', fs.existsSync(path.join(env.SERVER_STUDIO_DATA_DIR, 'server-studio.plugin')));
51
-
52
- // Existing data must survive a reinstall.
53
- fs.writeFileSync(path.join(env.SERVER_STUDIO_DATA_DIR, 'data.json'), '[{"id":"keep","name":"keep me"}]');
54
- execFileSync('node', [path.join(ROOT, 'bin', 'cli.js'), 'install'], { env, stdio: 'ignore' });
55
- check('reinstall keeps existing servers',
56
- JSON.parse(fs.readFileSync(path.join(env.SERVER_STUDIO_DATA_DIR, 'data.json'), 'utf8'))[0].id === 'keep');
57
-
58
- // ---- running server ----
59
- const srv = spawn('node', [path.join(env.SERVER_STUDIO_APP_DEST, 'Contents', 'Resources', 'server.js')],
60
- { env: { ...env, PORT: String(PORT) }, stdio: ['ignore', 'pipe', 'pipe'] });
61
- const up = await new Promise(res => {
62
- const t = setTimeout(() => res(false), 5000);
63
- srv.stdout.on('data', d => { if (String(d).includes('running on')) { clearTimeout(t); res(true); } });
64
- });
65
- check('server started on its own port', up);
66
-
67
- if (up) {
68
- const marker = path.join(SB, 'pwned.txt');
69
- const payload = JSON.stringify({ command: 'touch ' + marker });
70
-
71
- const a1 = await req({ method: 'POST', path: '/api/run', headers: { 'Content-Type': 'text/plain' } }, payload);
72
- check('text/plain CSRF rejected', a1.code === 415, 'got ' + a1.code);
73
-
74
- const a2 = await req({ method: 'POST', path: '/api/run',
75
- headers: { 'Content-Type': 'application/json', Origin: 'https://evil.example' } }, payload);
76
- check('foreign Origin rejected', a2.code === 403, 'got ' + a2.code);
77
-
78
- const a3 = await req({ method: 'OPTIONS', path: '/api/run', headers: { Origin: 'https://evil.example' } });
79
- check('preflight refused', a3.code === 403 || a3.code === 405, 'got ' + a3.code);
80
-
81
- const good = await req({ method: 'POST', path: '/api/servers',
82
- headers: { 'Content-Type': 'application/json', Origin: 'http://localhost:' + PORT } }, '[]');
83
- check('same-origin write accepted', good.code === 200, 'got ' + good.code);
84
-
85
- await new Promise(r => setTimeout(r, 300));
86
- check('no attack executed a command', !fs.existsSync(marker));
87
- }
88
- srv.kill();
89
-
90
- // ---- uninstall ----
91
- execFileSync('node', [path.join(ROOT, 'bin', 'cli.js'), 'uninstall'], { env, stdio: 'ignore' });
92
- check('app removed', !fs.existsSync(env.SERVER_STUDIO_APP_DEST));
93
- check('skill removed', !fs.existsSync(env.SERVER_STUDIO_SKILL_DEST));
94
- check('saved servers kept without --purge', fs.existsSync(path.join(env.SERVER_STUDIO_DATA_DIR, 'data.json')));
95
-
96
- execFileSync('node', [path.join(ROOT, 'bin', 'cli.js'), 'uninstall', '--purge'], { env, stdio: 'ignore' });
97
- check('--purge deletes saved servers', !fs.existsSync(env.SERVER_STUDIO_DATA_DIR));
98
-
99
- // ---- platform shims ----
100
- // The real Windows and Linux behaviour cannot run here, so this only proves the
101
- // module picks the right command and data path for each OS.
102
- function loadPlatformAs(name) {
103
- const real = process.platform;
104
- Object.defineProperty(process, 'platform', { value: name, configurable: true });
105
- delete require.cache[require.resolve(path.join(ROOT, 'src', 'platform.js'))];
106
- const m = require(path.join(ROOT, 'src', 'platform.js'));
107
- Object.defineProperty(process, 'platform', { value: real, configurable: true });
108
- return m;
109
- }
110
- const saved = process.env.SERVER_STUDIO_DATA_DIR;
111
- delete process.env.SERVER_STUDIO_DATA_DIR;
112
- const win = loadPlatformAs('win32');
113
- const lin = loadPlatformAs('linux');
114
- const mac = loadPlatformAs('darwin');
115
- check('windows data dir uses AppData', /AppData|Roaming|Server Studio/.test(win.dataDir()) && !win.dataDir().includes('Library'));
116
- check('linux data dir uses .config', lin.dataDir().includes('.config'));
117
- check('mac data dir uses Application Support', mac.dataDir().includes('Application Support'));
118
- check('windows terminal is named Command Prompt', win.terminalName() === 'Command Prompt');
119
- check('mac terminal is named Terminal', mac.terminalName() === 'Terminal');
120
- if (saved) process.env.SERVER_STUDIO_DATA_DIR = saved;
121
-
122
- fs.rmSync(SB, { recursive: true, force: true });
123
- console.log('\n' + (failed ? failed + ' failed' : 'all passed'));
124
- process.exit(failed ? 1 : 0);
125
- })();