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 +21 -0
- package/README.md +121 -0
- package/app/Server Studio.app/Contents/Info.plist +26 -0
- package/app/Server Studio.app/Contents/MacOS/ServerStudio +21 -0
- package/app/Server Studio.app/Contents/PkgInfo +1 -0
- package/app/Server Studio.app/Contents/Resources/AppIcon.icns +0 -0
- package/bin/cli.js +170 -0
- package/dist/server-studio.plugin +0 -0
- package/package.json +48 -0
- package/plugin/.claude-plugin/plugin.json +6 -0
- package/plugin/README.md +7 -0
- package/scripts/build-plugin.js +28 -0
- package/scripts/test.js +125 -0
- package/skill/SKILL.md +86 -0
- package/skill/register-server.js +126 -0
- package/src/index.html +549 -0
- package/src/platform.js +114 -0
- package/src/server.js +188 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Server Studio - tiny local backend (Node built-ins only, no npm install needed)
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const net = require('net');
|
|
10
|
+
|
|
11
|
+
const plat = require('./platform');
|
|
12
|
+
|
|
13
|
+
const PORT = process.env.PORT || 4587;
|
|
14
|
+
const RES = __dirname;
|
|
15
|
+
const DATA_DIR = plat.dataDir();
|
|
16
|
+
const DATA_FILE = path.join(DATA_DIR, 'data.json');
|
|
17
|
+
|
|
18
|
+
/* ---------- storage ---------- */
|
|
19
|
+
function ensureStore() {
|
|
20
|
+
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (e) {}
|
|
21
|
+
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, '[]');
|
|
22
|
+
}
|
|
23
|
+
function readData() {
|
|
24
|
+
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')) || []; }
|
|
25
|
+
catch (e) { return []; }
|
|
26
|
+
}
|
|
27
|
+
function writeData(d) {
|
|
28
|
+
fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* ---------- helpers ---------- */
|
|
32
|
+
function send(res, code, obj) {
|
|
33
|
+
const body = JSON.stringify(obj);
|
|
34
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
35
|
+
res.end(body);
|
|
36
|
+
}
|
|
37
|
+
function body(req) {
|
|
38
|
+
return new Promise(resolve => {
|
|
39
|
+
// Only real JSON requests are accepted. A cross-site HTML form can only send
|
|
40
|
+
// text/plain, multipart or urlencoded, so refusing those blocks form-based CSRF.
|
|
41
|
+
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
42
|
+
if (ct !== 'application/json') return resolve(null);
|
|
43
|
+
let b = '';
|
|
44
|
+
req.on('data', c => { b += c; if (b.length > 5e6) req.destroy(); });
|
|
45
|
+
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve(null); } });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A browser attaches Origin to any cross-site request. Same-origin GETs and the
|
|
50
|
+
// app's own fetches either omit it or send our own origin, so anything else is foreign.
|
|
51
|
+
function sameOrigin(req) {
|
|
52
|
+
const origin = req.headers.origin;
|
|
53
|
+
if (!origin) return true;
|
|
54
|
+
try {
|
|
55
|
+
const u = new URL(origin);
|
|
56
|
+
const okHost = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
|
|
57
|
+
return okHost && u.port === String(PORT);
|
|
58
|
+
} catch (e) { return false; }
|
|
59
|
+
}
|
|
60
|
+
function checkPort(port) {
|
|
61
|
+
return new Promise(resolve => {
|
|
62
|
+
if (!port) return resolve(false);
|
|
63
|
+
const s = net.connect({ host: '127.0.0.1', port: Number(port) });
|
|
64
|
+
let done = false;
|
|
65
|
+
const fin = v => { if (done) return; done = true; try { s.destroy(); } catch (e) {} resolve(v); };
|
|
66
|
+
s.on('connect', () => fin(true));
|
|
67
|
+
s.on('error', () => fin(false));
|
|
68
|
+
s.setTimeout(800, () => fin(false));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function portFromUrl(url) {
|
|
73
|
+
const m = String(url || '').match(/:(\d{2,5})\b/);
|
|
74
|
+
return m ? Number(m[1]) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---------- routes ---------- */
|
|
78
|
+
const server = http.createServer(async (req, res) => {
|
|
79
|
+
const url = req.url.split('?')[0];
|
|
80
|
+
|
|
81
|
+
if (!sameOrigin(req)) return send(res, 403, { error: 'cross-origin request blocked' });
|
|
82
|
+
// No CORS headers are ever sent, so a preflight must not succeed.
|
|
83
|
+
if (req.method === 'OPTIONS') return send(res, 405, { error: 'method not allowed' });
|
|
84
|
+
|
|
85
|
+
// static
|
|
86
|
+
if (req.method === 'GET' && (url === '/' || url === '/index.html')) {
|
|
87
|
+
return fs.readFile(path.join(RES, 'index.html'), (e, buf) => {
|
|
88
|
+
if (e) { res.writeHead(500); return res.end('index.html missing'); }
|
|
89
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
90
|
+
res.end(buf);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (req.method === 'GET' && url === '/api/servers') {
|
|
95
|
+
return send(res, 200, readData());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (req.method === 'POST' && url === '/api/servers') {
|
|
99
|
+
const data = await body(req);
|
|
100
|
+
if (!Array.isArray(data)) return send(res, 400, { error: 'expected a JSON array' });
|
|
101
|
+
writeData(data);
|
|
102
|
+
return send(res, 200, { ok: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (req.method === 'POST' && url === '/api/run') {
|
|
106
|
+
const b = await body(req);
|
|
107
|
+
if (!b) return send(res, 415, { error: 'expected application/json' });
|
|
108
|
+
const { cwd, command } = b;
|
|
109
|
+
if (!command) return send(res, 400, { error: 'no command' });
|
|
110
|
+
return plat.runInTerminal(cwd, command, (err) => {
|
|
111
|
+
if (err) return send(res, 500, { error: String(err) });
|
|
112
|
+
send(res, 200, { ok: true });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (req.method === 'POST' && url === '/api/open') {
|
|
117
|
+
const b = await body(req);
|
|
118
|
+
if (!b) return send(res, 415, { error: 'expected application/json' });
|
|
119
|
+
const { target } = b;
|
|
120
|
+
if (!target) return send(res, 400, { error: 'no target' });
|
|
121
|
+
return plat.openExternal(target, (err) => {
|
|
122
|
+
if (err) return send(res, 500, { error: String(err) });
|
|
123
|
+
send(res, 200, { ok: true });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (req.method === 'POST' && url === '/api/reveal') {
|
|
128
|
+
const b = await body(req);
|
|
129
|
+
if (!b) return send(res, 415, { error: 'expected application/json' });
|
|
130
|
+
const { path: p } = b;
|
|
131
|
+
if (!p) return send(res, 400, { error: 'no path' });
|
|
132
|
+
return plat.revealFolder(p, (err) => {
|
|
133
|
+
if (err) return send(res, 500, { error: String(err) });
|
|
134
|
+
send(res, 200, { ok: true });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (req.method === 'POST' && url === '/api/stop') {
|
|
139
|
+
const b = await body(req);
|
|
140
|
+
if (!b) return send(res, 415, { error: 'expected application/json' });
|
|
141
|
+
const { port } = b;
|
|
142
|
+
if (!port) return send(res, 400, { error: 'no port' });
|
|
143
|
+
return plat.killPort(port, () => {
|
|
144
|
+
send(res, 200, { ok: true });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (req.method === 'POST' && url === '/api/pickfolder') {
|
|
149
|
+
return plat.pickFolder((err, picked) => {
|
|
150
|
+
if (err || !picked) return send(res, 200, { cancelled: true });
|
|
151
|
+
send(res, 200, { path: picked });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (req.method === 'GET' && url === '/api/platform') {
|
|
156
|
+
return send(res, 200, { platform: process.platform, terminalName: plat.terminalName(), pathExample: plat.pathExample() });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (req.method === 'GET' && url === '/api/status') {
|
|
160
|
+
const data = readData();
|
|
161
|
+
const out = {};
|
|
162
|
+
await Promise.all(data.map(async s => {
|
|
163
|
+
const port = s.port || portFromUrl(s.url);
|
|
164
|
+
out[s.id] = port ? await checkPort(port) : false;
|
|
165
|
+
}));
|
|
166
|
+
return send(res, 200, out);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
170
|
+
res.end('{"error":"not found"}');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/* ---------- boot ---------- */
|
|
174
|
+
ensureStore();
|
|
175
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
176
|
+
console.log('Server Studio running on http://localhost:' + PORT);
|
|
177
|
+
plat.openExternal('http://localhost:' + PORT + '/', () => {});
|
|
178
|
+
});
|
|
179
|
+
server.on('error', (e) => {
|
|
180
|
+
if (e.code === 'EADDRINUSE') {
|
|
181
|
+
// already running -> just open the window
|
|
182
|
+
plat.openExternal('http://localhost:' + PORT + '/', () => {});
|
|
183
|
+
setTimeout(() => process.exit(0), 500);
|
|
184
|
+
} else {
|
|
185
|
+
console.error(e);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
});
|