fullstackgtm 0.43.0 → 0.45.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.
Files changed (130) hide show
  1. package/CHANGELOG.md +378 -1
  2. package/README.md +37 -14
  3. package/dist/audit.d.ts +3 -1
  4. package/dist/audit.js +29 -2
  5. package/dist/cli/audit.d.ts +15 -0
  6. package/dist/cli/audit.js +392 -0
  7. package/dist/cli/auth.d.ts +80 -0
  8. package/dist/cli/auth.js +500 -0
  9. package/dist/cli/call.d.ts +1 -0
  10. package/dist/cli/call.js +373 -0
  11. package/dist/cli/capabilities.d.ts +30 -0
  12. package/dist/cli/capabilities.js +197 -0
  13. package/dist/cli/draft.d.ts +7 -0
  14. package/dist/cli/draft.js +87 -0
  15. package/dist/cli/enrich.d.ts +8 -0
  16. package/dist/cli/enrich.js +788 -0
  17. package/dist/cli/fix.d.ts +33 -0
  18. package/dist/cli/fix.js +344 -0
  19. package/dist/cli/help.d.ts +25 -0
  20. package/dist/cli/help.js +609 -0
  21. package/dist/cli/icp.d.ts +7 -0
  22. package/dist/cli/icp.js +229 -0
  23. package/dist/cli/init.d.ts +7 -0
  24. package/dist/cli/init.js +58 -0
  25. package/dist/cli/market.d.ts +1 -0
  26. package/dist/cli/market.js +391 -0
  27. package/dist/cli/plans.d.ts +5 -0
  28. package/dist/cli/plans.js +454 -0
  29. package/dist/cli/schedule.d.ts +8 -0
  30. package/dist/cli/schedule.js +479 -0
  31. package/dist/cli/shared.d.ts +70 -0
  32. package/dist/cli/shared.js +331 -0
  33. package/dist/cli/signals.d.ts +7 -0
  34. package/dist/cli/signals.js +412 -0
  35. package/dist/cli/suggest.d.ts +49 -0
  36. package/dist/cli/suggest.js +135 -0
  37. package/dist/cli/tam.d.ts +9 -0
  38. package/dist/cli/tam.js +387 -0
  39. package/dist/cli/ui.d.ts +121 -0
  40. package/dist/cli/ui.js +375 -0
  41. package/dist/cli.d.ts +1 -57
  42. package/dist/cli.js +104 -4556
  43. package/dist/connector.d.ts +15 -0
  44. package/dist/connector.js +35 -0
  45. package/dist/connectors/hubspot.d.ts +3 -1
  46. package/dist/connectors/hubspot.js +10 -3
  47. package/dist/connectors/outboxChannel.d.ts +64 -0
  48. package/dist/connectors/outboxChannel.js +170 -0
  49. package/dist/connectors/prospectSources.d.ts +22 -0
  50. package/dist/connectors/prospectSources.js +43 -0
  51. package/dist/connectors/salesforce.d.ts +3 -1
  52. package/dist/connectors/salesforce.js +12 -5
  53. package/dist/connectors/signalSources.d.ts +105 -0
  54. package/dist/connectors/signalSources.js +264 -0
  55. package/dist/connectors/theirstack.d.ts +84 -0
  56. package/dist/connectors/theirstack.js +125 -0
  57. package/dist/icp.d.ts +52 -4
  58. package/dist/icp.js +112 -35
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +1 -0
  61. package/dist/init.js +3 -0
  62. package/dist/judge.d.ts +2 -0
  63. package/dist/judge.js +6 -0
  64. package/dist/judgeEval.d.ts +7 -0
  65. package/dist/judgeEval.js +8 -1
  66. package/dist/marketClassify.d.ts +2 -0
  67. package/dist/marketClassify.js +7 -1
  68. package/dist/mcp-bin.js +2 -2
  69. package/dist/mcp.js +259 -166
  70. package/dist/runReport.d.ts +26 -0
  71. package/dist/runReport.js +15 -0
  72. package/dist/schedule.d.ts +80 -2
  73. package/dist/schedule.js +272 -5
  74. package/dist/signals.d.ts +54 -0
  75. package/dist/signals.js +64 -0
  76. package/dist/spoolFiles.d.ts +44 -0
  77. package/dist/spoolFiles.js +114 -0
  78. package/dist/tam.d.ts +225 -0
  79. package/dist/tam.js +470 -0
  80. package/dist/types.d.ts +11 -0
  81. package/docs/api.md +91 -11
  82. package/docs/outbox-format.md +92 -0
  83. package/docs/recipes.md +37 -0
  84. package/docs/roadmap-to-1.0.md +31 -1
  85. package/docs/signal-spool-format.md +175 -0
  86. package/docs/tam.md +195 -0
  87. package/llms.txt +83 -11
  88. package/package.json +1 -1
  89. package/skills/fullstackgtm/SKILL.md +4 -3
  90. package/src/audit.ts +27 -1
  91. package/src/cli/audit.ts +447 -0
  92. package/src/cli/auth.ts +549 -0
  93. package/src/cli/call.ts +398 -0
  94. package/src/cli/capabilities.ts +215 -0
  95. package/src/cli/draft.ts +101 -0
  96. package/src/cli/enrich.ts +885 -0
  97. package/src/cli/fix.ts +372 -0
  98. package/src/cli/help.ts +664 -0
  99. package/src/cli/icp.ts +265 -0
  100. package/src/cli/init.ts +65 -0
  101. package/src/cli/market.ts +423 -0
  102. package/src/cli/plans.ts +523 -0
  103. package/src/cli/schedule.ts +526 -0
  104. package/src/cli/shared.ts +375 -0
  105. package/src/cli/signals.ts +434 -0
  106. package/src/cli/suggest.ts +151 -0
  107. package/src/cli/tam.ts +435 -0
  108. package/src/cli/ui.ts +426 -0
  109. package/src/cli.ts +103 -5170
  110. package/src/connector.ts +46 -0
  111. package/src/connectors/hubspot.ts +14 -2
  112. package/src/connectors/outboxChannel.ts +202 -0
  113. package/src/connectors/prospectSources.ts +57 -0
  114. package/src/connectors/salesforce.ts +18 -2
  115. package/src/connectors/signalSources.ts +314 -0
  116. package/src/connectors/theirstack.ts +170 -0
  117. package/src/icp.ts +120 -34
  118. package/src/index.ts +32 -0
  119. package/src/init.ts +3 -0
  120. package/src/judge.ts +7 -0
  121. package/src/judgeEval.ts +8 -1
  122. package/src/marketClassify.ts +8 -1
  123. package/src/mcp-bin.ts +2 -2
  124. package/src/mcp.ts +130 -57
  125. package/src/runReport.ts +39 -0
  126. package/src/schedule.ts +330 -7
  127. package/src/signals.ts +90 -0
  128. package/src/spoolFiles.ts +116 -0
  129. package/src/tam.ts +654 -0
  130. package/src/types.ts +12 -0
