fullstackgtm 0.23.1 → 0.24.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,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
+ }