pal-explorer-cli 0.4.12 → 0.4.13

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 (99) hide show
  1. package/README.md +149 -149
  2. package/bin/pal.js +63 -2
  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 +203 -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/help.js +357 -357
  98. package/lib/utils/torrent.js +1 -0
  99. package/package.json +4 -3
@@ -1,344 +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
- }
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
+ $ pal schedule List all scheduled tasks
91
+ $ pal schedule add share /path --at "2026-03-15 09:00"
92
+ $ pal schedule add share /path --at "2026-03-15 09:00" --every daily
93
+ $ pal schedule add download --magnet "magnet:..." --at "22:00" --window "22:00-06:00"
94
+ $ pal schedule add revoke --share-path /path --at "2026-04-01 00:00"
95
+ $ pal schedule cancel <taskId>
96
+ $ pal schedule run-once Execute due tasks once and exit
97
+ $ pal 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 `pal 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: pal 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 `pal 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
+ }