Skip to content
Merged
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
36 changes: 33 additions & 3 deletions src/commands/auth/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Command } from '../../command';
import type { Config } from '../../config/schema';
import { formatOutput } from '../../output/formatter';
import { getArgString, promptIfMissing } from '../helpers';
import { promptPassword, promptSelect, promptText, intro, outro, note } from '../../utils/prompt';
import { promptEnter, promptPassword, promptSelect, promptText, intro, outro, note } from '../../utils/prompt';
import { isInteractive } from '../../utils/env';
import { oauthLogin, selectWorkspace, type WhoamiResult } from './login';
import { writeCredentials } from '../../auth/credentials';
Expand Down Expand Up @@ -62,6 +62,15 @@ const OAUTH_PROVIDER_LABELS: Record<'google' | 'github', string> = {
github: 'GitHub',
};

// Verbs stay separate on purpose: users agree to the Terms by contract but
// only acknowledge the Privacy Policy — never "agree to our Terms and
// Privacy Policy".
const TERMS_NOTICE = [
'By continuing, you agree to the Terms of Service and acknowledge the Privacy Policy:',
' https://polylane.com/terms/',
' https://polylane.com/privacy/',
].join('\n');

function writeSessionCredential(token: string, expiresAt: string, account: string): void {
const cred: OAuthCredential = {
type: 'oauth',
Expand Down Expand Up @@ -143,9 +152,15 @@ async function oauthSignup(config: Config, provider: 'google' | 'github'): Promi
[
`Your browser will open the Polylane signup page.`,
`Pick "${label}" there, then approve the CLI's access when asked.`,
``,
TERMS_NOTICE,
].join('\n'),
`Sign up with ${label}`
);
await promptEnter(
{ nonInteractive: config.nonInteractive },
'Press Enter to create your account, or Ctrl-C to cancel.'
);
await oauthLogin(config, true, { signupEntry: true, provider });
}

Expand Down Expand Up @@ -219,9 +234,24 @@ export async function emailSignup(config: Config, args: Record<string, unknown>)
return;
}

const passwordArg = getArgString(args, 'password');
const password =
getArgString(args, 'password') ??
(await promptPassword({ nonInteractive: config.nonInteractive }, 'Password'));
passwordArg ?? (await promptPassword({ nonInteractive: config.nonInteractive }, 'Password'));

// The terms notice rides the one account-creating POST below; emailSignup and
// oauthSignup are mutually exclusive per run, so it shows at most once. The
// gate wording is neutral because this is also `auth login`'s Email route and
// signup is idempotent for an existing account. A --password invocation is
// scripted consent: print the notice, never block on Enter.
if (passwordArg === undefined && isInteractive(config.nonInteractive)) {
note(TERMS_NOTICE);
await promptEnter(
{ nonInteractive: config.nonInteractive },
'Press Enter to continue, or Ctrl-C to cancel.'
);
} else {
process.stderr.write(`\n${TERMS_NOTICE}\n\n`);
}

// Need response headers (Set-Cookie -> session expiry) so call request() directly
// rather than via the generated client which only exposes the body.
Expand Down
8 changes: 8 additions & 0 deletions src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ export async function promptSelectOrBack<T extends string>(
return result as T;
}

export async function promptEnter(ctx: PromptContext, message: string): Promise<void> {
ensureInteractive(ctx, message);
const result = await p.text({ message });
if (p.isCancel(result)) {
throw new CLIError('Cancelled', ExitCode.GENERAL);
}
}
Comment thread
justinhelmer marked this conversation as resolved.

