From 984cdbdc8624911edb4c0dd692a5abe099911c03 Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Wed, 19 Aug 2026 17:28:32 -0700 Subject: [PATCH 1/3] feat: terms/privacy notice at account creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every CLI path that creates an account now shows the Terms of Service / Privacy Policy notice before the account-creating step: the email signup POST and the provider signup browser trip. Interactive runs gate on Enter (Ctrl-C cancels); scripted or non-interactive runs print the notice and never block. The verbs stay separate on purpose — users agree to the Terms but only acknowledge the Privacy Policy. Co-Authored-By: Claude Fable 5 --- src/commands/auth/signup.ts | 36 +++++++++- src/utils/prompt.ts | 8 +++ test/signup.test.ts | 140 +++++++++++++++++++++++++++++++++++- 3 files changed, 180 insertions(+), 4 deletions(-) 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/signup.test.ts b/test/signup.test.ts index 10c0e1a..68a6cd5 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,33 @@ 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. +const realPrompt = await import('../src/utils/prompt'); +const realEnv = await import('../src/utils/env'); + +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); + }, + }, +}); +mock.module('../src/utils/env', { + namedExports: { + ...realEnv, + isInteractive: (nonInteractive: boolean): boolean => !nonInteractive, + }, +}); + const { authSignupCommand, nextSteps } = await import('../src/commands/auth/signup'); const { mockConfig } = await import('./helpers/config'); @@ -61,6 +88,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; From 32264b89e39cb104b6e1748cddbc8409cac041b3 Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Wed, 19 Aug 2026 17:43:42 -0700 Subject: [PATCH 2/3] test: register module mocks before the mocked modules ever load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 20's mock.module cannot override a module already in the ESM cache (22+ re-links it; 20 leaves the mock silently inert), so the real exports now come from ?real query URLs — separate cache entries — and the env mock registers before prompt.ts?real pulls in the canonical './env'. Fixes the Node 20.x CI leg where the real promptPassword ran and threw 'Missing required input: Password'. Co-Authored-By: Claude Fable 5 --- test/signup.test.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/test/signup.test.ts b/test/signup.test.ts index 68a6cd5..e358d48 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -12,9 +12,21 @@ process.env.HOME = tempHome; // 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. -const realPrompt = await import('../src/utils/prompt'); -const realEnv = await import('../src/utils/env'); +// 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: { @@ -28,12 +40,6 @@ mock.module('../src/utils/prompt', { }, }, }); -mock.module('../src/utils/env', { - namedExports: { - ...realEnv, - isInteractive: (nonInteractive: boolean): boolean => !nonInteractive, - }, -}); const { authSignupCommand, nextSteps } = await import('../src/commands/auth/signup'); const { mockConfig } = await import('./helpers/config'); From 0f2abde61f2ec04812d252f0ffcc49a157256dca Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Wed, 19 Aug 2026 17:46:58 -0700 Subject: [PATCH 3/3] test: make onboarding-run's output silencers real on Node 20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file's noop mocks for intro/outro/note/formatOutput were silently inert on Node 20 (mock.module cannot override an already-cached module there), so the raw clack writes they exist to suppress still reached stdout and could land mid-frame in the test runner's serialized reporter stream — the exact 'Unable to deserialize cloned data' crash the mocks document. The terms-notice change added output on paths these tests execute, which made the latent flake bite in CI's Node 20 leg. Pull the real exports through ?real query URLs so the canonical entries stay unloaded until the mocks register. Co-Authored-By: Claude Fable 5 --- test/onboarding-run.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 },