Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/commands/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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),
Expand Down
23 changes: 21 additions & 2 deletions src/lib/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand All @@ -42,7 +47,15 @@ export async function checkExistingSession(): Promise<string | null> {
}

export async function runAuthFlow(opts: AuthFlowOptions): Promise<void> {
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
Expand Down Expand Up @@ -207,7 +220,13 @@ export async function runAuthFlow(opts: AuthFlowOptions): Promise<void> {
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) {
Expand Down
77 changes: 77 additions & 0 deletions test/commands/signup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading