pal-explorer-cli 0.4.11 → 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.
- package/README.md +149 -149
- package/bin/pal.js +63 -2
- package/extensions/@palexplorer/analytics/extension.json +20 -1
- package/extensions/@palexplorer/analytics/index.js +19 -9
- package/extensions/@palexplorer/audit/extension.json +14 -0
- package/extensions/@palexplorer/auth-email/extension.json +15 -0
- package/extensions/@palexplorer/auth-oauth/extension.json +15 -0
- package/extensions/@palexplorer/chat/extension.json +14 -0
- package/extensions/@palexplorer/discovery/extension.json +17 -0
- package/extensions/@palexplorer/discovery/index.js +1 -1
- package/extensions/@palexplorer/email-notifications/extension.json +23 -0
- package/extensions/@palexplorer/groups/extension.json +15 -0
- package/extensions/@palexplorer/share-links/extension.json +15 -0
- package/extensions/@palexplorer/sync/extension.json +16 -0
- package/extensions/@palexplorer/user-mgmt/extension.json +15 -0
- package/lib/capabilities.js +24 -24
- package/lib/commands/analytics.js +175 -175
- package/lib/commands/api-keys.js +131 -131
- package/lib/commands/audit.js +235 -235
- package/lib/commands/auth.js +137 -137
- package/lib/commands/backup.js +76 -76
- package/lib/commands/billing.js +148 -148
- package/lib/commands/chat.js +217 -217
- package/lib/commands/cloud-backup.js +231 -231
- package/lib/commands/comment.js +99 -99
- package/lib/commands/completion.js +203 -203
- package/lib/commands/compliance.js +218 -218
- package/lib/commands/config.js +136 -136
- package/lib/commands/connect.js +44 -44
- package/lib/commands/dept.js +294 -294
- package/lib/commands/device.js +146 -146
- package/lib/commands/download.js +240 -226
- package/lib/commands/explorer.js +178 -178
- package/lib/commands/extension.js +1060 -970
- package/lib/commands/favorite.js +90 -90
- package/lib/commands/federation.js +270 -270
- package/lib/commands/file.js +533 -533
- package/lib/commands/group.js +271 -271
- package/lib/commands/gui-share.js +29 -29
- package/lib/commands/init.js +61 -61
- package/lib/commands/invite.js +59 -59
- package/lib/commands/list.js +58 -58
- package/lib/commands/log.js +116 -116
- package/lib/commands/nearby.js +108 -108
- package/lib/commands/network.js +251 -251
- package/lib/commands/notify.js +198 -198
- package/lib/commands/org.js +273 -273
- package/lib/commands/pal.js +403 -180
- package/lib/commands/permissions.js +216 -216
- package/lib/commands/pin.js +97 -97
- package/lib/commands/protocol.js +357 -357
- package/lib/commands/rbac.js +147 -147
- package/lib/commands/recover.js +36 -36
- package/lib/commands/register.js +171 -171
- package/lib/commands/relay.js +131 -131
- package/lib/commands/remote.js +368 -368
- package/lib/commands/revoke.js +50 -50
- package/lib/commands/scanner.js +280 -280
- package/lib/commands/schedule.js +344 -344
- package/lib/commands/scim.js +203 -203
- package/lib/commands/search.js +181 -181
- package/lib/commands/serve.js +438 -438
- package/lib/commands/server.js +350 -350
- package/lib/commands/share-link.js +199 -199
- package/lib/commands/share.js +336 -323
- package/lib/commands/sso.js +200 -200
- package/lib/commands/status.js +145 -145
- package/lib/commands/stream.js +562 -562
- package/lib/commands/su.js +187 -187
- package/lib/commands/sync.js +979 -979
- package/lib/commands/transfers.js +152 -152
- package/lib/commands/uninstall.js +188 -188
- package/lib/commands/update.js +204 -204
- package/lib/commands/user.js +276 -276
- package/lib/commands/vfs.js +84 -84
- package/lib/commands/web-login.js +79 -79
- package/lib/commands/web.js +52 -52
- package/lib/commands/webhook.js +180 -180
- package/lib/commands/whoami.js +59 -59
- package/lib/commands/workspace.js +121 -121
- package/lib/core/billing.js +16 -5
- package/lib/core/dhtDiscovery.js +9 -2
- package/lib/core/discoveryClient.js +13 -7
- package/lib/core/extensions.js +142 -1
- package/lib/core/identity.js +33 -2
- package/lib/core/imageProcessor.js +109 -0
- package/lib/core/imageTorrent.js +167 -0
- package/lib/core/permissions.js +1 -1
- package/lib/core/pro.js +11 -4
- package/lib/core/serverList.js +4 -1
- package/lib/core/shares.js +12 -1
- package/lib/core/signalingServer.js +14 -2
- package/lib/core/su.js +1 -1
- package/lib/core/users.js +1 -1
- package/lib/protocol/messages.js +12 -3
- package/lib/utils/explorer.js +1 -1
- package/lib/utils/help.js +357 -357
- package/lib/utils/torrent.js +1 -0
- package/package.json +4 -3
package/lib/commands/schedule.js
CHANGED
|
@@ -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
|
-
$
|
|
91
|
-
$
|
|
92
|
-
$
|
|
93
|
-
$
|
|
94
|
-
$
|
|
95
|
-
$
|
|
96
|
-
$
|
|
97
|
-
$
|
|
98
|
-
`)
|
|
99
|
-
.action(() => {
|
|
100
|
-
const tasks = loadTasks();
|
|
101
|
-
if (tasks.length === 0) {
|
|
102
|
-
console.log(chalk.gray('No scheduled tasks. Use `
|
|
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:
|
|
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 `
|
|
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
|
+
}
|