pal-explorer-cli 0.4.12 → 0.4.14

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 (100) hide show
  1. package/README.md +149 -149
  2. package/bin/pal.js +77 -4
  3. package/extensions/@palexplorer/analytics/extension.json +20 -1
  4. package/extensions/@palexplorer/analytics/index.js +19 -9
  5. package/extensions/@palexplorer/audit/extension.json +14 -0
  6. package/extensions/@palexplorer/auth-email/extension.json +15 -0
  7. package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
  8. package/extensions/@palexplorer/chat/extension.json +14 -0
  9. package/extensions/@palexplorer/discovery/extension.json +17 -0
  10. package/extensions/@palexplorer/discovery/index.js +1 -1
  11. package/extensions/@palexplorer/email-notifications/extension.json +23 -0
  12. package/extensions/@palexplorer/groups/extension.json +15 -0
  13. package/extensions/@palexplorer/share-links/extension.json +15 -0
  14. package/extensions/@palexplorer/sync/extension.json +16 -0
  15. package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
  16. package/lib/capabilities.js +24 -24
  17. package/lib/commands/analytics.js +175 -175
  18. package/lib/commands/api-keys.js +131 -131
  19. package/lib/commands/audit.js +235 -235
  20. package/lib/commands/auth.js +137 -137
  21. package/lib/commands/backup.js +76 -76
  22. package/lib/commands/billing.js +148 -148
  23. package/lib/commands/chat.js +217 -217
  24. package/lib/commands/cloud-backup.js +231 -231
  25. package/lib/commands/comment.js +99 -99
  26. package/lib/commands/completion.js +265 -203
  27. package/lib/commands/compliance.js +218 -218
  28. package/lib/commands/config.js +136 -136
  29. package/lib/commands/connect.js +44 -44
  30. package/lib/commands/dept.js +294 -294
  31. package/lib/commands/device.js +146 -146
  32. package/lib/commands/download.js +240 -226
  33. package/lib/commands/explorer.js +178 -178
  34. package/lib/commands/extension.js +1060 -970
  35. package/lib/commands/favorite.js +90 -90
  36. package/lib/commands/federation.js +270 -270
  37. package/lib/commands/file.js +533 -533
  38. package/lib/commands/group.js +271 -271
  39. package/lib/commands/gui-share.js +29 -29
  40. package/lib/commands/init.js +61 -61
  41. package/lib/commands/invite.js +59 -59
  42. package/lib/commands/list.js +58 -58
  43. package/lib/commands/log.js +116 -116
  44. package/lib/commands/nearby.js +108 -108
  45. package/lib/commands/network.js +251 -251
  46. package/lib/commands/notify.js +198 -198
  47. package/lib/commands/org.js +273 -273
  48. package/lib/commands/pal.js +403 -180
  49. package/lib/commands/permissions.js +216 -216
  50. package/lib/commands/pin.js +97 -97
  51. package/lib/commands/protocol.js +357 -357
  52. package/lib/commands/rbac.js +147 -147
  53. package/lib/commands/recover.js +36 -36
  54. package/lib/commands/register.js +171 -171
  55. package/lib/commands/relay.js +131 -131
  56. package/lib/commands/remote.js +368 -368
  57. package/lib/commands/revoke.js +50 -50
  58. package/lib/commands/scanner.js +280 -280
  59. package/lib/commands/schedule.js +344 -344
  60. package/lib/commands/scim.js +203 -203
  61. package/lib/commands/search.js +181 -181
  62. package/lib/commands/serve.js +438 -438
  63. package/lib/commands/server.js +350 -350
  64. package/lib/commands/share-link.js +199 -199
  65. package/lib/commands/share.js +336 -323
  66. package/lib/commands/sso.js +200 -200
  67. package/lib/commands/status.js +145 -145
  68. package/lib/commands/stream.js +562 -562
  69. package/lib/commands/su.js +187 -187
  70. package/lib/commands/sync.js +979 -979
  71. package/lib/commands/transfers.js +152 -152
  72. package/lib/commands/uninstall.js +188 -188
  73. package/lib/commands/update.js +204 -204
  74. package/lib/commands/user.js +276 -276
  75. package/lib/commands/vfs.js +84 -84
  76. package/lib/commands/web-login.js +79 -79
  77. package/lib/commands/web.js +52 -52
  78. package/lib/commands/webhook.js +180 -180
  79. package/lib/commands/whoami.js +59 -59
  80. package/lib/commands/workspace.js +121 -121
  81. package/lib/core/billing.js +16 -5
  82. package/lib/core/dhtDiscovery.js +9 -2
  83. package/lib/core/discoveryClient.js +13 -7
  84. package/lib/core/extensions.js +142 -1
  85. package/lib/core/identity.js +33 -2
  86. package/lib/core/imageProcessor.js +109 -0
  87. package/lib/core/imageTorrent.js +167 -0
  88. package/lib/core/permissions.js +1 -1
  89. package/lib/core/pro.js +11 -4
  90. package/lib/core/serverList.js +4 -1
  91. package/lib/core/shares.js +12 -1
  92. package/lib/core/signalingServer.js +14 -2
  93. package/lib/core/su.js +1 -1
  94. package/lib/core/users.js +1 -1
  95. package/lib/protocol/messages.js +12 -3
  96. package/lib/utils/explorer.js +1 -1
  97. package/lib/utils/fuzzy.js +47 -0
  98. package/lib/utils/help.js +357 -357
  99. package/lib/utils/torrent.js +1 -0
  100. package/package.json +4 -3
@@ -1,350 +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
- }
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 "pal serve" /sc onlogon /rl limited`));
30
+ return resolve();
31
+ }
32
+ console.log(chalk.green(`Scheduled task "${taskName}" created. pal 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
+ $ pal server install Install as system service
122
+ $ pal server status Check service status
123
+ $ pal server config Show server configuration
124
+ $ pal server register Register device with discovery server
125
+ `);
126
+
127
+ server
128
+ .command('install')
129
+ .description('install pal 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 `pal 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
+ }