diff --git a/src/commands/signup.ts b/src/commands/signup.ts index 746437d..01c7d21 100644 --- a/src/commands/signup.ts +++ b/src/commands/signup.ts @@ -29,6 +29,13 @@ export default class Signup extends BaseCommand { char: 'n', description: 'Your name (skips interactive prompt during signup)', }), + ref: Flags.string({ + description: + 'Referral token from linqapp.com, used to connect this signup to the site visit that led to it. Populated automatically when the quickstart is copied from https://linqapp.com/cli.', + // Not a user-facing knob — it only ever arrives pre-filled in a copied + // command, and surfacing it in `--help` invites confusion. + hidden: true, + }), }; async run(): Promise { @@ -82,6 +89,7 @@ export default class Signup extends BaseCommand { email, code: flags.code?.trim(), name: flags.name?.trim(), + ref: flags.ref?.trim(), log: (msg) => this.log(msg), exit: (code) => this.exit(code), parseError: (res) => this.parseError(res), diff --git a/src/lib/auth-flow.ts b/src/lib/auth-flow.ts index 40ca9a3..f786c61 100644 --- a/src/lib/auth-flow.ts +++ b/src/lib/auth-flow.ts @@ -21,6 +21,11 @@ interface AuthFlowOptions { // so `linq signup` is fully scriptable / AI-agent driven. code?: string; name?: string; + // Opaque referral token minted by linqapp.com and baked into the copied + // quickstart. Forwarded verbatim; the backend decides whether it is still + // valid. Nothing here depends on its format, so a stale or malformed value + // costs nothing beyond losing attribution for this signup. + ref?: string; log: (msg: string) => void; exit: (code: number) => never; parseError: (res: Response) => Promise; @@ -42,7 +47,15 @@ export async function checkExistingSession(): Promise { } export async function runAuthFlow(opts: AuthFlowOptions): Promise { - const { email, code: codeFlag, name: nameFlag, log, exit, parseError } = opts; + const { + email, + code: codeFlag, + name: nameFlag, + ref: refFlag, + log, + exit, + parseError, + } = opts; // Step 1: Send OTP. // Skipped entirely when `--code` was passed — the caller already has a @@ -207,7 +220,13 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise { const signupRes = await fetch(`${BACKEND_URL}/cli/signup`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ signupToken: verifyResult.signupToken, name }), + body: JSON.stringify({ + signupToken: verifyResult.signupToken, + name, + // Omitted entirely when absent so the payload is unchanged for + // anyone who did not arrive via a copied quickstart. + ...(refFlag ? { webDistinctId: refFlag } : {}), + }), }); if (!signupRes.ok) { diff --git a/test/commands/signup.test.ts b/test/commands/signup.test.ts index dbc6d43..80ef29d 100644 --- a/test/commands/signup.test.ts +++ b/test/commands/signup.test.ts @@ -131,4 +131,81 @@ describe('signup (email OTP flow)', () => { // Should never have prompted for a code expect(mockInput).not.toHaveBeenCalled(); }); + + // --ref carries the visitor's linqapp.com PostHog id across the gap between + // the browser and the terminal. It arrives pre-filled in the quickstart + // copied from the CLI page and is forwarded verbatim to /cli/signup, where + // the backend aliases the anonymous site history onto the new account. + function signupBody() { + const call = mockFetch.mock.calls.find((c) => + (c[0] as string).endsWith('/cli/signup') + ); + return JSON.parse((call![1] as RequestInit).body as string); + } + + // Both --ref tests run fully non-interactively (--email --code --name), + // which skips send-otp entirely: re-sending would mint a fresh OTP and + // invalidate the code being verified. So only verify-code and signup are + // mocked here. + function mockNonInteractiveSignupFlow() { + mockFetch + .mockResolvedValueOnce( + jsonResponse(200, { + needsSignup: true, + signupToken: 'sgn-tok-1', + email: 'new@example.com', + }) + ) + .mockResolvedValueOnce( + jsonResponse(201, { + token: 'api-token-xyz', + orgId: '1234', + email: 'new@example.com', + name: 'Test User', + accountInfo: { + accountLabel: 'Shared', + phones: [{ phoneNumber: '+12025551234' }], + }, + }) + ); + } + + it('forwards --ref to /cli/signup as webDistinctId', async () => { + mockNonInteractiveSignupFlow(); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new Signup( + [ + '--email', + 'new@example.com', + '--code', + '123456', + '--name', + 'Test User', + '--ref', + '0198f2a1-dead-beef-cafe-000000000000~1780000000', + ], + config + ); + await cmd.run(); + + expect(signupBody().webDistinctId).toBe( + '0198f2a1-dead-beef-cafe-000000000000~1780000000' + ); + }); + + it('omits webDistinctId entirely when --ref is absent', async () => { + mockNonInteractiveSignupFlow(); + + const config = await Config.load({ root: process.cwd() }); + const cmd = new Signup( + ['--email', 'new@example.com', '--code', '123456', '--name', 'Test User'], + config + ); + await cmd.run(); + + // Absent, not null/empty — the payload must be byte-identical to what + // pre-2.6.0 clients send for anyone who did not arrive via the quickstart. + expect('webDistinctId' in signupBody()).toBe(false); + }); });