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.
Files changed (149) hide show
  1. package/CHANGELOG.md +293 -1
  2. package/INSTALL_FOR_AGENTS.md +13 -11
  3. package/README.md +45 -24
  4. package/dist/audit.d.ts +3 -1
  5. package/dist/audit.js +29 -2
  6. package/dist/backfill.d.ts +86 -0
  7. package/dist/backfill.js +251 -0
  8. package/dist/bin.js +4 -1
  9. package/dist/cli/audit.d.ts +15 -0
  10. package/dist/cli/audit.js +392 -0
  11. package/dist/cli/auth.d.ts +82 -0
  12. package/dist/cli/auth.js +607 -0
  13. package/dist/cli/backfill.d.ts +1 -0
  14. package/dist/cli/backfill.js +125 -0
  15. package/dist/cli/backfillRuns.d.ts +1 -0
  16. package/dist/cli/backfillRuns.js +187 -0
  17. package/dist/cli/call.d.ts +1 -0
  18. package/dist/cli/call.js +387 -0
  19. package/dist/cli/capabilities.d.ts +30 -0
  20. package/dist/cli/capabilities.js +197 -0
  21. package/dist/cli/draft.d.ts +7 -0
  22. package/dist/cli/draft.js +87 -0
  23. package/dist/cli/enrich.d.ts +8 -0
  24. package/dist/cli/enrich.js +806 -0
  25. package/dist/cli/fix.d.ts +33 -0
  26. package/dist/cli/fix.js +346 -0
  27. package/dist/cli/help.d.ts +25 -0
  28. package/dist/cli/help.js +646 -0
  29. package/dist/cli/icp.d.ts +7 -0
  30. package/dist/cli/icp.js +229 -0
  31. package/dist/cli/init.d.ts +7 -0
  32. package/dist/cli/init.js +58 -0
  33. package/dist/cli/market.d.ts +1 -0
  34. package/dist/cli/market.js +391 -0
  35. package/dist/cli/plans.d.ts +5 -0
  36. package/dist/cli/plans.js +456 -0
  37. package/dist/cli/schedule.d.ts +8 -0
  38. package/dist/cli/schedule.js +479 -0
  39. package/dist/cli/shared.d.ts +71 -0
  40. package/dist/cli/shared.js +342 -0
  41. package/dist/cli/signals.d.ts +7 -0
  42. package/dist/cli/signals.js +412 -0
  43. package/dist/cli/suggest.d.ts +49 -0
  44. package/dist/cli/suggest.js +135 -0
  45. package/dist/cli/tam.d.ts +9 -0
  46. package/dist/cli/tam.js +387 -0
  47. package/dist/cli/ui.d.ts +142 -0
  48. package/dist/cli/ui.js +427 -0
  49. package/dist/cli.d.ts +1 -57
  50. package/dist/cli.js +123 -5157
  51. package/dist/connector.d.ts +23 -0
  52. package/dist/connector.js +47 -0
  53. package/dist/connectors/hubspot.d.ts +10 -1
  54. package/dist/connectors/hubspot.js +244 -10
  55. package/dist/connectors/hubspotAuth.js +5 -1
  56. package/dist/connectors/linkedin.js +14 -14
  57. package/dist/connectors/salesforce.d.ts +10 -1
  58. package/dist/connectors/salesforce.js +39 -6
  59. package/dist/connectors/signalSources.js +7 -59
  60. package/dist/connectors/stripe.d.ts +37 -0
  61. package/dist/connectors/stripe.js +103 -31
  62. package/dist/enrich.d.ts +7 -0
  63. package/dist/enrich.js +7 -0
  64. package/dist/health.d.ts +11 -69
  65. package/dist/health.js +4 -134
  66. package/dist/healthScore.d.ts +71 -0
  67. package/dist/healthScore.js +143 -0
  68. package/dist/icp.d.ts +15 -11
  69. package/dist/icp.js +19 -36
  70. package/dist/index.d.ts +3 -1
  71. package/dist/index.js +3 -1
  72. package/dist/judge.d.ts +2 -0
  73. package/dist/judge.js +6 -0
  74. package/dist/llm.d.ts +29 -0
  75. package/dist/llm.js +206 -0
  76. package/dist/market.d.ts +39 -1
  77. package/dist/market.js +116 -44
  78. package/dist/marketClassify.d.ts +11 -1
  79. package/dist/marketClassify.js +17 -2
  80. package/dist/marketTaxonomy.d.ts +6 -1
  81. package/dist/marketTaxonomy.js +4 -2
  82. package/dist/mcp-bin.js +31 -2
  83. package/dist/mcp.js +372 -166
  84. package/dist/progress.d.ts +96 -0
  85. package/dist/progress.js +142 -0
  86. package/dist/runReport.d.ts +24 -0
  87. package/dist/runReport.js +139 -4
  88. package/dist/schedule.d.ts +80 -2
  89. package/dist/schedule.js +254 -1
  90. package/dist/spoolFiles.d.ts +44 -0
  91. package/dist/spoolFiles.js +114 -0
  92. package/dist/types.d.ts +29 -1
  93. package/docs/api.md +75 -8
  94. package/docs/architecture.md +2 -0
  95. package/docs/linkedin-connector-spec.md +1 -1
  96. package/docs/signal-spool-format.md +13 -0
  97. package/llms.txt +18 -9
  98. package/package.json +10 -3
  99. package/skills/fullstackgtm/SKILL.md +2 -1
  100. package/src/audit.ts +27 -1
  101. package/src/backfill.ts +340 -0
  102. package/src/bin.ts +5 -1
  103. package/src/cli/audit.ts +447 -0
  104. package/src/cli/auth.ts +669 -0
  105. package/src/cli/backfill.ts +156 -0
  106. package/src/cli/backfillRuns.ts +198 -0
  107. package/src/cli/call.ts +414 -0
  108. package/src/cli/capabilities.ts +215 -0
  109. package/src/cli/draft.ts +101 -0
  110. package/src/cli/enrich.ts +908 -0
  111. package/src/cli/fix.ts +373 -0
  112. package/src/cli/help.ts +702 -0
  113. package/src/cli/icp.ts +265 -0
  114. package/src/cli/init.ts +65 -0
  115. package/src/cli/market.ts +423 -0
  116. package/src/cli/plans.ts +524 -0
  117. package/src/cli/schedule.ts +526 -0
  118. package/src/cli/shared.ts +392 -0
  119. package/src/cli/signals.ts +434 -0
  120. package/src/cli/suggest.ts +151 -0
  121. package/src/cli/tam.ts +435 -0
  122. package/src/cli/ui.ts +497 -0
  123. package/src/cli.ts +122 -5855
  124. package/src/connector.ts +66 -0
  125. package/src/connectors/hubspot.ts +273 -9
  126. package/src/connectors/hubspotAuth.ts +5 -1
  127. package/src/connectors/linkedin.ts +14 -14
  128. package/src/connectors/salesforce.ts +51 -3
  129. package/src/connectors/signalSources.ts +7 -56
  130. package/src/connectors/stripe.ts +154 -34
  131. package/src/enrich.ts +13 -0
  132. package/src/health.ts +14 -213
  133. package/src/healthScore.ts +223 -0
  134. package/src/icp.ts +19 -35
  135. package/src/index.ts +28 -1
  136. package/src/judge.ts +7 -0
  137. package/src/llm.ts +239 -0
  138. package/src/market.ts +157 -44
  139. package/src/marketClassify.ts +26 -2
  140. package/src/marketTaxonomy.ts +10 -2
  141. package/src/mcp-bin.ts +34 -2
  142. package/src/mcp.ts +270 -63
  143. package/src/progress.ts +197 -0
  144. package/src/runReport.ts +159 -4
  145. package/src/schedule.ts +310 -3
  146. package/src/spoolFiles.ts +116 -0
  147. package/src/types.ts +32 -2
  148. package/docs/dx-punch-list.md +0 -87
  149. package/docs/roadmap-to-1.0.md +0 -211
