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
45 changes: 27 additions & 18 deletions src/commands/auth/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down Expand Up @@ -201,17 +198,31 @@ async function persistDefaultWorkspace(config: Config): Promise<void> {
}
}

// 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<void> {
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
Expand Down Expand Up @@ -245,10 +256,7 @@ export async function emailSignup(config: Config, args: Record<string, unknown>)
// 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`);
}
Expand Down Expand Up @@ -283,23 +291,24 @@ export async function emailSignup(config: Config, args: Record<string, unknown>)
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;
}

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;
}

Expand All @@ -319,7 +328,7 @@ export async function emailSignup(config: Config, args: Record<string, unknown>)
}

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 <code>`
);
Expand Down
7 changes: 5 additions & 2 deletions src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,11 @@ export async function promptSelectOrBack<T extends string>(

export async function promptEnter(ctx: PromptContext, message: string): Promise<void> {
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);
}
}
Expand Down
78 changes: 77 additions & 1 deletion test/signup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<typeof mockConfig>[0] = {}): Promise<void> {
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;
Expand Down
Loading