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
127 changes: 127 additions & 0 deletions src/lib/adapters/cli-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,5 +523,132 @@ describe('CLIAdapter', () => {
expect(calls).toContain('Using your active WorkOS environment');
expect(calls.join('\n')).not.toContain('Using environment:');
});

it('unclaimed copy also covers the cli-flags path, which is where provisioning lands', async () => {
// runWithCore backfills options.apiKey/clientId from the .env.local that
// provisioning just wrote, so a freshly provisioned environment
// short-circuits at checkingCliFlags and never reaches staging
// resolution. The copy has to exist on this path or it never fires.
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_x',
clientId: 'client_x',
claimToken: 'ct_x',
authkitDomain: 'witty-rest-53.authkit.app',
});
await adapter.start();
const ui = await import('../../utils/ui.js');

emitter.emit('credentials:found', {
source: 'env',
credentials: { clientId: 'client_x', apiKey: 'sk_test_x' },
});

const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
expect(calls).toContain('Using a new WorkOS environment created for this install (witty-rest-53.authkit.app)');
expect(calls.join('\n')).not.toContain('Found existing WorkOS credentials');
});

it('names .env.local for an env-file backfill into a claimed environment', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'staging-3',
type: 'sandbox',
apiKey: 'sk_test_x',
clientId: 'client_x',
});
await adapter.start();
const ui = await import('../../utils/ui.js');

emitter.emit('credentials:found', {
source: 'env',
credentials: { clientId: 'client_x', apiKey: 'sk_test_x' },
});

const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
expect(calls).toContain('Found existing WorkOS credentials in .env.local');
});

it('never claims .env.local for credentials passed as flags', async () => {
mockGetActiveEnvironment.mockReturnValue(null);
await adapter.start();
const ui = await import('../../utils/ui.js');

emitter.emit('credentials:found', {
source: 'cli',
credentials: { clientId: 'client_flag', apiKey: 'sk_test_flag' },
});

const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
expect(calls.join('\n')).not.toContain('.env.local');
});

it('says the environment is new and names it by AuthKit domain when unclaimed', async () => {
// Provisioning returns no environmentId/environmentName, so the generic
// "Using your active WorkOS environment" made a just-created environment
// read as one the user already had — the friction that sent people to the
// dashboard to compare client IDs.
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_x',
clientId: 'client_x',
claimToken: 'ct_x',
authkitDomain: 'witty-rest-53.authkit.app',
});
await adapter.start();
const ui = await import('../../utils/ui.js');

emitter.emit('staging:fetching', {});
emitter.emit('staging:success', { source: 'stored', credentials: { clientId: 'client_x', apiKey: 'sk_test_x' } });

const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
expect(calls).toContain('Using a new WorkOS environment created for this install (witty-rest-53.authkit.app)');
expect(calls.join('\n')).not.toContain('Using your active WorkOS environment');
});

it('drops the domain suffix for an unclaimed env provisioned before authkitDomain was stored', async () => {
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_x',
clientId: 'client_x',
claimToken: 'ct_x',
});
await adapter.start();
const ui = await import('../../utils/ui.js');

emitter.emit('staging:fetching', {});
emitter.emit('staging:success', { source: 'stored', credentials: { clientId: 'client_x', apiKey: 'sk_test_x' } });

const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
expect(calls).toContain('Using a new WorkOS environment created for this install');
});

it('keeps the generic copy when an unclaimed profile did not supply the credentials', async () => {
// A leftover unclaimed profile can sit active while the install used the
// project's own keys — calling that environment "created for this install"
// would be a lie about which environment the app now targets.
mockGetActiveEnvironment.mockReturnValue({
name: 'unclaimed',
type: 'unclaimed',
apiKey: 'sk_test_x',
clientId: 'client_x',
claimToken: 'ct_x',
authkitDomain: 'witty-rest-53.authkit.app',
});
await adapter.start();
const ui = await import('../../utils/ui.js');

emitter.emit('staging:fetching', {});
emitter.emit('staging:success', {
source: 'stored',
credentials: { clientId: 'client_x', apiKey: 'sk_test_project' },
});

const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
expect(calls).toContain('Using your active WorkOS environment');
expect(calls.join('\n')).not.toContain('created for this install');
});
});
});
48 changes: 46 additions & 2 deletions src/lib/adapters/cli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { relative } from 'node:path';
import ui, { PromptUnavailableError } from '../../utils/ui.js';
import chalk from 'chalk';
import { getConfig } from '../settings.js';
import { getActiveEnvironment, profileEnvironmentLabel } from '../config-store.js';
import { getActiveEnvironment, isUnclaimedEnvironment, profileEnvironmentLabel } from '../config-store.js';
import { ProgressTracker } from '../progress-tracker.js';
import { renderCompletionSummary, renderBrandMark } from '../../utils/summary-box.js';
import { classifyAgentFailure, describeAgentFailure } from '../failure-classifier.js';
Expand Down Expand Up @@ -291,7 +291,39 @@ export class CLIAdapter implements InstallerAdapter {
this.queueableLog(() => ui.log.warn('Could not detect framework automatically'));
};

