fullstackgtm 0.55.0 → 0.55.2

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 CHANGED
@@ -7,6 +7,27 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.55.2] — 2026-07-13
11
+
12
+ ### Changed
13
+
14
+ - The OSS repository now publishes a representative contributor test suite
15
+ covering CLI contracts, planning, configuration, HTTP safety, and security,
16
+ while hosted, live-integration, and adversarial release tests remain private.
17
+
18
+ ### Fixed
19
+
20
+ - OSS mirror generation and `npm test` now fail when zero public tests are
21
+ discovered, preventing false-green contributor and release checks.
22
+
23
+ ## [0.55.1] — 2026-07-13
24
+
25
+ ### Fixed
26
+
27
+ - Interactive masked login prompts no longer repaint an empty
28
+ `0 characters received` line after Enter is pressed. Submission now settles
29
+ the renderer before `readline` clears its internal input buffer.
30
+
10
31
  ## [0.55.0] — 2026-07-13
11
32
 
12
33
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -64,6 +64,12 @@ Linux, macOS, and Windows; source-level tests remain on Node 22.6+.
64
64
  > (a `pretest` guard) rather than silently passing with zero tests. On the
65
65
  > published mirror, `tests/` is present and `npm test` works there.
66
66
 
67
+ The public mirror includes a representative suite covering supported CLI,
68
+ planning, configuration, HTTP, and security behavior. Additional hosted-product,
69
+ live-integration, and adversarial release tests remain in the private monorepo;
70
+ both suites gate releases. The public test selection is reviewed explicitly in
71
+ `oss/public-tests.txt`, and `npm test` fails if the mirror contains zero tests.
72
+
67
73
  **Node:** the published runtime supports Node ≥ 20 (`engines`), but the test
68
74
  runner needs Node **≥ 22.6** for `--experimental-strip-types`. Develop on 22.6+.
69
75
 
@@ -44,7 +44,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
44
44
  enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
45
45
  [source options] [--run-label <label>] [--json]
46
46
  enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
47
- enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
47
+ enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--company-domain <domain>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
48
48
  enrich status [--runs] [--source <id>] [--config <path>] [--json]
49
49
 
50
50
  acquire creates NET-NEW leads from a staged prospect list (ingest first):
@@ -515,8 +515,14 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
515
515
  : disc.provider === "clay"
516
516
  ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
517
517
  : {};
518
+ const targetCompanyDomain = option(rest, "--company-domain")?.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "");
519
+ if (targetCompanyDomain) {
520
+ if (disc.provider !== "clay")
521
+ throw new Error("enrich acquire: --company-domain currently requires --source clay");
522
+ filters = { ...filters, company_identifier: [targetCompanyDomain] };
523
+ }
518
524
  const queryFingerprint = createHash("sha256")
519
- .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
525
+ .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null, targetCompanyDomain: targetCompanyDomain ?? null }))
520
526
  .digest("hex");
521
527
  const checkpointKey = {
522
528
  provider: disc.provider,
@@ -599,7 +605,7 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
599
605
  if (!cursor) {
600
606
  const route = clayRoutes[clayRouteIndex];
601
607
  if (route)
602
- filters = route.filters;
608
+ filters = { ...route.filters, ...(targetCompanyDomain ? { company_identifier: [targetCompanyDomain] } : {}) };
603
609
  progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
604
610
  cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
605
611
  }
@@ -643,6 +649,8 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
643
649
  }
644
650
  discovered += page.length;
645
651
  progress.items(discovered, scanLimit);
652
+ if (targetCompanyDomain)
653
+ page = page.filter((prospect) => normalizedCompanyDomain(prospect.companyDomain) === targetCompanyDomain);
646
654
  if (page.length === 0) {
647
655
  exhausted = true;
648
656
  break;
@@ -885,6 +893,9 @@ function printAcquireOutput(options) {
885
893
  console.log("The meter is charged only when a create lands at apply.");
886
894
  }
887
895
  }
