Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/commands/install.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ describe('handleInstall', () => {
});
});

it.each([
['auth_required', 4],
['cancelled', 2],
] as const)('preserves %s exit conventions in human and JSON modes', async (code, exitCode) => {
const { InstallDeclinedError } = await import('../lib/installer-errors.js');
vi.mocked(runInstaller).mockRejectedValue(
new InstallDeclinedError('Callback unverified; recover access or configure manually.', code),
);
for (const json of [false, true]) {
vi.mocked(isJsonMode).mockReturnValue(json);
await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toMatchObject({ exitCode });
if (json)
expect(exitWithError).toHaveBeenCalledWith({ code, message: expect.stringContaining('Callback unverified') });
}
expect(maybeRunSetupAfter).not.toHaveBeenCalled();
});

it('exits non-zero without extra output in human mode (guidance already printed)', async () => {
const { InstallDeclinedError } = await import('../lib/installer-errors.js');
vi.mocked(runInstaller).mockRejectedValue(new InstallDeclinedError('Next.js 14 is unsupported'));
Expand Down
4 changes: 2 additions & 2 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { runInstaller } from '../run.js';
import type { InstallerArgs } from '../run.js';
import ui from '../utils/ui.js';
import { exitWithError, isJsonMode } from '../utils/output.js';
import { ExitCode, exitWithCode } from '../utils/exit-codes.js';
import { ExitCode, exitWithCode, resolveErrorCode } from '../utils/exit-codes.js';
import { isCiMode } from '../utils/interaction-mode.js';
import type { ArgumentsCamelCase } from 'yargs';
import { InstallDeclinedError } from '../lib/installer-errors.js';
Expand Down Expand Up @@ -48,7 +48,7 @@ export async function handleInstall(argv: ArgumentsCamelCase<InstallerArgs>): Pr
if (isJsonMode()) {
exitWithError({ code: err.code, message: err.message });
}
exitWithCode(ExitCode.GENERAL_ERROR);
exitWithCode(resolveErrorCode(err.code).exit);
}

const { getLogFilePath } = await import('../utils/debug.js');
Expand Down
26 changes: 25 additions & 1 deletion src/commands/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await i
const { provisionStagingEnvironment, runLogin } = await import('./login.js');
const { maybeRunSetupAfter } = await import('./setup.js');
const { isJsonMode, outputJson } = await import('../utils/output.js');
const { clearCredentials, setInsecureStorage } = await import('../lib/credentials.js');
const { clearCredentials, setInsecureStorage, saveCredentials, getCredentials } = await import('../lib/credentials.js');
const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js');
const uiMod = await import('../utils/ui.js');

Expand Down Expand Up @@ -151,6 +151,30 @@ describe('login', () => {
} catch {}
});

it('reuses a locally unexpired session even when a dashboard request previously rejected it', async () => {
const rejectedSession = {
accessToken: 'fake_revoked_but_unexpired_token',
refreshToken: 'fake_refresh_token',
expiresAt: Date.now() + 3_600_000,
userId: 'fake_user',
};
saveCredentials(rejectedSession);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await runLogin();
expect(log).toHaveBeenCalledWith(expect.stringContaining('Already logged in'));
expect(getCredentials()).toMatchObject(rejectedSession);
expect(mockOpen).not.toHaveBeenCalled();
expect(mockRequestDeviceCode).not.toHaveBeenCalled();
expect(mockPollForToken).not.toHaveBeenCalled();
expect(mockFetchStagingCredentials).not.toHaveBeenCalled();
expect(mockTryResolveProfileEnvironmentId).not.toHaveBeenCalled();
expect(maybeRunSetupAfter).not.toHaveBeenCalled();
} finally {
log.mockRestore();
}
});