private handleCredentialsFound = (): void => {
/**
* Copy for an install running against a newly provisioned unclaimed
* environment, or undefined when it isn't.
*
* Provisioning returns no environmentId/environmentName, so
* `profileEnvironmentLabel` has nothing to build from and the generic copy
* reads as though the CLI picked up an environment the user already had —
* the ambiguity that sends people to the dashboard to compare client IDs.
*
* Requires an exact credential match: a leftover unclaimed profile can sit
* active while the install used the project's own keys, and calling that
* environment "created for this install" would misname what the app targets.
*/
private newUnclaimedEnvironmentLine(credentials?: { clientId?: string; apiKey?: string }): string | undefined {
const active = getActiveEnvironment();
if (!active || !isUnclaimedEnvironment(active)) return undefined;
if (!credentials?.clientId || !credentials.apiKey) return undefined;
if (active.clientId !== credentials.clientId || active.apiKey !== credentials.apiKey) return undefined;
const domain = active.authkitDomain ? ` (${active.authkitDomain})` : '';
return `Using a new WorkOS environment created for this install${domain}`;
}

private handleCredentialsFound = ({ source, credentials }: InstallerEvents['credentials:found']): void => {
const newEnvironment = this.newUnclaimedEnvironmentLine(credentials);
if (newEnvironment) {
ui.log.success(newEnvironment);
return;
}
// Only the env-file backfill can honestly name .env.local. For credentials
// the user passed as flags, the integration installer announces them
// itself — naming a file they may never have touched is the same
// wrong-provenance claim in the other direction.
if (source === 'cli') return;
ui.log.success('Found existing WorkOS credentials in .env.local');
};

Expand Down Expand Up @@ -350,6 +382,18 @@ export class CLIAdapter implements InstallerAdapter {
);
const label = active && suppliedCredentials ? profileEnvironmentLabel(active) : undefined;
const named = label ? `${label} (${active!.name})` : undefined;

// An unclaimed environment can never satisfy the naming above, so it gets
// its own copy (see newUnclaimedEnvironmentLine). Reachable here when
// .env.local was not pre-populated and checkStoredAuth routed an already
// active unclaimed profile through staging resolution.
const newEnvironment = this.newUnclaimedEnvironmentLine(credentials);
if (newEnvironment) {
this.stopSpinner('Environment ready');
ui.log.success(newEnvironment);
return;
}

if (source === 'device') {
this.stopSpinner('Environment ready');
ui.log.success(named ? `Set up environment: ${named}` : 'Set up a WorkOS environment for this install');
Expand Down
45 changes: 45 additions & 0 deletions src/lib/completion-data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,49 @@ describe('buildCompletionData', () => {
expect(data.signInSnippet).toBeUndefined();
expect(data.nextSteps.some((s) => /refreshAuth/.test(s))).toBe(false);
});

describe('unclaimed-environment claim step', () => {
it('leads the next steps with the claim command when one is supplied', async () => {
writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } });

const data = await buildCompletionData(
{ integration: 'nextjs', changedFiles: [], installDir },
{ ...baseDeps, claimCommand: 'workos profile claim' },
);

// First, not buried: the provision-time notice has already scrolled away
// behind scaffolding and the agent run by the time this box renders.
expect(data.nextSteps[0]).toBe('Run `workos profile claim` to link this environment to your WorkOS account');
// The concrete steps still follow, in order.
expect(data.nextSteps[1]).toContain('start your dev server');
expect(data.nextSteps[2]).toContain('test authentication');
});

it('omits the claim step for a claimed environment', async () => {
writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } });

const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps);

expect(data.nextSteps.some((s) => /claim/i.test(s))).toBe(false);
expect(data.nextSteps[0]).toContain('start your dev server');
});

it('keeps the claim step ahead of framework steps too', async () => {
writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } });

const data = await buildCompletionData(
{ integration: 'nextjs', changedFiles: [], installDir },
{
...baseDeps,
claimCommand: 'workos profile claim',
frameworkNextSteps: ['Visit the WorkOS Dashboard to manage users and settings'],
},
);

const claimIndex = data.nextSteps.findIndex((s) => /profile claim/.test(s));
const dashboardIndex = data.nextSteps.findIndex((s) => /WorkOS Dashboard/.test(s));
expect(claimIndex).toBe(0);
expect(dashboardIndex).toBeGreaterThan(claimIndex);
});
});
});
16 changes: 15 additions & 1 deletion src/lib/completion-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface CompletionDataDeps {
frameworkNextSteps?: string[];
/** Per-framework "add a sign-in link" snippet */
signInSnippet?: string;
/**
* Claim command for an unclaimed environment this install actually used
* (e.g. `workos profile claim`), or undefined for a claimed environment.
* Resolved by the caller, which owns the config lookup.
*/
claimCommand?: string;
}

