server-studio 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Md Mahdi Hasan
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 ADDED
@@ -0,0 +1,121 @@
1
+ # Server Studio
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.
6
+
7
+ Node.js is the only requirement. No npm install inside the app, no Electron, no background daemon.
8
+
9
+ ## Install
10
+
11
+ **macOS**
12
+
13
+ ```bash
14
+ npx server-studio install
15
+ ```
16
+
17
+ That copies the app to `/Applications` and installs the Claude Code skill. Then open it from
18
+ Applications, or:
19
+
20
+ ```bash
21
+ open -a "Server Studio"
22
+ ```
23
+
24
+ **Windows and Linux**
25
+
26
+ There is no app bundle to install, so run the dashboard directly:
27
+
28
+ ```bash
29
+ npx server-studio start
30
+ ```
31
+
32
+ It serves the same dashboard at `http://localhost:4587` and opens your browser. Add
33
+ `npx server-studio install --no-app` first if you also want the Claude Code skill.
34
+
35
+ To remove it again:
36
+
37
+ ```bash
38
+ npx server-studio uninstall
39
+ ```
40
+
41
+ Your saved servers are kept. Add `--purge` if you want those deleted too.
42
+
43
+ | Option | What it does |
44
+ |---|---|
45
+ | `--no-app` | Skip the Mac app, install only the skill |
46
+ | `--no-skill` | Skip the skill, install only the app |
47
+ | `--dry-run` | Print what would happen, change nothing |
48
+ | `--purge` | Uninstall only: also delete your saved server list |
49
+
50
+ ## What's in the box
51
+
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.
54
+
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.
57
+
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`.
60
+
61
+ ## Platform support
62
+
63
+ | | macOS | Windows | Linux |
64
+ |---|---|---|---|
65
+ | Dashboard | yes | yes | yes |
66
+ | Installable app | yes | run with `start` | run with `start` |
67
+ | Run in a terminal | Terminal | Command Prompt | first of x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm |
68
+ | Stop a port | `lsof` + `kill` | `netstat` + `taskkill` | `lsof` + `kill` |
69
+ | Browse for a folder | native dialog | native dialog | needs `zenity`, otherwise type the path |
70
+
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.
73
+
74
+ ## Your data
75
+
76
+ Saved servers live in one file:
77
+
78
+ | macOS | `~/Library/Application Support/Server Studio/data.json` |
79
+ |---|---|
80
+ | Windows | `%APPDATA%\Server Studio\data.json` |
81
+ | Linux | `~/.config/server-studio/data.json` |
82
+
83
+ It sits outside the app bundle, so installing, updating and uninstalling never touch it. Point
84
+ `SERVER_STUDIO_DATA_DIR` somewhere else to use a different file, which is handy for testing against
85
+ a throwaway list.
86
+
87
+ ## Security
88
+
89
+ The app runs shell commands you saved, so it is deliberately locked to your own machine:
90
+
91
+ - It binds to `127.0.0.1` only and is never reachable from the network.
92
+ - Every write requires `Content-Type: application/json`, which blocks form-based CSRF from any
93
+ site you happen to have open.
94
+ - Requests carrying a foreign `Origin` are rejected, and preflights are refused.
95
+
96
+ Treat the run command on a card the way you would treat a line you are about to paste into your own
97
+ terminal, because that is exactly what it becomes.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ npm run build:plugin # rebuild dist/server-studio.plugin from skill/
103
+ npm test # install into a temp folder and check the result
104
+ ```
105
+
106
+ `skill/` is the single source of truth for the skill. The plugin is built from it, so the two
107
+ cannot drift apart.
108
+
109
+ To run the app against a throwaway server list:
110
+
111
+ ```bash
112
+ SERVER_STUDIO_DATA_DIR=/tmp/ss-test node src/server.js
113
+ ```
114
+
115
+ `src/` holds the dashboard and the per-OS shims in `platform.js`, and is the only copy of that
116
+ code. On macOS the installer copies it into the app bundle, so the bundle never holds a stale
117
+ version.
118
+
119
+ ## License
120
+
121
+ MIT
@@ -0,0 +1,26 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleName</key>
6
+ <string>Server Studio</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>Server Studio</string>
9
+ <key>CFBundleIdentifier</key>
10
+ <string>com.mhasan.serverstudio</string>
11
+ <key>CFBundleVersion</key>
12
+ <string>1.0</string>
13
+ <key>CFBundleShortVersionString</key>
14
+ <string>1.0</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>CFBundleExecutable</key>
18
+ <string>ServerStudio</string>
19
+ <key>CFBundleIconFile</key>
20
+ <string>AppIcon</string>
21
+ <key>LSMinimumSystemVersion</key>
22
+ <string>10.13</string>
23
+ <key>NSHighResolutionCapable</key>
24
+ <true/>
25
+ </dict>
26
+ </plist>
@@ -0,0 +1,21 @@
1
+ #!/bin/bash
2
+ # Launcher for Server Studio.app
3
+ HERE="$(cd "$(dirname "$0")" && pwd)"
4
+ RES="$(cd "$HERE/../Resources" && pwd)"
5
+
6
+ # Make sure common Node install locations are on PATH
7
+ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
8
+
9
+ NODE="$(command -v node)"
10
+ if [ -z "$NODE" ]; then
11
+ for p in /opt/homebrew/bin/node /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node; do
12
+ [ -x "$p" ] && NODE="$p" && break
13
+ done
14
+ fi
15
+
16
+ if [ -z "$NODE" ]; then
17
+ osascript -e 'display dialog "Server Studio needs Node.js.\n\nInstall it from https://nodejs.org then open this app again." with title "Server Studio" buttons {"OK"} default button "OK" with icon caution'
18
+ exit 1
19
+ fi
20
+
21
+ exec "$NODE" "$RES/server.js"
@@ -0,0 +1 @@
1
+ APPL????
package/bin/cli.js ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // Server Studio installer. Node built-ins only, no dependencies.
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { execFileSync } = require('child_process');
9
+
10
+ const ROOT = path.join(__dirname, '..');
11
+ const APP_NAME = 'Server Studio.app';
12
+ const APP_SRC = path.join(ROOT, 'app', APP_NAME);
13
+ const SRC = path.join(ROOT, 'src');
14
+ const MAC = process.platform === 'darwin';
15
+ const APP_DEST = process.env.SERVER_STUDIO_APP_DEST || path.join('/Applications', APP_NAME);
16
+ const SKILL_SRC = path.join(ROOT, 'skill');
17
+ const SKILL_DEST = process.env.SERVER_STUDIO_SKILL_DEST ||
18
+ path.join(os.homedir(), '.claude', 'skills', 'server-studio');
19
+
20
+
21
+ const args = process.argv.slice(2);
22
+ let DATA_DIR;
23
+ const has = f => args.includes(f);
24
+ const cmd = has('-h') || has('--help') ? 'help' : (args.find(a => !a.startsWith('-')) || 'install');
25
+ const DRY = has('--dry-run');
26
+
27
+ function die(msg) { console.error('error: ' + msg); process.exit(1); }
28
+ function ok(msg) { console.log(' ok ' + msg); }
29
+ function skip(msg) { console.log(' -- ' + msg); }
30
+
31
+ function dataDir() {
32
+ if (process.env.SERVER_STUDIO_DATA_DIR) return process.env.SERVER_STUDIO_DATA_DIR;
33
+ if (MAC) return path.join(os.homedir(), 'Library', 'Application Support', 'Server Studio');
34
+ if (process.platform === 'win32') {
35
+ return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Server Studio');
36
+ }
37
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'server-studio');
38
+ }
39
+
40
+ function hasNode() {
41
+ try { execFileSync('/usr/bin/env', ['which', 'node'], { stdio: 'ignore' }); return true; }
42
+ catch (e) { return false; }
43
+ }
44
+
45
+ /* ---------- install ---------- */
46
+ function install() {
47
+ DATA_DIR = dataDir();
48
+ if (DRY) {
49
+ console.log('Dry run, nothing will be written.\n');
50
+ console.log(' app -> ' + APP_DEST);
51
+ console.log(' skill -> ' + SKILL_DEST);
52
+ console.log(' data -> ' + path.join(DATA_DIR, 'data.json') + ' (created only if missing)');
53
+ return;
54
+ }
55
+ console.log('Installing Server Studio...\n');
56
+
57
+ if (!MAC) {
58
+ skip('Mac app skipped, this is ' + process.platform + '. Use: npx server-studio start');
59
+ } else if (!has('--no-app')) {
60
+ if (fs.existsSync(APP_DEST)) {
61
+ fs.rmSync(APP_DEST, { recursive: true, force: true });
62
+ fs.cpSync(APP_SRC, APP_DEST, { recursive: true });
63
+ ok('app updated at ' + APP_DEST);
64
+ } else {
65
+ fs.cpSync(APP_SRC, APP_DEST, { recursive: true });
66
+ ok('app installed at ' + APP_DEST);
67
+ }
68
+ // cpSync does not preserve the exec bit on the launcher.
69
+ // src/ is the single source of truth, copied into the bundle at install time.
70
+ fs.cpSync(SRC, path.join(APP_DEST, 'Contents', 'Resources'), { recursive: true });
71
+ fs.chmodSync(path.join(APP_DEST, 'Contents', 'MacOS', 'ServerStudio'), 0o755);
72
+ } else skip('app skipped (--no-app)');
73
+
74
+ if (!has('--no-skill')) {
75
+ fs.mkdirSync(path.dirname(SKILL_DEST), { recursive: true });
76
+ fs.rmSync(SKILL_DEST, { recursive: true, force: true });
77
+ fs.cpSync(SKILL_SRC, SKILL_DEST, { recursive: true });
78
+ ok('Claude Code skill at ' + SKILL_DEST);
79
+ } else skip('skill skipped (--no-skill)');
80
+
81
+ // Your saved servers live outside the app, so installing never touches them.
82
+ fs.mkdirSync(DATA_DIR, { recursive: true });
83
+ const dataFile = path.join(DATA_DIR, 'data.json');
84
+ if (!fs.existsSync(dataFile)) {
85
+ fs.writeFileSync(dataFile, '[]');
86
+ ok('created empty server list at ' + dataFile);
87
+ } else {
88
+ ok('kept existing server list (' + JSON.parse(fs.readFileSync(dataFile, 'utf8') || '[]').length + ' servers)');
89
+ }
90
+
91
+ if (!hasNode()) {
92
+ console.log('\nnote: Node.js was not found on PATH. The app needs it to run.');
93
+ }
94
+
95
+ // npx wipes its cache after running, so the plugin is copied somewhere durable
96
+ // before its path is printed.
97
+ const pluginSrc = path.join(ROOT, 'dist', 'server-studio.plugin');
98
+ let pluginFile = null;
99
+ if (fs.existsSync(pluginSrc)) {
100
+ pluginFile = path.join(DATA_DIR, 'server-studio.plugin');
101
+ try { fs.copyFileSync(pluginSrc, pluginFile); }
102
+ catch (e) { pluginFile = null; }
103
+ }
104
+
105
+ console.log(MAC
106
+ ? '\nDone. Open it from Applications, or run: open -a "Server Studio"'
107
+ : '\nDone. Start it with: npx server-studio start');
108
+ if (pluginFile) {
109
+ console.log('\nUsing Claude Cowork? Install the plugin by opening this file:');
110
+ console.log(' ' + pluginFile);
111
+ }
112
+ }
113
+
114
+ /* ---------- uninstall ---------- */
115
+ function uninstall() {
116
+ DATA_DIR = dataDir();
117
+ if (DRY) {
118
+ console.log('Dry run, nothing will be removed.\n');
119
+ console.log(' would remove ' + APP_DEST);
120
+ console.log(' would remove ' + SKILL_DEST);
121
+ console.log(' would keep ' + DATA_DIR + (has('--purge') ? ' (--purge: would DELETE)' : ''));
122
+ return;
123
+ }
124
+ console.log('Removing Server Studio...\n');
125
+
126
+ if (MAC && fs.existsSync(APP_DEST)) { fs.rmSync(APP_DEST, { recursive: true, force: true }); ok('removed ' + APP_DEST); }
127
+ else if (MAC) skip('no app at ' + APP_DEST);
128
+
129
+ if (fs.existsSync(SKILL_DEST)) { fs.rmSync(SKILL_DEST, { recursive: true, force: true }); ok('removed ' + SKILL_DEST); }
130
+ else skip('no skill at ' + SKILL_DEST);
131
+
132
+ // The server list is the user's own data, so it survives unless asked for explicitly.
133
+ if (has('--purge')) {
134
+ if (fs.existsSync(DATA_DIR)) { fs.rmSync(DATA_DIR, { recursive: true, force: true }); ok('deleted saved servers at ' + DATA_DIR); }
135
+ else skip('no saved servers to delete');
136
+ } else if (fs.existsSync(DATA_DIR)) {
137
+ console.log('\nYour saved servers were kept at:\n ' + DATA_DIR);
138
+ console.log('Delete them too with: npx server-studio uninstall --purge');
139
+ }
140
+ }
141
+
142
+ function start() {
143
+ DATA_DIR = dataDir();
144
+ const { spawn } = require('child_process');
145
+ const server = path.join(SRC, 'server.js');
146
+ const child = spawn(process.execPath, [server], { stdio: 'inherit' });
147
+ child.on('exit', code => process.exit(code || 0));
148
+ process.on('SIGINT', () => { child.kill('SIGINT'); });
149
+ }
150
+
151
+ function help() {
152
+ console.log(`Server Studio
153
+
154
+ npx server-studio install install the app + Claude Code skill
155
+ npx server-studio start run the dashboard (works on macOS, Windows and Linux)
156
+ npx server-studio uninstall remove them (keeps your saved servers)
157
+
158
+ Options
159
+ --no-app do not install the Mac app (ignored off macOS)
160
+ --no-skill do not install the Claude Code skill
161
+ --purge uninstall only: also delete your saved server list
162
+ --dry-run print what would happen, change nothing
163
+ `);
164
+ }
165
+
166
+ if (cmd === 'help') help();
167
+ else if (cmd === 'install') install();
168
+ else if (cmd === 'start') start();
169
+ else if (cmd === 'uninstall') uninstall();
170
+ else { console.error('unknown command: ' + cmd + '\n'); help(); process.exit(1); }
Binary file
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "server-studio",
3
+ "version": "1.0.0",
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
+ "author": "Md Mahdi Hasan",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/mehdi214designer/server-studio#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mehdi214designer/server-studio.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/mehdi214designer/server-studio/issues"
14
+ },
15
+ "bin": {
16
+ "server-studio": "bin/cli.js"
17
+ },
18
+ "scripts": {
19
+ "build:plugin": "node scripts/build-plugin.js",
20
+ "prepack": "node scripts/build-plugin.js",
21
+ "test": "node scripts/test.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "app",
26
+ "src",
27
+ "skill",
28
+ "plugin",
29
+ "scripts",
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "keywords": [
35
+ "localhost",
36
+ "dev-server",
37
+ "ports",
38
+ "dashboard",
39
+ "macos",
40
+ "windows",
41
+ "linux",
42
+ "claude-code",
43
+ "claude-skill"
44
+ ],
45
+ "engines": {
46
+ "node": ">=18"
47
+ }
48
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "server-studio",
3
+ "version": "1.0.0",
4
+ "description": "Save local dev servers into the Server Studio app, with one permanent unique port per project.",
5
+ "keywords": ["server", "localhost", "dev server", "ports"]
6
+ }
@@ -0,0 +1,7 @@
1
+ # Server Studio (Cowork plugin)
2
+
3
+ Adds the `/server-studio` skill, which saves a project's local dev server into the
4
+ Server Studio app so it is one click to run later.
5
+
6
+ The skill source lives in `skill/` at the repo root. This plugin is built from it by
7
+ `npm run build:plugin`, so the two can never drift apart.
@@ -0,0 +1,28 @@
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));
@@ -0,0 +1,125 @@
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
+ })();