wogiflow 2.32.0 → 2.33.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.
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * Wogi Flow — `flow schedule` CLI (Phase 1A — wf-b211a076).
7
+ *
8
+ * Installs platform-native unit files that invoke `flow-scheduled-runner.js`
9
+ * for users who prefer not to use GitHub Actions.
10
+ *
11
+ * Subcommands:
12
+ * flow schedule install --target=launchd|cron|systemd [--dry-run]
13
+ * flow schedule status
14
+ * flow schedule remove --target=launchd|cron|systemd
15
+ *
16
+ * Targets:
17
+ * launchd — macOS user-domain LaunchAgent plists in ~/Library/LaunchAgents/
18
+ * cron — crontab entries appended for the current user
19
+ * systemd — systemd --user .service + .timer units in ~/.config/systemd/user/
20
+ *
21
+ * The same 4 jobs are installed regardless of target. Schedules:
22
+ * nightly-regression 03:00 daily
23
+ * weekly-audit Mon 09:00
24
+ * weekly-digest Fri 17:00
25
+ * per-pr-review on-demand only (no schedule entry — invoked by gh webhook
26
+ * or `flow scheduled-runner per-pr-review` manually)
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const os = require('node:os');
31
+ const path = require('node:path');
32
+ const { JOB_NAMES } = require('../lib/scheduled-mode');
33
+
34
+ // ============================================================
35
+ // Schedule table — shared across all targets
36
+ // ============================================================
37
+
38
+ /**
39
+ * Job schedule definitions.
40
+ * - cronExpr is in classic 5-field cron format (UTC where the platform supports it).
41
+ * - macOS launchd uses StartCalendarInterval (hour/minute/weekday fields).
42
+ * - systemd uses OnCalendar with extended cron-like syntax.
43
+ *
44
+ * per-pr-review is intentionally omitted from schedules — it runs on PR events
45
+ * (via the GH Actions workflow), or on demand via the CLI.
46
+ */
47
+ const SCHEDULES = Object.freeze({
48
+ 'nightly-regression': {
49
+ cronExpr: '0 3 * * *',
50
+ launchd: { Hour: 3, Minute: 0 },
51
+ systemdOnCalendar: '*-*-* 03:00:00',
52
+ },
53
+ 'weekly-audit': {
54
+ cronExpr: '0 9 * * 1',
55
+ launchd: { Hour: 9, Minute: 0, Weekday: 1 },
56
+ systemdOnCalendar: 'Mon *-*-* 09:00:00',
57
+ },
58
+ 'weekly-digest': {
59
+ cronExpr: '0 17 * * 5',
60
+ launchd: { Hour: 17, Minute: 0, Weekday: 5 },
61
+ systemdOnCalendar: 'Fri *-*-* 17:00:00',
62
+ },
63
+ });
64
+
65
+ const SCHEDULED_JOB_NAMES = Object.keys(SCHEDULES);
66
+
67
+ const ALLOWED_TARGETS = new Set(['launchd', 'cron', 'systemd']);
68
+
69
+ // ============================================================
70
+ // Path helpers
71
+ // ============================================================
72
+
73
+ function repoRoot() {
74
+ // Best-effort: walk up looking for package.json with name "wogiflow"
75
+ let dir = process.cwd();
76
+ for (let i = 0; i < 25; i++) {
77
+ const pkg = path.join(dir, 'package.json');
78
+ if (fs.existsSync(pkg)) {
79
+ try {
80
+ const data = fs.readFileSync(pkg, 'utf-8');
81
+ if (data.includes('"wogiflow"') || data.includes('wogi-flow')) return dir;
82
+ } catch (_err) { /* */ }
83
+ }
84
+ const parent = path.dirname(dir);
85
+ if (parent === dir) break;
86
+ dir = parent;
87
+ }
88
+ return process.cwd();
89
+ }
90
+
91
+ function runnerScriptPath() {
92
+ return path.join(repoRoot(), 'scripts', 'flow-scheduled-runner.js');
93
+ }
94
+
95
+ function nodeBinary() {
96
+ return process.execPath; // canonical, absolute
97
+ }
98
+
99
+ // ============================================================
100
+ // Unit content generators (pure — no I/O)
101
+ // ============================================================
102
+
103
+ function generateCrontabLines(opts = {}) {
104
+ const node = opts.node || nodeBinary();
105
+ const runner = opts.runner || runnerScriptPath();
106
+ const projectRoot = opts.projectRoot || repoRoot();
107
+ const lines = [
108
+ `# === wogi-scheduled (managed by flow schedule) — DO NOT EDIT BELOW ===`,
109
+ ];
110
+ for (const jobName of SCHEDULED_JOB_NAMES) {
111
+ const spec = SCHEDULES[jobName];
112
+ lines.push(
113
+ `${spec.cronExpr} cd ${projectRoot} && ${node} ${runner} ${jobName} ` +
114
+ `>> ${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`)} 2>&1`
115
+ );
116
+ }
117
+ lines.push(`# === wogi-scheduled end ===`);
118
+ return lines;
119
+ }
120
+
121
+ function generateLaunchdPlist(jobName, opts = {}) {
122
+ const node = opts.node || nodeBinary();
123
+ const runner = opts.runner || runnerScriptPath();
124
+ const projectRoot = opts.projectRoot || repoRoot();
125
+ const spec = SCHEDULES[jobName];
126
+ if (!spec) throw new Error(`No schedule defined for "${jobName}"`);
127
+
128
+ const calBlock = [
129
+ ` <key>Hour</key>`,
130
+ ` <integer>${spec.launchd.Hour}</integer>`,
131
+ ` <key>Minute</key>`,
132
+ ` <integer>${spec.launchd.Minute}</integer>`,
133
+ ];
134
+ if (typeof spec.launchd.Weekday === 'number') {
135
+ calBlock.push(` <key>Weekday</key>`);
136
+ calBlock.push(` <integer>${spec.launchd.Weekday}</integer>`);
137
+ }
138
+
139
+ return `<?xml version="1.0" encoding="UTF-8"?>
140
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
141
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
142
+ <plist version="1.0">
143
+ <dict>
144
+ <key>Label</key>
145
+ <string>io.wogi.scheduled.${jobName}</string>
146
+ <key>ProgramArguments</key>
147
+ <array>
148
+ <string>${node}</string>
149
+ <string>${runner}</string>
150
+ <string>${jobName}</string>
151
+ </array>
152
+ <key>WorkingDirectory</key>
153
+ <string>${projectRoot}</string>
154
+ <key>StandardOutPath</key>
155
+ <string>${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`)}</string>
156
+ <key>StandardErrorPath</key>
157
+ <string>${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.err.log`)}</string>
158
+ <key>StartCalendarInterval</key>
159
+ <dict>
160
+ ${calBlock.join('\n')}
161
+ </dict>
162
+ <key>RunAtLoad</key>
163
+ <false/>
164
+ </dict>
165
+ </plist>
166
+ `;
167
+ }
168
+
169
+ function generateSystemdServiceUnit(jobName, opts = {}) {
170
+ const node = opts.node || nodeBinary();
171
+ const runner = opts.runner || runnerScriptPath();
172
+ const projectRoot = opts.projectRoot || repoRoot();
173
+ if (!SCHEDULES[jobName]) throw new Error(`No schedule defined for "${jobName}"`);
174
+
175
+ return `[Unit]
176
+ Description=Wogi Flow scheduled job: ${jobName}
177
+ After=network-online.target
178
+
179
+ [Service]
180
+ Type=oneshot
181
+ WorkingDirectory=${projectRoot}
182
+ ExecStart=${node} ${runner} ${jobName}
183
+ StandardOutput=append:${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.log`)}
184
+ StandardError=append:${path.join(projectRoot, '.workflow', 'scratch', `scheduled-${jobName}.err.log`)}
185
+ `;
186
+ }
187
+
188
+ function generateSystemdTimerUnit(jobName) {
189
+ const spec = SCHEDULES[jobName];
190
+ if (!spec) throw new Error(`No schedule defined for "${jobName}"`);
191
+ return `[Unit]
192
+ Description=Wogi Flow scheduled timer: ${jobName}
193
+
194
+ [Timer]
195
+ OnCalendar=${spec.systemdOnCalendar}
196
+ Persistent=true
197
+ Unit=wogi-scheduled-${jobName}.service
198
+
199
+ [Install]
200
+ WantedBy=timers.target
201
+ `;
202
+ }
203
+
204
+ // ============================================================
205
+ // Install / remove / status (with FS injection for tests)
206
+ // ============================================================
207
+
208
+ function installLaunchd(opts = {}, deps = {}) {
209
+ const fsx = deps.fs || fs;
210
+ const homeDir = opts.homeDir || os.homedir();
211
+ const dir = path.join(homeDir, 'Library', 'LaunchAgents');
212
+ const dryRun = Boolean(opts.dryRun);
213
+ const written = [];
214
+
215
+ for (const jobName of SCHEDULED_JOB_NAMES) {
216
+ const filename = `io.wogi.scheduled.${jobName}.plist`;
217
+ const dest = path.join(dir, filename);
218
+ const content = generateLaunchdPlist(jobName, opts);
219
+ if (!dryRun) {
220
+ fsx.mkdirSync(dir, { recursive: true });
221
+ fsx.writeFileSync(dest, content);
222
+ }
223
+ written.push({ path: dest, jobName, content });
224
+ }
225
+ return { target: 'launchd', dryRun, written };
226
+ }
227
+
228
+ function installCron(opts = {}, deps = {}) {
229
+ const fsx = deps.fs || fs;
230
+ const homeDir = opts.homeDir || os.homedir();
231
+ const fragmentPath = opts.fragmentPath ||
232
+ path.join(homeDir, '.config', 'wogi-flow', 'crontab-fragment');
233
+ const dryRun = Boolean(opts.dryRun);
234
+ const lines = generateCrontabLines(opts);
235
+ const content = lines.join('\n') + '\n';
236
+
237
+ if (!dryRun) {
238
+ fsx.mkdirSync(path.dirname(fragmentPath), { recursive: true });
239
+ fsx.writeFileSync(fragmentPath, content);
240
+ }
241
+
242
+ return {
243
+ target: 'cron',
244
+ dryRun,
245
+ written: [{ path: fragmentPath, content }],
246
+ note:
247
+ `Crontab fragment written. Activate with:\n` +
248
+ ` (crontab -l 2>/dev/null; cat ${fragmentPath}) | crontab -\n` +
249
+ `Idempotency: re-running install OVERWRITES the fragment but does NOT auto-install ` +
250
+ `into crontab — the user runs the command above.`,
251
+ };
252
+ }
253
+
254
+ function installSystemd(opts = {}, deps = {}) {
255
+ const fsx = deps.fs || fs;
256
+ const homeDir = opts.homeDir || os.homedir();
257
+ const dir = path.join(homeDir, '.config', 'systemd', 'user');
258
+ const dryRun = Boolean(opts.dryRun);
259
+ const written = [];
260
+
261
+ for (const jobName of SCHEDULED_JOB_NAMES) {
262
+ const serviceUnit = generateSystemdServiceUnit(jobName, opts);
263
+ const timerUnit = generateSystemdTimerUnit(jobName);
264
+ const servicePath = path.join(dir, `wogi-scheduled-${jobName}.service`);
265
+ const timerPath = path.join(dir, `wogi-scheduled-${jobName}.timer`);
266
+ if (!dryRun) {
267
+ fsx.mkdirSync(dir, { recursive: true });
268
+ fsx.writeFileSync(servicePath, serviceUnit);
269
+ fsx.writeFileSync(timerPath, timerUnit);
270
+ }
271
+ written.push({ path: servicePath, jobName, content: serviceUnit });
272
+ written.push({ path: timerPath, jobName, content: timerUnit });
273
+ }
274
+ return {
275
+ target: 'systemd',
276
+ dryRun,
277
+ written,
278
+ note:
279
+ `Activate with:\n` +
280
+ SCHEDULED_JOB_NAMES.map(
281
+ (j) => ` systemctl --user enable --now wogi-scheduled-${j}.timer`
282
+ ).join('\n'),
283
+ };
284
+ }
285
+
286
+ function removeLaunchd(opts = {}, deps = {}) {
287
+ const fsx = deps.fs || fs;
288
+ const homeDir = opts.homeDir || os.homedir();
289
+ const dir = path.join(homeDir, 'Library', 'LaunchAgents');
290
+ const removed = [];
291
+ for (const jobName of SCHEDULED_JOB_NAMES) {
292
+ const dest = path.join(dir, `io.wogi.scheduled.${jobName}.plist`);
293
+ try {
294
+ if (fsx.existsSync(dest)) {
295
+ fsx.unlinkSync(dest);
296
+ removed.push(dest);
297
+ }
298
+ } catch (_err) { /* fail-open */ }
299
+ }
300
+ return { target: 'launchd', removed };
301
+ }
302
+
303
+ function removeCron(opts = {}, deps = {}) {
304
+ const fsx = deps.fs || fs;
305
+ const homeDir = opts.homeDir || os.homedir();
306
+ const fragmentPath = opts.fragmentPath ||
307
+ path.join(homeDir, '.config', 'wogi-flow', 'crontab-fragment');
308
+ const removed = [];
309
+ try {
310
+ if (fsx.existsSync(fragmentPath)) {
311
+ fsx.unlinkSync(fragmentPath);
312
+ removed.push(fragmentPath);
313
+ }
314
+ } catch (_err) { /* */ }
315
+ return {
316
+ target: 'cron',
317
+ removed,
318
+ note:
319
+ `Fragment file removed. Manually purge the lines between the\n` +
320
+ `'# === wogi-scheduled ...' markers from your active crontab with:\n` +
321
+ ` crontab -e`,
322
+ };
323
+ }
324
+
325
+ function removeSystemd(opts = {}, deps = {}) {
326
+ const fsx = deps.fs || fs;
327
+ const homeDir = opts.homeDir || os.homedir();
328
+ const dir = path.join(homeDir, '.config', 'systemd', 'user');
329
+ const removed = [];
330
+ for (const jobName of SCHEDULED_JOB_NAMES) {
331
+ for (const ext of ['service', 'timer']) {
332
+ const dest = path.join(dir, `wogi-scheduled-${jobName}.${ext}`);
333
+ try {
334
+ if (fsx.existsSync(dest)) {
335
+ fsx.unlinkSync(dest);
336
+ removed.push(dest);
337
+ }
338
+ } catch (_err) { /* */ }
339
+ }
340
+ }
341
+ return { target: 'systemd', removed };
342
+ }
343
+
344
+ // ============================================================
345
+ // Status — list what is currently installed
346
+ // ============================================================
347
+
348
+ function getStatus(deps = {}) {
349
+ const fsx = deps.fs || fs;
350
+ const homeDir = deps.homeDir || os.homedir();
351
+ const status = { launchd: [], cron: [], systemd: [] };
352
+
353
+ // launchd
354
+ const ldir = path.join(homeDir, 'Library', 'LaunchAgents');
355
+ for (const jobName of SCHEDULED_JOB_NAMES) {
356
+ const p = path.join(ldir, `io.wogi.scheduled.${jobName}.plist`);
357
+ if (fsx.existsSync(p)) status.launchd.push(p);
358
+ }
359
+
360
+ // cron
361
+ const cfrag = path.join(homeDir, '.config', 'wogi-flow', 'crontab-fragment');
362
+ if (fsx.existsSync(cfrag)) status.cron.push(cfrag);
363
+
364
+ // systemd
365
+ const sdir = path.join(homeDir, '.config', 'systemd', 'user');
366
+ for (const jobName of SCHEDULED_JOB_NAMES) {
367
+ for (const ext of ['service', 'timer']) {
368
+ const p = path.join(sdir, `wogi-scheduled-${jobName}.${ext}`);
369
+ if (fsx.existsSync(p)) status[ext === 'timer' ? 'systemd' : 'systemd'].push(p);
370
+ }
371
+ }
372
+ return status;
373
+ }
374
+
375
+ // ============================================================
376
+ // CLI dispatch
377
+ // ============================================================
378
+
379
+ function parseCliArgs(argv) {
380
+ const args = { subcommand: argv[0] || null, target: null, dryRun: false };
381
+ for (let i = 1; i < argv.length; i++) {
382
+ const a = argv[i];
383
+ if (a.startsWith('--target=')) args.target = a.slice('--target='.length);
384
+ else if (a === '--dry-run') args.dryRun = true;
385
+ }
386
+ return args;
387
+ }
388
+
389
+ function dispatch(argv, deps = {}) {
390
+ const args = parseCliArgs(argv);
391
+
392
+ if (!args.subcommand || args.subcommand === '--help' || args.subcommand === '-h') {
393
+ return { ok: true, output: helpText() };
394
+ }
395
+
396
+ if (args.subcommand === 'status') {
397
+ const status = getStatus(deps);
398
+ return { ok: true, output: JSON.stringify(status, null, 2), status };
399
+ }
400
+
401
+ if (args.subcommand === 'install' || args.subcommand === 'remove') {
402
+ if (!ALLOWED_TARGETS.has(args.target)) {
403
+ return {
404
+ ok: false,
405
+ output:
406
+ `Error: --target=<launchd|cron|systemd> is required\n\n${helpText()}`,
407
+ };
408
+ }
409
+ const op = args.subcommand === 'install' ? installFor(args.target) : removeFor(args.target);
410
+ const opOpts = { dryRun: args.dryRun };
411
+ if (deps.homeDir) opOpts.homeDir = deps.homeDir;
412
+ const result = op(opOpts, deps);
413
+ return { ok: true, output: JSON.stringify(result, null, 2), result };
414
+ }
415
+
416
+ return { ok: false, output: `Unknown subcommand: ${args.subcommand}\n\n${helpText()}` };
417
+ }
418
+
419
+ function installFor(target) {
420
+ return ({ launchd: installLaunchd, cron: installCron, systemd: installSystemd })[target];
421
+ }
422
+
423
+ function removeFor(target) {
424
+ return ({ launchd: removeLaunchd, cron: removeCron, systemd: removeSystemd })[target];
425
+ }
426
+
427
+ function helpText() {
428
+ return `flow schedule — install platform-native scheduled-mode units
429
+
430
+ Subcommands:
431
+ install --target=<launchd|cron|systemd> [--dry-run]
432
+ remove --target=<launchd|cron|systemd>
433
+ status
434
+
435
+ Jobs installed: ${SCHEDULED_JOB_NAMES.join(', ')}
436
+ (per-pr-review runs via GH Actions / on-demand only, no schedule entry.)
437
+ `;
438
+ }
439
+
440
+ // ============================================================
441
+ // Exports + CLI
442
+ // ============================================================
443
+
444
+ module.exports = {
445
+ SCHEDULES,
446
+ SCHEDULED_JOB_NAMES,
447
+ ALLOWED_TARGETS,
448
+ generateCrontabLines,
449
+ generateLaunchdPlist,
450
+ generateSystemdServiceUnit,
451
+ generateSystemdTimerUnit,
452
+ installLaunchd,
453
+ installCron,
454
+ installSystemd,
455
+ removeLaunchd,
456
+ removeCron,
457
+ removeSystemd,
458
+ getStatus,
459
+ dispatch,
460
+ parseCliArgs,
461
+ // Re-export for convenience to consumers
462
+ JOB_NAMES,
463
+ };
464
+
465
+ if (require.main === module) {
466
+ const result = dispatch(process.argv.slice(2));
467
+ if (result.output) console.log(result.output);
468
+ process.exit(result.ok ? 0 : 1);
469
+ }