beta-signup.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /**
  2. * CodeGraph Pro beta opt-in — the installer's one-time offer to join the
  3. * beta-access waitlist (the same list the getcodegraph.com homepage form
  4. * feeds). Strictly opt-in: the user must answer yes AND type their email;
  5. * nothing is ever sent otherwise, and `--yes` / non-interactive runs never
  6. * see the prompt.
  7. *
  8. * The choice (subscribed or declined) is stored once in the user-level
  9. * state dir (~/.codegraph) so re-installs and upgrades never re-ask —
  10. * mirroring the telemetry consent pattern. A failed submit stores nothing,
  11. * so a later install can offer again.
  12. */
  13. import * as fs from 'fs';
  14. import * as path from 'path';
  15. import * as os from 'os';
  16. /** JSON waitlist endpoint on the landing page (see its /api/waitlist route). */
  17. export const BETA_SIGNUP_ENDPOINT = 'https://getcodegraph.com/api/waitlist';
  18. export const BETA_SIGNUP_URL = 'https://getcodegraph.com';
  19. /** Same shape the landing-page form validates against. */
  20. export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  21. const SUBMIT_TIMEOUT_MS = 8_000;
  22. interface BetaSignupChoiceFile {
  23. status: 'subscribed' | 'declined';
  24. updated_at: string;
  25. }
  26. export interface BetaSignupDeps {
  27. /** Global state dir; defaults to ~/.codegraph. Tests inject a temp dir. */
  28. dir?: string;
  29. fetchImpl?: typeof fetch;
  30. now?: () => Date;
  31. /** Where the signup came from, recorded with the email. */
  32. source?: 'cli-install' | 'cli-upgrade';
  33. /** TTY probes; default to the real process streams. Tests inject. */
  34. stdinIsTTY?: boolean;
  35. stdoutIsTTY?: boolean;
  36. }
  37. function choicePath(deps: BetaSignupDeps = {}): string {
  38. return path.join(deps.dir ?? path.join(os.homedir(), '.codegraph'), 'beta-signup.json');
  39. }
  40. /** True once the user has answered (either way) on this machine. */
  41. export function hasBetaSignupChoice(deps: BetaSignupDeps = {}): boolean {
  42. try {
  43. const raw = JSON.parse(fs.readFileSync(choicePath(deps), 'utf8')) as BetaSignupChoiceFile;
  44. return raw.status === 'subscribed' || raw.status === 'declined';
  45. } catch {
  46. return false;
  47. }
  48. }
  49. /** Persist the answer so no future install re-asks. Fail silent. */
  50. export function recordBetaSignupChoice(
  51. subscribed: boolean,
  52. deps: BetaSignupDeps = {},
  53. ): void {
  54. try {
  55. const file = choicePath(deps);
  56. fs.mkdirSync(path.dirname(file), { recursive: true });
  57. const choice: BetaSignupChoiceFile = {
  58. status: subscribed ? 'subscribed' : 'declined',
  59. updated_at: (deps.now?.() ?? new Date()).toISOString(),
  60. };
  61. fs.writeFileSync(file, JSON.stringify(choice, null, 2) + '\n');
  62. } catch {
  63. /* a full disk must not break the installer */
  64. }
  65. }
  66. /**
  67. * Submit one email to the beta waitlist. Returns true on success, false on
  68. * any failure (bad response, offline, timeout) — never throws, never retries.
  69. */
  70. export async function submitBetaSignup(
  71. email: string,
  72. deps: BetaSignupDeps = {},
  73. ): Promise<boolean> {
  74. const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
  75. try {
  76. const res = await fetchImpl(BETA_SIGNUP_ENDPOINT, {
  77. method: 'POST',
  78. headers: { 'Content-Type': 'application/json' },
  79. body: JSON.stringify({ email, source: deps.source ?? 'cli-install' }),
  80. signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
  81. });
  82. return res.ok;
  83. } catch {
  84. return false;
  85. }
  86. }
  87. /**
  88. * The one gate every ask site shares: offer only on a real terminal, and
  89. * never once ANY prior ask (install or upgrade) has been answered on this
  90. * machine. Exported separately so the no-spam rule is unit-testable.
  91. */
  92. export function shouldOfferBetaSignup(deps: BetaSignupDeps = {}): boolean {
  93. const stdinTTY = deps.stdinIsTTY ?? process.stdin.isTTY;
  94. const stdoutTTY = deps.stdoutIsTTY ?? process.stdout.isTTY;
  95. if (!stdinTTY || !stdoutTTY) return false;
  96. return !hasBetaSignupChoice(deps);
  97. }
  98. // Dynamic import helper — tsc compiles import() to require() in CJS mode,
  99. // which fails for ESM-only packages (same trick as installer/index.ts).
  100. // eslint-disable-next-line @typescript-eslint/no-implied-eval
  101. const importESM = new Function('specifier', 'return import(specifier)') as
  102. (specifier: string) => Promise<typeof import('@clack/prompts')>;
  103. /**
  104. * The full interactive offer: confirm → email → submit → remember. Shared by
  105. * `codegraph install` (end of a successful install) and `codegraph upgrade`
  106. * (after a successful binary update). Silently does nothing when the gate
  107. * says no; never throws — a marketing question must not fail the command
  108. * that hosts it. Cancel (Ctrl-C) and a failed submit store nothing, so a
  109. * later install/upgrade may offer again; an explicit yes or no is stored
  110. * forever.
  111. */
  112. export async function maybeOfferBetaSignup(deps: BetaSignupDeps = {}): Promise<void> {
  113. try {
  114. if (!shouldOfferBetaSignup(deps)) return;
  115. const clack = await importESM('@clack/prompts');
  116. const wantsBeta = await clack.confirm({
  117. message:
  118. 'Want early access to CodeGraph Pro? Join the beta waitlist — we’ll only email you about CodeGraph, never share your address.',
  119. initialValue: true,
  120. });
  121. if (clack.isCancel(wantsBeta)) {
  122. clack.log.info(`Skipped — you can join anytime at ${BETA_SIGNUP_URL}.`);
  123. return;
  124. }
  125. if (!wantsBeta) {
  126. recordBetaSignupChoice(false, deps);
  127. clack.log.info(`No problem — you can join anytime at ${BETA_SIGNUP_URL}.`);
  128. return;
  129. }
  130. const email = await clack.text({
  131. message: 'What email should we send beta access to?',
  132. placeholder: 'you@company.com',
  133. validate: (value) =>
  134. EMAIL_RE.test((value ?? '').trim()) ? undefined : 'That email address doesn’t look right.',
  135. });
  136. if (clack.isCancel(email)) {
  137. clack.log.info(`Skipped — you can join anytime at ${BETA_SIGNUP_URL}.`);
  138. return;
  139. }
  140. const s = clack.spinner();
  141. s.start('Joining the beta waitlist...');
  142. const ok = await submitBetaSignup(email.trim(), deps);
  143. if (ok) {
  144. s.stop('You’re on the list — we’ll email you when beta access opens.');
  145. recordBetaSignupChoice(true, deps);
  146. } else {
  147. s.stop('Couldn’t reach the waitlist right now.');
  148. clack.log.warn(`No worries — you can join anytime at ${BETA_SIGNUP_URL}.`);
  149. }
  150. } catch {
  151. /* never let the beta question break an install or upgrade */
  152. }
  153. }