fullstackgtm 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/cli/auth.ts
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken } from "../connectors/hubspotAuth.ts";
|
|
6
|
+
import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken } from "../connectors/salesforceAuth.ts";
|
|
7
|
+
import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, storeCredential, type StoredCredential } from "../credentials.ts";
|
|
8
|
+
import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.ts";
|
|
9
|
+
import { resolveLlmCredential, validateLlmKey } from "../llm.ts";
|
|
10
|
+
import { createFilePlanStore, type StoredPlan } from "../planStore.ts";
|
|
11
|
+
import { isOptionValue, numericOption, option, readPackageInfo, readSecret } from "./shared.ts";
|
|
12
|
+
import { box, colorEnabled, paint, scoreColor, sparkline } from "./ui.ts";
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Provider error bodies can echo request parameters (including secrets) and
|
|
17
|
+
* land in logs. Surface only the status and, when present, a known-safe error
|
|
18
|
+
* description field — never the raw body.
|
|
19
|
+
*/
|
|
20
|
+
function safeStatus(response: Response): string {
|
|
21
|
+
return `HTTP ${response.status} ${response.statusText}`.trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rejectArgvSecret(args: string[], ...flags: string[]) {
|
|
25
|
+
for (const flag of flags) {
|
|
26
|
+
if (args.includes(flag)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`${flag} no longer accepts a value on the command line (argv secrets leak via \`ps\` and ` +
|
|
29
|
+
`shell history). Pipe the secret on stdin instead, e.g. \`echo "$SECRET" | fullstackgtm login ...\`.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The broker channel carries a long-lived pairing bearer and receives freshly
|
|
37
|
+
* minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
|
|
38
|
+
* dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
|
|
39
|
+
* shell escaping does nothing for a token sent in cleartext.
|
|
40
|
+
*/
|
|
41
|
+
export function assertSecureBrokerUrl(raw: string): URL {
|
|
42
|
+
let url: URL;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`--via must be a full URL (e.g. https://gtm.yourco.com), got "${raw}".`);
|
|
47
|
+
}
|
|
48
|
+
const isLocalhost =
|
|
49
|
+
url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
|
|
50
|
+
if (url.protocol === "https:") return url;
|
|
51
|
+
if (url.protocol === "http:" && isLocalhost) return url; // local dev only
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
|
|
54
|
+
"credentials, so it must use https (http is allowed only for localhost dev).",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_HOSTED_BASE_URL = "https://app.fullstackgtm.com";
|
|
59
|
+
|
|
60
|
+
type CrmProvider = "hubspot" | "salesforce";
|
|
61
|
+
|
|
62
|
+
async function brokerLogin(baseUrl: string) {
|
|
63
|
+
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
64
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
65
|
+
const os = await import("node:os");
|
|
66
|
+
// Self-reported, shown to the approver so they can recognize this request
|
|
67
|
+
// and refuse one they didn't initiate.
|
|
68
|
+
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
69
|
+
let startResponse: Response;
|
|
70
|
+
try {
|
|
71
|
+
startResponse = await fetch(`${base}/api/cli/auth/start`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ requesterLabel }),
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
78
|
+
throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
|
|
79
|
+
}
|
|
80
|
+
if (!startResponse.ok) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const start = await startResponse.json();
|
|
86
|
+
// Only auto-open a verification URL that belongs to the --via origin the user
|
|
87
|
+
// typed — a malicious/typo'd deployment cannot redirect the browser elsewhere.
|
|
88
|
+
let sameOrigin = false;
|
|
89
|
+
try {
|
|
90
|
+
sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
|
|
91
|
+
} catch {
|
|
92
|
+
sameOrigin = false;
|
|
93
|
+
}
|
|
94
|
+
console.error(
|
|
95
|
+
`\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`,
|
|
96
|
+
);
|
|
97
|
+
if (sameOrigin) {
|
|
98
|
+
void openInBrowser(start.verificationUrl);
|
|
99
|
+
} else {
|
|
100
|
+
console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
|
|
104
|
+
const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
|
|
105
|
+
while (Date.now() < deadline) {
|
|
106
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
107
|
+
const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
111
|
+
});
|
|
112
|
+
if (!pollResponse.ok) {
|
|
113
|
+
throw new Error(`Pairing poll failed (${pollResponse.status}).`);
|
|
114
|
+
}
|
|
115
|
+
const poll = await pollResponse.json();
|
|
116
|
+
if (poll.status === "pending") continue;
|
|
117
|
+
if (poll.status === "approved" && poll.cliToken) {
|
|
118
|
+
const now = new Date().toISOString();
|
|
119
|
+
storeCredential("broker", {
|
|
120
|
+
kind: "broker",
|
|
121
|
+
accessToken: poll.cliToken,
|
|
122
|
+
baseUrl: base,
|
|
123
|
+
createdAt: now,
|
|
124
|
+
updatedAt: now,
|
|
125
|
+
});
|
|
126
|
+
console.log(`Paired with ${base}. Credentials stored in ${credentialsPath()}.`);
|
|
127
|
+
console.log(
|
|
128
|
+
"Provider commands now use the organization's stored sync credentials via the deployment.",
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`Pairing was ${poll.status}.`);
|
|
133
|
+
}
|
|
134
|
+
throw new Error("Pairing timed out before it was approved.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function hostedProviderLogin(provider: CrmProvider, baseUrl: string) {
|
|
138
|
+
const viaUrl = assertSecureBrokerUrl(baseUrl);
|
|
139
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
140
|
+
const os = await import("node:os");
|
|
141
|
+
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
142
|
+
let startResponse: Response;
|
|
143
|
+
try {
|
|
144
|
+
startResponse = await fetch(`${base}/api/cli/oauth/start`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ provider, requesterLabel }),
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
151
|
+
throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
|
|
152
|
+
}
|
|
153
|
+
if (!startResponse.ok) {
|
|
154
|
+
throw new Error(`Could not start ${provider} OAuth with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
|
|
155
|
+
}
|
|
156
|
+
const start = await startResponse.json();
|
|
157
|
+
let sameOrigin = false;
|
|
158
|
+
try {
|
|
159
|
+
sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
|
|
160
|
+
} catch {
|
|
161
|
+
sameOrigin = false;
|
|
162
|
+
}
|
|
163
|
+
console.error(
|
|
164
|
+
`\n${provider} OAuth code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your hosted FullStackGTM dashboard:\n\n ${start.verificationUrl}\n`,
|
|
165
|
+
);
|
|
166
|
+
if (sameOrigin) {
|
|
167
|
+
void openInBrowser(start.verificationUrl);
|
|
168
|
+
} else {
|
|
169
|
+
console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
|
|
173
|
+
const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
176
|
+
const pollResponse = await fetch(`${base}/api/cli/auth/poll`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "Content-Type": "application/json" },
|
|
179
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
180
|
+
});
|
|
181
|
+
if (!pollResponse.ok) throw new Error(`Pairing poll failed (${pollResponse.status}).`);
|
|
182
|
+
const poll = await pollResponse.json();
|
|
183
|
+
if (poll.status === "pending") continue;
|
|
184
|
+
if (poll.status === "approved" && poll.cliToken) {
|
|
185
|
+
const now = new Date().toISOString();
|
|
186
|
+
storeCredential("broker", {
|
|
187
|
+
kind: "broker",
|
|
188
|
+
accessToken: poll.cliToken,
|
|
189
|
+
baseUrl: base,
|
|
190
|
+
createdAt: now,
|
|
191
|
+
updatedAt: now,
|
|
192
|
+
});
|
|
193
|
+
console.log(`Logged in to ${provider} via hosted OAuth at ${base}. Credentials stored in ${credentialsPath()}.`);
|
|
194
|
+
console.log("Provider tokens are minted server-side by the hosted app; no provider app secret is stored in this CLI.");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`Pairing was ${poll.status}.`);
|
|
198
|
+
}
|
|
199
|
+
throw new Error("Pairing timed out before it was approved.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function hostedBase(args: string[]): string {
|
|
203
|
+
return option(args, "--via") ?? process.env.FULLSTACKGTM_HOSTED_URL ?? DEFAULT_HOSTED_BASE_URL;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function guidedProviderLogin(provider: CrmProvider, args: string[], reason: string) {
|
|
207
|
+
const command = `fullstackgtm login ${provider} --hosted`;
|
|
208
|
+
if (!process.stdin.isTTY || process.env.CI) {
|
|
209
|
+
console.error(`${reason}\nNext command: ${command}`);
|
|
210
|
+
throw new Error(reason);
|
|
211
|
+
}
|
|
212
|
+
console.error(
|
|
213
|
+
`\n${provider} login options:\n` +
|
|
214
|
+
` 1) Hosted OAuth (default): no provider app needed; browser approval via FullStackGTM.\n` +
|
|
215
|
+
` 2) BYO ${provider === "salesforce" ? "Connected App" : "HubSpot app"}: advanced OAuth/device flow.\n` +
|
|
216
|
+
` 3) Paste token: read from stdin/prompt; never from argv.\n`,
|
|
217
|
+
);
|
|
218
|
+
const readline = await import("node:readline/promises");
|
|
219
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
220
|
+
const answer = (await rl.question("Choose [1]: ")).trim();
|
|
221
|
+
rl.close();
|
|
222
|
+
if (answer === "" || answer === "1") return hostedProviderLogin(provider, hostedBase(args));
|
|
223
|
+
if (answer === "2") {
|
|
224
|
+
throw new Error(
|
|
225
|
+
provider === "salesforce"
|
|
226
|
+
? "Advanced BYO Salesforce: run `fullstackgtm login salesforce --device --client-id <consumer key>` or pipe a token with `--instance-url <url>`."
|
|
227
|
+
: `Advanced BYO HubSpot: run \`fullstackgtm login hubspot --oauth --client-id <id>\` (redirect http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback).`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (answer === "3") {
|
|
231
|
+
throw new Error(
|
|
232
|
+
provider === "salesforce"
|
|
233
|
+
? "Paste-token Salesforce: pipe the token with `fullstackgtm login salesforce --instance-url <url>`."
|
|
234
|
+
: "Paste-token HubSpot: pipe the token with `fullstackgtm login hubspot --private-token`.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return hostedProviderLogin(provider, hostedBase(args));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function salesforceLogin(args: string[]) {
|
|
241
|
+
const now = new Date().toISOString();
|
|
242
|
+
rejectArgvSecret(args, "--token");
|
|
243
|
+
|
|
244
|
+
if (args.includes("--hosted") || (!args.includes("--device") && !args.includes("--instance-url"))) {
|
|
245
|
+
await hostedProviderLogin("salesforce", hostedBase(args));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (args.includes("--device")) {
|
|
250
|
+
const clientId = option(args, "--client-id");
|
|
251
|
+
if (!clientId) {
|
|
252
|
+
await guidedProviderLogin("salesforce", args, "--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const loginUrl = option(args, "--login-url") ?? undefined;
|
|
256
|
+
const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
|
|
257
|
+
console.error(
|
|
258
|
+
`\nPairing code: ${authorization.userCode}\n\nConfirm this code at:\n\n ${authorization.verificationUri}\n`,
|
|
259
|
+
);
|
|
260
|
+
void openInBrowser(authorization.verificationUri);
|
|
261
|
+
const tokens = await pollSalesforceDeviceLogin({
|
|
262
|
+
clientId,
|
|
263
|
+
deviceCode: authorization.deviceCode,
|
|
264
|
+
intervalSeconds: authorization.intervalSeconds,
|
|
265
|
+
loginUrl,
|
|
266
|
+
});
|
|
267
|
+
storeCredential("salesforce", {
|
|
268
|
+
kind: "oauth",
|
|
269
|
+
accessToken: tokens.accessToken,
|
|
270
|
+
refreshToken: tokens.refreshToken,
|
|
271
|
+
instanceUrl: tokens.instanceUrl,
|
|
272
|
+
expiresAt: tokens.expiresAt,
|
|
273
|
+
clientId,
|
|
274
|
+
loginUrl,
|
|
275
|
+
createdAt: now,
|
|
276
|
+
updatedAt: now,
|
|
277
|
+
});
|
|
278
|
+
console.log(
|
|
279
|
+
`Logged in to Salesforce (${tokens.instanceUrl}). Credentials stored in ${credentialsPath()}.`,
|
|
280
|
+
);
|
|
281
|
+
console.log("Tokens refresh silently; no further browser interaction is needed.");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const instanceUrl = option(args, "--instance-url");
|
|
286
|
+
if (!instanceUrl) {
|
|
287
|
+
await guidedProviderLogin(
|
|
288
|
+
"salesforce",
|
|
289
|
+
args,
|
|
290
|
+
"Salesforce login needs hosted OAuth, --device --client-id <consumer key>, or --instance-url <https://yourorg.my.salesforce.com> with the access token piped on stdin.",
|
|
291
|
+
);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const token = await readSecret("Salesforce access token");
|
|
295
|
+
if (!token) throw new Error("No access token provided.");
|
|
296
|
+
if (!args.includes("--no-validate")) {
|
|
297
|
+
const result = await validateSalesforceToken(token, instanceUrl);
|
|
298
|
+
if (!result.ok) throw new Error(result.detail);
|
|
299
|
+
console.log(result.detail);
|
|
300
|
+
}
|
|
301
|
+
storeCredential("salesforce", {
|
|
302
|
+
kind: "private_app",
|
|
303
|
+
accessToken: token,
|
|
304
|
+
instanceUrl,
|
|
305
|
+
createdAt: now,
|
|
306
|
+
updatedAt: now,
|
|
307
|
+
});
|
|
308
|
+
console.log(`Logged in to Salesforce. Credentials stored in ${credentialsPath()}.`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function login(args: string[]) {
|
|
312
|
+
const provider = args.find((arg) => !arg.startsWith("--") && !isOptionValue(args, arg));
|
|
313
|
+
const via = option(args, "--via");
|
|
314
|
+
if (via && !provider) {
|
|
315
|
+
await brokerLogin(via);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (provider === "salesforce") {
|
|
319
|
+
await salesforceLogin(args);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (provider === "stripe") {
|
|
323
|
+
rejectArgvSecret(args, "--token");
|
|
324
|
+
const key = await readSecret("Stripe secret key (sk_...)");
|
|
325
|
+
if (!key) throw new Error("No Stripe key provided.");
|
|
326
|
+
if (!args.includes("--no-validate")) {
|
|
327
|
+
const response = await fetch("https://api.stripe.com/v1/customers?limit=1", {
|
|
328
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
329
|
+
});
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
throw new Error(`Stripe rejected the key (${response.status}): ${safeStatus(response)}`);
|
|
332
|
+
}
|
|
333
|
+
console.log("Key accepted by the Stripe API.");
|
|
334
|
+
}
|
|
335
|
+
const stamp = new Date().toISOString();
|
|
336
|
+
storeCredential("stripe", {
|
|
337
|
+
kind: "private_app",
|
|
338
|
+
accessToken: key,
|
|
339
|
+
createdAt: stamp,
|
|
340
|
+
updatedAt: stamp,
|
|
341
|
+
});
|
|
342
|
+
console.log(`Logged in to Stripe. Credentials stored in ${credentialsPath()}.`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (provider === "anthropic" || provider === "openai") {
|
|
346
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
347
|
+
const key = await readSecret(`${provider} API key (${provider === "anthropic" ? "sk-ant-..." : "sk-..."})`);
|
|
348
|
+
if (!key) throw new Error(`No ${provider} key provided.`);
|
|
349
|
+
if (!args.includes("--no-validate")) {
|
|
350
|
+
const validation = await validateLlmKey(provider, key);
|
|
351
|
+
if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
|
|
352
|
+
console.log(validation.detail);
|
|
353
|
+
}
|
|
354
|
+
const stamp = new Date().toISOString();
|
|
355
|
+
storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
356
|
+
console.log(`Stored ${provider} API key in ${credentialsPath()}. \`fullstackgtm call parse\` and \`call score\` use it automatically.`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (provider === "apollo") {
|
|
360
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
361
|
+
const key = await readSecret("Apollo API key");
|
|
362
|
+
if (!key) throw new Error("No Apollo key provided.");
|
|
363
|
+
if (!args.includes("--no-validate")) {
|
|
364
|
+
const response = await fetch("https://api.apollo.io/api/v1/auth/health", {
|
|
365
|
+
headers: { "X-Api-Key": key, Accept: "application/json" },
|
|
366
|
+
});
|
|
367
|
+
if (!response.ok) {
|
|
368
|
+
throw new Error(`Apollo rejected the key: ${safeStatus(response)}`);
|
|
369
|
+
}
|
|
370
|
+
console.log("Key accepted by the Apollo API.");
|
|
371
|
+
}
|
|
372
|
+
const stamp = new Date().toISOString();
|
|
373
|
+
storeCredential("apollo", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
374
|
+
console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
|
|
378
|
+
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
379
|
+
const key = await readSecret(`${provider} API key`);
|
|
380
|
+
if (!key) throw new Error(`No ${provider} key provided.`);
|
|
381
|
+
// No free auth-health endpoint; validating would spend credits, so the key
|
|
382
|
+
// is stored as-is and validated on the first pull.
|
|
383
|
+
const stamp = new Date().toISOString();
|
|
384
|
+
storeCredential(provider, { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
|
|
385
|
+
const usedBy =
|
|
386
|
+
provider === "theirstack" ? "`fullstackgtm tam estimate --source theirstack`" : "`fullstackgtm enrich acquire`";
|
|
387
|
+
console.log(`Stored ${provider} API key in ${credentialsPath()}. ${usedBy} uses it automatically (validated on first pull).`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (provider !== "hubspot") {
|
|
391
|
+
throw new Error(
|
|
392
|
+
"login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const now = new Date().toISOString();
|
|
396
|
+
rejectArgvSecret(args, "--token");
|
|
397
|
+
|
|
398
|
+
if (args.includes("--hosted") || (!args.includes("--oauth") && !args.includes("--private-token"))) {
|
|
399
|
+
await hostedProviderLogin("hubspot", hostedBase(args));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (args.includes("--oauth")) {
|
|
404
|
+
rejectArgvSecret(args, "--client-secret");
|
|
405
|
+
const clientId = option(args, "--client-id");
|
|
406
|
+
if (!clientId) {
|
|
407
|
+
await guidedProviderLogin(
|
|
408
|
+
"hubspot",
|
|
409
|
+
args,
|
|
410
|
+
"--oauth requires --client-id from your own HubSpot app " +
|
|
411
|
+
`(register http://localhost:${numericOption(args, "--port") ?? DEFAULT_LOOPBACK_PORT}/callback as a redirect URL).`,
|
|
412
|
+
);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const clientSecret = await readSecret("HubSpot app client secret");
|
|
416
|
+
if (!clientSecret) throw new Error("No client secret provided.");
|
|
417
|
+
const scopes = option(args, "--scopes")?.split(",").map((scope) => scope.trim());
|
|
418
|
+
const tokens = await runHubspotLoopbackLogin({
|
|
419
|
+
clientId,
|
|
420
|
+
clientSecret,
|
|
421
|
+
port: numericOption(args, "--port"),
|
|
422
|
+
scopes,
|
|
423
|
+
});
|
|
424
|
+
storeCredential("hubspot", {
|
|
425
|
+
kind: "oauth",
|
|
426
|
+
accessToken: tokens.accessToken,
|
|
427
|
+
refreshToken: tokens.refreshToken,
|
|
428
|
+
expiresAt: tokens.expiresAt,
|
|
429
|
+
clientId,
|
|
430
|
+
clientSecret,
|
|
431
|
+
scopes,
|
|
432
|
+
createdAt: now,
|
|
433
|
+
updatedAt: now,
|
|
434
|
+
});
|
|
435
|
+
console.log(`Logged in to HubSpot via OAuth. Credentials stored in ${credentialsPath()}.`);
|
|
436
|
+
console.log("Tokens refresh silently; no further browser interaction is needed.");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const token = await readSecret("HubSpot private app token");
|
|
441
|
+
if (!token) throw new Error("No token provided.");
|
|
442
|
+
if (!args.includes("--no-validate")) {
|
|
443
|
+
const result = await validateHubspotToken(token);
|
|
444
|
+
if (!result.ok) throw new Error(result.detail);
|
|
445
|
+
console.log(result.detail);
|
|
446
|
+
}
|
|
447
|
+
storeCredential("hubspot", {
|
|
448
|
+
kind: "private_app",
|
|
449
|
+
accessToken: token,
|
|
450
|
+
createdAt: now,
|
|
451
|
+
updatedAt: now,
|
|
452
|
+
});
|
|
453
|
+
console.log(`Logged in to HubSpot. Credentials stored in ${credentialsPath()}.`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function logout(args: string[]) {
|
|
457
|
+
const provider = args.find((arg) => !arg.startsWith("--"));
|
|
458
|
+
if (!provider) throw new Error("Usage: fullstackgtm logout hubspot");
|
|
459
|
+
if (!getCredential(provider)) {
|
|
460
|
+
console.log(`No stored credentials for ${provider}.`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
deleteCredential(provider);
|
|
464
|
+
console.log(`Removed stored ${provider} credentials.`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
type ProviderDoctorStatus = {
|
|
468
|
+
source: "env" | "stored" | "broker" | "none";
|
|
469
|
+
detail: string;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
export function doctorReport(env: Record<string, string | undefined> = process.env) {
|
|
473
|
+
const packageInfo = readPackageInfo();
|
|
474
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
475
|
+
const storePath = credentialsPath();
|
|
476
|
+
const configPath = resolve("fullstackgtm.config.json");
|
|
477
|
+
const broker = getCredential("broker");
|
|
478
|
+
|
|
479
|
+
const providers: Record<string, ProviderDoctorStatus> = {
|
|
480
|
+
hubspot: env.HUBSPOT_ACCESS_TOKEN
|
|
481
|
+
? { source: "env", detail: "HUBSPOT_ACCESS_TOKEN" }
|
|
482
|
+
: providerStatus("hubspot", broker),
|
|
483
|
+
salesforce:
|
|
484
|
+
env.SALESFORCE_ACCESS_TOKEN && env.SALESFORCE_INSTANCE_URL
|
|
485
|
+
? { source: "env", detail: "SALESFORCE_ACCESS_TOKEN + SALESFORCE_INSTANCE_URL" }
|
|
486
|
+
: providerStatus("salesforce", broker),
|
|
487
|
+
stripe: env.STRIPE_SECRET_KEY
|
|
488
|
+
? { source: "env", detail: "STRIPE_SECRET_KEY" }
|
|
489
|
+
: providerStatus("stripe", broker),
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const llm = resolveLlmCredential(env);
|
|
493
|
+
const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
|
|
494
|
+
try {
|
|
495
|
+
import.meta.resolve(name);
|
|
496
|
+
return false;
|
|
497
|
+
} catch {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
|
|
503
|
+
const nextSteps =
|
|
504
|
+
connected.length === 0
|
|
505
|
+
? [
|
|
506
|
+
"fullstackgtm audit --demo # no credentials needed",
|
|
507
|
+
"fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
|
|
508
|
+
]
|
|
509
|
+
: [`fullstackgtm audit --provider ${connected[0][0]}`];
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
package: packageInfo,
|
|
513
|
+
node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
|
|
514
|
+
profile: activeProfile(),
|
|
515
|
+
credentialStore: { path: storePath, exists: existsSync(storePath) },
|
|
516
|
+
config: { path: configPath, exists: existsSync(configPath) },
|
|
517
|
+
providers,
|
|
518
|
+
broker: broker ? { paired: true, baseUrl: broker.baseUrl ?? "unknown" } : { paired: false },
|
|
519
|
+
llm: llm
|
|
520
|
+
? { configured: true, provider: llm.provider, source: llm.source }
|
|
521
|
+
: { configured: false, detail: "call parse/score will prompt once, or set ANTHROPIC_API_KEY / OPENAI_API_KEY" },
|
|
522
|
+
mcp: { peersInstalled: missingPeers.length === 0, missing: missingPeers },
|
|
523
|
+
nextSteps,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function providerStatus(provider: string, broker: StoredCredential | null): ProviderDoctorStatus {
|
|
528
|
+
const stored = getCredential(provider);
|
|
529
|
+
if (stored) {
|
|
530
|
+
return { source: "stored", detail: `${stored.kind} login, updated ${stored.updatedAt}` };
|
|
531
|
+
}
|
|
532
|
+
if (broker) {
|
|
533
|
+
return { source: "broker", detail: `via ${broker.baseUrl ?? "hosted deployment"}` };
|
|
534
|
+
}
|
|
535
|
+
return { source: "none", detail: provider === "hubspot" || provider === "salesforce" ? `fullstackgtm login ${provider} (hosted OAuth default)` : `fullstackgtm login ${provider}` };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export type WorkspaceDoctor = {
|
|
539
|
+
profile: string;
|
|
540
|
+
healthScore: number | null;
|
|
541
|
+
scoreDelta: number | null;
|
|
542
|
+
lastAuditAt: string | null;
|
|
543
|
+
auditCount: number;
|
|
544
|
+
pendingPlans: Array<{ id: string; summary: string; operations: number; approved: number }>;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The workspace slice of doctor: current health + plans awaiting approval.
|
|
549
|
+
* Folded into doctor (rather than adding a separate triage verb) so a single
|
|
550
|
+
* call returns environment + workspace state + copy-pasteable next commands —
|
|
551
|
+
* previously an agent needed three round-trips (doctor, health, plans list)
|
|
552
|
+
* to orient itself.
|
|
553
|
+
*/
|
|
554
|
+
export async function workspaceDoctor(): Promise<WorkspaceDoctor> {
|
|
555
|
+
const profile = activeWorkspaceProfile();
|
|
556
|
+
const rollup = summarizeHealth(readHealthTimeline(), profile);
|
|
557
|
+
let pending: StoredPlan[] = [];
|
|
558
|
+
try {
|
|
559
|
+
pending = await createFilePlanStore().list("needs_approval");
|
|
560
|
+
} catch {
|
|
561
|
+
// A malformed plans dir must not take doctor down — doctor is the verb
|
|
562
|
+
// that diagnoses broken installs. The plans slice just comes back empty.
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
profile,
|
|
566
|
+
healthScore: rollup?.current.score ?? null,
|
|
567
|
+
scoreDelta: rollup?.scoreDelta ?? null,
|
|
568
|
+
lastAuditAt: rollup?.latest ?? null,
|
|
569
|
+
auditCount: rollup?.auditCount ?? 0,
|
|
570
|
+
pendingPlans: pending
|
|
571
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.plan.id.localeCompare(b.plan.id))
|
|
572
|
+
.slice(0, 10)
|
|
573
|
+
.map((stored) => ({
|
|
574
|
+
id: stored.plan.id,
|
|
575
|
+
summary: stored.plan.summary,
|
|
576
|
+
operations: stored.plan.operations.length,
|
|
577
|
+
approved: stored.approvedOperationIds.length,
|
|
578
|
+
})),
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* State-aware next steps: a plan awaiting approval outranks the generic
|
|
584
|
+
* "run an audit" advice — the exact show → approve → apply chain, ready to
|
|
585
|
+
* paste, with the connected provider filled in when there is one.
|
|
586
|
+
*/
|
|
587
|
+
/**
|
|
588
|
+
* Rich-only sparkline of the profile's score history, e.g. " ▂▄▅▇". Empty
|
|
589
|
+
* string in plain mode or with fewer than two readings, so the plain doctor
|
|
590
|
+
* line is byte-identical to the pre-TUI CLI.
|
|
591
|
+
*/
|
|
592
|
+
function healthSparkline(profile: string, rich: boolean): string {
|
|
593
|
+
if (!rich) return "";
|
|
594
|
+
const history = summarizeHealth(readHealthTimeline(), profile)?.history ?? [];
|
|
595
|
+
if (history.length < 2) return "";
|
|
596
|
+
return ` ${sparkline(history.map((point) => point.score))}`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function nextStepsWithWorkspace(report: ReturnType<typeof doctorReport>, workspace: WorkspaceDoctor): string[] {
|
|
600
|
+
if (workspace.pendingPlans.length === 0) return report.nextSteps;
|
|
601
|
+
const [first] = workspace.pendingPlans;
|
|
602
|
+
const connected = Object.entries(report.providers).find(([, status]) => status.source !== "none");
|
|
603
|
+
const providerHint = connected ? connected[0] : "<hubspot|salesforce|stripe>";
|
|
604
|
+
return [
|
|
605
|
+
`fullstackgtm plans show ${first.id}`,
|
|
606
|
+
`fullstackgtm plans approve ${first.id} --operations <ids|all>`,
|
|
607
|
+
`fullstackgtm apply --plan-id ${first.id} --provider ${providerHint}`,
|
|
608
|
+
];
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function doctorCommand(args: string[]) {
|
|
612
|
+
const report = doctorReport();
|
|
613
|
+
const workspace = await workspaceDoctor();
|
|
614
|
+
const nextSteps = nextStepsWithWorkspace(report, workspace);
|
|
615
|
+
if (args.includes("--json")) {
|
|
616
|
+
console.log(JSON.stringify({ ...report, workspace, nextSteps }, null, 2));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Styling contract: with color off (piped/NO_COLOR/CI) every painter is the
|
|
621
|
+
// identity function and the rich-only extras are skipped, so the plain
|
|
622
|
+
// output stays byte-identical to the unstyled CLI.
|
|
623
|
+
const p = paint(colorEnabled(process.stdout));
|
|
624
|
+
const mark = (ok: boolean) => (ok ? p.green("ok") : p.red("MISSING"));
|
|
625
|
+
const delta =
|
|
626
|
+
workspace.scoreDelta === null
|
|
627
|
+
? ""
|
|
628
|
+
: ` (Δ ${
|
|
629
|
+
workspace.scoreDelta >= 0
|
|
630
|
+
? p.green(`+${workspace.scoreDelta}`)
|
|
631
|
+
: p.red(`${workspace.scoreDelta}`)
|
|
632
|
+
})`;
|
|
633
|
+
const healthLine =
|
|
634
|
+
workspace.auditCount === 0
|
|
635
|
+
? p.dim("no saved audits yet (fullstackgtm audit --provider <name> --save starts the timeline)")
|
|
636
|
+
: `${scoreColor(workspace.healthScore ?? 0, p)}/100${delta} — ${workspace.auditCount} audit(s) saved, last ${workspace.lastAuditAt}${healthSparkline(workspace.profile, p.enabled)}`;
|
|
637
|
+
const lines = [
|
|
638
|
+
`Package: ${p.bold(`${report.package.name} ${report.package.version}`)}`,
|
|
639
|
+
`Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
|
|
640
|
+
`Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
|
|
641
|
+
`Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
|
|
642
|
+
`Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
|
|
643
|
+
"",
|
|
644
|
+
"Providers:",
|
|
645
|
+
...Object.entries(report.providers).map(([provider, status]) =>
|
|
646
|
+
` ${provider.padEnd(11)} ${status.source === "none" ? p.dim(`not connected (${status.detail})`) : `${p.green(status.source)}: ${status.detail}`}`,
|
|
647
|
+
),
|
|
648
|
+
` ${"broker".padEnd(11)} ${report.broker.paired ? `${p.green("paired")} with ${report.broker.baseUrl}` : p.dim("not paired (fullstackgtm login --via <hosted url>)")}`,
|
|
649
|
+
` ${"llm".padEnd(11)} ${report.llm.configured ? `${p.green(`${report.llm.provider}`)} key (${report.llm.source}) — call parse/score ready` : p.dim(`not configured (${report.llm.detail})`)}`,
|
|
650
|
+
"",
|
|
651
|
+
report.mcp.peersInstalled
|
|
652
|
+
? `MCP: ${p.green("peers installed")} — \`fullstackgtm-mcp\` is ready`
|
|
653
|
+
: `MCP: ${p.yellow("optional peers missing")} (${report.mcp.missing.join(", ")}) — needed only for \`fullstackgtm-mcp\``,
|
|
654
|
+
"",
|
|
655
|
+
"Workspace:",
|
|
656
|
+
` ${"health".padEnd(11)} ${healthLine}`,
|
|
657
|
+
` ${"plans".padEnd(11)} ${
|
|
658
|
+
workspace.pendingPlans.length === 0
|
|
659
|
+
? "none awaiting approval"
|
|
660
|
+
: `${p.yellow(`${workspace.pendingPlans.length} awaiting approval`)}: ${workspace.pendingPlans.map((plan) => plan.id).join(", ")}`
|
|
661
|
+
}`,
|
|
662
|
+
"",
|
|
663
|
+
...(p.enabled
|
|
664
|
+
? box(nextSteps, p, "Next step")
|
|
665
|
+
: ["Next step:", ...nextSteps.map((step) => ` ${step}`)]),
|
|
666
|
+
];
|
|
667
|
+
console.log(lines.join("\n"));
|
|
668
|
+
if (!report.node.ok) process.exitCode = 1;
|
|
669
|
+
}
|