Skip to content

Commit ad8f85f

Browse files
authored
fix(install): surface unclaimed environments honestly (#234)
* fix(install): surface unclaimed environments honestly A friction log of the AuthKit getting-started path found the installer describing a zero-auth environment it had just minted itself as "the WorkOS credentials you provided". The tester went to the dashboard to reconcile a client ID that did not match, and only found the claim prompt by accident after starting a dev server. The provenance gate added in #200 was correct; the label feeding it was not. runWithCore backfills credentials from the project's .env.local before the machine starts, and that is the path a freshly provisioned environment takes, so checkingCliFlags short-circuited and called them 'cli'. Reading the machine's own transitions exonerates it -- the mislabeling happens one level up, in whoever builds the initial context. Claiming is the one step that cannot be recovered later, since an unclaimed environment's credentials live only on this machine, so it now leads the completion box instead of appearing once at provision time and scrolling away behind the agent run. Provisioning returns no environmentId or environmentName, so the environment naming from #231 could never reach an unclaimed environment and every status line degraded to generic copy. Persisting authkitDomain gives it the one human-recognizable name it has. * fix(install): stop labeling mixed credential pairs as env-sourced A pair where the user supplied one credential and the project env file backfilled the other was labeled 'env' wholesale, so the installer announced `.env.local` as the origin of a value typed on the command line and stayed silent about the value the user did provide. Only a pair the user supplied nothing toward is an env-file resolution — the freshly provisioned unclaimed environment path this provenance was added for. Mixed pairs keep the caller's source. Extracted the decision into resolveCredentialSource() so it is covered directly.
1 parent d8919df commit ad8f85f

12 files changed

Lines changed: 430 additions & 8 deletions

‎src/lib/adapters/cli-adapter.spec.ts‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,5 +523,132 @@ describe('CLIAdapter', () => {
523523
expect(calls).toContain('Using your active WorkOS environment');
524524
expect(calls.join('\n')).not.toContain('Using environment:');
525525
});
526+
527+
it('unclaimed copy also covers the cli-flags path, which is where provisioning lands', async () => {
528+
// runWithCore backfills options.apiKey/clientId from the .env.local that
529+
// provisioning just wrote, so a freshly provisioned environment
530+
// short-circuits at checkingCliFlags and never reaches staging
531+
// resolution. The copy has to exist on this path or it never fires.
532+
mockGetActiveEnvironment.mockReturnValue({
533+
name: 'unclaimed',
534+
type: 'unclaimed',
535+
apiKey: 'sk_test_x',
536+
clientId: 'client_x',
537+
claimToken: 'ct_x',
538+
authkitDomain: 'witty-rest-53.authkit.app',
539+
});
540+
await adapter.start();
541+
const ui = await import('../../utils/ui.js');
542+
543+
emitter.emit('credentials:found', {
544+
source: 'env',
545+
credentials: { clientId: 'client_x', apiKey: 'sk_test_x' },
546+
});
547+
548+
const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
549+
expect(calls).toContain('Using a new WorkOS environment created for this install (witty-rest-53.authkit.app)');
550+
expect(calls.join('\n')).not.toContain('Found existing WorkOS credentials');
551+
});
552+
553+
it('names .env.local for an env-file backfill into a claimed environment', async () => {
554+
mockGetActiveEnvironment.mockReturnValue({
555+
name: 'staging-3',
556+
type: 'sandbox',
557+
apiKey: 'sk_test_x',
558+
clientId: 'client_x',
559+
});
560+
await adapter.start();
561+
const ui = await import('../../utils/ui.js');
562+
563+
emitter.emit('credentials:found', {
564+
source: 'env',
565+
credentials: { clientId: 'client_x', apiKey: 'sk_test_x' },
566+
});
567+
568+
const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
569+
expect(calls).toContain('Found existing WorkOS credentials in .env.local');
570+
});
571+
572+
it('never claims .env.local for credentials passed as flags', async () => {
573+
mockGetActiveEnvironment.mockReturnValue(null);
574+
await adapter.start();
575+
const ui = await import('../../utils/ui.js');
576+
577+
emitter.emit('credentials:found', {
578+
source: 'cli',
579+
credentials: { clientId: 'client_flag', apiKey: 'sk_test_flag' },
580+
});
581+
582+
const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
583+
expect(calls.join('\n')).not.toContain('.env.local');
584+
});
585+
586+
it('says the environment is new and names it by AuthKit domain when unclaimed', async () => {
587+
// Provisioning returns no environmentId/environmentName, so the generic
588+
// "Using your active WorkOS environment" made a just-created environment
589+
// read as one the user already had — the friction that sent people to the
590+
// dashboard to compare client IDs.
591+
mockGetActiveEnvironment.mockReturnValue({
592+
name: 'unclaimed',
593+
type: 'unclaimed',
594+
apiKey: 'sk_test_x',
595+
clientId: 'client_x',
596+
claimToken: 'ct_x',
597+
authkitDomain: 'witty-rest-53.authkit.app',
598+
});
599+
await adapter.start();
600+
const ui = await import('../../utils/ui.js');
601+
602+
emitter.emit('staging:fetching', {});
603+
emitter.emit('staging:success', { source: 'stored', credentials: { clientId: 'client_x', apiKey: 'sk_test_x' } });
604+
605+
const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
606+
expect(calls).toContain('Using a new WorkOS environment created for this install (witty-rest-53.authkit.app)');
607+
expect(calls.join('\n')).not.toContain('Using your active WorkOS environment');
608+
});
609+
610+
it('drops the domain suffix for an unclaimed env provisioned before authkitDomain was stored', async () => {
611+
mockGetActiveEnvironment.mockReturnValue({
612+
name: 'unclaimed',
613+
type: 'unclaimed',
614+
apiKey: 'sk_test_x',
615+
clientId: 'client_x',
616+
claimToken: 'ct_x',
617+
});
618+
await adapter.start();
619+
const ui = await import('../../utils/ui.js');
620+
621+
emitter.emit('staging:fetching', {});
622+
emitter.emit('staging:success', { source: 'stored', credentials: { clientId: 'client_x', apiKey: 'sk_test_x' } });
623+
624+
const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
625+
expect(calls).toContain('Using a new WorkOS environment created for this install');
626+
});
627+
628+
it('keeps the generic copy when an unclaimed profile did not supply the credentials', async () => {
629+
// A leftover unclaimed profile can sit active while the install used the
630+
// project's own keys — calling that environment "created for this install"
631+
// would be a lie about which environment the app now targets.
632+
mockGetActiveEnvironment.mockReturnValue({
633+
name: 'unclaimed',
634+
type: 'unclaimed',
635+
apiKey: 'sk_test_x',
636+
clientId: 'client_x',
637+
claimToken: 'ct_x',
638+
authkitDomain: 'witty-rest-53.authkit.app',
639+
});
640+
await adapter.start();
641+
const ui = await import('../../utils/ui.js');
642+
643+
emitter.emit('staging:fetching', {});
644+
emitter.emit('staging:success', {
645+
source: 'stored',
646+
credentials: { clientId: 'client_x', apiKey: 'sk_test_project' },
647+
});
648+
649+
const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0]));
650+
expect(calls).toContain('Using your active WorkOS environment');
651+
expect(calls.join('\n')).not.toContain('created for this install');
652+
});
526653
});
527654
});