describe('provisionStagingEnvironment', () => {
it('creates a staging environment on success', async () => {
mockFetchStagingCredentials.mockResolvedValueOnce({
Expand Down
10 changes: 8 additions & 2 deletions src/integrations/dotnet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,22 @@ export async function run(options: InstallerOptions): Promise<string> {
integration: config.metadata.integration,
});

const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);
let { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);

// Auto-configure WorkOS environment (redirect URI, CORS, homepage)
const callerHandledConfig = Boolean(options.apiKey || options.clientId);
if (!callerHandledConfig && apiKey) {
const port = 5000; // ASP.NET Core default HTTP port
await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, {
const result = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, {
clientId,
interactive: !options.ci,
homepageUrl: options.homepageUrl,
redirectUri: options.redirectUri,
});
if (result?.recoveredCredentials) {
({ apiKey, clientId } = result.recoveredCredentials);
Object.assign(options, result.recoveredCredentials);
}
}

// Build prompt — credentials are passed via prompt context since .NET doesn't use .env.local
Expand Down
10 changes: 8 additions & 2 deletions src/integrations/go/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,22 @@ export async function run(options: InstallerOptions): Promise<string> {
});

// Get WorkOS credentials
const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);
let { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);

// Auto-configure WorkOS environment (redirect URI, CORS)
const callerHandledConfig = Boolean(options.apiKey || options.clientId);
if (!callerHandledConfig && apiKey) {
const redirectUri = options.redirectUri || `http://localhost:${GO_DEFAULT_PORT}${GO_CALLBACK_PATH}`;
await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, GO_DEFAULT_PORT, {
const result = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, GO_DEFAULT_PORT, {
clientId,
interactive: !options.ci,
homepageUrl: options.homepageUrl,
redirectUri,
});
if (result?.recoveredCredentials) {
({ apiKey, clientId } = result.recoveredCredentials);
Object.assign(options, result.recoveredCredentials);
}
}

// Gather Go-specific context
Expand Down
15 changes: 9 additions & 6 deletions src/integrations/ruby/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,22 @@ export async function run(options: InstallerOptions): Promise<string> {
});

// Get WorkOS credentials
const { apiKey, clientId: _clientId } = await getOrAskForWorkOSCredentials(
options,
config.environment.requiresApiKey,
);
let { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);

// Auto-configure WorkOS environment (redirect URI, CORS, homepage) if not already done
const callerHandledConfig = Boolean(options.apiKey || options.clientId);
if (!callerHandledConfig && apiKey) {
const port = 3000; // Rails default
await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, {
const result = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, {
clientId,
interactive: !options.ci,
homepageUrl: options.homepageUrl,
redirectUri: options.redirectUri,
});
if (result?.recoveredCredentials) {
({ apiKey, clientId } = result.recoveredCredentials);
Object.assign(options, result.recoveredCredentials);
}
}

// Build prompt for the agent
Expand All @@ -108,7 +111,7 @@ export async function run(options: InstallerOptions): Promise<string> {

The following environment variables are needed (create a .env file if one does not exist):
- WORKOS_API_KEY
- WORKOS_CLIENT_ID
- WORKOS_CLIENT_ID=${clientId}
- WORKOS_REDIRECT_URI=${redirectUri}

## Integration Instructions
Expand Down
27 changes: 27 additions & 0 deletions src/lib/agent-runner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,33 @@ describe('installer prompt', () => {
expect(initializeAgent).not.toHaveBeenCalled();
});

it('forwards an accepted recovered pair to files, agent initialization and downstream options', async () => {
const pair = { apiKey: 'sk_test_fake_recovered', clientId: 'client_test' };
vi.mocked(autoConfigureWorkOSEnvironment).mockResolvedValueOnce({
redirectUri: { success: true, alreadyExists: false },
corsOrigin: { success: true, alreadyExists: false },
recoveredCredentials: pair,
});
const direct = { ...options, clientId: undefined };
await runAgentInstaller({ ...config, metadata: { ...config.metadata, integration: 'sveltekit' } }, direct);
expect(autoConfigureWorkOSEnvironment).toHaveBeenCalledWith(
'test-key',
'sveltekit',
expect.any(Number),
expect.objectContaining({ clientId: pair.clientId }),
);
expect(writeEnvLocal).toHaveBeenCalledWith(
direct.installDir,
expect.objectContaining({ WORKOS_API_KEY: pair.apiKey, WORKOS_CLIENT_ID: pair.clientId }),
);
expect(initializeAgent).toHaveBeenCalledWith(
expect.objectContaining({ workOSApiKey: pair.apiKey }),
expect.objectContaining(pair),
);
expect(direct).toMatchObject(pair);
expect(vi.mocked(runAgent).mock.calls[0][1]).not.toContain(pair.apiKey);
});

it('does not register a callback in the API-key environment when run directly', async () => {
await runAgentInstaller(config, { ...options, clientId: undefined });
expect(autoConfigureWorkOSEnvironment).not.toHaveBeenCalled();
Expand Down
10 changes: 8 additions & 2 deletions src/lib/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal
}

// Get WorkOS credentials (API key optional for client-only SDKs)
const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);
let { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);

// Check if caller (state machine) already configured WorkOS environment
// If credentials were passed via options, the caller handled config+env writing
Expand All @@ -77,10 +77,16 @@ export async function runAgentInstaller(config: FrameworkConfig, options: Instal
// dashboard targeting or the API-only callback path, never both.
if (!callerHandledConfig && apiKey && config.environment.requiresApiKey && config.metadata.integration !== 'nextjs') {
const port = detectPort(config.metadata.integration, options.installDir);
await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, {
const result = await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, {
clientId,
interactive: !options.ci,
homepageUrl: options.homepageUrl,
redirectUri: options.redirectUri,
});
if (result?.recoveredCredentials) {
({ apiKey, clientId } = result.recoveredCredentials);
Object.assign(options, result.recoveredCredentials);
}
}

// Write environment variables to .env.local BEFORE agent runs
Expand Down
Loading
Loading