896
+ function normalizedCompanyDomain(value) {
897
+ return (value ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "");
898
+ }
888
899
  function hostedRunsUrl() {
889
900
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
890
901
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/dist/cli/help.js CHANGED
@@ -630,7 +630,7 @@ export const COMMAND_FLAGS = {
630
630
  dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
631
631
  reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
632
632
  backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--verbose", "--json"],
633
- enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--verbose", "--json", "--out"],
633
+ enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--company-domain", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--verbose", "--json", "--out"],
634
634
  call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--verbose", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
635
635
  suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
636
636
  plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--verbose", "--json"],
@@ -645,7 +645,7 @@ export const COMMAND_FLAGS = {
645
645
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
646
646
  };
647
647
  export const FLAGS_WITH_VALUES = new Set([
648
- "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
648
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--company-domain", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
649
649
  ]);
650
650
  // Lifecycle-grouped front door. One line per verb, organized by the
651
651
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
@@ -65,6 +65,15 @@ export declare function loadIcp(args: string[]): Icp | undefined;
65
65
  * e.g. `echo "$TOKEN" | fullstackgtm login hubspot`
66
66
  * - interactive: prompted on the TTY with the input muted
67
67
  */
68
+ export declare function createSecretMaskRenderer(options: {
69
+ label: string;
70
+ lineLength: () => number;
71
+ write: (value: string) => void;
72
+ }): {
73
+ mute(): void;
74
+ settle(): void;
75
+ write(value: string): void;
76
+ };
68
77
  export declare function readSecret(label: string): Promise<string>;
69
78
  export declare function isOptionValue(args: string[], arg: string): boolean;
70
79
  export declare function readPackageInfo(): {
@@ -288,6 +288,35 @@ export function loadIcp(args) {
288
288
  * e.g. `echo "$TOKEN" | fullstackgtm login hubspot`
289
289
  * - interactive: prompted on the TTY with the input muted
290
290
  */
291
+ export function createSecretMaskRenderer(options) {
292
+ let muted = false;
293
+ let renderQueued = false;
294
+ let settled = false;
295
+ return {
296
+ mute() { muted = true; },
297
+ settle() { settled = true; },
298
+ write(value) {
299
+ if (!muted) {
300
+ options.write(value);
301
+ return;
302
+ }
303
+ if (settled || renderQueued)
304
+ return;
305
+ renderQueued = true;
306
+ queueMicrotask(() => {
307
+ renderQueued = false;
308
+ // readline clears `line` during close. Submission settles this renderer
309
+ // first so an Enter-triggered repaint cannot redraw an empty prompt
310
+ // after the final mask/newline ("0 characters received").
311
+ if (settled)
312
+ return;
313
+ const length = options.lineLength();
314
+ const mask = `${"•".repeat(Math.min(length, 12))}${length > 12 ? "…" : ""}`;
315
+ options.write(`\r\u001b[2K${options.label}: ${mask} (${length} character${length === 1 ? "" : "s"} received)`);
316
+ });
317
+ },
318
+ };
319
+ }
291
320
  export async function readSecret(label) {
292
321
  if (!process.stdin.isTTY) {
293
322
  const chunks = [];
@@ -311,30 +340,20 @@ export async function readSecret(label) {
311
340
  output: process.stderr,
312
341
  terminal: true,
313
342
  });
314
- let muted = false;
315
343
  const mutable = rl;
316
- let renderQueued = false;
317
- mutable._writeToOutput = (value) => {
318
- if (!muted) {
319
- process.stderr.write(value);
320
- return;
321
- }
322
- if (renderQueued)
323
- return;
324
- renderQueued = true;
325
- queueMicrotask(() => {
326
- renderQueued = false;
327
- const length = mutable.line?.length ?? 0;
328
- const mask = `${"•".repeat(Math.min(length, 12))}${length > 12 ? "…" : ""}`;
329
- process.stderr.write(`\r\u001b[2K${label}: ${mask} (${length} character${length === 1 ? "" : "s"} received)`);
330
- });
331
- };
344
+ const renderer = createSecretMaskRenderer({
345
+ label,
346
+ lineLength: () => mutable.line?.length ?? 0,
347
+ write: (value) => process.stderr.write(value),
348
+ });
349
+ mutable._writeToOutput = (value) => renderer.write(value);
332
350
  rl.question(`${label}: `, (answer) => {
351
+ renderer.settle();
333
352
  rl.close();
334
353
  process.stderr.write("\n");
335
354
  resolveSecret(answer.trim());
336
355
  });
337
- muted = true;
356
+ renderer.mute();
338
357
  });
339
358
  }
340
359
  export function isOptionValue(args, arg) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.55.0",
3
+ "version": "0.55.2",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -47,7 +47,7 @@
47
47
  "scripts": {
48
48
  "clean": "node scripts/clean.mjs",
49
49
  "build": "npm run clean && tsc -p tsconfig.build.json",
50
- "pretest": "test -d tests || { echo 'No tests/ in this directory. In the published mirror, tests live here and `npm test` works. In the monorepo, run package tests from the repo ROOT: `node --experimental-strip-types --test tests/fullstackgtm*.test.ts` (the tests live at the monorepo root tests/).' >&2; exit 1; }",
50
+ "pretest": "node scripts/verify-public-tests.mjs",
51
51
  "test": "node --experimental-strip-types --test tests/*.test.ts",
52
52
  "verify:package": "npm run build && node scripts/verify-packed-install.mjs --with-peers",
53
53
  "prepublishOnly": "npm run verify:package"
package/src/cli/enrich.ts CHANGED
@@ -50,7 +50,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
50
50
  enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
51
51
  [source options] [--run-label <label>] [--json]
52
52
  enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
53
- enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
53
+ enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--company-domain <domain>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
54
54
  enrich status [--runs] [--source <id>] [--config <path>] [--json]
55
55
 
56
56
  acquire creates NET-NEW leads from a staged prospect list (ingest first):
@@ -596,8 +596,13 @@ async function acquireFromApi(
596
596
  : disc.provider === "clay"
597
597
  ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
598
598
  : {};
599
+ const targetCompanyDomain = option(rest, "--company-domain")?.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "");
600
+ if (targetCompanyDomain) {
601
+ if (disc.provider !== "clay") throw new Error("enrich acquire: --company-domain currently requires --source clay");
602
+ filters = { ...filters, company_identifier: [targetCompanyDomain] };
603
+ }
599
604
  const queryFingerprint = createHash("sha256")
600
- .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
605
+ .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null, targetCompanyDomain: targetCompanyDomain ?? null }))
601
606
  .digest("hex");
