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,323 +1,336 @@
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
- }
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
+ .option('--category <type>', 'Category: documents, media, projects, games, backups, other')
46
+ .option('--description <text>', 'Share description (max 200 chars)')
47
+ .option('--color <hex>', 'Share color (hex, e.g. #89b4fa)')
48
+ .option('--icon <emoji>', 'Share icon emoji')
49
+ .option('--tags <list>', 'Comma-separated tags, e.g. "photos,vacation,2026"')
50
+ .addHelpText('after', `
51
+ Examples:
52
+ $ pal share ./photos Share a folder publicly (recursive)
53
+ $ pal share ./photos --no-recursive Share only top-level files in folder
54
+ $ pal share ./docs -v private -w alice Share privately with pal "alice"
55
+ $ pal share ./project --with-group team Share with entire group
56
+ $ pal share ./file.zip --password secret123 Password-protected share
57
+ $ pal share ./file.zip Share a single file
58
+ $ pal share ./music --streamable Share as streaming media server
59
+ $ pal share ./report.pdf --expires 3d Share for 3 days then auto-expire
60
+ $ pal share ./file.zip --max-downloads 5 Expire after 5 downloads
61
+ $ pal share ./docs -v network --with-network mynet
62
+
63
+ Note: Private shares create an encrypted copy of your files for E2E seeding.
64
+ This uses additional disk space equal to the original share size.
65
+ `)
66
+ .action(async (filePath, options) => {
67
+ if (!filePath || !filePath.trim()) {
68
+ console.log(chalk.red('Error: file path cannot be empty.'));
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+ const VALID_VISIBILITIES = ['global', 'private', 'group', 'network', 'link-only', 'public'];
73
+ if (!VALID_VISIBILITIES.includes(options.visibility)) {
74
+ console.log(chalk.red(`Invalid visibility '${options.visibility}'. Must be one of: ${VALID_VISIBILITIES.join(', ')}`));
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ const maxDl = parseInt(options.maxDownloads);
79
+ if (isNaN(maxDl) || maxDl < 0) {
80
+ console.log(chalk.red('--max-downloads must be a non-negative integer.'));
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ const absolutePath = path.resolve(filePath);
85
+ try {
86
+ try { requireRole('user'); } catch (e) {
87
+ console.log(chalk.red(e.message));
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+ if (!fs.existsSync(absolutePath)) {
92
+ console.log(chalk.red(`Path does not exist: ${absolutePath}`));
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ const friends = getFriends();
97
+
98
+ // Validate pals if provided
99
+ let recipients = [];
100
+ if (options.withGroup) {
101
+ const group = getGroup(options.withGroup);
102
+ if (!group) {
103
+ console.log(chalk.red(`Group '${options.withGroup}' not found.`));
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ if (group.members.length === 0) {
108
+ console.log(chalk.yellow(`Group '${group.name}' has no members.`));
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ recipients = group.members.map(m => {
113
+ const pal = friends.find(f => f.id === m.id);
114
+ return pal || { name: m.name, id: m.id, handle: m.handle };
115
+ });
116
+ console.log(chalk.cyan(`Sharing with group '${group.name}' (${recipients.length} members)`));
117
+ }
118
+ if (options.with) {
119
+ const palRecipients = options.with.map(nameOrId => {
120
+ const stripped = nameOrId.replace(/^@/, '');
121
+ const pal = friends.find(f =>
122
+ f.id === stripped || f.name === stripped || f.handle === stripped || f.publicKey === stripped
123
+ );
124
+ if (!pal) {
125
+ console.warn(chalk.yellow(`Warning: Pal '${stripped}' not found in your list.`));
126
+ return { name: stripped, id: 'unknown' };
127
+ }
128
+ return pal;
129
+ });
130
+ // Merge, avoid duplicates by id
131
+ for (const r of palRecipients) {
132
+ if (!recipients.find(e => e.id === r.id)) {
133
+ recipients.push(r);
134
+ }
135
+ }
136
+ }
137
+
138
+ checkLimit('maxShareRecipients', recipients.length);
139
+
140
+ console.log(chalk.blue(`Preparing to share: ${absolutePath}`));
141
+
142
+ let type = 'file';
143
+ try {
144
+ const stat = fs.statSync(absolutePath);
145
+ if (stat.isDirectory()) {
146
+ const parsed = path.parse(absolutePath);
147
+ type = parsed.dir === parsed.root || absolutePath === parsed.root ? 'drive' : 'folder';
148
+ }
149
+ } catch {
150
+ // path doesn't exist yet — default to 'file'
151
+ }
152
+
153
+ const recursive = options.recursive !== false;
154
+ let expiresIn = 0;
155
+ if (options.expires) {
156
+ expiresIn = parseDuration(options.expires);
157
+ if (!expiresIn) {
158
+ console.log(chalk.red('Invalid --expires format. Use: 1h, 3d, 7d, 30d'));
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ }
163
+ const maxDownloads = parseInt(options.maxDownloads) || 0;
164
+ const password = options.password || null;
165
+ const category = options.category || null;
166
+ const description = options.description ? options.description.slice(0, 200) : null;
167
+ const color = options.color || null;
168
+ const icon = options.icon || null;
169
+ const share = addShare(absolutePath, type, options.visibility, { read: true }, recipients, { recursive, expiresIn, maxDownloads, password, category, description, color, icon });
170
+ if (options.tags) {
171
+ const { updateShare } = await import('../core/shares.js');
172
+ updateShare(share.id, { tags: options.tags.split(',').map(t => t.trim()).filter(Boolean) });
173
+ }
174
+
175
+ // Set streamable flag
176
+ if (options.streamable) {
177
+ const shares = config.get('shares');
178
+ const idx = shares.findIndex(s => s.id === share.id);
179
+ if (idx !== -1) {
180
+ shares[idx].streamable = true;
181
+ config.set('shares', shares);
182
+ }
183
+ console.log(chalk.magenta(' Media streaming enabled for this share'));
184
+ }
185
+
186
+ // Set network association
187
+ if (options.withNetwork) {
188
+ const shares = config.get('shares');
189
+ const idx = shares.findIndex(s => s.id === share.id);
190
+ if (idx !== -1) {
191
+ shares[idx].sharedWithNetworks = [options.withNetwork];
192
+ config.set('shares', shares);
193
+ }
194
+ }
195
+
196
+ // Persist group association
197
+ if (options.withGroup) {
198
+ const group = getGroup(options.withGroup);
199
+ if (group) {
200
+ const shares = config.get('shares');
201
+ const idx = shares.findIndex(s => s.id === share.id);
202
+ if (idx !== -1) {
203
+ shares[idx].sharedWithGroups = [group.id];
204
+ config.set('shares', shares);
205
+ }
206
+ }
207
+ }
208
+
209
+ // Encrypt for private shares that have named recipients
210
+ if (options.visibility === 'private' && share.recipients.length > 0) {
211
+ const { generateShareKey, encryptDirectory, getEncryptedShareDir, encryptShareKeyForRecipient } = await import('../crypto/shareEncryption.js');
212
+ const shareKey = generateShareKey();
213
+ const encDir = getEncryptedShareDir(share.id);
214
+ console.log(chalk.gray('Encrypting files for private share...'));
215
+ const encStart = Date.now();
216
+ const encSpinner = setInterval(() => {
217
+ const elapsed = ((Date.now() - encStart) / 1000).toFixed(0);
218
+ process.stdout.write(`\r${chalk.gray(` Encrypting... ${elapsed}s`)}`);
219
+ }, 1000);
220
+ try {
221
+ encryptDirectory(absolutePath, encDir, shareKey);
222
+ } finally {
223
+ clearInterval(encSpinner);
224
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
225
+ }
226
+
227
+ // Wrap the shareKey for each recipient
228
+ const encryptedShareKeys = {};
229
+ for (const recipient of share.recipients) {
230
+ const pal = friends.find(f => f.name === recipient.name || f.id === recipient.id || f.handle === recipient.handle);
231
+ if (pal?.id && pal.id !== 'unknown') {
232
+ encryptedShareKeys[pal.id] = encryptShareKeyForRecipient(shareKey, pal.id);
233
+ }
234
+ }
235
+
236
+ // Store share key securely in credential store
237
+ await storeShareKey(share.id, shareKey.toString('hex'));
238
+
239
+ // Persist encryption metadata onto the stored share (without shareKeyHex)
240
+ const shares = config.get('shares');
241
+ const idx = shares.findIndex(s => s.id === share.id);
242
+ if (idx !== -1) {
243
+ shares[idx].encryptedPath = encDir;
244
+ shares[idx].encryptedShareKeys = encryptedShareKeys;
245
+ config.set('shares', shares);
246
+ }
247
+
248
+ console.log(chalk.green('Encryption complete.'));
249
+
250
+ // Warn about disk overhead for private shares
251
+ try {
252
+ const stat = fs.statSync(absolutePath);
253
+ if (stat.isDirectory()) {
254
+ let totalSize = 0;
255
+ const walk = (dir) => {
256
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
257
+ const full = path.join(dir, entry.name);
258
+ if (entry.isFile()) totalSize += fs.statSync(full).size;
259
+ else if (entry.isDirectory()) walk(full);
260
+ }
261
+ };
262
+ walk(absolutePath);
263
+ const sizeMB = (totalSize / 1024 / 1024).toFixed(0);
264
+ if (totalSize > 1024 * 1024 * 1024) {
265
+ const sizeGB = (totalSize / 1024 / 1024 / 1024).toFixed(1);
266
+ console.log(chalk.yellow(` Warning: Private shares create an encrypted copy (~${sizeGB} GB additional disk space).`));
267
+ console.log(chalk.yellow(` Total disk usage for this share: ~${(totalSize * 2 / 1024 / 1024 / 1024).toFixed(1)} GB (original + encrypted copy).`));
268
+ } else if (totalSize > 50 * 1024 * 1024) {
269
+ console.log(chalk.yellow(` Note: Private shares create an encrypted copy (~${sizeMB} MB additional disk space).`));
270
+ }
271
+ }
272
+ } catch {}
273
+ }
274
+
275
+ logger.info(`Share added: ${absolutePath}`, { visibility: options.visibility, type, id: share.id });
276
+ console.log(chalk.green(`✔ Resource added to ${options.visibility} share list!`));
277
+ if (recipients.length > 0) {
278
+ console.log(chalk.cyan(` Shared with: ${recipients.map(r => r.name).join(', ')}`));
279
+ }
280
+
281
+ // Send PAL/1.0 share.offer envelopes to recipients
282
+ if (options.visibility === 'private' && recipients.length > 0) {
283
+ try {
284
+ const { getIdentity } = await import('../core/identity.js');
285
+ const identity = await getIdentity();
286
+ if (identity?.privateKey) {
287
+ const { initiateShare } = await import('../protocol/negotiation.js');
288
+ const { postTo, getPrimaryServer } = await import('../core/discoveryClient.js');
289
+ const keyPair = {
290
+ publicKey: Buffer.from(identity.publicKey, 'hex'),
291
+ privateKey: Buffer.from(identity.privateKey, 'hex'),
292
+ };
293
+ let sent = 0;
294
+ for (const r of recipients) {
295
+ const pal = friends.find(f => f.id === r.id);
296
+ if (pal?.id && pal.id !== 'unknown') {
297
+ const result = await initiateShare(keyPair, pal.id, share, options.policy || {});
298
+ if (result.ok) {
299
+ await postTo(getPrimaryServer(), '/api/v1/messages', {
300
+ toHandle: pal.handle || pal.name,
301
+ fromHandle: identity.handle || identity.name,
302
+ payload: result.envelope,
303
+ });
304
+ sent++;
305
+ }
306
+ }
307
+ }
308
+ if (sent > 0) console.log(chalk.gray(` PAL/1.0 share offers sent to ${sent} recipient(s)`));
309
+ }
310
+ } catch {
311
+ // Protocol notifications are best-effort
312
+ }
313
+ }
314
+
315
+ if (share.password) console.log(chalk.yellow(' Password protected'));
316
+ if (share.expiresAt) console.log(chalk.gray(` Expires: ${new Date(share.expiresAt).toLocaleString()}`));
317
+ if (share.maxDownloads) console.log(chalk.gray(` Max downloads: ${share.maxDownloads}`));
318
+ console.log(chalk.white('To start seeding, use: pal serve'));
319
+ console.log(chalk.gray(`ID: ${share.id}`));
320
+ } catch (err) {
321
+ logger.error(`Share failed: ${err.message}`, { path: absolutePath });
322
+ console.error(chalk.red(`Error: ${err.message}`));
323
+ }
324
+ });
325
+ }
326
+
327
+ function parseDuration(str) {
328
+ const match = str.match(/^(\d+)([dhm])$/);
329
+ if (!match) return null;
330
+ const n = parseInt(match[1]);
331
+ const unit = match[2];
332
+ if (unit === 'd') return n * 86400000;
333
+ if (unit === 'h') return n * 3600000;
334
+ if (unit === 'm') return n * 60000;
335
+ return null;
336
+ }