fullstackgtm 0.50.0 → 0.50.1
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 +24 -0
- package/README.md +7 -0
- package/dist/cli/audit.js +6 -1
- package/dist/cli/auth.js +25 -0
- package/dist/cli/backfill.js +4 -0
- package/dist/cli/call.js +28 -6
- package/dist/cli/draft.js +11 -1
- package/dist/cli/enrich.js +3 -2
- package/dist/cli/fix.js +6 -3
- package/dist/cli/help.js +22 -22
- package/dist/cli/icp.js +15 -1
- package/dist/cli/market.js +14 -1
- package/dist/cli/planOutput.d.ts +5 -0
- package/dist/cli/planOutput.js +27 -0
- package/dist/cli/plans.js +184 -33
- package/dist/cli/schedule.js +22 -11
- package/dist/cli/signals.js +22 -3
- package/dist/cli/tam.js +10 -1
- package/dist/cli/ui.js +14 -6
- package/dist/connectors/hubspot.d.ts +4 -0
- package/dist/connectors/hubspot.js +36 -18
- package/dist/hostedPatchPlan.js +6 -1
- package/package.json +1 -1
- package/src/cli/audit.ts +5 -1
- package/src/cli/auth.ts +24 -0
- package/src/cli/backfill.ts +3 -0
- package/src/cli/call.ts +25 -6
- package/src/cli/draft.ts +9 -1
- package/src/cli/enrich.ts +3 -2
- package/src/cli/fix.ts +5 -3
- package/src/cli/help.ts +22 -22
- package/src/cli/icp.ts +13 -1
- package/src/cli/market.ts +12 -1
- package/src/cli/planOutput.ts +30 -0
- package/src/cli/plans.ts +174 -29
- package/src/cli/schedule.ts +21 -15
- package/src/cli/signals.ts +22 -3
- package/src/cli/tam.ts +8 -1
- package/src/cli/ui.ts +15 -6
- package/src/connectors/hubspot.ts +41 -17
- package/src/hostedPatchPlan.ts +6 -1
package/dist/cli/plans.js
CHANGED
|
@@ -17,7 +17,7 @@ import { progressReporter, reportCounts } from "../runReport.js";
|
|
|
17
17
|
import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.js";
|
|
18
18
|
import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
|
|
19
19
|
import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.js";
|
|
20
|
-
import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
|
|
20
|
+
import { box, colorEnabled, createProgressRenderer, createStatusLine, formatDuration, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
|
|
21
21
|
import { unknownSubcommandError } from "./suggest.js";
|
|
22
22
|
function parseValueOverrides(args) {
|
|
23
23
|
const valueOverrides = {};
|
|
@@ -29,6 +29,119 @@ function parseValueOverrides(args) {
|
|
|
29
29
|
}
|
|
30
30
|
return valueOverrides;
|
|
31
31
|
}
|
|
32
|
+
function recordHeadline(operation) {
|
|
33
|
+
const after = operation.afterValue && typeof operation.afterValue === "object" && !Array.isArray(operation.afterValue)
|
|
34
|
+
? operation.afterValue : {};
|
|
35
|
+
const properties = after.properties && typeof after.properties === "object" && !Array.isArray(after.properties)
|
|
36
|
+
? after.properties : {};
|
|
37
|
+
const first = typeof properties.firstname === "string" ? properties.firstname : "";
|
|
38
|
+
const last = typeof properties.lastname === "string" ? properties.lastname : "";
|
|
39
|
+
const company = typeof properties.company === "string" ? properties.company
|
|
40
|
+
: typeof after.associateCompanyName === "string" ? after.associateCompanyName : undefined;
|
|
41
|
+
const title = [first, last].filter(Boolean).join(" ") || company || operation.objectId || operation.id;
|
|
42
|
+
const role = typeof properties.jobtitle === "string" ? properties.jobtitle : undefined;
|
|
43
|
+
return { title, ...(role ? { subtitle: role } : {}), ...(company ? { company } : {}) };
|
|
44
|
+
}
|
|
45
|
+
function planEffect(stored) {
|
|
46
|
+
const total = stored.plan.operations.length;
|
|
47
|
+
const approved = stored.approvedOperationIds.length;
|
|
48
|
+
if (stored.status === "applied") {
|
|
49
|
+
const results = stored.runs.at(-1)?.results ?? [];
|
|
50
|
+
const applied = results.filter((result) => result.status === "applied").length;
|
|
51
|
+
const skipped = results.filter((result) => result.status === "skipped").length;
|
|
52
|
+
return `Completed ${applied} operation${applied === 1 ? "" : "s"}${skipped ? `; ${skipped} skipped` : ""}. No further writes will run.`;
|
|
53
|
+
}
|
|
54
|
+
if (stored.status === "approved")
|
|
55
|
+
return `Apply will execute ${approved} selected operation${approved === 1 ? "" : "s"}; ${total - approved} will not run.`;
|
|
56
|
+
if (stored.status === "rejected")
|
|
57
|
+
return "Rejected. No provider writes can run.";
|
|
58
|
+
return `No provider writes can run until operations are approved (0 of ${total} selected).`;
|
|
59
|
+
}
|
|
60
|
+
function printPlanCards(stored, hostedUrl) {
|
|
61
|
+
const p = paint(colorEnabled(process.stdout));
|
|
62
|
+
const total = stored.plan.operations.length;
|
|
63
|
+
const approved = new Set(stored.approvedOperationIds);
|
|
64
|
+
const width = Math.max(56, Math.min(100, (process.stdout.columns ?? 100) - 4));
|
|
65
|
+
const fit = (value) => truncateToWidth(value, width);
|
|
66
|
+
const summary = [
|
|
67
|
+
fit(stored.plan.title),
|
|
68
|
+
`Status ${stored.status.toUpperCase().replaceAll("_", " ")}`,
|
|
69
|
+
`Selection ${approved.size} of ${total} operation${total === 1 ? "" : "s"} approved`,
|
|
70
|
+
`Runs ${stored.runs.length}`,
|
|
71
|
+
fit(`Effect ${planEffect(stored)}`),
|
|
72
|
+
];
|
|
73
|
+
console.log(box(summary, p, "Plan").join("\n"));
|
|
74
|
+
if (hostedUrl)
|
|
75
|
+
console.log(`Hosted: ${hostedUrl}`);
|
|
76
|
+
console.log("\nOperations");
|
|
77
|
+
let operationIndex = 0;
|
|
78
|
+
const latestResults = new Map((stored.runs.at(-1)?.results ?? []).map((result) => [result.operationId, result.status]));
|
|
79
|
+
for (const operation of stored.plan.operations) {
|
|
80
|
+
const selected = approved.has(operation.id);
|
|
81
|
+
const resultStatus = latestResults.get(operation.id);
|
|
82
|
+
const headline = recordHeadline(operation);
|
|
83
|
+
const action = operation.operation === "create_record"
|
|
84
|
+
? `Create ${operation.objectType}${headline.company ? ` and resolve/link ${headline.company}` : ""}`
|
|
85
|
+
: `${operation.operation.replaceAll("_", " ")} ${operation.objectType}`;
|
|
86
|
+
const lines = [
|
|
87
|
+
fit(headline.title),
|
|
88
|
+
...(headline.subtitle || headline.company
|
|
89
|
+
? [fit([headline.subtitle, headline.company].filter(Boolean).join(" · "))]
|
|
90
|
+
: []),
|
|
91
|
+
fit(`Action ${action}`),
|
|
92
|
+
`Operation ${operation.id}`,
|
|
93
|
+
];
|
|
94
|
+
if (operationIndex++ > 0)
|
|
95
|
+
console.log("");
|
|
96
|
+
const state = resultStatus === "applied" ? "✓ APPLIED"
|
|
97
|
+
: resultStatus && selected ? `! ${resultStatus.toUpperCase()}`
|
|
98
|
+
: selected ? "✓ APPROVED" : stored.status === "applied" ? "○ EXCLUDED" : "○ NOT APPROVED";
|
|
99
|
+
console.log(box(lines, p, state).join("\n"));
|
|
100
|
+
}
|
|
101
|
+
if (stored.status === "approved") {
|
|
102
|
+
console.log(`\nNext: fullstackgtm apply --plan-id ${stored.plan.id} --provider <hubspot|salesforce>`);
|
|
103
|
+
}
|
|
104
|
+
console.log(`Details: fullstackgtm plans show ${stored.plan.id} --verbose`);
|
|
105
|
+
}
|
|
106
|
+
function printApplyCards(run, plan, approvedOperationIds, planIdStored) {
|
|
107
|
+
const p = paint(colorEnabled(process.stdout));
|
|
108
|
+
const approved = new Set(approvedOperationIds);
|
|
109
|
+
const operations = new Map(plan.operations.map((operation) => [operation.id, operation]));
|
|
110
|
+
const counts = { applied: 0, skipped: 0, failed: 0, conflict: 0 };
|
|
111
|
+
for (const result of run.results)
|
|
112
|
+
counts[result.status] += 1;
|
|
113
|
+
const elapsed = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
|
|
114
|
+
const summary = [
|
|
115
|
+
`Status ${run.status.toUpperCase()}`,
|
|
116
|
+
`Provider ${run.provider}`,
|
|
117
|
+
`Outcome ${counts.applied} applied · ${run.results.length - approved.size} excluded · ${counts.failed + counts.conflict} failed/conflicted`,
|
|
118
|
+
`Duration ${formatDuration(elapsed)}`,
|
|
119
|
+
...(planIdStored ? ["Receipt recorded locally; hosted reconciliation requested"] : []),
|
|
120
|
+
];
|
|
121
|
+
console.log(box(summary, p, "Apply result").join("\n"));
|
|
122
|
+
console.log("\nOperations");
|
|
123
|
+
let index = 0;
|
|
124
|
+
for (const result of run.results) {
|
|
125
|
+
const operation = operations.get(result.operationId);
|
|
126
|
+
if (!operation)
|
|
127
|
+
continue;
|
|
128
|
+
const selected = approved.has(result.operationId);
|
|
129
|
+
const headline = recordHeadline(operation);
|
|
130
|
+
const state = !selected ? "○ EXCLUDED" : result.status === "applied" ? "✓ APPLIED" : `! ${result.status.toUpperCase()}`;
|
|
131
|
+
const lines = [
|
|
132
|
+
headline.title,
|
|
133
|
+
...(headline.subtitle || headline.company ? [[headline.subtitle, headline.company].filter(Boolean).join(" · ")] : []),
|
|
134
|
+
`Operation ${result.operationId}`,
|
|
135
|
+
...(result.detail ? [truncateToWidth(`Result ${result.detail}`, 100)] : []),
|
|
136
|
+
];
|
|
137
|
+
if (index++ > 0)
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(box(lines, p, state).join("\n"));
|
|
140
|
+
}
|
|
141
|
+
if (planIdStored)
|
|
142
|
+
console.log(`\nInspect: fullstackgtm plans show ${run.planId}`);
|
|
143
|
+
console.log("Details: rerun with --verbose; machine output: --json");
|
|
144
|
+
}
|
|
32
145
|
function tryLoadAcquireConfig(args) {
|
|
33
146
|
try {
|
|
34
147
|
const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
|
|
@@ -322,9 +435,12 @@ export async function apply(args) {
|
|
|
322
435
|
if (args.includes("--json")) {
|
|
323
436
|
console.log(JSON.stringify(run, null, 2));
|
|
324
437
|
}
|
|
325
|
-
else {
|
|
438
|
+
else if (args.includes("--verbose")) {
|
|
326
439
|
console.log(formatPatchPlanRun(run));
|
|
327
440
|
}
|
|
441
|
+
else {
|
|
442
|
+
printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
|
|
443
|
+
}
|
|
328
444
|
if (run.status === "failed")
|
|
329
445
|
process.exitCode = 1;
|
|
330
446
|
}
|
|
@@ -422,31 +538,48 @@ export async function plansCommand(args) {
|
|
|
422
538
|
}
|
|
423
539
|
const p = paint(colorEnabled(process.stdout));
|
|
424
540
|
if (p.enabled) {
|
|
425
|
-
|
|
426
|
-
|
|
541
|
+
const statusCounts = new Map();
|
|
542
|
+
for (const stored of plans)
|
|
543
|
+
statusCounts.set(stored.status, (statusCounts.get(stored.status) ?? 0) + 1);
|
|
544
|
+
const awaiting = plans.filter((stored) => stored.status === "needs_approval").length;
|
|
545
|
+
const approved = plans.filter((stored) => stored.status === "approved").length;
|
|
546
|
+
console.log(box([
|
|
547
|
+
`${plans.length} plan${plans.length === 1 ? "" : "s"} · ${awaiting} awaiting approval · ${approved} ready to apply`,
|
|
548
|
+
[...statusCounts.entries()].map(([name, count]) => `${name.replaceAll("_", " ")}: ${count}`).join(" · "),
|
|
549
|
+
], p, "Change queue").join("\n"));
|
|
550
|
+
console.log("");
|
|
551
|
+
// Interactive terminals get an aligned decision table; the plain
|
|
552
|
+
// per-line format below remains stable for pipes and scripts.
|
|
427
553
|
const rows = plans.map((stored) => [
|
|
428
|
-
stored.plan.id,
|
|
429
554
|
stored.status,
|
|
430
|
-
|
|
555
|
+
stored.plan.title,
|
|
556
|
+
`${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
|
|
431
557
|
`${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
|
|
432
|
-
stored.plan.
|
|
558
|
+
stored.plan.id,
|
|
433
559
|
]);
|
|
434
560
|
// Long summaries would wrap and break the table's alignment. The summary
|
|
435
561
|
// is the LAST column: cap it to what's left of the terminal width after
|
|
436
562
|
// the fixed columns and their two-space gutters (floor of 24 so narrow
|
|
437
563
|
// terminals still show something useful).
|
|
438
564
|
const columns = process.stdout.columns ?? 80;
|
|
439
|
-
const fixedWidth = [0,
|
|
440
|
-
const
|
|
565
|
+
const fixedWidth = [0, 2, 3, 4].reduce((sum, index) => sum + Math.max(...rows.map((row) => row[index].length)), 0) + 2 * 4;
|
|
566
|
+
const titleWidth = Math.max(24, columns - fixedWidth);
|
|
441
567
|
for (const row of rows)
|
|
442
|
-
row[
|
|
568
|
+
row[1] = truncateToWidth(row[1], titleWidth);
|
|
443
569
|
const statusPainter = (cell) => {
|
|
444
570
|
const painted = planStatusWord(cell.trimEnd(), p);
|
|
445
571
|
return painted + cell.slice(cell.trimEnd().length);
|
|
446
572
|
};
|
|
447
|
-
for (const line of table(rows, [
|
|
573
|
+
for (const line of table(rows, [statusPainter, null, p.dim, p.dim, p.dim])) {
|
|
448
574
|
console.log(line);
|
|
449
575
|
}
|
|
576
|
+
const next = plans.find((stored) => stored.status === "approved") ?? plans.find((stored) => stored.status === "needs_approval");
|
|
577
|
+
if (next) {
|
|
578
|
+
const command = next.status === "approved"
|
|
579
|
+
? `fullstackgtm apply --plan-id ${next.plan.id} --provider <name>`
|
|
580
|
+
: `fullstackgtm plans show ${next.plan.id}`;
|
|
581
|
+
console.log(`\nNext: ${command}`);
|
|
582
|
+
}
|
|
450
583
|
return;
|
|
451
584
|
}
|
|
452
585
|
for (const stored of plans) {
|
|
@@ -473,16 +606,11 @@ export async function plansCommand(args) {
|
|
|
473
606
|
console.log(JSON.stringify(stored, null, 2));
|
|
474
607
|
return;
|
|
475
608
|
}
|
|
476
|
-
const showPaint = paint(colorEnabled(process.stdout));
|
|
477
|
-
console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
|
|
478
|
-
console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
|
|
479
|
-
console.log(`Runs: ${stored.runs.length}`);
|
|
480
609
|
const mirror = await uploadHostedPatchPlan(stored);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (stored.applyAttempts?.length) {
|
|
610
|
+
printPlanCards(stored, mirror.status === "saved" ? mirror.state.url : undefined);
|
|
611
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict")
|
|
612
|
+
console.log(`Hosted sync pending: ${mirror.reason}`);
|
|
613
|
+
if (rest.includes("--verbose") && stored.applyAttempts?.length) {
|
|
486
614
|
console.log("Apply attempts:");
|
|
487
615
|
for (const attempt of stored.applyAttempts) {
|
|
488
616
|
console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
|
|
@@ -490,8 +618,11 @@ export async function plansCommand(args) {
|
|
|
490
618
|
console.log(` ${attempt.note}`);
|
|
491
619
|
}
|
|
492
620
|
}
|
|
493
|
-
|
|
494
|
-
|
|
621
|
+
if (rest.includes("--verbose")) {
|
|
622
|
+
const showPaint = paint(colorEnabled(process.stdout));
|
|
623
|
+
console.log("\nFull plan document\n");
|
|
624
|
+
console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
|
|
625
|
+
}
|
|
495
626
|
return;
|
|
496
627
|
}
|
|
497
628
|
if (subcommand === "approve") {
|
|
@@ -578,19 +709,39 @@ export async function plansCommand(args) {
|
|
|
578
709
|
if (subcommand === "sync") {
|
|
579
710
|
const plans = await store.list();
|
|
580
711
|
let updated = 0;
|
|
712
|
+
let completed = 0;
|
|
581
713
|
const warnings = [];
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
714
|
+
const status = createStatusLine();
|
|
715
|
+
status.set(`Synchronizing plan replicas 0/${plans.length}`);
|
|
716
|
+
// A workspace can accumulate hundreds of local plans. Reconcile with a
|
|
717
|
+
// small worker pool so one network round-trip per plan does not turn into
|
|
718
|
+
// a minutes-long silent serial wait, without stampeding the hosted API.
|
|
719
|
+
let next = 0;
|
|
720
|
+
const worker = async () => {
|
|
721
|
+
while (next < plans.length) {
|
|
722
|
+
const stored = plans[next++];
|
|
723
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
724
|
+
if (result.status === "updated")
|
|
725
|
+
updated += 1;
|
|
726
|
+
else if (result.status === "conflict" || result.status === "unavailable")
|
|
727
|
+
warnings.push(`${stored.plan.id}: ${result.reason}`);
|
|
728
|
+
// Historical local-only plans have no remote row to receive a receipt.
|
|
729
|
+
// `backfill runs` is the explicit import path for that old history.
|
|
730
|
+
if (stored.status === "applied" && result.status !== "missing" && (stored.applyAttempts?.length ?? 0) > 0) {
|
|
731
|
+
const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
|
|
732
|
+
const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
|
|
733
|
+
if (pushed.status === "conflict" || pushed.status === "unavailable")
|
|
734
|
+
warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
|
|
735
|
+
}
|
|
736
|
+
completed += 1;
|
|
737
|
+
status.set(`Synchronizing plan replicas ${completed}/${plans.length}`);
|
|
593
738
|
}
|
|
739
|
+
};
|
|
740
|
+
try {
|
|
741
|
+
await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
status.done();
|
|
594
745
|
}
|
|
595
746
|
console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
|
|
596
747
|
for (const warning of warnings)
|
package/dist/cli/schedule.js
CHANGED
|
@@ -8,6 +8,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
|
|
|
8
8
|
import { runCli } from "../cli.js";
|
|
9
9
|
import { isOptionValue, numericOption, option } from "./shared.js";
|
|
10
10
|
import { unknownSubcommandError } from "./suggest.js";
|
|
11
|
+
import { colorEnabled, paint, table } from "./ui.js";
|
|
11
12
|
/**
|
|
12
13
|
* The schedule layer: declarative cadences for read/plan-side commands,
|
|
13
14
|
* materialized through a provider (MVP: the user crontab), with an
|
|
@@ -113,10 +114,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
113
114
|
console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
const p = paint(colorEnabled(process.stdout));
|
|
118
|
+
const verbose = rest.includes("--verbose");
|
|
119
|
+
const rows = [
|
|
120
|
+
["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
|
|
121
|
+
...withNext.map((entry) => [
|
|
122
|
+
entry.enabled ? "on" : "off",
|
|
123
|
+
`${entry.label} · ${entry.id}`,
|
|
124
|
+
entry.nextFiring ?? "—",
|
|
125
|
+
...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
|
|
126
|
+
]),
|
|
127
|
+
];
|
|
128
|
+
console.log(table(rows, [p.dim, null, null]).join("\n"));
|
|
129
|
+
console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
|
|
120
130
|
return;
|
|
121
131
|
}
|
|
122
132
|
if (subcommand === "remove") {
|
|
@@ -251,20 +261,21 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
251
261
|
const last = entry.lastRun;
|
|
252
262
|
const streak = entry.streak;
|
|
253
263
|
const missed = entry.missedFirings;
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
const verbose = rest.includes("--verbose");
|
|
265
|
+
const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
|
|
266
|
+
console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
|
|
267
|
+
console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
|
|
268
|
+
if (verbose)
|
|
269
|
+
console.log(` ${entry.argv.join(" ")} · cron ${entry.cron}`);
|
|
256
270
|
if (last) {
|
|
257
271
|
const artifacts = [
|
|
258
272
|
...last.artifacts.planIds.map((planId) => `plan ${planId}`),
|
|
259
273
|
...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
|
|
260
274
|
];
|
|
261
|
-
console.log(` last
|
|
262
|
-
(last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
|
|
263
|
-
(artifacts.length ? ` — ${artifacts.join(", ")}` : ""));
|
|
264
|
-
console.log(` streak: ${streak.length} ${streak.outcome}(s)`);
|
|
275
|
+
console.log(` last ${last.firedAt} · ${last.trigger} · ${streak.length} ${streak.outcome}${streak.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
|
|
265
276
|
}
|
|
266
277
|
else {
|
|
267
|
-
console.log(" last
|
|
278
|
+
console.log(" last never fired");
|
|
268
279
|
}
|
|
269
280
|
if (missed.length > 0) {
|
|
270
281
|
console.log(` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
|
package/dist/cli/signals.js
CHANGED
|
@@ -22,6 +22,20 @@ function resolveSignalsConfig(args) {
|
|
|
22
22
|
return loadSignalsConfig(local);
|
|
23
23
|
return DEFAULT_SIGNALS_CONFIG;
|
|
24
24
|
}
|
|
25
|
+
function concise(value, max = 72) {
|
|
26
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
27
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
28
|
+
}
|
|
29
|
+
function renderSignals(signals) {
|
|
30
|
+
if (signals.length === 0)
|
|
31
|
+
return "No signals matched.";
|
|
32
|
+
const lines = [`Fresh signals (${signals.length})`, ""];
|
|
33
|
+
for (const signal of signals) {
|
|
34
|
+
lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
|
|
35
|
+
lines.push(` ${concise(signal.trigger)}`);
|
|
36
|
+
}
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
25
39
|
/**
|
|
26
40
|
* Resolve the watchlist of accounts to scan. Sources, in precedence order:
|
|
27
41
|
* - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
|
|
@@ -211,7 +225,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
211
225
|
const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
|
|
212
226
|
const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
213
227
|
// Ranked fresh signals to stdout; guidance to stderr.
|
|
214
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
228
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
215
229
|
console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
|
|
216
230
|
`(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
|
|
217
231
|
const save = saveRequested(rest);
|
|
@@ -259,7 +273,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
259
273
|
return true;
|
|
260
274
|
});
|
|
261
275
|
const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
262
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
276
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
263
277
|
console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
|
|
264
278
|
return;
|
|
265
279
|
}
|
|
@@ -286,7 +300,12 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
286
300
|
const outcomes = await store.listOutcomes();
|
|
287
301
|
const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
|
|
288
302
|
const weights = computeWeights(config, outcomes, signalsById);
|
|
289
|
-
|
|
303
|
+
if (rest.includes("--json") || rest.includes("--verbose")) {
|
|
304
|
+
console.log(JSON.stringify(weights, null, 2));
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
|
|
308
|
+
}
|
|
290
309
|
if (rest.includes("--explain")) {
|
|
291
310
|
// Per-bucket config default vs learned + booked/total over credited signals.
|
|
292
311
|
const booked = new Map();
|
package/dist/cli/tam.js
CHANGED
|
@@ -253,9 +253,18 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
|
|
|
253
253
|
writeFileSync(resolve(process.cwd(), out), md);
|
|
254
254
|
console.log(`Wrote ${out}.`);
|
|
255
255
|
}
|
|
256
|
-
else {
|
|
256
|
+
else if (rest.includes("--verbose")) {
|
|
257
257
|
console.log(md);
|
|
258
258
|
}
|
|
259
|
+
else if (timeline.length > 0) {
|
|
260
|
+
console.log(coverageToText(model, timeline.at(-1), eta));
|
|
261
|
+
console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
|
|
265
|
+
console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
|
|
266
|
+
console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
|
|
267
|
+
}
|
|
259
268
|
return;
|
|
260
269
|
}
|
|
261
270
|
if (sub === "populate") {
|
package/dist/cli/ui.js
CHANGED
|
@@ -262,8 +262,12 @@ export function createChecklist(items, stream = process.stderr, env = process.en
|
|
|
262
262
|
const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
|
|
263
263
|
return ` ${glyph} ${label}${note}`;
|
|
264
264
|
});
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
// Keep the cursor on the board's last row while it is live. A trailing
|
|
266
|
+
// newline on every repaint can scroll the old top row into permanent
|
|
267
|
+
// history when the board sits at the bottom of the terminal, producing
|
|
268
|
+
// apparent duplicate/errored frames in terminal captures.
|
|
269
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
270
|
+
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
|
|
267
271
|
painted = lines.length;
|
|
268
272
|
};
|
|
269
273
|
const start = () => {
|
|
@@ -292,14 +296,18 @@ export function createChecklist(items, stream = process.stderr, env = process.en
|
|
|
292
296
|
timer = null;
|
|
293
297
|
if (options.persist) {
|
|
294
298
|
// The caller's final state update already painted the completed board.
|
|
295
|
-
//
|
|
296
|
-
|
|
299
|
+
// Advance exactly once so subsequent output begins below it.
|
|
300
|
+
if (painted > 0)
|
|
301
|
+
stream.write("\n");
|
|
297
302
|
painted = 0;
|
|
298
303
|
return;
|
|
299
304
|
}
|
|
300
305
|
if (painted > 0) {
|
|
301
|
-
// Erase
|
|
302
|
-
|
|
306
|
+
// Erase in place without linefeeds, which could themselves scroll.
|
|
307
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
308
|
+
const clear = Array.from({ length: painted }, (_, index) => `\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`).join("");
|
|
309
|
+
const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
310
|
+
stream.write(`${up}${clear}${restore}`);
|
|
303
311
|
painted = 0;
|
|
304
312
|
}
|
|
305
313
|
},
|
|
@@ -18,6 +18,10 @@ export type HubspotConnectorOptions = {
|
|
|
18
18
|
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
19
19
|
*/
|
|
20
20
|
progress?: ProgressEmitter;
|
|
21
|
+
/** Maximum retries for HubSpot 429/5xx responses (default 5). */
|
|
22
|
+
maxRetries?: number;
|
|
23
|
+
/** Injectable delay for deterministic retry tests. */
|
|
24
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
21
25
|
};
|
|
22
26
|
/**
|
|
23
27
|
* Reference connector for HubSpot.
|
|
@@ -30,6 +30,8 @@ const PULL_STAGE_BY_TYPE = {
|
|
|
30
30
|
export function createHubspotConnector(options) {
|
|
31
31
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
32
32
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
33
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
34
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
33
35
|
const mappings = options.fieldMappings;
|
|
34
36
|
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
35
37
|
// search API is eventually consistent, so a just-created company is
|
|
@@ -66,28 +68,44 @@ export function createHubspotConnector(options) {
|
|
|
66
68
|
emitter.items(fetched);
|
|
67
69
|
};
|
|
68
70
|
async function request(path, init = {}) {
|
|
69
|
-
const token = await options.getAccessToken();
|
|
70
71
|
let response;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
72
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
73
|
+
const token = await options.getAccessToken();
|
|
74
|
+
try {
|
|
75
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
76
|
+
...init,
|
|
77
|
+
headers: {
|
|
78
|
+
Authorization: `Bearer ${token}`,
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
...(init.headers ?? {}),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
86
|
+
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
87
|
+
}
|
|
88
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
89
|
+
if (!retryable || attempt === maxRetries)
|
|
90
|
+
break;
|
|
91
|
+
await response.text().catch(() => undefined);
|
|
92
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
93
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
94
|
+
const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
|
|
95
|
+
const delayMs = Math.min(30_000, Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt));
|
|
96
|
+
const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
|
|
97
|
+
options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
|
|
98
|
+
await sleep(delayMs);
|
|
99
|
+
}
|
|
100
|
+
if (!response || !response.ok) {
|
|
86
101
|
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
87
102
|
// (contact emails, company domains) and the request payload, and these
|
|
88
103
|
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
89
|
-
await response
|
|
90
|
-
|
|
104
|
+
await response?.text().catch(() => undefined);
|
|
105
|
+
if (response?.status === 429) {
|
|
106
|
+
throw new Error(`HubSpot rate limit (429) persisted after ${maxRetries} retries. Wait for the portal limit to reset, then retry; no CRM writes were made.`);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
|
|
91
109
|
}
|
|
92
110
|
// DELETE and some association writes return 204 with an empty body.
|
|
93
111
|
const text = await response.text();
|
package/dist/hostedPatchPlan.js
CHANGED
|
@@ -191,7 +191,12 @@ export async function reportHostedPlanLifecycle(stored, options = {}) {
|
|
|
191
191
|
return { status: "unpaired" };
|
|
192
192
|
const status = stored.status === "applied" ? "applied" : stored.status === "rejected" ? "rejected" : "approved";
|
|
193
193
|
const runRecord = status === "applied" ? stored.runs.at(-1) : undefined;
|
|
194
|
-
|
|
194
|
+
// The hosted terminal transition is authority-bound to the exact approved
|
|
195
|
+
// subset. Local runs also record excluded operations as `skipped` for a
|
|
196
|
+
// complete human audit trail; those are not execution receipts and must not
|
|
197
|
+
// be echoed as though they were authorized provider work.
|
|
198
|
+
const approved = new Set(stored.approvedOperationIds);
|
|
199
|
+
const operationReceipts = runRecord?.results.filter((result) => approved.has(result.operationId)).map((result) => ({
|
|
195
200
|
packageOpId: result.operationId,
|
|
196
201
|
status: result.status,
|
|
197
202
|
...(result.detail ? { error: result.detail.slice(0, 1000) } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.1",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
package/src/cli/audit.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { reportCounts, reportCrm, reportFindings } from "../runReport.ts";
|
|
|
13
13
|
import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "../types.ts";
|
|
14
14
|
import { connectorFor, numericOption, option, readSnapshot, saveRequested, selectedRules } from "./shared.ts";
|
|
15
15
|
import { colorEnabled, createChecklist, formatCount, paint, scoreColor, sparkline, stylizePlanMarkdown, table, type Paint } from "./ui.ts";
|
|
16
|
+
import { compactPlan, verbosePlanRequested } from "./planOutput.ts";
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
const SEVERITY_RANK: Record<AuditFindingSeverity, number> = {
|
|
@@ -258,13 +259,16 @@ export async function audit(args: string[]) {
|
|
|
258
259
|
}
|
|
259
260
|
if (args.includes("--json")) {
|
|
260
261
|
console.log(JSON.stringify(plan, null, 2));
|
|
262
|
+
} else if (!verbosePlanRequested(args)) {
|
|
263
|
+
console.log(compactPlan(plan, { saved: saveRequested(args) }));
|
|
264
|
+
console.error(`\n${auditNextStep(args, plan)}`);
|
|
261
265
|
} else {
|
|
262
266
|
// Default to the summary view (rule table + counts); the full per-operation
|
|
263
267
|
// dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
|
|
264
268
|
// Interactive terminals get the styled rendering; piped output is unchanged.
|
|
265
269
|
console.log(
|
|
266
270
|
stylizePlanMarkdown(
|
|
267
|
-
patchPlanToMarkdown(plan, { summary:
|
|
271
|
+
patchPlanToMarkdown(plan, { summary: false }),
|
|
268
272
|
paint(colorEnabled(process.stdout)),
|
|
269
273
|
),
|
|
270
274
|
);
|
package/src/cli/auth.ts
CHANGED
|
@@ -638,6 +638,30 @@ export async function doctorCommand(args: string[]) {
|
|
|
638
638
|
workspace.auditCount === 0
|
|
639
639
|
? p.dim("no saved audits yet (fullstackgtm audit --provider <name> --save starts the timeline)")
|
|
640
640
|
: `${scoreColor(workspace.healthScore ?? 0, p)}/100${delta} — ${workspace.auditCount} audit(s) saved, last ${workspace.lastAuditAt}${healthSparkline(workspace.profile, p.enabled)}`;
|
|
641
|
+
const connectedProviders = Object.entries(report.providers).filter(([, provider]) => provider.source !== "none");
|
|
642
|
+
const blockers = [
|
|
643
|
+
...(!report.node.ok ? [`Node v${report.node.version} is unsupported; install ${report.node.required}.`] : []),
|
|
644
|
+
...(connectedProviders.length === 0 ? ["No CRM connected."] : []),
|
|
645
|
+
...(workspace.pendingPlans.length > 0 ? [`${workspace.pendingPlans.length} plan${workspace.pendingPlans.length === 1 ? "" : "s"} awaiting approval.`] : []),
|
|
646
|
+
];
|
|
647
|
+
if (!args.includes("--verbose")) {
|
|
648
|
+
const ready = report.node.ok && connectedProviders.length > 0;
|
|
649
|
+
const compact = [
|
|
650
|
+
`${ready ? p.green("Ready") : p.yellow("Setup needed")} · ${report.package.name} ${report.package.version} · profile ${report.profile}`,
|
|
651
|
+
`CRM ${connectedProviders.length > 0 ? connectedProviders.map(([name]) => name).join(", ") : "not connected"}`,
|
|
652
|
+
`Health ${healthLine}`,
|
|
653
|
+
`Plans ${workspace.pendingPlans.length === 0 ? "none awaiting approval" : `${workspace.pendingPlans.length} awaiting approval · ${workspace.pendingPlans[0].id}`}`,
|
|
654
|
+
...(blockers.length > 0 ? ["", "Attention", ...blockers.map((blocker) => ` ${blocker}`)] : []),
|
|
655
|
+
"",
|
|
656
|
+
"Next",
|
|
657
|
+
...nextSteps.map((step) => ` ${step}`),
|
|
658
|
+
"",
|
|
659
|
+
"Run `fullstackgtm doctor --verbose` for credential paths and optional tooling checks.",
|
|
660
|
+
];
|
|
661
|
+
console.log(p.enabled ? box(compact, p, "Workspace readiness").join("\n") : compact.join("\n"));
|
|
662
|
+
if (!report.node.ok) process.exitCode = 1;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
641
665
|
const lines = [
|
|
642
666
|
`Package: ${p.bold(`${report.package.name} ${report.package.version}`)}`,
|
|
643
667
|
`Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
|