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/src/cli/schedule.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
|
|
|
9
9
|
import { runCli } from "../cli.ts";
|
|
10
10
|
import { isOptionValue, numericOption, option } from "./shared.ts";
|
|
11
11
|
import { unknownSubcommandError } from "./suggest.ts";
|
|
12
|
+
import { colorEnabled, paint, table } from "./ui.ts";
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -120,12 +121,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
120
121
|
console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const p = paint(colorEnabled(process.stdout));
|
|
125
|
+
const verbose = rest.includes("--verbose");
|
|
126
|
+
const rows = [
|
|
127
|
+
["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
|
|
128
|
+
...withNext.map((entry) => [
|
|
129
|
+
entry.enabled ? "on" : "off",
|
|
130
|
+
`${entry.label} · ${entry.id}`,
|
|
131
|
+
entry.nextFiring ?? "—",
|
|
132
|
+
...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
|
|
133
|
+
]),
|
|
134
|
+
];
|
|
135
|
+
console.log(table(rows, [p.dim, null, null]).join("\n"));
|
|
136
|
+
console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
|
|
129
137
|
return;
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -272,21 +280,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
272
280
|
const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
|
|
273
281
|
const streak = entry.streak as { outcome: string; length: number } | null;
|
|
274
282
|
const missed = entry.missedFirings as string[];
|
|
275
|
-
|
|
276
|
-
|
|
283
|
+
const verbose = rest.includes("--verbose");
|
|
284
|
+
const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
|
|
285
|
+
console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
|
|
286
|
+
console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
|
|
287
|
+
if (verbose) console.log(` ${(entry.argv as string[]).join(" ")} · cron ${entry.cron}`);
|
|
277
288
|
if (last) {
|
|
278
289
|
const artifacts = [
|
|
279
290
|
...last.artifacts.planIds.map((planId) => `plan ${planId}`),
|
|
280
291
|
...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
|
|
281
292
|
];
|
|
282
|
-
console.log(
|
|
283
|
-
` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
|
|
284
|
-
(last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
|
|
285
|
-
(artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
|
|
286
|
-
);
|
|
287
|
-
console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
|
|
293
|
+
console.log(` last ${last.firedAt} · ${last.trigger} · ${streak!.length} ${streak!.outcome}${streak!.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
|
|
288
294
|
} else {
|
|
289
|
-
console.log(" last
|
|
295
|
+
console.log(" last never fired");
|
|
290
296
|
}
|
|
291
297
|
if (missed.length > 0) {
|
|
292
298
|
console.log(
|
package/src/cli/signals.ts
CHANGED
|
@@ -27,6 +27,21 @@ function resolveSignalsConfig(args: string[]): SignalsConfig {
|
|
|
27
27
|
/** A watchlist entry: an account domain plus optional per-source board tokens. */
|
|
28
28
|
type WatchlistAccount = { domain: string; boards?: Partial<Record<AtsBoardSource, string>> };
|
|
29
29
|
|
|
30
|
+
function concise(value: string, max = 72): string {
|
|
31
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
32
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderSignals(signals: Signal[]): string {
|
|
36
|
+
if (signals.length === 0) return "No signals matched.";
|
|
37
|
+
const lines = [`Fresh signals (${signals.length})`, ""];
|
|
38
|
+
for (const signal of signals) {
|
|
39
|
+
lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
|
|
40
|
+
lines.push(` ${concise(signal.trigger)}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/**
|
|
31
46
|
* Resolve the watchlist of accounts to scan. Sources, in precedence order:
|
|
32
47
|
* - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
|
|
@@ -228,7 +243,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
228
243
|
const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
229
244
|
|
|
230
245
|
// Ranked fresh signals to stdout; guidance to stderr.
|
|
231
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
246
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
232
247
|
console.error(
|
|
233
248
|
`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
|
|
234
249
|
`(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`,
|
|
@@ -275,7 +290,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
275
290
|
return true;
|
|
276
291
|
});
|
|
277
292
|
const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
278
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
293
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
279
294
|
console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
|
|
280
295
|
return;
|
|
281
296
|
}
|
|
@@ -305,7 +320,11 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
305
320
|
const outcomes = await store.listOutcomes();
|
|
306
321
|
const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
|
|
307
322
|
const weights = computeWeights(config, outcomes, signalsById);
|
|
308
|
-
|
|
323
|
+
if (rest.includes("--json") || rest.includes("--verbose")) {
|
|
324
|
+
console.log(JSON.stringify(weights, null, 2));
|
|
325
|
+
} else {
|
|
326
|
+
console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
|
|
327
|
+
}
|
|
309
328
|
if (rest.includes("--explain")) {
|
|
310
329
|
// Per-bucket config default vs learned + booked/total over credited signals.
|
|
311
330
|
const booked = new Map<SignalBucket, number>();
|
package/src/cli/tam.ts
CHANGED
|
@@ -275,8 +275,15 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
|
|
|
275
275
|
if (out) {
|
|
276
276
|
writeFileSync(resolve(process.cwd(), out), md);
|
|
277
277
|
console.log(`Wrote ${out}.`);
|
|
278
|
-
} else {
|
|
278
|
+
} else if (rest.includes("--verbose")) {
|
|
279
279
|
console.log(md);
|
|
280
|
+
} else if (timeline.length > 0) {
|
|
281
|
+
console.log(coverageToText(model, timeline.at(-1)!, eta));
|
|
282
|
+
console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
|
|
283
|
+
} else {
|
|
284
|
+
console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
|
|
285
|
+
console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
|
|
286
|
+
console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
|
|
280
287
|
}
|
|
281
288
|
return;
|
|
282
289
|
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -324,8 +324,12 @@ export function createChecklist(
|
|
|
324
324
|
const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
|
|
325
325
|
return ` ${glyph} ${label}${note}`;
|
|
326
326
|
});
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
// Keep the cursor on the board's last row while it is live. A trailing
|
|
328
|
+
// newline on every repaint can scroll the old top row into permanent
|
|
329
|
+
// history when the board sits at the bottom of the terminal, producing
|
|
330
|
+
// apparent duplicate/errored frames in terminal captures.
|
|
331
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
332
|
+
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
|
|
329
333
|
painted = lines.length;
|
|
330
334
|
};
|
|
331
335
|
|
|
@@ -353,14 +357,19 @@ export function createChecklist(
|
|
|
353
357
|
timer = null;
|
|
354
358
|
if (options.persist) {
|
|
355
359
|
// The caller's final state update already painted the completed board.
|
|
356
|
-
//
|
|
357
|
-
|
|
360
|
+
// Advance exactly once so subsequent output begins below it.
|
|
361
|
+
if (painted > 0) stream.write("\n");
|
|
358
362
|
painted = 0;
|
|
359
363
|
return;
|
|
360
364
|
}
|
|
361
365
|
if (painted > 0) {
|
|
362
|
-
// Erase
|
|
363
|
-
|
|
366
|
+
// Erase in place without linefeeds, which could themselves scroll.
|
|
367
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
368
|
+
const clear = Array.from({ length: painted }, (_, index) =>
|
|
369
|
+
`\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`,
|
|
370
|
+
).join("");
|
|
371
|
+
const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
372
|
+
stream.write(`${up}${clear}${restore}`);
|
|
364
373
|
painted = 0;
|
|
365
374
|
}
|
|
366
375
|
},
|
|
@@ -42,6 +42,10 @@ export type HubspotConnectorOptions = {
|
|
|
42
42
|
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
43
43
|
*/
|
|
44
44
|
progress?: ProgressEmitter;
|
|
45
|
+
/** Maximum retries for HubSpot 429/5xx responses (default 5). */
|
|
46
|
+
maxRetries?: number;
|
|
47
|
+
/** Injectable delay for deterministic retry tests. */
|
|
48
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
|
|
@@ -76,6 +80,8 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
|
|
|
76
80
|
export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
|
|
77
81
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
78
82
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
83
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
84
|
+
const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
79
85
|
const mappings = options.fieldMappings;
|
|
80
86
|
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
81
87
|
// search API is eventually consistent, so a just-created company is
|
|
@@ -113,27 +119,45 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Hubspo
|
|
|
113
119
|
};
|
|
114
120
|
|
|
115
121
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
116
|
-
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
let response: Response | undefined;
|
|
123
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
124
|
+
const token = await options.getAccessToken();
|
|
125
|
+
try {
|
|
126
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
127
|
+
...init,
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${token}`,
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
...(init.headers ?? {}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
136
|
+
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
137
|
+
}
|
|
138
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
139
|
+
if (!retryable || attempt === maxRetries) break;
|
|
140
|
+
await response.text().catch(() => undefined);
|
|
141
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
142
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
143
|
+
const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
|
|
144
|
+
const delayMs = Math.min(
|
|
145
|
+
30_000,
|
|
146
|
+
Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt),
|
|
147
|
+
);
|
|
148
|
+
const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
|
|
149
|
+
options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
|
|
150
|
+
await sleep(delayMs);
|
|
130
151
|
}
|
|
131
|
-
if (!response.ok) {
|
|
152
|
+
if (!response || !response.ok) {
|
|
132
153
|
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
133
154
|
// (contact emails, company domains) and the request payload, and these
|
|
134
155
|
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
135
|
-
await response
|
|
136
|
-
|
|
156
|
+
await response?.text().catch(() => undefined);
|
|
157
|
+
if (response?.status === 429) {
|
|
158
|
+
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.`);
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
|
|
137
161
|
}
|
|
138
162
|
// DELETE and some association writes return 204 with an empty body.
|
|
139
163
|
const text = await response.text();
|
package/src/hostedPatchPlan.ts
CHANGED
|
@@ -207,7 +207,12 @@ export async function reportHostedPlanLifecycle(stored: StoredPlan, options: Opt
|
|
|
207
207
|
if (!paired) return { status: "unpaired" };
|
|
208
208
|
const status = stored.status === "applied" ? "applied" : stored.status === "rejected" ? "rejected" : "approved";
|
|
209
209
|
const runRecord = status === "applied" ? stored.runs.at(-1) : undefined;
|
|
210
|
-
|
|
210
|
+
// The hosted terminal transition is authority-bound to the exact approved
|
|
211
|
+
// subset. Local runs also record excluded operations as `skipped` for a
|
|
212
|
+
// complete human audit trail; those are not execution receipts and must not
|
|
213
|
+
// be echoed as though they were authorized provider work.
|
|
214
|
+
const approved = new Set(stored.approvedOperationIds);
|
|
215
|
+
const operationReceipts = runRecord?.results.filter((result) => approved.has(result.operationId)).map((result) => ({
|
|
211
216
|
packageOpId: result.operationId,
|
|
212
217
|
status: result.status,
|
|
213
218
|
...(result.detail ? { error: result.detail.slice(0, 1000) } : {}),
|