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
13 changes: 10 additions & 3 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface WhoamiResult {
username?: string;
}

interface WorkspaceItem {
export interface WorkspaceItem {
id: string;
name: string;
slug: string;
Expand Down Expand Up @@ -69,7 +69,14 @@ async function validateApiKey(config: Config, key: string): Promise<WhoamiResult
}
}

export async function selectWorkspace(config: Config, user: WhoamiResult): Promise<string | undefined> {
// `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<string | undefined> {
const spinner = new Spinner('Finding your workspaces…');
spinner.start();
try {
Expand All @@ -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)) {
Expand Down
23 changes: 18 additions & 5 deletions src/commands/auth/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,13 +180,26 @@ async function verifyEmail(config: Config, email: string, code: string): Promise
return { ...json.result, expiresAt };
}

async function persistDefaultWorkspace(config: Config): Promise<void> {
// 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<void> {
try {
const user = await requestJson<WhoamiResult>(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 });
}
Expand All @@ -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}.`);
Expand Down
6 changes: 6 additions & 0 deletions src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
49 changes: 47 additions & 2 deletions test/signup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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' }),
Expand All @@ -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'));
});

Expand Down Expand Up @@ -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' });
Expand Down
Loading