Skip to content
Closed
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
11 changes: 11 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,17 @@ async function runCli(): Promise<void> {
await runClaim();
},
);
registerSubcommand(
yargs,
'provision',
'Provision a new unclaimed WorkOS environment (credentials only, no code changes)',
(y) => y,
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runEnvProvision } = await import('./commands/env.js');
await runEnvProvision();
},
);
return yargs.demandCommand(1, 'Please specify an env subcommand').strict();
})
.command(
Expand Down
119 changes: 118 additions & 1 deletion src/commands/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ vi.mock('../utils/clack.js', () => ({
},
}));

// Partial-mock the unclaimed-env API so we control provisioning outcomes but
// keep the real UnclaimedEnvApiError class for instanceof checks.
vi.mock('../lib/unclaimed-env-api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/unclaimed-env-api.js')>();
return { ...actual, provisionUnclaimedEnvironment: vi.fn() };
});

// Guard: runEnvProvision must NEVER write project .env files.
vi.mock('../lib/env-writer.js', () => ({ writeCredentialsEnv: vi.fn() }));

let testDir: string;

vi.mock('node:os', async (importOriginal) => {
Expand All @@ -40,7 +50,9 @@ vi.mock('node:os', async (importOriginal) => {
});

const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js');
const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList } = await import('./env.js');
const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList, runEnvProvision } = await import('./env.js');
const { provisionUnclaimedEnvironment, UnclaimedEnvApiError } = await import('../lib/unclaimed-env-api.js');
const { writeCredentialsEnv } = await import('../lib/env-writer.js');
const { setOutputMode } = await import('../utils/output.js');
const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js');
const { CliExit } = await import('../utils/cli-exit.js');
Expand Down Expand Up @@ -254,6 +266,111 @@ describe('env commands', () => {
});
});

describe('runEnvProvision', () => {
const CREDS = {
clientId: 'client_x',
apiKey: 'sk_test_x',
claimToken: 'ct_x',
authkitDomain: 'foo.authkit.app',
};

let consoleOutput: string[];
let logSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.mocked(provisionUnclaimedEnvironment).mockReset();
vi.mocked(writeCredentialsEnv).mockReset();
consoleOutput = [];
logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
consoleOutput.push(args.map(String).join(' '));
});
});

afterEach(() => {
logSpy.mockRestore();
setOutputMode('human');
});

it('emits the provisioned credentials as JSON (agent credential delivery)', async () => {
setOutputMode('json');
vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS);

await runEnvProvision();

const out = JSON.parse(consoleOutput[0]);
expect(out.status).toBe('ok');
expect(out.data.apiKey).toBe('sk_test_x');
expect(out.data.clientId).toBe('client_x');
expect(out.data.claimToken).toBe('ct_x');
expect(out.data.authkitDomain).toBe('foo.authkit.app');
});

it('persists the provisioned env locally as the active unclaimed env', async () => {
setOutputMode('json');
vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS);

await runEnvProvision();

const config = getConfig();
const env = config?.environments.unclaimed;
expect(env?.type).toBe('unclaimed');
expect(env?.clientId).toBe('client_x');
expect((env as { claimToken?: string } | undefined)?.claimToken).toBe('ct_x');
expect(config?.activeEnvironment).toBe('unclaimed');
});

it('never writes a project .env file', async () => {
setOutputMode('json');
vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS);

await runEnvProvision();

expect(writeCredentialsEnv).not.toHaveBeenCalled();
});

it('surfaces a 429 as a structured rate_limited error — no config write, no login fallback', async () => {
setOutputMode('json');
setInteractionMode({ mode: 'agent', source: 'env' });
vi.mocked(provisionUnclaimedEnvironment).mockRejectedValue(
new UnclaimedEnvApiError('Rate limited. Please wait a moment and try again.', 429),
);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await expect(runEnvProvision()).rejects.toThrow(CliExit);
const parsed = JSON.parse(String(errorSpy.mock.calls[0][0]));
expect(parsed.error.code).toBe('rate_limited');
expect(getConfig()).toBeNull();
expect(writeCredentialsEnv).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});

