pal-explorer-cli 0.4.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 (156) hide show
  1. package/LICENSE.md +18 -0
  2. package/README.md +314 -0
  3. package/bin/pal.js +230 -0
  4. package/extensions/@palexplorer/analytics/README.md +45 -0
  5. package/extensions/@palexplorer/analytics/docs/MONETIZATION.md +14 -0
  6. package/extensions/@palexplorer/analytics/docs/PLAN.md +23 -0
  7. package/extensions/@palexplorer/analytics/docs/PRIVACY.md +38 -0
  8. package/extensions/@palexplorer/analytics/extension.json +27 -0
  9. package/extensions/@palexplorer/analytics/index.js +186 -0
  10. package/extensions/@palexplorer/analytics/test/analytics.test.js +82 -0
  11. package/extensions/@palexplorer/audit/extension.json +17 -0
  12. package/extensions/@palexplorer/audit/index.js +2 -0
  13. package/extensions/@palexplorer/auth-email/extension.json +17 -0
  14. package/extensions/@palexplorer/auth-email/index.js +102 -0
  15. package/extensions/@palexplorer/auth-oauth/extension.json +16 -0
  16. package/extensions/@palexplorer/auth-oauth/index.js +199 -0
  17. package/extensions/@palexplorer/chat/extension.json +17 -0
  18. package/extensions/@palexplorer/chat/index.js +2 -0
  19. package/extensions/@palexplorer/discovery/extension.json +16 -0
  20. package/extensions/@palexplorer/discovery/index.js +111 -0
  21. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  22. package/extensions/@palexplorer/email-notifications/index.js +242 -0
  23. package/extensions/@palexplorer/explorer-integration/extension.json +13 -0
  24. package/extensions/@palexplorer/explorer-integration/index.js +122 -0
  25. package/extensions/@palexplorer/groups/extension.json +17 -0
  26. package/extensions/@palexplorer/groups/index.js +2 -0
  27. package/extensions/@palexplorer/networks/extension.json +17 -0
  28. package/extensions/@palexplorer/networks/index.js +2 -0
  29. package/extensions/@palexplorer/share-links/extension.json +17 -0
  30. package/extensions/@palexplorer/share-links/index.js +2 -0
  31. package/extensions/@palexplorer/sync/extension.json +17 -0
  32. package/extensions/@palexplorer/sync/index.js +2 -0
  33. package/extensions/@palexplorer/user-mgmt/extension.json +17 -0
  34. package/extensions/@palexplorer/user-mgmt/index.js +2 -0
  35. package/extensions/@palexplorer/vfs/extension.json +17 -0
  36. package/extensions/@palexplorer/vfs/index.js +167 -0
  37. package/lib/capabilities.js +263 -0
  38. package/lib/commands/analytics.js +175 -0
  39. package/lib/commands/api-keys.js +131 -0
  40. package/lib/commands/audit.js +235 -0
  41. package/lib/commands/auth.js +137 -0
  42. package/lib/commands/backup.js +76 -0
  43. package/lib/commands/billing.js +148 -0
  44. package/lib/commands/chat.js +217 -0
  45. package/lib/commands/cloud-backup.js +231 -0
  46. package/lib/commands/comment.js +99 -0
  47. package/lib/commands/completion.js +203 -0
  48. package/lib/commands/compliance.js +218 -0
  49. package/lib/commands/config.js +136 -0
  50. package/lib/commands/connect.js +44 -0
  51. package/lib/commands/dept.js +294 -0
  52. package/lib/commands/device.js +146 -0
  53. package/lib/commands/download.js +226 -0
  54. package/lib/commands/explorer.js +178 -0
  55. package/lib/commands/extension.js +970 -0
  56. package/lib/commands/favorite.js +90 -0
  57. package/lib/commands/federation.js +270 -0
  58. package/lib/commands/file.js +533 -0
  59. package/lib/commands/group.js +271 -0
  60. package/lib/commands/gui-share.js +29 -0
  61. package/lib/commands/init.js +61 -0
  62. package/lib/commands/invite.js +59 -0
  63. package/lib/commands/list.js +59 -0
  64. package/lib/commands/log.js +116 -0
  65. package/lib/commands/nearby.js +108 -0
  66. package/lib/commands/network.js +251 -0
  67. package/lib/commands/notify.js +198 -0
  68. package/lib/commands/org.js +273 -0
  69. package/lib/commands/pal.js +180 -0
  70. package/lib/commands/permissions.js +216 -0
  71. package/lib/commands/pin.js +97 -0
  72. package/lib/commands/protocol.js +357 -0
  73. package/lib/commands/rbac.js +147 -0
  74. package/lib/commands/recover.js +36 -0
  75. package/lib/commands/register.js +171 -0
  76. package/lib/commands/relay.js +131 -0
  77. package/lib/commands/remote.js +368 -0
  78. package/lib/commands/revoke.js +50 -0
  79. package/lib/commands/scanner.js +280 -0
  80. package/lib/commands/schedule.js +344 -0
  81. package/lib/commands/scim.js +203 -0
  82. package/lib/commands/search.js +181 -0
  83. package/lib/commands/serve.js +438 -0
  84. package/lib/commands/server.js +350 -0
  85. package/lib/commands/share-link.js +199 -0
  86. package/lib/commands/share.js +323 -0
  87. package/lib/commands/sso.js +200 -0
  88. package/lib/commands/status.js +136 -0
  89. package/lib/commands/stream.js +562 -0
  90. package/lib/commands/su.js +187 -0
  91. package/lib/commands/sync.js +827 -0
  92. package/lib/commands/transfers.js +152 -0
  93. package/lib/commands/uninstall.js +188 -0
  94. package/lib/commands/update.js +204 -0
  95. package/lib/commands/user.js +276 -0
  96. package/lib/commands/vfs.js +84 -0
  97. package/lib/commands/web.js +52 -0
  98. package/lib/commands/webhook.js +180 -0
  99. package/lib/commands/whoami.js +59 -0
  100. package/lib/commands/workspace.js +121 -0
  101. package/lib/core/accessLog.js +54 -0
  102. package/lib/core/analytics.js +99 -0
  103. package/lib/core/backup.js +84 -0
  104. package/lib/core/billing.js +336 -0
  105. package/lib/core/bitfieldStore.js +53 -0
  106. package/lib/core/connectionManager.js +182 -0
  107. package/lib/core/dhtDiscovery.js +148 -0
  108. package/lib/core/discoveryClient.js +408 -0
  109. package/lib/core/extensionAnalyzer.js +357 -0
  110. package/lib/core/extensionSandbox.js +250 -0
  111. package/lib/core/extensionWorkerHost.js +166 -0
  112. package/lib/core/extensions.js +1082 -0
  113. package/lib/core/fileDiff.js +69 -0
  114. package/lib/core/groups.js +119 -0
  115. package/lib/core/identity.js +340 -0
  116. package/lib/core/mdnsService.js +126 -0
  117. package/lib/core/networks.js +81 -0
  118. package/lib/core/permissions.js +109 -0
  119. package/lib/core/pro.js +27 -0
  120. package/lib/core/resolver.js +74 -0
  121. package/lib/core/serverList.js +224 -0
  122. package/lib/core/sharePolicy.js +69 -0
  123. package/lib/core/shares.js +325 -0
  124. package/lib/core/signalingServer.js +441 -0
  125. package/lib/core/streamTransport.js +106 -0
  126. package/lib/core/su.js +55 -0
  127. package/lib/core/syncEngine.js +264 -0
  128. package/lib/core/syncState.js +159 -0
  129. package/lib/core/transfers.js +259 -0
  130. package/lib/core/users.js +225 -0
  131. package/lib/core/vfs.js +216 -0
  132. package/lib/core/webServer.js +702 -0
  133. package/lib/core/webrtcStream.js +396 -0
  134. package/lib/crypto/chatEncryption.js +57 -0
  135. package/lib/crypto/shareEncryption.js +195 -0
  136. package/lib/crypto/sharePassword.js +35 -0
  137. package/lib/crypto/streamEncryption.js +189 -0
  138. package/lib/package.json +1 -0
  139. package/lib/protocol/envelope.js +271 -0
  140. package/lib/protocol/handler.js +191 -0
  141. package/lib/protocol/index.js +27 -0
  142. package/lib/protocol/messages.js +247 -0
  143. package/lib/protocol/negotiation.js +127 -0
  144. package/lib/protocol/policy.js +142 -0
  145. package/lib/protocol/router.js +86 -0
  146. package/lib/protocol/sync.js +122 -0
  147. package/lib/utils/cli.js +15 -0
  148. package/lib/utils/config.js +123 -0
  149. package/lib/utils/configIntegrity.js +87 -0
  150. package/lib/utils/downloadDir.js +9 -0
  151. package/lib/utils/explorer.js +83 -0
  152. package/lib/utils/format.js +12 -0
  153. package/lib/utils/help.js +357 -0
  154. package/lib/utils/logger.js +103 -0
  155. package/lib/utils/torrent.js +203 -0
  156. package/package.json +71 -0
