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