@@ -0,0 +1,526 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { existsSync, mkdirSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { activeProfile, credentialsDir } from "../credentials.ts";
6
+ import { createFilePlanStore } from "../planStore.ts";
7
+ import { createFileEnrichRunStore } from "../enrich.ts";
8
+ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, installLaunchdAgents, isDuplicateCronFiring, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, systemLaunchdIo, tokenizeCommand, uninstallLaunchdAgents, validateSchedulableArgv, type ScheduleEntry, type ScheduleRunRecord, type ScheduleRunTrigger } from "../schedule.ts";
9
+ import { runCli } from "../cli.ts";
10
+ import { isOptionValue, numericOption, option } from "./shared.ts";
11
+ import { unknownSubcommandError } from "./suggest.ts";
12
+
13
+
14
+ /**
15
+ * The schedule layer: declarative cadences for read/plan-side commands,
16
+ * materialized through a provider (MVP: the user crontab), with an
17
+ * append-only run history. The governance invariant — scheduling never
18
+ * auto-approves — is enforced at `add` time and re-checked at `run` time.
19
+ * Spec: docs/schedule.md (monorepo).
20
+ */
21
+ export async function scheduleCommand(args: string[]) {
22
+ const [subcommand, ...rest] = args;
23
+
24
+ // Catch --help BEFORE any store read, credential access, or crontab
25
+ // round-trip (the enrich/market early-catch pattern — `schedule install
26
+ // --help` must never touch the crontab).
27
+ if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
28
+ console.log(`Usage:
29
+ schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]
30
+ schedule list [--json]
31
+ schedule remove <id>
32
+ schedule enable <id> | disable <id>
33
+ schedule run <id> execute now; the single entry point every provider's timer calls
34
+ schedule install [--timer crontab|launchd] materialize enabled entries into the system timer
35
+ schedule uninstall [--timer crontab|launchd] remove everything install created (nothing else)
36
+ schedule status [<id>] [--runs <n>] [--json]
37
+
38
+ add validates the command against the schedulable allowlist — read/plan-side
39
+ only: audit, snapshot, enrich append|refresh, market capture|refresh,
40
+ suggest, report, doctor — and parses the 5-field cron expression (*, lists,
41
+ ranges, steps), but touches NO crontab: entries are declarative until install
42
+ materializes them (the plan/apply split — declare, then deliberately
43
+ activate).
44
+
45
+ Scheduling never auto-approves. apply is schedulable ONLY as
46
+ \`apply --plan-id <id>\`, and every firing re-checks the plan's status is
47
+ approved — an unapproved plan records a plan_not_approved no-op run instead
48
+ of executing. There is no flag that relaxes this. Unattended runs accumulate
49
+ proposals (plans, run records, reports), never surprise CRM writes.
50
+
51
+ install materializes enabled entries into the system timer. --timer defaults
52
+ by platform: launchd on macOS, crontab elsewhere. launchd writes one
53
+ LaunchAgent plist per entry (com.fullstackgtm.<profile>.<id> in
54
+ ~/Library/LaunchAgents) — no Full Disk Access needed (macOS gates \`crontab\`
55
+ behind it), and firings missed while asleep coalesce into one run on wake.
56
+ crontab renders a sentinel-delimited block (# >>> fullstackgtm <profile> >>>
57
+ ...) in the user crontab. Either way every timer line/plist invokes
58
+ \`schedule run <id> --profile <profile>\` and nothing else; re-install
59
+ replaces what install owns wholesale and never touches anything outside it.
60
+ Providers other than local (modal, aws) are not yet implemented — they will
61
+ be scaffold generators calling the same \`schedule run\` contract.
62
+
63
+ run executes the command in-process and records the outcome (exit code,
64
+ output tail, artifacts: plan ids / enrich run labels) under the profile's
65
+ schedule/runs/<id>/; the exit code propagates. Manual runs record
66
+ trigger: manual. status shows next firing and surfaces missed firings
67
+ (laptop asleep = cron skips silently; visibility only, no catch-up).`);
68
+ return;
69
+ }
70
+
71
+ const store = createFileScheduleStore();
72
+ const runStore = createFileScheduleRunStore();
73
+
74
+ if (subcommand === "add") {
75
+ const commandText = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
76
+ if (!commandText) {
77
+ throw new Error('Usage: fullstackgtm schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]');
78
+ }
79
+ const cronText = option(rest, "--cron");
80
+ if (!cronText) throw new Error('schedule add requires --cron "<minute hour day-of-month month day-of-week>"');
81
+ requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
82
+
83
+ let argv = tokenizeCommand(commandText);
84
+ if (argv[0] === "fullstackgtm") argv = argv.slice(1);
85
+ validateSchedulableArgv(argv);
86
+ const cron = parseCron(cronText);
87
+ const next = nextCronFiring(cron, new Date()); // also rejects never-firing expressions
88
+ const createdAt = new Date().toISOString();
89
+ const label =
90
+ option(rest, "--label") ??
91
+ argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
92
+ assertSingleLineLabel(label);
93
+ const entry: ScheduleEntry = {
94
+ id: scheduleId(label, cron.source, argv, createdAt),
95
+ label,
96
+ argv,
97
+ cron: cron.source,
98
+ provider: "local",
99
+ enabled: true,
100
+ createdAt,
101
+ };
102
+ await store.add(entry);
103
+ console.log(`Added ${entry.id} "${label}": \`${argv.join(" ")}\` at \`${cron.source}\` (next firing: ${next.toISOString()}).`);
104
+ console.log("Declarative until materialized — activate with `fullstackgtm schedule install`.");
105
+ return;
106
+ }
107
+
108
+ if (subcommand === "list") {
109
+ const entries = await store.list();
110
+ const now = new Date();
111
+ const withNext = entries.map((entry) => ({
112
+ ...entry,
113
+ nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
114
+ }));
115
+ if (rest.includes("--json")) {
116
+ console.log(JSON.stringify(withNext, null, 2));
117
+ return;
118
+ }
119
+ if (entries.length === 0) {
120
+ console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
121
+ return;
122
+ }
123
+ for (const entry of withNext) {
124
+ console.log(
125
+ `${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? "—"} ${entry.label}: ${entry.argv.join(" ")}`,
126
+ );
127
+ }
128
+ console.log("\nEntries are declarative — `schedule install` materializes enabled ones into the crontab.");
129
+ return;
130
+ }
131
+
132
+ if (subcommand === "remove") {
133
+ const id = positionalArg(rest, "Usage: fullstackgtm schedule remove <id>");
134
+ if (!(await store.remove(id))) throw new Error(`No schedule with id ${id}.`);
135
+ console.log(`Removed ${id}. Run \`fullstackgtm schedule install\` to update the crontab block.`);
136
+ return;
137
+ }
138
+
139
+ if (subcommand === "enable" || subcommand === "disable") {
140
+ const id = positionalArg(rest, `Usage: fullstackgtm schedule ${subcommand} <id>`);
141
+ const entry = await store.setEnabled(id, subcommand === "enable");
142
+ console.log(
143
+ `${subcommand === "enable" ? "Enabled" : "Disabled"} ${entry.id} "${entry.label}". ` +
144
+ "Run `fullstackgtm schedule install` to update the crontab block.",
145
+ );
146
+ return;
147
+ }
148
+
149
+ if (subcommand === "run") {
150
+ const id = positionalArg(rest, "Usage: fullstackgtm schedule run <id>");
151
+ const entry = await store.get(id);
152
+ if (!entry) throw new Error(`No schedule with id ${id} in profile "${activeProfile()}".`);
153
+ if (!entry.enabled) {
154
+ throw new Error(`Schedule ${id} is disabled — enable it with \`fullstackgtm schedule enable ${id}\`.`);
155
+ }
156
+ const trigger = (option(rest, "--trigger") ?? "manual") as ScheduleRunTrigger;
157
+ if (trigger !== "cron" && trigger !== "manual") {
158
+ throw new Error("--trigger must be cron or manual");
159
+ }
160
+ const run = await executeScheduledRun(entry, trigger);
161
+ if (run.exitCode !== 0) process.exitCode = run.exitCode;
162
+ return;
163
+ }
164
+
165
+ if (subcommand === "install" || subcommand === "uninstall") {
166
+ requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
167
+ const timer = resolveScheduleTimer(option(rest, "--timer") ?? undefined);
168
+ const profile = activeProfile();
169
+
170
+ if (timer === "launchd") {
171
+ const io = systemLaunchdIo();
172
+ if (subcommand === "uninstall") {
173
+ const removed = uninstallLaunchdAgents(profile, io);
174
+ console.log(
175
+ `Removed ${removed.length} fullstackgtm LaunchAgent(s) for profile "${profile}" ` +
176
+ "(plists outside com.fullstackgtm.* untouched).",
177
+ );
178
+ return;
179
+ }
180
+ const enabled = (await store.list()).filter((entry) => entry.enabled && entry.provider === "local");
181
+ if (enabled.length === 0) {
182
+ const removed = uninstallLaunchdAgents(profile, io);
183
+ console.log(
184
+ `No enabled schedules for profile "${profile}" — removed ${removed.length} LaunchAgent(s).`,
185
+ );
186
+ return;
187
+ }
188
+ const logDir = join(credentialsDir(), "schedule", "logs");
189
+ mkdirSync(logDir, { recursive: true, mode: 0o700 });
190
+ const { installed } = installLaunchdAgents(profile, enabled, scheduleCliArgv(), io, {
191
+ environment: process.env.FSGTM_HOME ? { FSGTM_HOME: process.env.FSGTM_HOME } : undefined,
192
+ logDir,
193
+ });
194
+ console.log(
195
+ `Installed ${installed.length} LaunchAgent(s) for profile "${profile}" ` +
196
+ "(one plist per schedule in ~/Library/LaunchAgents — no crontab, no Full Disk Access). " +
197
+ "Re-running install replaces them wholesale; `schedule uninstall` removes them.",
198
+ );
199
+ return;
200
+ }
201
+
202
+ const io = systemCrontabIo();
203
+ const existing = io.read();
204
+ if (subcommand === "uninstall") {
205
+ io.write(replaceManagedBlock(existing, profile, null));
206
+ console.log(`Removed the fullstackgtm managed crontab block for profile "${profile}" (lines outside the sentinels untouched).`);
207
+ return;
208
+ }
209
+ const enabled = (await store.list()).filter((entry) => entry.enabled && entry.provider === "local");
210
+ if (enabled.length === 0) {
211
+ io.write(replaceManagedBlock(existing, profile, null));
212
+ console.log(`No enabled schedules for profile "${profile}" — removed the managed block.`);
213
+ return;
214
+ }
215
+ const block = renderManagedBlock(profile, enabled, scheduleCliInvocation());
216
+ io.write(replaceManagedBlock(existing, profile, block));
217
+ console.log(
218
+ `Installed ${enabled.length} schedule(s) into the managed crontab block for profile "${profile}". ` +
219
+ "Re-running install replaces the block wholesale; `schedule uninstall` removes it.",
220
+ );
221
+ return;
222
+ }
223
+
224
+ if (subcommand === "status") {
225
+ const id = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
226
+ const entries = await store.list();
227
+ const selected = id ? entries.filter((entry) => entry.id === id) : entries;
228
+ if (id && selected.length === 0) throw new Error(`No schedule with id ${id}.`);
229
+ if (selected.length === 0) {
230
+ console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
231
+ return;
232
+ }
233
+ const showRuns = numericOption(rest, "--runs");
234
+ const now = new Date();
235
+ const report = [] as Array<Record<string, unknown>>;
236
+ for (const entry of selected) {
237
+ const runs = await runStore.list(entry.id);
238
+ const last = runs.length > 0 ? runs[runs.length - 1] : null;
239
+ let streak = 0;
240
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
241
+ if ((runs[index].exitCode === 0) !== (last!.exitCode === 0)) break;
242
+ streak += 1;
243
+ }
244
+ const missed = computeMissedFirings(entry, runs, now);
245
+ report.push({
246
+ id: entry.id,
247
+ label: entry.label,
248
+ argv: entry.argv,
249
+ cron: entry.cron,
250
+ enabled: entry.enabled,
251
+ nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
252
+ lastRun: last
253
+ ? {
254
+ firedAt: last.firedAt,
255
+ trigger: last.trigger,
256
+ exitCode: last.exitCode,
257
+ noopReason: last.noopReason,
258
+ artifacts: last.artifacts,
259
+ }
260
+ : null,
261
+ streak: last ? { outcome: last.exitCode === 0 ? "success" : "failure", length: streak } : null,
262
+ missedFirings: missed.missed.map((firing) => firing.toISOString()),
263
+ missedFiringsCapped: missed.capped,
264
+ runs: showRuns !== undefined ? runs.slice(-showRuns) : undefined,
265
+ });
266
+ }
267
+ if (rest.includes("--json")) {
268
+ console.log(JSON.stringify(report, null, 2));
269
+ return;
270
+ }
271
+ for (const entry of report) {
272
+ const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
273
+ const streak = entry.streak as { outcome: string; length: number } | null;
274
+ const missed = entry.missedFirings as string[];
275
+ console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${(entry.argv as string[]).join(" ")}`);
276
+ console.log(` next: ${entry.nextFiring ?? "— (disabled)"}`);
277
+ if (last) {
278
+ const artifacts = [
279
+ ...last.artifacts.planIds.map((planId) => `plan ${planId}`),
280
+ ...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
281
+ ];
282
+ console.log(
283
+ ` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
284
+ (last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
285
+ (artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
286
+ );
287
+ console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
288
+ } else {
289
+ console.log(" last: never fired");
290
+ }
291
+ if (missed.length > 0) {
292
+ console.log(
293
+ ` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
294
+ `(latest: ${missed[missed.length - 1]}) — cron has no catch-up (launchd coalesces missed firings into one run on wake); visibility only`,
295
+ );
296
+ }
297
+ const runs = entry.runs as ScheduleRunRecord[] | undefined;
298
+ if (runs) {
299
+ for (const run of runs) {
300
+ console.log(` ${run.firedAt} ${run.trigger.padEnd(6)} exit ${run.exitCode}${run.noopReason ? ` no-op: ${run.noopReason}` : ""}`);
301
+ }
302
+ }
303
+ }
304
+ return;
305
+ }
306
+
307
+ throw unknownSubcommandError("schedule", subcommand, [
308
+ "add", "list", "remove", "enable", "disable", "run", "install", "uninstall", "status",
309
+ ]);
310
+ }
311
+
312
+ function positionalArg(rest: string[], usage: string): string {
313
+ const value = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
314
+ if (!value) throw new Error(usage);
315
+ return value;
316
+ }
317
+
318
+ function safeNextFiring(cron: string, now: Date): string | null {
319
+ try {
320
+ return nextCronFiring(parseCron(cron), now).toISOString();
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Which local timer materializes the entries. Defaults by platform: launchd
328
+ * on macOS (crontab there is TCC-gated behind Full Disk Access — a permission
329
+ * no program can request), the user crontab everywhere else. `--timer` is the
330
+ * escape hatch, e.g. a macOS user who already granted FDA and prefers cron.
331
+ */
332
+ function resolveScheduleTimer(raw: string | undefined): "crontab" | "launchd" {
333
+ const timer = raw ?? (process.platform === "darwin" ? "launchd" : "crontab");
334
+ if (timer !== "crontab" && timer !== "launchd") {
335
+ throw new Error(`--timer must be crontab or launchd, got "${raw}".`);
336
+ }
337
+ if (timer === "launchd" && process.platform !== "darwin") {
338
+ throw new Error("--timer launchd is macOS-only; use --timer crontab on this platform.");
339
+ }
340
+ return timer;
341
+ }
342
+
343
+ /** The provider seam: local ships now; modal/aws arrive as scaffold generators. */
344
+ function requireLocalScheduleProvider(provider: string) {
345
+ if (provider === "local") return;
346
+ if (provider === "modal" || provider === "aws") {
347
+ throw new Error(
348
+ `Provider "${provider}" is not yet implemented — it will be a scaffold generator that emits a ` +
349
+ "deploy-ready artifact whose runner calls the same `schedule run <id>` contract. MVP provider: local.",
350
+ );
351
+ }
352
+ throw new Error(`Unknown provider "${provider}" — not yet implemented. MVP provider: local.`);
353
+ }
354
+
355
+ /**
356
+ * Resolve how cron should invoke this CLI: the current node binary plus the
357
+ * entry script (source checkouts need --experimental-strip-types), with
358
+ * FSGTM_HOME pinned when set so the cron environment sees the same store.
359
+ */
360
+ function scheduleCliInvocation(): string {
361
+ const script = process.argv[1] ? resolve(process.argv[1]) : null;
362
+ if (!script || !existsSync(script)) {
363
+ throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
364
+ }
365
+ // A newline/control char in any of these flows verbatim into the crontab
366
+ // executable line; single-quote escaping defends the shell, not cron's line
367
+ // parser. Refuse early with a clear message (renderManagedBlock re-checks).
368
+ for (const [name, value] of [
369
+ ["FSGTM_HOME", process.env.FSGTM_HOME],
370
+ ["the node executable path", process.execPath],
371
+ ["the CLI script path", script],
372
+ ] as const) {
373
+ if (value && hasControlChar(value)) {
374
+ throw new Error(`Cannot install schedules: ${name} contains a newline or control character.`);
375
+ }
376
+ }
377
+ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
378
+ const parts = [quote(process.execPath)];
379
+ if (script.endsWith(".ts")) parts.push("--experimental-strip-types");
380
+ parts.push(quote(script));
381
+ const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
382
+ // cron treats an unescaped `%` in the command field as a newline/stdin split.
383
+ // Escape it as `\%` so a stray `%` in a path can't truncate the managed line.
384
+ return (home + parts.join(" ")).replace(/%/g, "\\%");
385
+ }
386
+
387
+ /**
388
+ * The argv-array analog of scheduleCliInvocation for launchd ProgramArguments:
389
+ * same entry-point resolution, but no shell ever parses it, so no quoting or
390
+ * %-escaping — launchd execs the array directly. (FSGTM_HOME travels via the
391
+ * plist's EnvironmentVariables dict instead of an inline prefix.)
392
+ */
393
+ function scheduleCliArgv(): string[] {
394
+ const script = process.argv[1] ? resolve(process.argv[1]) : null;
395
+ if (!script || !existsSync(script)) {
396
+ throw new Error("Cannot resolve the fullstackgtm entry point for LaunchAgent plists (process.argv[1] is missing).");
397
+ }
398
+ const parts = [process.execPath];
399
+ if (script.endsWith(".ts")) parts.push("--experimental-strip-types");
400
+ parts.push(script);
401
+ return parts;
402
+ }
403
+
404
+ /**
405
+ * The single provider entry point: execute the scheduled command in-process
406
+ * (dispatch into the CLI router — never a shell), capture the output tail and
407
+ * exit code as a run record, and hand the exit code back to the caller. Cron
408
+ * calls it, cloud providers will call it, and a human running it manually
409
+ * produces the same record (trigger: manual).
410
+ *
411
+ * Governance re-checks happen here, not just at `add` time: the allowlist is
412
+ * re-validated (schedules.json is user-editable), and a scheduled apply
413
+ * hard-checks the plan is approved — an unapproved plan records a
414
+ * plan_not_approved no-op run and does NOT execute. Scheduling never
415
+ * auto-approves.
416
+ */
417
+ async function executeScheduledRun(
418
+ entry: ScheduleEntry,
419
+ trigger: ScheduleRunTrigger,
420
+ ): Promise<ScheduleRunRecord> {
421
+ const runStore = createFileScheduleRunStore();
422
+ const firedAt = new Date().toISOString();
423
+ const noop = async (
424
+ reason: NonNullable<ScheduleRunRecord["noopReason"]>,
425
+ exitCode: number,
426
+ message: string,
427
+ ): Promise<ScheduleRunRecord> => {
428
+ const run: ScheduleRunRecord = {
429
+ scheduleId: entry.id,
430
+ firedAt,
431
+ completedAt: new Date().toISOString(),
432
+ trigger,
433
+ exitCode,
434
+ outputTail: message,
435
+ artifacts: { planIds: [], runLabels: [] },
436
+ noopReason: reason,
437
+ };
438
+ await runStore.record(run);
439
+ console.error(message);
440
+ return run;
441
+ };
442
+
443
+ // launchd can trigger the same job twice in one minute when day-of-month
444
+ // and day-of-week are both restricted (Vixie OR compiles to two interval
445
+ // sets). Drop the duplicate here — the single entry point — so no timer
446
+ // quirk can double-run a command. Manual runs always run.
447
+ if (trigger === "cron" && isDuplicateCronFiring(await runStore.list(entry.id), firedAt)) {
448
+ return noop(
449
+ "duplicate_firing",
450
+ 0,
451
+ `A cron firing for ${entry.id} was already recorded in this minute — skipping the duplicate.`,
452
+ );
453
+ }
454
+
455
+ try {
456
+ validateSchedulableArgv(entry.argv);
457
+ } catch (error) {
458
+ return noop("not_schedulable", 1, error instanceof Error ? error.message : String(error));
459
+ }
460
+ if (entry.argv[0] === "apply") {
461
+ const planId = option(entry.argv.slice(1), "--plan-id")!;
462
+ const stored = await createFilePlanStore().get(planId);
463
+ const status = stored ? stored.status : "missing";
464
+ if (status !== "approved") {
465
+ // The governance gate holding is not a machinery failure: exit 0, with
466
+ // the refusal recorded on the run for `schedule status` to surface.
467
+ return noop(
468
+ "plan_not_approved",
469
+ 0,
470
+ `Plan ${planId} is ${status}, not approved — scheduled apply refuses to execute. ` +
471
+ `Scheduling never auto-approves; review and approve with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
472
+ );
473
+ }
474
+ }
475
+
476
+ // Artifact attribution by store diff: anything new in the plan store or the
477
+ // enrich run store after the command ran was produced by this firing.
478
+ const planStore = createFilePlanStore();
479
+ const plansBefore = new Set((await planStore.list()).map((stored) => stored.plan.id));
480
+ const enrichStore = createFileEnrichRunStore();
481
+ const enrichBefore = new Set((await enrichStore.list()).map((run) => run.runLabel));
482
+
483
+ const lines: string[] = [];
484
+ const originalLog = console.log;
485
+ const originalError = console.error;
486
+ const tee =
487
+ (original: (...args: unknown[]) => void) =>
488
+ (...args: unknown[]) => {
489
+ for (const line of args.map(String).join(" ").split("\n")) lines.push(line);
490
+ original(...args);
491
+ };
492
+ console.log = tee(originalLog);
493
+ console.error = tee(originalError);
494
+ const priorExitCode = process.exitCode;
495
+ process.exitCode = undefined;
496
+ let exitCode = 0;
497
+ try {
498
+ await runCli([...entry.argv]);
499
+ exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
500
+ } catch (error) {
501
+ exitCode = 1;
502
+ lines.push(error instanceof Error ? error.message : String(error));
503
+ } finally {
504
+ console.log = originalLog;
505
+ console.error = originalError;
506
+ process.exitCode = priorExitCode;
507
+ }
508
+
509
+ const planIds = (await planStore.list())
510
+ .map((stored) => stored.plan.id)
511
+ .filter((planId) => !plansBefore.has(planId));
512
+ const runLabels = (await enrichStore.list())
513
+ .map((run) => run.runLabel)
514
+ .filter((runLabel) => !enrichBefore.has(runLabel));
515
+ const run: ScheduleRunRecord = {
516
+ scheduleId: entry.id,
517
+ firedAt,
518
+ completedAt: new Date().toISOString(),
519
+ trigger,
520
+ exitCode,
521
+ outputTail: lines.slice(-50).join("\n"),
522
+ artifacts: { planIds, runLabels },
523
+ };
524
+ await runStore.record(run);
525
+ return run;
526
+ }