@@ -0,0 +1,438 @@
1
+ import WebTorrent from 'webtorrent';
2
+ import chalk from 'chalk';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { spawn } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+ import { listShares, getShareKey, isShareExpired, expireOldShares } from '../core/shares.js';
8
+ import { getTransfers, updateTransferProgress, completeTransfer } from '../core/transfers.js';
9
+ import { encryptForSeed, getEncryptedSeedDir } from '../crypto/streamEncryption.js';
10
+ import { generateShareKey } from '../crypto/shareEncryption.js';
11
+ import config from '../utils/config.js';
12
+ import logger from '../utils/logger.js';
13
+ import { requireRole } from '../core/users.js';
14
+ import { startWebServer } from '../core/webServer.js';
15
+ import { getPrimaryServer, postTo } from '../core/discoveryClient.js';
16
+ import { startMdns } from '../core/mdnsService.js';
17
+ import { connect, getConnectionState } from '../core/connectionManager.js';
18
+ import { startAsync as startSignaling, setMessageHandler, stop as stopSignaling } from '../core/signalingServer.js';
19
+ import { DEFAULT_TRACKERS } from '../utils/torrent.js';
20
+ import os from 'os';
21
+
22
+ function getLanIp() {
23
+ const interfaces = os.networkInterfaces();
24
+ const candidates = [];
25
+ for (const name of Object.keys(interfaces)) {
26
+ for (const iface of interfaces[name]) {
27
+ if (iface.family === 'IPv4' && !iface.internal) {
28
+ // Prefer 192.168.x.x and 10.x.x.x over virtual adapters (172.x usually WSL/Docker)
29
+ const priority = iface.address.startsWith('192.168.') || iface.address.startsWith('10.') ? 0 : 1;
30
+ candidates.push({ address: iface.address, priority, name });
31
+ }
32
+ }
33
+ }
34
+ candidates.sort((a, b) => a.priority - b.priority);
35
+ return candidates[0]?.address || null;
36
+ }
37
+
38
+ const pidFile = () => path.join(path.dirname(config.path), 'serve.pid');
39
+
40
+ export default function serveCommand(program) {
41
+ program
42
+ .command('serve')
43
+ .description('start the sharing daemon (seeds all active shares)')
44
+ .option('--daemon', 'Run as a background daemon')
45
+ .option('--stop', 'Stop a running daemon')
46
+ .option('--web', 'Start the web dashboard alongside the seeder')
47
+ .option('--web-port <port>', 'Web dashboard port (default: 8585)', '8585')
48
+ .option('--lan', 'Bind web server to 0.0.0.0 for LAN access')
49
+ .option('--public-ip <ip>', 'Public IP/hostname for serveUrl announcement (for VPS/cloud)')
50
+ .addHelpText('after', `
51
+ Examples:
52
+ $ pe serve Start seeding in foreground
53
+ $ pe serve --daemon Start as background daemon
54
+ $ pe serve --stop Stop the daemon
55
+ $ pe serve --web Start with web dashboard
56
+ $ pe serve --web --web-port 9090 Custom web dashboard port
57
+ $ pe serve --web --lan --public-ip 1.2.3.4 VPS with public IP
58
+ `)
59
+ .action(async (opts) => {
60
+ try { requireRole('user'); } catch (e) {
61
+ console.log(chalk.red(e.message));
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+ if (opts.stop) {
66
+ const pidPath = pidFile();
67
+ if (!fs.existsSync(pidPath)) {
68
+ console.log(chalk.yellow('No daemon PID file found.'));
69
+ return;
70
+ }
71
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10);
72
+ try {
73
+ process.kill(pid, 0); // check if alive
74
+ process.kill(pid, 'SIGTERM');
75
+ fs.unlinkSync(pidPath);
76
+ console.log(chalk.green(`Daemon stopped (PID ${pid}).`));
77
+ } catch (err) {
78
+ fs.unlinkSync(pidPath);
79
+ if (err.code === 'ESRCH') {
80
+ console.log(chalk.yellow(`Daemon (PID ${pid}) was not running. Cleaned up stale PID file.`));
81
+ } else {
82
+ console.log(chalk.red(`Failed to stop daemon (PID ${pid}): ${err.message}`));
83
+ process.exitCode = 1;
84
+ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ if (opts.daemon) {
90
+ const binPath = fileURLToPath(new URL('../../bin/pal.js', import.meta.url));
91
+ const args = [binPath, 'serve'];
92
+ if (opts.web) {
93
+ args.push('--web');
94
+ if (opts.webPort && opts.webPort !== '8585') args.push('--web-port', opts.webPort);
95
+ if (opts.lan) args.push('--lan');
96
+ if (opts.publicIp) args.push('--public-ip', opts.publicIp);
97
+ }
98
+ const child = spawn(process.execPath, args, {
99
+ detached: true,
100
+ stdio: 'ignore'
101
+ });
102
+ child.unref();
103
+ fs.writeFileSync(pidFile(), String(child.pid));
104
+ console.log(chalk.green(`Daemon started (PID: ${child.pid})`));
105
+ return;
106
+ }
107
+
108
+ // ── Start P2P services FIRST (signaling, mDNS, DHT) ──
109
+ // These must start before seeding so peers can query us immediately
110
+
111
+ // Start PAL/1.0 signaling server (port 7474)
112
+ let signalingActive = false;
113
+ try {
114
+ const { handleIncoming } = await import('../protocol/handler.js');
115
+ const identity = config.get('identity');
116
+ if (identity?.privateKey) {
117
+ const keyPair = {
118
+ publicKey: Buffer.from(identity.publicKey, 'hex'),
119
+ privateKey: Buffer.from(identity.privateKey, 'hex'),
120
+ };
121
+ setMessageHandler((envelope) => handleIncoming(envelope, keyPair));
122
+ }
123
+ const sigResult = await startSignaling();
124
+ signalingActive = sigResult.bound;
125
+ if (signalingActive) {
126
+ console.log(chalk.cyan('PAL/1.0 signaling server active (port 7474)'));
127
+ } else {
128
+ console.log(chalk.yellow(`Signaling server failed to bind (${sigResult.error || 'unknown'})`));
129
+ }
130
+ } catch (err) {
131
+ console.error(chalk.red(`Failed to start signaling: ${err.message}`));
132
+ }
133
+
134
+ // Start mDNS background advertisement (only if signaling bound successfully)
135
+ if (signalingActive) {
136
+ try {
137
+ const identity = config.get('identity');
138
+ if (identity) {
139
+ startMdns(identity, {});
140
+ console.log(chalk.cyan('mDNS discovery active (LAN peer detection)'));
141
+ }
142
+ } catch { /* non-fatal */ }
143
+ } else {
144
+ console.log(chalk.yellow('Skipping mDNS — signaling server not active (port in use by another instance?)'));
145
+ }
146
+
147
+ // Re-publish to DHT every 30 minutes
148
+ const identity = config.get('identity');
149
+ if (identity?.handle && identity?.privateKey && identity?.publicKey) {
150
+ const republishDHT = async () => {
151
+ try {
152
+ const { DHTDiscovery } = await import('../core/dhtDiscovery.js');
153
+ const dht = new DHTDiscovery();
154
+ const dhtHash = await dht.publish(identity.handle, identity.publicKey, identity.privateKey);
155
+ const cache = config.get('handleCache') || {};
156
+ if (!cache[identity.handle]) cache[identity.handle] = {};
157
+ cache[identity.handle].dhtHash = dhtHash;
158
+ config.set('handleCache', cache);
159
+ logger.info('DHT re-published', { handle: identity.handle, hash: dhtHash.slice(0, 12) });
160
+ dht.destroy();
161
+ } catch (err) {
162
+ logger.warn(`DHT re-publish failed: ${err.message}`);
163
+ }
164
+ };
165
+ republishDHT();
166
+ setInterval(republishDHT, 30 * 60 * 1000);
167
+ }
168
+
169
+ // Register device with discovery server so peers can find us
170
+ try {
171
+ const ident = config.get('identity');
172
+ const device = config.get('device');
173
+ if (ident?.handle && ident?.publicKey && device?.id) {
174
+ const { getIdentity: getFullIdentity } = await import('../core/identity.js');
175
+ const fullIdent = await getFullIdentity();
176
+ if (fullIdent?.privateKey) {
177
+ const { signMessage } = await import('../crypto/chatEncryption.js');
178
+ const timestamp = String(Date.now());
179
+ const sigMsg = `${ident.handle}:${device.id}:${device.name}:${timestamp}`;
180
+ const signature = signMessage(sigMsg, fullIdent.privateKey);
181
+ const serverUrl = getPrimaryServer();
182
+ postTo(serverUrl, '/devices/register', {
183
+ handle: ident.handle,
184
+ deviceId: device.id,
185
+ deviceName: device.name,
186
+ publicKey: ident.publicKey,
187
+ timestamp,
188
+ signature,
189
+ }).then(() => console.log(chalk.cyan('Device registered with discovery server')))
190
+ .catch(() => {});
191
+ }
192
+ }
193
+ } catch {}
194
+
195
+ // Connect to network in background (don't block serving)
196
+ const connState = getConnectionState();
197
+ if (connState.status !== 'connected') {
198
+ console.log(chalk.blue('Connecting to network...'));
199
+ connect({
200
+ onProgress: (step) => console.log(chalk.gray(` ${step}`)),
201
+ }).catch(() => console.log(chalk.yellow('Network connect failed — serving locally only')));
202
+ }
203
+
204
+ // ── Seed shares via WebTorrent ──
205
+ const shares = listShares();
206
+ if (shares.length === 0) {
207
+ console.log(chalk.gray('No active shares to seed. Signaling server still running.'));
208
+ }
209
+
210
+ const client = new WebTorrent();
211
+ client.setMaxListeners(Math.max(shares.length, 10) + 20);
212
+
213
+ // Auto-expire old shares before seeding
214
+ expireOldShares();
215
+
216
+ if (shares.length > 0) {
217
+ logger.info('Starting Pal Explorer Seeder...', { shares: shares.length });
218
+ console.log(chalk.blue(`Seeding ${shares.length} share(s)...`));
219
+ }
220
+
221
+ for (const share of shares) {
222
+ try {
223
+ if (isShareExpired(share)) {
224
+ console.log(chalk.gray(`Skipping ${share.name}: expired`));
225
+ continue;
226
+ }
227
+
228
+ if (!fs.existsSync(share.path)) {
229
+ console.log(chalk.yellow(`Skipping ${share.name}: path not found (${share.path})`));
230
+ continue;
231
+ }
232
+
233
+ let seedPath = share.path;
234
+
235
+ // Non-recursive: seed only top-level files
236
+ if (share.recursive === false && fs.statSync(share.path).isDirectory()) {
237
+ const items = fs.readdirSync(share.path, { withFileTypes: true });
238
+ const topFiles = items.filter(i => i.isFile()).map(i => path.join(share.path, i.name));
239
+ if (topFiles.length === 0) {
240
+ console.log(chalk.yellow(`Skipping ${share.name}: no top-level files (non-recursive).`));
241
+ continue;
242
+ }
243
+ seedPath = topFiles;
244
+ }
245
+
246
+ if (share.visibility === 'private') {
247
+ const shareKeyHex = await getShareKey(share.id);
248
+ if (shareKeyHex) {
249
+ const shareKey = Buffer.from(shareKeyHex, 'hex');
250
+ const encDir = share.encryptedPath || getEncryptedSeedDir(share.id);
251
+ if (!fs.existsSync(path.join(encDir, '.manifest.enc'))) {
252
+ console.log(chalk.gray(` Encrypting ${share.name} for E2EE seeding...`));
253
+ encryptForSeed(share.path, encDir, shareKey);
254
+ const allShares = config.get('shares') || [];
255
+ const entry = allShares.find(s => s.id === share.id);
256
+ if (entry) {
257
+ entry.encryptedPath = encDir;
258
+ config.set('shares', allShares);
259
+ }
260
+ }
261
+ seedPath = encDir;
262
+ } else if (share.encryptedPath && fs.existsSync(share.encryptedPath)) {
263
+ seedPath = share.encryptedPath;
264
+ }
265
+ }
266
+
267
+ console.log(chalk.yellow(`Initializing: ${share.name} (${share.visibility})`));
268
+
269
+ const options = {
270
+ name: share.name,
271
+ announce: share.visibility === 'private' ? [] : [...DEFAULT_TRACKERS]
272
+ };
273
+
274
+ client.seed(seedPath, options, async (torrent) => {
275
+ logger.info(`Seeding: ${share.name}`, { magnet: torrent.magnetURI, size: torrent.length });
276
+ console.log(chalk.green(`✔ Seeding: ${share.name}`));
277
+ if (share.visibility === 'global') {
278
+ console.log(chalk.cyan(` Magnet: ${torrent.magnetURI}`));
279
+ } else {
280
+ console.log(chalk.red(` [PRIVATE E2EE] Encrypted transfer only.`));
281
+ console.log(chalk.gray(` Secret Magnet: ${torrent.magnetURI}`));
282
+ }
283
+ console.log(chalk.gray(` Size: ${(torrent.length / 1024 / 1024).toFixed(2)} MB`));
284
+
285
+ const allShares = config.get('shares') || [];
286
+ const entry = allShares.find(s => s.path === share.path);
287
+ const isNewMagnet = entry && !entry.magnet;
288
+ if (entry) {
289
+ if (isNewMagnet) entry.magnet = torrent.magnetURI;
290
+ entry.files = torrent.files.map(f => ({ name: f.name, path: f.path, size: f.length }));
291
+ config.set('shares', allShares);
292
+ }
293
+
294
+ // Send share invite to recipients via discovery server inbox
295
+ if (isNewMagnet && share.visibility === 'private' && share.recipients?.length > 0) {
296
+ const ident = config.get('identity');
297
+ const discoveryUrl = getPrimaryServer();
298
+ const { getFriends } = await import('../core/users.js');
299
+ const friends = getFriends();
300
+ const encKeys = share.encryptedShareKeys || {};
301
+
302
+ for (const recipient of share.recipients) {
303
+ const pal = friends.find(f => f.id === recipient.id || f.name === recipient.name);
304
+ const handle = pal?.handle || recipient.handle;
305
+ if (!handle) continue;
306
+
307
+ const invite = {
308
+ type: 'share-invite',
309
+ shareName: share.name,
310
+ shareId: share.id,
311
+ magnet: torrent.magnetURI,
312
+ encryptedShareKey: encKeys[pal?.id || recipient.id] || null,
313
+ from: ident?.handle || ident?.name || 'unknown',
314
+ };
315
+
316
+ try {
317
+ const res = await postTo(discoveryUrl, '/messages', { toHandle: handle, fromHandle: ident?.handle, payload: JSON.stringify(invite) });
318
+ if (res.ok) {
319
+ console.log(chalk.cyan(` Sent share invite to @${handle}`));
320
+ } else {
321
+ console.log(chalk.yellow(` Failed to notify @${handle}: ${res.status}`));
322
+ }
323
+ } catch {
324
+ console.log(chalk.yellow(` Could not reach discovery server to notify @${handle}`));
325
+ }
326
+ }
327
+ }
328
+ });
329
+ } catch (err) {
330
+ logger.error(`Failed to seed ${share.name}: ${err.message}`, { share: share.id });
331
+ console.error(chalk.red(`Failed to seed ${share.name}: ${err.message}`));
332
+ }
333
+ }
334
+
335
+ // Auto-resume incomplete downloads (skip paused)
336
+ const pendingDownloads = getTransfers().filter(t => !t.paused && t.status !== 'completed');
337
+ if (pendingDownloads.length > 0) {
338
+ console.log(chalk.blue(`Resuming ${pendingDownloads.length} incomplete download(s)...`));
339
+ for (const t of pendingDownloads) {
340
+ try {
341
+ const torrent = client.add(t.magnet, { path: t.savePath });
342
+ torrent.on('download', () => updateTransferProgress(t.magnet, torrent.progress));
343
+ torrent.on('done', () => {
344
+ completeTransfer(t.magnet);
345
+ console.log(chalk.green(`✔ Download complete: ${t.name}`));
346
+ });
347
+ console.log(chalk.yellow(` Resuming: ${t.name || t.magnet.slice(0, 40) + '...'}`));
348
+ } catch (err) {
349
+ console.error(chalk.red(`Failed to resume download: ${err.message}`));
350
+ }
351
+ }
352
+ }
353
+
354
+ // Start web dashboard if requested
355
+ if (opts.web) {
356
+ try {
357
+ const webPort = parseInt(opts.webPort, 10);
358
+ const bindAddress = opts.lan ? '0.0.0.0' : '127.0.0.1';
359
+ const { token } = await startWebServer(webPort, client, { bindAddress });
360
+ console.log('');
361
+ const announceIp = opts.publicIp || getLanIp() || 'localhost';
362
+ const webServeUrl = `http://${announceIp}:${webPort}`;
363
+ if (opts.lan) {
364
+ console.log(chalk.green(`Web dashboard running at ${webServeUrl}?token=${token}`));
365
+ console.log(chalk.dim(` (LAN accessible, browse from other devices)`));
366
+ } else {
367
+ console.log(chalk.green(`Web dashboard running at http://localhost:${webPort}?token=${token}`));
368
+ }
369
+
370
+ const { hooks } = await import('../core/extensions.js');
371
+ await hooks.emit('on:server:start', { port: webPort, url: webServeUrl });
372
+ } catch (err) {
373
+ console.error(chalk.red(`Failed to start web dashboard: ${err.message}`));
374
+ }
375
+ }
376
+
377
+ // Store torrent port in config once WebTorrent is listening
378
+ const storeTorrentPort = () => {
379
+ const torrentPort = client.address()?.port || null;
380
+ if (torrentPort) {
381
+ config.set('torrentPort', torrentPort);
382
+ // Update mDNS with port info
383
+ try {
384
+ const ident = config.get('identity');
385
+ if (ident) {
386
+ const webPort = opts.web ? parseInt(opts.webPort, 10) : null;
387
+ import('../core/mdnsService.js').then(({ stopMdns }) => {
388
+ stopMdns();
389
+ startMdns(ident, { torrentPort, webPort });
390
+ });
391
+ }
392
+ } catch { /* non-fatal */ }
393
+ }
394
+ };
395
+ // Try immediately, then retry after a delay (WebTorrent may not be listening yet)
396
+ storeTorrentPort();
397
+ setTimeout(storeTorrentPort, 5000);
398
+ setTimeout(storeTorrentPort, 15000);
399
+
400
+ console.log('');
401
+ console.log(chalk.bgBlue('Daemon is running. Press Ctrl+C to stop sharing.'));
402
+ console.log('');
403
+
404
+ // Bug #20 fix: auto-seed new shares created after daemon started
405
+ const seededPaths = new Set(shares.map(s => s.path));
406
+ setInterval(() => {
407
+ try {
408
+ expireOldShares();
409
+ const currentShares = listShares();
410
+ for (const share of currentShares) {
411
+ if (seededPaths.has(share.path)) continue;
412
+ if (isShareExpired(share)) continue;
413
+ if (!fs.existsSync(share.path)) continue;
414
+ seededPaths.add(share.path);
415
+ const seedOpts = { name: share.name, announce: share.visibility === 'private' ? [] : [...DEFAULT_TRACKERS] };
416
+ client.seed(share.path, seedOpts, (torrent) => {
417
+ console.log(chalk.green(`✔ Auto-seeding new share: ${share.name}`));
418
+ const allShares = config.get('shares') || [];
419
+ const entry = allShares.find(s => s.path === share.path);
420
+ if (entry && !entry.magnet) {
421
+ entry.magnet = torrent.magnetURI;
422
+ entry.files = torrent.files.map(f => ({ name: f.name, path: f.path, size: f.length }));
423
+ config.set('shares', allShares);
424
+ }
425
+ });
426
+ }
427
+ } catch {}
428
+ }, 60000);
429
+
430
+ // Periodic stats every 30 seconds
431
+ setInterval(() => {
432
+ const up = (client.uploadSpeed / 1024).toFixed(1);
433
+ const down = (client.downloadSpeed / 1024).toFixed(1);
434
+ const count = client.torrents.length;
435
+ console.log(chalk.gray(`[stats] ↑ ${up} KB/s ↓ ${down} KB/s torrents: ${count}`));
436
+ }, 30000);
437
+ });
438
+ }