‎src/lib/adapters/cli-adapter.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { relative } from 'node:path';
44
import ui, { PromptUnavailableError } from '../../utils/ui.js';
55
import chalk from 'chalk';
66
import { getConfig } from '../settings.js';
7-
import { getActiveEnvironment, profileEnvironmentLabel } from '../config-store.js';
7+
import { getActiveEnvironment, isUnclaimedEnvironment, profileEnvironmentLabel } from '../config-store.js';
88
import { ProgressTracker } from '../progress-tracker.js';
99
import { renderCompletionSummary, renderBrandMark } from '../../utils/summary-box.js';
1010
import { classifyAgentFailure, describeAgentFailure } from '../failure-classifier.js';
@@ -291,7 +291,39 @@ export class CLIAdapter implements InstallerAdapter {
291291
this.queueableLog(() => ui.log.warn('Could not detect framework automatically'));
292292
};
293293

294-
private handleCredentialsFound = (): void => {
294+
/**
295+
* Copy for an install running against a newly provisioned unclaimed
296+
* environment, or undefined when it isn't.
297+
*
298+
* Provisioning returns no environmentId/environmentName, so
299+
* `profileEnvironmentLabel` has nothing to build from and the generic copy
300+
* reads as though the CLI picked up an environment the user already had —
301+
* the ambiguity that sends people to the dashboard to compare client IDs.
302+
*
303+
* Requires an exact credential match: a leftover unclaimed profile can sit
304+
* active while the install used the project's own keys, and calling that
305+
* environment "created for this install" would misname what the app targets.
306+
*/
307+
private newUnclaimedEnvironmentLine(credentials?: { clientId?: string; apiKey?: string }): string | undefined {
308+
const active = getActiveEnvironment();
309+
if (!active || !isUnclaimedEnvironment(active)) return undefined;
310+
if (!credentials?.clientId || !credentials.apiKey) return undefined;
311+
if (active.clientId !== credentials.clientId || active.apiKey !== credentials.apiKey) return undefined;
312+
const domain = active.authkitDomain ? ` (${active.authkitDomain})` : '';
313+
return `Using a new WorkOS environment created for this install${domain}`;
314+
}
315+
316+
private handleCredentialsFound = ({ source, credentials }: InstallerEvents['credentials:found']): void => {
317+
const newEnvironment = this.newUnclaimedEnvironmentLine(credentials);
318+
if (newEnvironment) {
319+
ui.log.success(newEnvironment);
320+
return;
321+
}
322+
// Only the env-file backfill can honestly name .env.local. For credentials
323+
// the user passed as flags, the integration installer announces them
324+
// itself — naming a file they may never have touched is the same
325+
// wrong-provenance claim in the other direction.
326+
if (source === 'cli') return;
295327
ui.log.success('Found existing WorkOS credentials in .env.local');
296328
};
297329

@@ -350,6 +382,18 @@ export class CLIAdapter implements InstallerAdapter {
350382
);
351383
const label = active && suppliedCredentials ? profileEnvironmentLabel(active) : undefined;
352384
const named = label ? `${label} (${active!.name})` : undefined;
385+
386+
// An unclaimed environment can never satisfy the naming above, so it gets
387+
// its own copy (see newUnclaimedEnvironmentLine). Reachable here when
388+
// .env.local was not pre-populated and checkStoredAuth routed an already
389+
// active unclaimed profile through staging resolution.
390+
const newEnvironment = this.newUnclaimedEnvironmentLine(credentials);
391+
if (newEnvironment) {
392+
this.stopSpinner('Environment ready');
393+
ui.log.success(newEnvironment);
394+
return;
395+
}
396+
353397
if (source === 'device') {
354398
this.stopSpinner('Environment ready');
355399
ui.log.success(named ? `Set up environment: ${named}` : 'Set up a WorkOS environment for this install');

‎src/lib/completion-data.spec.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,49 @@ describe('buildCompletionData', () => {
111111
expect(data.signInSnippet).toBeUndefined();
112112
expect(data.nextSteps.some((s) => /refreshAuth/.test(s))).toBe(false);
113113
});
114+
115+
describe('unclaimed-environment claim step', () => {
116+
it('leads the next steps with the claim command when one is supplied', async () => {
117+
writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } });
118+
119+
const data = await buildCompletionData(
120+
{ integration: 'nextjs', changedFiles: [], installDir },
121+
{ ...baseDeps, claimCommand: 'workos profile claim' },
122+
);
123+
124+
// First, not buried: the provision-time notice has already scrolled away
125+
// behind scaffolding and the agent run by the time this box renders.
126+
expect(data.nextSteps[0]).toBe('Run `workos profile claim` to link this environment to your WorkOS account');
127+
// The concrete steps still follow, in order.
128+
expect(data.nextSteps[1]).toContain('start your dev server');
129+
expect(data.nextSteps[2]).toContain('test authentication');
130+
});
131+
132+
it('omits the claim step for a claimed environment', async () => {
133+
writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } });
134+
135+
const data = await buildCompletionData({ integration: 'nextjs', changedFiles: [], installDir }, baseDeps);
136+
137+
expect(data.nextSteps.some((s) => /claim/i.test(s))).toBe(false);
138+
expect(data.nextSteps[0]).toContain('start your dev server');
139+
});
140+
141+
it('keeps the claim step ahead of framework steps too', async () => {
142+
writePackageJson({ scripts: { dev: 'next dev' }, dependencies: { next: '15.0.0' } });
143+
144+
const data = await buildCompletionData(
145+
{ integration: 'nextjs', changedFiles: [], installDir },
146+
{
147+
...baseDeps,
148+
claimCommand: 'workos profile claim',
149+
frameworkNextSteps: ['Visit the WorkOS Dashboard to manage users and settings'],
150+
},
151+
);
152+
153+
const claimIndex = data.nextSteps.findIndex((s) => /profile claim/.test(s));
154+
const dashboardIndex = data.nextSteps.findIndex((s) => /WorkOS Dashboard/.test(s));
155+
expect(claimIndex).toBe(0);
156+
expect(dashboardIndex).toBeGreaterThan(claimIndex);
157+
});
158+
});
114159
});

