fullstackgtm 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +378 -1
- package/README.md +37 -14
- 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 +104 -4556
- 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/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.d.ts +3 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +264 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/icp.d.ts +52 -4
- package/dist/icp.js +112 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +3 -0
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- 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/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +272 -5
- package/dist/signals.d.ts +54 -0
- package/dist/signals.js +64 -0
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +11 -0
- package/docs/api.md +91 -11
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +37 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +175 -0
- package/docs/tam.md +195 -0
- package/llms.txt +83 -11
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +4 -3
- 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 +103 -5170
- package/src/connector.ts +46 -0
- package/src/connectors/hubspot.ts +14 -2
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +18 -2
- package/src/connectors/signalSources.ts +314 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/icp.ts +120 -34
- package/src/index.ts +32 -0
- package/src/init.ts +3 -0
- package/src/judge.ts +7 -0
- package/src/judgeEval.ts +8 -1
- package/src/marketClassify.ts +8 -1
- package/src/mcp-bin.ts +2 -2
- package/src/mcp.ts +130 -57
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +330 -7
- package/src/signals.ts +90 -0
- package/src/spoolFiles.ts +116 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
package/src/cli/audit.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { auditSnapshot, defaultPolicy } from "../audit.ts";
|
|
6
|
+
import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
|
|
7
|
+
import { getCredential, resolveHubspotConnection } from "../credentials.ts";
|
|
8
|
+
import { patchPlanToMarkdown } from "../format.ts";
|
|
9
|
+
import { appendHealthEntry, computeHealth, healthToMarkdown, readHealthTimeline, saveWorkspaceSnapshot, summarizeHealth, activeWorkspaceProfile, type HealthRollup } from "../health.ts";
|
|
10
|
+
import { createFilePlanStore } from "../planStore.ts";
|
|
11
|
+
import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "../report.ts";
|
|
12
|
+
import { reportCounts, reportCrm, reportFindings } from "../runReport.ts";
|
|
13
|
+
import type { AuditFindingSeverity, CanonicalGtmSnapshot, PatchPlan } from "../types.ts";
|
|
14
|
+
import { connectorFor, numericOption, option, readSnapshot, saveRequested, selectedRules } from "./shared.ts";
|
|
15
|
+
import { colorEnabled, createChecklist, formatCount, paint, scoreColor, sparkline, stylizePlanMarkdown, table, type Paint } from "./ui.ts";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
const SEVERITY_RANK: Record<AuditFindingSeverity, number> = {
|
|
19
|
+
info: 0,
|
|
20
|
+
warning: 1,
|
|
21
|
+
critical: 2,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function failOnThreshold(args: string[]): AuditFindingSeverity | null {
|
|
25
|
+
const value = option(args, "--fail-on");
|
|
26
|
+
if (!value) return null;
|
|
27
|
+
if (value !== "info" && value !== "warning" && value !== "critical") {
|
|
28
|
+
throw new Error("--fail-on must be one of: info, warning, critical");
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function snapshotCommand(args: string[]) {
|
|
34
|
+
const since = option(args, "--since");
|
|
35
|
+
let snapshot: CanonicalGtmSnapshot;
|
|
36
|
+
if (since) {
|
|
37
|
+
const provider = option(args, "--provider");
|
|
38
|
+
if (!provider) throw new Error("--since requires --provider <name>");
|
|
39
|
+
const connector = await connectorFor(provider, args);
|
|
40
|
+
if (!connector.fetchChanges) {
|
|
41
|
+
throw new Error(`The ${provider} connector does not support incremental fetch.`);
|
|
42
|
+
}
|
|
43
|
+
snapshot = await connector.fetchChanges(since);
|
|
44
|
+
} else {
|
|
45
|
+
snapshot = await readSnapshot(args);
|
|
46
|
+
}
|
|
47
|
+
const serialized = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
48
|
+
const archive = option(args, "--archive");
|
|
49
|
+
if (archive) {
|
|
50
|
+
const dir = resolve(process.cwd(), archive);
|
|
51
|
+
const { mkdirSync } = await import("node:fs");
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
const fileName = `${snapshot.provider}-${snapshot.generatedAt.replace(/[:.]/g, "-")}.json`;
|
|
54
|
+
const path = resolve(dir, fileName);
|
|
55
|
+
writeFileSync(path, serialized);
|
|
56
|
+
console.log(`Archived snapshot to ${path}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const out = option(args, "--out");
|
|
60
|
+
if (out) {
|
|
61
|
+
writeFileSync(resolve(process.cwd(), out), serialized);
|
|
62
|
+
console.log(
|
|
63
|
+
`Wrote snapshot (${snapshot.users.length} users, ${snapshot.accounts.length} accounts, ` +
|
|
64
|
+
`${snapshot.contacts.length} contacts, ${snapshot.deals.length} deals, ` +
|
|
65
|
+
`${snapshot.activities.length} activities) to ${out}`,
|
|
66
|
+
);
|
|
67
|
+
} else {
|
|
68
|
+
console.log(serialized.trimEnd());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// #2 (dx-punch-list): every read verb points forward. `audit` is the keystone —
|
|
73
|
+
// `audit --demo` is the most-run first command and used to dead-end on a blank
|
|
74
|
+
// line. Context-aware guidance mirrors doctor's "Next step" and fix's chaining,
|
|
75
|
+
// stepping the user along Detect → Govern → apply. Printed to stderr so stdout
|
|
76
|
+
// stays clean for pipes/--out; suppressed under --json (machine/agent context).
|
|
77
|
+
function auditNextStep(args: string[], plan: PatchPlan): string {
|
|
78
|
+
const provider = option(args, "--provider");
|
|
79
|
+
const usingLiveData = Boolean(provider) || Boolean(option(args, "--input"));
|
|
80
|
+
const saved = saveRequested(args);
|
|
81
|
+
const count = plan.findings.length;
|
|
82
|
+
|
|
83
|
+
if (count === 0) {
|
|
84
|
+
return [
|
|
85
|
+
"✓ No findings — this snapshot is clean. Nothing to apply.",
|
|
86
|
+
" Keep it clean: gate new records with `fullstackgtm resolve`,",
|
|
87
|
+
" and schedule a recurring check with `fullstackgtm schedule add ...`.",
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const head = `${count} finding${count === 1 ? "" : "s"} — a dry-run plan. Nothing was written.`;
|
|
92
|
+
if (!usingLiveData) {
|
|
93
|
+
return [
|
|
94
|
+
head,
|
|
95
|
+
"Next:",
|
|
96
|
+
" • Client-ready writeup: fullstackgtm report --demo",
|
|
97
|
+
" • Run on your CRM: fullstackgtm login hubspot → fullstackgtm audit --provider hubspot --save",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
if (!saved) {
|
|
101
|
+
return [
|
|
102
|
+
head,
|
|
103
|
+
"Next: persist it with --save, then suggest → approve → apply:",
|
|
104
|
+
` fullstackgtm audit --provider ${provider ?? "<name>"} --save`,
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
// --save already confirmed the plan id above; chain the governed spine.
|
|
108
|
+
const providerFlag = provider ? ` --provider ${provider}` : "";
|
|
109
|
+
return [
|
|
110
|
+
head,
|
|
111
|
+
"Next: derive values, approve the safe ones, apply:",
|
|
112
|
+
` fullstackgtm suggest --plan-id ${plan.id}${providerFlag} --out suggestions.json`,
|
|
113
|
+
` fullstackgtm plans approve ${plan.id} --values-from suggestions.json`,
|
|
114
|
+
` fullstackgtm apply --plan-id ${plan.id}${providerFlag}`,
|
|
115
|
+
].join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve the live HubSpot account's record-URL base (e.g.
|
|
120
|
+
* `https://app-na2.hubspot.com/contacts/<portalId>/record`) and report it on the
|
|
121
|
+
* run so the dashboard can deep-link findings. Best-effort: any failure is
|
|
122
|
+
* swallowed — deep-links are a nicety, never a reason to fail the audit.
|
|
123
|
+
*/
|
|
124
|
+
async function reportHubspotDeepLinkBase(): Promise<void> {
|
|
125
|
+
try {
|
|
126
|
+
const connection = await resolveHubspotConnection();
|
|
127
|
+
if (!connection?.accessToken) return;
|
|
128
|
+
const response = await fetch("https://api.hubapi.com/account-info/v3/details", {
|
|
129
|
+
headers: { Authorization: `Bearer ${connection.accessToken}` },
|
|
130
|
+
});
|
|
131
|
+
if (!response.ok) return;
|
|
132
|
+
const info = (await response.json()) as { portalId?: number; uiDomain?: string };
|
|
133
|
+
if (!info.portalId || !info.uiDomain) return;
|
|
134
|
+
reportCrm({
|
|
135
|
+
provider: "hubspot",
|
|
136
|
+
recordUrlBase: `https://${info.uiDomain}/contacts/${info.portalId}/record`,
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// deep-link base is best-effort
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* When paired (a broker credential exists), hand the local HubSpot token to the
|
|
145
|
+
* hosted deployment so its Integrations page shows HubSpot connected and the
|
|
146
|
+
* backend can sync on its own. The token lands in the same place a manual
|
|
147
|
+
* in-app connect would (encrypted on the org's integration). Best-effort:
|
|
148
|
+
* never blocks or fails the audit.
|
|
149
|
+
*/
|
|
150
|
+
async function registerHubspotWithBroker(): Promise<void> {
|
|
151
|
+
try {
|
|
152
|
+
const broker = getCredential("broker");
|
|
153
|
+
if (!broker?.baseUrl || !broker.accessToken) return; // only when paired
|
|
154
|
+
const connection = await resolveHubspotConnection();
|
|
155
|
+
if (!connection?.accessToken) return;
|
|
156
|
+
const response = await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/integration`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
Authorization: `Bearer ${broker.accessToken}`,
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify({ provider: "hubspot", token: connection.accessToken }),
|
|
163
|
+
signal: AbortSignal.timeout(8000),
|
|
164
|
+
});
|
|
165
|
+
if (response.ok) {
|
|
166
|
+
console.error(
|
|
167
|
+
`Registered HubSpot with ${broker.baseUrl} — it now shows on the dashboard's Integrations page.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// registering the connection is best-effort; never affect the audit
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function audit(args: string[]) {
|
|
176
|
+
const threshold = failOnThreshold(args);
|
|
177
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
178
|
+
const rules = selectedRules(args, await resolveConfiguredRules(loaded));
|
|
179
|
+
const snapshot = await readSnapshot(args);
|
|
180
|
+
|
|
181
|
+
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
182
|
+
const today = option(args, "--today");
|
|
183
|
+
if (today) policy.today = today;
|
|
184
|
+
const staleDealDays = numericOption(args, "--stale-days");
|
|
185
|
+
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
186
|
+
|
|
187
|
+
// Interactive terminals watch the rule registry fill in (○ → spinner → ✓,
|
|
188
|
+
// with per-rule finding counts) on stderr; the board erases itself before
|
|
189
|
+
// the plan renders. Piped/CI/agent runs get an inert no-op.
|
|
190
|
+
const board = createChecklist(rules.map((rule) => ({ id: rule.id, label: rule.id })));
|
|
191
|
+
let plan: PatchPlan;
|
|
192
|
+
try {
|
|
193
|
+
plan = auditSnapshot(
|
|
194
|
+
snapshot,
|
|
195
|
+
policy,
|
|
196
|
+
rules,
|
|
197
|
+
board.active
|
|
198
|
+
? (ruleId, phase, count) =>
|
|
199
|
+
board.update(
|
|
200
|
+
ruleId,
|
|
201
|
+
phase === "start" ? "running" : "ok",
|
|
202
|
+
phase === "done" ? `${count} finding${count === 1 ? "" : "s"}` : undefined,
|
|
203
|
+
)
|
|
204
|
+
: undefined,
|
|
205
|
+
);
|
|
206
|
+
} finally {
|
|
207
|
+
board.done();
|
|
208
|
+
}
|
|
209
|
+
reportCounts({
|
|
210
|
+
findings: plan.findings.length,
|
|
211
|
+
critical: plan.findings.filter((f) => f.severity === "critical").length,
|
|
212
|
+
warning: plan.findings.filter((f) => f.severity === "warning").length,
|
|
213
|
+
});
|
|
214
|
+
// Row-level detail for the hosted dashboard: IDs + issue type only (no field
|
|
215
|
+
// values). Enrich each finding with its proposed op's field/operation when one
|
|
216
|
+
// is linked, so the dashboard can show "what" and "where" without "the value".
|
|
217
|
+
const opByFinding = new Map<string, { field?: string; operation?: string }>();
|
|
218
|
+
for (const op of plan.operations ?? []) {
|
|
219
|
+
for (const findingId of op.findingIds ?? []) {
|
|
220
|
+
if (!opByFinding.has(findingId)) {
|
|
221
|
+
opByFinding.set(findingId, { field: op.field, operation: op.operation });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
reportFindings(
|
|
226
|
+
plan.findings.map((f) => ({
|
|
227
|
+
objectType: f.objectType,
|
|
228
|
+
objectId: f.objectId,
|
|
229
|
+
severity: f.severity,
|
|
230
|
+
ruleId: f.ruleId,
|
|
231
|
+
...opByFinding.get(f.id),
|
|
232
|
+
})),
|
|
233
|
+
);
|
|
234
|
+
// When auditing a live HubSpot, emit the account's record-URL base so the
|
|
235
|
+
// hosted dashboard can deep-link each finding to the real CRM record. Best
|
|
236
|
+
// effort and HubSpot-only for now; never blocks or fails the audit.
|
|
237
|
+
if (option(args, "--provider") === "hubspot") {
|
|
238
|
+
await reportHubspotDeepLinkBase();
|
|
239
|
+
await registerHubspotWithBroker();
|
|
240
|
+
}
|
|
241
|
+
const out = option(args, "--out");
|
|
242
|
+
if (out) {
|
|
243
|
+
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
|
|
244
|
+
}
|
|
245
|
+
if (saveRequested(args)) {
|
|
246
|
+
await createFilePlanStore().save(plan);
|
|
247
|
+
// Engagement workspace: stamp the per-profile health timeline + snapshot so
|
|
248
|
+
// the org accrues a continuous record from the verb people already run.
|
|
249
|
+
// Honor --today (audit is deterministic under it); fall back to wall-clock.
|
|
250
|
+
const health = computeHealth(plan, snapshot, today ?? new Date().toISOString());
|
|
251
|
+
appendHealthEntry(health);
|
|
252
|
+
saveWorkspaceSnapshot(plan.id, snapshot);
|
|
253
|
+
console.error(
|
|
254
|
+
`Saved plan ${plan.id}. Health ${health.score}/100 recorded (\`fullstackgtm health\`). ` +
|
|
255
|
+
`Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`.`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (args.includes("--json")) {
|
|
259
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
260
|
+
} else {
|
|
261
|
+
// Default to the summary view (rule table + counts); the full per-operation
|
|
262
|
+
// dump is opt-in via --full or, for a deliverable, `report`. (dx-punch-list #3)
|
|
263
|
+
// Interactive terminals get the styled rendering; piped output is unchanged.
|
|
264
|
+
console.log(
|
|
265
|
+
stylizePlanMarkdown(
|
|
266
|
+
patchPlanToMarkdown(plan, { summary: !args.includes("--full") }),
|
|
267
|
+
paint(colorEnabled(process.stdout)),
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
console.error(`\n${auditNextStep(args, plan)}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
threshold &&
|
|
275
|
+
plan.findings.some((finding) => SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[threshold])
|
|
276
|
+
) {
|
|
277
|
+
process.exitCode = 2;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Roll up the active profile's health timeline (accrued by `audit --save`):
|
|
283
|
+
* current deterministic score, change since the last audit, and per-rule
|
|
284
|
+
* deltas. Read-only — it only reads `health.jsonl`, never re-audits.
|
|
285
|
+
*/
|
|
286
|
+
export function healthCommand(args: string[]) {
|
|
287
|
+
const profile = activeWorkspaceProfile();
|
|
288
|
+
const rollup = summarizeHealth(readHealthTimeline(), profile);
|
|
289
|
+
if (!rollup) {
|
|
290
|
+
if (args.includes("--json")) {
|
|
291
|
+
console.log(JSON.stringify({ profile, auditCount: 0 }, null, 2));
|
|
292
|
+
} else {
|
|
293
|
+
console.log(
|
|
294
|
+
`No audits recorded yet for profile "${profile}".\n` +
|
|
295
|
+
"Start the timeline: `fullstackgtm audit --provider <name> --save`" +
|
|
296
|
+
(profile === "default" ? "" : ` --profile ${profile}`) +
|
|
297
|
+
".",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (args.includes("--json")) {
|
|
303
|
+
console.log(JSON.stringify(rollup, null, 2));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const p = paint(colorEnabled(process.stdout));
|
|
307
|
+
// The markdown rollup is a deliverable — piped/redirected output stays
|
|
308
|
+
// byte-identical to the pre-TUI CLI. Interactive terminals get the styled view.
|
|
309
|
+
console.log(p.enabled ? renderHealthRich(rollup, p) : healthToMarkdown(rollup));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Interactive-terminal rendering of the health rollup: the markdown's facts, styled. */
|
|
313
|
+
function renderHealthRich(rollup: HealthRollup, p: Paint): string {
|
|
314
|
+
const { current, scoreDelta } = rollup;
|
|
315
|
+
const deltaText =
|
|
316
|
+
scoreDelta === null
|
|
317
|
+
? p.dim("(first audit — no prior reading)")
|
|
318
|
+
: `${scoreDelta === 0 ? p.dim("·") : scoreDelta > 0 ? p.green("▲") : p.red("▼")} ${
|
|
319
|
+
scoreDelta >= 0 ? p.green(`+${scoreDelta}`) : p.red(`${scoreDelta}`)
|
|
320
|
+
} ${p.dim("since the previous audit")}`;
|
|
321
|
+
const trend =
|
|
322
|
+
rollup.history.length > 1
|
|
323
|
+
? ` ${p.cyan(sparkline(rollup.history.map((point) => point.score)))}`
|
|
324
|
+
: "";
|
|
325
|
+
const severity = (count: number, label: string, painter: (text: string) => string) =>
|
|
326
|
+
count > 0 ? painter(`${count} ${label}`) : p.dim(`${count} ${label}`);
|
|
327
|
+
|
|
328
|
+
const lines = [
|
|
329
|
+
p.bold(`GTM health — profile ${rollup.profile}`),
|
|
330
|
+
"",
|
|
331
|
+
`Score ${p.bold(scoreColor(current.score, p))}${p.dim("/100")} ${deltaText}${trend}`,
|
|
332
|
+
p.dim(`${rollup.auditCount} audit(s), ${shortDay(rollup.first)} → ${shortDay(rollup.latest)}`),
|
|
333
|
+
`Findings: ${formatCount(current.findings)} (${severity(current.severityCounts.critical, "critical", p.red)}, ${severity(current.severityCounts.warning, "warning", p.yellow)}, ${severity(current.severityCounts.info, "info", p.dim)})`,
|
|
334
|
+
"",
|
|
335
|
+
// Widths are measured on raw cell text, so cells stay unstyled here; the
|
|
336
|
+
// header row is dimmed as a whole line after layout.
|
|
337
|
+
...table([
|
|
338
|
+
["", "type", "score", "findings", "records"],
|
|
339
|
+
...(["account", "contact", "deal"] as const).map((type) => {
|
|
340
|
+
const bucket = current.byObjectType[type];
|
|
341
|
+
return ["", type, `${bucket.score}/100`, formatCount(bucket.findings), formatCount(bucket.records)];
|
|
342
|
+
}),
|
|
343
|
+
]).map((row, index) => (index === 0 ? p.dim(row) : row)),
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
if (rollup.ruleDeltas.length > 0) {
|
|
347
|
+
// More findings = worse, so a positive delta paints red. The delta is the
|
|
348
|
+
// LAST column: ANSI codes there never skew the padding of columns before it.
|
|
349
|
+
const deltaCell = (delta: number) =>
|
|
350
|
+
delta === 0 ? p.dim("·") : delta > 0 ? p.red(`+${delta}`) : p.green(`${delta}`);
|
|
351
|
+
lines.push(
|
|
352
|
+
"",
|
|
353
|
+
p.dim("By rule (Δ since previous audit)"),
|
|
354
|
+
...table(
|
|
355
|
+
rollup.ruleDeltas.map((rule) => ["", rule.ruleId, formatCount(rule.current), deltaCell(rule.delta)]),
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return lines.join("\n");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 2026-06-12T08:00:00Z → "2026-06-12". */
|
|
364
|
+
function shortDay(at: string): string {
|
|
365
|
+
return at.slice(0, 10);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Render an audit as a client-facing deliverable. Same sources and audit
|
|
370
|
+
* options as `audit`; `--plan` instead renders an existing plan JSON without
|
|
371
|
+
* re-fetching (useful for a plan produced earlier or by another machine).
|
|
372
|
+
*/
|
|
373
|
+
export async function reportCommand(args: string[]) {
|
|
374
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
375
|
+
const configuredRules = await resolveConfiguredRules(loaded);
|
|
376
|
+
|
|
377
|
+
let plan: PatchPlan;
|
|
378
|
+
let snapshot: CanonicalGtmSnapshot | undefined;
|
|
379
|
+
const planPath = option(args, "--plan");
|
|
380
|
+
if (planPath) {
|
|
381
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
382
|
+
const input = option(args, "--input");
|
|
383
|
+
if (input) {
|
|
384
|
+
snapshot = JSON.parse(
|
|
385
|
+
readFileSync(resolve(process.cwd(), input), "utf8"),
|
|
386
|
+
) as CanonicalGtmSnapshot;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
snapshot = await readSnapshot(args);
|
|
390
|
+
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
391
|
+
const today = option(args, "--today");
|
|
392
|
+
if (today) policy.today = today;
|
|
393
|
+
const staleDealDays = numericOption(args, "--stale-days");
|
|
394
|
+
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
395
|
+
plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const reportOptions: ReportOptions = {
|
|
399
|
+
title: option(args, "--title") ?? undefined,
|
|
400
|
+
clientName: option(args, "--client") ?? undefined,
|
|
401
|
+
preparedBy: option(args, "--prepared-by") ?? undefined,
|
|
402
|
+
date: option(args, "--today") ?? undefined,
|
|
403
|
+
maxExamplesPerRule: numericOption(args, "--max-examples"),
|
|
404
|
+
rules: configuredRules,
|
|
405
|
+
snapshot,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const out = option(args, "--out");
|
|
409
|
+
const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
|
|
410
|
+
if (format !== "markdown" && format !== "html") {
|
|
411
|
+
throw new Error("--format must be markdown or html");
|
|
412
|
+
}
|
|
413
|
+
const rendered =
|
|
414
|
+
format === "html"
|
|
415
|
+
? auditReportToHtml(plan, reportOptions)
|
|
416
|
+
: auditReportToMarkdown(plan, reportOptions);
|
|
417
|
+
|
|
418
|
+
if (out) {
|
|
419
|
+
writeFileSync(resolve(process.cwd(), out), rendered);
|
|
420
|
+
console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
|
|
421
|
+
} else {
|
|
422
|
+
console.log(rendered.trimEnd());
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function rulesCommand(args: string[]) {
|
|
427
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
428
|
+
const rules = await resolveConfiguredRules(loaded);
|
|
429
|
+
if (args.includes("--json")) {
|
|
430
|
+
console.log(
|
|
431
|
+
JSON.stringify(
|
|
432
|
+
rules.map(({ id, title, description, category }) => ({
|
|
433
|
+
id,
|
|
434
|
+
title,
|
|
435
|
+
description,
|
|
436
|
+
category: category ?? "uncategorized",
|
|
437
|
+
})),
|
|
438
|
+
null,
|
|
439
|
+
2,
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
for (const rule of rules) {
|
|
445
|
+
console.log(`${rule.id} [${rule.category ?? "uncategorized"}]\n ${rule.title}\n ${rule.description}\n`);
|
|
446
|
+
}
|
|
447
|
+
}
|