diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 488d206..17add42 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -157,10 +157,7 @@ async function oauthSignup(config: Config, provider: 'google' | 'github'): Promi ].join('\n'), `Sign up with ${label}` ); - await promptEnter( - { nonInteractive: config.nonInteractive }, - 'Press Enter to create your account, or Ctrl-C to cancel.' - ); + await promptEnter({ nonInteractive: config.nonInteractive }, 'Create your account?'); await oauthLogin(config, true, { signupEntry: true, provider }); } @@ -201,17 +198,31 @@ async function persistDefaultWorkspace(config: Config): Promise { } } +// Raw envelopes (user object, session token, landing) are output only in +// JSON mode, where they are the data contract for scripts. In a terminal +// the token already lives in credentials.json and the tables are noise. +function emitResult(config: Config, data: unknown): void { + if (config.output === 'json') formatOutput(config, data); +} + +// The installer owns the post-sign-in journey (connects, mapping, topology +// link), so the CLI's own next-steps box would contradict it mid-flow. The +// env var is set per-invocation by the installer, never persisted. +function underInstaller(): boolean { + return Boolean(process.env.POLYLANE_ONBOARDING_RUN); +} + async function finishEmailSignIn(config: Config, email: string, session: VerifiedSession): Promise { if (!session.token) { - formatOutput(config, { landing: session.landing }); + emitResult(config, { landing: session.landing }); outro('Email verified, but no session was returned. Run `polylane auth login`.'); return; } writeSessionCredential(session.token, session.expiresAt, email); await persistDefaultWorkspace(config); - formatOutput(config, { token: session.token, landing: session.landing }); - note(nextSteps(session.expiresAt, session.landing), 'Next steps'); - outro('Signed in.'); + emitResult(config, { token: session.token, landing: session.landing }); + if (!underInstaller()) note(nextSteps(session.expiresAt, session.landing), 'Next steps'); + outro(`Signed in as ${email}.`); } // Also the CLI's email sign-in path: signup is idempotent for an existing @@ -245,10 +256,7 @@ export async function emailSignup(config: Config, args: Record) // 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.' - ); + await promptEnter({ nonInteractive: config.nonInteractive }, 'Continue?'); } else { process.stderr.write(`\n${TERMS_NOTICE}\n\n`); } @@ -283,7 +291,7 @@ export async function emailSignup(config: Config, args: Record) const { user, token } = json.result; if (!user) { // dry-run stub or unexpected server response - formatOutput(config, json.result); + emitResult(config, json.result); outro('Account created, but no session returned. Run `polylane auth login`.'); return; } @@ -291,15 +299,16 @@ export async function emailSignup(config: Config, args: Record) if (user.emailVerified) { // Existing account re-authenticated: the session works immediately. if (!token) { - formatOutput(config, json.result); + emitResult(config, json.result); outro('Account created, but no session returned. Run `polylane auth login`.'); return; } const expiresAt = parseSessionExpiresAt(res.headers.get('set-cookie')) ?? new Date().toISOString(); writeSessionCredential(token, expiresAt, user.email ?? user.id); - formatOutput(config, json.result); - note(nextSteps(expiresAt), 'Next steps'); - outro('Signed in.'); + await persistDefaultWorkspace(config); + emitResult(config, json.result); + if (!underInstaller()) note(nextSteps(expiresAt), 'Next steps'); + outro(`Signed in as ${user.email ?? user.id}.`); return; } @@ -319,7 +328,7 @@ export async function emailSignup(config: Config, args: Record) } if (!isInteractive(config.nonInteractive)) { - formatOutput(config, json.result); + emitResult(config, json.result); outro( `Check ${email} for a verification code, then run: polylane auth signup --email ${email} --code ` ); diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index fdae7c8..3863676 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -102,8 +102,11 @@ export async function promptSelectOrBack( export async function promptEnter(ctx: PromptContext, message: string): Promise { ensureInteractive(ctx, message); - const result = await p.text({ message }); - if (p.isCancel(result)) { + // Not p.text: a text prompt submitted empty renders a dim "undefined" as + // its final value. A confirm keeps Enter-to-continue and renders the + // chosen label instead. + const result = await p.confirm({ message, active: 'Continue', inactive: 'Cancel' }); + if (p.isCancel(result) || result === false) { throw new CLIError('Cancelled', ExitCode.GENERAL); } } diff --git a/test/signup.test.ts b/test/signup.test.ts index e358d48..fc4d94c 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -142,7 +142,7 @@ describe('auth signup terms notice', () => { 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.']); + assert.deepEqual(promptEnterCalls, ['Continue?']); }); it('prints the notice without gating on a non-interactive scripted signup', async () => { @@ -205,6 +205,82 @@ describe('auth signup terms notice', () => { }); }); +// The existing-verified-account (re-auth) path: the signup POST returns a +// verified user plus a session token immediately, no --code round-trip. +describe('auth signup existing-account re-auth', () => { + const reauthRoutes = { + '/v1/auth/signup': signupResponse, + '/v1/auth/whoami': (): Response => + jsonResponse({ success: true, error: null, result: { id: 'user_1', email: 'dev@acme.com' } }), + '/v1/workspaces': (): Response => + jsonResponse({ + success: true, + error: null, + result: { items: [{ id: WORKSPACE_ID, name: 'Acme', slug: 'acme' }], count: 1 }, + }), + }; + + before(() => { + delete process.env.POLYLANE_API_KEY; + delete process.env.POLYLANE_WORKSPACE_ID; + delete process.env.POLYLANE_API_DOMAIN; + }); + + beforeEach(() => { + rmSync(CONFIG_FILE, { force: true }); + rmSync(CREDENTIALS_FILE, { force: true }); + delete process.env.POLYLANE_ONBOARDING_RUN; + }); + + async function run(overrides: Parameters[0] = {}): Promise { + mockApi(reauthRoutes); + captureOutput(); + try { + await authSignupCommand.execute( + mockConfig({ telemetry: false, ...overrides }), + {} as GlobalFlags, + { email: 'dev@acme.com', password: 'hunter2-hunter2' } + ); + } finally { + restoreOutput(); + } + } + + it('text mode never prints the token, the raw user object, or "undefined"', async () => { + await run({ output: 'text' }); + assert.ok(!output.includes('tok_signup'), 'session token leaked to text output'); + assert.ok(!output.includes('emailVerified'), 'raw user object dumped to text output'); + assert.ok(!output.includes('undefined')); + assert.ok(output.includes('Signed in as dev@acme.com.')); + }); + + it('JSON mode still emits the full envelope for scripts', async () => { + await run({ output: 'json' }); + assert.ok(output.includes('tok_signup')); + assert.ok(output.includes('emailVerified')); + }); + + it('persists workspace_id to config.json on re-auth', async () => { + await run({ output: 'text' }); + const config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as { workspace_id?: string }; + assert.equal(config.workspace_id, WORKSPACE_ID); + }); + + it('prints next steps standalone but suppresses them under the installer', async () => { + await run({ output: 'text' }); + assert.ok(output.includes('Onboarding (in order)')); + + process.env.POLYLANE_ONBOARDING_RUN = 'run_test'; + try { + await run({ output: 'text' }); + } finally { + delete process.env.POLYLANE_ONBOARDING_RUN; + } + assert.ok(!output.includes('Onboarding (in order)')); + assert.ok(output.includes('Signed in as dev@acme.com.')); + }); +}); + describe('auth signup --code (email verification)', () => { before(() => { delete process.env.POLYLANE_API_KEY;