@@ -0,0 +1,392 @@
1
+ // Extracted verbatim from src/cli.ts (mechanical split, no behavior change).
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { credentialsPath, getCredential, resolveHubspotConnection, resolveSalesforceConnection, storeCredential } from "../credentials.ts";
6
+ import { builtinAuditRules } from "../rules.ts";
7
+ import { detectProviderFromKey, resolveLlmCredential, validateLlmKey, type LlmProvider } from "../llm.ts";
8
+ import { parseIcp, type Icp } from "../icp.ts";
9
+ import { nearest } from "./suggest.ts";
10
+ import type { FieldMappings } from "../mappings.ts";
11
+ import type { CanonicalGtmSnapshot, GtmConnector } from "../types.ts";
12
+ import {
13
+ composeListeners,
14
+ createProgressEmitter,
15
+ SNAPSHOT_PULL_STAGES,
16
+ STRIPE_SNAPSHOT_STAGES,
17
+ type ProgressEmitter,
18
+ } from "../progress.ts";
19
+ import { progressReporter } from "../runReport.ts";
20
+ import { createProgressRenderer } from "./ui.ts";
21
+
22
+
23
+ export function option(args: string[], name: string) {
24
+ const index = args.indexOf(name);
25
+ if (index === -1) return null;
26
+ return args[index + 1] ?? null;
27
+ }
28
+
29
+ export function repeatedOption(args: string[], name: string) {
30
+ const values: string[] = [];
31
+ for (let index = 0; index < args.length; index += 1) {
32
+ if (args[index] === name && args[index + 1] !== undefined) {
33
+ values.push(args[index + 1]);
34
+ }
35
+ }
36
+ return values;
37
+ }
38
+
39
+ /**
40
+ * Additive alias: `--dry-run` asserts the preview default. Every verb that
41
+ * honors it is already preview-by-default (plans are dry-run; nothing writes
42
+ * to a CRM without approve → apply) — the flag additionally suppresses
43
+ * `--save` (and the `fix --yes` write stage) so the invocation can never
44
+ * persist a plan/run record or reach a write. Omitting it changes nothing:
45
+ * defaults stay exactly as before.
46
+ */
47
+ export function dryRunRequested(args: string[]): boolean {
48
+ return args.includes("--dry-run");
49
+ }
50
+
51
+ let dryRunOverridesSaveNoted = false;
52
+
53
+ /** `--save`, unless `--dry-run` locks the preview default (see dryRunRequested). */
54
+ export function saveRequested(args: string[]): boolean {
55
+ const save = args.includes("--save");
56
+ if (save && dryRunRequested(args)) {
57
+ if (!dryRunOverridesSaveNoted) {
58
+ console.error("(--dry-run overrides --save: preview only, nothing persisted)");
59
+ dryRunOverridesSaveNoted = true;
60
+ }
61
+ return false;
62
+ }
63
+ return save;
64
+ }
65
+
66
+ /**
67
+ * Additive alias: `--confirm` is accepted wherever an ad-hoc confirmation
68
+ * flag already gates a write stage (e.g. `fix --yes`). The old flags keep
69
+ * working; `--dry-run` wins over both.
70
+ */
71
+ export function confirmRequested(args: string[], ...legacyAliases: string[]): boolean {
72
+ if (dryRunRequested(args)) return false;
73
+ return ["--confirm", ...legacyAliases].some((flag) => args.includes(flag));
74
+ }
75
+
76
+ export function numericOption(args: string[], name: string) {
77
+ const value = option(args, name);
78
+ if (value === null) return undefined;
79
+ const parsed = Number(value);
80
+ if (!Number.isFinite(parsed)) throw new Error(`${name} must be a number`);
81
+ return parsed;
82
+ }
83
+
84
+ async function hubspotConnection(args: string[]) {
85
+ const tokenEnv = option(args, "--token-env");
86
+ if (tokenEnv) {
87
+ const token = process.env[tokenEnv];
88
+ if (!token) throw new Error(`${tokenEnv} is not set.`);
89
+ return { accessToken: token };
90
+ }
91
+ if (process.env.HUBSPOT_ACCESS_TOKEN) {
92
+ return { accessToken: process.env.HUBSPOT_ACCESS_TOKEN };
93
+ }
94
+ const stored = await resolveHubspotConnection();
95
+ if (stored) return stored;
96
+ throw new Error(
97
+ "No HubSpot credentials. Run `fullstackgtm login hubspot`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set HUBSPOT_ACCESS_TOKEN. (`fullstackgtm doctor` shows credential status; see the README's \"Connect your CRM\" section for the private-app scopes to grant.)",
98
+ );
99
+ }
100
+
101
+ async function salesforceConnection() {
102
+ if (process.env.SALESFORCE_ACCESS_TOKEN && process.env.SALESFORCE_INSTANCE_URL) {
103
+ return {
104
+ accessToken: process.env.SALESFORCE_ACCESS_TOKEN,
105
+ instanceUrl: process.env.SALESFORCE_INSTANCE_URL,
106
+ };
107
+ }
108
+ const stored = await resolveSalesforceConnection();
109
+ if (stored) return stored;
110
+ throw new Error(
111
+ "No Salesforce credentials. Run `fullstackgtm login salesforce`, pair with a hosted deployment via `fullstackgtm login --via <url>`, or set SALESFORCE_ACCESS_TOKEN and SALESFORCE_INSTANCE_URL. (`fullstackgtm doctor` shows credential status; device-flow login needs an admin-created Connected App — see the README's \"Connect your CRM\" section.)",
112
+ );
113
+ }
114
+
115
+ export async function connectorFor(
116
+ provider: string,
117
+ args: string[],
118
+ progress?: ProgressEmitter,
119
+ ): Promise<GtmConnector> {
120
+ // Connector modules are the two largest files in dist (~100KB together) and
121
+ // load lazily here: only live `--provider` runs pay for them, not --demo/
122
+ // --sample/--input runs or the always-eager help/version paths.
123
+ if (provider === "hubspot") {
124
+ const connection = await hubspotConnection(args);
125
+ const { createHubspotConnector } = await import("../connectors/hubspot.ts");
126
+ return createHubspotConnector({
127
+ getAccessToken: () => connection.accessToken,
128
+ fieldMappings: (connection.fieldMappings as FieldMappings | undefined) ?? undefined,
129
+ // Point at a mock/proxy HubSpot (tests, evals, request-recording).
130
+ apiBaseUrl: process.env.HUBSPOT_API_BASE_URL,
131
+ progress,
132
+ });
133
+ }
134
+ if (provider === "salesforce") {
135
+ const connection = await salesforceConnection();
136
+ const { createSalesforceConnector } = await import("../connectors/salesforce.ts");
137
+ return createSalesforceConnector({
138
+ getConnection: () => connection,
139
+ fieldMappings:
140
+ ((connection as { fieldMappings?: unknown }).fieldMappings as
141
+ | FieldMappings
142
+ | undefined) ?? undefined,
143
+ progress,
144
+ });
145
+ }
146
+ if (provider === "stripe") {
147
+ const key =
148
+ process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
149
+ if (!key) {
150
+ throw new Error(
151
+ "No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. A restricted key with read access to Customers and Subscriptions is enough. (`fullstackgtm doctor` shows credential status.)",
152
+ );
153
+ }
154
+ const { createStripeConnector } = await import("../connectors/stripe.ts");
155
+ return createStripeConnector({ getApiKey: () => key, progress });
156
+ }
157
+ const providerSuggestion = nearest(provider.toLowerCase(), ["hubspot", "salesforce", "stripe"], 3);
158
+ throw new Error(
159
+ `Unknown provider: ${provider}.${providerSuggestion ? ` Did you mean ${providerSuggestion}?` : ""} ` +
160
+ "Supported providers: hubspot, salesforce, stripe",
161
+ );
162
+ }
163
+
164
+ export async function readSnapshot(
165
+ args: string[],
166
+ progress?: ProgressEmitter,
167
+ ): Promise<CanonicalGtmSnapshot> {
168
+ const provider = option(args, "--provider");
169
+ if (provider) {
170
+ // A verb driving its own progress board (e.g. `backfill stripe`) passes
171
+ // its emitter through; the pull's stage/items events flow into it and no
172
+ // second board is painted.
173
+ if (progress) {
174
+ const connector = await connectorFor(provider, args, progress);
175
+ return connector.fetchSnapshot();
176
+ }
177
+ // Live pulls can page for minutes with nothing on screen. On an
178
+ // interactive terminal, a stderr checklist ticks through the pull stages
179
+ // with running tallies; piped/CI/agent runs render nothing (zero bytes
180
+ // written). Either way the emitter feeds the broker heartbeat when the
181
+ // CLI is paired, so long pulls tick live on the hosted dashboard too.
182
+ const renderer = createProgressRenderer(
183
+ provider === "stripe" ? STRIPE_SNAPSHOT_STAGES : SNAPSHOT_PULL_STAGES,
184
+ );
185
+ const emitter = createProgressEmitter(
186
+ composeListeners(renderer.listener, progressReporter()),
187
+ );
188
+ const connector = await connectorFor(provider, args, emitter);
189
+ try {
190
+ return await connector.fetchSnapshot();
191
+ } finally {
192
+ renderer.done();
193
+ }
194
+ }
195
+ if (args.includes("--demo")) {
196
+ const { generateDemoSnapshot } = await import("../demo.ts");
197
+ return generateDemoSnapshot({
198
+ seed: numericOption(args, "--seed"),
199
+ today: option(args, "--today") ?? undefined,
200
+ });
201
+ }
202
+ if (args.includes("--sample")) return (await import("../sampleData.ts")).sampleSnapshot;
203
+ const input = option(args, "--input");
204
+ if (!input) {
205
+ throw new Error(
206
+ "No data source. Pass one of: --provider <hubspot|salesforce|stripe> (live, read-only), " +
207
+ "--input <snapshot.json> (a saved snapshot), --demo (a generated messy CRM), or " +
208
+ "--sample (the tiny built-in fixture). Refusing to silently audit sample data.",
209
+ );
210
+ }
211
+ const path = resolve(process.cwd(), input);
212
+ let parsed: unknown;
213
+ try {
214
+ parsed = JSON.parse(readFileSync(path, "utf8"));
215
+ } catch (error) {
216
+ const reason = error instanceof Error ? error.message : String(error);
217
+ throw new Error(`--input ${input} could not be read as JSON: ${reason}`);
218
+ }
219
+ return assertCanonicalSnapshot(parsed, input);
220
+ }
221
+
222
+ /**
223
+ * Validate that an --input file actually has the canonical snapshot shape
224
+ * (the JSON `snapshot --out` writes) instead of blindly casting — a plan or
225
+ * arbitrary JSON passed here used to surface as a confusing crash deep in a
226
+ * rule, or worse, an empty "clean" audit.
227
+ */
228
+ export function assertCanonicalSnapshot(parsed: unknown, source: string): CanonicalGtmSnapshot {
229
+ const expected = "expected the shape written by `fullstackgtm snapshot --out <path>`: " +
230
+ "{ generatedAt, provider, users[], accounts[], contacts[], deals[], activities[] }";
231
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
232
+ throw new Error(`--input ${source} is not a canonical GTM snapshot (top level is not an object); ${expected}.`);
233
+ }
234
+ const record = parsed as Record<string, unknown>;
235
+ const arrayFields = ["users", "accounts", "contacts", "deals", "activities"] as const;
236
+ const problems = arrayFields
237
+ .filter((field) => !Array.isArray(record[field]))
238
+ .map((field) => (record[field] === undefined ? `missing "${field}"` : `"${field}" is not an array`));
239
+ if (problems.length > 0) {
240
+ throw new Error(
241
+ `--input ${source} is not a canonical GTM snapshot (${problems.join(", ")}); ${expected}.`,
242
+ );
243
+ }
244
+ return parsed as CanonicalGtmSnapshot;
245
+ }
246
+
247
+ export function selectedRules(args: string[], baseRules = builtinAuditRules) {
248
+ const requested = option(args, "--rules");
249
+ if (!requested) return baseRules;
250
+ const ids = requested.split(",").map((id) => id.trim()).filter(Boolean);
251
+ const known = new Map(baseRules.map((rule) => [rule.id, rule]));
252
+ const rules = ids.map((id) => {
253
+ const rule = known.get(id);
254
+ if (!rule) {
255
+ throw new Error(
256
+ `Unknown rule: ${id}. Available rules: ${baseRules.map((r) => r.id).join(", ")}`,
257
+ );
258
+ }
259
+ return rule;
260
+ });
261
+ return rules;
262
+ }
263
+
264
+ /**
265
+ * First-touch key onboarding: env vars win, then the credential store; on a
266
+ * TTY a missing key is captured once (validated, stored 0600 like provider
267
+ * logins). Non-interactive contexts get an actionable error instead.
268
+ */
269
+ export async function requireLlmCredential(
270
+ command: "parse" | "score" | "market classify" = "parse",
271
+ ): Promise<{ provider: LlmProvider; apiKey: string; anthropicBaseUrl?: string; openaiBaseUrl?: string }> {
272
+ // Base-URL overrides are resolved here (not inside forcedToolCall) so the LLM
273
+ // seam stays pure/injectable. Spread into LlmCallOptions at every call site
274
+ // via `...credential`; unset env leaves the upstream defaults untouched.
275
+ const baseUrls = resolveLlmBaseUrls();
276
+ const resolved = resolveLlmCredential();
277
+ if (resolved) return { ...resolved, ...baseUrls };
278
+ // Scoring is inherently LLM work — there is no keyword fallback to suggest.
279
+ const fallbackHint =
280
+ command === "parse"
281
+ ? ", or pass --deterministic for the free keyword baseline"
282
+ : command === "score"
283
+ ? " (call score has no non-LLM mode)"
284
+ : ", or classify by hand: `market worksheet --vendor <id>` then `market observe --from`";
285
+ const work = command === "score" ? "scoring" : command === "parse" ? "extraction" : "classification";
286
+ if (!process.stdin.isTTY) {
287
+ throw new Error(
288
+ `LLM ${work} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`,
289
+ );
290
+ }
291
+ console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
292
+ console.error(`Paste it once; it is validated and stored at ${credentialsPath()} (file mode 0600), like CRM logins.`);
293
+ console.error("(Alternatives: set ANTHROPIC_API_KEY / OPENAI_API_KEY, or pass --deterministic for the free keyword baseline.)\n");
294
+ const apiKey = await readSecret("API key (sk-ant-... or sk-...): ");
295
+ const provider = detectProviderFromKey(apiKey);
296
+ const validation = await validateLlmKey(provider, apiKey);
297
+ if (!validation.ok) throw new Error(`${provider} rejected the key: ${validation.detail}`);
298
+ const now = new Date().toISOString();
299
+ storeCredential(provider, { kind: "api_key", accessToken: apiKey, createdAt: now, updatedAt: now });
300
+ console.error(`Stored ${provider} key (${validation.detail}). Future runs use it automatically; remove with \`fullstackgtm logout ${provider}\`.\n`);
301
+ return { provider, apiKey, ...baseUrls };
302
+ }
303
+
304
+ /**
305
+ * Read optional LLM base-URL overrides from env so the package can run against
306
+ * an Anthropic/OpenAI-compatible gateway (GLM-5.2, z.ai, Ollama) without code
307
+ * changes. Returns only the keys that are set; an empty object when neither is,
308
+ * so spreading into LlmCallOptions is a no-op by default.
309
+ */
310
+ export function resolveLlmBaseUrls(
311
+ env: Record<string, string | undefined> = process.env,
312
+ ): { anthropicBaseUrl?: string; openaiBaseUrl?: string } {
313
+ const out: { anthropicBaseUrl?: string; openaiBaseUrl?: string } = {};
314
+ if (env.ANTHROPIC_API_BASE_URL) out.anthropicBaseUrl = env.ANTHROPIC_API_BASE_URL;
315
+ if (env.OPENAI_API_BASE_URL) out.openaiBaseUrl = env.OPENAI_API_BASE_URL;
316
+ return out;
317
+ }
318
+
319
+ /** Load the active ICP: --icp <path>, else ./icp.json. Undefined if none. */
320
+ export function loadIcp(args: string[]): Icp | undefined {
321
+ const explicit = option(args, "--icp");
322
+ const path = resolve(process.cwd(), explicit ?? "icp.json");
323
+ if (!existsSync(path)) {
324
+ if (explicit) throw new Error(`--icp ${explicit}: file not found`);
325
+ return undefined;
326
+ }
327
+ return parseIcp(readFileSync(path, "utf8"));
328
+ }
329
+
330
+ /**
331
+ * Read a secret without exposing it on the command line. Secrets passed as
332
+ * argv leak through the process list (`ps`), `/proc`, and shell history, so
333
+ * the CLI never accepts them as flags. Instead:
334
+ * - non-interactive: pipe it on stdin (docker `--password-stdin` style),
335
+ * e.g. `echo "$TOKEN" | fullstackgtm login hubspot`
336
+ * - interactive: prompted on the TTY with the input muted
337
+ */
338
+ export async function readSecret(label: string): Promise<string> {
339
+ if (!process.stdin.isTTY) {
340
+ const chunks: Buffer[] = [];
341
+ for await (const chunk of process.stdin) {
342
+ chunks.push(chunk as Buffer);
343
+ }
344
+ const piped = Buffer.concat(chunks).toString("utf8").trim();
345
+ if (!piped) {
346
+ throw new Error(
347
+ `No secret provided. Pipe it on stdin (e.g. \`echo "$SECRET" | fullstackgtm login ...\`) ` +
348
+ `or run interactively. Secrets are never accepted as CLI arguments — they leak via the ` +
349
+ `process list and shell history.`,
350
+ );
351
+ }
352
+ // Multi-line stdin (rare) — take the first non-empty line.
353
+ return piped.split(/\r?\n/)[0].trim();
354
+ }
355
+
356
+ const readline = await import("node:readline");
357
+ return new Promise<string>((resolveSecret) => {
358
+ const rl = readline.createInterface({
359
+ input: process.stdin,
360
+ output: process.stderr,
361
+ terminal: true,
362
+ });
363
+ let muted = false;
364
+ const mutable = rl as unknown as { _writeToOutput?: (value: string) => void };
365
+ mutable._writeToOutput = (value: string) => {
366
+ if (!muted) process.stderr.write(value);
367
+ };
368
+ rl.question(`${label}: `, (answer) => {
369
+ rl.close();
370
+ process.stderr.write("\n");
371
+ resolveSecret(answer.trim());
372
+ });
373
+ muted = true;
374
+ });
375
+ }
376
+
377
+ export function isOptionValue(args: string[], arg: string) {
378
+ const index = args.indexOf(arg);
379
+ return index > 0 && args[index - 1].startsWith("--");
380
+ }
381
+
382
+ export function readPackageInfo() {
383
+ try {
384
+ // This file lives one level deeper than the old src/cli.ts, so the hop to
385
+ // the package root is ../.. (was ..). Same target file as before the split.
386
+ const raw = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
387
+ const parsed = JSON.parse(raw) as { name?: string; version?: string };
388
+ return { name: parsed.name ?? "fullstackgtm", version: parsed.version ?? "unknown" };
389
+ } catch {
390
+ return { name: "fullstackgtm", version: "unknown" };
391
+ }
392
+ }