fullstackgtm 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/runReport.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* pure added value for users who granted it.
|
|
9
9
|
*/
|
|
10
10
|
import { getCredential } from "./credentials.ts";
|
|
11
|
+
import type { ProgressListener, ProgressSnapshot } from "./progress.ts";
|
|
11
12
|
|
|
12
13
|
type RunEvent = { ts: number; type: string; detail?: string };
|
|
13
14
|
|
|
@@ -61,6 +62,16 @@ export function reportEvent(type: string, detail?: string): void {
|
|
|
61
62
|
events.push({ ts: Date.now(), type, detail });
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// FNV-1a — the stable-id hash used across the package (see backfill.ts).
|
|
66
|
+
function fnv1a(value: string): string {
|
|
67
|
+
let hash = 0x811c9dc5;
|
|
68
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
69
|
+
hash ^= value.charCodeAt(i);
|
|
70
|
+
hash = Math.imul(hash, 0x01000193);
|
|
71
|
+
}
|
|
72
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
// Setup/inspection verbs aren't interesting as "runs" — skip the noise.
|
|
65
76
|
const SKIP_COMMANDS = new Set([
|
|
66
77
|
"login",
|
|
@@ -74,6 +85,144 @@ const SKIP_COMMANDS = new Set([
|
|
|
74
85
|
"-v",
|
|
75
86
|
]);
|
|
76
87
|
|
|
88
|
+
/** The stable per-invocation identity flushRunReport and heartbeats share. */
|
|
89
|
+
function runIdentity(
|
|
90
|
+
args: string[],
|
|
91
|
+
startedAt: number,
|
|
92
|
+
): { command: string; clientRunId: string } | null {
|
|
93
|
+
const command = args[0];
|
|
94
|
+
if (!command || SKIP_COMMANDS.has(command)) return null;
|
|
95
|
+
const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
|
|
96
|
+
const full = `${command}${sub}`;
|
|
97
|
+
return { command: full, clientRunId: `run_${fnv1a(`${full}:${startedAt}:${process.pid}`)}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Live progress streaming ─────────────────────────────────────────────────
|
|
101
|
+
// While a long command runs, a paired CLI streams heartbeats to the hosted
|
|
102
|
+
// app: POST /api/cli/run with the SAME clientRunId the final flushRunReport
|
|
103
|
+
// will use, status "in_progress", and the progress emitter's snapshot. The
|
|
104
|
+
// server upserts by clientRunId, so the terminal flush simply wins. Same
|
|
105
|
+
// invariants as flushRunReport: opt-in (paired only), fire-and-forget,
|
|
106
|
+
// time-capped, never affects the command's outcome or output.
|
|
107
|
+
|
|
108
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
109
|
+
/** No heartbeat before the run is ~this old — fast commands never stream. */
|
|
110
|
+
const HEARTBEAT_MIN_RUN_MS = 5000;
|
|
111
|
+
const HEARTBEAT_TIMEOUT_MS = 2000;
|
|
112
|
+
|
|
113
|
+
type RunContext = { command: string; clientRunId: string; startedAt: number };
|
|
114
|
+
|
|
115
|
+
let runContext: RunContext | null = null;
|
|
116
|
+
let lastHeartbeatAt = 0;
|
|
117
|
+
// Broker credential resolved once per run (undefined = not looked up yet;
|
|
118
|
+
// null = looked up, not paired).
|
|
119
|
+
let heartbeatBroker: { baseUrl: string; accessToken: string } | null | undefined;
|
|
120
|
+
let inflightHeartbeat: AbortController | null = null;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Arm heartbeat streaming for this invocation. Call once from the entry point
|
|
124
|
+
* (alongside capturing `startedAt`); without it, progress listeners from
|
|
125
|
+
* `progressReporter()` are inert. Safe to call for any command — skip-listed
|
|
126
|
+
* commands simply never stream.
|
|
127
|
+
*/
|
|
128
|
+
export function beginRunReport(args: string[], startedAt: number): void {
|
|
129
|
+
lastHeartbeatAt = 0;
|
|
130
|
+
heartbeatBroker = undefined;
|
|
131
|
+
inflightHeartbeat = null;
|
|
132
|
+
const identity = runIdentity(args, startedAt);
|
|
133
|
+
runContext = identity ? { ...identity, startedAt } : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A ProgressListener that streams the run's progress snapshot to the paired
|
|
138
|
+
* hosted app. Verbs compose it with their local renderer:
|
|
139
|
+
*
|
|
140
|
+
* createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
|
|
141
|
+
*
|
|
142
|
+
* Throttled to one POST per ~5s, first no earlier than ~5s into the run,
|
|
143
|
+
* 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
|
|
144
|
+
* must stay generic ("batch 4/12"); CRM field values never leave the machine.
|
|
145
|
+
*/
|
|
146
|
+
export function progressReporter(
|
|
147
|
+
overrides: {
|
|
148
|
+
now?: () => number;
|
|
149
|
+
intervalMs?: number;
|
|
150
|
+
minRunMs?: number;
|
|
151
|
+
fetchImpl?: typeof fetch;
|
|
152
|
+
} = {},
|
|
153
|
+
): ProgressListener {
|
|
154
|
+
const now = overrides.now ?? Date.now;
|
|
155
|
+
const intervalMs = overrides.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
156
|
+
const minRunMs = overrides.minRunMs ?? HEARTBEAT_MIN_RUN_MS;
|
|
157
|
+
const fetchImpl = overrides.fetchImpl ?? fetch;
|
|
158
|
+
return (_event, snapshot) => {
|
|
159
|
+
try {
|
|
160
|
+
if (!runContext) return;
|
|
161
|
+
const at = now();
|
|
162
|
+
if (at - runContext.startedAt < minRunMs) return;
|
|
163
|
+
if (at - lastHeartbeatAt < intervalMs) return;
|
|
164
|
+
if (heartbeatBroker === undefined) {
|
|
165
|
+
const broker = getCredential("broker");
|
|
166
|
+
heartbeatBroker =
|
|
167
|
+
broker?.baseUrl && broker.accessToken
|
|
168
|
+
? { baseUrl: broker.baseUrl.replace(/\/+$/, ""), accessToken: broker.accessToken }
|
|
169
|
+
: null; // opt-in: only when paired
|
|
170
|
+
}
|
|
171
|
+
if (!heartbeatBroker) return;
|
|
172
|
+
lastHeartbeatAt = at;
|
|
173
|
+
sendHeartbeat(fetchImpl, heartbeatBroker, runContext, snapshot, at);
|
|
174
|
+
} catch {
|
|
175
|
+
// Observability is best-effort; never affect the command.
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sendHeartbeat(
|
|
181
|
+
fetchImpl: typeof fetch,
|
|
182
|
+
broker: { baseUrl: string; accessToken: string },
|
|
183
|
+
context: RunContext,
|
|
184
|
+
snapshot: ProgressSnapshot,
|
|
185
|
+
at: number,
|
|
186
|
+
): void {
|
|
187
|
+
// One heartbeat in flight at a time: a stalled POST is superseded, and the
|
|
188
|
+
// terminal flush aborts any straggler so it can never delay process exit.
|
|
189
|
+
inflightHeartbeat?.abort();
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
inflightHeartbeat = controller;
|
|
192
|
+
const timer = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
|
|
193
|
+
(timer as { unref?: () => void }).unref?.();
|
|
194
|
+
void fetchImpl(`${broker.baseUrl}/api/cli/run`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
command: context.command,
|
|
199
|
+
clientRunId: context.clientRunId,
|
|
200
|
+
status: "in_progress",
|
|
201
|
+
startedAt: context.startedAt,
|
|
202
|
+
finishedAt: at,
|
|
203
|
+
durationMs: at - context.startedAt,
|
|
204
|
+
progress: {
|
|
205
|
+
// Stage names + counts only — never CRM field values.
|
|
206
|
+
stage: snapshot.stage,
|
|
207
|
+
stageIndex: snapshot.stageIndex,
|
|
208
|
+
stageCount: snapshot.stageCount,
|
|
209
|
+
itemsDone: snapshot.itemsDone,
|
|
210
|
+
itemsTotal: snapshot.itemsTotal,
|
|
211
|
+
note: snapshot.note,
|
|
212
|
+
heartbeatAt: at,
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
signal: controller.signal,
|
|
216
|
+
})
|
|
217
|
+
.catch(() => {
|
|
218
|
+
// fire-and-forget
|
|
219
|
+
})
|
|
220
|
+
.finally(() => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
if (inflightHeartbeat === controller) inflightHeartbeat = null;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
77
226
|
/**
|
|
78
227
|
* Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
|
|
79
228
|
* swallows every error. Call once per process from the entry point.
|
|
@@ -84,19 +233,25 @@ export async function flushRunReport(
|
|
|
84
233
|
startedAt: number,
|
|
85
234
|
error?: string,
|
|
86
235
|
): Promise<void> {
|
|
87
|
-
|
|
88
|
-
|
|
236
|
+
// The terminal upsert supersedes any streaming heartbeat still in flight.
|
|
237
|
+
inflightHeartbeat?.abort();
|
|
238
|
+
inflightHeartbeat = null;
|
|
239
|
+
// Stable per-invocation identity: the server dedupes on clientRunId, so a
|
|
240
|
+
// retried report, an "in_progress" heartbeat, or a later `backfill runs`
|
|
241
|
+
// replay of local history never duplicates.
|
|
242
|
+
const identity = runIdentity(args, startedAt);
|
|
243
|
+
if (!identity) return;
|
|
89
244
|
const broker = getCredential("broker");
|
|
90
245
|
if (!broker?.baseUrl || !broker.accessToken) return; // opt-in: only when paired
|
|
91
246
|
|
|
92
|
-
const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
|
|
93
247
|
const finishedAt = Date.now();
|
|
94
248
|
try {
|
|
95
249
|
await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
|
|
96
250
|
method: "POST",
|
|
97
251
|
headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
|
|
98
252
|
body: JSON.stringify({
|
|
99
|
-
command:
|
|
253
|
+
command: identity.command,
|
|
254
|
+
clientRunId: identity.clientRunId,
|
|
100
255
|
status,
|
|
101
256
|
startedAt,
|
|
102
257
|
finishedAt,
|
package/src/schedule.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
5
6
|
|
|
@@ -49,9 +50,11 @@ export type ScheduleRunRecord = {
|
|
|
49
50
|
* recorded for visibility but nothing ran. Reasons: "plan_not_approved"
|
|
50
51
|
* (scheduled apply against a plan that is not approved — there is no flag
|
|
51
52
|
* that relaxes this), "not_schedulable" (the stored argv no longer passes
|
|
52
|
-
* the allowlist, e.g. after a hand edit of schedules.json)
|
|
53
|
+
* the allowlist, e.g. after a hand edit of schedules.json),
|
|
54
|
+
* "duplicate_firing" (a cron-triggered run already recorded in this minute
|
|
55
|
+
* — launchd can double-trigger the Vixie dom+dow OR corner).
|
|
53
56
|
*/
|
|
54
|
-
noopReason?: "plan_not_approved" | "not_schedulable";
|
|
57
|
+
noopReason?: "plan_not_approved" | "not_schedulable" | "duplicate_firing";
|
|
55
58
|
};
|
|
56
59
|
|
|
57
60
|
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep schedule.ts
|
|
@@ -729,3 +732,307 @@ export function replaceManagedBlock(existing: string, profile: string, block: st
|
|
|
729
732
|
if (merged.length === 0) return "";
|
|
730
733
|
return `${merged.join("\n")}\n`;
|
|
731
734
|
}
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
// Local provider: launchd LaunchAgents (macOS)
|
|
738
|
+
//
|
|
739
|
+
// On macOS `crontab -` writes to the TCC-protected /var/at/tabs, which
|
|
740
|
+
// requires granting Full Disk Access to the *terminal app* — a permission no
|
|
741
|
+
// program can request and a huge over-grant for one timer line. Per-user
|
|
742
|
+
// LaunchAgents need no permission at all: one plist per enabled entry in
|
|
743
|
+
// ~/Library/LaunchAgents, loaded with `launchctl bootstrap gui/<uid>`. Bonus
|
|
744
|
+
// over cron: launchd coalesces firings missed while asleep into one run on
|
|
745
|
+
// wake, where cron silently skips. Same audit story as the crontab block —
|
|
746
|
+
// every plist's ProgramArguments is `... schedule run <id>` and nothing else,
|
|
747
|
+
// and install replaces the com.fullstackgtm.<profile>.* fleet wholesale.
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* One launchd StartCalendarInterval dict: every present key must match (AND),
|
|
751
|
+
* a missing key is a wildcard, and an array of dicts fires when ANY dict
|
|
752
|
+
* matches. Weekday 0 is Sunday, as in cron.
|
|
753
|
+
*/
|
|
754
|
+
export type LaunchdCalendarInterval = {
|
|
755
|
+
Minute?: number;
|
|
756
|
+
Hour?: number;
|
|
757
|
+
Day?: number;
|
|
758
|
+
Weekday?: number;
|
|
759
|
+
Month?: number;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Compile a cron expression to StartCalendarInterval dicts. Full-range fields
|
|
764
|
+
* (`*`, but also spellings like `0-59`) are omitted (launchd wildcard); the
|
|
765
|
+
* rest cross-product, capped so a dense expression cannot render a megabyte
|
|
766
|
+
* plist. Vixie day semantics: when day-of-month AND day-of-week are both
|
|
767
|
+
* restricted, either may match — launchd ANDs keys within one dict, so that
|
|
768
|
+
* becomes the union of a Day dict set and a Weekday dict set. A date matching
|
|
769
|
+
* both sets makes launchd trigger twice in the same minute; the run entry
|
|
770
|
+
* point drops the second via isDuplicateCronFiring.
|
|
771
|
+
*/
|
|
772
|
+
export function cronToCalendarIntervals(cron: CronExpression, cap = 512): LaunchdCalendarInterval[] {
|
|
773
|
+
const full = (values: number[], min: number, max: number) => values.length === max - min + 1;
|
|
774
|
+
const minutes: Array<number | undefined> = full(cron.minute, 0, 59) ? [undefined] : cron.minute;
|
|
775
|
+
const hours: Array<number | undefined> = full(cron.hour, 0, 23) ? [undefined] : cron.hour;
|
|
776
|
+
const months: Array<number | undefined> = full(cron.month, 1, 12) ? [undefined] : cron.month;
|
|
777
|
+
const domRestricted = cron.dayOfMonthRestricted && !full(cron.dayOfMonth, 1, 31);
|
|
778
|
+
const dowRestricted = cron.dayOfWeekRestricted && !full(cron.dayOfWeek, 0, 6);
|
|
779
|
+
const daySlots: Array<Pick<LaunchdCalendarInterval, "Day" | "Weekday">> = [];
|
|
780
|
+
if (domRestricted) for (const day of cron.dayOfMonth) daySlots.push({ Day: day });
|
|
781
|
+
if (dowRestricted) for (const weekday of cron.dayOfWeek) daySlots.push({ Weekday: weekday });
|
|
782
|
+
if (daySlots.length === 0) daySlots.push({});
|
|
783
|
+
const count = minutes.length * hours.length * daySlots.length * months.length;
|
|
784
|
+
if (count > cap) {
|
|
785
|
+
throw new Error(
|
|
786
|
+
`Cron expression "${cron.source}" expands to ${count} launchd calendar intervals (cap ${cap}). ` +
|
|
787
|
+
"Simplify the expression or split it into separate schedules.",
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
const intervals: LaunchdCalendarInterval[] = [];
|
|
791
|
+
for (const month of months) {
|
|
792
|
+
for (const daySlot of daySlots) {
|
|
793
|
+
for (const hour of hours) {
|
|
794
|
+
for (const minute of minutes) {
|
|
795
|
+
const dict: LaunchdCalendarInterval = {};
|
|
796
|
+
if (minute !== undefined) dict.Minute = minute;
|
|
797
|
+
if (hour !== undefined) dict.Hour = hour;
|
|
798
|
+
if (daySlot.Day !== undefined) dict.Day = daySlot.Day;
|
|
799
|
+
if (daySlot.Weekday !== undefined) dict.Weekday = daySlot.Weekday;
|
|
800
|
+
if (month !== undefined) dict.Month = month;
|
|
801
|
+
intervals.push(dict);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return intervals;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* True when a cron-triggered run was already recorded in `firedAt`'s minute.
|
|
811
|
+
* The `schedule run` entry point checks this for trigger:cron so a launchd
|
|
812
|
+
* double-trigger (dom+dow OR corner above) records a duplicate_firing no-op
|
|
813
|
+
* instead of running the command twice. Manual runs are never deduplicated.
|
|
814
|
+
*/
|
|
815
|
+
export function isDuplicateCronFiring(runs: ScheduleRunRecord[], firedAt: string): boolean {
|
|
816
|
+
const minute = new Date(firedAt);
|
|
817
|
+
minute.setSeconds(0, 0);
|
|
818
|
+
return runs.some((run) => {
|
|
819
|
+
if (run.trigger !== "cron") return false;
|
|
820
|
+
const fired = new Date(run.firedAt);
|
|
821
|
+
fired.setSeconds(0, 0);
|
|
822
|
+
return fired.getTime() === minute.getTime();
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** Reverse-DNS namespace for one profile's agents; install/uninstall own this prefix wholesale. */
|
|
827
|
+
export function launchdAgentPrefix(profile: string): string {
|
|
828
|
+
if (!/^[A-Za-z0-9._-]+$/.test(profile)) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`Cannot build a launchd label from profile "${profile}" — profile names in LaunchAgent labels ` +
|
|
831
|
+
"must be alphanumeric with dot/dash/underscore.",
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
return `com.fullstackgtm.${profile}.`;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function launchdLabel(profile: string, scheduleEntryId: string): string {
|
|
838
|
+
if (!/^[A-Za-z0-9._-]+$/.test(scheduleEntryId)) {
|
|
839
|
+
throw new Error(`Cannot build a launchd label from schedule id "${scheduleEntryId}".`);
|
|
840
|
+
}
|
|
841
|
+
return `${launchdAgentPrefix(profile)}${scheduleEntryId}`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function xmlEscape(value: string): string {
|
|
845
|
+
return value.replace(/[&<>"']/g, (char) => {
|
|
846
|
+
switch (char) {
|
|
847
|
+
case "&":
|
|
848
|
+
return "&";
|
|
849
|
+
case "<":
|
|
850
|
+
return "<";
|
|
851
|
+
case ">":
|
|
852
|
+
return ">";
|
|
853
|
+
case '"':
|
|
854
|
+
return """;
|
|
855
|
+
default:
|
|
856
|
+
return "'";
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Render one LaunchAgent plist. ProgramArguments is an argv array — launchd
|
|
863
|
+
* execs it directly, no shell, so there is no quoting/injection surface at
|
|
864
|
+
* all (values are XML-escaped for the document, nothing more).
|
|
865
|
+
*/
|
|
866
|
+
export function renderLaunchdPlist(options: {
|
|
867
|
+
label: string;
|
|
868
|
+
programArguments: string[];
|
|
869
|
+
calendarIntervals: LaunchdCalendarInterval[];
|
|
870
|
+
environment?: Record<string, string>;
|
|
871
|
+
standardOutPath?: string;
|
|
872
|
+
standardErrorPath?: string;
|
|
873
|
+
}): string {
|
|
874
|
+
const lines = [
|
|
875
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
876
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
877
|
+
'<plist version="1.0">',
|
|
878
|
+
"<dict>",
|
|
879
|
+
"\t<key>Label</key>",
|
|
880
|
+
`\t<string>${xmlEscape(options.label)}</string>`,
|
|
881
|
+
"\t<key>ProgramArguments</key>",
|
|
882
|
+
"\t<array>",
|
|
883
|
+
...options.programArguments.map((arg) => `\t\t<string>${xmlEscape(arg)}</string>`),
|
|
884
|
+
"\t</array>",
|
|
885
|
+
"\t<key>StartCalendarInterval</key>",
|
|
886
|
+
"\t<array>",
|
|
887
|
+
];
|
|
888
|
+
const keyOrder = ["Minute", "Hour", "Day", "Weekday", "Month"] as const;
|
|
889
|
+
for (const interval of options.calendarIntervals) {
|
|
890
|
+
lines.push("\t\t<dict>");
|
|
891
|
+
for (const key of keyOrder) {
|
|
892
|
+
const value = interval[key];
|
|
893
|
+
if (value === undefined) continue;
|
|
894
|
+
lines.push(`\t\t\t<key>${key}</key>`, `\t\t\t<integer>${value}</integer>`);
|
|
895
|
+
}
|
|
896
|
+
lines.push("\t\t</dict>");
|
|
897
|
+
}
|
|
898
|
+
lines.push("\t</array>");
|
|
899
|
+
if (options.environment && Object.keys(options.environment).length > 0) {
|
|
900
|
+
lines.push("\t<key>EnvironmentVariables</key>", "\t<dict>");
|
|
901
|
+
for (const [name, value] of Object.entries(options.environment)) {
|
|
902
|
+
lines.push(`\t\t<key>${xmlEscape(name)}</key>`, `\t\t<string>${xmlEscape(value)}</string>`);
|
|
903
|
+
}
|
|
904
|
+
lines.push("\t</dict>");
|
|
905
|
+
}
|
|
906
|
+
if (options.standardOutPath) {
|
|
907
|
+
lines.push("\t<key>StandardOutPath</key>", `\t<string>${xmlEscape(options.standardOutPath)}</string>`);
|
|
908
|
+
}
|
|
909
|
+
if (options.standardErrorPath) {
|
|
910
|
+
lines.push("\t<key>StandardErrorPath</key>", `\t<string>${xmlEscape(options.standardErrorPath)}</string>`);
|
|
911
|
+
}
|
|
912
|
+
lines.push("</dict>", "</plist>");
|
|
913
|
+
return `${lines.join("\n")}\n`;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export type LaunchdIo = {
|
|
917
|
+
/** Plist file names currently in the agents directory. */
|
|
918
|
+
list(): string[];
|
|
919
|
+
write(fileName: string, content: string): void;
|
|
920
|
+
remove(fileName: string): void;
|
|
921
|
+
/** Load one agent plist into the user's gui domain; throws on failure. */
|
|
922
|
+
bootstrap(fileName: string): void;
|
|
923
|
+
/** Best-effort unload by label; a label that is not loaded is not an error. */
|
|
924
|
+
bootout(label: string): void;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* The real ~/Library/LaunchAgents + launchctl. Everything above takes a
|
|
929
|
+
* LaunchdIo so tests inject fakes and never touch the user's agents.
|
|
930
|
+
*/
|
|
931
|
+
export function systemLaunchdIo(agentsDirOverride?: string): LaunchdIo {
|
|
932
|
+
const agentsDir = agentsDirOverride ?? join(homedir(), "Library", "LaunchAgents");
|
|
933
|
+
const domain = () => {
|
|
934
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
935
|
+
if (uid === null) throw new Error("launchd scheduling requires a POSIX user id (macOS only).");
|
|
936
|
+
return `gui/${uid}`;
|
|
937
|
+
};
|
|
938
|
+
const launchctl = (args: string[]) => {
|
|
939
|
+
const result = spawnSync("launchctl", args, { encoding: "utf8" });
|
|
940
|
+
if (result.error) throw new Error(`Cannot run \`launchctl\`: ${result.error.message}`);
|
|
941
|
+
return result;
|
|
942
|
+
};
|
|
943
|
+
return {
|
|
944
|
+
list() {
|
|
945
|
+
try {
|
|
946
|
+
return readdirSync(agentsDir).filter((name) => name.endsWith(".plist"));
|
|
947
|
+
} catch {
|
|
948
|
+
return [];
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
write(fileName, content) {
|
|
952
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
953
|
+
// 0644 like every LaunchAgent: launchd refuses group/world-WRITABLE
|
|
954
|
+
// plists, and this file holds a dispatch line, not a secret.
|
|
955
|
+
writeFileSync(join(agentsDir, fileName), content, { mode: 0o644 });
|
|
956
|
+
},
|
|
957
|
+
remove(fileName) {
|
|
958
|
+
rmSync(join(agentsDir, fileName), { force: true });
|
|
959
|
+
},
|
|
960
|
+
bootstrap(fileName) {
|
|
961
|
+
const result = launchctl(["bootstrap", domain(), join(agentsDir, fileName)]);
|
|
962
|
+
if (result.status !== 0) {
|
|
963
|
+
throw new Error(
|
|
964
|
+
`\`launchctl bootstrap\` failed for ${fileName}: ${(result.stderr ?? "").trim() || `exit ${result.status}`}`,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
bootout(label) {
|
|
969
|
+
launchctl(["bootout", `${domain()}/${label}`]);
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Materialize enabled entries as one LaunchAgent each, replacing the
|
|
976
|
+
* profile's com.fullstackgtm.<profile>.* fleet wholesale (stale agents are
|
|
977
|
+
* booted out and deleted; plists outside the prefix are never touched).
|
|
978
|
+
* `cliArgv` is the argv-array analog of the crontab invocation line.
|
|
979
|
+
*/
|
|
980
|
+
export function installLaunchdAgents(
|
|
981
|
+
profile: string,
|
|
982
|
+
entries: ScheduleEntry[],
|
|
983
|
+
cliArgv: string[],
|
|
984
|
+
io: LaunchdIo,
|
|
985
|
+
options: { environment?: Record<string, string>; logDir?: string } = {},
|
|
986
|
+
): { installed: string[]; removed: string[] } {
|
|
987
|
+
for (const token of cliArgv) {
|
|
988
|
+
if (hasControlChar(token)) {
|
|
989
|
+
throw new Error(
|
|
990
|
+
"Refusing to render LaunchAgents: the resolved CLI invocation (node path or script path) " +
|
|
991
|
+
"contains a newline or control character.",
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const removed = uninstallLaunchdAgents(profile, io);
|
|
996
|
+
const installed: string[] = [];
|
|
997
|
+
for (const entry of entries) {
|
|
998
|
+
assertRenderableEntry(profile, entry);
|
|
999
|
+
let intervals: LaunchdCalendarInterval[];
|
|
1000
|
+
try {
|
|
1001
|
+
intervals = cronToCalendarIntervals(parseCron(entry.cron));
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
throw new Error(
|
|
1004
|
+
`Schedule ${entry.id} ("${entry.label}") cannot materialize as a LaunchAgent: ` +
|
|
1005
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
const label = launchdLabel(profile, entry.id);
|
|
1009
|
+
const logPath = options.logDir ? join(options.logDir, `${label}.log`) : undefined;
|
|
1010
|
+
const plist = renderLaunchdPlist({
|
|
1011
|
+
label,
|
|
1012
|
+
programArguments: [...cliArgv, "schedule", "run", entry.id, "--profile", profile, "--trigger", "cron"],
|
|
1013
|
+
calendarIntervals: intervals,
|
|
1014
|
+
environment: options.environment,
|
|
1015
|
+
standardOutPath: logPath,
|
|
1016
|
+
standardErrorPath: logPath,
|
|
1017
|
+
});
|
|
1018
|
+
const fileName = `${label}.plist`;
|
|
1019
|
+
io.write(fileName, plist);
|
|
1020
|
+
io.bootstrap(fileName);
|
|
1021
|
+
installed.push(label);
|
|
1022
|
+
}
|
|
1023
|
+
return { installed, removed };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Boot out and delete every agent in the profile's prefix; returns the removed labels. */
|
|
1027
|
+
export function uninstallLaunchdAgents(profile: string, io: LaunchdIo): string[] {
|
|
1028
|
+
const prefix = launchdAgentPrefix(profile);
|
|
1029
|
+
const removed: string[] = [];
|
|
1030
|
+
for (const name of io.list()) {
|
|
1031
|
+
if (!name.startsWith(prefix)) continue;
|
|
1032
|
+
const label = name.slice(0, -".plist".length);
|
|
1033
|
+
io.bootout(label);
|
|
1034
|
+
io.remove(name);
|
|
1035
|
+
removed.push(label);
|
|
1036
|
+
}
|
|
1037
|
+
return removed;
|
|
1038
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared reader for the spool container convention (docs/signal-spool-format.md):
|
|
3
|
+
* newline-delimited JSON — one object per line, append-only — with a JSON array
|
|
4
|
+
* accepted in a `.json` file for hand-staged convenience, and a DIRECTORY
|
|
5
|
+
* meaning "every `*.jsonl` / `*.json` file in it, name-sorted" so multiple
|
|
6
|
+
* receivers can each append their own file.
|
|
7
|
+
*
|
|
8
|
+
* The container is the convention; the row SCHEMA stays with each caller:
|
|
9
|
+
* `signals fetch --from` validates staged signal rows, `enrich ingest` stages
|
|
10
|
+
* enrichment rows, `market observe --from` validates observation-set envelopes.
|
|
11
|
+
* This module only parses objects out of files — it never interprets them.
|
|
12
|
+
*
|
|
13
|
+
* Extracted from the `file` signal-source connector (the Phase-2 webhook
|
|
14
|
+
* landing-zone reader), which now delegates here; error-message text is
|
|
15
|
+
* parameterized by `label` so existing connector errors are unchanged.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Spool files in a landing-zone directory: `*.jsonl` / `*.json`, name-sorted. */
|
|
22
|
+
export function spoolFilesIn(dir: string): string[] {
|
|
23
|
+
let names: string[];
|
|
24
|
+
try {
|
|
25
|
+
names = readdirSync(dir);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return names
|
|
30
|
+
.filter((name) => name.endsWith(".jsonl") || name.endsWith(".json"))
|
|
31
|
+
.sort()
|
|
32
|
+
.map((name) => join(dir, name));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parse a JSON-array spool file into rows. `label` prefixes error messages. */
|
|
36
|
+
export function parseSpoolArray(raw: string, path: string, label: string): Record<string, unknown>[] {
|
|
37
|
+
let parsed: unknown;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(raw);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`${label} ${path}: not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(parsed)) throw new Error(`${label} ${path}: expected a JSON array of staged signal rows.`);
|
|
44
|
+
return parsed.map((entry, index) => {
|
|
45
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
46
|
+
throw new Error(`${label} ${path}: row ${index} is not an object.`);
|
|
47
|
+
}
|
|
48
|
+
return entry as Record<string, unknown>;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse a JSONL spool file into rows. `label` prefixes error messages. */
|
|
53
|
+
export function parseSpoolJsonl(raw: string, path: string, label: string): Record<string, unknown>[] {
|
|
54
|
+
const out: Record<string, unknown>[] = [];
|
|
55
|
+
const lines = raw.split("\n");
|
|
56
|
+
lines.forEach((line, index) => {
|
|
57
|
+
const t = line.trim();
|
|
58
|
+
if (!t) return;
|
|
59
|
+
let parsed: unknown;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(t);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(`${label} ${path}: line ${index} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
|
|
64
|
+
}
|
|
65
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
66
|
+
throw new Error(`${label} ${path}: line ${index} is not a JSON object.`);
|
|
67
|
+
}
|
|
68
|
+
out.push(parsed as Record<string, unknown>);
|
|
69
|
+
});
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parse one spool file's text — JSON array if it opens with `[`, else JSONL. */
|
|
74
|
+
export function parseSpoolText(raw: string, path: string, label: string): Record<string, unknown>[] {
|
|
75
|
+
const trimmed = raw.trim();
|
|
76
|
+
if (!trimmed) return [];
|
|
77
|
+
return trimmed.startsWith("[") ? parseSpoolArray(trimmed, path, label) : parseSpoolJsonl(trimmed, path, label);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Should an explicitly named intake path be read with spool semantics?
|
|
82
|
+
* True for a directory (a landing zone) or a `*.jsonl` file. Plain `.json` /
|
|
83
|
+
* `.csv` files keep each verb's original single-file handling, so existing
|
|
84
|
+
* invocations parse exactly as before.
|
|
85
|
+
*/
|
|
86
|
+
export function isSpoolPath(absPath: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
if (statSync(absPath).isDirectory()) return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return absPath.endsWith(".jsonl");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A parsed spool row plus where it came from (for error labels). */
|
|
96
|
+
export type SpoolRow = { file: string; index: number; row: Record<string, unknown> };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Read an explicitly named spool path — a `.jsonl` file or a directory of
|
|
100
|
+
* `*.jsonl` / `*.json` files. Unlike the webhook `file` connector (where a
|
|
101
|
+
* missing spool is an empty source), the user named this path: a missing or
|
|
102
|
+
* unreadable file is an error, and a malformed row throws with file + line.
|
|
103
|
+
*/
|
|
104
|
+
export function readSpoolPath(absPath: string, label: string): SpoolRow[] {
|
|
105
|
+
const isDir = statSync(absPath).isDirectory();
|
|
106
|
+
const files = isDir ? spoolFilesIn(absPath) : [absPath];
|
|
107
|
+
if (isDir && files.length === 0) {
|
|
108
|
+
throw new Error(`${label} ${absPath}: directory contains no *.jsonl / *.json spool files.`);
|
|
109
|
+
}
|
|
110
|
+
const rows: SpoolRow[] = [];
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const raw = readFileSync(file, "utf8");
|
|
113
|
+
parseSpoolText(raw, file, label).forEach((row, index) => rows.push({ file, index, row }));
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
}
|