server-studio 1.1.1 → 1.2.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/src/server.js CHANGED
@@ -9,11 +9,16 @@ const os = require('os');
9
9
  const net = require('net');
10
10
 
11
11
  const plat = require('./platform');
12
+ const setport = require('./setport');
13
+ const telemetry = require('./telemetry');
12
14
 
13
15
  const PORT = process.env.PORT || 4587;
14
16
  const RES = __dirname;
15
17
  const DATA_DIR = plat.dataDir();
16
18
  const DATA_FILE = path.join(DATA_DIR, 'data.json');
19
+ // Folders are stored separately. data.json stays a plain array so the published
20
+ // register-server.js, which reads and writes that array, keeps working untouched.
21
+ const FOLDERS_FILE = path.join(DATA_DIR, 'folders.json');
17
22
 
18
23
  /* ---------- storage ---------- */
19
24
  function ensureStore() {
@@ -27,6 +32,13 @@ function readData() {
27
32
  function writeData(d) {
28
33
  fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2));
29
34
  }
35
+ function readFolders() {
36
+ try { const f = JSON.parse(fs.readFileSync(FOLDERS_FILE, 'utf8')); return Array.isArray(f) ? f : []; }
37
+ catch (e) { return []; }
38
+ }
39
+ function writeFolders(f) {
40
+ fs.writeFileSync(FOLDERS_FILE, JSON.stringify(f, null, 2));
41
+ }
30
42
 
31
43
  /* ---------- helpers ---------- */
32
44
  function send(res, code, obj) {
@@ -74,6 +86,41 @@ function portFromUrl(url) {
74
86
  return m ? Number(m[1]) : null;
75
87
  }
76
88
 
89
+ /* ---------- version check ---------- */
90
+ function appVersion() {
91
+ // Running from a checkout, package.json is one level up. Inside the installed app
92
+ // bundle there is no package.json, so the installer stamps version.json beside the
93
+ // code. Try both before giving up.
94
+ try { return require('./version.json').version || '0.0.0'; } catch (e) {}
95
+ try { return require('../package.json').version || '0.0.0'; } catch (e) {}
96
+ return '0.0.0';
97
+ }
98
+ function isNewer(a, b) {
99
+ const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number);
100
+ for (let i = 0; i < 3; i++) {
101
+ const x = pa[i] || 0, y = pb[i] || 0;
102
+ if (x !== y) return x > y;
103
+ }
104
+ return false;
105
+ }
106
+ function latestVersion() {
107
+ return new Promise((resolve, reject) => {
108
+ const req = require('https').get({
109
+ host: 'registry.npmjs.org',
110
+ path: '/server-studio/latest',
111
+ headers: { 'User-Agent': 'server-studio' },
112
+ timeout: 2500,
113
+ }, r => {
114
+ if (r.statusCode !== 200) { r.resume(); return resolve(null); }
115
+ let d = '';
116
+ r.on('data', c => { d += c; if (d.length > 2e5) r.destroy(); });
117
+ r.on('end', () => { try { resolve(JSON.parse(d).version || null); } catch (e) { resolve(null); } });
118
+ });
119
+ req.on('error', () => resolve(null));
120
+ req.on('timeout', () => { req.destroy(); resolve(null); });
121
+ });
122
+ }
123
+
77
124
  /* ---------- routes ---------- */
