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,323 @@
1
+ import chalk from 'chalk';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { addShare, storeShareKey } from '../core/shares.js';
5
+ import { getGroup } from '../core/groups.js';
6
+ import config from '../utils/config.js';
7
+ import logger from '../utils/logger.js';
8
+ import { requireRole, getFriends } from '../core/users.js';
9
+ import { checkLimit } from '../core/pro.js';
10
+
11
+ export default function shareCommand(program) {
12
+ program
13
+ .command('share-rename <id> <name>')
14
+ .description('rename a shared resource')
15
+ .action((id, name) => {
16
+ if (!name || !name.trim()) {
17
+ console.log(chalk.red('Error: name cannot be empty.'));
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const shares = config.get('shares') || [];
22
+ const share = shares.find(s => s.id === id);
23
+ if (!share) {
24
+ console.log(chalk.red('Share not found.'));
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ share.name = name;
29
+ config.set('shares', shares);
30
+ console.log(chalk.green(`✔ Share renamed to: ${name}`));
31
+ });
32
+
33
+ program
34
+ .command('share <file>')
35
+ .description('share a file, folder, or drive via P2P')
36
+ .option('-v, --visibility <type>', 'Visibility: global, private, group, network, link-only', 'global')
37
+ .option('-w, --with <pals...>', 'Specific pals to share with (names or IDs)')
38
+ .option('--with-group <group>', 'Share with all members of a group')
39
+ .option('--with-network <network>', 'Share with a network')
40
+ .option('--streamable', 'Enable media streaming (audio/video playback without download)')
41
+ .option('--no-recursive', 'Share only top-level files in folder (no subfolders)')
42
+ .option('--expires <duration>', 'Auto-expire after duration (e.g. 1h, 3d, 7d, 30d)')
43
+ .option('--max-downloads <n>', 'Auto-expire after N downloads (0 = unlimited)', '0')
44
+ .option('--password <pwd>', 'Password-protect this share')
45
+ .addHelpText('after', `
46
+ Examples:
47
+ $ pe share ./photos Share a folder publicly (recursive)
48
+ $ pe share ./photos --no-recursive Share only top-level files in folder
49
+ $ pe share ./docs -v private -w alice Share privately with pal "alice"
50
+ $ pe share ./project --with-group team Share with entire group
51
+ $ pe share ./file.zip --password secret123 Password-protected share
52
+ $ pe share ./file.zip Share a single file
53
+ $ pe share ./music --streamable Share as streaming media server
54
+ $ pe share ./report.pdf --expires 3d Share for 3 days then auto-expire
55
+ $ pe share ./file.zip --max-downloads 5 Expire after 5 downloads
56
+ $ pe share ./docs -v network --with-network mynet
57
+
58
+ Note: Private shares create an encrypted copy of your files for E2E seeding.
59
+ This uses additional disk space equal to the original share size.
60
+ `)
61
+ .action(async (filePath, options) => {
62
+ if (!filePath || !filePath.trim()) {
63
+ console.log(chalk.red('Error: file path cannot be empty.'));
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ const VALID_VISIBILITIES = ['global', 'private', 'group', 'network', 'link-only', 'public'];
68
+ if (!VALID_VISIBILITIES.includes(options.visibility)) {
69
+ console.log(chalk.red(`Invalid visibility '${options.visibility}'. Must be one of: ${VALID_VISIBILITIES.join(', ')}`));
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ const maxDl = parseInt(options.maxDownloads);
74
+ if (isNaN(maxDl) || maxDl < 0) {
75
+ console.log(chalk.red('--max-downloads must be a non-negative integer.'));
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ const absolutePath = path.resolve(filePath);
80
+ try {
81
+ try { requireRole('user'); } catch (e) {
82
+ console.log(chalk.red(e.message));
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ if (!fs.existsSync(absolutePath)) {
87
+ console.log(chalk.red(`Path does not exist: ${absolutePath}`));
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+ const friends = getFriends();
92
+
93
+ // Validate pals if provided
94
+ let recipients = [];
95
+ if (options.withGroup) {
96
+ const group = getGroup(options.withGroup);
97
+ if (!group) {
98
+ console.log(chalk.red(`Group '${options.withGroup}' not found.`));
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ if (group.members.length === 0) {
103
+ console.log(chalk.yellow(`Group '${group.name}' has no members.`));
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ recipients = group.members.map(m => {
108
+ const pal = friends.find(f => f.id === m.id);
109
+ return pal || { name: m.name, id: m.id, handle: m.handle };
110
+ });
111
+ console.log(chalk.cyan(`Sharing with group '${group.name}' (${recipients.length} members)`));
112
+ }
113
+ if (options.with) {
114
+ const palRecipients = options.with.map(nameOrId => {
115
+ const stripped = nameOrId.replace(/^@/, '');
116
+ const pal = friends.find(f =>
117
+ f.id === stripped || f.name === stripped || f.handle === stripped || f.publicKey === stripped
118
+ );
119
+ if (!pal) {
120
+ console.warn(chalk.yellow(`Warning: Pal '${stripped}' not found in your list.`));
121
+ return { name: stripped, id: 'unknown' };
122
+ }
123
+ return pal;
124
+ });
125
+ // Merge, avoid duplicates by id
126
+ for (const r of palRecipients) {
127
+ if (!recipients.find(e => e.id === r.id)) {
128
+ recipients.push(r);
129
+ }
130
+ }
131
+ }
132
+
133
+ checkLimit('maxShareRecipients', recipients.length);
134
+
135
+ console.log(chalk.blue(`Preparing to share: ${absolutePath}`));
136
+
137
+ let type = 'file';
138
+ try {
139
+ const stat = fs.statSync(absolutePath);
140
+ if (stat.isDirectory()) {
141
+ const parsed = path.parse(absolutePath);
142
+ type = parsed.dir === parsed.root || absolutePath === parsed.root ? 'drive' : 'folder';
143
+ }
144
+ } catch {
145
+ // path doesn't exist yet — default to 'file'
146
+ }
147
+
148
+ const recursive = options.recursive !== false;
149
+ let expiresIn = 0;
150
+ if (options.expires) {
151
+ expiresIn = parseDuration(options.expires);
152
+ if (!expiresIn) {
153
+ console.log(chalk.red('Invalid --expires format. Use: 1h, 3d, 7d, 30d'));
154
+ process.exitCode = 1;
155
+ return;
156
+ }
157
+ }
158
+ const maxDownloads = parseInt(options.maxDownloads) || 0;
159
+ const password = options.password || null;
160
+ const share = addShare(absolutePath, type, options.visibility, { read: true }, recipients, { recursive, expiresIn, maxDownloads, password });
161
+
162
+ // Set streamable flag
163
+ if (options.streamable) {
164
+ const shares = config.get('shares');
165
+ const idx = shares.findIndex(s => s.id === share.id);
166
+ if (idx !== -1) {
167
+ shares[idx].streamable = true;
168
+ config.set('shares', shares);
169
+ }
170
+ console.log(chalk.magenta(' Media streaming enabled for this share'));
171
+ }
172
+
173
+ // Set network association
174
+ if (options.withNetwork) {
175
+ const shares = config.get('shares');
176
+ const idx = shares.findIndex(s => s.id === share.id);
177
+ if (idx !== -1) {
178
+ shares[idx].sharedWithNetworks = [options.withNetwork];
179
+ config.set('shares', shares);
180
+ }
181
+ }
182
+
183
+ // Persist group association
184
+ if (options.withGroup) {
185
+ const group = getGroup(options.withGroup);
186
+ if (group) {
187
+ const shares = config.get('shares');
188
+ const idx = shares.findIndex(s => s.id === share.id);
189
+ if (idx !== -1) {
190
+ shares[idx].sharedWithGroups = [group.id];
191
+ config.set('shares', shares);
192
+ }
193
+ }
194
+ }
195
+
196
+ // Encrypt for private shares that have named recipients
197
+ if (options.visibility === 'private' && share.recipients.length > 0) {
198
+ const { generateShareKey, encryptDirectory, getEncryptedShareDir, encryptShareKeyForRecipient } = await import('../crypto/shareEncryption.js');
199
+ const shareKey = generateShareKey();
200
+ const encDir = getEncryptedShareDir(share.id);
201
+ console.log(chalk.gray('Encrypting files for private share...'));
202
+ const encStart = Date.now();
203
+ const encSpinner = setInterval(() => {
204
+ const elapsed = ((Date.now() - encStart) / 1000).toFixed(0);
205
+ process.stdout.write(`\r${chalk.gray(` Encrypting... ${elapsed}s`)}`);
206
+ }, 1000);
207
+ try {
208
+ encryptDirectory(absolutePath, encDir, shareKey);
209
+ } finally {
210
+ clearInterval(encSpinner);
211
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
212
+ }
213
+
214
+ // Wrap the shareKey for each recipient
215
+ const encryptedShareKeys = {};
216
+ for (const recipient of share.recipients) {
217
+ const pal = friends.find(f => f.name === recipient.name || f.id === recipient.id || f.handle === recipient.handle);
218
+ if (pal?.id && pal.id !== 'unknown') {
219
+ encryptedShareKeys[pal.id] = encryptShareKeyForRecipient(shareKey, pal.id);
220
+ }
221
+ }
222
+
223
+ // Store share key securely in credential store
224
+ await storeShareKey(share.id, shareKey.toString('hex'));
225
+
226
+ // Persist encryption metadata onto the stored share (without shareKeyHex)
227
+ const shares = config.get('shares');
228
+ const idx = shares.findIndex(s => s.id === share.id);
229
+ if (idx !== -1) {
230
+ shares[idx].encryptedPath = encDir;
231
+ shares[idx].encryptedShareKeys = encryptedShareKeys;
232
+ config.set('shares', shares);
233
+ }
234
+
235
+ console.log(chalk.green('Encryption complete.'));
236
+
237
+ // Warn about disk overhead for private shares
238
+ try {
239
+ const stat = fs.statSync(absolutePath);
240
+ if (stat.isDirectory()) {
241
+ let totalSize = 0;
242
+ const walk = (dir) => {
243
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
244
+ const full = path.join(dir, entry.name);
245
+ if (entry.isFile()) totalSize += fs.statSync(full).size;
246
+ else if (entry.isDirectory()) walk(full);
247
+ }
248
+ };
249
+ walk(absolutePath);
250
+ const sizeMB = (totalSize / 1024 / 1024).toFixed(0);
251
+ if (totalSize > 1024 * 1024 * 1024) {
252
+ const sizeGB = (totalSize / 1024 / 1024 / 1024).toFixed(1);
253
+ console.log(chalk.yellow(` Warning: Private shares create an encrypted copy (~${sizeGB} GB additional disk space).`));
254
+ console.log(chalk.yellow(` Total disk usage for this share: ~${(totalSize * 2 / 1024 / 1024 / 1024).toFixed(1)} GB (original + encrypted copy).`));
255
+ } else if (totalSize > 50 * 1024 * 1024) {
256
+ console.log(chalk.yellow(` Note: Private shares create an encrypted copy (~${sizeMB} MB additional disk space).`));
257
+ }
258
+ }
259
+ } catch {}
260
+ }
261
+
262
+ logger.info(`Share added: ${absolutePath}`, { visibility: options.visibility, type, id: share.id });
263
+ console.log(chalk.green(`✔ Resource added to ${options.visibility} share list!`));
264
+ if (recipients.length > 0) {
265
+ console.log(chalk.cyan(` Shared with: ${recipients.map(r => r.name).join(', ')}`));
266
+ }
267
+
268
+ // Send PAL/1.0 share.offer envelopes to recipients
269
+ if (options.visibility === 'private' && recipients.length > 0) {
270
+ try {
271
+ const { getIdentity } = await import('../core/identity.js');
272
+ const identity = await getIdentity();
273
+ if (identity?.privateKey) {
274
+ const { initiateShare } = await import('../protocol/negotiation.js');
275
+ const { postTo, getPrimaryServer } = await import('../core/discoveryClient.js');
276
+ const keyPair = {
277
+ publicKey: Buffer.from(identity.publicKey, 'hex'),
278
+ privateKey: Buffer.from(identity.privateKey, 'hex'),
279
+ };
280
+ let sent = 0;
281
+ for (const r of recipients) {
282
+ const pal = friends.find(f => f.id === r.id);
283
+ if (pal?.id && pal.id !== 'unknown') {
284
+ const result = await initiateShare(keyPair, pal.id, share, options.policy || {});
285
+ if (result.ok) {
286
+ await postTo(getPrimaryServer(), '/api/v1/messages', {
287
+ toHandle: pal.handle || pal.name,
288
+ fromHandle: identity.handle || identity.name,
289
+ payload: result.envelope,
290
+ });
291
+ sent++;
292
+ }
293
+ }
294
+ }
295
+ if (sent > 0) console.log(chalk.gray(` PAL/1.0 share offers sent to ${sent} recipient(s)`));
296
+ }
297
+ } catch {
298
+ // Protocol notifications are best-effort
299
+ }
300
+ }
301
+
302
+ if (share.password) console.log(chalk.yellow(' Password protected'));
303
+ if (share.expiresAt) console.log(chalk.gray(` Expires: ${new Date(share.expiresAt).toLocaleString()}`));
304
+ if (share.maxDownloads) console.log(chalk.gray(` Max downloads: ${share.maxDownloads}`));
305
+ console.log(chalk.white('To start seeding, use: pe serve'));
306
+ console.log(chalk.gray(`ID: ${share.id}`));
307
+ } catch (err) {
308
+ logger.error(`Share failed: ${err.message}`, { path: absolutePath });
309
+ console.error(chalk.red(`Error: ${err.message}`));
310
+ }
311
+ });
312
+ }
313
+
314
+ function parseDuration(str) {
315
+ const match = str.match(/^(\d+)([dhm])$/);
316
+ if (!match) return null;
317
+ const n = parseInt(match[1]);
318
+ const unit = match[2];
319
+ if (unit === 'd') return n * 86400000;
320
+ if (unit === 'h') return n * 3600000;
321
+ if (unit === 'm') return n * 60000;
322
+ return null;
323
+ }
@@ -0,0 +1,200 @@
1
+ import chalk from 'chalk';
2
+
3
+ export default function ssoCommand(program) {
4
+ const cmd = program
5
+ .command('sso')
6
+ .description('manage enterprise SSO (SAML/OIDC)')
7
+ .addHelpText('after', `
8
+ Examples:
9
+ $ pe sso configure --provider oidc --issuer https://auth.example.com --client-id abc --client-secret xyz
10
+ $ pe sso configure --provider saml --issuer https://idp.example.com/metadata
11
+ $ pe sso login Initiate SSO login
12
+ $ pe sso status Show current SSO configuration
13
+ $ pe sso enforce --on Enforce mandatory SSO
14
+ $ pe sso enforce --off Disable mandatory SSO
15
+ `)
16
+ .action(() => {
17
+ cmd.outputHelp();
18
+ });
19
+
20
+ cmd
21
+ .command('configure')
22
+ .description('configure SSO provider (SAML or OIDC)')
23
+ .requiredOption('--provider <type>', 'SSO provider type (saml or oidc)')
24
+ .option('--issuer <url>', 'IdP issuer URL')
25
+ .option('--client-id <id>', 'OIDC client ID')
26
+ .option('--client-secret <secret>', 'OIDC client secret')
27
+ .action(async (opts) => {
28
+ try {
29
+ const provider = opts.provider.toLowerCase();
30
+ if (provider !== 'saml' && provider !== 'oidc') {
31
+ console.log(chalk.red('Provider must be "saml" or "oidc".'));
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ if (provider === 'oidc' && (!opts.clientId || !opts.clientSecret)) {
37
+ console.log(chalk.red('OIDC requires --client-id and --client-secret.'));
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+
42
+ if (!opts.issuer) {
43
+ console.log(chalk.red('--issuer is required.'));
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+
48
+ const extConfig = (await import('../utils/config.js')).default;
49
+ const existing = extConfig.get('ext.sso-connector') || {};
50
+
51
+ const clientSecret = opts.clientSecret || existing.clientSecret || null;
52
+ const ssoConfig = {
53
+ ...existing,
54
+ provider,
55
+ issuer: opts.issuer,
56
+ clientId: opts.clientId || existing.clientId || null,
57
+ clientSecret: null,
58
+ };
59
+
60
+ if (clientSecret) {
61
+ try {
62
+ const keytar = (await import('keytar')).default;
63
+ await keytar.setPassword('palexplorer', 'sso.clientSecret', clientSecret);
64
+ } catch {
65
+ ssoConfig.clientSecret = clientSecret;
66
+ }
67
+ }
68
+
69
+ extConfig.set('ext.sso-connector', ssoConfig);
70
+
71
+ console.log(chalk.green(`✔ SSO configured`));
72
+ console.log(` Provider: ${chalk.white(provider.toUpperCase())}`);
73
+ console.log(` Issuer: ${chalk.white(opts.issuer)}`);
74
+ if (provider === 'oidc') {
75
+ console.log(` Client: ${chalk.white(opts.clientId)}`);
76
+ }
77
+ } catch (err) {
78
+ console.log(chalk.red(`Failed to configure SSO: ${err.message}`));
79
+ process.exitCode = 1;
80
+ }
81
+ });
82
+
83
+ cmd
84
+ .command('login')
85
+ .description('initiate SSO login')
86
+ .action(async () => {
87
+ try {
88
+ const extConfig = (await import('../utils/config.js')).default;
89
+ const cfg = extConfig.get('ext.sso-connector');
90
+
91
+ if (!cfg || !cfg.provider) {
92
+ console.log(chalk.red('SSO not configured. Run: pe sso configure'));
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+
97
+ let redirectUrl;
98
+ if (cfg.provider === 'oidc') {
99
+ let clientId = cfg.clientId;
100
+ let clientSecret = cfg.clientSecret;
101
+ if (!clientSecret) {
102
+ try {
103
+ const keytar = (await import('keytar')).default;
104
+ clientSecret = await keytar.getPassword('palexplorer', 'sso.clientSecret');
105
+ } catch {}
106
+ }
107
+ const state = Math.random().toString(36).slice(2);
108
+ const params = new URLSearchParams({
109
+ response_type: 'code',
110
+ client_id: clientId,
111
+ redirect_uri: 'http://localhost:9876/callback',
112
+ scope: 'openid profile email',
113
+ state,
114
+ });
115
+ redirectUrl = `${cfg.issuer}/authorize?${params}`;
116
+ } else {
117
+ redirectUrl = `${cfg.issuer}/sso/saml?RelayState=palexplorer`;
118
+ }
119
+
120
+ console.log('');
121
+ console.log(chalk.cyan('SSO Login'));
122
+ console.log(` Provider: ${chalk.white(cfg.provider.toUpperCase())}`);
123
+ console.log(` Redirect: ${chalk.white(redirectUrl)}`);
124
+ console.log('');
125
+ console.log(chalk.gray('Open the URL above in your browser to authenticate.'));
126
+ } catch (err) {
127
+ console.log(chalk.red(`SSO login failed: ${err.message}`));
128
+ process.exitCode = 1;
129
+ }
130
+ });
131
+
132
+ cmd
133
+ .command('status')
134
+ .description('show current SSO configuration')
135
+ .action(async () => {
136
+ try {
137
+ const extConfig = (await import('../utils/config.js')).default;
138
+ const cfg = extConfig.get('ext.sso-connector');
139
+
140
+ console.log('');
141
+ console.log(chalk.cyan.bold('SSO Configuration'));
142
+
143
+ if (!cfg || !cfg.provider) {
144
+ console.log(chalk.gray(' Not configured. Run: pe sso configure'));
145
+ console.log('');
146
+ return;
147
+ }
148
+
149
+ console.log(` Provider: ${chalk.white(cfg.provider.toUpperCase())}`);
150
+ console.log(` Issuer: ${chalk.white(cfg.issuer || 'not set')}`);
151
+ if (cfg.provider === 'oidc') {
152
+ console.log(` Client ID: ${chalk.white(cfg.clientId || 'not set')}`);
153
+ console.log(` Client Secret: ${cfg.clientSecret ? chalk.gray('********') : chalk.yellow('not set')}`);
154
+ }
155
+ console.log(` Enforce SSO: ${cfg.enforceSSO ? chalk.green('yes') : chalk.gray('no')}`);
156
+ console.log('');
157
+ } catch (err) {
158
+ console.log(chalk.red(`Failed to get SSO status: ${err.message}`));
159
+ process.exitCode = 1;
160
+ }
161
+ });
162
+
163
+ cmd
164
+ .command('enforce')
165
+ .description('enable or disable mandatory SSO')
166
+ .option('--on', 'Enable mandatory SSO')
167
+ .option('--off', 'Disable mandatory SSO')
168
+ .action(async (opts) => {
169
+ try {
170
+ if (!opts.on && !opts.off) {
171
+ console.log(chalk.red('Specify --on or --off.'));
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+
176
+ const extConfig = (await import('../utils/config.js')).default;
177
+ const cfg = extConfig.get('ext.sso-connector');
178
+
179
+ if (!cfg || !cfg.provider) {
180
+ console.log(chalk.red('SSO not configured. Run: pe sso configure'));
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+
185
+ const enforce = !!opts.on;
186
+ extConfig.set('ext.sso-connector', { ...cfg, enforceSSO: enforce });
187
+
188
+ if (enforce) {
189
+ console.log(chalk.green('✔ Mandatory SSO enabled'));
190
+ console.log(chalk.gray(' All users must authenticate via SSO.'));
191
+ } else {
192
+ console.log(chalk.green('✔ Mandatory SSO disabled'));
193
+ console.log(chalk.gray(' Users can authenticate with local credentials.'));
194
+ }
195
+ } catch (err) {
196
+ console.log(chalk.red(`Failed to update enforcement: ${err.message}`));
197
+ process.exitCode = 1;
198
+ }
199
+ });
200
+ }
@@ -0,0 +1,136 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import config from '../utils/config.js';
5
+ import { getIdentity } from '../core/identity.js';
6
+ import { listShares } from '../core/shares.js';
7
+ import { getTransfers } from '../core/transfers.js';
8
+ import { getServers, checkServer } from '../core/discoveryClient.js';
9
+ import { getConnectionState } from '../core/connectionManager.js';
10
+
11
+ export default function statusCommand(program) {
12
+ program
13
+ .command('status')
14
+ .description('show system health dashboard')
15
+ .addHelpText('after', `
16
+ Examples:
17
+ $ pe status Show system health dashboard
18
+ `)
19
+ .action(async () => {
20
+ try {
21
+ const opts = program.opts();
22
+ const identity = await getIdentity();
23
+
24
+ if (opts.json) {
25
+ const shares = listShares();
26
+ const active = getTransfers() || [];
27
+ const pidPath = path.join(path.dirname(config.path), 'serve.pid');
28
+ const servers = getServers();
29
+ const checks = await Promise.all(servers.map(s => checkServer(s)));
30
+ const conn = getConnectionState();
31
+ const data = {
32
+ identity: identity ? { name: identity.name, handle: identity.handle, publicKey: identity.publicKey?.slice(0, 16) } : null,
33
+ network: { status: conn.status, connectedCount: conn.connectedCount || 0 },
34
+ daemon: fs.existsSync(pidPath) ? 'running' : 'stopped',
35
+ shares: { count: shares.length },
36
+ transfers: { active: active.length },
37
+ discoveryServers: checks
38
+ };
39
+ console.log(JSON.stringify(data, null, 2));
40
+ return;
41
+ }
42
+
43
+ // Identity
44
+ console.log('');
45
+ console.log(chalk.cyan.bold('Identity'));
46
+ if (identity) {
47
+ console.log(` Name: ${chalk.green(identity.name || 'Unknown')}`);
48
+ console.log(` Handle: ${chalk.green(identity.handle || 'Not linked')}`);
49
+ console.log(` Public Key: ${chalk.yellow(identity.publicKey?.slice(0, 16) + '...')}`);
50
+ } else {
51
+ console.log(chalk.gray(' Not initialized. Run `pe init <name>` to get started.'));
52
+ }
53
+
54
+ // Network Connection
55
+ console.log('');
56
+ console.log(chalk.cyan.bold('Network'));
57
+ const conn = getConnectionState();
58
+ const connColor = conn.status === 'connected' ? chalk.green
59
+ : conn.status === 'connecting' ? chalk.yellow
60
+ : chalk.red;
61
+ console.log(` Status: ${connColor(conn.status)}`);
62
+ if (conn.protocol) {
63
+ console.log(` Protocol: ${chalk.white(conn.protocol)} v${conn.protocolVersion}`);
64
+ }
65
+ if (conn.connectedCount > 0) {
66
+ console.log(` Servers: ${chalk.white(conn.connectedCount)} reachable`);
67
+ }
68
+
69
+ // Daemon
70
+ console.log('');
71
+ console.log(chalk.cyan.bold('Daemon'));
72
+ const pidPath = path.join(path.dirname(config.path), 'serve.pid');
73
+ if (fs.existsSync(pidPath)) {
74
+ const pid = fs.readFileSync(pidPath, 'utf8').trim();
75
+ try {
76
+ process.kill(parseInt(pid, 10), 0);
77
+ console.log(` Status: ${chalk.green('Running')} (PID ${pid})`);
78
+ } catch {
79
+ console.log(` Status: ${chalk.yellow('Stale PID file')} (process ${pid} not found)`);
80
+ }
81
+ } else {
82
+ console.log(` Status: ${chalk.gray('Not running')}`);
83
+ }
84
+
85
+ // Shares
86
+ console.log('');
87
+ console.log(chalk.cyan.bold('Shares'));
88
+ const shares = listShares();
89
+ if (shares.length === 0) {
90
+ console.log(chalk.gray(' No active shares.'));
91
+ } else {
92
+ let totalSize = 0;
93
+ for (const s of shares) {
94
+ try {
95
+ const stat = fs.statSync(s.path);
96
+ totalSize += stat.isDirectory() ? 0 : stat.size;
97
+ } catch { /* skip */ }
98
+ }
99
+ console.log(` Active: ${chalk.white(shares.length)}`);
100
+ if (totalSize > 0) {
101
+ console.log(` Size: ${chalk.white((totalSize / 1024 / 1024).toFixed(1) + ' MB')}`);
102
+ }
103
+ }
104
+
105
+ // Transfers
106
+ console.log('');
107
+ console.log(chalk.cyan.bold('Transfers'));
108
+ const active = getTransfers() || [];
109
+ if (active.length === 0) {
110
+ console.log(chalk.gray(' No active transfers.'));
111
+ } else {
112
+ console.log(` Active: ${chalk.white(active.length)}`);
113
+ }
114
+
115
+ // Discovery servers
116
+ console.log('');
117
+ console.log(chalk.cyan.bold('Discovery Servers'));
118
+ const servers = getServers();
119
+ const checks = await Promise.all(servers.map(s => checkServer(s)));
120
+ for (let i = 0; i < checks.length; i++) {
121
+ const c = checks[i];
122
+ const primary = i === 0 ? chalk.cyan(' (primary)') : '';
123
+ if (c.reachable) {
124
+ console.log(` ${chalk.green('●')} ${c.url}${primary}`);
125
+ } else {
126
+ console.log(` ${chalk.red('●')} ${c.url} ${chalk.red('unreachable')}${primary}`);
127
+ }
128
+ }
129
+
130
+ console.log('');
131
+ } catch (err) {
132
+ console.error(chalk.red('Status check failed:'), err.message);
133
+ process.exitCode = 1;
134
+ }
135
+ });
136
+ }