/**
Expand All @@ -49,12 +55,20 @@ export async function buildCompletionData(ctx: CompletionContext, deps: Completi
// above already names the exact lockfile-aware command.
const framework = (deps.frameworkNextSteps ?? []).filter((s) => !/start .*dev(elopment)? server/i.test(s));

// An unclaimed environment's credentials live only on this machine, so a
// missed claim loses the environment for good — it leads the next steps for
// that reason, and because the provision-time notice is printed before
// scaffolding and the agent run, minutes of output before the install ends.
const claim = deps.claimCommand
? [`Run \`${deps.claimCommand}\` to link this environment to your WorkOS account`]
: [];

return {
integration: ctx.integration,
devCommand,
url,
files,
nextSteps: [...concrete, ...framework],
nextSteps: [...claim, ...concrete, ...framework],
docsUrl: deps.docsUrl,
dashboardUrl: deps.dashboardUrl,
signInSnippet: deps.signInSnippet,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ interface BaseEnvironmentConfig {
* disambiguate (e.g. "My Project > Staging").
*/
projectName?: string;
/**
* The environment's AuthKit domain (e.g. `witty-rest-53.authkit.app`).
*
* The only human-recognizable identifier an unclaimed environment has:
* provisioning returns no `environmentId` or `environmentName`, so display
* paths that rely on `profileEnvironmentLabel` have nothing to print for a
* freshly provisioned environment. Cosmetic — never an identity key.
*/
authkitDomain?: string;
}

export interface ClaimedEnvironmentConfig extends BaseEnvironmentConfig {
Expand Down
9 changes: 8 additions & 1 deletion src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ export interface InstallerEvents {
'git:dirty:confirmed': Record<string, never>;
'git:dirty:cancelled': Record<string, never>;
'credentials:gathering': { requiresApiKey: boolean };
'credentials:found': Record<string, never>;
/**
* Credentials were already on `options` when the machine started. `source`
* separates the two ways that happens — flags the user typed ('cli') versus a
* pair `runWithCore` backfilled from the project's env file ('env') — because
* the two need different copy, and the payload lets a listener check whether
* the active profile is the one that supplied them.
*/
'credentials:found': { source?: 'cli' | 'env'; credentials?: { clientId?: string; apiKey?: string } };
// Credential discovery events
'credentials:env:detected': { files: string[] };
'credentials:env:prompt': { files: string[] };
Expand Down
67 changes: 67 additions & 0 deletions src/lib/installer-core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,73 @@ describe('InstallerCore State Machine', () => {
actor.stop();
});

it('keeps an env-file provenance through the cli-flags short-circuit', async () => {
// runWithCore backfills options.apiKey/clientId from the project's
// .env.local — the path a freshly provisioned unclaimed environment takes.
// Those credentials must not be relabeled 'cli', or the installer tells
// the user it is using credentials they never provided.
const emitter = createInstallerEventEmitter();
const options: InstallerOptions = {
debug: false,
forceInstall: false,
installDir: '/test/project',
default: false,
local: true,
ci: false,
skipAuth: true,
dashboard: false,
emitter,
apiKey: 'sk_test_provisioned',
clientId: 'client_provisioned',
credentialSource: 'env',
};

const found: Array<{ source?: string }> = [];
emitter.on('credentials:found', (payload) => found.push(payload));

const actor = createActor(installerMachine.provide({ actors: baseMockActors }), {
input: { emitter, options },
});
actor.start();
actor.send({ type: 'START' });
await new Promise((r) => setTimeout(r, 200));

expect(found).toHaveLength(1);
expect(found[0].source).toBe('env');
actor.stop();
});

it('labels credentials that really came from flags as cli', async () => {
const emitter = createInstallerEventEmitter();
const options: InstallerOptions = {
debug: false,
forceInstall: false,
installDir: '/test/project',
default: false,
local: true,
ci: false,
skipAuth: true,
dashboard: false,
emitter,
apiKey: 'sk_test_flag',
clientId: 'client_flag',
};

const found: Array<{ source?: string }> = [];
emitter.on('credentials:found', (payload) => found.push(payload));

const actor = createActor(installerMachine.provide({ actors: baseMockActors }), {
input: { emitter, options },
});
actor.start();
actor.send({ type: 'START' });
await new Promise((r) => setTimeout(r, 200));

expect(found).toHaveLength(1);
expect(found[0].source).toBe('cli');
actor.stop();
});

it('skips device auth when checkStoredAuth returns true (unclaimed env)', async () => {
const emitter = createInstallerEventEmitter();
const options: InstallerOptions = {
Expand Down
Loading