selfnify-host 0.0.0-stage → 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.
Files changed (3) hide show
  1. package/host.js +872 -0
  2. package/package.json +25 -5
  3. package/README.md +0 -3
package/host.js ADDED
@@ -0,0 +1,872 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const http = require('http');
8
+ const https = require('https');
9
+ const crypto = require('crypto');
10
+ const readline = require('readline');
11
+
12
+ const VERSION = '1.0.0';
13
+ const DEFAULT_URL = 'https://host.lume.dpdns.org';
14
+ const CONFIG_DIR = path.join(os.homedir(), '.selfnify');
15
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'host.json');
16
+ const EXCLUDE_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', 'target', '__pycache__', '.idea', '.vscode']);
17
+ const EXCLUDE_FILES = new Set(['.env', '.DS_Store']);
18
+ const MAX_UPLOAD_MB = 95;
19
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
20
+ const COMMANDS = new Set([
21
+ 'start', 'stop', 'restart', 'delete', 'status', 'list', 'logs', 'env', 'config',
22
+ 'deploy', 'login', 'logout', 'pin', 'help', 'version',
23
+ ]);
24
+
25
+ const VALUE_FLAGS = { '--mem': 'mem', '--env': 'env', '-n': 'lines', '--lines': 'lines', '--server': 'server' };
26
+ const BOOL_FLAGS = {
27
+ '--autostart': 'autostart',
28
+ '--no-autostart': 'no-autostart',
29
+ '--no-install': 'no-install',
30
+ '--dry-run': 'dry-run',
31
+ '-v': 'verbose',
32
+ '--verbose': 'verbose',
33
+ '--no-color': 'no-color',
34
+ '-f': 'follow',
35
+ '--follow': 'follow',
36
+ '--install': 'install',
37
+ '-h': 'help',
38
+ '--help': 'help',
39
+ };
40
+
41
+ // ------------------------------------------------------------------ output
42
+
43
+ const state = { verbose: false, color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR };
44
+ const paint = (code) => (s) => (state.color ? '\x1b[' + code + 'm' + s + '\x1b[0m' : String(s));
45
+ const c = { dim: paint('2'), red: paint('31'), green: paint('32'), yellow: paint('33'), cyan: paint('36'), bold: paint('1') };
46
+ const out = (s) => process.stdout.write((s === undefined ? '' : s) + '\n');
47
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
48
+
49
+ class CliError extends Error {
50
+ constructor(message, status) {
51
+ super(message);
52
+ this.status = status;
53
+ }
54
+ }
55
+
56
+ // One line per step: "[2/6] Packing files ok 42 files, 1.2 MB"
57
+ async function step(n, total, label, fn) {
58
+ const tag = n ? c.dim('[' + n + '/' + total + ']') + ' ' : '';
59
+ const prefix = ' ' + tag + label.padEnd(26);
60
+ const live = Boolean(process.stdout.isTTY);
61
+ const show = (text) => {
62
+ if (live) process.stdout.write('\r\x1b[K' + prefix + text);
63
+ };
64
+ show(c.dim('...'));
65
+ try {
66
+ const detail = await fn({ progress: (text) => show(c.dim(text)) });
67
+ const line = prefix + c.green('ok') + (detail ? ' ' + c.dim(detail) : '');
68
+ process.stdout.write((live ? '\r\x1b[K' : '') + line + '\n');
69
+ return detail;
70
+ } catch (e) {
71
+ process.stdout.write((live ? '\r\x1b[K' : '') + prefix + c.red('failed') + '\n');
72
+ throw e;
73
+ }
74
+ }
75
+
76
+ function table(headers, rows) {
77
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
78
+ const fmt = (r) => ' ' + r.map((cell, i) => String(cell).padEnd(widths[i])).join(' ');
79
+ out(c.dim(fmt(headers)));
80
+ rows.forEach((r) => out(fmt(r)));
81
+ }
82
+
83
+ // ------------------------------------------------------------ config + pin
84
+
85
+ function loadConfig() {
86
+ try {
87
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
88
+ } catch (e) {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ function saveConfig(cfg) {
94
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
95
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
96
+ try {
97
+ fs.chmodSync(CONFIG_FILE, 0o600);
98
+ } catch (e) {
99
+ /* windows */
100
+ }
101
+ }
102
+
103
+ function normalizeUrl(url) {
104
+ let u = String(url).trim().replace(/\/+$/, '');
105
+ if (!/^https?:\/\//.test(u)) u = 'https://' + u;
106
+ return u;
107
+ }
108
+
109
+ const hostOf = (url) => {
110
+ try {
111
+ return new URL(url).host;
112
+ } catch (e) {
113
+ return url;
114
+ }
115
+ };
116
+
117
+ function hashPin(pin, salt) {
118
+ return crypto.scryptSync(pin, salt, 32).toString('hex');
119
+ }
120
+
121
+ function makePin(pin) {
122
+ const salt = crypto.randomBytes(16).toString('hex');
123
+ return { salt, hash: hashPin(pin, salt) };
124
+ }
125
+
126
+ function checkPin(rec, pin) {
127
+ if (!rec) return false;
128
+ const a = Buffer.from(hashPin(pin, rec.salt), 'hex');
129
+ const b = Buffer.from(rec.hash, 'hex');
130
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
131
+ }
132
+
133
+ // ----------------------------------------------------------------- prompts
134
+
135
+ function ask(question) {
136
+ return new Promise((resolve) => {
137
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
138
+ let answered = false;
139
+ rl.on('close', () => {
140
+ if (!answered) resolve('');
141
+ });
142
+ rl.question(question, (answer) => {
143
+ answered = true;
144
+ rl.close();
145
+ resolve(answer.trim());
146
+ });
147
+ });
148
+ }
149
+
150
+ function askHidden(question) {
151
+ if (!process.stdin.isTTY) return ask(question);
152
+ return new Promise((resolve) => {
153
+ const stdin = process.stdin;
154
+ let buf = '';
155
+ process.stdout.write(question);
156
+ const finish = (value) => {
157
+ stdin.setRawMode(false);
158
+ stdin.removeListener('data', onData);
159
+ stdin.pause();
160
+ process.stdout.write('\n');
161
+ resolve(value);
162
+ };
163
+ const onData = (chunk) => {
164
+ for (const ch of chunk.toString('utf8')) {
165
+ if (ch === '\r' || ch === '\n') return finish(buf.trim());
166
+ if (ch === '\u0003') {
167
+ stdin.setRawMode(false);
168
+ process.stdout.write('\n');
169
+ process.exit(130);
170
+ }
171
+ if (ch === '\u007f' || ch === '\b') buf = buf.slice(0, -1);
172
+ else if (ch >= ' ') buf += ch;
173
+ }
174
+ return undefined;
175
+ };
176
+ stdin.setRawMode(true);
177
+ stdin.resume();
178
+ stdin.on('data', onData);
179
+ });
180
+ }
181
+
182
+ async function requirePin(cfg, action) {
183
+ if (!cfg.pin) throw new CliError('no PIN set yet. Run: host login');
184
+ for (let i = 0; i < 3; i++) {
185
+ const pin = await askHidden(' PIN to ' + action + ': ');
186
+ if (checkPin(cfg.pin, pin)) return;
187
+ out(c.red(' wrong PIN') + c.dim(' (' + (2 - i) + ' tries left)'));
188
+ }
189
+ throw new CliError('too many wrong PINs');
190
+ }
191
+
192
+ async function chooseNewPin() {
193
+ for (let i = 0; i < 5; i++) {
194
+ const p1 = await askHidden(' Choose a PIN (4-8 digits): ');
195
+ if (!/^\d{4,8}$/.test(p1)) {
196
+ out(c.yellow(' PIN must be 4 to 8 digits'));
197
+ continue;
198
+ }
199
+ const p2 = await askHidden(' Repeat PIN: ');
200
+ if (p1 !== p2) {
201
+ out(c.yellow(" PINs didn't match"));
202
+ continue;
203
+ }
204
+ return p1;
205
+ }
206
+ throw new CliError('could not set a PIN');
207
+ }
208
+
209
+ // --------------------------------------------------------------------- api
210
+
211
+ function request(method, apiPath, opts) {
212
+ const o = opts || {};
213
+ const cfg = o.cfg || loadConfig();
214
+ if (!cfg.password) return Promise.reject(new CliError('not logged in. Run: host login'));
215
+ const url = new URL((cfg.url || DEFAULT_URL) + apiPath);
216
+ const lib = url.protocol === 'http:' ? http : https;
217
+ const headers = { Authorization: 'Bearer ' + cfg.password, 'User-Agent': 'selfnify-host/' + VERSION };
218
+ let payload = null;
219
+ if (o.raw) {
220
+ payload = o.raw;
221
+ headers['Content-Type'] = 'application/gzip';
222
+ } else if (o.body !== undefined) {
223
+ payload = Buffer.from(JSON.stringify(o.body));
224
+ headers['Content-Type'] = 'application/json';
225
+ }
226
+ if (payload) headers['Content-Length'] = payload.length;
227
+ if (state.verbose) out(c.dim(' -> ' + method + ' ' + apiPath));
228
+ return new Promise((resolve, reject) => {
229
+ const req = lib.request(url, { method, headers }, (res) => {
230
+ const chunks = [];
231
+ res.on('data', (d) => chunks.push(d));
232
+ res.on('end', () => {
233
+ const text = Buffer.concat(chunks).toString('utf8');
234
+ let data = null;
235
+ try {
236
+ data = JSON.parse(text);
237
+ } catch (e) {
238
+ /* not json */
239
+ }
240
+ if (state.verbose) out(c.dim(' <- ' + res.statusCode));
241
+ if (res.statusCode >= 200 && res.statusCode < 300 && data) return resolve(data);
242
+ let msg = (data && data.error) || 'HTTP ' + res.statusCode;
243
+ if (!data && text.includes('1010')) msg = 'blocked by Cloudflare (error 1010), tell the host admin';
244
+ else if (!data && res.statusCode === 502) msg = 'server is not answering (is the service running?)';
245
+ if (res.statusCode === 401) msg += ' (run: host login)';
246
+ return reject(new CliError(msg, res.statusCode));
247
+ });
248
+ });
249
+ req.setTimeout(o.timeout || 120000, () => req.destroy(new Error('request timed out')));
250
+ req.on('error', (e) => reject(new CliError("can't reach the server (" + e.message + ')')));
251
+ if (payload) req.write(payload);
252
+ req.end();
253
+ });
254
+ }
255
+
256
+ const bp = (name, sub) => '/bots/' + encodeURIComponent(name) + (sub || '');
257
+
258
+ // ----------------------------------------------------------------- helpers
259
+
260
+ function parseArgs(argv) {
261
+ const flags = {};
262
+ const pos = [];
263
+ for (let i = 0; i < argv.length; i++) {
264
+ const a = argv[i];
265
+ if (VALUE_FLAGS[a]) {
266
+ if (i + 1 >= argv.length) throw new CliError(a + ' needs a value');
267
+ i += 1;
268
+ flags[VALUE_FLAGS[a]] = argv[i];
269
+ } else if (BOOL_FLAGS[a]) {
270
+ flags[BOOL_FLAGS[a]] = true;
271
+ } else if (a.startsWith('--') && a.includes('=')) {
272
+ const parts = a.slice(2).split('=');
273
+ flags[parts[0]] = parts.slice(1).join('=');
274
+ } else if (a.startsWith('-') && a.length > 1 && !/^-\d/.test(a)) {
275
+ throw new CliError('unknown flag ' + a + '. Try: host help');
276
+ } else {
277
+ pos.push(a);
278
+ }
279
+ }
280
+ return { flags, pos };
281
+ }
282
+
283
+ function requireName(name, usage) {
284
+ if (!name) throw new CliError('which bot? usage: ' + usage);
285
+ return name;
286
+ }
287
+
288
+ function parseEnvFile(file) {
289
+ let text;
290
+ try {
291
+ text = fs.readFileSync(file, 'utf8');
292
+ } catch (e) {
293
+ throw new CliError("can't read " + file + ' (' + e.message + ')');
294
+ }
295
+ const pairs = {};
296
+ text.split(/\r?\n/).forEach((raw) => {
297
+ let line = raw.trim();
298
+ if (!line || line.startsWith('#') || !line.includes('=')) return;
299
+ if (line.startsWith('export ')) line = line.slice(7);
300
+ const idx = line.indexOf('=');
301
+ const key = line.slice(0, idx).trim();
302
+ let value = line.slice(idx + 1).trim();
303
+ if (value.length >= 2 && value[0] === value[value.length - 1] && '"\''.includes(value[0])) {
304
+ value = value.slice(1, -1);
305
+ }
306
+ pairs[key] = value;
307
+ });
308
+ return pairs;
309
+ }
310
+
311
+ function parseStart(str) {
312
+ const tokens = String(str).trim().split(/\s+/);
313
+ const cmd = tokens[0].toLowerCase().replace(/\.exe$/, '');
314
+ let lang = null;
315
+ if (['python', 'python3', 'py'].includes(cmd)) lang = 'python';
316
+ else if (['node', 'nodejs'].includes(cmd)) lang = 'node';
317
+ else if (['cargo', 'rust'].includes(cmd)) lang = 'rust';
318
+ if (!lang) throw new CliError('don\'t know how to run "' + str + '". Use: python, node or cargo');
319
+ return { lang, entry: tokens[1] || null };
320
+ }
321
+
322
+ function detectStart(dir) {
323
+ const has = (f) => fs.existsSync(path.join(dir, f));
324
+ if (has('Cargo.toml')) return 'cargo';
325
+ if (has('package.json')) return 'node';
326
+ if (has('requirements.txt') || has('main.py') || has('bot.py')) return 'python';
327
+ return null;
328
+ }
329
+
330
+ function nodeMain(dir) {
331
+ try {
332
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
333
+ if (pkg.main) return pkg.main;
334
+ } catch (e) {
335
+ /* no package.json */
336
+ }
337
+ return 'index.js';
338
+ }
339
+
340
+ const defaultEntry = (lang) => ({ python: 'main.py', node: 'index.js', rust: '' }[lang]);
341
+ const sanitizeName = (s) => s.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[-_]+/, '').slice(0, 32);
342
+
343
+ async function packDir(dir) {
344
+ const tar = require('tar');
345
+ const entries = fs.readdirSync(dir).filter((n) => !EXCLUDE_DIRS.has(n) && !EXCLUDE_FILES.has(n));
346
+ if (entries.length === 0) throw new CliError('nothing to upload in ' + dir);
347
+ const files = [];
348
+ const filter = (p, stat) => {
349
+ const parts = String(p).split(/[\\/]/);
350
+ if (parts.some((s) => EXCLUDE_DIRS.has(s))) return false;
351
+ const base = parts[parts.length - 1];
352
+ if (EXCLUDE_FILES.has(base) || base.endsWith('.pyc')) return false;
353
+ if (stat && typeof stat.isSymbolicLink === 'function' && stat.isSymbolicLink()) return false;
354
+ if (stat && typeof stat.isFile === 'function' && stat.isFile()) files.push(String(p));
355
+ return true;
356
+ };
357
+ const stream = tar.c({ gzip: true, cwd: dir, portable: true, filter }, entries);
358
+ const chunks = [];
359
+ await new Promise((resolve, reject) => {
360
+ stream.on('data', (d) => chunks.push(d));
361
+ stream.on('end', resolve);
362
+ stream.on('error', reject);
363
+ });
364
+ if (files.length === 0) throw new CliError('no files to upload in ' + dir);
365
+ return { data: Buffer.concat(chunks), files };
366
+ }
367
+
368
+ async function installBot(name, lang, progress) {
369
+ try {
370
+ await request('POST', bp(name, '/install'));
371
+ } catch (e) {
372
+ if (/nothing to install/.test(e.message)) return 'nothing to install';
373
+ throw e;
374
+ }
375
+ const t0 = Date.now();
376
+ for (;;) {
377
+ await sleep(3000);
378
+ const b = await request('GET', bp(name));
379
+ const secs = Math.round((Date.now() - t0) / 1000);
380
+ if (b.status !== 'installing') {
381
+ if (!/finished OK/.test(b.note || '')) {
382
+ const l = await request('GET', bp(name, '/logs') + '?lines=25&kind=install');
383
+ out();
384
+ out(c.dim(l.logs));
385
+ throw new CliError('install failed (log above)');
386
+ }
387
+ return secs + 's';
388
+ }
389
+ progress((lang === 'rust' ? 'building, Rust is slow, relax ' : 'installing ') + secs + 's');
390
+ if (secs > 3600) throw new CliError('install is taking over an hour. Check: host logs ' + name + ' --install');
391
+ }
392
+ }
393
+
394
+ // ---------------------------------------------------------------- commands
395
+
396
+ async function cmdLogin(flags, first) {
397
+ const old = loadConfig();
398
+ const url = normalizeUrl(flags.server || old.url || DEFAULT_URL);
399
+ if (!process.stdin.isTTY) throw new CliError('login needs a terminal');
400
+ out();
401
+ out(c.bold(first ? " Welcome to selfnify host. Let's log you in." : ' Login'));
402
+ out(c.dim(' server: ' + url));
403
+ if (old.pin && old.password) await requirePin(old, 'log in again');
404
+ const password = await askHidden(' Password: ');
405
+ if (!password) throw new CliError("password can't be empty");
406
+ const who = await step(null, 0, 'Checking password', async () => {
407
+ const r = await request('GET', '/status', { cfg: { url, password } });
408
+ return 'hi ' + r.you.name;
409
+ });
410
+ const pin = await chooseNewPin();
411
+ saveConfig({ url, password, pin: makePin(pin), bots: old.bots || {} });
412
+ out(c.green(' Saved.') + c.dim(' Password and PIN are stored on this device only (' + CONFIG_FILE + ').'));
413
+ out(c.dim(' The PIN is asked before big changes like delete. ' + (who || '')));
414
+ out();
415
+ }
416
+
417
+ async function ensureLogin(flags) {
418
+ const cfg = loadConfig();
419
+ if (cfg.password && cfg.url) return cfg;
420
+ if (!process.stdin.isTTY) throw new CliError('not logged in. Run: host login');
421
+ await cmdLogin(flags, true);
422
+ return loadConfig();
423
+ }
424
+
425
+ async function cmdLogout() {
426
+ const cfg = loadConfig();
427
+ if (!cfg.password) {
428
+ out(c.dim(' already logged out'));
429
+ return;
430
+ }
431
+ await requirePin(cfg, 'log out');
432
+ fs.unlinkSync(CONFIG_FILE);
433
+ out(c.green(' Logged out.') + c.dim(' Saved password, PIN and deploy shortcuts removed from this device.'));
434
+ }
435
+
436
+ async function cmdPin() {
437
+ const cfg = loadConfig();
438
+ if (!cfg.password) throw new CliError('not logged in. Run: host login');
439
+ await requirePin(cfg, 'change your PIN');
440
+ const pin = await chooseNewPin();
441
+ cfg.pin = makePin(pin);
442
+ saveConfig(cfg);
443
+ out(c.green(' PIN changed.'));
444
+ }
445
+
446
+ async function cmdList() {
447
+ const r = await request('GET', '/bots');
448
+ if (!r.bots.length) {
449
+ out(c.dim(' no bots yet. Deploy one: host ./mybot start node main index.js name mybot'));
450
+ return;
451
+ }
452
+ table(
453
+ ['BOT', 'LANG', 'STATUS', 'RAM', 'AUTO', 'NOTE'],
454
+ r.bots.map((b) => [b.name, b.language, b.status, b.rss_mb + '/' + b.mem_mb + ' MB', b.autostart ? 'yes' : 'no', (b.note || '').slice(0, 40)])
455
+ );
456
+ }
457
+
458
+ async function cmdStatus(name) {
459
+ if (!name) {
460
+ const s = await request('GET', '/status');
461
+ const quota = s.you.quota_gb === null ? 'no limit' : s.you.quota_gb + ' GB';
462
+ out(c.bold(' ' + s.you.name) + c.dim(' @ ' + hostOf(loadConfig().url)));
463
+ out(' storage ' + s.you.used_mb + ' MB of ' + quota);
464
+ out(' server ' + s.server.bots_running + ' of ' + s.server.bots_total + ' bots running, ' + s.server.ram_free_mb + ' MB RAM free');
465
+ return;
466
+ }
467
+ const b = await request('GET', bp(name));
468
+ const col = b.status === 'running' ? c.green : b.status === 'installing' ? c.yellow : c.dim;
469
+ out(c.bold(' ' + b.name) + ' ' + col(b.status + (b.pid ? ' (pid ' + b.pid + ')' : '')));
470
+ out(' language ' + b.language + (b.language === 'rust' ? '' : ' main ' + b.entry));
471
+ out(' memory ' + b.rss_mb + ' / ' + b.mem_mb + ' MB');
472
+ out(' autostart ' + (b.autostart ? 'on' : 'off') + ' restart on crash ' + (b.restart_on_crash ? 'on' : 'off'));
473
+ out(' env vars ' + (b.env_keys.join(', ') || '(none)'));
474
+ if (b.note) out(' note ' + b.note);
475
+ }
476
+
477
+ async function cmdAction(action, name) {
478
+ requireName(name, 'host ' + action + ' <bot>');
479
+ const verb = { start: 'Starting', stop: 'Stopping', restart: 'Restarting' }[action];
480
+ await step(null, 0, verb + ' ' + name, async () => {
481
+ if (action === 'restart') {
482
+ try {
483
+ await request('POST', bp(name, '/stop'));
484
+ } catch (e) {
485
+ if (e.status === 404) throw e;
486
+ }
487
+ const b = await request('POST', bp(name, '/start'));
488
+ return b.status + (b.pid ? ' (pid ' + b.pid + ')' : '');
489
+ }
490
+ const b = await request('POST', bp(name, '/' + action));
491
+ return b.status + (b.pid ? ' (pid ' + b.pid + ')' : '');
492
+ });
493
+ }
494
+
495
+ async function cmdDelete(name) {
496
+ requireName(name, 'host delete <bot>');
497
+ const cfg = loadConfig();
498
+ const b = await request('GET', bp(name));
499
+ out(' ' + c.bold(name) + c.dim(' (' + b.language + ', ' + b.status + ')') + ' will be deleted with ALL its files.');
500
+ await requirePin(cfg, 'delete ' + name);
501
+ await step(null, 0, 'Deleting ' + name, async () => {
502
+ await request('DELETE', bp(name));
503
+ return 'gone';
504
+ });
505
+ const fresh = loadConfig();
506
+ if (fresh.bots && fresh.bots[name]) {
507
+ delete fresh.bots[name];
508
+ saveConfig(fresh);
509
+ }
510
+ }
511
+
512
+ function overlap(prev, cur) {
513
+ for (let size = Math.min(prev.length, cur.length); size > 0; size--) {
514
+ let ok = true;
515
+ for (let i = 0; i < size; i++) {
516
+ if (prev[prev.length - size + i] !== cur[i]) {
517
+ ok = false;
518
+ break;
519
+ }
520
+ }
521
+ if (ok) return size;
522
+ }
523
+ return 0;
524
+ }
525
+
526
+ async function cmdLogs(name, flags) {
527
+ requireName(name, 'host logs <bot> [-f] [-n 50] [--install]');
528
+ const n = Math.max(1, parseInt(flags.lines || '50', 10) || 50);
529
+ const kind = flags.install ? 'install' : 'run';
530
+ const fetchLines = async (count) => (await request('GET', bp(name, '/logs') + '?lines=' + count + '&kind=' + kind)).logs;
531
+ if (!flags.follow) {
532
+ out((await fetchLines(n)) || c.dim(' (no logs yet)'));
533
+ return;
534
+ }
535
+ let prev = (await fetchLines(200)).split('\n').filter((l) => l.length);
536
+ out(prev.length ? prev.slice(-n).join('\n') : c.dim(' (no logs yet)'));
537
+ out(c.dim(' -- following, Ctrl+C to stop --'));
538
+ process.on('SIGINT', () => {
539
+ out();
540
+ process.exit(0);
541
+ });
542
+ let warned = false;
543
+ for (;;) {
544
+ await sleep(2000);
545
+ try {
546
+ const cur = (await fetchLines(200)).split('\n').filter((l) => l.length);
547
+ cur.slice(overlap(prev, cur)).forEach((l) => out(l));
548
+ if (cur.length) prev = cur;
549
+ warned = false;
550
+ } catch (e) {
551
+ if (!warned) out(c.yellow(' connection problem, retrying...'));
552
+ warned = true;
553
+ }
554
+ }
555
+ }
556
+
557
+ async function cmdEnv(name, sub, rest) {
558
+ requireName(name, 'host env <bot> push [file] | set K=V ... | unset K ... | list');
559
+ if (!sub) throw new CliError('usage: host env ' + name + ' push [file] | set K=V ... | unset K ... | list');
560
+ if (sub === 'list') {
561
+ const b = await request('GET', bp(name));
562
+ out(' env vars on ' + name + ': ' + (b.env_keys.join(', ') || '(none)'));
563
+ return;
564
+ }
565
+ let pairs;
566
+ if (sub === 'set') {
567
+ pairs = {};
568
+ rest.forEach((item) => {
569
+ const idx = item.indexOf('=');
570
+ if (idx < 1) throw new CliError('use KEY=VALUE for each variable');
571
+ pairs[item.slice(0, idx)] = item.slice(idx + 1);
572
+ });
573
+ } else if (sub === 'unset') {
574
+ pairs = {};
575
+ rest.forEach((k) => {
576
+ pairs[k] = null;
577
+ });
578
+ } else if (sub === 'push') {
579
+ pairs = parseEnvFile(rest[0] || '.env');
580
+ } else {
581
+ throw new CliError("unknown env command '" + sub + "'");
582
+ }
583
+ if (!Object.keys(pairs).length) throw new CliError('nothing to send');
584
+ await step(null, 0, 'Updating env vars', async () => {
585
+ const r = await request('POST', bp(name, '/env'), { body: pairs });
586
+ return r.env_keys.length + ' on the server';
587
+ });
588
+ out(c.dim(' restart to apply: host restart ' + name));
589
+ }
590
+
591
+ async function cmdConfig(name, flags) {
592
+ requireName(name, 'host config <bot> [--mem MB] [--autostart|--no-autostart] [--main file]');
593
+ const body = {};
594
+ if (flags.autostart) body.autostart = true;
595
+ if (flags['no-autostart']) body.autostart = false;
596
+ if (flags.mem !== undefined) {
597
+ const mem = parseInt(flags.mem, 10);
598
+ if (!(mem >= 30)) throw new CliError('--mem needs a number, 30 or more (MB)');
599
+ body.mem_mb = mem;
600
+ }
601
+ if (flags.main) body.entry = flags.main;
602
+ if (Object.keys(body).length) {
603
+ await step(null, 0, 'Updating ' + name, async () => {
604
+ await request('POST', bp(name, '/config'), { body });
605
+ return Object.keys(body).join(', ');
606
+ });
607
+ }
608
+ await cmdStatus(name);
609
+ }
610
+
611
+ async function deploy(opts, flags) {
612
+ const t0 = Date.now();
613
+ const dir = path.resolve(opts.path);
614
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new CliError('"' + opts.path + '" is not a folder');
615
+ const startStr = opts.start || detectStart(dir);
616
+ if (!startStr) throw new CliError('could not tell the language. Add: start python | node | cargo');
617
+ const parsed = parseStart(startStr);
618
+ const lang = parsed.lang;
619
+ const main = opts.main || parsed.entry || (lang === 'node' ? nodeMain(dir) : defaultEntry(lang));
620
+ const name = opts.name || sanitizeName(path.basename(dir));
621
+ if (!NAME_RE.test(name)) throw new CliError('bad bot name "' + name + '": use a-z, 0-9, _ or -, max 32 characters');
622
+ if (lang !== 'rust' && !fs.existsSync(path.join(dir, main))) throw new CliError('main file "' + main + '" not found in ' + dir);
623
+ let mem;
624
+ if (flags.mem !== undefined) {
625
+ mem = parseInt(flags.mem, 10);
626
+ if (!(mem >= 30)) throw new CliError('--mem needs a number, 30 or more (MB)');
627
+ }
628
+
629
+ const dry = Boolean(flags['dry-run']);
630
+ const total = dry ? 2 : 5 + (flags.env ? 1 : 0) + (flags['no-install'] ? 0 : 1);
631
+ let n = 0;
632
+ const next = (label, fn) => step(++n, total, label, fn);
633
+
634
+ out();
635
+ out(' ' + c.bold('Deploying ' + name) + c.dim(' ' + lang + (lang === 'rust' ? '' : ', main ' + main)));
636
+ out(c.dim(' from ' + dir));
637
+ out();
638
+
639
+ const cfg = loadConfig();
640
+ await next('Checking login', async () => {
641
+ const s = await request('GET', '/status');
642
+ return s.you.name + ' @ ' + hostOf(cfg.url);
643
+ });
644
+
645
+ let pack;
646
+ await next('Packing files', async () => {
647
+ pack = await packDir(dir);
648
+ const mb = pack.data.length / 1024 / 1024;
649
+ if (mb > MAX_UPLOAD_MB) throw new CliError('upload is ' + mb.toFixed(1) + ' MB (limit about ' + MAX_UPLOAD_MB + ' MB). Look for big files that do not belong.');
650
+ return pack.files.length + ' files, ' + mb.toFixed(1) + ' MB';
651
+ });
652
+
653
+ if (dry) {
654
+ out();
655
+ pack.files.slice(0, 25).forEach((f) => out(c.dim(' ' + f)));
656
+ if (pack.files.length > 25) out(c.dim(' ... and ' + (pack.files.length - 25) + ' more'));
657
+ out(c.yellow('\n dry run: nothing was uploaded'));
658
+ return;
659
+ }
660
+
661
+ let bot = null;
662
+ await next('Preparing bot', async () => {
663
+ try {
664
+ bot = await request('GET', bp(name));
665
+ } catch (e) {
666
+ if (e.status !== 404) throw e;
667
+ }
668
+ if (!bot) {
669
+ const body = { name, language: lang };
670
+ if (lang !== 'rust') body.entry = main;
671
+ if (mem) body.mem_mb = mem;
672
+ if (flags.autostart) body.autostart = true;
673
+ bot = await request('POST', '/bots', { body });
674
+ return 'created';
675
+ }
676
+ if (bot.language !== lang) {
677
+ throw new CliError('"' + name + '" already exists as a ' + bot.language + ' bot. Delete it first or pick another name.');
678
+ }
679
+ const patch = {};
680
+ if (lang !== 'rust' && bot.entry !== main) patch.entry = main;
681
+ if (mem) patch.mem_mb = mem;
682
+ if (flags.autostart) patch.autostart = true;
683
+ if (Object.keys(patch).length) bot = await request('POST', bp(name, '/config'), { body: patch });
684
+ return 'found, updating';
685
+ });
686
+
687
+ await next('Uploading', async ({ progress }) => {
688
+ if (bot.status === 'installing') throw new CliError('an install is already running for this bot, try again in a minute');
689
+ if (bot.status === 'running') {
690
+ progress('stopping old version');
691
+ await request('POST', bp(name, '/stop'));
692
+ }
693
+ progress('sending ' + (pack.data.length / 1024 / 1024).toFixed(1) + ' MB');
694
+ const r = await request('PUT', bp(name, '/upload'), { raw: pack.data, timeout: 900000 });
695
+ return r.extracted_mb + ' MB on the server';
696
+ });
697
+
698
+ if (flags.env) {
699
+ await next('Sending env vars', async () => {
700
+ const pairs = parseEnvFile(flags.env);
701
+ if (!Object.keys(pairs).length) throw new CliError('no variables found in ' + flags.env);
702
+ const r = await request('POST', bp(name, '/env'), { body: pairs });
703
+ return r.env_keys.length + ' on the server';
704
+ });
705
+ }
706
+
707
+ if (!flags['no-install']) {
708
+ await next('Installing dependencies', ({ progress }) => installBot(name, lang, progress));
709
+ }
710
+
711
+ let finalLine = '';
712
+ await next('Starting', async () => {
713
+ await request('POST', bp(name, '/start'));
714
+ await sleep(4000);
715
+ const now = await request('GET', bp(name));
716
+ if (now.status !== 'running') {
717
+ const l = await request('GET', bp(name, '/logs') + '?lines=15&kind=run');
718
+ out();
719
+ out(c.dim(l.logs || ' (no output)'));
720
+ throw new CliError('the bot stopped right after starting (log above)');
721
+ }
722
+ finalLine = 'running (pid ' + now.pid + ')';
723
+ return finalLine;
724
+ });
725
+
726
+ const fresh = loadConfig();
727
+ fresh.bots = Object.assign({}, fresh.bots, { [name]: { path: dir, start: startStr, main: opts.main || null } });
728
+ saveConfig(fresh);
729
+
730
+ out();
731
+ out(' ' + c.green('Done') + c.dim(' in ' + Math.round((Date.now() - t0) / 1000) + 's'));
732
+ out(c.dim(' logs: host logs ' + name + ' -f stop: host stop ' + name + ' redeploy: host deploy ' + name));
733
+ out();
734
+ }
735
+
736
+ function parseDeployArgs(pos) {
737
+ const opts = { path: pos[0], start: null, main: null, name: null };
738
+ for (let i = 1; i < pos.length; i += 2) {
739
+ const key = String(pos[i]).toLowerCase();
740
+ if (!['start', 'main', 'name'].includes(key) || pos[i + 1] === undefined) {
741
+ throw new CliError('unexpected "' + pos[i] + '". Usage: host <path> start <cmd> main <file> name <bot>');
742
+ }
743
+ opts[key] = pos[i + 1];
744
+ }
745
+ return opts;
746
+ }
747
+
748
+ function printHelp() {
749
+ const h = (s) => out(c.bold(s));
750
+ out();
751
+ out(' ' + c.bold('selfnify host') + c.dim(' v' + VERSION + ' - run your bots on the Selfnify server'));
752
+ out();
753
+ h(' Deploy a bot');
754
+ out(' host <path> start <cmd> main <file> name <bot>');
755
+ out(c.dim(' host ./mybot start node main index.js name mybot'));
756
+ out(c.dim(' host . start python main main.py name musicbot'));
757
+ out(c.dim(' start = python | node | cargo main = file to run name = bot name'));
758
+ out(c.dim(' leave out start/main/name and it guesses from your folder'));
759
+ out();
760
+ h(' Manage');
761
+ out(' host start <bot> host stop <bot> host restart <bot>');
762
+ out(' host status [bot] host list');
763
+ out(' host logs <bot> [-f] [-n 50] [--install]');
764
+ out(' host delete <bot> ' + c.dim('(asks for your PIN)'));
765
+ out(' host deploy <bot> ' + c.dim('redeploy with the same settings as last time'));
766
+ out(' host env <bot> push [file] | set K=V ... | unset K ... | list');
767
+ out(' host config <bot> [--mem MB] [--autostart|--no-autostart] [--main file]');
768
+ out();
769
+ h(' Account');
770
+ out(' host login host logout host pin host help host version');
771
+ out();
772
+ h(' Flags');
773
+ out(' --mem MB --autostart --no-install --env <file> --dry-run');
774
+ out(' -f/--follow -n <lines> --install --server <url> -v/--verbose --no-color');
775
+ out();
776
+ out(c.dim(' Also works as: selfnify <same commands>'));
777
+ out();
778
+ }
779
+
780
+ async function main() {
781
+ const { flags, pos } = parseArgs(process.argv.slice(2));
782
+ state.verbose = Boolean(flags.verbose);
783
+ if (flags['no-color']) state.color = false;
784
+ if (!pos.length || (flags.help && !pos.length)) {
785
+ printHelp();
786
+ return;
787
+ }
788
+ const first = pos[0].toLowerCase();
789
+
790
+ if (!COMMANDS.has(first)) {
791
+ if (!fs.existsSync(pos[0]) && pos[1] && COMMANDS.has(String(pos[1]).toLowerCase())) {
792
+ throw new CliError('did you mean: host ' + pos[1] + ' ' + pos[0] + ' ?');
793
+ }
794
+ const opts = parseDeployArgs(pos);
795
+ await ensureLogin(flags);
796
+ await deploy(opts, flags);
797
+ return;
798
+ }
799
+
800
+ switch (first) {
801
+ case 'help':
802
+ printHelp();
803
+ return;
804
+ case 'version':
805
+ out('selfnify-host ' + VERSION);
806
+ return;
807
+ case 'login':
808
+ await cmdLogin(flags, false);
809
+ return;
810
+ case 'logout':
811
+ await cmdLogout();
812
+ return;
813
+ case 'pin':
814
+ await cmdPin();
815
+ return;
816
+ default:
817
+ break;
818
+ }
819
+
820
+ if (flags.help) {
821
+ printHelp();
822
+ return;
823
+ }
824
+
825
+ const cfg = await ensureLogin(flags);
826
+ switch (first) {
827
+ case 'start':
828
+ case 'stop':
829
+ case 'restart':
830
+ await cmdAction(first, pos[1]);
831
+ break;
832
+ case 'delete':
833
+ await cmdDelete(pos[1]);
834
+ break;
835
+ case 'status':
836
+ await cmdStatus(pos[1]);
837
+ break;
838
+ case 'list':
839
+ await cmdList();
840
+ break;
841
+ case 'logs':
842
+ await cmdLogs(pos[1], flags);
843
+ break;
844
+ case 'env':
845
+ await cmdEnv(pos[1], pos[2], pos.slice(3));
846
+ break;
847
+ case 'config':
848
+ await cmdConfig(pos[1], flags);
849
+ break;
850
+ case 'deploy': {
851
+ const name = requireName(pos[1], 'host deploy <bot>');
852
+ const saved = (cfg.bots || {})[name];
853
+ if (!saved) {
854
+ throw new CliError('no saved deploy for "' + name + '". Deploy it once with: host <path> start <cmd> main <file> name ' + name);
855
+ }
856
+ await deploy({ path: saved.path, start: saved.start, main: saved.main, name }, flags);
857
+ break;
858
+ }
859
+ default:
860
+ break;
861
+ }
862
+ }
863
+
864
+ main().catch((e) => {
865
+ if (e instanceof CliError) {
866
+ out(c.red(' error: ') + e.message);
867
+ } else {
868
+ out(c.red(' unexpected error: ') + (e && e.message ? e.message : e));
869
+ if (state.verbose && e && e.stack) out(c.dim(e.stack));
870
+ }
871
+ process.exitCode = 1;
872
+ });
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
- {
2
- "name": "selfnify-host",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
1
+ {
2
+ "name": "selfnify-host",
3
+ "version": "1.0.0",
4
+ "description": "Deploy and manage bots (Python, Node.js, Rust) on the Selfnify host with one command.",
5
+ "bin": {
6
+ "host": "host.js",
7
+ "selfnify": "host.js"
8
+ },
9
+ "files": [
10
+ "host.js"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "dependencies": {
16
+ "tar": "^7.4.3"
17
+ },
18
+ "keywords": [
19
+ "selfnify",
20
+ "bot",
21
+ "hosting",
22
+ "discord",
23
+ "cli"
24
+ ],
25
+ "license": "MIT"
6
26
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.