diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 5e54593..488d206 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -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'; @@ -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', @@ -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 }); } @@ -219,9 +234,24 @@ export async function emailSignup(config: Config, args: Record) 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. diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 0873aed..e4c750b 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -98,6 +98,14 @@ export async function promptSelectOrBack( return result as T; } +export async function promptEnter(ctx: PromptContext, message: string): Promise { + ensureInteractive(ctx, message); + const result = await p.text({ message }); + if (p.isCancel(result)) { + throw new CLIError('Cancelled', ExitCode.GENERAL); + } +} + export async function promptConfirm( ctx: PromptContext, message: string, diff --git a/test/onboarding-run.test.ts b/test/onboarding-run.test.ts index 4496be2..18f89c9 100644 --- a/test/onboarding-run.test.ts +++ b/test/onboarding-run.test.ts @@ -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 }, diff --git a/test/signup.test.ts b/test/signup.test.ts index 10c0e1a..e358d48 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -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'; @@ -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 => 'prompted-password', + promptEnter: async (_ctx: unknown, message: string): Promise => { + promptEnterCalls.push(message); + }, + }, +}); + const { authSignupCommand, nextSteps } = await import('../src/commands/auth/signup'); const { mockConfig } = await import('./helpers/config'); @@ -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.']); + }); + + 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;