‎src/lib/completion-data.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export interface CompletionDataDeps {
2424
frameworkNextSteps?: string[];
2525
/** Per-framework "add a sign-in link" snippet */
2626
signInSnippet?: string;
27+
/**
28+
* Claim command for an unclaimed environment this install actually used
29+
* (e.g. `workos profile claim`), or undefined for a claimed environment.
30+
* Resolved by the caller, which owns the config lookup.
31+
*/
32+
claimCommand?: string;
2733
}
2834

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

58+
// An unclaimed environment's credentials live only on this machine, so a
59+
// missed claim loses the environment for good — it leads the next steps for
60+
// that reason, and because the provision-time notice is printed before
61+
// scaffolding and the agent run, minutes of output before the install ends.
62+
const claim = deps.claimCommand
63+
? [`Run \`${deps.claimCommand}\` to link this environment to your WorkOS account`]
64+
: [];
65+
5266
return {
5367
integration: ctx.integration,
5468
devCommand,
5569
url,
5670
files,
57-
nextSteps: [...concrete, ...framework],
71+
nextSteps: [...claim, ...concrete, ...framework],
5872
docsUrl: deps.docsUrl,
5973
dashboardUrl: deps.dashboardUrl,
6074
signInSnippet: deps.signInSnippet,

‎src/lib/config-store.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ interface BaseEnvironmentConfig {
3737
* disambiguate (e.g. "My Project > Staging").
3838
*/
3939
projectName?: string;
40+
/**
41+
* The environment's AuthKit domain (e.g. `witty-rest-53.authkit.app`).
42+
*
43+
* The only human-recognizable identifier an unclaimed environment has:
44+
* provisioning returns no `environmentId` or `environmentName`, so display
45+
* paths that rely on `profileEnvironmentLabel` have nothing to print for a
46+
* freshly provisioned environment. Cosmetic — never an identity key.
47+
*/
48+
authkitDomain?: string;
4049
}
4150

4251
export interface ClaimedEnvironmentConfig extends BaseEnvironmentConfig {

‎src/lib/events.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,14 @@ export interface InstallerEvents {
5757
'git:dirty:confirmed': Record<string, never>;
5858
'git:dirty:cancelled': Record<string, never>;
5959
'credentials:gathering': { requiresApiKey: boolean };
60-
'credentials:found': Record<string, never>;
60+
/**
61+
* Credentials were already on `options` when the machine started. `source`
62+
* separates the two ways that happens — flags the user typed ('cli') versus a
63+
* pair `runWithCore` backfilled from the project's env file ('env') — because
64+
* the two need different copy, and the payload lets a listener check whether
65+
* the active profile is the one that supplied them.
66+
*/
67+
'credentials:found': { source?: 'cli' | 'env'; credentials?: { clientId?: string; apiKey?: string } };
6168
// Credential discovery events
6269
'credentials:env:detected': { files: string[] };
6370
'credentials:env:prompt': { files: string[] };

‎src/lib/installer-core.spec.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,73 @@ describe('InstallerCore State Machine', () => {
414414
actor.stop();
415415
});
416416

417+
it('keeps an env-file provenance through the cli-flags short-circuit', async () => {
418+
// runWithCore backfills options.apiKey/clientId from the project's
419+
// .env.local — the path a freshly provisioned unclaimed environment takes.
420+
// Those credentials must not be relabeled 'cli', or the installer tells
421+
// the user it is using credentials they never provided.
422+
const emitter = createInstallerEventEmitter();
423+
const options: InstallerOptions = {
424+
debug: false,
425+
forceInstall: false,
426+
installDir: '/test/project',
427+
default: false,
428+
local: true,
429+
ci: false,
430+
skipAuth: true,
431+
dashboard: false,
432+
emitter,
433+
apiKey: 'sk_test_provisioned',
434+
clientId: 'client_provisioned',
435+
credentialSource: 'env',
436+
};
437+
438+
const found: Array<{ source?: string }> = [];
439+
emitter.on('credentials:found', (payload) => found.push(payload));
440+
441+
const actor = createActor(installerMachine.provide({ actors: baseMockActors }), {
442+
input: { emitter, options },
443+
});
444+
actor.start();
445+
actor.send({ type: 'START' });
446+
await new Promise((r) => setTimeout(r, 200));
447+
448+
expect(found).toHaveLength(1);
449+
expect(found[0].source).toBe('env');
450+
actor.stop();
451+
});
452+
453+
it('labels credentials that really came from flags as cli', async () => {
454+
const emitter = createInstallerEventEmitter();
455+
const options: InstallerOptions = {
456+
debug: false,
457+
forceInstall: false,
458+
installDir: '/test/project',
459+
default: false,
460+
local: true,
461+
ci: false,
462+
skipAuth: true,
463+
dashboard: false,
464+
emitter,
465+
apiKey: 'sk_test_flag',
466+
clientId: 'client_flag',
467+
};
468+
469+
const found: Array<{ source?: string }> = [];
470+
emitter.on('credentials:found', (payload) => found.push(payload));
471+
472+
const actor = createActor(installerMachine.provide({ actors: baseMockActors }), {
473+
input: { emitter, options },
474+
});
475+
actor.start();
476+
actor.send({ type: 'START' });
477+
await new Promise((r) => setTimeout(r, 200));
478+
479+
expect(found).toHaveLength(1);
480+
expect(found[0].source).toBe('cli');
481+
actor.stop();
482+
});
483+
417484
it('skips device auth when checkStoredAuth returns true (unclaimed env)', async () => {
418485
const emitter = createInstallerEventEmitter();
419486
const options: InstallerOptions = {

0 commit comments

Comments
 (0)