fullstackgtm 0.44.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.
- package/CHANGELOG.md +185 -1
- package/README.md +19 -7
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +80 -0
- package/dist/cli/auth.js +500 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +373 -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 +788 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +344 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +609 -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 +454 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +70 -0
- package/dist/cli/shared.js +331 -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 +121 -0
- package/dist/cli/ui.js +375 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +100 -5130
- package/dist/connector.d.ts +15 -0
- package/dist/connector.js +35 -0
- package/dist/connectors/hubspot.d.ts +3 -1
- package/dist/connectors/hubspot.js +10 -3
- package/dist/connectors/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.js +7 -59
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/marketClassify.d.ts +2 -0
- package/dist/marketClassify.js +7 -1
- package/dist/mcp-bin.js +2 -2
- package/dist/mcp.js +259 -166
- 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 +11 -0
- package/docs/api.md +73 -7
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +15 -6
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- package/src/audit.ts +27 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +549 -0
- package/src/cli/call.ts +398 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +885 -0
- package/src/cli/fix.ts +372 -0
- package/src/cli/help.ts +664 -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 +523 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +375 -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 +426 -0
- package/src/cli.ts +99 -5829
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +7 -56
- package/src/icp.ts +19 -35
- package/src/judge.ts +7 -0
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +12 -0
package/src/mcp.ts
CHANGED
|
@@ -38,6 +38,8 @@ const { StdioServerTransport } = await importPeer<typeof import("@modelcontextpr
|
|
|
38
38
|
);
|
|
39
39
|
const { z } = await importPeer<typeof import("zod/v4")>("zod/v4");
|
|
40
40
|
import { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
41
|
+
import { assertCanonicalSnapshot } from "./cli/shared.ts";
|
|
42
|
+
import { nearest } from "./cli/suggest.ts";
|
|
41
43
|
import { loadConfig, mergePolicy, resolveConfiguredRules } from "./config.ts";
|
|
42
44
|
import { applyPatchPlan } from "./connector.ts";
|
|
43
45
|
import { createHubspotConnector } from "./connectors/hubspot.ts";
|
|
@@ -123,8 +125,10 @@ async function connectorFor(provider: string): Promise<GtmConnector> {
|
|
|
123
125
|
}
|
|
124
126
|
return createStripeConnector({ getApiKey: () => key });
|
|
125
127
|
}
|
|
128
|
+
const providerSuggestion = nearest(provider.toLowerCase(), ["hubspot", "salesforce", "stripe"], 3);
|
|
126
129
|
throw new Error(
|
|
127
|
-
`Unknown provider: ${provider}
|
|
130
|
+
`Unknown provider: ${provider}.${providerSuggestion ? ` Did you mean ${providerSuggestion}?` : ""} ` +
|
|
131
|
+
"Supported providers: hubspot, salesforce, stripe",
|
|
128
132
|
);
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -138,9 +142,10 @@ async function readSnapshot(
|
|
|
138
142
|
return connector.fetchSnapshot();
|
|
139
143
|
}
|
|
140
144
|
if (!inputPath) return sampleSnapshot;
|
|
141
|
-
return
|
|
142
|
-
readFileSync(resolve(process.cwd(), inputPath), "utf8"),
|
|
143
|
-
|
|
145
|
+
return assertCanonicalSnapshot(
|
|
146
|
+
JSON.parse(readFileSync(resolve(process.cwd(), inputPath), "utf8")),
|
|
147
|
+
inputPath,
|
|
148
|
+
);
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
function packageVersion() {
|
|
@@ -152,15 +157,29 @@ function packageVersion() {
|
|
|
152
157
|
}
|
|
153
158
|
}
|
|
154
159
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
type ToolDefinition = {
|
|
161
|
+
name: string;
|
|
162
|
+
// Whether the tool can write to a CRM. Everything else reads, or writes
|
|
163
|
+
// only local workspace state (market_observe appends to the local
|
|
164
|
+
// observation store).
|
|
165
|
+
writesCrm: boolean;
|
|
166
|
+
config: { title: string; description: string; inputSchema?: Record<string, unknown> };
|
|
167
|
+
// Args are validated by the SDK against config.inputSchema before the
|
|
168
|
+
// handler runs; the loose typing here is what lets one table drive a
|
|
169
|
+
// registration loop instead of per-call generics.
|
|
170
|
+
handler: (args: any) => Promise<ReturnType<typeof content>> | ReturnType<typeof content>;
|
|
171
|
+
};
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
// Single registration table. startMcpServer() registers exactly this list,
|
|
174
|
+
// and fullstackgtm_capabilities reports its names from the same array — the
|
|
175
|
+
// advertised inventory cannot drift from what is actually registered.
|
|
176
|
+
// (A hand-written parallel tool list was a core defect of the first pass at
|
|
177
|
+
// a capabilities tool, which reported 4 of the 8 registered tools.)
|
|
178
|
+
const toolDefinitions: ToolDefinition[] = [
|
|
179
|
+
{
|
|
180
|
+
name: "fullstackgtm_audit",
|
|
181
|
+
writesCrm: false,
|
|
182
|
+
config: {
|
|
164
183
|
title: "GTM Ops Audit",
|
|
165
184
|
description:
|
|
166
185
|
"Run a dry-run GTM hygiene audit and return a reviewable patch plan. " +
|
|
@@ -176,23 +195,23 @@ export async function startMcpServer() {
|
|
|
176
195
|
staleDealDays: z.number().int().positive().optional(),
|
|
177
196
|
},
|
|
178
197
|
},
|
|
179
|
-
async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
|
|
198
|
+
handler: async ({ provider, inputPath, configPath, rules, output, today, staleDealDays }) => {
|
|
180
199
|
const loaded = configPath ? loadConfig(configPath) : null;
|
|
181
200
|
const policy = mergePolicy(defaultPolicy(today), loaded?.config);
|
|
182
201
|
if (today) policy.today = today;
|
|
183
202
|
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
184
203
|
const ruleSet = await resolveConfiguredRules(loaded);
|
|
185
204
|
const selected = rules?.length
|
|
186
|
-
? ruleSet.filter((rule) => rules.includes(rule.id))
|
|
205
|
+
? ruleSet.filter((rule: { id: string }) => rules.includes(rule.id))
|
|
187
206
|
: ruleSet;
|
|
188
207
|
const plan = auditSnapshot(await readSnapshot(provider, inputPath), policy, selected);
|
|
189
208
|
return content(output === "markdown" ? patchPlanToMarkdown(plan) : plan);
|
|
190
209
|
},
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
{
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "fullstackgtm_suggest",
|
|
213
|
+
writesCrm: false,
|
|
214
|
+
config: {
|
|
196
215
|
title: "Suggest Placeholder Values",
|
|
197
216
|
description:
|
|
198
217
|
"Derive values for a plan's requires_human_* placeholder operations from snapshot " +
|
|
@@ -204,16 +223,16 @@ export async function startMcpServer() {
|
|
|
204
223
|
inputPath: z.string().optional(),
|
|
205
224
|
},
|
|
206
225
|
},
|
|
207
|
-
async ({ planPath, provider, inputPath }) => {
|
|
226
|
+
handler: async ({ planPath, provider, inputPath }) => {
|
|
208
227
|
const plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
209
228
|
const snapshot = await readSnapshot(provider, inputPath);
|
|
210
229
|
return content({ suggestions: suggestValues(plan, snapshot) });
|
|
211
230
|
},
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
{
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "fullstackgtm_call_parse",
|
|
234
|
+
writesCrm: false,
|
|
235
|
+
config: {
|
|
217
236
|
title: "Parse Call Transcript",
|
|
218
237
|
description:
|
|
219
238
|
"Parse a call transcript (Speaker:/[Speaker]: lines or Granola utterance JSON) into " +
|
|
@@ -230,7 +249,7 @@ export async function startMcpServer() {
|
|
|
230
249
|
model: z.string().optional(),
|
|
231
250
|
},
|
|
232
251
|
},
|
|
233
|
-
async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
252
|
+
handler: async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
234
253
|
const raw =
|
|
235
254
|
transcript ??
|
|
236
255
|
(transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
|
|
@@ -255,11 +274,11 @@ export async function startMcpServer() {
|
|
|
255
274
|
}
|
|
256
275
|
return content(parseCall(raw, { title, sourceSystem: source }));
|
|
257
276
|
},
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
{
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: "fullstackgtm_resolve",
|
|
280
|
+
writesCrm: false,
|
|
281
|
+
config: {
|
|
263
282
|
title: "Resolve Record (create gate)",
|
|
264
283
|
description:
|
|
265
284
|
"Before creating a CRM record, check whether it already exists. Returns a verdict " +
|
|
@@ -276,29 +295,29 @@ export async function startMcpServer() {
|
|
|
276
295
|
inputPath: z.string().optional(),
|
|
277
296
|
},
|
|
278
297
|
},
|
|
279
|
-
async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
298
|
+
handler: async ({ objectType, name, domain, email, accountId, provider, inputPath }) => {
|
|
280
299
|
const snapshot = await readSnapshot(provider, inputPath);
|
|
281
300
|
return content(resolveRecord(snapshot, { objectType, name, domain, email, accountId }));
|
|
282
301
|
},
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
{
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "fullstackgtm_rules",
|
|
305
|
+
writesCrm: false,
|
|
306
|
+
config: {
|
|
288
307
|
title: "List Audit Rules",
|
|
289
308
|
description: "List the built-in deterministic audit rules with ids and descriptions.",
|
|
290
309
|
inputSchema: {},
|
|
291
310
|
},
|
|
292
|
-
async () => {
|
|
311
|
+
handler: async () => {
|
|
293
312
|
return content(
|
|
294
313
|
builtinAuditRules.map(({ id, title, description }) => ({ id, title, description })),
|
|
295
314
|
);
|
|
296
315
|
},
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
{
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "fullstackgtm_apply",
|
|
319
|
+
writesCrm: true,
|
|
320
|
+
config: {
|
|
302
321
|
title: "Apply Approved Patch Operations",
|
|
303
322
|
description:
|
|
304
323
|
"Apply explicitly approved operations from a patch plan through a provider " +
|
|
@@ -312,7 +331,7 @@ export async function startMcpServer() {
|
|
|
312
331
|
output: z.enum(["json", "markdown"]).optional(),
|
|
313
332
|
},
|
|
314
333
|
},
|
|
315
|
-
async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
|
|
334
|
+
handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
|
|
316
335
|
const plan = JSON.parse(
|
|
317
336
|
readFileSync(resolve(process.cwd(), planPath), "utf8"),
|
|
318
337
|
) as PatchPlan;
|
|
@@ -322,11 +341,11 @@ export async function startMcpServer() {
|
|
|
322
341
|
});
|
|
323
342
|
return content(output === "markdown" ? formatPatchPlanRun(run) : run);
|
|
324
343
|
},
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
{
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "fullstackgtm_market_worksheet",
|
|
347
|
+
writesCrm: false,
|
|
348
|
+
config: {
|
|
330
349
|
title: "Market Map Classification Worksheet",
|
|
331
350
|
description:
|
|
332
351
|
"Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
|
|
@@ -341,15 +360,15 @@ export async function startMcpServer() {
|
|
|
341
360
|
captureRun: z.string().optional(),
|
|
342
361
|
},
|
|
343
362
|
},
|
|
344
|
-
async ({ vendorId, configPath, captureRun }) => {
|
|
363
|
+
handler: async ({ vendorId, configPath, captureRun }) => {
|
|
345
364
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
346
365
|
return content(buildWorksheet(config, vendorId, { captureRun }));
|
|
347
366
|
},
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
{
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: "fullstackgtm_market_observe",
|
|
370
|
+
writesCrm: false,
|
|
371
|
+
config: {
|
|
353
372
|
title: "Submit Market Map Observations",
|
|
354
373
|
description:
|
|
355
374
|
"Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
|
|
@@ -361,7 +380,7 @@ export async function startMcpServer() {
|
|
|
361
380
|
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
362
381
|
},
|
|
363
382
|
},
|
|
364
|
-
async ({ observationsPath, configPath }) => {
|
|
383
|
+
handler: async ({ observationsPath, configPath }) => {
|
|
365
384
|
const config = loadMarketConfigOrHint(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
366
385
|
const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8")) as ObservationSet;
|
|
367
386
|
const problems = validateObservationSet(config, set);
|
|
@@ -377,7 +396,61 @@ export async function startMcpServer() {
|
|
|
377
396
|
const fronts = computeFrontStates(config, set);
|
|
378
397
|
return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
|
|
379
398
|
},
|
|
380
|
-
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
|
|
402
|
+
// Registered first so agents that list tools see the contract entry up top.
|
|
403
|
+
// Its payload closes over the registration table, so the reported inventory
|
|
404
|
+
// is derived, not hand-maintained.
|
|
405
|
+
toolDefinitions.unshift({
|
|
406
|
+
name: "fullstackgtm_capabilities",
|
|
407
|
+
writesCrm: false,
|
|
408
|
+
config: {
|
|
409
|
+
title: "FullStackGTM Capabilities",
|
|
410
|
+
description:
|
|
411
|
+
"Machine-readable contract for this MCP server: the tool inventory (derived from the " +
|
|
412
|
+
"server's own registration table, with per-tool CRM-write flags), safety defaults, and " +
|
|
413
|
+
"the CLI entrypoints for everything the tools don't cover.",
|
|
414
|
+
},
|
|
415
|
+
handler: async () =>
|
|
416
|
+
content({
|
|
417
|
+
ok: true,
|
|
418
|
+
tool: "fullstackgtm",
|
|
419
|
+
version: packageVersion(),
|
|
420
|
+
planFirst: true,
|
|
421
|
+
mcpTools: toolDefinitions.map(({ name, writesCrm, config }) => ({
|
|
422
|
+
name,
|
|
423
|
+
title: config.title,
|
|
424
|
+
writesCrm,
|
|
425
|
+
})),
|
|
426
|
+
safety:
|
|
427
|
+
"Only tools flagged writesCrm can reach a CRM; fullstackgtm_apply writes only operations " +
|
|
428
|
+
"listed in approvedOperationIds and never writes requires_human_* placeholders without a value override.",
|
|
429
|
+
server: { command: "npx -y fullstackgtm-mcp" },
|
|
430
|
+
cli: {
|
|
431
|
+
command: "npx fullstackgtm <command> [--json]",
|
|
432
|
+
capabilities: "npx fullstackgtm capabilities --json",
|
|
433
|
+
agentGuide: "npx fullstackgtm robot-docs",
|
|
434
|
+
},
|
|
435
|
+
examples: [
|
|
436
|
+
"npx -y fullstackgtm-mcp",
|
|
437
|
+
"npx fullstackgtm capabilities --json",
|
|
438
|
+
"npx fullstackgtm audit --demo --json",
|
|
439
|
+
],
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
export async function startMcpServer() {
|
|
444
|
+
const server = new McpServer({
|
|
445
|
+
name: "fullstackgtm",
|
|
446
|
+
version: packageVersion(),
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
for (const tool of toolDefinitions) {
|
|
450
|
+
// The table is loosely typed so it can drive one loop; the SDK still
|
|
451
|
+
// validates every call against the zod inputSchema at runtime.
|
|
452
|
+
server.registerTool(tool.name, tool.config as never, tool.handler as never);
|
|
453
|
+
}
|
|
381
454
|
|
|
382
455
|
const transport = new StdioServerTransport();
|
|
383
456
|
await server.connect(transport);
|
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
|
+
}
|