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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or
- **Output**: Auto-switches to JSON when piped or `--json` flag. `WORKOS_FORCE_TTY=1` overrides.
- **Auth**: Exits code 4 instead of opening browser. Resource commands (organization, user, role, permission, membership, invitation, session, event, feature-flag, org-domain, portal, webhook, config) use the dashboard session from a prior `workos auth login`; expired access tokens refresh silently while the stored refresh token is valid, so only a truly dead session exits 4. `WORKOS_API_KEY` applies only to `workos api` and the still-REST commands (`connection`, `directory`, `audit-log`, `api-key`, `vault`, plus the workflow/debug commands `seed`, `setup-org`, `onboard-user`, `verify-login`, `debug-sso`, `debug-sync`, `migrations`).
- **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }`
- **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention)
- **Exit codes**: 0=success, 1=error, 2=canceled, 4=auth required (follows `gh` CLI convention)
- **Headless flags**: `--no-branch`, `--no-git-check`. CI mode (`WORKOS_MODE=ci`) auto-continues past a dirty tree without `--no-git-check`; agent mode requires the flag.
- **Installer Git policy**: Generated changes stay unstaged/uncommitted; pre-existing staging is preserved. No installer-controlled staging, commits, pushes, PR creation, or commit/PR text generation. `--commit`, `--no-commit`, and `--create-pr` are deprecated compatibility-only no-ops, with human-only notices. Branch prompts/`--no-branch` remain unchanged; branches do not isolate uncommitted work. Change reporting is scoped to `installDir` and may include pre-existing changes.

Expand Down Expand Up @@ -46,6 +46,10 @@ shipped once already.
unexpected field divergence. Its `ACCEPTED` map lists deliberate curations only;
a casing-only difference appearing there is a bug, not an accepted divergence.

## Spelling

Use American English in code, comments, docs and UI copy (`canceled`, `behavior`, `organization`). The `cancelled` values that already ship are public contracts: the JSON error code, `termination.reason`, `installer.outcome`, `setup.outcome` and the `workos debug reset --json` key. Leave them as they are unless you also plan the migration.

## Tech Constraints

- **Bun** only; the shipped CLI is a Bun-compiled standalone binary
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ workos install --api-key sk_test_xxx --client-id client_xxx 2>/dev/null
| ---- | ----------------------- |
| 0 | Success |
| 1 | General error |
| 2 | Cancelled |
| 2 | Canceled |
| 4 | Authentication required |

### Environment Variables
Expand Down
2 changes: 1 addition & 1 deletion scripts/command-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# JSON errors on stderr, and JSON output on stdout. Everything here is
# offline-safe — CI runs it inside a --network none container, and release
# smoke runs it on native hardware for every platform binary. Exit code 2
# (cancelled) needs an interactive session and is covered by unit tests.
# (canceled) needs an interactive session and is covered by unit tests.
#
# POSIX sh only: this runs in debian-slim and Alpine containers (no bash)
# and under Git Bash on the Windows runners.
Expand Down
4 changes: 2 additions & 2 deletions scripts/prompt-ownership-pty.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const config = {
emitter,
sendEvent(event: { type: string }) {
events.push(event.type);
if (event.type === 'GIT_CANCELLED') {
if (event.type === 'GIT_CANCELED') {
emitter.emit('complete', { success: false, summary: 'Synthetic cancellation' });
finished();
}
Expand Down Expand Up @@ -96,7 +96,7 @@ try {
assert.equal(process.stdin.listenerCount('readable'), inputBefore);
assert.equal(emitter.listenerCount('agent:start'), 0);
if (scenario === 'answers') assert.deepEqual(events, ['ENV_SCAN_DECLINED', 'SCAFFOLD_CONFIRMED']);
if (scenario === 'cancel') assert(events.includes('GIT_CANCELLED'));
if (scenario === 'cancel') assert(events.includes('GIT_CANCELED'));
// Leave the process alive long enough to detect orphaned redraw intervals.
await delay(400);
await report('clean');
Expand Down
2 changes: 1 addition & 1 deletion scripts/prompt-ownership-pty.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

The UI facade has one spinner owner across terminal and hosted rendering. Replacement, stop, clear, and host teardown retire handles permanently. Only an actually drawn spinner line can be erased. Queued prompt callers reserve the terminal before awaiting input; existing and newly started spinners remain suspended until all questions settle. Handback resumes the current owner, never the entry handle. Terminal completion/error lines wait behind input; hosted warnings/errors remain immediately visible.

Prompt requests retain their host and are cancelled on host teardown rather than opening a late inquirer question. A one-turn queued handoff lets the installer process cancellation before the next question opens. The adapter counts queued prompt callers, supplies its cancellation signal to every prompt, and drains its handlers before normal hosted teardown. Exit/signal teardown also detaches CLI handlers.
Prompt requests retain their host and are canceled on host teardown rather than opening a late inquirer question. A one-turn queued handoff lets the installer process cancellation before the next question opens. The adapter counts queued prompt callers, supplies its cancellation signal to every prompt, and drains its handlers before normal hosted teardown. Exit/signal teardown also detaches CLI handlers.

Agent success ends the spinner without requiring validation (Rails/`--no-validate`); failure, completion, cancellation and stop retire animation. Logging no longer stops/recreates adapter spinners: the facade borrows the current owner's line. This prevents a stale adapter handle from reclaiming an obsolete phase. Hosted logs are delivered in their original phase to preserve transcript filtering.

Expand Down
2 changes: 1 addition & 1 deletion src/bin-readonly-installer.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ describe('installer leaves changes uncommitted through the real parser and orche
it('cancels before the agent without asking any post-install questions', () => {
git(project, 'checkout', '-qb', 'existing-feature');
const result = run(['install', '--no-branch', '--create-pr'], { TEST_HUMAN: '1', TEST_CANCEL: '1' });
expect(result.stdout).toContain('cancelled');
expect(result.stdout).toContain('canceled');
expect(result.evidence.some((e) => e.kind === 'agent-options')).toBe(false);
expect(result.evidence.filter((e) => e.kind === 'prompt').map((e) => e.value)).toEqual(['Continue anyway?']);
});
Expand Down
4 changes: 2 additions & 2 deletions src/catalog/confirm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,15 @@ describe('confirmDestructive', () => {
expect(mockConfirm).not.toHaveBeenCalled();
});

it('exits 2 (CANCELLED) when the interactive prompt is declined', async () => {
it('exits 2 (CANCELED) when the interactive prompt is declined', async () => {
setInteractionMode({ mode: 'human', source: 'default' });
setOutputMode('human');
mockConfirm.mockResolvedValue(false);
await expectExit(confirmDestructive({}, { action: 'delete user usr_1' }), 2);
expect(mockConfirm).toHaveBeenCalledOnce();
});

it('exits 2 (CANCELLED) when the interactive prompt is cancelled (Ctrl+C)', async () => {
it('exits 2 (CANCELED) when the interactive prompt is canceled (Ctrl+C)', async () => {
setInteractionMode({ mode: 'human', source: 'default' });
setOutputMode('human');
const cancelSymbol = Symbol('ui-cancel');
Expand Down
6 changes: 3 additions & 3 deletions src/catalog/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { isCiMode, isPromptAllowed } from '../utils/interaction-mode.js';
* `src/utils/exit-codes.ts`):
* - Non-interactive + destructive + no `--yes` => exit 1, `confirmation_required`
* - Non-interactive + `--yes` => proceed (resolves)
* - Interactive, user cancels => exit 2 (CANCELLED)
* - Interactive, user cancels => exit 2 (CANCELED)
* - Interactive, user confirms => proceed (resolves)
*
* Note: exit 2 is reserved for active prompt cancellation only. A
Expand Down Expand Up @@ -60,8 +60,8 @@ export async function confirmDestructive(
console.log(`\n${chalk.yellow('About to')} ${opts.action}`);
const ok = await ui.confirm({ message: 'Proceed?' });
if (!ok || ui.isCancel(ok)) {
// Active cancellation of an interactive prompt => CANCELLED (exit 2).
exitWithCode(ExitCode.CANCELLED);
// Active cancellation of an interactive prompt => CANCELED (exit 2).
exitWithCode(ExitCode.CANCELED);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export async function runApiRequest(endpoint: string, options: ApiCommandOptions
if (hasBody) prettyPrint(body);
const ok = await ui.confirm({ message: 'Proceed?' });
if (!ok || ui.isCancel(ok)) {
exitWithCode(ExitCode.CANCELLED);
exitWithCode(ExitCode.CANCELED);
}
}

Expand Down
24 changes: 12 additions & 12 deletions src/commands/api/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ import { resolveApiKey, resolveApiBaseUrl } from '../../lib/api-key.js';
import { ExitCode, exitWithCode } from '../../utils/exit-codes.js';
import { exitWithError } from '../../utils/output.js';

function assertNotCancelled<T>(value: T | symbol): T {
if (ui.isCancel(value)) exitWithCode(ExitCode.CANCELLED);
function assertNotCanceled<T>(value: T | symbol): T {
if (ui.isCancel(value)) exitWithCode(ExitCode.CANCELED);
return value as T;
}

export async function apiInteractive(options?: { apiKey?: string }): Promise<void> {
const catalog = await loadCatalog();
const grouped = endpointsByTag(catalog.endpoints);

const tag = assertNotCancelled(
const tag = assertNotCanceled(
await ui.select({
message: 'Select a category:',
options: catalog.tags.map((t) => {
Expand All @@ -26,7 +26,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
);

const endpoints = grouped.get(tag)!;
const ep = assertNotCancelled(
const ep = assertNotCanceled(
await ui.select<EndpointInfo>({
message: 'Select an endpoint:',
options: endpoints.map((e) => ({
Expand All @@ -39,7 +39,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi

let resolvedPath = ep.path;
for (const param of ep.pathParams) {
const value = assertNotCancelled(
const value = assertNotCanceled(
await ui.text({
message: `${param.name}:`,
placeholder: param.description || undefined,
Expand All @@ -58,7 +58,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
const params: string[] = [];

for (const qp of requiredParams) {
const value = assertNotCancelled(
const value = assertNotCanceled(
await ui.text({
message: `${qp.name} (required):`,
placeholder: qp.description || undefined,
Expand All @@ -71,7 +71,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
}

if (optionalParams.length > 0) {
const wantsOptional = assertNotCancelled(
const wantsOptional = assertNotCanceled(
await ui.confirm({
message: `Add optional query parameters? (${optionalParams.length} available)`,
initialValue: false,
Expand All @@ -80,7 +80,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi

if (wantsOptional) {
for (const qp of optionalParams) {
const value = assertNotCancelled(
const value = assertNotCanceled(
await ui.text({
message: `${qp.name}:`,
placeholder: qp.description || undefined,
Expand All @@ -103,7 +103,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
if (ep.hasRequestBody) {
let collectBody = ep.requestBodyRequired;
if (!collectBody) {
collectBody = assertNotCancelled(
collectBody = assertNotCanceled(
await ui.confirm({
message: 'Provide a request body?',
initialValue: ep.method === 'POST' || ep.method === 'PUT',
Expand All @@ -112,7 +112,7 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
}

if (collectBody) {
body = assertNotCancelled(
body = assertNotCanceled(
await ui.text({
message: 'Request body (JSON):',
placeholder: '{"key": "value"}',
Expand All @@ -137,8 +137,8 @@ export async function apiInteractive(options?: { apiKey?: string }): Promise<voi
}
console.log();

const ok = assertNotCancelled(await ui.confirm({ message: 'Execute this request?' }));
if (!ok) exitWithCode(ExitCode.CANCELLED);
const ok = assertNotCanceled(await ui.confirm({ message: 'Execute this request?' }));
if (!ok) exitWithCode(ExitCode.CANCELED);

const response = await apiRequest({
method: ep.method,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/connection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ describe('connection commands', () => {
mockConfirm.mockResolvedValue(false);
await runConnectionDelete('conn_01ABC', {}, 'sk_test');
expect(mockSdk.sso.deleteConnection).not.toHaveBeenCalled();
expect(consoleOutput.some((l) => l.includes('cancelled'))).toBe(true);
expect(consoleOutput.some((l) => l.includes('canceled'))).toBe(true);
});

it('cancels on user cancel', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export async function runConnectionDelete(
});

if (ui.isCancel(confirmed) || !confirmed) {
console.log('Delete cancelled.');
console.log('Delete canceled.');
return;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export async function runDebugReset({
if (isJsonMode()) {
outputJson({ cleared: false, cancelled: true });
} else {
ui.log.info('Reset cancelled');
ui.log.info('Reset canceled');
}
return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/directory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ describe('directory commands', () => {
mockConfirm.mockResolvedValue(false);
await runDirectoryDelete('directory_01ABC', {}, 'sk_test');
expect(mockSdk.directorySync.deleteDirectory).not.toHaveBeenCalled();
expect(consoleOutput.some((l) => l.includes('cancelled'))).toBe(true);
expect(consoleOutput.some((l) => l.includes('canceled'))).toBe(true);
});

it('cancels on user cancel', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function runDirectoryDelete(
});

if (ui.isCancel(confirmed) || !confirmed) {
console.log('Delete cancelled.');
console.log('Delete canceled.');
return;
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function runEnvAdd(options: {
message: 'Enter a name for the environment (e.g., production, sandbox, local)',
validate: (value) => validateEnvName(value),
});
if (ui.isCancel(nameResult)) exitWithCode(ExitCode.CANCELLED);
if (ui.isCancel(nameResult)) exitWithCode(ExitCode.CANCELED);
name = nameResult;

const typeResult = await ui.select({
Expand All @@ -75,7 +75,7 @@ export async function runEnvAdd(options: {
{ value: 'sandbox', label: 'Sandbox' },
],
});
if (ui.isCancel(typeResult)) exitWithCode(ExitCode.CANCELLED);
if (ui.isCancel(typeResult)) exitWithCode(ExitCode.CANCELED);

const apiKeyResult = await ui.password({
message: 'Enter the API key for this environment',
Expand All @@ -84,7 +84,7 @@ export async function runEnvAdd(options: {
return undefined;
},
});
if (ui.isCancel(apiKeyResult)) exitWithCode(ExitCode.CANCELLED);
if (ui.isCancel(apiKeyResult)) exitWithCode(ExitCode.CANCELED);
apiKey = apiKeyResult;

const config = getOrCreateConfig();
Expand Down Expand Up @@ -280,7 +280,7 @@ export async function runEnvSwitch(name?: string): Promise<void> {
message: 'Select an environment',
options,
});
if (ui.isCancel(selected)) exitWithCode(ExitCode.CANCELLED);
if (ui.isCancel(selected)) exitWithCode(ExitCode.CANCELED);
name = selected as string;
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export async function runEnvironmentUse(environmentIdArg?: string): Promise<void
});
}
const choice = await promptForEnvironment(environments);
if (choice === null) exitWithCode(ExitCode.CANCELLED);
if (choice === null) exitWithCode(ExitCode.CANCELED);
targetId = choice;
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe('runSetup — automatic triggers (login/install)', () => {
expect(hints.some((m) => m.includes('workos mcp install'))).toBe(true);
});

it('treats cancel (ctrl-c) as skip — no decline recorded, but emits a cancelled event', async () => {
it('treats cancel (ctrl-c) as skip — no decline recorded, but emits a canceled event', async () => {
detectSome();
vi.mocked(ui.confirm).mockResolvedValue(CANCEL);

Expand Down
2 changes: 1 addition & 1 deletion src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ function emitSetupEvent(
extraAttributes: {
'setup.trigger': trigger,
// `accepted` kept for back-compat dashboards; `outcome` distinguishes a
// deliberate "no" (declined) from a walk-away/ctrl-c (cancelled).
// deliberate "no" (declined) from a walk-away/ctrl-c (canceled).
'setup.accepted': outcome === 'accepted',
'setup.outcome': outcome,
'setup.skills_agents': agents.skills.join(','),
Expand Down
4 changes: 2 additions & 2 deletions src/integrations/react-router/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ vi.mock('../../utils/ui.js', () => ({
},
}));

// Passthrough abortIfCancelled + a package.json with no react-router version, so
// Passthrough abortIfCanceled + a package.json with no react-router version, so
// getReactRouterMode always hits the ambiguous "no version" prompt branch.
vi.mock('../../utils/ui-utils.js', () => ({
abortIfCancelled: vi.fn(async (p) => await p),
abortIfCanceled: vi.fn(async (p) => await p),
getPackageDotJson: vi.fn(async () => ({})),
}));

Expand Down
8 changes: 4 additions & 4 deletions src/integrations/react-router/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { major } from 'semver';
import fg from 'fast-glob';
import { abortIfCancelled, getPackageDotJson } from '../../utils/ui-utils.js';
import { abortIfCanceled, getPackageDotJson } from '../../utils/ui-utils.js';
import ui from '../../utils/ui.js';
import { getVersionBucket } from '../../utils/semver.js';
import type { InstallerOptions } from '../../utils/types.js';
Expand Down Expand Up @@ -100,7 +100,7 @@ export async function getReactRouterMode(options: InstallerOptions): Promise<Rea
if (!reactRouterVersion) {
if (!isPromptAllowed()) return defaultModeNonInteractive();
ui.log.info(`Learn more about React Router modes: ${chalk.cyan('https://reactrouter.com/start/modes')}`);
const result: ReactRouterMode = await abortIfCancelled(
const result: ReactRouterMode = await abortIfCanceled(
ui.select({
message: 'What React Router version and mode are you using?',
options: [
Expand Down Expand Up @@ -144,7 +144,7 @@ export async function getReactRouterMode(options: InstallerOptions): Promise<Rea

if (!isPromptAllowed()) return defaultModeNonInteractive();
ui.log.info(`Learn more about React Router modes: ${chalk.cyan('https://reactrouter.com/start/modes')}`);
const result: ReactRouterMode = await abortIfCancelled(
const result: ReactRouterMode = await abortIfCanceled(
ui.select({
message: 'What React Router v7 mode are you using?',
options: [
Expand All @@ -160,7 +160,7 @@ export async function getReactRouterMode(options: InstallerOptions): Promise<Rea

if (!isPromptAllowed()) return defaultModeNonInteractive();
ui.log.info(`Learn more about React Router modes: ${chalk.cyan('https://reactrouter.com/start/modes')}`);
const result: ReactRouterMode = await abortIfCancelled(
const result: ReactRouterMode = await abortIfCanceled(
ui.select({
message: 'What React Router version and mode are you using?',
options: [
Expand Down
6 changes: 3 additions & 3 deletions src/lib/adapters/cli-adapter.coordination.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ describe('CLI adapter with real UI coordination', () => {
}
});

it('cancelling the first question never opens the now-moot queued sibling', async () => {
it('canceling the first question never opens the now-moot queued sibling', async () => {
sendEvent.mockImplementation((event) => {
if (event.type === 'GIT_CANCELLED') emitter.emit('complete', { success: false, summary: 'Cancelled' });
if (event.type === 'GIT_CANCELED') emitter.emit('complete', { success: false, summary: 'Canceled' });
});
emitter.emit('agent:start', {});
emitter.emit('git:dirty', { files: ['fixture.rb'] });
Expand All @@ -157,7 +157,7 @@ describe('CLI adapter with real UI coordination', () => {
questions[0].answer(false as never);
await drain();
expect(questions.map((q) => q.message)).toEqual(['Continue anyway?']);
expect(output()).toContain('Cancelled');
expect(output()).toContain('Canceled');
expect(vi.getTimerCount()).toBe(0);
});

Expand Down
Loading
Loading