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,50 @@
1
+ import chalk from 'chalk';
2
+ import { removeShare, rotateShareKey, removeRecipientFromShare, listShares } from '../core/shares.js';
3
+
4
+ export default function revokeCommand(program) {
5
+ program
6
+ .command('revoke <idOrPath>')
7
+ .description('revoke access to a shared resource or remove a specific recipient')
8
+ .option('--pal <palId>', 'Remove a specific pal from the share (triggers key rotation)')
9
+ .addHelpText('after', `
10
+ Examples:
11
+ $ pe revoke abc123 Revoke share by ID
12
+ $ pe revoke ./photos Revoke share by path
13
+ $ pe revoke abc123 --pal alice Remove one recipient (rotates key)
14
+ `)
15
+ .action(async (idOrPath, opts) => {
16
+ try {
17
+ if (opts.pal) {
18
+ // Remove a specific recipient and rotate keys
19
+ const shares = listShares();
20
+ const share = shares.find(s => s.id === idOrPath || s.path === idOrPath);
21
+ if (!share) {
22
+ console.error(chalk.red('Share not found.'));
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+
27
+ removeRecipientFromShare(share.id, opts.pal);
28
+ console.log(chalk.yellow(`Removed ${opts.pal} from share ${share.id}`));
29
+
30
+ if (share.visibility === 'private' && (share.recipients || []).length > 0) {
31
+ console.log(chalk.blue('Rotating share key and re-encrypting...'));
32
+ const { newEncryptedShareKeys } = await rotateShareKey(share.id);
33
+ const remaining = Object.keys(newEncryptedShareKeys).length;
34
+ console.log(chalk.green(`Key rotated. ${remaining} recipient(s) will need new magnet on next serve.`));
35
+ }
36
+ return;
37
+ }
38
+
39
+ // Full revoke
40
+ const removed = removeShare(idOrPath);
41
+ if (removed) {
42
+ console.log(chalk.green(`Successfully revoked share: ${idOrPath}`));
43
+ } else {
44
+ console.error(chalk.red('Share not found.'));
45
+ }
46
+ } catch (err) {
47
+ console.error(chalk.red(`Error: ${err.message}`));
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,280 @@
1
+ import chalk from 'chalk';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ const BUILTIN_RULES = [
6
+ { name: 'SSN', pattern: '\\d{3}-\\d{2}-\\d{4}', severity: 'critical', description: 'Social Security Number' },
7
+ { name: 'CreditCard', pattern: '4\\d{3}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}', severity: 'critical', description: 'Credit card number (Visa)' },
8
+ { name: 'CreditCardMC', pattern: '5[1-5]\\d{2}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}', severity: 'critical', description: 'Credit card number (Mastercard)' },
9
+ { name: 'APIKey', pattern: '(?:sk_|pk_|api_|key_)[a-zA-Z0-9]{16,}', severity: 'high', description: 'API key' },
10
+ { name: 'PrivateKey', pattern: '-----BEGIN PRIVATE KEY-----', severity: 'critical', description: 'Private key block' },
11
+ { name: 'AWSKey', pattern: 'AKIA[0-9A-Z]{16}', severity: 'critical', description: 'AWS access key' },
12
+ { name: 'Email', pattern: '\\b[\\w.-]+@[\\w.-]+\\.\\w{2,}\\b', severity: 'low', description: 'Email address' },
13
+ ];
14
+
15
+ const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1 };
16
+ const SEVERITY_COLORS = {
17
+ critical: chalk.red,
18
+ high: chalk.yellow,
19
+ medium: chalk.cyan,
20
+ low: chalk.gray,
21
+ };
22
+
23
+ function classifyFile(maxSeverity) {
24
+ if (maxSeverity === 'critical') return 'Restricted';
25
+ if (maxSeverity === 'high') return 'Confidential';
26
+ if (maxSeverity === 'medium') return 'Internal';
27
+ return 'Public';
28
+ }
29
+
30
+ function scanContent(content, rules) {
31
+ const findings = [];
32
+ const lines = content.split('\n');
33
+ for (const rule of rules) {
34
+ const re = new RegExp(rule.pattern, 'g');
35
+ for (let i = 0; i < lines.length; i++) {
36
+ let match;
37
+ while ((match = re.exec(lines[i])) !== null) {
38
+ findings.push({
39
+ rule: rule.name,
40
+ severity: rule.severity,
41
+ line: i + 1,
42
+ match: match[0].length > 40 ? match[0].slice(0, 37) + '...' : match[0],
43
+ });
44
+ }
45
+ }
46
+ }
47
+ return findings;
48
+ }
49
+
50
+ export default function scannerCommand(program) {
51
+ const cmd = program
52
+ .command('scanner')
53
+ .description('content scanning and DLP (Enterprise)')
54
+ .addHelpText('after', `
55
+ Examples:
56
+ $ pe scanner scan ./documents Scan directory for sensitive data
57
+ $ pe scanner scan ./secrets.txt Scan a single file
58
+ $ pe scanner rules List active DLP rules
59
+ $ pe scanner rules --add MyRule "\\d{6}" high Add a custom rule
60
+ $ pe scanner rules --remove MyRule Remove a custom rule
61
+ $ pe scanner quarantine List quarantined files
62
+ $ pe scanner release <id> Release file from quarantine
63
+ `)
64
+ .action(() => { cmd.outputHelp(); });
65
+
66
+ cmd
67
+ .command('scan <path>')
68
+ .description('scan a file or directory for sensitive data')
69
+ .action(async (targetPath) => {
70
+ try {
71
+ const extConfig = (await import('../utils/config.js')).default;
72
+ const extSettings = extConfig.get('ext.content-scanner') || {};
73
+ const customRules = extSettings.rules || [];
74
+ const allRules = [...BUILTIN_RULES, ...customRules];
75
+
76
+ const resolved = path.resolve(targetPath);
77
+ if (!fs.existsSync(resolved)) {
78
+ console.log(chalk.red(`Path not found: ${resolved}`));
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+
83
+ const stat = fs.statSync(resolved);
84
+ const files = [];
85
+ if (stat.isDirectory()) {
86
+ const entries = fs.readdirSync(resolved, { recursive: true });
87
+ for (const entry of entries) {
88
+ const full = path.join(resolved, entry);
89
+ try {
90
+ if (fs.statSync(full).isFile()) files.push(full);
91
+ } catch { /* skip unreadable */ }
92
+ }
93
+ } else {
94
+ files.push(resolved);
95
+ }
96
+
97
+ if (files.length === 0) {
98
+ console.log(chalk.gray('No files to scan.'));
99
+ return;
100
+ }
101
+
102
+ console.log(chalk.cyan(`Scanning ${files.length} file(s)...\n`));
103
+ let totalFindings = 0;
104
+ let maxSeverity = 'low';
105
+
106
+ for (const file of files) {
107
+ let content;
108
+ try {
109
+ content = fs.readFileSync(file, 'utf8');
110
+ } catch {
111
+ continue;
112
+ }
113
+
114
+ const findings = scanContent(content, allRules);
115
+ if (findings.length === 0) continue;
116
+
117
+ totalFindings += findings.length;
118
+ const rel = path.relative(process.cwd(), file);
119
+ console.log(` ${chalk.white(rel)}`);
120
+
121
+ for (const f of findings) {
122
+ const colorFn = SEVERITY_COLORS[f.severity] || chalk.white;
123
+ console.log(` Line ${chalk.gray(f.line)}: ${colorFn(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.rule)} — ${chalk.dim(f.match)}`);
124
+ if (SEVERITY_ORDER[f.severity] > SEVERITY_ORDER[maxSeverity]) {
125
+ maxSeverity = f.severity;
126
+ }
127
+ }
128
+ console.log('');
129
+ }
130
+
131
+ if (totalFindings === 0) {
132
+ console.log(chalk.green('✔ No sensitive data found.'));
133
+ console.log(` Classification: ${chalk.green('Public')}`);
134
+ } else {
135
+ const classification = classifyFile(maxSeverity);
136
+ console.log(chalk.gray('─'.repeat(50)));
137
+ console.log(` Total findings: ${chalk.white(totalFindings)}`);
138
+ console.log(` Classification: ${SEVERITY_COLORS[maxSeverity](classification)}`);
139
+ }
140
+ } catch (err) {
141
+ console.log(chalk.red(`Scan failed: ${err.message}`));
142
+ process.exitCode = 1;
143
+ }
144
+ });
145
+
146
+ cmd
147
+ .command('rules')
148
+ .description('list active DLP rules')
149
+ .option('--add <name>', 'add a custom rule (requires <pattern> <severity> as args)')
150
+ .option('--remove <name>', 'remove a custom rule')
151
+ .action(async (opts, command) => {
152
+ try {
153
+ const extConfig = (await import('../utils/config.js')).default;
154
+ const extSettings = extConfig.get('ext.content-scanner') || {};
155
+
156
+ if (opts.remove) {
157
+ const customRules = extSettings.rules || [];
158
+ const idx = customRules.findIndex(r => r.name === opts.remove);
159
+ if (idx === -1) {
160
+ console.log(chalk.yellow(`Rule not found: ${opts.remove}`));
161
+ process.exitCode = 1;
162
+ return;
163
+ }
164
+ customRules.splice(idx, 1);
165
+ extSettings.rules = customRules;
166
+ extConfig.set('ext.content-scanner', extSettings);
167
+ console.log(chalk.green(`✔ Rule removed: ${opts.remove}`));
168
+ return;
169
+ }
170
+
171
+ if (opts.add) {
172
+ const args = command.args;
173
+ if (args.length < 2) {
174
+ console.log(chalk.red('Usage: pe scanner rules --add <name> <pattern> <severity>'));
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ const pattern = args[0];
179
+ try {
180
+ const testRe = new RegExp(pattern);
181
+ const start = Date.now();
182
+ 'a'.repeat(25).match(testRe);
183
+ if (Date.now() - start > 100) throw new Error('Pattern too complex');
184
+ } catch (e) {
185
+ console.error(chalk.red(`Invalid or unsafe regex pattern: ${e.message}`));
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ const severity = args[1] || 'medium';
190
+ if (!['critical', 'high', 'medium', 'low'].includes(severity)) {
191
+ console.log(chalk.red('Severity must be: critical, high, medium, or low'));
192
+ process.exitCode = 1;
193
+ return;
194
+ }
195
+ const customRules = extSettings.rules || [];
196
+ if (customRules.find(r => r.name === opts.add)) {
197
+ console.log(chalk.yellow(`Rule already exists: ${opts.add}`));
198
+ process.exitCode = 1;
199
+ return;
200
+ }
201
+ customRules.push({ name: opts.add, pattern, severity, description: `Custom rule: ${opts.add}` });
202
+ extSettings.rules = customRules;
203
+ extConfig.set('ext.content-scanner', extSettings);
204
+ console.log(chalk.green(`✔ Rule added: ${opts.add}`));
205
+ console.log(` Pattern: ${chalk.white(pattern)}`);
206
+ console.log(` Severity: ${SEVERITY_COLORS[severity](severity)}`);
207
+ return;
208
+ }
209
+
210
+ // List all rules
211
+ const customRules = extSettings.rules || [];
212
+ const allRules = [...BUILTIN_RULES, ...customRules];
213
+ console.log(chalk.bold(`DLP Rules (${allRules.length})\n`));
214
+ console.log(chalk.cyan(' Built-in:'));
215
+ for (const r of BUILTIN_RULES) {
216
+ const colorFn = SEVERITY_COLORS[r.severity] || chalk.white;
217
+ console.log(` ${chalk.white(r.name)} ${colorFn(`[${r.severity}]`)} — ${chalk.dim(r.description)}`);
218
+ }
219
+ if (customRules.length > 0) {
220
+ console.log('');
221
+ console.log(chalk.cyan(' Custom:'));
222
+ for (const r of customRules) {
223
+ const colorFn = SEVERITY_COLORS[r.severity] || chalk.white;
224
+ console.log(` ${chalk.white(r.name)} ${colorFn(`[${r.severity}]`)} — ${chalk.dim(r.pattern)}`);
225
+ }
226
+ }
227
+ } catch (err) {
228
+ console.log(chalk.red(`Rules failed: ${err.message}`));
229
+ process.exitCode = 1;
230
+ }
231
+ });
232
+
233
+ cmd
234
+ .command('quarantine')
235
+ .description('list quarantined files')
236
+ .action(async () => {
237
+ try {
238
+ const extConfig = (await import('../utils/config.js')).default;
239
+ const store = extConfig.get('ext_store.content-scanner') || {};
240
+ const quarantined = store.quarantined || [];
241
+ if (quarantined.length === 0) {
242
+ console.log(chalk.dim('No quarantined files.'));
243
+ return;
244
+ }
245
+ console.log(chalk.bold(`Quarantined Files (${quarantined.length})\n`));
246
+ for (const f of quarantined) {
247
+ const colorFn = SEVERITY_COLORS[f.severity] || chalk.white;
248
+ console.log(` ${chalk.white(f.id)} ${colorFn(`[${f.severity}]`)} ${chalk.cyan(f.path)}`);
249
+ console.log(` Reason: ${chalk.dim(f.reason || 'DLP violation')} Quarantined: ${chalk.dim(f.quarantinedAt)}`);
250
+ }
251
+ } catch (err) {
252
+ console.log(chalk.red(`Quarantine list failed: ${err.message}`));
253
+ process.exitCode = 1;
254
+ }
255
+ });
256
+
257
+ cmd
258
+ .command('release <id>')
259
+ .description('release a file from quarantine')
260
+ .action(async (id) => {
261
+ try {
262
+ const extConfig = (await import('../utils/config.js')).default;
263
+ const store = extConfig.get('ext_store.content-scanner') || {};
264
+ const quarantined = store.quarantined || [];
265
+ const idx = quarantined.findIndex(f => f.id === id);
266
+ if (idx === -1) {
267
+ console.log(chalk.yellow(`Quarantined file not found: ${id}`));
268
+ process.exitCode = 1;
269
+ return;
270
+ }
271
+ const released = quarantined.splice(idx, 1)[0];
272
+ store.quarantined = quarantined;
273
+ extConfig.set('ext_store.content-scanner', store);
274
+ console.log(chalk.green(`✔ Released from quarantine: ${released.path}`));
275
+ } catch (err) {
276
+ console.log(chalk.red(`Release failed: ${err.message}`));
277
+ process.exitCode = 1;
278
+ }
279
+ });
280
+ }
@@ -0,0 +1,344 @@
1
+ import chalk from 'chalk';
2
+ import config from '../utils/config.js';
3
+ import { parseCommaList } from '../utils/cli.js';
4
+
5
+ function loadTasks() {
6
+ return config.get('scheduledTasks') || [];
7
+ }
8
+
9
+ function saveTasks(tasks) {
10
+ config.set('scheduledTasks', tasks);
11
+ }
12
+
13
+ function formatDate(ts) {
14
+ return new Date(ts).toLocaleString();
15
+ }
16
+
17
+ const STATUS_COLORS = {
18
+ pending: chalk.blue,
19
+ running: chalk.yellow,
20
+ completed: chalk.green,
21
+ failed: chalk.red,
22
+ cancelled: chalk.gray,
23
+ };
24
+
25
+ const VALID_EVERY = ['hourly', 'daily', 'weekly', 'monthly'];
26
+
27
+ export function parseEvery(val) {
28
+ if (!val) return null;
29
+ const lower = val.toLowerCase();
30
+ if (VALID_EVERY.includes(lower)) return lower;
31
+ const match = lower.match(/^(\d+)\s*(m|min|minutes?|h|hr|hours?|d|days?)$/);
32
+ if (match) {
33
+ const n = parseInt(match[1], 10);
34
+ const unit = match[2][0];
35
+ if (unit === 'm') return n * 60_000;
36
+ if (unit === 'h') return n * 3_600_000;
37
+ if (unit === 'd') return n * 86_400_000;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ export function computeNextRun(task) {
43
+ if (!task.every) return null;
44
+ const raw = task.lastRunAt || task.executeAt || Date.now();
45
+ const base = typeof raw === 'string' ? new Date(raw).getTime() : raw;
46
+ if (typeof task.every === 'number') return base + task.every;
47
+ const d = new Date(base);
48
+ if (task.every === 'hourly') d.setHours(d.getHours() + 1);
49
+ else if (task.every === 'daily') d.setDate(d.getDate() + 1);
50
+ else if (task.every === 'weekly') d.setDate(d.getDate() + 7);
51
+ else if (task.every === 'monthly') d.setMonth(d.getMonth() + 1);
52
+ return d.getTime();
53
+ }
54
+
55
+ export function parseWindow(val) {
56
+ if (!val) return null;
57
+ const match = val.match(/^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/);
58
+ if (!match) return null;
59
+ const sh = parseInt(match[1], 10), sm = parseInt(match[2], 10);
60
+ const eh = parseInt(match[3], 10), em = parseInt(match[4], 10);
61
+ if (sh > 23 || sm > 59 || eh > 23 || em > 59) return null;
62
+ return { startHour: sh, startMin: sm, endHour: eh, endMin: em };
63
+ }
64
+
65
+ export function isInWindow(window, now) {
66
+ if (!window) return true;
67
+ const d = now || new Date();
68
+ const current = d.getHours() * 60 + d.getMinutes();
69
+ const start = window.startHour * 60 + window.startMin;
70
+ const end = window.endHour * 60 + window.endMin;
71
+ if (start <= end) return current >= start && current < end;
72
+ // Overnight window (e.g. 22:00-06:00)
73
+ return current >= start || current < end;
74
+ }
75
+
76
+ export function isTaskReady(task, now) {
77
+ if (task.status !== 'pending') return false;
78
+ const ts = now || Date.now();
79
+ if (task.executeAt > ts) return false;
80
+ if (task.window && !isInWindow(task.window, new Date(ts))) return false;
81
+ return true;
82
+ }
83
+
84
+ export default function scheduleCommand(program) {
85
+ const cmd = program
86
+ .command('schedule')
87
+ .description('manage scheduled tasks (shares, downloads, auto-revokes)')
88
+ .addHelpText('after', `
89
+ Examples:
90
+ $ pe schedule List all scheduled tasks
91
+ $ pe schedule add share /path --at "2026-03-15 09:00"
92
+ $ pe schedule add share /path --at "2026-03-15 09:00" --every daily
93
+ $ pe schedule add download --magnet "magnet:..." --at "22:00" --window "22:00-06:00"
94
+ $ pe schedule add revoke --share-path /path --at "2026-04-01 00:00"
95
+ $ pe schedule cancel <taskId>
96
+ $ pe schedule run-once Execute due tasks once and exit
97
+ $ pe schedule daemon Run scheduler in foreground (30s interval)
98
+ `)
99
+ .action(() => {
100
+ const tasks = loadTasks();
101
+ if (tasks.length === 0) {
102
+ console.log(chalk.gray('No scheduled tasks. Use `pe schedule add` to create one.'));
103
+ return;
104
+ }
105
+ console.log('');
106
+ console.log(chalk.cyan('Scheduled Tasks:'));
107
+ for (const t of tasks) {
108
+ const colorFn = STATUS_COLORS[t.status] || chalk.white;
109
+ console.log(` ${chalk.white(t.id)} [${colorFn(t.status)}] ${chalk.yellow(t.type)}`);
110
+ console.log(` Execute at: ${chalk.white(formatDate(t.executeAt))}`);
111
+ if (t.every) console.log(` Repeat: ${chalk.cyan(typeof t.every === 'number' ? `${t.every / 60000}m` : t.every)}`);
112
+ if (t.window) console.log(` Window: ${chalk.cyan(`${String(t.window.startHour).padStart(2, '0')}:${String(t.window.startMin).padStart(2, '0')}-${String(t.window.endHour).padStart(2, '0')}:${String(t.window.endMin).padStart(2, '0')}`)}`);
113
+ if (t.data.folderPath) console.log(` Path: ${chalk.gray(t.data.folderPath)}`);
114
+ if (t.data.magnet) console.log(` Magnet: ${chalk.gray(t.data.magnet.slice(0, 50))}...`);
115
+ if (t.data.sharePath) console.log(` Share: ${chalk.gray(t.data.sharePath)}`);
116
+ if (t.completedAt) console.log(` Completed: ${chalk.gray(t.completedAt)}`);
117
+ if (t.error) console.log(` Error: ${chalk.red(t.error)}`);
118
+ if (t.runCount) console.log(` Runs: ${chalk.gray(t.runCount)}`);
119
+ }
120
+ });
121
+
122
+ const addCmd = cmd
123
+ .command('add <type>')
124
+ .description('schedule a new task (share, download, revoke)')
125
+ .option('--at <datetime>', 'When to execute (ISO 8601 or local datetime string)')
126
+ .option('--every <interval>', 'Repeat: hourly, daily, weekly, monthly, or Nm/Nh/Nd (e.g. 30m, 2h, 1d)')
127
+ .option('--window <range>', 'Only run during time window (e.g. "22:00-06:00")')
128
+ .option('--magnet <uri>', 'Magnet URI (for download tasks)')
129
+ .option('--name <name>', 'Name for the download')
130
+ .option('--share-path <path>', 'Share path (for revoke tasks)')
131
+ .option('--visibility <vis>', 'Share visibility (global|private)', 'global')
132
+ .option('--recipients <handles>', 'Comma-separated recipient handles')
133
+ .action((type, opts) => {
134
+ if (!['share', 'download', 'revoke'].includes(type)) {
135
+ console.log(chalk.red('Invalid task type. Use: share, download, revoke'));
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+
140
+ if (!opts.at) {
141
+ console.log(chalk.red('--at is required. Example: --at "2026-03-15 09:00"'));
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+
146
+ const executeAt = new Date(opts.at).getTime();
147
+ if (isNaN(executeAt) || executeAt <= Date.now()) {
148
+ console.log(chalk.red('--at must be a valid future datetime.'));
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+
153
+ let every = null;
154
+ if (opts.every) {
155
+ every = parseEvery(opts.every);
156
+ if (every === null) {
157
+ console.log(chalk.red('Invalid --every value. Use: hourly, daily, weekly, monthly, or Nm/Nh/Nd'));
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ }
162
+
163
+ let window = null;
164
+ if (opts.window) {
165
+ window = parseWindow(opts.window);
166
+ if (!window) {
167
+ console.log(chalk.red('Invalid --window format. Use: "HH:MM-HH:MM" (e.g. "22:00-06:00")'));
168
+ process.exitCode = 1;
169
+ return;
170
+ }
171
+ }
172
+
173
+ let data = {};
174
+ if (type === 'share') {
175
+ const folderPath = addCmd.args[1];
176
+ if (!folderPath) {
177
+ console.log(chalk.red('Usage: pe schedule add share <path> --at <datetime>'));
178
+ process.exitCode = 1;
179
+ return;
180
+ }
181
+ data = {
182
+ folderPath,
183
+ visibility: opts.visibility,
184
+ recipients: parseCommaList(opts.recipients),
185
+ };
186
+ } else if (type === 'download') {
187
+ if (!opts.magnet) {
188
+ console.log(chalk.red('--magnet is required for download tasks.'));
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+ data = { magnet: opts.magnet, name: opts.name || 'Scheduled Download' };
193
+ } else if (type === 'revoke') {
194
+ if (!opts.sharePath) {
195
+ console.log(chalk.red('--share-path is required for revoke tasks.'));
196
+ process.exitCode = 1;
197
+ return;
198
+ }
199
+ data = { sharePath: opts.sharePath };
200
+ }
201
+
202
+ const tasks = loadTasks();
203
+ const task = {
204
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
205
+ type,
206
+ executeAt,
207
+ data,
208
+ status: 'pending',
209
+ createdAt: new Date().toISOString(),
210
+ };
211
+ if (every) task.every = every;
212
+ if (window) task.window = window;
213
+ tasks.push(task);
214
+ saveTasks(tasks);
215
+
216
+ console.log(chalk.green(`✔ Task scheduled: ${task.id}`));
217
+ console.log(` Type: ${chalk.yellow(type)}`);
218
+ console.log(` Execute at: ${chalk.white(formatDate(executeAt))}`);
219
+ if (every) console.log(` Repeat: ${chalk.cyan(opts.every)}`);
220
+ if (window) console.log(` Window: ${chalk.cyan(opts.window)}`);
221
+ });
222
+
223
+ cmd
224
+ .command('cancel <taskId>')
225
+ .description('cancel a pending scheduled task')
226
+ .action((taskId) => {
227
+ const tasks = loadTasks();
228
+ const task = tasks.find(t => t.id === taskId);
229
+ if (!task) {
230
+ console.log(chalk.red('Task not found.'));
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+ if (task.status !== 'pending') {
235
+ console.log(chalk.red(`Cannot cancel task with status: ${task.status}`));
236
+ process.exitCode = 1;
237
+ return;
238
+ }
239
+ task.status = 'cancelled';
240
+ saveTasks(tasks);
241
+ console.log(chalk.green(`✔ Task ${taskId} cancelled.`));
242
+ });
243
+
244
+ cmd
245
+ .command('clear')
246
+ .description('remove all completed/failed/cancelled tasks')
247
+ .action(() => {
248
+ const tasks = loadTasks();
249
+ const remaining = tasks.filter(t => t.status === 'pending' || t.status === 'running');
250
+ const removed = tasks.length - remaining.length;
251
+ saveTasks(remaining);
252
+ console.log(chalk.green(`✔ Cleared ${removed} tasks. ${remaining.length} remaining.`));
253
+ });
254
+
255
+ cmd
256
+ .command('run-once')
257
+ .description('check and execute any due tasks, then exit')
258
+ .action(async () => {
259
+ const executed = await runCliScheduler();
260
+ if (executed === 0) {
261
+ console.log(chalk.gray('No tasks due.'));
262
+ } else {
263
+ console.log(chalk.green(`✔ Executed ${executed} task(s).`));
264
+ }
265
+ });
266
+
267
+ cmd
268
+ .command('daemon')
269
+ .description('run scheduler in foreground, checking every 30 seconds')
270
+ .option('--interval <seconds>', 'Check interval in seconds', '30')
271
+ .action(async (opts) => {
272
+ const interval = Math.max(5, parseInt(opts.interval, 10) || 30) * 1000;
273
+ console.log(chalk.cyan(`Scheduler daemon running (interval: ${interval / 1000}s). Press Ctrl+C to stop.`));
274
+ const run = async () => {
275
+ const executed = await runCliScheduler();
276
+ if (executed > 0) console.log(chalk.green(`[${new Date().toLocaleTimeString()}] Executed ${executed} task(s).`));
277
+ };
278
+ await run();
279
+ const timer = setInterval(run, interval);
280
+ process.on('SIGINT', () => { clearInterval(timer); process.exit(0); });
281
+ process.on('SIGTERM', () => { clearInterval(timer); process.exit(0); });
282
+ // Keep alive
283
+ await new Promise(() => {});
284
+ });
285
+ }
286
+
287
+ async function runCliScheduler() {
288
+ const tasks = loadTasks();
289
+ const now = Date.now();
290
+ let executed = 0;
291
+
292
+ for (const task of tasks) {
293
+ if (!isTaskReady(task, now)) continue;
294
+
295
+ task.status = 'running';
296
+
297
+ try {
298
+ if (task.type === 'share') {
299
+ console.log(chalk.cyan(`Sharing ${task.data.folderPath}...`));
300
+ const { default: shareAction } = await import('../core/shares.js');
301
+ if (typeof shareAction.addShare === 'function') {
302
+ await shareAction.addShare(task.data.folderPath, {
303
+ visibility: task.data.visibility,
304
+ recipients: task.data.recipients,
305
+ });
306
+ }
307
+ } else if (task.type === 'download') {
308
+ console.log(chalk.cyan(`Starting download: ${task.data.name}...`));
309
+ // CLI downloads need the torrent client - best-effort
310
+ console.log(chalk.gray(` Magnet: ${task.data.magnet.slice(0, 60)}...`));
311
+ console.log(chalk.gray(' Note: CLI downloads require `pe serve` for full torrent support.'));
312
+ } else if (task.type === 'revoke') {
313
+ console.log(chalk.cyan(`Revoking share: ${task.data.sharePath}...`));
314
+ const shares = config.get('shares') || [];
315
+ const idx = shares.findIndex(s => s.path === task.data.sharePath);
316
+ if (idx >= 0) {
317
+ shares.splice(idx, 1);
318
+ config.set('shares', shares);
319
+ }
320
+ }
321
+
322
+ task.lastRunAt = new Date().toISOString();
323
+ task.runCount = (task.runCount || 0) + 1;
324
+
325
+ if (task.every) {
326
+ // Recurring: reschedule
327
+ task.executeAt = computeNextRun(task);
328
+ task.status = 'pending';
329
+ console.log(chalk.gray(` Next run: ${formatDate(task.executeAt)}`));
330
+ } else {
331
+ task.status = 'completed';
332
+ task.completedAt = new Date().toISOString();
333
+ }
334
+ executed++;
335
+ } catch (err) {
336
+ console.error(chalk.red(` Task ${task.id} failed: ${err.message}`));
337
+ task.status = 'failed';
338
+ task.error = err.message;
339
+ }
340
+ }
341
+
342
+ if (executed > 0) saveTasks(tasks);
343
+ return executed;
344
+ }