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