it('maps an unexpected non-API error to provision_failed', async () => {
setOutputMode('json');
setInteractionMode({ mode: 'agent', source: 'env' });
vi.mocked(provisionUnclaimedEnvironment).mockRejectedValue(new Error('boom'));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await expect(runEnvProvision()).rejects.toThrow(CliExit);
const parsed = JSON.parse(String(errorSpy.mock.calls[0][0]));
expect(parsed.error.code).toBe('provision_failed');
} finally {
errorSpy.mockRestore();
}
});

it('prints the credentials and the env-claim hint in human mode', async () => {
setOutputMode('human');
vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS);

await runEnvProvision();

expect(consoleOutput.join('\n')).toContain('sk_test_x');
expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('env claim'));
});
});

describe('JSON output mode', () => {
let consoleOutput: string[];

Expand Down
66 changes: 66 additions & 0 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mod
import { missingArgsRecovery } from '../utils/recovery-hints.js';
import { formatWorkOSCommand } from '../utils/command-invocation.js';
import { ExitCode, exitWithCode } from '../utils/exit-codes.js';
import {
provisionUnclaimedEnvironment,
UnclaimedEnvApiError,
type UnclaimedEnvProvisionResult,
} from '../lib/unclaimed-env-api.js';

const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/;

Expand Down Expand Up @@ -119,6 +124,67 @@ export async function runEnvAdd(options: {
outputSuccess('Environment added', { name: name!, type, active: isFirst });
}

/**
* `workos env provision` — provision a fresh unclaimed environment (credentials only).
*
* Calls the low-level `provisionUnclaimedEnvironment()` directly (no auth, no
* code-gen). Credentials are delivered on stdout (JSON is the agent credential
* channel) and persisted locally as an unclaimed env so a follow-up
* `env claim` works. NEVER writes to the project directory or any `.env` file,
* and NEVER falls back to an interactive login on failure.
*/
export async function runEnvProvision(): Promise<void> {
let result: UnclaimedEnvProvisionResult;
try {
result = await provisionUnclaimedEnvironment();
} catch (error) {
if (error instanceof UnclaimedEnvApiError) {
exitWithError({
code: error.statusCode === 429 ? 'rate_limited' : 'provision_failed',
message: error.message,
...(error.statusCode && { apiContext: { status: error.statusCode } }),
});
}
const message = error instanceof Error ? error.message : 'Unknown error';
exitWithError({ code: 'provision_failed', message: `Failed to provision environment: ${message}` });
}

// Persist as an unclaimed env (parity with install) — NEVER writes to the project dir.
const config = getOrCreateConfig();
config.environments['unclaimed'] = {
name: 'unclaimed',
type: 'unclaimed',
apiKey: result.apiKey,
clientId: result.clientId,
claimToken: result.claimToken,
};
config.activeEnvironment = 'unclaimed';
saveConfig(config);
Comment on lines +152 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 env provision unconditionally overwrites existing unclaimed environment and repoints active env

The runEnvProvision function at src/commands/env.ts:154-162 always writes to config.environments['unclaimed'] and always sets config.activeEnvironment = 'unclaimed', regardless of what was previously configured. If a user runs env provision twice without claiming the first environment, the first environment's claim token is permanently lost. This is consistent with the existing tryProvisionUnclaimedEnv in src/lib/unclaimed-env-provision.ts:58-66, so it's not a regression, but it contrasts with the careful mismatch/clobber-prevention logic added to provisionStagingEnvironment in this same PR. The unconditional active-env repoint also silently switches away from any prior active environment without warning.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if (isJsonMode()) {
outputSuccess('Environment provisioned', {
name: 'unclaimed',
type: 'unclaimed',
active: true,
apiKey: result.apiKey,
clientId: result.clientId,
claimToken: result.claimToken,
authkitDomain: result.authkitDomain,
});
return;
}

clack.log.success('Provisioned a new WorkOS environment');
console.log('');
console.log(` ${chalk.dim('API key')} ${result.apiKey}`);
console.log(` ${chalk.dim('Client ID')} ${result.clientId}`);
console.log(` ${chalk.dim('AuthKit')} ${result.authkitDomain}`);
console.log('');
clack.log.info(
`Set as active environment. Run \`${formatWorkOSCommand('env claim')}\` to link it to your account (permanent).`,
);
}

export async function runEnvRemove(name: string): Promise<void> {
const config = getConfig();
if (!config || Object.keys(config.environments).length === 0) {
Expand Down
Loading
Loading