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,167 @@
1
+ import { v2 as webdav } from 'webdav-server';
2
+ import { exec } from 'child_process';
3
+ import crypto from 'crypto';
4
+ import path from 'path';
5
+ import fs from 'fs';
6
+
7
+ let server = null;
8
+ let token = null;
9
+ let mounted = false;
10
+ let shares = new Map();
11
+ let ctx = null;
12
+
13
+ export function activate(context) {
14
+ ctx = context;
15
+
16
+ context.hooks.on('on:app:ready', async () => {
17
+ const port = context.config.get('port') || 1900;
18
+ const autoMount = context.config.get('autoMount') !== false;
19
+ try {
20
+ await startServer(port);
21
+ await syncShares(context);
22
+ if (autoMount) {
23
+ const letter = context.config.get('driveLetter') || 'P';
24
+ await mount(letter, port);
25
+ }
26
+ } catch (err) {
27
+ context.logger.error('VFS startup failed:', err.message);
28
+ }
29
+ });
30
+
31
+ context.hooks.on('after:share:create', async () => {
32
+ if (server) await syncShares(context);
33
+ });
34
+
35
+ context.hooks.on('after:share:revoke', async () => {
36
+ if (server) await syncShares(context);
37
+ });
38
+
39
+ context.hooks.on('on:app:shutdown', async () => {
40
+ await stop();
41
+ });
42
+ }
43
+
44
+ export function deactivate() {
45
+ return stop();
46
+ }
47
+
48
+ export function getStatus() {
49
+ return {
50
+ running: !!server,
51
+ mounted,
52
+ shareCount: shares.size,
53
+ shares: Array.from(shares.entries()).map(([name, localPath]) => ({ name, localPath })),
54
+ };
55
+ }
56
+
57
+ async function startServer(port = 1900) {
58
+ if (server) return;
59
+ token = crypto.randomBytes(32).toString('hex');
60
+
61
+ const userManager = new webdav.SimpleUserManager();
62
+ const user = userManager.addUser('pal', token, false);
63
+ const privilegeManager = new webdav.SimplePathPrivilegeManager();
64
+ privilegeManager.setRights(user, '/', ['all']);
65
+
66
+ server = new webdav.WebDAVServer({
67
+ port,
68
+ hostname: '127.0.0.1',
69
+ requireAuthentification: true,
70
+ httpAuthentication: new webdav.HTTPBasicAuthentication(userManager, 'Palexplorer VFS'),
71
+ privilegeManager,
72
+ });
73
+
74
+ return new Promise((resolve, reject) => {
75
+ server.start((s) => {
76
+ if (s instanceof Error) return reject(s);
77
+ ctx?.logger.info(`WebDAV server on http://127.0.0.1:${port}`);
78
+ resolve();
79
+ });
80
+ });
81
+ }
82
+
83
+ async function stop() {
84
+ if (mounted) {
85
+ try { await unmount(); } catch {}
86
+ }
87
+ if (server) {
88
+ return new Promise((resolve) => {
89
+ server.stop(() => { server = null; token = null; resolve(); });
90
+ });
91
+ }
92
+ }
93
+
94
+ async function syncShares(context) {
95
+ const configShares = context.shares?.list() || [];
96
+ const activeNames = new Set();
97
+
98
+ for (const share of configShares) {
99
+ if (share.status !== 'active') continue;
100
+ const name = path.basename(share.path);
101
+ activeNames.add(name);
102
+ if (!shares.has(name)) {
103
+ try {
104
+ await new Promise((resolve, reject) => {
105
+ server.setFileSystem(`/${name}`, new webdav.PhysicalFileSystem(share.path), (ok) => {
106
+ if (ok) { shares.set(name, share.path); resolve(); }
107
+ else reject(new Error(`Failed to add: ${name}`));
108
+ });
109
+ });
110
+ } catch {}
111
+ }
112
+ }
113
+
114
+ for (const [name] of shares) {
115
+ if (!activeNames.has(name)) {
116
+ server.removeFileSystem(`/${name}`, () => { shares.delete(name); });
117
+ }
118
+ }
119
+ }
120
+
121
+ async function mount(driveLetter = 'P', port = 1900) {
122
+ const p = process.platform;
123
+ if (p === 'win32') {
124
+ const cmd = `net use ${driveLetter}: http://127.0.0.1:${port} /user:pal ${token} /persistent:yes`;
125
+ return new Promise((resolve) => {
126
+ exec(cmd, (err, stdout, stderr) => {
127
+ if (err && stderr?.includes('67')) {
128
+ ctx?.logger.warn('WebClient service not running, drive skipped.');
129
+ return resolve();
130
+ }
131
+ mounted = !err || stderr?.includes('85');
132
+ if (mounted) ctx?.logger.info(`Mounted at ${driveLetter}:`);
133
+ resolve();
134
+ });
135
+ });
136
+ }
137
+ if (p === 'darwin') {
138
+ const mp = '/Volumes/Palexplorer';
139
+ return new Promise((resolve) => {
140
+ exec(`mkdir -p "${mp}" && mount_webdav -s -S http://127.0.0.1:${port} "${mp}"`, (err) => {
141
+ mounted = !err;
142
+ if (mounted) ctx?.logger.info(`Mounted at ${mp}`);
143
+ resolve();
144
+ });
145
+ });
146
+ }
147
+ if (p === 'linux') {
148
+ const mp = `${process.env.HOME}/Palexplorer`;
149
+ return new Promise((resolve) => {
150
+ exec(`mkdir -p "${mp}" && mount -t davfs http://127.0.0.1:${port} "${mp}"`, (err) => {
151
+ mounted = !err;
152
+ if (mounted) ctx?.logger.info(`Mounted at ${mp}`);
153
+ resolve();
154
+ });
155
+ });
156
+ }
157
+ }
158
+
159
+ async function unmount() {
160
+ const p = process.platform;
161
+ const cmd = p === 'win32' ? `net use P: /delete /yes`
162
+ : p === 'darwin' ? `umount /Volumes/Palexplorer`
163
+ : `umount ${process.env.HOME}/Palexplorer`;
164
+ return new Promise((resolve) => {
165
+ exec(cmd, () => { mounted = false; resolve(); });
166
+ });
167
+ }
@@ -0,0 +1,263 @@
1
+ // Shared capability registry for CLI and GUI parity
2
+ // Each capability describes an action available in the system.
3
+ // GUI pages and CLI commands reference these to stay in sync.
4
+
5
+ export const CAPABILITIES = {
6
+ // === Shares ===
7
+ 'share.create': {
8
+ label: 'Create Share',
9
+ description: 'Share a file or folder with pals',
10
+ category: 'shares',
11
+ options: {
12
+ visibility: { type: 'enum', values: ['private', 'public'], default: 'private' },
13
+ recipients: { type: 'string[]', label: 'Recipients (pals or groups)' },
14
+ expires: { type: 'duration', label: 'Expiry duration' },
15
+ password: { type: 'string', label: 'Password protection' },
16
+ maxDownloads: { type: 'number', label: 'Max downloads', min: 1 },
17
+ nonRecursive: { type: 'boolean', label: 'Top-level files only', default: false },
18
+ },
19
+ cli: 'pe share <path>',
20
+ gui: 'ShareModal',
21
+ tier: 'free',
22
+ },
23
+ 'share.revoke': {
24
+ label: 'Revoke Share',
25
+ description: 'Remove access to a shared file',
26
+ category: 'shares',
27
+ cli: 'pe revoke <shareId>',
28
+ gui: 'ShareExplorer (context menu)',
29
+ tier: 'free',
30
+ },
31
+ 'share.list': {
32
+ label: 'List Shares',
33
+ description: 'View all active shares',
34
+ category: 'shares',
35
+ cli: 'pe list',
36
+ gui: 'ShareExplorer',
37
+ tier: 'free',
38
+ },
39
+ 'share.link.create': {
40
+ label: 'Create Share Link',
41
+ description: 'Generate a web-accessible share link',
42
+ category: 'shares',
43
+ options: {
44
+ expires: { type: 'duration', label: 'Link expiry' },
45
+ password: { type: 'string', label: 'Link password' },
46
+ maxDownloads: { type: 'number', label: 'Max downloads', min: 1 },
47
+ },
48
+ cli: 'pe share-link create',
49
+ gui: 'ShareLinksPage',
50
+ tier: 'free',
51
+ },
52
+
53
+ // === Downloads ===
54
+ 'download': {
55
+ label: 'Download',
56
+ description: 'Download from a magnet link or share',
57
+ category: 'transfers',
58
+ cli: 'pe download <magnet>',
59
+ gui: 'TransfersPage (drag & drop or command palette)',
60
+ tier: 'free',
61
+ },
62
+
63
+ // === Pals ===
64
+ 'pal.add': {
65
+ label: 'Add Pal',
66
+ description: 'Add a friend by handle, public key, or invite link',
67
+ category: 'people',
68
+ options: {
69
+ method: { type: 'enum', values: ['handle', 'publicKey', 'inviteLink'], label: 'Add method' },
70
+ },
71
+ cli: 'pe pal add <identifier>',
72
+ gui: 'AddPalModal',
73
+ tier: 'free',
74
+ gaps: ['GUI does not support invite link method'],
75
+ },
76
+ 'pal.remove': {
77
+ label: 'Remove Pal',
78
+ category: 'people',
79
+ cli: 'pe pal remove <handle>',
80
+ gui: 'PalsPage (context menu)',
81
+ tier: 'free',
82
+ },
83
+ 'pal.list': {
84
+ label: 'List Pals',
85
+ category: 'people',
86
+ cli: 'pe pal list',
87
+ gui: 'PalsPage',
88
+ tier: 'free',
89
+ },
90
+
91
+ // === Groups ===
92
+ 'group.create': {
93
+ label: 'Create Group',
94
+ category: 'people',
95
+ cli: 'pe group create <name>',
96
+ gui: 'GroupModal',
97
+ tier: 'free',
98
+ },
99
+ 'group.list': {
100
+ label: 'List Groups',
101
+ category: 'people',
102
+ cli: 'pe group list',
103
+ gui: 'GroupsPage',
104
+ tier: 'free',
105
+ },
106
+
107
+ // === Sync ===
108
+ 'sync.push': {
109
+ label: 'Push Sync',
110
+ description: 'Push a directory to a pal',
111
+ category: 'transfers',
112
+ options: {
113
+ watch: { type: 'boolean', label: 'Watch for changes', default: false },
114
+ },
115
+ cli: 'pe sync push <dir> <pal>',
116
+ gui: 'SyncPage',
117
+ tier: 'free',
118
+ },
119
+ 'sync.pull': {
120
+ label: 'Pull Sync',
121
+ description: 'Pull a directory from a pal',
122
+ category: 'transfers',
123
+ cli: 'pe sync pull <dir> <pal>',
124
+ gui: 'SyncPage',
125
+ tier: 'free',
126
+ },
127
+
128
+ // === Chat ===
129
+ 'chat.send': {
130
+ label: 'Send Message',
131
+ category: 'social',
132
+ cli: 'pe chat send <pal> <message>',
133
+ gui: 'ChatPage',
134
+ tier: 'free',
135
+ },
136
+
137
+ // === Invite ===
138
+ 'invite.create': {
139
+ label: 'Create Invite',
140
+ description: 'Generate an invite link or QR code',
141
+ category: 'people',
142
+ options: {
143
+ qr: { type: 'boolean', label: 'Generate QR code' },
144
+ },
145
+ cli: 'pe invite --qr',
146
+ gui: null,
147
+ tier: 'free',
148
+ gaps: ['No QR generation in GUI'],
149
+ },
150
+
151
+ // === Remote Browse ===
152
+ 'remote.browse': {
153
+ label: 'Browse Remote Pal',
154
+ description: 'Browse files on a remote pal',
155
+ category: 'shares',
156
+ cli: 'pe remote browse <pal>',
157
+ gui: 'RemoteBrowser (not linked from nav)',
158
+ tier: 'free',
159
+ gaps: ['RemoteBrowser exists but not accessible from nav'],
160
+ },
161
+
162
+ // === Stream ===
163
+ 'stream.start': {
164
+ label: 'Start Stream',
165
+ description: 'Stream media from a share',
166
+ category: 'transfers',
167
+ cli: 'pe stream <shareId>',
168
+ gui: 'StreamPage',
169
+ tier: 'pro',
170
+ },
171
+
172
+ // === Identity ===
173
+ 'identity.init': {
174
+ label: 'Create Identity',
175
+ category: 'system',
176
+ cli: 'pe init <name>',
177
+ gui: 'SetupWizard',
178
+ tier: 'free',
179
+ },
180
+ 'identity.register': {
181
+ label: 'Register Handle',
182
+ category: 'system',
183
+ cli: 'pe register <handle>',
184
+ gui: 'SettingsPage',
185
+ tier: 'free',
186
+ },
187
+ 'identity.recover': {
188
+ label: 'Recover Identity',
189
+ description: 'Restore from recovery phrase',
190
+ category: 'system',
191
+ cli: 'pe recover',
192
+ gui: null,
193
+ tier: 'free',
194
+ gaps: ['No recovery phrase restore flow in GUI'],
195
+ },
196
+
197
+ // === Device ===
198
+ 'device.list': {
199
+ label: 'List Devices',
200
+ category: 'system',
201
+ cli: 'pe device list',
202
+ gui: 'SettingsPage (partial)',
203
+ tier: 'free',
204
+ gaps: ['No dedicated device management page'],
205
+ },
206
+
207
+ // === Billing ===
208
+ 'billing.status': {
209
+ label: 'Billing Status',
210
+ category: 'system',
211
+ cli: 'pe billing status',
212
+ gui: 'SettingsPage (partial)',
213
+ tier: 'pro',
214
+ },
215
+
216
+ // === Extensions ===
217
+ 'extension.install': {
218
+ label: 'Install Extension',
219
+ category: 'system',
220
+ cli: 'pe extension install <name>',
221
+ gui: 'ExtensionsPage',
222
+ tier: 'free',
223
+ },
224
+
225
+ // === Favorites ===
226
+ 'favorite.add': {
227
+ label: 'Favorite Share',
228
+ category: 'shares',
229
+ cli: 'pe favorite add <shareId>',
230
+ gui: 'SharesPage (star UI, no filter view)',
231
+ tier: 'free',
232
+ gaps: ['No dedicated favorites view/filter'],
233
+ },
234
+
235
+ // === Search ===
236
+ 'search': {
237
+ label: 'Search',
238
+ description: 'Search files, shares, and pals',
239
+ category: 'shares',
240
+ cli: 'pe search <query>',
241
+ gui: 'SearchPage + top bar search',
242
+ tier: 'free',
243
+ },
244
+ };
245
+
246
+ // Helpers
247
+ export function getCapabilitiesByCategory(category) {
248
+ return Object.entries(CAPABILITIES)
249
+ .filter(([, cap]) => cap.category === category)
250
+ .map(([id, cap]) => ({ id, ...cap }));
251
+ }
252
+
253
+ export function getCapabilityGaps() {
254
+ return Object.entries(CAPABILITIES)
255
+ .filter(([, cap]) => cap.gaps?.length > 0)
256
+ .map(([id, cap]) => ({ id, label: cap.label, gaps: cap.gaps }));
257
+ }
258
+
259
+ export function getCapabilitiesByTier(tier) {
260
+ return Object.entries(CAPABILITIES)
261
+ .filter(([, cap]) => cap.tier === tier)
262
+ .map(([id, cap]) => ({ id, ...cap }));
263
+ }
@@ -0,0 +1,175 @@
1
+ import chalk from 'chalk';
2
+
3
+ export default function analyticsCommand(program) {
4
+ const cmd = program
5
+ .command('analytics')
6
+ .description('transfer analytics and reporting (Pro)')
7
+ .addHelpText('after', `
8
+ Examples:
9
+ $ pe analytics metrics Show transfer metrics (default: week)
10
+ $ pe analytics metrics --period month Monthly metrics
11
+ $ pe analytics report --format csv Generate CSV report
12
+ $ pe analytics top-shares Show most downloaded shares
13
+ $ pe analytics storage-trends Show 30-day storage trends
14
+ $ pe analytics dashboard Open web dashboard
15
+ `)
16
+ .action(() => { cmd.outputHelp(); });
17
+
18
+ cmd
19
+ .command('metrics')
20
+ .description('show transfer metrics')
21
+ .option('--period <period>', 'time period: day, week, month', 'week')
22
+ .action(async (opts) => {
23
+ try {
24
+ const extConfig = (await import('../utils/config.js')).default;
25
+ const store = extConfig.get('ext_store.reporting-dashboard') || {};
26
+ const metrics = store.reportingMetrics || {};
27
+ const periodMs = { day: 86400000, week: 604800000, month: 2592000000 };
28
+ const cutoff = Date.now() - (periodMs[opts.period] || periodMs.week);
29
+ const transfers = (metrics.transfers || []).filter(t => new Date(t.timestamp).getTime() > cutoff);
30
+
31
+ console.log(chalk.bold(`Transfer Metrics (${opts.period})\n`));
32
+ console.log(` Total transfers: ${chalk.white(transfers.length)}`);
33
+ const totalBytes = transfers.reduce((sum, t) => sum + (t.bytes || 0), 0);
34
+ console.log(` Total data: ${chalk.white(formatBytes(totalBytes))}`);
35
+ const avgSpeed = transfers.length > 0 ? totalBytes / transfers.reduce((sum, t) => sum + (t.duration || 1), 0) : 0;
36
+ console.log(` Avg speed: ${chalk.white(formatBytes(avgSpeed) + '/s')}`);
37
+ console.log(` Shares created: ${chalk.white((metrics.sharesCreated || []).filter(s => new Date(s.timestamp).getTime() > cutoff).length)}`);
38
+ } catch (err) {
39
+ console.log(chalk.red(`Failed to get metrics: ${err.message}`));
40
+ process.exitCode = 1;
41
+ }
42
+ });
43
+
44
+ cmd
45
+ .command('report')
46
+ .description('generate a transfer report')
47
+ .option('--format <format>', 'output format: json, csv, pdf', 'json')
48
+ .option('-o, --output <path>', 'output file path')
49
+ .action(async (opts) => {
50
+ try {
51
+ const fs = await import('fs');
52
+ const path = await import('path');
53
+ const extConfig = (await import('../utils/config.js')).default;
54
+ const store = extConfig.get('ext_store.reporting-dashboard') || {};
55
+ const metrics = store.reportingMetrics || {};
56
+
57
+ const data = {
58
+ generatedAt: new Date().toISOString(),
59
+ format: opts.format,
60
+ transfers: metrics.transfers || [],
61
+ sharesCreated: metrics.sharesCreated || [],
62
+ };
63
+
64
+ let content;
65
+ if (opts.format === 'csv') {
66
+ const rows = (data.transfers || []).map(t => `${t.timestamp},${t.bytes},${t.duration},${t.peer || ''}`);
67
+ content = 'timestamp,bytes,duration,peer\n' + rows.join('\n');
68
+ } else {
69
+ content = JSON.stringify(data, null, 2);
70
+ }
71
+
72
+ const outPath = opts.output || `palexplorer-report-${new Date().toISOString().slice(0, 10)}.${opts.format}`;
73
+ const resolved = path.resolve(outPath);
74
+ fs.writeFileSync(resolved, content, 'utf8');
75
+ console.log(chalk.green(`✔ Report generated: ${resolved}`));
76
+ } catch (err) {
77
+ console.log(chalk.red(`Report failed: ${err.message}`));
78
+ process.exitCode = 1;
79
+ }
80
+ });
81
+
82
+ cmd
83
+ .command('top-shares')
84
+ .description('show most downloaded shares')
85
+ .option('--limit <n>', 'number of shares to show', '10')
86
+ .action(async (opts) => {
87
+ try {
88
+ const extConfig = (await import('../utils/config.js')).default;
89
+ const store = extConfig.get('ext_store.reporting-dashboard') || {};
90
+ const metrics = store.reportingMetrics || {};
91
+ const shares = metrics.topShares || [];
92
+ const limit = parseInt(opts.limit, 10) || 10;
93
+
94
+ if (shares.length === 0) {
95
+ console.log(chalk.dim('No share data available yet.'));
96
+ return;
97
+ }
98
+
99
+ console.log(chalk.bold(`Top ${Math.min(limit, shares.length)} Shares\n`));
100
+ const sorted = shares.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)).slice(0, limit);
101
+ for (let i = 0; i < sorted.length; i++) {
102
+ console.log(` ${chalk.dim(`${i + 1}.`)} ${chalk.cyan(sorted[i].name)} — ${chalk.white(sorted[i].downloads || 0)} downloads`);
103
+ }
104
+ } catch (err) {
105
+ console.log(chalk.red(`Failed: ${err.message}`));
106
+ process.exitCode = 1;
107
+ }
108
+ });
109
+
110
+ cmd
111
+ .command('storage-trends')
112
+ .description('show 30-day storage usage trends')
113
+ .option('--days <n>', 'number of days', '30')
114
+ .action(async (opts) => {
115
+ try {
116
+ const extConfig = (await import('../utils/config.js')).default;
117
+ const store = extConfig.get('ext_store.reporting-dashboard') || {};
118
+ const metrics = store.reportingMetrics || {};
119
+ const trends = metrics.storageTrends || {};
120
+ const days = parseInt(opts.days, 10) || 30;
121
+
122
+ const entries = Object.entries(trends).sort().slice(-days);
123
+ if (entries.length === 0) {
124
+ console.log(chalk.dim('No storage trend data available yet.'));
125
+ return;
126
+ }
127
+
128
+ console.log(chalk.bold(`Storage Trends (last ${entries.length} days)\n`));
129
+ for (const [date, bytes] of entries) {
130
+ console.log(` ${chalk.dim(date)} ${chalk.white(formatBytes(bytes))}`);
131
+ }
132
+ } catch (err) {
133
+ console.log(chalk.red(`Failed: ${err.message}`));
134
+ process.exitCode = 1;
135
+ }
136
+ });
137
+
138
+ cmd
139
+ .command('dashboard')
140
+ .description('open the analytics web dashboard')
141
+ .action(async () => {
142
+ try {
143
+ const extConfig = (await import('../utils/config.js')).default;
144
+ const extConf = extConfig.get('ext.reporting-dashboard') || {};
145
+ const port = extConf.dashboardPort;
146
+ if (!port) {
147
+ console.log(chalk.yellow('Dashboard port not configured. Set it with:'));
148
+ console.log(chalk.dim(' pe ext config reporting-dashboard dashboardPort 9090'));
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ const portNum = parseInt(port, 10);
153
+ if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
154
+ console.error('Invalid analytics port');
155
+ return;
156
+ }
157
+ const url = `http://127.0.0.1:${portNum}`;
158
+ console.log(chalk.green(`Dashboard: ${chalk.cyan(url)}`));
159
+ const { exec } = await import('child_process');
160
+ const opener = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
161
+ exec(`${opener} ${url}`);
162
+ } catch (err) {
163
+ console.log(chalk.red(`Failed: ${err.message}`));
164
+ process.exitCode = 1;
165
+ }
166
+ });
167
+ }
168
+
169
+ function formatBytes(bytes) {
170
+ if (bytes === 0) return '0 B';
171
+ const k = 1024;
172
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
173
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
174
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
175
+ }