export async function promptConfirm(
ctx: PromptContext,
message: string,
Expand Down
8 changes: 6 additions & 2 deletions test/onboarding-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ const { buildBrowserFlowUrls, oauthDeviceCodeFlow } = await import('../src/auth/
// the CLI's own output functions to no-ops, via module mocking, before importing the
// commands that bind them. Their output is not what these tests assert on — the
// run/ref forwarding and the one-shot file cleanup are.
const realPrompt = await import('../src/utils/prompt');
const realFormatter = await import('../src/output/formatter');
// The real exports come from ?real query URLs (separate cache entries): a plain
// import would warm the canonical module-cache entry, and Node 20's mock.module
// cannot override an already-loaded module (22+ re-links it; on 20 the mocks
// were silently inert and the raw clack writes reached stdout after all).
const realPrompt = (await import('../src/utils/prompt.ts?real' as string)) as typeof import('../src/utils/prompt');
const realFormatter = (await import('../src/output/formatter.ts?real' as string)) as typeof import('../src/output/formatter');
const noop = (): void => {};
mock.module('../src/utils/prompt', {
namedExports: { ...realPrompt, intro: noop, outro: noop, note: noop, cancel: noop },
Expand Down
146 changes: 145 additions & 1 deletion test/signup.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, before, after, beforeEach } from 'node:test';
import { describe, it, before, after, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
Expand All @@ -8,6 +8,39 @@ import type { GlobalFlags } from '../src/types/flags';
const tempHome = mkdtempSync(join(tmpdir(), 'polylane-signup-test-'));
process.env.HOME = tempHome;

// The terms-notice tests drive interactive paths, which don't exist under
// node:test (no TTY): isInteractive is re-derived from the config flag alone,
// prompts that would block are stubbed, and note() writes its message plain —
// the clack box wraps long lines, which would break substring assertions.
// The real exports are pulled through `?real` query URLs: a plain import would
// warm the canonical module-cache entry, and Node 20's mock.module cannot
// override an already-loaded module (22+ re-links it; on 20 the mock stays
// silently inert and the real prompts run). Order matters for the same reason:
// the env mock must register before prompt.ts?real loads, because prompt.ts
// imports the canonical './env' as a child.
const realEnv = (await import('../src/utils/env.ts?real' as string)) as typeof import('../src/utils/env');
mock.module('../src/utils/env', {
namedExports: {
...realEnv,
isInteractive: (nonInteractive: boolean): boolean => !nonInteractive,
},
});

const realPrompt = (await import('../src/utils/prompt.ts?real' as string)) as typeof import('../src/utils/prompt');
const promptEnterCalls: string[] = [];
mock.module('../src/utils/prompt', {
namedExports: {
...realPrompt,
note: (message: string, title?: string): void => {
process.stderr.write(`${title ? `${title}\n` : ''}${message}\n`);
},
promptPassword: async (): Promise<string> => 'prompted-password',
promptEnter: async (_ctx: unknown, message: string): Promise<void> => {
promptEnterCalls.push(message);
},
},
});

const { authSignupCommand, nextSteps } = await import('../src/commands/auth/signup');
const { mockConfig } = await import('./helpers/config');

Expand Down Expand Up @@ -61,6 +94,117 @@ function verifyEmailResponse(landing: unknown): Response {
);
}

const TERMS_LINE = 'you agree to the Terms of Service and acknowledge the Privacy Policy';

function signupResponse(): Response {
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString();
return jsonResponse(
{
success: true,
error: null,
result: {
user: { id: 'user_1', email: 'dev@acme.com', emailVerified: true },
token: 'tok_signup',
},
},
{ 'set-cookie': `auth_session=tok_signup; Expires=${expires}; Path=/; HttpOnly` }
);
}

describe('auth signup terms notice', () => {
before(() => {
delete process.env.POLYLANE_API_KEY;
delete process.env.POLYLANE_WORKSPACE_ID;
delete process.env.POLYLANE_API_DOMAIN;
delete process.env.POLYLANE_ONBOARDING_RUN;
});

beforeEach(() => {
rmSync(CONFIG_FILE, { force: true });
rmSync(CREDENTIALS_FILE, { force: true });
promptEnterCalls.length = 0;
});

it('shows the notice once and gates on Enter on the interactive email path', async () => {
mockApi({ '/v1/auth/signup': signupResponse });

captureOutput();
try {
await authSignupCommand.execute(
mockConfig({ telemetry: false, nonInteractive: false }),
{} as GlobalFlags,
{ email: 'dev@acme.com' }
);
} finally {
restoreOutput();
}

assert.equal(output.split(TERMS_LINE).length - 1, 1);
assert.ok(output.includes('https://polylane.com/terms/'));
assert.ok(output.includes('https://polylane.com/privacy/'));
assert.deepEqual(promptEnterCalls, ['Press Enter to continue, or Ctrl-C to cancel.']);
});
Comment thread
justinhelmer marked this conversation as resolved.

it('prints the notice without gating on a non-interactive scripted signup', async () => {
mockApi({ '/v1/auth/signup': signupResponse });

captureOutput();
try {
await authSignupCommand.execute(
mockConfig({ telemetry: false }),
{} as GlobalFlags,
{ email: 'dev@acme.com', password: 'hunter2-hunter2' }
);
} finally {
restoreOutput();
}

assert.equal(output.split(TERMS_LINE).length - 1, 1);
assert.deepEqual(promptEnterCalls, []);
});

it('keeps the notice but skips the gate when --password is passed interactively', async () => {
mockApi({ '/v1/auth/signup': signupResponse });

captureOutput();
try {
await authSignupCommand.execute(
mockConfig({ telemetry: false, nonInteractive: false }),
{} as GlobalFlags,
{ email: 'dev@acme.com', password: 'hunter2-hunter2' }
);
} finally {
restoreOutput();
}

assert.equal(output.split(TERMS_LINE).length - 1, 1);
assert.deepEqual(promptEnterCalls, []);
});

it('does not show the notice on the --code completion path', async () => {
mockApi({
'/v1/auth/verify_email': () => verifyEmailResponse({ kind: 'none' }),
'/v1/auth/whoami': () =>
jsonResponse({ success: true, error: null, result: { id: 'user_1', email: 'dev@acme.com' } }),
'/v1/workspaces': () =>
jsonResponse({ success: true, error: null, result: { items: [], count: 0 } }),
});

captureOutput();
try {
await authSignupCommand.execute(
mockConfig({ telemetry: false }),
{} as GlobalFlags,
{ email: 'dev@acme.com', code: '123456' }
);
} finally {
restoreOutput();
}

assert.ok(!output.includes(TERMS_LINE));
});
});

describe('auth signup --code (email verification)', () => {
before(() => {
delete process.env.POLYLANE_API_KEY;
Expand Down
Loading