78
125
  const server = http.createServer(async (req, res) => {
79
126
  const url = req.url.split('?')[0];
@@ -152,6 +199,62 @@ const server = http.createServer(async (req, res) => {
152
199
  });
153
200
  }
154
201
 
202
+ // Optional email signup. The browser posts here (same-origin), and we forward the
203
+ // user's chosen email to the remote counter. Inert if no analytics URL is set.
204
+ if (req.method === 'POST' && url === '/api/subscribe') {
205
+ const b = await body(req);
206
+ if (!b) return send(res, 415, { error: 'expected application/json' });
207
+ const email = String(b.email || '').trim().toLowerCase();
208
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || email.length > 254) {
209
+ return send(res, 400, { error: 'invalid email' });
210
+ }
211
+ // The message is optional. It is what turns this from a mailing list into a
212
+ // place to ask for things, so it is passed through rather than dropped.
213
+ const message = String(b.message || '').trim().slice(0, 2000);
214
+ if (!telemetry.subscribeEnabled) return send(res, 200, { ok: false, disabled: true });
215
+ const r = await telemetry.subscribe(email, message);
216
+ return send(res, r ? 200 : 502, { ok: r });
217
+ }
218
+
219
+ if (req.method === 'GET' && url === '/api/subscribe') {
220
+ // Tied to the signup endpoint, not the counter. The box should work even for
221
+ // someone who has turned telemetry off entirely.
222
+ return send(res, 200, { enabled: telemetry.subscribeEnabled });
223
+ }
224
+
225
+ // Version check against the public npm registry. No auth, no dependency, and it
226
+ // fails quietly so a blocked network just means no banner.
227
+ if (req.method === 'GET' && url === '/api/update') {
228
+ const current = appVersion();
229
+ return latestVersion().then(latest => {
230
+ send(res, 200, { current, latest, newer: !!(latest && isNewer(latest, current)) });
231
+ }).catch(() => send(res, 200, { current, latest: null, newer: false }));
232
+ }
233
+
234
+ if (req.method === 'GET' && url === '/api/folders') {
235
+ return send(res, 200, readFolders());
236
+ }
237
+
238
+ if (req.method === 'POST' && url === '/api/folders') {
239
+ const data = await body(req);
240
+ if (!Array.isArray(data)) return send(res, 400, { error: 'expected a JSON array' });
241
+ writeFolders(data);
242
+ return send(res, 200, { ok: true });
243
+ }
244
+
245
+ // Writes a fixed port into a project's package.json dev script. Pass apply:false to
246
+ // get the exact before/after first, so the user approves a real diff, not a promise.
247
+ if (req.method === 'POST' && url === '/api/setport') {
248
+ const b = await body(req);
249
+ if (!b) return send(res, 415, { error: 'expected application/json' });
250
+ const port = Number(b.port);
251
+ if (!port || port < 1 || port > 65535) return send(res, 400, { error: 'bad port' });
252
+ const r = b.apply ? setport.apply(String(b.cwd || ''), port) : setport.plan(String(b.cwd || ''), port);
253
+ return send(res, 200, { ok: !!r.ok, reason: r.reason || null, file: r.file || null,
254
+ key: r.key || null, before: r.before || null, after: r.after || null,
255
+ how: r.how || null, backup: r.backup || null });
256
+ }
257
+
155
258
  if (req.method === 'GET' && url === '/api/platform') {
156
259
  return send(res, 200, { platform: process.platform, terminalName: plat.terminalName(), pathExample: plat.pathExample() });
157
260
  }
@@ -174,6 +277,7 @@ const server = http.createServer(async (req, res) => {
174
277
  ensureStore();
175
278
  server.listen(PORT, '127.0.0.1', () => {
176
279
  console.log('Server Studio running on http://localhost:' + PORT);
280
+ telemetry.ping('start'); // one anonymous count per real launch (app or CLI)
177
281
  plat.openExternal('http://localhost:' + PORT + '/', () => {});
178
282
  });
179
283
  server.on('error', (e) => {
package/src/setport.js ADDED
@@ -0,0 +1,82 @@
1
+ // Writes a fixed port into a project's package.json dev script, so the card and the
2
+ // project agree instead of drifting.
3
+ //
4
+ // Deliberately narrow. It only ever touches "scripts.dev" (or "scripts.start"), and only
5
+ // when the shape is recognised. It edits the raw text rather than re-serialising the JSON,
6
+ // so nothing else in the file moves. Anything unfamiliar is refused, never guessed at.
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const UNSAFE = /&&|\|\||;|\|/; // chained scripts: too easy to break, so decline
13
+
14
+ // Work out what the script should become, or why it cannot be changed.
15
+ function rewriteScript(script, port) {
16
+ const s = String(script || '').trim();
17
+ if (!s) return { ok: false, reason: 'there is no dev script to edit' };
18
+ if (UNSAFE.test(s)) return { ok: false, reason: 'the dev script chains several commands' };
19
+
20
+ // Already carries an explicit port: replace the number, do not add another flag.
21
+ const long = s.match(/(--port[= ])(\d{2,5})/);
22
+ if (long) return { ok: true, next: s.replace(long[0], long[1] + port), how: 'updated --port' };
23
+ const short = s.match(/(\s-p[= ])(\d{2,5})/);
24
+ if (short) return { ok: true, next: s.replace(short[0], short[1] + port), how: 'updated -p' };
25
+ const envPort = s.match(/(^|\s)PORT=(\d{2,5})/);
26
+ if (envPort) return { ok: true, next: s.replace(envPort[0], envPort[1] + 'PORT=' + port), how: 'updated PORT=' };
27
+
28
+ // No port yet: add one the way that tool expects.
29
+ if (/(^|\s)vite(\s|$)/.test(s)) return { ok: true, next: s + ' --port ' + port + ' --strictPort', how: 'added --port --strictPort' };
30
+ if (/(^|\s)next\s+dev(\s|$)/.test(s)) return { ok: true, next: s + ' -p ' + port, how: 'added -p' };
31
+ if (/(^|\s)astro\s+dev(\s|$)/.test(s)) return { ok: true, next: s + ' --port ' + port, how: 'added --port' };
32
+ if (/react-scripts\s+start/.test(s)) return { ok: true, next: 'PORT=' + port + ' ' + s, how: 'added PORT=' };
33
+ if (/(^|\s)nuxt\s+dev(\s|$)/.test(s)) return { ok: true, next: s + ' --port ' + port, how: 'added --port' };
34
+
35
+ return { ok: false, reason: 'the dev script is not a framework this can safely edit' };
36
+ }
37
+
38
+ function plan(cwd, port) {
39
+ if (!cwd) return { ok: false, reason: 'this server has no project folder set' };
40
+ const file = path.join(cwd, 'package.json');
41
+ let raw;
42
+ try { raw = fs.readFileSync(file, 'utf8'); }
43
+ catch (e) { return { ok: false, reason: 'no package.json in that folder' }; }
44
+
45
+ let pkg;
46
+ try { pkg = JSON.parse(raw); }
47
+ catch (e) { return { ok: false, reason: 'package.json is not valid JSON' }; }
48
+
49
+ const scripts = pkg.scripts || {};
50
+ const key = scripts.dev !== undefined ? 'dev' : (scripts.start !== undefined ? 'start' : null);
51
+ if (!key) return { ok: false, reason: 'no dev or start script in package.json' };
52
+
53
+ const before = scripts[key];
54
+ const r = rewriteScript(before, port);
55
+ if (!r.ok) return { ok: false, reason: r.reason, script: before };
56
+ if (r.next === before) return { ok: false, reason: 'that script already uses port ' + port, script: before };
57
+
58
+ // Match the key together with its value. Matching the value alone is not enough: a dev
59
+ // script of "vite" also appears as the dependency name "vite", which is two hits.
60
+ const esc = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
+ const re = new RegExp('("' + esc(key) + '"\\s*:\\s*)' + esc(JSON.stringify(before)));
62
+ const hits = raw.match(new RegExp(re.source, 'g')) || [];
63
+ if (hits.length === 0) return { ok: false, reason: 'could not locate the script safely in the file' };
64
+ if (hits.length > 1) return { ok: false, reason: 'that script appears more than once, too risky to edit' };
65
+
66
+ return { ok: true, file, key, before, after: r.next, how: r.how, raw, re };
67
+ }
68
+
69
+ function apply(cwd, port) {
70
+ const p = plan(cwd, port);
71
+ if (!p.ok) return p;
72
+ const backup = p.file + '.backup-' + Date.now();
73
+ try {
74
+ fs.writeFileSync(backup, p.raw);
75
+ fs.writeFileSync(p.file, p.raw.replace(p.re, '$1' + JSON.stringify(p.after).replace(/\$/g, '$$$$')));
76
+ } catch (e) {
77
+ return { ok: false, reason: 'could not write package.json: ' + e.message };
78
+ }
79
+ return { ok: true, file: p.file, key: p.key, before: p.before, after: p.after, how: p.how, backup };
80
+ }
81
+
82
+ module.exports = { plan, apply, rewriteScript };
@@ -0,0 +1,179 @@
1
+ // Anonymous usage counter + optional email signup. Built-ins only, no dependencies.
2
+ //
3
+ // Two things live here, and they are very different:
4
+ // ping(event) anonymous, opt-out, fire-and-forget. A random id + OS/version.
5
+ // subscribe(email) an explicit, user-typed signup. Sends the email the user chose
6
+ // to give. Never automatic, never affected by the opt-out flag.
7
+ //
8
+ // Both are INERT until SERVER_STUDIO_ANALYTICS_URL points at your Worker origin, e.g.
9
+ // SERVER_STUDIO_ANALYTICS_URL=https://server-studio-analytics.you.workers.dev
10
+ // The code appends /collect and /subscribe itself.
11
+ 'use strict';
12
+
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+ const fs = require('fs');
16
+
17
+ const BASE = (process.env.SERVER_STUDIO_ANALYTICS_URL || '').replace(/\/+$/, '');
18
+
19
+ // Signups go to the site's own subscriber list, not to the counter. They are two
20
+ // separate things: one is an anonymous tally, this is a person asking to be emailed.
21
+ // Overridable so a fork points somewhere else instead of at this list.
22
+ const SUBSCRIBE_URL = process.env.SERVER_STUDIO_SUBSCRIBE_URL || 'https://www.mahdicreates.com/api/subscribe';
23
+
24
+ function optedOut() {
25
+ if (process.env.SERVER_STUDIO_NO_TELEMETRY) return true;
26
+ const dnt = String(process.env.DO_NOT_TRACK || '').toLowerCase();
27
+ return dnt === '1' || dnt === 'true';
28
+ }
29
+
30
+ function appVersion() {
31
+ // Running from a checkout, package.json is one level up. Inside the installed app
32
+ // bundle there is no package.json, so the installer stamps version.json beside the
33
+ // code. Try both before giving up.
34
+ try { return require('./version.json').version || '0.0.0'; } catch (e) {}
35
+ try { return require('../package.json').version || '0.0.0'; } catch (e) {}
36
+ return '0.0.0';
37
+ }
38
+
39
+ function dataDir() {
40
+ try { return require('./platform').dataDir(); }
41
+ catch (e) {
42
+ const os = require('os');
43
+ if (process.env.SERVER_STUDIO_DATA_DIR) return process.env.SERVER_STUDIO_DATA_DIR;
44
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Server Studio');
45
+ if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Server Studio');
46
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'server-studio');
47
+ }
48
+ }
49
+
50
+ // { id, fresh }. `fresh` is true the first time an id is minted.
51
+ function installId() {
52
+ const dir = dataDir();
53
+ const file = path.join(dir, 'analytics-id');
54
+ try {
55
+ const existing = fs.readFileSync(file, 'utf8').trim();
56
+ if (existing) return { id: existing, fresh: false };
57
+ } catch (e) { /* not created yet */ }
58
+ const id = crypto.randomUUID();
59
+ try {
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ fs.writeFileSync(file, id + '\n');
62
+ return { id, fresh: true };
63
+ } catch (e) {
64
+ return { id, fresh: false }; // read-only home: still count, just cannot dedupe
65
+ }
66
+ }
67
+
68
+ function notice() {
69
+ let link = '';
70
+ try { link = (require('../package.json').homepage || '').split('#')[0]; } catch (e) {}
71
+ console.error(
72
+ 'Server Studio sends one anonymous ping (a random id + your OS and version, no\n' +
73
+ 'personal data) so installs can be counted. Turn it off any time with\n' +
74
+ ' SERVER_STUDIO_NO_TELEMETRY=1' + (link ? ' · ' + link + '#telemetry' : '')
75
+ );
76
+ }
77
+
78
+ // Low-level POST. Resolves { ok } and never rejects.
79
+ function post(pathname, obj, timeout, keepAlive) {
80
+ return new Promise(resolve => {
81
+ try {
82
+ if (!BASE) return resolve({ ok: false });
83
+ const payload = JSON.stringify(obj);
84
+ const u = new URL(BASE + pathname);
85
+ const client = u.protocol === 'http:' ? require('http') : require('https');
86
+ const req = client.request({
87
+ hostname: u.hostname,
88
+ port: u.port || (u.protocol === 'http:' ? 80 : 443),
89
+ path: u.pathname,
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
92
+ timeout: timeout || 1500,
93
+ }, res => {
94
+ res.resume(); // drain
95
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300 });
96
+ });
97
+ req.on('error', () => resolve({ ok: false }));
98
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false }); });
99
+ // Never keep the process alive for a ping. On a network that cannot reach the
100
+ // endpoint this is the difference between exiting now and waiting out the
101
+ // timeout. A ping that would not have arrived is not worth a second of anyone's
102
+ // install. Subscribe passes keepAlive because a person is waiting on the answer.
103
+ //
104
+ // The socket has to be unref'd as well as the request. On Node 18 unref'ing the
105
+ // request before a socket exists does not propagate, and the process sat there
106
+ // for seconds on a blocked network.
107
+ if (!keepAlive) {
108
+ req.on('socket', sock => { if (sock && typeof sock.unref === 'function') sock.unref(); });
109
+ if (typeof req.unref === 'function') req.unref();
110
+ }
111
+ req.end(payload);
112
+ } catch (e) { resolve({ ok: false }); }
113
+ });
114
+ }
115
+
116
+ // POST to an absolute URL and hand back the parsed body. Never rejects.
117
+ function postTo(absUrl, obj, timeout) {
118
+ return new Promise(resolve => {
119
+ try {
120
+ const payload = JSON.stringify(obj);
121
+ const u = new URL(absUrl);
122
+ const client = u.protocol === 'http:' ? require('http') : require('https');
123
+ const req = client.request({
124
+ hostname: u.hostname,
125
+ port: u.port || (u.protocol === 'http:' ? 80 : 443),
126
+ path: u.pathname + (u.search || ''),
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
129
+ timeout: timeout || 6000,
130
+ }, res => {
131
+ let d = '';
132
+ res.on('data', c => { d += c; if (d.length > 1e5) res.destroy(); });
133
+ res.on('end', () => {
134
+ let parsed = null;
135
+ try { parsed = JSON.parse(d || '{}'); } catch (e) { parsed = null; }
136
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, json: parsed });
137
+ });
138
+ });
139
+ req.on('error', () => resolve({ ok: false, json: null }));
140
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, json: null }); });
141
+ req.end(payload);
142
+ } catch (e) { resolve({ ok: false, json: null }); }
143
+ });
144
+ }
145
+
146
+ // Anonymous, fire-and-forget.
147
+ function ping(event) {
148
+ try {
149
+ if (!BASE || optedOut() || process.env.CI) return;
150
+ const { id, fresh } = installId();
151
+ if (fresh) { try { notice(); } catch (e) {} }
152
+ post('/collect', {
153
+ event: String(event || 'ping'),
154
+ id,
155
+ v: appVersion(),
156
+ os: process.platform,
157
+ arch: process.arch,
158
+ node: process.versions.node.split('.')[0],
159
+ }, 1500);
160
+ } catch (e) { /* best-effort, always */ }
161
+ }
162
+
163
+ // Explicit signup. Returns a Promise<boolean> so the UI can confirm.
164
+ async function subscribe(email, message) {
165
+ try {
166
+ const e = String(email || '').trim().toLowerCase();
167
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e) || e.length > 254) return false;
168
+ if (!SUBSCRIBE_URL) return false;
169
+ const body = { email: e };
170
+ const m = String(message || '').trim().slice(0, 2000);
171
+ if (m) body.message = m;
172
+ // Deliberately not gated on the telemetry opt-out: typing an address and pressing
173
+ // send is an explicit request, not tracking, and refusing it would be wrong.
174
+ const r = await postTo(SUBSCRIBE_URL, body, 6000);
175
+ return !!(r && r.ok && r.json && r.json.success);
176
+ } catch (e) { return false; }
177
+ }
178
+
179
+ module.exports = { ping, subscribe, enabled: !!BASE, subscribeEnabled: !!SUBSCRIBE_URL };