feat(installer): offer CodeGraph Pro beta signup after install and upgrade (#1297)
One-time, strictly opt-in prompt at the end of codegraph install and codegraph upgrade to join the CodeGraph Pro beta waitlist (same list as the getcodegraph.com homepage form). Nothing is sent unless the user answers yes AND enters an email; either answer is recorded machine-wide so no later install or upgrade re-asks, and --yes / non-interactive / CI runs never see the prompt. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2b0b4b587e
commit
a66683d3eb
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* CodeGraph Pro beta opt-in (installer Step 5½).
|
||||
*
|
||||
* Covers the module the interactive prompt drives:
|
||||
* - ask-once persistence in the user-level state dir (temp dir injected —
|
||||
* no real ~/.codegraph ever touched)
|
||||
* - the submit request shape (endpoint, method, JSON body)
|
||||
* - fail-soft behavior: bad responses and network errors return false,
|
||||
* never throw
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
BETA_SIGNUP_ENDPOINT,
|
||||
EMAIL_RE,
|
||||
hasBetaSignupChoice,
|
||||
recordBetaSignupChoice,
|
||||
shouldOfferBetaSignup,
|
||||
submitBetaSignup,
|
||||
} from '../src/installer/beta-signup';
|
||||
|
||||
describe('beta signup choice persistence', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-beta-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports no choice on a fresh machine', () => {
|
||||
expect(hasBetaSignupChoice({ dir })).toBe(false);
|
||||
});
|
||||
|
||||
it('remembers a subscribed choice so no future install re-asks', () => {
|
||||
recordBetaSignupChoice(true, { dir });
|
||||
expect(hasBetaSignupChoice({ dir })).toBe(true);
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'beta-signup.json'), 'utf8'));
|
||||
expect(raw.status).toBe('subscribed');
|
||||
expect(raw.updated_at).toBeTruthy();
|
||||
});
|
||||
|
||||
it('remembers a declined choice too — declining is also asked-once', () => {
|
||||
recordBetaSignupChoice(false, { dir });
|
||||
expect(hasBetaSignupChoice({ dir })).toBe(true);
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'beta-signup.json'), 'utf8'));
|
||||
expect(raw.status).toBe('declined');
|
||||
});
|
||||
|
||||
it('creates the state dir when missing', () => {
|
||||
const nested = path.join(dir, 'not', 'yet', 'there');
|
||||
recordBetaSignupChoice(true, { dir: nested });
|
||||
expect(hasBetaSignupChoice({ dir: nested })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a corrupted choice file as no choice', () => {
|
||||
fs.writeFileSync(path.join(dir, 'beta-signup.json'), 'not json');
|
||||
expect(hasBetaSignupChoice({ dir })).toBe(false);
|
||||
fs.writeFileSync(path.join(dir, 'beta-signup.json'), JSON.stringify({ status: 'maybe' }));
|
||||
expect(hasBetaSignupChoice({ dir })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldOfferBetaSignup — the shared no-spam gate', () => {
|
||||
let dir: string;
|
||||
const tty = { stdinIsTTY: true, stdoutIsTTY: true };
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-beta-gate-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('offers on a fresh machine with a real terminal', () => {
|
||||
expect(shouldOfferBetaSignup({ dir, ...tty })).toBe(true);
|
||||
});
|
||||
|
||||
it('never offers again once subscribed — from install OR upgrade', () => {
|
||||
recordBetaSignupChoice(true, { dir });
|
||||
expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-install' })).toBe(false);
|
||||
expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-upgrade' })).toBe(false);
|
||||
});
|
||||
|
||||
it('never offers again once declined either', () => {
|
||||
recordBetaSignupChoice(false, { dir });
|
||||
expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-install' })).toBe(false);
|
||||
expect(shouldOfferBetaSignup({ dir, ...tty, source: 'cli-upgrade' })).toBe(false);
|
||||
});
|
||||
|
||||
it('never offers without a terminal (scripts, CI, piped output)', () => {
|
||||
expect(shouldOfferBetaSignup({ dir, stdinIsTTY: false, stdoutIsTTY: true })).toBe(false);
|
||||
expect(shouldOfferBetaSignup({ dir, stdinIsTTY: true, stdoutIsTTY: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitBetaSignup', () => {
|
||||
it('POSTs the email as JSON to the waitlist endpoint', async () => {
|
||||
let captured: { url: string; init: RequestInit } | null = null;
|
||||
const fetchImpl = (async (url: unknown, init?: RequestInit) => {
|
||||
captured = { url: String(url), init: init! };
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const ok = await submitBetaSignup('dev@example.com', { fetchImpl });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(captured!.url).toBe(BETA_SIGNUP_ENDPOINT);
|
||||
expect(captured!.init.method).toBe('POST');
|
||||
expect((captured!.init.headers as Record<string, string>)['Content-Type']).toBe(
|
||||
'application/json',
|
||||
);
|
||||
const body = JSON.parse(String(captured!.init.body));
|
||||
expect(body).toEqual({ email: 'dev@example.com', source: 'cli-install' });
|
||||
});
|
||||
|
||||
it('records where the signup came from (install vs upgrade)', async () => {
|
||||
let body: Record<string, unknown> = {};
|
||||
const fetchImpl = (async (_url: unknown, init?: RequestInit) => {
|
||||
body = JSON.parse(String(init!.body));
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
await submitBetaSignup('dev@example.com', { fetchImpl, source: 'cli-upgrade' });
|
||||
expect(body.source).toBe('cli-upgrade');
|
||||
});
|
||||
|
||||
it('returns false on a non-2xx response', async () => {
|
||||
const fetchImpl = (async () =>
|
||||
new Response('{"ok":false}', { status: 502 })) as typeof fetch;
|
||||
expect(await submitBetaSignup('dev@example.com', { fetchImpl })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false (never throws) when the network fails', async () => {
|
||||
const fetchImpl = (async () => {
|
||||
throw new Error('offline');
|
||||
}) as typeof fetch;
|
||||
expect(await submitBetaSignup('dev@example.com', { fetchImpl })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EMAIL_RE', () => {
|
||||
it('matches the landing-page validation shape', () => {
|
||||
expect(EMAIL_RE.test('you@company.com')).toBe(true);
|
||||
expect(EMAIL_RE.test('first.last+tag@sub.domain.io')).toBe(true);
|
||||
expect(EMAIL_RE.test('nope')).toBe(false);
|
||||
expect(EMAIL_RE.test('nope@nodot')).toBe(false);
|
||||
expect(EMAIL_RE.test('spaces in@mail.com')).toBe(false);
|
||||
expect(EMAIL_RE.test('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -410,6 +410,69 @@ describe('runUpgrade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beta signup offer — fires ONLY after a real, successful binary update.
|
||||
// (The hook itself gates on TTY + the once-per-machine stored choice; see
|
||||
// __tests__/beta-signup.test.ts. Here we pin WHEN the upgrade path invokes it.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('runUpgrade beta signup offer', () => {
|
||||
function withSpy(deps: UpgradeDeps): { deps: UpgradeDeps; offered: () => number } {
|
||||
let n = 0;
|
||||
deps.offerBetaSignup = async () => { n += 1; };
|
||||
return { deps, offered: () => n };
|
||||
}
|
||||
|
||||
it('offers after a successful npm upgrade', async () => {
|
||||
const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' });
|
||||
const { offered } = withSpy(deps);
|
||||
expect(await runUpgrade({}, deps)).toBe(0);
|
||||
expect(offered()).toBe(1);
|
||||
});
|
||||
|
||||
it('does not offer on --check', async () => {
|
||||
const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' });
|
||||
const { offered } = withSpy(deps);
|
||||
expect(await runUpgrade({ check: true }, deps)).toBe(0);
|
||||
expect(offered()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not offer when already up to date', async () => {
|
||||
const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.9' });
|
||||
const { offered } = withSpy(deps);
|
||||
expect(await runUpgrade({}, deps)).toBe(0);
|
||||
expect(offered()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not offer when the upgrade fails', async () => {
|
||||
const { deps } = makeDeps(
|
||||
{ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' },
|
||||
1 // npm exits non-zero
|
||||
);
|
||||
const { offered } = withSpy(deps);
|
||||
expect(await runUpgrade({}, deps)).toBe(1);
|
||||
expect(offered()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not offer on npx / source no-op paths', async () => {
|
||||
for (const method of [
|
||||
{ kind: 'npx' } as const,
|
||||
{ kind: 'source', root: '/dev/codegraph' } as const,
|
||||
]) {
|
||||
const { deps } = makeDeps({ method, currentVersion: '0.9.8' });
|
||||
const { offered } = withSpy(deps);
|
||||
expect(await runUpgrade({}, deps)).toBe(0);
|
||||
expect(offered()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('a throwing offer never fails the upgrade', async () => {
|
||||
const { deps } = makeDeps({ method: { kind: 'npm', scope: 'global' }, currentVersion: '0.9.8' });
|
||||
deps.offerBetaSignup = async () => { throw new Error('boom'); };
|
||||
expect(await runUpgrade({}, deps)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-upgrade self-heal of installed agent surfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user