602
607
  const checkpointKey: AcquireCheckpointKey = {
603
608
  provider: disc.provider,
@@ -690,7 +695,7 @@ async function acquireFromApi(
690
695
  while (true) {
691
696
  if (!cursor) {
692
697
  const route = clayRoutes[clayRouteIndex];
693
- if (route) filters = route.filters;
698
+ if (route) filters = { ...route.filters, ...(targetCompanyDomain ? { company_identifier: [targetCompanyDomain] } : {}) };
694
699
  progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
695
700
  cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
696
701
  }
@@ -730,6 +735,7 @@ async function acquireFromApi(
730
735
  }
731
736
  discovered += page.length;
732
737
  progress.items(discovered, scanLimit);
738
+ if (targetCompanyDomain) page = page.filter((prospect) => normalizedCompanyDomain(prospect.companyDomain) === targetCompanyDomain);
733
739
  if (page.length === 0) {
734
740
  exhausted = true;
735
741
  break;
@@ -1011,6 +1017,10 @@ function printAcquireOutput(options: {
1011
1017
  }
1012
1018
  }
1013
1019
 
1020
+ function normalizedCompanyDomain(value: string | undefined): string {
1021
+ return (value ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "");
1022
+ }
1023
+
1014
1024
  function hostedRunsUrl(): string | null {
1015
1025
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
1016
1026
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/src/cli/help.ts CHANGED
@@ -692,7 +692,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
692
692
  dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
693
693
  reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
694
694
  backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--verbose", "--json"],
695
- enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--verbose", "--json", "--out"],
695
+ enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--company-domain", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--verbose", "--json", "--out"],
696
696
  call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--verbose", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
697
697
  suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
698
698
  plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--verbose", "--json"],
@@ -708,7 +708,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
708
708
  };
709
709
 
710
710
  export const FLAGS_WITH_VALUES = new Set([
711
- "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
711
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--company-domain", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
712
712
  ]);
713
713
 
714
714
  // Lifecycle-grouped front door. One line per verb, organized by the
package/src/cli/shared.ts CHANGED
@@ -336,6 +336,35 @@ export function loadIcp(args: string[]): Icp | undefined {
336
336
  * e.g. `echo "$TOKEN" | fullstackgtm login hubspot`
337
337
  * - interactive: prompted on the TTY with the input muted
338
338
  */
339
+ export function createSecretMaskRenderer(options: {
340
+ label: string;
341
+ lineLength: () => number;
342
+ write: (value: string) => void;
343
+ }) {
344
+ let muted = false;
345
+ let renderQueued = false;
346
+ let settled = false;
347
+ return {
348
+ mute() { muted = true; },
349
+ settle() { settled = true; },
350
+ write(value: string) {
351
+ if (!muted) { options.write(value); return; }
352
+ if (settled || renderQueued) return;
353
+ renderQueued = true;
354
+ queueMicrotask(() => {
355
+ renderQueued = false;
356
+ // readline clears `line` during close. Submission settles this renderer
357
+ // first so an Enter-triggered repaint cannot redraw an empty prompt
358
+ // after the final mask/newline ("0 characters received").
359
+ if (settled) return;
360
+ const length = options.lineLength();
361
+ const mask = `${"•".repeat(Math.min(length, 12))}${length > 12 ? "…" : ""}`;
362
+ options.write(`\r\u001b[2K${options.label}: ${mask} (${length} character${length === 1 ? "" : "s"} received)`);
363
+ });
364
+ },
365
+ };
366
+ }
367
+
339
368
  export async function readSecret(label: string): Promise<string> {
340
369
  if (!process.stdin.isTTY) {
341
370
  const chunks: Buffer[] = [];
@@ -362,26 +391,20 @@ export async function readSecret(label: string): Promise<string> {
362
391
  output: process.stderr,
363
392
  terminal: true,
364
393
  });
365
- let muted = false;
366
394
  const mutable = rl as unknown as { _writeToOutput?: (value: string) => void; line?: string };
367
- let renderQueued = false;
368
- mutable._writeToOutput = (value: string) => {
369
- if (!muted) { process.stderr.write(value); return; }
370
- if (renderQueued) return;
371
- renderQueued = true;
372
- queueMicrotask(() => {
373
- renderQueued = false;
374
- const length = mutable.line?.length ?? 0;
375
- const mask = `${"•".repeat(Math.min(length, 12))}${length > 12 ? "…" : ""}`;
376
- process.stderr.write(`\r\u001b[2K${label}: ${mask} (${length} character${length === 1 ? "" : "s"} received)`);
377
- });
378
- };
395
+ const renderer = createSecretMaskRenderer({
396
+ label,
397
+ lineLength: () => mutable.line?.length ?? 0,
398
+ write: (value) => process.stderr.write(value),
399
+ });
400
+ mutable._writeToOutput = (value: string) => renderer.write(value);
379
401
  rl.question(`${label}: `, (answer) => {
402
+ renderer.settle();
380
403
  rl.close();
381
404
  process.stderr.write("\n");
382
405
  resolveSecret(answer.trim());
383
406
  });
384
- muted = true;
407
+ renderer.mute();
385
408
  });
386
409
  }
387
410