From ade7089372a22d07d043a8cfeaeeb6bfa4cab2fd Mon Sep 17 00:00:00 2001 From: Justin Helmer Date: Tue, 25 Aug 2026 10:16:50 -0700 Subject: [PATCH] fix(auth): say the workspace was created, gutter-aligned, during email sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-workspace outcome after email sign-in was a raw stderr line ("Using workspace My Workspace (ws_…)") that broke the clack prompt gutter and read as if the workspace pre-existed. selectWorkspace now takes an announce callback; the signup flow renders a clack step line worded by the landing kind: created → "Created your first workspace (called "X"), and set it as your default."; joined / existing keep their own phrasing. API-key and OAuth login paths are unchanged. Co-Authored-By: Claude Fable 5 --- src/commands/auth/login.ts | 13 +++++++--- src/commands/auth/signup.ts | 23 +++++++++++++---- src/utils/prompt.ts | 6 +++++ test/signup.test.ts | 49 +++++++++++++++++++++++++++++++++++-- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 25870fc..5f51087 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -22,7 +22,7 @@ export interface WhoamiResult { username?: string; } -interface WorkspaceItem { +export interface WorkspaceItem { id: string; name: string; slug: string; @@ -69,7 +69,14 @@ async function validateApiKey(config: Config, key: string): Promise { +// `announce` renders the single-workspace outcome; the default is a plain +// stderr line for the API-key / OAuth paths. The clack-driven signup flow +// passes its own so the line keeps the prompt gutter alignment. +export async function selectWorkspace( + config: Config, + user: WhoamiResult, + announce: (ws: WorkspaceItem) => void = (ws) => process.stderr.write(`Using workspace ${ws.name} (${ws.id})\n`) +): Promise { const spinner = new Spinner('Finding your workspaces…'); spinner.start(); try { @@ -84,7 +91,7 @@ export async function selectWorkspace(config: Config, user: WhoamiResult): Promi } if (list.items.length === 1) { const ws = list.items[0]!; - process.stderr.write(`Using workspace ${ws.name} (${ws.id})\n`); + announce(ws); return ws.id; } if (!isInteractive(config.nonInteractive)) { diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 832c37e..51e08b7 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -2,9 +2,9 @@ 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 { promptPassword, promptSelect, promptText, intro, outro, note, step } from '../../utils/prompt'; import { isInteractive } from '../../utils/env'; -import { oauthLogin, selectWorkspace, type WhoamiResult } from './login'; +import { oauthLogin, selectWorkspace, type WhoamiResult, type WorkspaceItem } from './login'; import { writeCredentials } from '../../auth/credentials'; import { resolveOnboardingRunId, consumeOnboardingRunFile } from '../../auth/onboarding-run'; import { parseSessionExpiresAt } from '../../auth/signup-helpers'; @@ -180,13 +180,26 @@ async function verifyEmail(config: Config, email: string, code: string): Promise return { ...json.result, expiresAt }; } -async function persistDefaultWorkspace(config: Config): Promise { +// What the sign-in did about the workspace, said plainly: a first-time user +// never asked for one, so "Using workspace X" reads as if it already existed. +export function workspaceOutcome(ws: WorkspaceItem, landing?: Landing): string { + switch (landing?.kind) { + case 'created': + return `Created your first workspace (called "${ws.name}"), and set it as your default.`; + case 'joined': + return `Joined the "${ws.name}" workspace, and set it as your default.`; + default: + return `Using workspace "${ws.name}" as your default.`; + } +} + +async function persistDefaultWorkspace(config: Config, landing?: Landing): Promise { try { const user = await requestJson(config, { method: 'GET', url: '/v1/auth/whoami', }); - const wsId = await selectWorkspace(config, user); + const wsId = await selectWorkspace(config, user, (ws) => step(workspaceOutcome(ws, landing))); if (wsId) { writeConfigFile({ workspace_id: wsId }); } @@ -209,7 +222,7 @@ async function finishEmailSignIn(config: Config, email: string, session: Verifie return; } writeSessionCredential(session.token, session.expiresAt, email); - await persistDefaultWorkspace(config); + await persistDefaultWorkspace(config, session.landing); emitResult(config, { token: session.token, landing: session.landing }); if (config.hints) note(nextSteps(session.landing), 'Next steps'); outro(`Signed in as ${email}.`); diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index baa8297..f27289a 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -129,6 +129,12 @@ export function outro(message: string): void { p.outro(message); } +// A gutter-aligned status line between prompts (clack's ◇ step marker), for +// outcomes that happen without a question — e.g. the default workspace. +export function step(message: string): void { + p.log.step(message); +} + export function cancel(message: string): void { p.cancel(message); } diff --git a/test/signup.test.ts b/test/signup.test.ts index dfabeae..f6618d5 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -43,7 +43,7 @@ mock.module('../src/utils/prompt', { }, }); -const { authSignupCommand, nextSteps } = await import('../src/commands/auth/signup'); +const { authSignupCommand, nextSteps, workspaceOutcome } = await import('../src/commands/auth/signup'); const { mockConfig } = await import('./helpers/config'); const CONFIG_FILE = join(tempHome, '.polylane', 'config.json'); @@ -265,10 +265,12 @@ describe('auth signup existing-account re-auth', () => { assert.ok(output.includes('emailVerified')); }); - it('persists workspace_id to config.json on re-auth', async () => { + it('persists workspace_id to config.json on re-auth and announces it as "using", not created', 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); + assert.ok(output.includes('Using workspace "Acme" as your default.'), output); + assert.ok(!output.includes('Created your first workspace')); }); it('prints next steps by default but not with hints disabled', async () => { @@ -329,6 +331,34 @@ describe('auth signup --code (email verification)', () => { assert.equal(creds.access_token, 'tok_test'); }); + it('says the workspace was created for a first-time user, gutter-aligned, without the raw id', async () => { + mockApi({ + '/v1/auth/verify_email': () => verifyEmailResponse({ kind: 'created', workspaceSlug: 'acme' }), + '/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: [{ id: WORKSPACE_ID, name: 'Acme', slug: 'acme' }], count: 1 }, + }), + }); + + captureOutput(); + try { + await authSignupCommand.execute( + mockConfig({ telemetry: false }), + {} as GlobalFlags, + { email: 'dev@acme.com', code: '123456' } + ); + } finally { + restoreOutput(); + } + + assert.ok(output.includes('Created your first workspace (called "Acme"), and set it as your default.'), output); + assert.ok(!output.includes(`Using workspace Acme (${WORKSPACE_ID})`), 'raw un-aligned workspace line still printed'); + }); + it('parses the landing shape and names the workspace in next steps', async () => { mockApi({ '/v1/auth/verify_email': () => verifyEmailResponse({ kind: 'joined', workspaceSlug: 'acme' }), @@ -354,6 +384,7 @@ describe('auth signup --code (email verification)', () => { } assert.ok(output.includes('You joined the "acme" workspace')); + assert.ok(output.includes('Joined the "Acme" workspace, and set it as your default.'), output); assert.ok(!output.includes('polylane workspace create')); }); @@ -385,6 +416,20 @@ describe('auth signup --code (email verification)', () => { }); }); +describe('workspaceOutcome', () => { + const ws = { id: 'ws_1', name: 'Acme', slug: 'acme' }; + it('names a created workspace as created', () => { + assert.equal(workspaceOutcome(ws, { kind: 'created' }), 'Created your first workspace (called "Acme"), and set it as your default.'); + }); + it('names a joined workspace as joined', () => { + assert.equal(workspaceOutcome(ws, { kind: 'joined' }), 'Joined the "Acme" workspace, and set it as your default.'); + }); + it('falls back to "using" for existing accounts and unknown landings', () => { + assert.equal(workspaceOutcome(ws, { kind: 'existing' }), 'Using workspace "Acme" as your default.'); + assert.equal(workspaceOutcome(ws), 'Using workspace "Acme" as your default.'); + }); +}); + describe('nextSteps', () => { it('names a created workspace and does not suggest creating one', () => { const text = nextSteps({ kind: 'created', workspaceSlug: 'acme' });