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,350 @@
1
+ import chalk from 'chalk';
2
+ import config from '../utils/config.js';
3
+ import { spawn, exec } from 'child_process';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import fs from 'fs';
7
+ import readline from 'readline';
8
+ import { getPrimaryServer, getServers, checkServer, verifyAllServers, fetchServerKey, acceptServerKey, keyFingerprint } from '../core/discoveryClient.js';
9
+ import { refreshServerList, healthCheckServers, getServerRoles, SERVER_ROLES, isWriteTrusted } from '../core/serverList.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ function confirm(question) {
14
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
15
+ return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim().toLowerCase() === 'y'); }));
16
+ }
17
+
18
+ async function installWindows() {
19
+ const pePath = process.execPath;
20
+ const scriptPath = path.resolve(__dirname, '../../bin/pal.js');
21
+ const taskName = 'PalexplorerServe';
22
+ const cmd = `schtasks /create /tn "${taskName}" /tr "\\"${pePath}\\" \\"${scriptPath}\\" serve" /sc onlogon /rl limited /f`;
23
+
24
+ return new Promise((resolve) => {
25
+ exec(cmd, (err, stdout, stderr) => {
26
+ if (err) {
27
+ console.log(chalk.yellow('Could not create scheduled task automatically.'));
28
+ console.log(chalk.gray('To install manually, create a scheduled task:'));
29
+ console.log(chalk.gray(` schtasks /create /tn "${taskName}" /tr "pe serve" /sc onlogon /rl limited`));
30
+ return resolve();
31
+ }
32
+ console.log(chalk.green(`Scheduled task "${taskName}" created. pe serve will run on login.`));
33
+ resolve();
34
+ });
35
+ });
36
+ }
37
+
38
+ async function installLinux() {
39
+ const serviceSource = path.resolve(__dirname, '../../installer/linux/palexplorer.service');
40
+ const serviceDir = path.join(process.env.HOME, '.config/systemd/user');
41
+ const serviceDest = path.join(serviceDir, 'palexplorer.service');
42
+
43
+ try {
44
+ fs.mkdirSync(serviceDir, { recursive: true });
45
+
46
+ if (fs.existsSync(serviceSource)) {
47
+ fs.copyFileSync(serviceSource, serviceDest);
48
+ } else {
49
+ const serviceContent = `[Unit]
50
+ Description=Palexplorer P2P File Sharing Daemon
51
+ After=network-online.target
52
+ Wants=network-online.target
53
+
54
+ [Service]
55
+ Type=simple
56
+ ExecStart=${process.execPath} ${path.resolve(__dirname, '../../bin/pal.js')} serve
57
+ Restart=on-failure
58
+ RestartSec=10
59
+ Environment=NODE_ENV=production
60
+
61
+ [Install]
62
+ WantedBy=default.target
63
+ `;
64
+ fs.writeFileSync(serviceDest, serviceContent);
65
+ }
66
+
67
+ console.log(chalk.green(`Service file written to ${serviceDest}`));
68
+ console.log(chalk.gray('Enable with:'));
69
+ console.log(chalk.gray(' systemctl --user daemon-reload'));
70
+ console.log(chalk.gray(' systemctl --user enable --now palexplorer'));
71
+ } catch (err) {
72
+ console.log(chalk.red('Failed to install systemd service:'), err.message);
73
+ }
74
+ }
75
+
76
+ async function installMacOS() {
77
+ const plistDir = path.join(process.env.HOME, 'Library/LaunchAgents');
78
+ const plistPath = path.join(plistDir, 'com.palexplorer.serve.plist');
79
+ const pePath = process.execPath;
80
+ const scriptPath = path.resolve(__dirname, '../../bin/pal.js');
81
+
82
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
83
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
84
+ <plist version="1.0">
85
+ <dict>
86
+ <key>Label</key>
87
+ <string>com.palexplorer.serve</string>
88
+ <key>ProgramArguments</key>
89
+ <array>
90
+ <string>${pePath}</string>
91
+ <string>${scriptPath}</string>
92
+ <string>serve</string>
93
+ </array>
94
+ <key>RunAtLoad</key>
95
+ <true/>
96
+ <key>KeepAlive</key>
97
+ <true/>
98
+ <key>StandardOutPath</key>
99
+ <string>/tmp/palexplorer.log</string>
100
+ <key>StandardErrorPath</key>
101
+ <string>/tmp/palexplorer.err</string>
102
+ </dict>
103
+ </plist>
104
+ `;
105
+
106
+ try {
107
+ fs.mkdirSync(plistDir, { recursive: true });
108
+ fs.writeFileSync(plistPath, plistContent);
109
+ console.log(chalk.green(`LaunchAgent written to ${plistPath}`));
110
+ console.log(chalk.gray('Load with:'));
111
+ console.log(chalk.gray(` launchctl load ${plistPath}`));
112
+ } catch (err) {
113
+ console.log(chalk.red('Failed to install LaunchAgent:'), err.message);
114
+ }
115
+ }
116
+
117
+ export default function serverCommand(program) {
118
+ const server = program.command('server').description('provision and manage Palexplorer server nodes')
119
+ .addHelpText('after', `
120
+ Examples:
121
+ $ pe server install Install as system service
122
+ $ pe server status Check service status
123
+ $ pe server config Show server configuration
124
+ $ pe server register Register device with discovery server
125
+ `);
126
+
127
+ server
128
+ .command('install')
129
+ .description('install pe serve as a background service (cross-platform)')
130
+ .action(async () => {
131
+ const platform = process.platform;
132
+ console.log(chalk.blue(`Installing Palexplorer service for ${platform}...`));
133
+
134
+ if (platform === 'win32') {
135
+ await installWindows();
136
+ } else if (platform === 'linux') {
137
+ await installLinux();
138
+ } else if (platform === 'darwin') {
139
+ await installMacOS();
140
+ } else {
141
+ console.log(chalk.red(`Unsupported platform: ${platform}`));
142
+ }
143
+ });
144
+
145
+ server
146
+ .command('config <key> <value>')
147
+ .description('set server-specific operational parameters')
148
+ .action((key, value) => {
149
+ // Validate common keys
150
+ const validKeys = ['port', 'storage_path', 'max_connections', 'bandwidth_cap'];
151
+ if (!validKeys.includes(key)) {
152
+ console.log(chalk.yellow(`Warning: '${key}' is not a standard server parameter. Setting anyway.`));
153
+ }
154
+
155
+ const settings = config.get('settings') || {};
156
+ settings[key] = value;
157
+ config.set('settings', settings);
158
+ console.log(chalk.green(`✔ Server configuration updated: ${key} = ${value}`));
159
+ });
160
+
161
+ server
162
+ .command('register <handle>')
163
+ .description('register this server node with a handle in the Discovery Network')
164
+ .action(async (handle) => {
165
+ const DISCOVERY_URL = getPrimaryServer();
166
+ const { getIdentity, setIdentityHandle } = await import('../core/identity.js');
167
+ const identity = await getIdentity();
168
+ if (!identity || !identity.privateKey) {
169
+ console.log(chalk.red('No identity found. Run `pe init <name>` first.'));
170
+ return;
171
+ }
172
+
173
+ const sodium = (await import('sodium-native')).default;
174
+ const privateKey = Buffer.from(identity.privateKey, 'hex');
175
+
176
+ try {
177
+ console.log(chalk.blue(`Registering handle @${handle} on discovery server...`));
178
+ // Step 1: Get challenge
179
+ const chalRes = await fetch(`${DISCOVERY_URL}/register/challenge`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ handle, publicKey: identity.publicKey })
183
+ });
184
+ const chalData = await chalRes.json();
185
+ if (!chalData.challengeId) {
186
+ console.log(chalk.red(`Challenge failed: ${chalData.error || 'unknown error'}`));
187
+ return;
188
+ }
189
+
190
+ // Step 2: Sign challenge and register
191
+ const message = `register:${handle}:${chalData.nonce}`;
192
+ const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
193
+ sodium.crypto_sign_detached(sig, Buffer.from(message), privateKey);
194
+
195
+ const regRes = await fetch(`${DISCOVERY_URL}/register`, {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({ handle, publicKey: identity.publicKey, challengeId: chalData.challengeId, signature: sig.toString('hex') })
199
+ });
200
+ const result = await regRes.json();
201
+ if (result.success) {
202
+ console.log(chalk.green(`✔ Node registered as @${handle}`));
203
+ setIdentityHandle(handle);
204
+ } else {
205
+ console.log(chalk.red(`Registration failed: ${result.error}`));
206
+ }
207
+ } catch {
208
+ console.log(chalk.red(`Could not reach discovery server at ${DISCOVERY_URL}`));
209
+ }
210
+ });
211
+
212
+ server
213
+ .command('status')
214
+ .description('check server health and network connectivity')
215
+ .action(() => {
216
+ const identity = config.get('identity');
217
+ const settings = config.get('settings') || {};
218
+ const shares = config.get('shares') || [];
219
+
220
+ console.log('');
221
+ console.log(chalk.cyan('Palexplorer Server Status:'));
222
+ console.log(`- Node Identity: ${identity ? chalk.green(identity.name) : chalk.red('Not Initialized')}`);
223
+ console.log(`- Public Key: ${identity ? chalk.gray(identity.publicKey) : 'N/A'}`);
224
+ console.log(`- Active Shares: ${chalk.white(shares.length)}`);
225
+ console.log(`- Bound Port: ${chalk.white(settings.port || 'Auto (1900)')}`);
226
+ console.log(`- OS: ${chalk.white(process.platform)}`);
227
+ console.log('');
228
+ });
229
+
230
+ server
231
+ .command('list')
232
+ .description('list all configured discovery servers with health status and roles')
233
+ .action(async () => {
234
+ const servers = getServers();
235
+ console.log('');
236
+ console.log(chalk.cyan('Discovery Servers:'));
237
+ const checks = await Promise.all(servers.map(s => checkServer(s)));
238
+ for (let i = 0; i < checks.length; i++) {
239
+ const c = checks[i];
240
+ const primary = i === 0 ? chalk.cyan(' (primary)') : '';
241
+ const status = c.reachable ? chalk.green('reachable') : chalk.red('down');
242
+ const meta = getServerRoles(c.url);
243
+ const rolesStr = chalk.gray(`[${meta.roles.join(', ')}]`);
244
+ const addedBy = meta.addedBy !== 'bootstrap' ? chalk.gray(` (${meta.addedBy})`) : '';
245
+ const trust = isWriteTrusted(c.url) ? chalk.green('read+write') : chalk.yellow('read-only');
246
+ console.log(` ${i + 1}. ${chalk.white(c.url)} — ${status}${primary} ${rolesStr} ${trust}${addedBy}`);
247
+ }
248
+ console.log('');
249
+ });
250
+
251
+ server
252
+ .command('refresh')
253
+ .description('fetch and update server list from all known servers')
254
+ .action(async () => {
255
+ console.log(chalk.blue('Refreshing server list...'));
256
+ const servers = await refreshServerList((step) => {
257
+ console.log(chalk.gray(` ${step}`));
258
+ });
259
+ console.log(chalk.green(`\nFound ${servers.length} server(s). Running health checks...`));
260
+ const checks = await healthCheckServers(servers);
261
+ for (const c of checks) {
262
+ const status = c.reachable
263
+ ? chalk.green(`reachable (${c.latencyMs}ms)`)
264
+ : chalk.red('unreachable');
265
+ console.log(` ${chalk.white(c.url)} — ${status}`);
266
+ }
267
+ console.log('');
268
+ });
269
+
270
+ server
271
+ .command('roles <url>')
272
+ .description('query and display a server\'s supported roles')
273
+ .action(async (url) => {
274
+ const normalized = url.replace(/\/+$/, '');
275
+ try {
276
+ const parsed = new URL(normalized);
277
+ const host = parsed.hostname;
278
+ if (host === 'localhost' || host.startsWith('127.') || host.startsWith('10.') ||
279
+ host.startsWith('192.168.') || host.startsWith('169.254.') || host === '0.0.0.0' ||
280
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
281
+ console.log(chalk.yellow('⚠ Warning: querying a private/local network address.'));
282
+ }
283
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
284
+ console.log(chalk.red('Server URL must use http:// or https://'));
285
+ process.exitCode = 1;
286
+ return;
287
+ }
288
+ } catch { /* URL validation is best-effort */ }
289
+ console.log(chalk.blue(`Querying roles for ${normalized}...`));
290
+ try {
291
+ const res = await fetch(`${normalized}/status`, { signal: AbortSignal.timeout(5000) });
292
+ if (!res.ok) {
293
+ console.log(chalk.red(`Server returned status ${res.status}`));
294
+ return;
295
+ }
296
+ const data = await res.json();
297
+ console.log('');
298
+ console.log(chalk.cyan(`Server: ${normalized}`));
299
+ console.log(` Status: ${chalk.green(data.status || 'unknown')}`);
300
+ console.log(` Version: ${chalk.white(data.version || 'unknown')}`);
301
+ if (Array.isArray(data.roles)) {
302
+ console.log(` Roles: ${chalk.white(data.roles.join(', '))}`);
303
+ } else {
304
+ console.log(` Roles: ${chalk.yellow('not reported (legacy server, assumed all)')}`);
305
+ }
306
+ if (data.network) {
307
+ console.log(` Network: ${chalk.white(data.network)}`);
308
+ }
309
+ console.log('');
310
+ } catch (err) {
311
+ console.log(chalk.red(`Could not reach server: ${err.message}`));
312
+ }
313
+ });
314
+
315
+ server
316
+ .command('verify')
317
+ .description('verify pinned keys for all configured servers')
318
+ .action(async () => {
319
+ console.log(chalk.blue('Verifying server keys...\n'));
320
+ const results = await verifyAllServers();
321
+
322
+ for (const r of results) {
323
+ if (r.status === 'ok') {
324
+ console.log(` ${chalk.green('✔')} ${chalk.white(r.url)} — ${chalk.green('key verified')} (${chalk.cyan(r.fingerprint)})`);
325
+ } else if (r.status === 'key-changed') {
326
+ console.log(` ${chalk.red('✘')} ${chalk.white(r.url)} — ${chalk.red('KEY CHANGED')}`);
327
+ console.log(` Pinned: ${chalk.yellow(r.pinnedFingerprint)}`);
328
+ console.log(` Current: ${chalk.red(r.currentFingerprint)}`);
329
+ console.log(chalk.red(` ${r.message}`));
330
+
331
+ const ok = await confirm(chalk.white(' Accept the new key? (y/N) '));
332
+ if (ok) {
333
+ const keyResult = await fetchServerKey(r.url);
334
+ if (keyResult) {
335
+ acceptServerKey(r.url, keyResult.publicKey);
336
+ console.log(chalk.green(' ✔ New key accepted and pinned.'));
337
+ }
338
+ } else {
339
+ console.log(chalk.yellow(' Key NOT accepted. Consider removing this server.'));
340
+ }
341
+ } else if (r.status === 'no-pinned-key') {
342
+ console.log(` ${chalk.yellow('?')} ${chalk.white(r.url)} — ${chalk.yellow('no pinned key')}`);
343
+ console.log(chalk.gray(` ${r.message}`));
344
+ } else if (r.status === 'unreachable') {
345
+ console.log(` ${chalk.red('—')} ${chalk.white(r.url)} — ${chalk.red('unreachable')}`);
346
+ }
347
+ }
348
+ console.log('');
349
+ });
350
+ }
@@ -0,0 +1,199 @@
1
+ import chalk from 'chalk';
2
+ import config from '../utils/config.js';
3
+ import { getIdentity } from '../core/identity.js';
4
+ import { listShares, getShareKey } from '../core/shares.js';
5
+
6
+ const DISCOVERY_URL = process.env.PAL_DISCOVERY_URL || config.get('discoveryUrl') || 'http://localhost:3000';
7
+
8
+ export default function shareLinkCommand(program) {
9
+ const cmd = program
10
+ .command('share-link')
11
+ .description('create and manage web share links')
12
+ .addHelpText('after', `
13
+ Examples:
14
+ $ pe share-link create --magnet "magnet:..." --name "report.pdf"
15
+ $ pe share-link create --share abc123 Auto-fill from share ID (includes E2EE key)
16
+ $ pe share-link create --magnet "magnet:..." --name "report.pdf" --expires 3d --max-downloads 5
17
+ $ pe share-link list
18
+ `)
19
+ .action(() => {
20
+ cmd.outputHelp();
21
+ });
22
+
23
+ cmd
24
+ .command('create')
25
+ .description('create a web-accessible share link')
26
+ .option('--share <id>', 'Share ID (auto-fills magnet + E2EE key)')
27
+ .option('--magnet <uri>', 'Magnet URI of the shared content')
28
+ .option('--name <name>', 'File/share name')
29
+ .option('--message <msg>', 'Optional message for recipients')
30
+ .option('--expires <duration>', 'Expiry (e.g. 1d, 7d, 30d)', '7d')
31
+ .option('--max-downloads <n>', 'Max download count (0 = unlimited)', '0')
32
+ .option('--password <pwd>', 'Optional password protection')
33
+ .action(async (opts) => {
34
+ try {
35
+ const identity = await getIdentity();
36
+ const fromHandle = identity?.handle || 'anonymous';
37
+
38
+ let magnet = opts.magnet;
39
+ let fileName = opts.name;
40
+ let shareKeyHex = null;
41
+ let encrypted = false;
42
+
43
+ // Auto-fill from share ID
44
+ if (opts.share) {
45
+ const shares = listShares();
46
+ const share = shares.find(s => s.id === opts.share);
47
+ if (!share) {
48
+ console.log(chalk.red(`Share '${opts.share}' not found. Use 'pe list' to see shares.`));
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ if (!share.magnet) {
53
+ console.log(chalk.red('Share has no magnet URI yet. Start seeding first: pe serve'));
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+ magnet = share.magnet;
58
+ fileName = fileName || share.name;
59
+ if (share.visibility === 'private') {
60
+ shareKeyHex = await getShareKey(share.id);
61
+ if (shareKeyHex) {
62
+ encrypted = true;
63
+ } else {
64
+ console.log(chalk.yellow('Warning: Private share but no encryption key found. Link will not include decryption key.'));
65
+ }
66
+ }
67
+ }
68
+
69
+ if (!magnet) {
70
+ console.log(chalk.red('Either --magnet or --share is required.'));
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ if (!fileName) {
75
+ console.log(chalk.red('--name is required when not using --share.'));
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+
80
+ const expiresMs = parseDuration(opts.expires);
81
+ if (!expiresMs) {
82
+ console.log(chalk.red('Invalid --expires format. Use: 1d, 7d, 30d'));
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+
87
+ const res = await fetch(`${DISCOVERY_URL}/api/v1/share-links`, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({
91
+ magnet,
92
+ fileName,
93
+ fromHandle,
94
+ message: opts.message || '',
95
+ expiresIn: expiresMs,
96
+ maxDownloads: parseInt(opts.maxDownloads) || 0,
97
+ password: opts.password || null,
98
+ encrypted,
99
+ }),
100
+ signal: AbortSignal.timeout(10000),
101
+ });
102
+
103
+ if (!res.ok) {
104
+ const err = await res.json().catch(() => ({}));
105
+ console.log(chalk.red(`Failed: ${err.error || res.statusText}`));
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+
110
+ const data = await res.json();
111
+ // Append E2EE key as URL fragment (never sent to server)
112
+ const url = shareKeyHex ? `${data.url}#key=${shareKeyHex}` : data.url;
113
+ console.log(chalk.green('✔ Share link created:'));
114
+ console.log(` URL: ${chalk.cyan(url)}`);
115
+ if (encrypted) console.log(chalk.red(' ⚠ This URL contains the decryption key. Share it securely!'));
116
+ console.log(` ID: ${chalk.gray(data.id)}`);
117
+ if (data.expiresAt) console.log(` Expires: ${chalk.gray(new Date(data.expiresAt).toLocaleString())}`);
118
+ } catch (err) {
119
+ console.log(chalk.red(`Error: ${err.message}`));
120
+ process.exitCode = 1;
121
+ }
122
+ });
123
+
124
+ cmd
125
+ .command('list')
126
+ .description('list your active share links')
127
+ .action(async () => {
128
+ try {
129
+ const identity = await getIdentity();
130
+ if (!identity?.handle) {
131
+ console.log(chalk.red('You must register a handle first: pe register'));
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+
136
+ const res = await fetch(`${DISCOVERY_URL}/api/v1/share-links?handle=${encodeURIComponent(identity.handle)}`, {
137
+ signal: AbortSignal.timeout(10000),
138
+ });
139
+
140
+ if (!res.ok) {
141
+ console.log(chalk.red('Failed to fetch share links.'));
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+
146
+ const links = await res.json();
147
+ if (!Array.isArray(links) || links.length === 0) {
148
+ console.log(chalk.gray('No active share links.'));
149
+ return;
150
+ }
151
+
152
+ console.log('');
153
+ console.log(chalk.cyan('Share Links:'));
154
+ for (const l of links) {
155
+ console.log(` ${chalk.white(l.fileName || l.id)}`);
156
+ console.log(` URL: ${chalk.cyan(l.url)}`);
157
+ if (l.expiresAt) console.log(` Expires: ${chalk.gray(new Date(l.expiresAt).toLocaleString())}`);
158
+ if (l.downloads !== undefined) console.log(` Downloads: ${chalk.gray(l.downloads)}`);
159
+ }
160
+ } catch (err) {
161
+ console.log(chalk.red(`Error: ${err.message}`));
162
+ process.exitCode = 1;
163
+ }
164
+ });
165
+ cmd
166
+ .command('delete <id>')
167
+ .description('delete a share link')
168
+ .action(async (id) => {
169
+ try {
170
+ const identity = await getIdentity();
171
+ const res = await fetch(`${DISCOVERY_URL}/api/v1/share-links/${encodeURIComponent(id)}`, {
172
+ method: 'DELETE',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ handle: identity?.handle }),
175
+ signal: AbortSignal.timeout(10000),
176
+ });
177
+ if (!res.ok) {
178
+ console.log(chalk.red('Failed to delete share link.'));
179
+ process.exitCode = 1;
180
+ return;
181
+ }
182
+ console.log(chalk.green('✔ Share link deleted.'));
183
+ } catch (err) {
184
+ console.log(chalk.red(`Error: ${err.message}`));
185
+ process.exitCode = 1;
186
+ }
187
+ });
188
+ }
189
+
190
+ function parseDuration(str) {
191
+ const match = str.match(/^(\d+)([dhm])$/);
192
+ if (!match) return null;
193
+ const n = parseInt(match[1]);
194
+ const unit = match[2];
195
+ if (unit === 'd') return n * 86400000;
196
+ if (unit === 'h') return n * 3600000;
197
+ if (unit === 'm') return n * 60000;
198
+ return null;
199
+ }