fullstackgtm 0.23.2 → 0.25.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.
- package/CHANGELOG.md +57 -0
- package/README.md +25 -1
- package/dist/cli.js +399 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schedule.d.ts +143 -0
- package/dist/schedule.js +485 -0
- package/llms.txt +20 -0
- package/package.json +2 -1
- package/skills/fullstackgtm/SKILL.md +87 -0
- package/src/cli.ts +447 -2
- package/src/index.ts +26 -0
- package/src/schedule.ts +609 -0
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The schedule layer (spec: docs/schedule.md in the monorepo): declare once
|
|
8
|
+
* that a fullstackgtm command should run on a cadence, materialize the timers
|
|
9
|
+
* through a provider (MVP: the user crontab), and keep an append-only run
|
|
10
|
+
* history. Horizontal on purpose — no feature namespace owns cron logic.
|
|
11
|
+
*
|
|
12
|
+
* The governance invariant: **scheduling never auto-approves.** Schedulable
|
|
13
|
+
* commands are read/plan-side; `apply` is schedulable only as
|
|
14
|
+
* `apply --plan-id <id>` and the plan's approved status is re-checked at
|
|
15
|
+
* every firing. Unattended runs accumulate proposals, never surprise writes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Entry + run record shapes
|
|
20
|
+
|
|
21
|
+
export type ScheduleProvider = "local";
|
|
22
|
+
|
|
23
|
+
export type ScheduleEntry = {
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
/** Fullstackgtm command argv (no binary name, no --profile) — never shell. */
|
|
27
|
+
argv: string[];
|
|
28
|
+
/** 5-field cron expression (minute hour day-of-month month day-of-week). */
|
|
29
|
+
cron: string;
|
|
30
|
+
provider: ScheduleProvider;
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ScheduleRunTrigger = "cron" | "manual";
|
|
36
|
+
|
|
37
|
+
export type ScheduleRunRecord = {
|
|
38
|
+
scheduleId: string;
|
|
39
|
+
firedAt: string;
|
|
40
|
+
completedAt: string;
|
|
41
|
+
trigger: ScheduleRunTrigger;
|
|
42
|
+
exitCode: number;
|
|
43
|
+
/** Last ~50 lines of the command's stdout+stderr. */
|
|
44
|
+
outputTail: string;
|
|
45
|
+
/** Plan ids / enrich run labels the firing produced (store-diffed). */
|
|
46
|
+
artifacts: { planIds: string[]; runLabels: string[] };
|
|
47
|
+
/**
|
|
48
|
+
* Set when a governance gate refused to execute the command: the firing is
|
|
49
|
+
* recorded for visibility but nothing ran. Reasons: "plan_not_approved"
|
|
50
|
+
* (scheduled apply against a plan that is not approved — there is no flag
|
|
51
|
+
* that relaxes this), "not_schedulable" (the stored argv no longer passes
|
|
52
|
+
* the allowlist, e.g. after a hand edit of schedules.json).
|
|
53
|
+
*/
|
|
54
|
+
noopReason?: "plan_not_approved" | "not_schedulable";
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep schedule.ts
|
|
58
|
+
// importable without pulling the audit engine (the market/enrich precedent).
|
|
59
|
+
function fnv1a(value: string): string {
|
|
60
|
+
let hash = 0x811c9dc5;
|
|
61
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
62
|
+
hash ^= value.charCodeAt(i);
|
|
63
|
+
hash = Math.imul(hash, 0x01000193);
|
|
64
|
+
}
|
|
65
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function scheduleId(label: string, cron: string, argv: string[], createdAt: string): string {
|
|
69
|
+
return `sch_${fnv1a(`${label}|${cron}|${argv.join(" ")}|${createdAt}`)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Governance: the schedulable allowlist
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read/plan-side commands only. Their unattended output is plans in the
|
|
77
|
+
* queue, run records, and reports — never CRM writes. `apply` is the single
|
|
78
|
+
* exception, allowed only as `apply --plan-id <id>` (the plan store's
|
|
79
|
+
* approval status is re-checked at run time; see docs/schedule.md).
|
|
80
|
+
*/
|
|
81
|
+
const SCHEDULABLE: Record<string, string[] | null> = {
|
|
82
|
+
audit: null,
|
|
83
|
+
snapshot: null,
|
|
84
|
+
suggest: null,
|
|
85
|
+
report: null,
|
|
86
|
+
doctor: null,
|
|
87
|
+
enrich: ["append", "refresh"],
|
|
88
|
+
market: ["capture", "refresh"],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const ALLOWLIST_SUMMARY =
|
|
92
|
+
"audit, snapshot, enrich append|refresh, market capture|refresh, suggest, report, doctor — " +
|
|
93
|
+
"plus apply --plan-id <id> (re-checked approved at every firing)";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate that an argv resolves to a schedulable fullstackgtm command.
|
|
97
|
+
* Enforced at `schedule add` time AND re-checked at `schedule run` time (the
|
|
98
|
+
* store is a user-editable JSON file). Throws with the full allowlist on
|
|
99
|
+
* rejection; arbitrary shell is structurally impossible anyway (runs dispatch
|
|
100
|
+
* in-process into the CLI router, never through a shell).
|
|
101
|
+
*/
|
|
102
|
+
export function validateSchedulableArgv(argv: string[]): void {
|
|
103
|
+
const head = argv[0];
|
|
104
|
+
if (!head) throw new Error("Nothing to schedule: the command is empty.");
|
|
105
|
+
if (argv.includes("--profile")) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"Do not embed --profile in the scheduled command — schedules are already profile-scoped. " +
|
|
108
|
+
"Pass --profile <name> to `schedule add` itself instead.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (head === "apply") {
|
|
112
|
+
const planIdIndex = argv.indexOf("--plan-id");
|
|
113
|
+
const planId = planIdIndex === -1 ? undefined : argv[planIdIndex + 1];
|
|
114
|
+
if (!planId || planId.startsWith("--")) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"apply is schedulable ONLY as `apply --plan-id <id> ...`: the plan must already exist in " +
|
|
117
|
+
"the plan store, and its status is re-checked as approved at every firing. " +
|
|
118
|
+
"Scheduling never auto-approves.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (argv.includes("--plan") || argv.includes("--approve")) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
"A scheduled apply cannot take --plan/--approve — file-based approval would bypass the " +
|
|
124
|
+
"plan store's approval state. Use `apply --plan-id <id>` and approve via `plans approve`.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!Object.hasOwn(SCHEDULABLE, head)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`"${head}" is not schedulable. Schedulable commands are read/plan-side only: ${ALLOWLIST_SUMMARY}. ` +
|
|
132
|
+
"Unattended runs accumulate proposals, never writes.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const subcommands = SCHEDULABLE[head];
|
|
136
|
+
if (subcommands) {
|
|
137
|
+
const sub = argv[1];
|
|
138
|
+
if (!sub || !subcommands.includes(sub)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`"${head}${sub ? ` ${sub}` : ""}" is not schedulable — only: ${subcommands
|
|
141
|
+
.map((name) => `${head} ${name}`)
|
|
142
|
+
.join(", ")}.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
150
|
+
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
|
151
|
+
*/
|
|
152
|
+
export function tokenizeCommand(text: string): string[] {
|
|
153
|
+
const tokens: string[] = [];
|
|
154
|
+
let current = "";
|
|
155
|
+
let quote: '"' | "'" | null = null;
|
|
156
|
+
let inToken = false;
|
|
157
|
+
for (const char of text) {
|
|
158
|
+
if (quote) {
|
|
159
|
+
if (char === quote) quote = null;
|
|
160
|
+
else current += char;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (char === '"' || char === "'") {
|
|
164
|
+
quote = char;
|
|
165
|
+
inToken = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/\s/.test(char)) {
|
|
169
|
+
if (inToken) {
|
|
170
|
+
tokens.push(current);
|
|
171
|
+
current = "";
|
|
172
|
+
inToken = false;
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
current += char;
|
|
177
|
+
inToken = true;
|
|
178
|
+
}
|
|
179
|
+
if (quote) throw new Error(`Unclosed ${quote} quote in the scheduled command.`);
|
|
180
|
+
if (inToken) tokens.push(current);
|
|
181
|
+
return tokens;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Cron: minimal dependency-free 5-field parser
|
|
186
|
+
|
|
187
|
+
export type CronExpression = {
|
|
188
|
+
source: string;
|
|
189
|
+
minute: number[];
|
|
190
|
+
hour: number[];
|
|
191
|
+
dayOfMonth: number[];
|
|
192
|
+
month: number[];
|
|
193
|
+
/** 0–6, Sunday = 0 (an input 7 normalizes to 0). */
|
|
194
|
+
dayOfWeek: number[];
|
|
195
|
+
/** Vixie-cron day semantics: when BOTH are restricted, either may match. */
|
|
196
|
+
dayOfMonthRestricted: boolean;
|
|
197
|
+
dayOfWeekRestricted: boolean;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const CRON_FIELD_SPECS = [
|
|
201
|
+
{ name: "minute", min: 0, max: 59 },
|
|
202
|
+
{ name: "hour", min: 0, max: 23 },
|
|
203
|
+
{ name: "day-of-month", min: 1, max: 31 },
|
|
204
|
+
{ name: "month", min: 1, max: 12 },
|
|
205
|
+
{ name: "day-of-week", min: 0, max: 7 },
|
|
206
|
+
] as const;
|
|
207
|
+
|
|
208
|
+
export function parseCron(expression: string): CronExpression {
|
|
209
|
+
const fields = expression.trim().split(/\s+/);
|
|
210
|
+
if (fields.length !== 5) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Invalid cron expression "${expression}": expected 5 fields ` +
|
|
213
|
+
`(minute hour day-of-month month day-of-week), got ${fields.length}.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const [minute, hour, dayOfMonth, month, dayOfWeekRaw] = fields.map((field, index) =>
|
|
217
|
+
parseCronField(field, CRON_FIELD_SPECS[index], expression),
|
|
218
|
+
);
|
|
219
|
+
// 7 is an accepted alias for Sunday; normalize so matching uses 0–6.
|
|
220
|
+
const dayOfWeek = Array.from(new Set(dayOfWeekRaw.map((value) => (value === 7 ? 0 : value)))).sort(
|
|
221
|
+
(a, b) => a - b,
|
|
222
|
+
);
|
|
223
|
+
return {
|
|
224
|
+
source: expression.trim(),
|
|
225
|
+
minute,
|
|
226
|
+
hour,
|
|
227
|
+
dayOfMonth,
|
|
228
|
+
month,
|
|
229
|
+
dayOfWeek,
|
|
230
|
+
dayOfMonthRestricted: fields[2] !== "*",
|
|
231
|
+
dayOfWeekRestricted: fields[4] !== "*",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseCronField(
|
|
236
|
+
field: string,
|
|
237
|
+
spec: (typeof CRON_FIELD_SPECS)[number],
|
|
238
|
+
expression: string,
|
|
239
|
+
): number[] {
|
|
240
|
+
const values = new Set<number>();
|
|
241
|
+
const fail = (problem: string): never => {
|
|
242
|
+
throw new Error(`Invalid cron expression "${expression}": ${spec.name} field "${field}" ${problem}.`);
|
|
243
|
+
};
|
|
244
|
+
if (field === "") fail("is empty");
|
|
245
|
+
for (const part of field.split(",")) {
|
|
246
|
+
const slashed = part.split("/");
|
|
247
|
+
if (slashed.length > 2) fail(`has more than one "/" in "${part}"`);
|
|
248
|
+
const [rangeText, stepText] = slashed;
|
|
249
|
+
let step = 1;
|
|
250
|
+
if (stepText !== undefined) {
|
|
251
|
+
if (!/^\d+$/.test(stepText) || Number(stepText) < 1) {
|
|
252
|
+
fail(`has an invalid step "/${stepText}" (steps are positive integers)`);
|
|
253
|
+
}
|
|
254
|
+
step = Number(stepText);
|
|
255
|
+
}
|
|
256
|
+
let low: number;
|
|
257
|
+
let high: number;
|
|
258
|
+
if (rangeText === "*") {
|
|
259
|
+
low = spec.min;
|
|
260
|
+
high = spec.max;
|
|
261
|
+
} else if (/^\d+$/.test(rangeText)) {
|
|
262
|
+
low = Number(rangeText);
|
|
263
|
+
// Vixie semantics: "N/step" means N through the field max, stepped.
|
|
264
|
+
high = stepText !== undefined ? spec.max : low;
|
|
265
|
+
} else {
|
|
266
|
+
const range = /^(\d+)-(\d+)$/.exec(rangeText);
|
|
267
|
+
if (!range) return fail(`has an unsupported token "${part}" (use *, numbers, lists, ranges, steps)`);
|
|
268
|
+
low = Number(range[1]);
|
|
269
|
+
high = Number(range[2]);
|
|
270
|
+
if (low > high) fail(`has a range "${rangeText}" that starts after it ends`);
|
|
271
|
+
}
|
|
272
|
+
if (low < spec.min || high > spec.max) {
|
|
273
|
+
fail(`is out of range (${spec.min}–${spec.max})`);
|
|
274
|
+
}
|
|
275
|
+
for (let value = low; value <= high; value += step) values.add(value);
|
|
276
|
+
}
|
|
277
|
+
return Array.from(values).sort((a, b) => a - b);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function cronDayMatches(cron: CronExpression, date: Date): boolean {
|
|
281
|
+
if (!cron.month.includes(date.getMonth() + 1)) return false;
|
|
282
|
+
const domMatch = cron.dayOfMonth.includes(date.getDate());
|
|
283
|
+
const dowMatch = cron.dayOfWeek.includes(date.getDay());
|
|
284
|
+
if (cron.dayOfMonthRestricted && cron.dayOfWeekRestricted) return domMatch || dowMatch;
|
|
285
|
+
if (cron.dayOfMonthRestricted) return domMatch;
|
|
286
|
+
if (cron.dayOfWeekRestricted) return dowMatch;
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** True when the expression fires in `date`'s minute (local time, like cron). */
|
|
291
|
+
export function cronMatches(cron: CronExpression, date: Date): boolean {
|
|
292
|
+
return (
|
|
293
|
+
cron.minute.includes(date.getMinutes()) &&
|
|
294
|
+
cron.hour.includes(date.getHours()) &&
|
|
295
|
+
cronDayMatches(cron, date)
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The next firing strictly after `after` (local time). Throws when the
|
|
301
|
+
* expression never fires (e.g. "0 0 31 2 *") — `schedule add` calls this once
|
|
302
|
+
* so impossible expressions are rejected up front with a clear error.
|
|
303
|
+
*/
|
|
304
|
+
export function nextCronFiring(cron: CronExpression, after: Date): Date {
|
|
305
|
+
const start = new Date(after.getTime());
|
|
306
|
+
start.setSeconds(0, 0);
|
|
307
|
+
start.setMinutes(start.getMinutes() + 1);
|
|
308
|
+
// Day-level iteration; 4 years + 1 day covers schedules that only fire on
|
|
309
|
+
// a leap day. Hour/minute lists are sorted, so the first hit is the answer.
|
|
310
|
+
for (let dayOffset = 0; dayOffset <= 1462; dayOffset += 1) {
|
|
311
|
+
const day = new Date(start.getFullYear(), start.getMonth(), start.getDate() + dayOffset);
|
|
312
|
+
if (!cronDayMatches(cron, day)) continue;
|
|
313
|
+
const sameDay = dayOffset === 0;
|
|
314
|
+
for (const hour of cron.hour) {
|
|
315
|
+
if (sameDay && hour < start.getHours()) continue;
|
|
316
|
+
for (const minute of cron.minute) {
|
|
317
|
+
if (sameDay && hour === start.getHours() && minute < start.getMinutes()) continue;
|
|
318
|
+
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
throw new Error(`Cron expression "${cron.source}" never fires (no matching date within 4 years).`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Expected firings strictly inside (fromExclusive, toExclusive), capped — the
|
|
327
|
+
* input to missed-firing detection, never to catch-up execution.
|
|
328
|
+
*/
|
|
329
|
+
export function expectedFirings(
|
|
330
|
+
cron: CronExpression,
|
|
331
|
+
fromExclusive: Date,
|
|
332
|
+
toExclusive: Date,
|
|
333
|
+
cap = 100,
|
|
334
|
+
): Date[] {
|
|
335
|
+
const firings: Date[] = [];
|
|
336
|
+
let cursor = fromExclusive;
|
|
337
|
+
while (firings.length < cap) {
|
|
338
|
+
let next: Date;
|
|
339
|
+
try {
|
|
340
|
+
next = nextCronFiring(cron, cursor);
|
|
341
|
+
} catch {
|
|
342
|
+
break; // an expression that never fires has nothing to miss
|
|
343
|
+
}
|
|
344
|
+
if (next.getTime() >= toExclusive.getTime()) break;
|
|
345
|
+
firings.push(next);
|
|
346
|
+
cursor = next;
|
|
347
|
+
}
|
|
348
|
+
return firings;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Missed firings since the last run record (or the entry's creation when no
|
|
353
|
+
* run has ever fired): expected firings with no run record in their minute.
|
|
354
|
+
* Local cron has no catch-up — a laptop asleep at firing time skips the run —
|
|
355
|
+
* so this is visibility only; nothing is re-executed.
|
|
356
|
+
*/
|
|
357
|
+
export function computeMissedFirings(
|
|
358
|
+
entry: Pick<ScheduleEntry, "cron" | "createdAt">,
|
|
359
|
+
runs: ScheduleRunRecord[],
|
|
360
|
+
now: Date = new Date(),
|
|
361
|
+
cap = 100,
|
|
362
|
+
): { missed: Date[]; capped: boolean } {
|
|
363
|
+
const cron = parseCron(entry.cron);
|
|
364
|
+
const lastRun = runs.length > 0 ? runs[runs.length - 1] : null;
|
|
365
|
+
const baseline = new Date(lastRun ? lastRun.firedAt : entry.createdAt);
|
|
366
|
+
const expected = expectedFirings(cron, baseline, now, cap);
|
|
367
|
+
const firedMinutes = new Set(
|
|
368
|
+
runs.map((run) => {
|
|
369
|
+
const fired = new Date(run.firedAt);
|
|
370
|
+
fired.setSeconds(0, 0);
|
|
371
|
+
return fired.getTime();
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
const missed = expected.filter((firing) => !firedMinutes.has(firing.getTime()));
|
|
375
|
+
return { missed, capped: expected.length >= cap };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Stores: schedules.json + schedule/runs/<id>/<timestamp>.json
|
|
380
|
+
|
|
381
|
+
export function schedulesPath(baseDir?: string): string {
|
|
382
|
+
return join(baseDir ?? credentialsDir(), "schedules.json");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function scheduleRunsDir(baseDir?: string): string {
|
|
386
|
+
return join(baseDir ?? credentialsDir(), "schedule", "runs");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
type SchedulesFile = { version: 1; schedules: ScheduleEntry[] };
|
|
390
|
+
|
|
391
|
+
export interface ScheduleStore {
|
|
392
|
+
add(entry: ScheduleEntry): Promise<ScheduleEntry>;
|
|
393
|
+
list(): Promise<ScheduleEntry[]>;
|
|
394
|
+
get(id: string): Promise<ScheduleEntry | null>;
|
|
395
|
+
remove(id: string): Promise<boolean>;
|
|
396
|
+
setEnabled(id: string, enabled: boolean): Promise<ScheduleEntry>;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Schedule entries as one small JSON file per profile, sibling to plans and
|
|
401
|
+
* enrich runs. `directory` overrides the profile home (tests).
|
|
402
|
+
*/
|
|
403
|
+
export function createFileScheduleStore(directory?: string): ScheduleStore {
|
|
404
|
+
const path = () => schedulesPath(directory);
|
|
405
|
+
|
|
406
|
+
function read(): SchedulesFile {
|
|
407
|
+
try {
|
|
408
|
+
const parsed = JSON.parse(readFileSync(path(), "utf8"));
|
|
409
|
+
if (parsed && typeof parsed === "object" && parsed.version === 1 && Array.isArray(parsed.schedules)) {
|
|
410
|
+
return parsed as SchedulesFile;
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
// Missing or unreadable file falls through to an empty store.
|
|
414
|
+
}
|
|
415
|
+
return { version: 1, schedules: [] };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function write(file: SchedulesFile) {
|
|
419
|
+
if (directory) mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
420
|
+
else ensureSecureHomeDir();
|
|
421
|
+
writeSecureFile(path(), `${JSON.stringify(file, null, 2)}\n`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function mustFind(file: SchedulesFile, id: string): ScheduleEntry {
|
|
425
|
+
const entry = file.schedules.find((candidate) => candidate.id === id);
|
|
426
|
+
if (!entry) throw new Error(`No schedule with id ${id}. List entries with \`fullstackgtm schedule list\`.`);
|
|
427
|
+
return entry;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
async add(entry) {
|
|
432
|
+
const file = read();
|
|
433
|
+
if (file.schedules.some((candidate) => candidate.id === entry.id)) {
|
|
434
|
+
throw new Error(`Schedule ${entry.id} already exists.`);
|
|
435
|
+
}
|
|
436
|
+
file.schedules.push(entry);
|
|
437
|
+
write(file);
|
|
438
|
+
return entry;
|
|
439
|
+
},
|
|
440
|
+
async list() {
|
|
441
|
+
return read().schedules;
|
|
442
|
+
},
|
|
443
|
+
async get(id) {
|
|
444
|
+
return read().schedules.find((candidate) => candidate.id === id) ?? null;
|
|
445
|
+
},
|
|
446
|
+
async remove(id) {
|
|
447
|
+
const file = read();
|
|
448
|
+
const remaining = file.schedules.filter((candidate) => candidate.id !== id);
|
|
449
|
+
if (remaining.length === file.schedules.length) return false;
|
|
450
|
+
write({ ...file, schedules: remaining });
|
|
451
|
+
return true;
|
|
452
|
+
},
|
|
453
|
+
async setEnabled(id, enabled) {
|
|
454
|
+
const file = read();
|
|
455
|
+
const entry = mustFind(file, id);
|
|
456
|
+
entry.enabled = enabled;
|
|
457
|
+
write(file);
|
|
458
|
+
return entry;
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export interface ScheduleRunStore {
|
|
464
|
+
record(run: ScheduleRunRecord): Promise<ScheduleRunRecord>;
|
|
465
|
+
/** Run records for one schedule, oldest first; `limit` keeps the newest N. */
|
|
466
|
+
list(scheduleId: string, limit?: number): Promise<ScheduleRunRecord[]>;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function createFileScheduleRunStore(directory?: string): ScheduleRunStore {
|
|
470
|
+
const baseDir = () => directory ?? scheduleRunsDir();
|
|
471
|
+
|
|
472
|
+
function dirFor(scheduleId: string) {
|
|
473
|
+
if (!/^[\w.-]+$/.test(scheduleId)) throw new Error(`Invalid schedule id: ${scheduleId}`);
|
|
474
|
+
return join(baseDir(), scheduleId);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
async record(run) {
|
|
479
|
+
const dir = dirFor(run.scheduleId);
|
|
480
|
+
if (!directory) ensureSecureHomeDir();
|
|
481
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
482
|
+
// ISO timestamps carry ":" — sanitize for the filename, keep the record verbatim.
|
|
483
|
+
const fileName = `${run.firedAt.replace(/[:.]/g, "-")}.json`;
|
|
484
|
+
writeSecureFile(join(dir, fileName), `${JSON.stringify(run, null, 2)}\n`);
|
|
485
|
+
return run;
|
|
486
|
+
},
|
|
487
|
+
async list(scheduleId, limit) {
|
|
488
|
+
let names: string[] = [];
|
|
489
|
+
try {
|
|
490
|
+
names = readdirSync(dirFor(scheduleId)).filter((name) => name.endsWith(".json"));
|
|
491
|
+
} catch {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
const runs = names
|
|
495
|
+
.sort()
|
|
496
|
+
.map((name) => {
|
|
497
|
+
try {
|
|
498
|
+
return JSON.parse(readFileSync(join(dirFor(scheduleId), name), "utf8")) as ScheduleRunRecord;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
.filter((run): run is ScheduleRunRecord => run !== null);
|
|
504
|
+
return limit !== undefined && limit >= 0 ? runs.slice(-limit) : runs;
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Local provider: sentinel-delimited managed crontab block
|
|
511
|
+
|
|
512
|
+
export type CrontabIo = {
|
|
513
|
+
/** Current crontab content; "" when the user has none. */
|
|
514
|
+
read(): string;
|
|
515
|
+
write(content: string): void;
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The real user crontab via `crontab -l` / `crontab -`. Everything above this
|
|
520
|
+
* function takes a CrontabIo so tests inject fakes and never touch it.
|
|
521
|
+
*/
|
|
522
|
+
export function systemCrontabIo(): CrontabIo {
|
|
523
|
+
return {
|
|
524
|
+
read() {
|
|
525
|
+
const result = spawnSync("crontab", ["-l"], { encoding: "utf8" });
|
|
526
|
+
if (result.error) throw new Error(`Cannot run \`crontab\`: ${result.error.message}`);
|
|
527
|
+
if (result.status !== 0) {
|
|
528
|
+
// "no crontab for <user>" exits 1: an empty crontab, not a failure.
|
|
529
|
+
if (/no crontab/i.test(result.stderr ?? "")) return "";
|
|
530
|
+
throw new Error(`\`crontab -l\` failed: ${(result.stderr ?? "").trim()}`);
|
|
531
|
+
}
|
|
532
|
+
return result.stdout ?? "";
|
|
533
|
+
},
|
|
534
|
+
write(content) {
|
|
535
|
+
const result = spawnSync("crontab", ["-"], { input: content, encoding: "utf8" });
|
|
536
|
+
if (result.error) throw new Error(`Cannot run \`crontab\`: ${result.error.message}`);
|
|
537
|
+
if (result.status !== 0) {
|
|
538
|
+
throw new Error(`crontab rejected the rendered block: ${(result.stderr ?? "").trim()}`);
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function crontabSentinels(profile: string): { open: string; close: string } {
|
|
545
|
+
return {
|
|
546
|
+
open: `# >>> fullstackgtm ${profile} >>>`,
|
|
547
|
+
close: `# <<< fullstackgtm ${profile} <<<`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Render the managed block for one profile. Every line invokes
|
|
553
|
+
* `... schedule run <id> --profile <profile> --trigger cron` — the single
|
|
554
|
+
* provider entry point — so the crontab a user audits never contains anything
|
|
555
|
+
* but fullstackgtm dispatch (no arbitrary shell, ever).
|
|
556
|
+
*/
|
|
557
|
+
export function renderManagedBlock(
|
|
558
|
+
profile: string,
|
|
559
|
+
entries: ScheduleEntry[],
|
|
560
|
+
cliInvocation: string,
|
|
561
|
+
): string {
|
|
562
|
+
const { open, close } = crontabSentinels(profile);
|
|
563
|
+
const lines = [
|
|
564
|
+
open,
|
|
565
|
+
"# Managed by `fullstackgtm schedule install` — replaced wholesale on re-install; do not edit.",
|
|
566
|
+
];
|
|
567
|
+
for (const entry of entries) {
|
|
568
|
+
lines.push(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`);
|
|
569
|
+
lines.push(`${entry.cron} ${cliInvocation} schedule run ${entry.id} --profile ${profile} --trigger cron`);
|
|
570
|
+
}
|
|
571
|
+
lines.push(close);
|
|
572
|
+
return lines.join("\n");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Replace (or with `block: null`, remove) the profile's managed block in the
|
|
577
|
+
* crontab content, byte-for-byte preserving everything outside the sentinels.
|
|
578
|
+
* Idempotent: re-applying the same block yields identical content.
|
|
579
|
+
*/
|
|
580
|
+
export function replaceManagedBlock(existing: string, profile: string, block: string | null): string {
|
|
581
|
+
const { open, close } = crontabSentinels(profile);
|
|
582
|
+
const lines = existing.split("\n");
|
|
583
|
+
const start = lines.indexOf(open);
|
|
584
|
+
const end = lines.indexOf(close);
|
|
585
|
+
let before: string[];
|
|
586
|
+
let after: string[];
|
|
587
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
588
|
+
before = lines.slice(0, start);
|
|
589
|
+
after = lines.slice(end + 1);
|
|
590
|
+
} else if (start !== -1 || end !== -1) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`The crontab contains a damaged fullstackgtm block for profile "${profile}" ` +
|
|
593
|
+
"(one sentinel without its pair). Repair it by hand, then re-run install.",
|
|
594
|
+
);
|
|
595
|
+
} else {
|
|
596
|
+
before = lines;
|
|
597
|
+
after = [];
|
|
598
|
+
}
|
|
599
|
+
const merged = [...before];
|
|
600
|
+
if (block !== null) {
|
|
601
|
+
if (merged.length > 0 && merged[merged.length - 1].trim() !== "") merged.push("");
|
|
602
|
+
merged.push(...block.split("\n"));
|
|
603
|
+
}
|
|
604
|
+
merged.push(...after);
|
|
605
|
+
while (merged.length > 0 && merged[0].trim() === "") merged.shift();
|
|
606
|
+
while (merged.length > 0 && merged[merged.length - 1].trim() === "") merged.pop();
|
|
607
|
+
if (merged.length === 0) return "";
|
|
608
|
+
return `${merged.join("\n")}\n`;
|
|
609
|
+
}
|