localvibe 2.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/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # wavecast
2
+
3
+ Stream audio from your browser tab to every device on your Wi-Fi — one command, no config.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g wavecast
9
+ ```
10
+
11
+ You also need [FFmpeg](https://ffmpeg.org):
12
+
13
+ | OS | Command |
14
+ |---|---|
15
+ | macOS | `brew install ffmpeg` |
16
+ | Windows | `winget install ffmpeg` |
17
+ | Linux | `sudo apt install ffmpeg` |
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ wavecast
23
+ ```
24
+
25
+ First launch asks for your station name, DJ name and tagline.
26
+ A session key is printed in the terminal — paste it into the extension and click **Start Broadcast**.
27
+ Share the listener URL with anyone on the same Wi-Fi.
28
+
29
+ ```bash
30
+ wavecast setup # update station name, DJ, tagline
31
+ ```
32
+
33
+ ## Extension
34
+
35
+ Load the `extension/` folder as an unpacked Chrome extension:
36
+
37
+ 1. Open `chrome://extensions`
38
+ 2. Enable **Developer mode**
39
+ 3. Click **Load unpacked** → select the `extension` folder
40
+
41
+ ## Features
42
+
43
+ - Plays on any device — Android, iPhone, desktop, smart TV
44
+ - No virtual audio driver — captures any browser tab directly
45
+ - Station name, DJ name and tagline on every listener's screen
46
+ - Live "Now Playing" auto-pulled from the tab title (YouTube, Spotify, etc.)
47
+ - Listener count shown on the player
48
+ - Session key rotates on every restart — no credentials stored on disk
49
+
50
+ ## License
51
+
52
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ const readline = require('readline');
3
+ const crypto = require('crypto');
4
+ const https = require('https');
5
+ const http = require('http');
6
+ const { execSync } = require('child_process');
7
+ const os = require('os');
8
+ const config = require('../lib/config.js');
9
+ const { createServer } = require('../lib/server.js');
10
+
11
+ const PKG_VERSION = require('../package.json').version;
12
+ const cmd = process.argv[2] || 'start';
13
+
14
+ // ── Remote update check ───────────────────────────────────────────────────────
15
+ // The developer publishes version.json to this URL and signs it with UPDATE_KEY.
16
+ // CLI verifies the signature before trusting the payload — no one else can push
17
+ // a fake forceUpdate even if they intercept the URL.
18
+ const UPDATE_URL = process.env.LOCALVIBE_UPDATE_URL || 'https://gist.githubusercontent.com/JahidDev24/03697454cf67054981b8213598bc0b85/raw/1af98afafa8f02b68ba83d518ebae2d814a20bff/version.json';
19
+ const UPDATE_KEY = process.env.LOCALVIBE_UPDATE_KEY || 'localvibe-update-v1';
20
+
21
+ function verifyPayload(body) {
22
+ try {
23
+ const obj = JSON.parse(body);
24
+ const { sig, ...payload } = obj;
25
+ if (!sig) return null; // unsigned → ignore
26
+ const expected = crypto.createHmac('sha256', UPDATE_KEY)
27
+ .update(JSON.stringify(payload))
28
+ .digest('hex');
29
+ if (!crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null;
30
+ return payload;
31
+ } catch (_) { return null; }
32
+ }
33
+
34
+ function fetchUpdate() {
35
+ return new Promise((resolve) => {
36
+ const get = UPDATE_URL.startsWith('https') ? https.get : http.get;
37
+ const req = get(UPDATE_URL, { timeout: 4000 }, (res) => {
38
+ let body = '';
39
+ res.on('data', d => { body += d; if (body.length > 4096) req.destroy(); });
40
+ res.on('end', () => resolve(verifyPayload(body)));
41
+ });
42
+ req.on('error', () => resolve(null));
43
+ req.on('timeout', () => { req.destroy(); resolve(null); });
44
+ });
45
+ }
46
+
47
+ function semverGt(a, b) {
48
+ // returns true if a > b (simple major.minor.patch compare)
49
+ const pa = String(a).split('.').map(Number);
50
+ const pb = String(b).split('.').map(Number);
51
+ for (let i = 0; i < 3; i++) {
52
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
53
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ async function checkUpdate() {
59
+ const update = await fetchUpdate();
60
+ if (!update) return; // network unavailable or bad signature → skip silently
61
+
62
+ if (update.forceUpdate && semverGt(update.version, PKG_VERSION)) {
63
+ console.error(`\n╔═══════════════════════════════════════════════════════╗`);
64
+ console.error(`║ 🚨 WAVECAST UPDATE REQUIRED ║`);
65
+ console.error(`║ Your version : ${PKG_VERSION.padEnd(38)}║`);
66
+ console.error(`║ Required : ${update.version.padEnd(38)}║`);
67
+ if (update.warning)
68
+ console.error(`║ ${update.warning.slice(0,52).padEnd(53)}║`);
69
+ console.error(`║ Run: npm install -g localvibe ║`);
70
+ console.error(`╚═══════════════════════════════════════════════════════╝\n`);
71
+ process.exit(1);
72
+ }
73
+
74
+ if (update.warning) {
75
+ console.warn(`\n⚠️ ${update.warning}\n`);
76
+ }
77
+
78
+ if (semverGt(update.version, PKG_VERSION)) {
79
+ console.log(`💡 New version ${update.version} available — npm install -g localvibe\n`);
80
+ }
81
+ }
82
+
83
+ // ── Helpers ───────────────────────────────────────────────────────────────────
84
+ function ensureFfmpeg() {
85
+ try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; }
86
+ catch (_) {
87
+ const install = process.platform === 'darwin' ? 'brew install ffmpeg'
88
+ : process.platform === 'win32' ? 'winget install ffmpeg'
89
+ : 'sudo apt install ffmpeg';
90
+ console.error(`\n⚠️ FFmpeg not found. Install it:\n\n ${install}\n`);
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function ask(rl, question, fallback) {
96
+ return new Promise((resolve) => {
97
+ rl.question(` ${question} (${fallback}): `, (a) => resolve(a.trim() || fallback));
98
+ });
99
+ }
100
+
101
+ async function runSetup(existing = {}) {
102
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
103
+ console.log('\n🎙 Welcome to Wavecast! Quick setup — press Enter to keep defaults.\n');
104
+
105
+ const cfg = {
106
+ stationName: await ask(rl, 'Station name', existing.stationName || 'Wavecast FM'),
107
+ djName: await ask(rl, 'Your name (DJ)', existing.djName || os.userInfo().username),
108
+ tagline: await ask(rl, 'Tagline', existing.tagline || 'Live from my desk'),
109
+ port: Number(await ask(rl, 'Port', String(existing.port || 8012))),
110
+ accent: existing.accent || '#6c63ff',
111
+ };
112
+
113
+ rl.close();
114
+ config.save(cfg);
115
+ console.log('\n✅ Saved!\n');
116
+ return cfg;
117
+ }
118
+
119
+ // ── Commands ──────────────────────────────────────────────────────────────────
120
+ async function start() {
121
+ if (!ensureFfmpeg()) process.exit(1);
122
+
123
+ // Run update check in background — don't block startup unless forceUpdate
124
+ checkUpdate().catch(() => {});
125
+
126
+ let cfg = config.isFirstRun()
127
+ ? await runSetup()
128
+ : config.load();
129
+
130
+ // Fresh key every session — never stored on disk
131
+ const sessionKey = crypto.randomBytes(20).toString('base64url');
132
+
133
+ createServer(cfg, sessionKey).start(sessionKey);
134
+ }
135
+
136
+ function setup() {
137
+ runSetup(config.isFirstRun() ? {} : config.load()).catch(console.error);
138
+ }
139
+
140
+ switch (cmd) {
141
+ case 'start': start().catch(console.error); break;
142
+ case 'setup':
143
+ case 'init': setup(); break;
144
+ default:
145
+ console.log(`
146
+ 🎙 localvibe — Wi-Fi audio streaming
147
+
148
+ localvibe start your station
149
+ localvibe setup change station name, DJ, tagline
150
+ `);
151
+ }
package/lib/config.js ADDED
@@ -0,0 +1,34 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), '.localvibe');
6
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
7
+
8
+ const DEFAULTS = {
9
+ stationName: 'LocalVibe FM',
10
+ djName: '',
11
+ tagline: 'Live from my desk',
12
+ accent: '#6c63ff',
13
+ port: 8012,
14
+ };
15
+
16
+ function isFirstRun() {
17
+ return !fs.existsSync(CONFIG_FILE);
18
+ }
19
+
20
+ function load() {
21
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
22
+ let cfg = {};
23
+ if (fs.existsSync(CONFIG_FILE)) {
24
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch (_) {}
25
+ }
26
+ return { ...DEFAULTS, ...cfg };
27
+ }
28
+
29
+ function save(cfg) {
30
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
31
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
32
+ }
33
+
34
+ module.exports = { load, save, isFirstRun, CONFIG_FILE, CONFIG_DIR };
package/lib/server.js ADDED
@@ -0,0 +1,303 @@
1
+ // server.js — LAN Radio core.
2
+ //
3
+ // Security model:
4
+ // • Mutating endpoints (/live, /live-chunk, /live-end, /api/nowplaying)
5
+ // require X-Broadcast-Key, verified as SHA-256 timing-safe compare.
6
+ // • Broadcast endpoints accept loopback connections only unless
7
+ // config.allowRemoteBroadcast is set.
8
+ // • Optional TLS (self-signed, SAN includes LAN IPs) for encrypted packets.
9
+ // • Failed-auth rate limiting, body-size caps, hardened response headers.
10
+ // • Public surface is read-only: player page, HLS files, status JSON.
11
+
12
+ const express = require('express');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const fs = require('fs');
16
+ const http = require('http');
17
+ const crypto = require('crypto');
18
+ const { spawn } = require('child_process');
19
+
20
+ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
21
+ const MAX_CHUNK_BYTES = 2 * 1024 * 1024; // one MediaRecorder chunk is ~32 KB
22
+ const MAX_BODY_BYTES = 16 * 1024; // for small JSON endpoints
23
+
24
+ function createServer(config, sessionKey) {
25
+ const tls = null; // HTTPS removed — key auth is the security boundary
26
+ const app = express();
27
+ app.disable('x-powered-by');
28
+
29
+ const HLS_DIR = path.join(os.tmpdir(), `localvibe-hls-${process.pid}`);
30
+ fs.mkdirSync(HLS_DIR, { recursive: true });
31
+
32
+ const keyHash = crypto.createHash('sha256').update(sessionKey).digest();
33
+
34
+ // ── State ────────────────────────────────────────────
35
+ let broadcasterConnected = false;
36
+ let broadcastStartTime = null;
37
+ let totalBytesIn = 0;
38
+ let chunkTimeout = null;
39
+ let ffmpegProc = null;
40
+ let nowPlaying = '';
41
+ const listenerSeen = new Map();
42
+ const authFailures = new Map(); // ip -> [timestamps]
43
+
44
+ // ── Hardened headers on everything ───────────────────
45
+ app.use((req, res, next) => {
46
+ res.header('Access-Control-Allow-Origin', '*');
47
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
48
+ res.header('Access-Control-Allow-Headers', 'Content-Type, X-Broadcast-Key');
49
+ res.header('X-Content-Type-Options', 'nosniff');
50
+ res.header('X-Frame-Options', 'DENY');
51
+ res.header('Referrer-Policy', 'no-referrer');
52
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
53
+ next();
54
+ });
55
+
56
+ // ── Auth: SHA-256 timing-safe broadcast key ──────────
57
+ function rateLimited(ip) {
58
+ const now = Date.now();
59
+ const fails = (authFailures.get(ip) || []).filter((t) => now - t < 60000);
60
+ authFailures.set(ip, fails);
61
+ return fails.length >= 5;
62
+ }
63
+
64
+ function requireKey(req, res, next) {
65
+ const ip = req.socket.remoteAddress || '';
66
+
67
+ if (rateLimited(ip)) return res.status(429).json({ error: 'too many attempts' });
68
+
69
+ const provided = req.headers['x-broadcast-key'] || '';
70
+ const providedHash = crypto.createHash('sha256').update(String(provided)).digest();
71
+ if (!crypto.timingSafeEqual(providedHash, keyHash)) {
72
+ (authFailures.get(ip) || authFailures.set(ip, []).get(ip)).push(Date.now());
73
+ return res.status(401).json({ error: 'invalid broadcast key' });
74
+ }
75
+ next();
76
+ }
77
+
78
+ // Collect a request body with a hard size cap
79
+ function readBody(req, res, cap, cb) {
80
+ const parts = [];
81
+ let size = 0;
82
+ req.on('data', (d) => {
83
+ size += d.length;
84
+ if (size > cap) { res.status(413).json({ error: 'payload too large' }); req.destroy(); return; }
85
+ parts.push(d);
86
+ });
87
+ req.on('end', () => { if (!res.headersSent) cb(Buffer.concat(parts)); });
88
+ }
89
+
90
+ // ── ffmpeg transcoder: audio in → HLS (fMP4/AAC) ─────
91
+ function startTranscoder() {
92
+ ffmpegProc = spawn('ffmpeg', [
93
+ '-loglevel', 'error',
94
+ '-analyzeduration', '0',
95
+ '-i', 'pipe:0',
96
+ '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2',
97
+ '-af', 'asetpts=N/SR/TB',
98
+ '-f', 'hls',
99
+ '-hls_time', '2',
100
+ '-hls_list_size', '6',
101
+ '-hls_flags', 'delete_segments+append_list+discont_start',
102
+ '-hls_segment_type', 'fmp4',
103
+ '-hls_fmp4_init_filename', 'init.mp4',
104
+ '-hls_segment_filename', path.join(HLS_DIR, 'seg%06d.m4s'),
105
+ path.join(HLS_DIR, 'live.m3u8'),
106
+ ]);
107
+ ffmpegProc.stderr.on('data', (d) => console.error(' ffmpeg:', d.toString().trim()));
108
+ ffmpegProc.stdin.on('error', () => {});
109
+ ffmpegProc.on('close', () => { ffmpegProc = null; });
110
+ }
111
+
112
+ function stopTranscoder() {
113
+ if (ffmpegProc) {
114
+ const proc = ffmpegProc;
115
+ ffmpegProc = null;
116
+ try { proc.stdin.end(); } catch (_) {}
117
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} }, 500);
118
+ }
119
+ }
120
+
121
+ function wipeHlsDir() {
122
+ for (const f of fs.readdirSync(HLS_DIR)) {
123
+ try { fs.unlinkSync(path.join(HLS_DIR, f)); } catch (_) {}
124
+ }
125
+ }
126
+
127
+ function resetBroadcast() {
128
+ broadcasterConnected = false;
129
+ broadcastStartTime = null;
130
+ stopTranscoder();
131
+ }
132
+
133
+ function isWebmHeader(chunk) {
134
+ return chunk.length >= 4 &&
135
+ chunk[0] === 0x1a && chunk[1] === 0x45 &&
136
+ chunk[2] === 0xdf && chunk[3] === 0xa3;
137
+ }
138
+
139
+ // ── Broadcast endpoints (key-protected) ──────────────
140
+ app.post('/live', requireKey, (req, res) => {
141
+ if (ffmpegProc) stopTranscoder();
142
+ wipeHlsDir();
143
+ broadcasterConnected = true;
144
+ broadcastStartTime = Date.now();
145
+ totalBytesIn = 0;
146
+ startTranscoder();
147
+ console.log('\n🎙 Broadcaster connected (direct stream)');
148
+
149
+ req.on('data', (chunk) => {
150
+ totalBytesIn += chunk.length;
151
+ try { ffmpegProc?.stdin.write(chunk); } catch (_) {}
152
+ });
153
+ req.on('end', () => { resetBroadcast(); console.log('\n🔇 Broadcaster disconnected'); res.status(200).end(); });
154
+ req.on('error', resetBroadcast);
155
+ });
156
+
157
+ app.post('/live-chunk', requireKey, (req, res) => {
158
+ readBody(req, res, MAX_CHUNK_BYTES, (chunk) => {
159
+ if (isWebmHeader(chunk)) {
160
+ if (ffmpegProc) stopTranscoder();
161
+ if (!broadcasterConnected) {
162
+ wipeHlsDir();
163
+ broadcasterConnected = true;
164
+ broadcastStartTime = Date.now();
165
+ totalBytesIn = 0;
166
+ console.log('\n🎙 Broadcaster connected (extension)');
167
+ }
168
+ startTranscoder();
169
+ } else if (!ffmpegProc) {
170
+ return res.status(200).json({ needInit: true });
171
+ }
172
+
173
+ clearTimeout(chunkTimeout);
174
+ chunkTimeout = setTimeout(() => {
175
+ resetBroadcast();
176
+ console.log('\n🔇 Broadcaster timed out');
177
+ }, 3000);
178
+
179
+ totalBytesIn += chunk.length;
180
+ try { ffmpegProc?.stdin.write(chunk); } catch (_) {}
181
+ res.status(200).json({ ok: true });
182
+ });
183
+ });
184
+
185
+ app.post('/live-end', requireKey, (_req, res) => {
186
+ clearTimeout(chunkTimeout);
187
+ resetBroadcast();
188
+ nowPlaying = '';
189
+ console.log('\n🔇 Broadcaster disconnected');
190
+ res.status(200).end();
191
+ });
192
+
193
+ app.post('/api/nowplaying', requireKey, (req, res) => {
194
+ readBody(req, res, MAX_BODY_BYTES, (body) => {
195
+ try {
196
+ const { text } = JSON.parse(body.toString('utf8'));
197
+ nowPlaying = String(text || '').slice(0, 120);
198
+ res.json({ ok: true, nowPlaying });
199
+ } catch (_) {
200
+ res.status(400).json({ error: 'bad json' });
201
+ }
202
+ });
203
+ });
204
+
205
+ // ── Public read-only endpoints ───────────────────────
206
+ app.use('/hls', (req, res, next) => {
207
+ if (req.path.endsWith('.m3u8')) {
208
+ listenerSeen.set(req.ip, Date.now());
209
+ res.setHeader('Cache-Control', 'no-cache, no-store');
210
+ } else {
211
+ res.setHeader('Cache-Control', 'max-age=60');
212
+ }
213
+ next();
214
+ }, express.static(HLS_DIR));
215
+
216
+ app.get('/status', (_req, res) => {
217
+ const now = Date.now();
218
+ let n = 0;
219
+ for (const [ip, t] of listenerSeen) {
220
+ if (now - t < 15000) n++;
221
+ else listenerSeen.delete(ip);
222
+ }
223
+ res.json({
224
+ broadcasting: broadcasterConnected,
225
+ listeners: n,
226
+ uptimeSeconds: broadcastStartTime ? Math.floor((now - broadcastStartTime) / 1000) : 0,
227
+ nowPlaying,
228
+ });
229
+ });
230
+
231
+ app.get('/api/info', (_req, res) => {
232
+ const ip = getLocalIP();
233
+ res.json({
234
+ stationName: config.stationName,
235
+ djName: config.djName,
236
+ tagline: config.tagline,
237
+ accent: config.accent,
238
+ urls: {
239
+ listen: `http://${ip}:${config.port}`,
240
+ listenSecure: config.https ? `https://${ip}:${config.httpsPort}` : null,
241
+ // mDNS name — resolves on Apple devices, Windows 10+, Android 12+
242
+ local: `http://${os.hostname()}:${config.port}`,
243
+ },
244
+ });
245
+ });
246
+
247
+ app.get('/', (_req, res) => {
248
+ res.set('Cache-Control', 'no-store');
249
+ res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
250
+ });
251
+ app.get('/hls.light.min.js', (_req, res) => {
252
+ res.set('Cache-Control', 'no-store');
253
+ res.sendFile(path.join(PUBLIC_DIR, 'hls.light.min.js'));
254
+ });
255
+ app.get('/bg.jpg', (_req, res) => {
256
+ res.set('Cache-Control', 'max-age=86400');
257
+ res.sendFile(path.join(PUBLIC_DIR, 'bg.jpg'));
258
+ });
259
+
260
+ // ── Boot ─────────────────────────────────────────────
261
+ function start(sessionKey) {
262
+ const ip = getLocalIP();
263
+ const host = os.hostname();
264
+
265
+ http.createServer(app).listen(config.port, '0.0.0.0').on('error', (e) => {
266
+ if (e.code === 'EADDRINUSE') {
267
+ console.error(`\n❌ Port ${config.port} is already in use.`);
268
+ console.error(` Kill it: lsof -ti :${config.port} | xargs kill -9\n`);
269
+ } else { console.error(e.message); }
270
+ process.exit(1);
271
+ });
272
+
273
+ const W = 54;
274
+ const pad = (s) => ('║ ' + s).padEnd(W - 1) + '║';
275
+ const line = '═'.repeat(W - 2);
276
+ console.log(`\n╔${line}╗`);
277
+ console.log(pad(`📻 ${config.stationName}`));
278
+ if (config.djName) console.log(pad(`🎧 DJ ${config.djName}`));
279
+ if (config.tagline) console.log(pad(`✨ ${config.tagline}`));
280
+ console.log(`╠${line}╣`);
281
+ console.log(pad(`Listen: http://${ip}:${config.port}`));
282
+ console.log(pad(` http://${host}:${config.port}`));
283
+ console.log(`╠${line}╣`);
284
+ console.log(pad(`🔑 Session key (paste into extension):`));
285
+ console.log(pad(` ${sessionKey}`));
286
+ console.log(`╚${line}╝`);
287
+ console.log('\n ↑ Copy the key → open extension → paste → Start Broadcast\n');
288
+ console.log('Waiting for broadcaster…\n');
289
+ }
290
+
291
+ return { app, start };
292
+ }
293
+
294
+ function getLocalIP() {
295
+ for (const ifaces of Object.values(os.networkInterfaces())) {
296
+ for (const iface of ifaces) {
297
+ if (iface.family === 'IPv4' && !iface.internal) return iface.address;
298
+ }
299
+ }
300
+ return 'localhost';
301
+ }
302
+
303
+ module.exports = { createServer };
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "localvibe",
3
+ "version": "2.0.0",
4
+ "description": "Stream audio from your browser tab to every device on your Wi-Fi — one command, no config.",
5
+ "keywords": ["radio", "streaming", "hls", "lan", "audio", "broadcast", "wifi", "ffmpeg"],
6
+ "license": "MIT",
7
+ "author": "",
8
+ "main": "lib/server.js",
9
+ "bin": {
10
+ "localvibe": "bin/cli.js"
11
+ },
12
+ "files": ["bin", "lib", "public", "scripts", "README.md"],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "start": "node bin/cli.js",
18
+ "postinstall": "node scripts/postinstall.js"
19
+ },
20
+ "dependencies": {
21
+ "express": "^4.19.0"
22
+ }
23
+ }
package/public/bg.jpg ADDED
Binary file