@arkstack/scheduler 0.17.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,666 @@
1
+ import { Cron } from "croner";
2
+ import { Arkstack } from "@arkstack/contract";
3
+ import { createHash } from "node:crypto";
4
+ import { env, importFile, outputDir } from "@arkstack/common";
5
+ import path, { join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ //#region src/locks.ts
9
+ /**
10
+ * Mutual-exclusion helpers backing `withoutOverlapping()` and `onOneServer()`.
11
+ *
12
+ * Locks are stored in `@arkstack/cache` (an optional peer) so they coordinate
13
+ * across processes and servers. When cache is unavailable the scheduler degrades
14
+ * to a process-local lock — enough to prevent overlap within one process, but it
15
+ * cannot coordinate across servers.
16
+ */
17
+ const memoryLocks = /* @__PURE__ */ new Map();
18
+ /** Resolve the cache manager, or `null` when `@arkstack/cache` isn't installed. */
19
+ const cache = async () => {
20
+ try {
21
+ return (await import("@arkstack/cache")).Cache ?? null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+ /**
27
+ * Try to acquire a lock. Returns `true` when acquired, `false` when already held.
28
+ *
29
+ * @param key The lock key.
30
+ * @param ttlSeconds How long the lock is held before it auto-expires.
31
+ */
32
+ const acquireLock = async (key, ttlSeconds) => {
33
+ const store = await cache();
34
+ if (store) try {
35
+ return await store.add(key, (/* @__PURE__ */ new Date()).toISOString(), ttlSeconds);
36
+ } catch {}
37
+ const now = Date.now();
38
+ const expiry = memoryLocks.get(key);
39
+ if (expiry && expiry > now) return false;
40
+ memoryLocks.set(key, now + ttlSeconds * 1e3);
41
+ return true;
42
+ };
43
+ /**
44
+ * Release a previously acquired lock.
45
+ *
46
+ * @param key The lock key.
47
+ */
48
+ const releaseLock = async (key) => {
49
+ const store = await cache();
50
+ if (store) try {
51
+ await store.forget(key);
52
+ return;
53
+ } catch {}
54
+ memoryLocks.delete(key);
55
+ };
56
+ //#endregion
57
+ //#region src/cron.ts
58
+ /** Truncate a date to its minute (scheduler resolution is one minute). */
59
+ const toMinute = (date) => Math.floor(date.getTime() / 6e4);
60
+ /**
61
+ * Whether a cron expression is due at the given moment (minute resolution).
62
+ *
63
+ * Uses croner to find the next run at/after one second before `date`; the
64
+ * expression is due when that run falls in the same minute as `date`.
65
+ *
66
+ * @param expression A 5-field cron expression.
67
+ * @param date The moment to test (defaults to now).
68
+ * @param timezone IANA timezone the expression is evaluated in.
69
+ */
70
+ const isDue = (expression, date = /* @__PURE__ */ new Date(), timezone) => {
71
+ try {
72
+ const next = new Cron(expression, timezone ? { timezone } : {}).nextRun(/* @__PURE__ */ new Date(date.getTime() - 1e3));
73
+ return !!next && toMinute(next) === toMinute(date);
74
+ } catch {
75
+ return false;
76
+ }
77
+ };
78
+ /**
79
+ * The next time a cron expression will run after `from`.
80
+ *
81
+ * @param expression A 5-field cron expression.
82
+ * @param from The reference moment (defaults to now).
83
+ * @param timezone IANA timezone the expression is evaluated in.
84
+ */
85
+ const nextRun = (expression, from = /* @__PURE__ */ new Date(), timezone) => {
86
+ try {
87
+ return new Cron(expression, timezone ? { timezone } : {}).nextRun(from);
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+ //#endregion
93
+ //#region src/ScheduledEvent.ts
94
+ const DAYS = {
95
+ sunday: 0,
96
+ monday: 1,
97
+ tuesday: 2,
98
+ wednesday: 3,
99
+ thursday: 4,
100
+ friday: 5,
101
+ saturday: 6
102
+ };
103
+ /** Parse `HH:MM` into `[hour, minute]`. */
104
+ const parseTime = (time) => {
105
+ const [h, m] = time.split(":");
106
+ return [Number(h ?? 0), Number(m ?? 0)];
107
+ };
108
+ /** Minutes-from-midnight for `date`, in the given timezone. */
109
+ const minutesOfDay = (date, timezone) => {
110
+ const parts = new Intl.DateTimeFormat("en-US", {
111
+ hour: "2-digit",
112
+ minute: "2-digit",
113
+ hour12: false,
114
+ timeZone: timezone
115
+ }).formatToParts(date);
116
+ const hour = Number(parts.find((p) => p.type === "hour")?.value ?? 0) % 24;
117
+ const minute = Number(parts.find((p) => p.type === "minute")?.value ?? 0);
118
+ return hour * 60 + minute;
119
+ };
120
+ /**
121
+ * A single scheduled task with a fluent builder for its frequency, constraints,
122
+ * overlap behaviour and lifecycle hooks. Created via the {@link Schedule} facade
123
+ * (`command`/`call`/`job`/`exec`) and evaluated once a minute by `schedule:run`.
124
+ */
125
+ var ScheduledEvent = class {
126
+ type;
127
+ segments = [
128
+ "*",
129
+ "*",
130
+ "*",
131
+ "*",
132
+ "*"
133
+ ];
134
+ timezoneValue;
135
+ descriptionValue;
136
+ filters = [];
137
+ rejects = [];
138
+ environmentsValue;
139
+ windows = [];
140
+ beforeHooks = [];
141
+ afterHooks = [];
142
+ successHooks = [];
143
+ failureHooks = [];
144
+ withoutOverlappingValue = false;
145
+ overlapExpiresMinutes = 1440;
146
+ onOneServerValue = false;
147
+ runInBackgroundValue = false;
148
+ mutexNameValue;
149
+ callTask;
150
+ jobPayload;
151
+ processCommand;
152
+ processArgs = [];
153
+ constructor(type, target) {
154
+ this.type = type;
155
+ this.descriptionValue = target;
156
+ }
157
+ setCall(task) {
158
+ this.callTask = task;
159
+ return this;
160
+ }
161
+ setJob(job) {
162
+ this.jobPayload = job;
163
+ return this;
164
+ }
165
+ setProcess(command, args = []) {
166
+ this.processCommand = command;
167
+ this.processArgs = args;
168
+ return this;
169
+ }
170
+ /** The 5-field cron expression this event resolves to. */
171
+ get expression() {
172
+ return this.segments.join(" ");
173
+ }
174
+ splice(position, value) {
175
+ this.segments[position - 1] = String(value);
176
+ return this;
177
+ }
178
+ cron(expression) {
179
+ this.segments = expression.trim().split(/\s+/).slice(0, 5);
180
+ while (this.segments.length < 5) this.segments.push("*");
181
+ return this;
182
+ }
183
+ everyMinute() {
184
+ return this.splice(1, "*");
185
+ }
186
+ everyTwoMinutes() {
187
+ return this.splice(1, "*/2");
188
+ }
189
+ everyThreeMinutes() {
190
+ return this.splice(1, "*/3");
191
+ }
192
+ everyFourMinutes() {
193
+ return this.splice(1, "*/4");
194
+ }
195
+ everyFiveMinutes() {
196
+ return this.splice(1, "*/5");
197
+ }
198
+ everyTenMinutes() {
199
+ return this.splice(1, "*/10");
200
+ }
201
+ everyFifteenMinutes() {
202
+ return this.splice(1, "*/15");
203
+ }
204
+ everyThirtyMinutes() {
205
+ return this.splice(1, "0,30");
206
+ }
207
+ hourly() {
208
+ return this.splice(1, 0);
209
+ }
210
+ hourlyAt(minute) {
211
+ return this.splice(1, Array.isArray(minute) ? minute.join(",") : minute);
212
+ }
213
+ everyTwoHours(minute = 0) {
214
+ return this.splice(1, minute).splice(2, "*/2");
215
+ }
216
+ everyOddHour(minute = 0) {
217
+ return this.splice(1, minute).splice(2, "1-23/2");
218
+ }
219
+ daily() {
220
+ return this.splice(1, 0).splice(2, 0);
221
+ }
222
+ at(time) {
223
+ return this.dailyAt(time);
224
+ }
225
+ dailyAt(time) {
226
+ const [hour, minute] = parseTime(time);
227
+ return this.splice(1, minute).splice(2, hour);
228
+ }
229
+ twiceDaily(first = 1, second = 13, minute = 0) {
230
+ return this.splice(1, minute).splice(2, `${first},${second}`);
231
+ }
232
+ weekly() {
233
+ return this.splice(1, 0).splice(2, 0).splice(5, 0);
234
+ }
235
+ weeklyOn(day, time = "0:0") {
236
+ this.dailyAt(time);
237
+ return this.splice(5, Array.isArray(day) ? day.join(",") : day);
238
+ }
239
+ monthly() {
240
+ return this.splice(1, 0).splice(2, 0).splice(3, 1);
241
+ }
242
+ monthlyOn(day = 1, time = "0:0") {
243
+ this.dailyAt(time);
244
+ return this.splice(3, day);
245
+ }
246
+ quarterly() {
247
+ return this.splice(1, 0).splice(2, 0).splice(3, 1).splice(4, "1-12/3");
248
+ }
249
+ yearly() {
250
+ return this.splice(1, 0).splice(2, 0).splice(3, 1).splice(4, 1);
251
+ }
252
+ days(day) {
253
+ return this.splice(5, Array.isArray(day) ? day.join(",") : day);
254
+ }
255
+ weekdays() {
256
+ return this.splice(5, "1-5");
257
+ }
258
+ weekends() {
259
+ return this.splice(5, "0,6");
260
+ }
261
+ sundays() {
262
+ return this.splice(5, DAYS.sunday);
263
+ }
264
+ mondays() {
265
+ return this.splice(5, DAYS.monday);
266
+ }
267
+ tuesdays() {
268
+ return this.splice(5, DAYS.tuesday);
269
+ }
270
+ wednesdays() {
271
+ return this.splice(5, DAYS.wednesday);
272
+ }
273
+ thursdays() {
274
+ return this.splice(5, DAYS.thursday);
275
+ }
276
+ fridays() {
277
+ return this.splice(5, DAYS.friday);
278
+ }
279
+ saturdays() {
280
+ return this.splice(5, DAYS.saturday);
281
+ }
282
+ timezone(timezone) {
283
+ this.timezoneValue = timezone;
284
+ return this;
285
+ }
286
+ /**
287
+ * Only run when every registered `when` predicate is truthy.
288
+ *
289
+ * @param callback
290
+ * @returns
291
+ */
292
+ when(callback) {
293
+ this.filters.push(callback);
294
+ return this;
295
+ }
296
+ /**
297
+ * Skip when any registered `skip` predicate is truthy.
298
+ *
299
+ * @param callback
300
+ * @returns
301
+ */
302
+ skip(callback) {
303
+ this.rejects.push(callback);
304
+ return this;
305
+ }
306
+ /**
307
+ * Only run in these `APP_ENV` environments.
308
+ *
309
+ * @param callback
310
+ * @returns
311
+ */
312
+ environments(...environments) {
313
+ this.environmentsValue = environments.flat();
314
+ return this;
315
+ }
316
+ /**
317
+ * Only run when the current time is within `[start, end]` (HH:MM, event tz).
318
+ *
319
+ * @param start
320
+ * @param end
321
+ * @returns
322
+ */
323
+ between(start, end) {
324
+ this.windows.push({
325
+ start: this.toMinutes(start),
326
+ end: this.toMinutes(end),
327
+ negate: false
328
+ });
329
+ return this;
330
+ }
331
+ /**
332
+ * Only run when the current time is outside `[start, end]`.
333
+ *
334
+ * @param callback
335
+ * @returns
336
+ */
337
+ unlessBetween(start, end) {
338
+ this.windows.push({
339
+ start: this.toMinutes(start),
340
+ end: this.toMinutes(end),
341
+ negate: true
342
+ });
343
+ return this;
344
+ }
345
+ toMinutes(time) {
346
+ const [h, m] = parseTime(time);
347
+ return h * 60 + m;
348
+ }
349
+ /**
350
+ * Prevent the task from overlapping itself; the lock expires after `expiresMinutes`.
351
+ *
352
+ * @param expiresMinutes
353
+ * @returns
354
+ */
355
+ withoutOverlapping(expiresMinutes = 1440) {
356
+ this.withoutOverlappingValue = true;
357
+ this.overlapExpiresMinutes = expiresMinutes;
358
+ return this;
359
+ }
360
+ /**
361
+ * Run on only one server per due minute (requires a shared cache store).
362
+ *
363
+ * @param callback
364
+ * @returns
365
+ */
366
+ onOneServer() {
367
+ this.onOneServerValue = true;
368
+ return this;
369
+ }
370
+ /**
371
+ * Run the task in a detached background process (command/exec only).
372
+ *
373
+ * @param callback
374
+ * @returns
375
+ */
376
+ runInBackground() {
377
+ this.runInBackgroundValue = true;
378
+ return this;
379
+ }
380
+ /**
381
+ * A human description shown by `schedule:list`.
382
+ *
383
+ * @param callback
384
+ * @returns
385
+ */
386
+ description(description) {
387
+ this.descriptionValue = description;
388
+ return this;
389
+ }
390
+ /**
391
+ * An explicit mutex name (otherwise derived from the expression + description).
392
+ *
393
+ * @param name
394
+ * @returns
395
+ */
396
+ name(name) {
397
+ this.mutexNameValue = name;
398
+ return this;
399
+ }
400
+ before(hook) {
401
+ this.beforeHooks.push(hook);
402
+ return this;
403
+ }
404
+ after(hook) {
405
+ this.afterHooks.push(hook);
406
+ return this;
407
+ }
408
+ onSuccess(hook) {
409
+ this.successHooks.push(hook);
410
+ return this;
411
+ }
412
+ onFailure(hook) {
413
+ this.failureHooks.push(hook);
414
+ return this;
415
+ }
416
+ /**
417
+ * Whether the cron expression is due at `date`.
418
+ *
419
+ * @param date
420
+ * @returns
421
+ */
422
+ isDue(date = /* @__PURE__ */ new Date()) {
423
+ return isDue(this.expression, date, this.timezoneValue);
424
+ }
425
+ /**
426
+ * The next time this event will run after `from`.
427
+ *
428
+ * @param date
429
+ * @returns
430
+ */
431
+ nextRunAt(from = /* @__PURE__ */ new Date()) {
432
+ return nextRun(this.expression, from, this.timezoneValue);
433
+ }
434
+ /**
435
+ * Whether environment, time-window, `when` and `skip` constraints all pass.
436
+ *
437
+ * @param date
438
+ * @returns
439
+ */
440
+ async filtersPass(date = /* @__PURE__ */ new Date()) {
441
+ if (this.environmentsValue) {
442
+ const current = String(env("APP_ENV", "") || "");
443
+ if (!this.environmentsValue.includes(current)) return false;
444
+ }
445
+ for (const window of this.windows) {
446
+ const now = minutesOfDay(date, this.timezoneValue);
447
+ if ((window.start <= window.end ? now >= window.start && now <= window.end : now >= window.start || now <= window.end) === window.negate) return false;
448
+ }
449
+ for (const filter of this.filters) if (!await filter()) return false;
450
+ for (const reject of this.rejects) if (await reject()) return false;
451
+ return true;
452
+ }
453
+ /**
454
+ * A stable mutex key derived from the expression + description (or an explicit name).
455
+ *
456
+ * @returns
457
+ */
458
+ mutexName() {
459
+ if (this.mutexNameValue) return `arkstack-schedule-${this.mutexNameValue}`;
460
+ return `arkstack-schedule-${createHash("sha1").update(`${this.type}:${this.expression}:${this.descriptionValue}`).digest("hex")}`;
461
+ }
462
+ /**
463
+ * Run the task now, honouring overlap/one-server locks and lifecycle hooks.
464
+ * Assumes the event is already due and its filters pass.
465
+ *
466
+ * @param date The reference moment (used for the one-server per-minute key).
467
+ */
468
+ async run(date = /* @__PURE__ */ new Date()) {
469
+ const base = {
470
+ description: this.descriptionValue,
471
+ expression: this.expression
472
+ };
473
+ if (this.onOneServerValue) {
474
+ const minute = Math.floor(date.getTime() / 6e4);
475
+ if (!await acquireLock(`${this.mutexName()}-server-${minute}`, 60)) return {
476
+ ...base,
477
+ ran: false,
478
+ skipped: "one-server"
479
+ };
480
+ }
481
+ let overlapKey;
482
+ if (this.withoutOverlappingValue) {
483
+ overlapKey = this.mutexName();
484
+ if (!await acquireLock(overlapKey, this.overlapExpiresMinutes * 60)) return {
485
+ ...base,
486
+ ran: false,
487
+ skipped: "overlapping"
488
+ };
489
+ }
490
+ let error;
491
+ try {
492
+ await this.callHooks(this.beforeHooks);
493
+ await this.executeTask();
494
+ await this.callHooks(this.successHooks);
495
+ } catch (caught) {
496
+ error = caught;
497
+ await this.callHooks(this.failureHooks, caught);
498
+ } finally {
499
+ await this.callHooks(this.afterHooks, error);
500
+ if (overlapKey && !this.runInBackgroundValue) await releaseLock(overlapKey);
501
+ }
502
+ return {
503
+ ...base,
504
+ ran: !error,
505
+ error
506
+ };
507
+ }
508
+ async callHooks(hooks, error) {
509
+ for (const hook of hooks) await hook(error);
510
+ }
511
+ async executeTask() {
512
+ switch (this.type) {
513
+ case "call":
514
+ await this.callTask?.();
515
+ return;
516
+ case "job": {
517
+ const { dispatch } = await import("@arkstack/jobs");
518
+ await dispatch(this.jobPayload);
519
+ return;
520
+ }
521
+ case "command": {
522
+ const consoleEntry = join(Arkstack.rootDir(), "node_modules", "@arkstack", "console", "dist", "index.js");
523
+ await this.spawnProcess(process.execPath, [
524
+ consoleEntry,
525
+ ...this.processCommand ? [this.processCommand] : [],
526
+ ...this.processArgs
527
+ ]);
528
+ return;
529
+ }
530
+ case "exec":
531
+ await this.spawnProcess(this.processCommand ?? "", this.processArgs, true);
532
+ return;
533
+ }
534
+ }
535
+ spawnProcess(command, args, shell = false) {
536
+ return new Promise((resolve, reject) => {
537
+ const child = spawn(command, args, {
538
+ cwd: Arkstack.rootDir(),
539
+ shell,
540
+ detached: this.runInBackgroundValue,
541
+ stdio: this.runInBackgroundValue ? "ignore" : "inherit"
542
+ });
543
+ if (this.runInBackgroundValue) {
544
+ child.unref();
545
+ resolve();
546
+ return;
547
+ }
548
+ child.on("error", reject);
549
+ child.on("exit", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`Scheduled process exited with code ${code}`)));
550
+ });
551
+ }
552
+ };
553
+ //#endregion
554
+ //#region src/Schedule.ts
555
+ const REGISTRY = Symbol.for("arkstack.scheduler.events");
556
+ const registry = () => {
557
+ const store = globalThis;
558
+ return store[REGISTRY] ??= [];
559
+ };
560
+ /**
561
+ * The scheduling facade. Define tasks in `src/routes/console.ts`:
562
+ *
563
+ * ```ts
564
+ * import { Schedule } from '@arkstack/scheduler'
565
+ *
566
+ * Schedule.command('report:send').dailyAt('13:00')
567
+ * Schedule.call(() => prune()).hourly().withoutOverlapping()
568
+ * Schedule.job(new Heartbeat()).everyFiveMinutes()
569
+ * Schedule.exec('backup.sh').daily().onOneServer()
570
+ * ```
571
+ */
572
+ var Schedule = class {
573
+ /**
574
+ * Run an Arkstack CLI command (`ark <name>`) on the schedule.
575
+ *
576
+ * @param name
577
+ * @param args
578
+ * @returns
579
+ */
580
+ static command(name, args = []) {
581
+ const event = new ScheduledEvent("command", `ark ${name}`).setProcess(name, args);
582
+ return this.add(event);
583
+ }
584
+ /**
585
+ * Run a callback on the schedule.
586
+ *
587
+ * @param callback
588
+ * @returns
589
+ */
590
+ static call(callback) {
591
+ const event = new ScheduledEvent("call", "Closure").setCall(callback);
592
+ return this.add(event);
593
+ }
594
+ /**
595
+ * Dispatch a queued job on the schedule (requires `@arkstack/jobs`).
596
+ *
597
+ * @param callback
598
+ * @returns
599
+ */
600
+ static job(job) {
601
+ const event = new ScheduledEvent("job", job?.constructor?.name ?? "Job").setJob(job);
602
+ return this.add(event);
603
+ }
604
+ /**
605
+ * Run a shell command on the schedule.
606
+ *
607
+ * @param callback
608
+ * @returns
609
+ */
610
+ static exec(command, args = []) {
611
+ const event = new ScheduledEvent("exec", [command, ...args].join(" ")).setProcess(command, args);
612
+ return this.add(event);
613
+ }
614
+ /**
615
+ * All registered events.
616
+ *
617
+ * @param callback
618
+ * @returns
619
+ */
620
+ static events() {
621
+ return registry();
622
+ }
623
+ /**
624
+ * Events whose cron expression is due at `date`.
625
+ *
626
+ * @param callback
627
+ * @returns
628
+ */
629
+ static dueEvents(date = /* @__PURE__ */ new Date()) {
630
+ return registry().filter((event) => event.isDue(date));
631
+ }
632
+ /**
633
+ * Remove all registered events (used in tests).
634
+ *
635
+ * @param callback
636
+ * @returns
637
+ */
638
+ static clear() {
639
+ registry().length = 0;
640
+ }
641
+ static add(event) {
642
+ registry().push(event);
643
+ return event;
644
+ }
645
+ };
646
+ //#endregion
647
+ //#region src/loader.ts
648
+ /**
649
+ * Load the application's schedule definitions from `src/routes/console.ts`.
650
+ * The TypeScript source is preferred (loaded via jiti, so no build is needed),
651
+ * falling back to the built `<outDir>/routes/console.js`.
652
+ *
653
+ * @returns `true` when a console route file was found and loaded.
654
+ */
655
+ const loadSchedule = async () => {
656
+ const root = Arkstack.rootDir();
657
+ const dist = path.relative(root, outputDir());
658
+ const candidates = [join(root, "src", "routes", "console.ts"), join(root, dist, "routes", "console.js")];
659
+ for (const file of candidates) if (existsSync(file)) {
660
+ await importFile(file);
661
+ return true;
662
+ }
663
+ return false;
664
+ };
665
+ //#endregion
666
+ export { nextRun as a, isDue as i, Schedule as n, acquireLock as o, ScheduledEvent as r, releaseLock as s, loadSchedule as t };
@@ -0,0 +1,26 @@
1
+ import { n as Schedule } from "./loader-zz_XJXwZ.js";
2
+ //#region src/runner.ts
3
+ /**
4
+ * Run every event that is due at `now` and whose filters pass, collecting a
5
+ * result per event (including the ones skipped by filters or locks).
6
+ *
7
+ * @param now The reference moment (defaults to now).
8
+ */
9
+ const runDueEvents = async (now = /* @__PURE__ */ new Date()) => {
10
+ const results = [];
11
+ for (const event of Schedule.dueEvents(now)) {
12
+ if (!await event.filtersPass(now)) {
13
+ results.push({
14
+ description: event.descriptionValue,
15
+ expression: event.expression,
16
+ ran: false,
17
+ skipped: "filtered"
18
+ });
19
+ continue;
20
+ }
21
+ results.push(await event.run(now));
22
+ }
23
+ return results;
24
+ };
25
+ //#endregion
26
+ export { runDueEvents as t };
@@ -0,0 +1 @@
1
+ export { };
package/dist/setup.js ADDED
@@ -0,0 +1,21 @@
1
+ import { Publisher } from "@arkstack/common";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ //#region src/setup.ts
5
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
6
+ /**
7
+ * Register the artifacts `@arkstack/scheduler` publishes into the application.
8
+ *
9
+ * Run `ark publish --package @arkstack/scheduler` to copy the starter
10
+ * `src/routes/console.ts` schedule file into the app.
11
+ */
12
+ Publisher.publishes({
13
+ package: "@arkstack/scheduler",
14
+ tag: "scheduler-routes",
15
+ entries: [{
16
+ from: join(root, "stubs/routes/console.ts.stub"),
17
+ to: "src/routes/console.ts"
18
+ }]
19
+ });
20
+ //#endregion
21
+ export {};