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
115 changes: 115 additions & 0 deletions src/bin-default-command.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Integration test for the fixed `$0` default-command output.
*
* bin.ts runs runCli() at import and exposes no seams, so the only honest way
* to prove the friction fix is to drive the real CLI as a subprocess and read
* its streams. Pre-fix, a bare non-TTY invocation ran `yargs(rawArgs).showHelp()`
* on a FRESH yargs instance (zero commands registered), emitting a degenerate
* two-line `--help`/`--version` block to stderr and leaving stdout empty — a
* piped agent/CI consumer got nothing and never discovered `install`.
*
* We use a LOCAL spawn helper (not the shared one in
* bin-command-telemetry.integration.spec.ts) because that one hardcodes
* WORKOS_MODE=agent, which the CI and human-force-TTY cases must omit. The env
* is built clean (spawnSync replaces, not merges) so host agent/CI markers are
* absent unless a case adds them.
*/
const binPath = fileURLToPath(new URL('./bin.ts', import.meta.url));
const forceInsecureStorageImport = new URL('./test/force-insecure-storage.ts', import.meta.url).href;
const repoRoot = fileURLToPath(new URL('..', import.meta.url));

let sandboxTmp: string;

beforeEach(() => {
sandboxTmp = mkdtempSync(join(tmpdir(), 'wos-cli-default-it-'));
});

afterEach(() => {
rmSync(sandboxTmp, { recursive: true, force: true });
});

function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
const env: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: sandboxTmp,
USERPROFILE: sandboxTmp,
TMPDIR: sandboxTmp,
TMP: sandboxTmp,
TEMP: sandboxTmp,
// Keep machine streams clean: no telemetry, no update check network calls.
WORKOS_TELEMETRY: 'false',
// Unroutable API base (defense-in-depth; the $0 path never hits the network).
WORKOS_API_URL: 'http://127.0.0.1:59999',
// Per-case env last. Agent/CI markers are absent unless a case adds one.
...envOverrides,
};

return spawnSync(process.execPath, ['--import', 'tsx', '--import', forceInsecureStorageImport, binPath, ...args], {
cwd: repoRoot,
encoding: 'utf-8',
env,
});
}

describe('bin $0 default command (non-TTY)', () => {
it('agent mode emits the full machine-readable command tree on stdout', () => {
const result = runCli([], { WORKOS_MODE: 'agent' });

expect(result.status).toBe(0);

const tree = JSON.parse(result.stdout.trim());
expect(tree.name).toBe('workos');
expect(tree.version).toBeTruthy();

const names = tree.commands.map((c: { name: string }) => c.name);
expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor']));
// The installer must be discoverable — this is the friction being fixed.
expect(tree.commands.some((c: { name: string }) => c.name === 'install')).toBe(true);

// Regression guard: never emit the degenerate fresh-yargs help block.
expect(result.stderr).not.toContain('Show version number');
}, 20_000);

it('CI mode (CI=1, no WORKOS_MODE) emits the command tree on stdout', () => {
const result = runCli([], { CI: '1' });

expect(result.status).toBe(0);

const tree = JSON.parse(result.stdout.trim());
expect(tree.name).toBe('workos');
expect(tree.version).toBeTruthy();

const names = tree.commands.map((c: { name: string }) => c.name);
expect(names).toEqual(expect.arrayContaining(['install', 'auth login', 'organization', 'doctor']));
expect(result.stderr).not.toContain('Show version number');
}, 20_000);

it('human non-TTY edge (WORKOS_FORCE_TTY=1) shows the configured help, not JSON', () => {
// WORKOS_MODE omitted entirely (never set to '' — that throws InvalidInteractionModeError).
const result = runCli([], { WORKOS_FORCE_TTY: '1' });

expect(result.status).toBe(0);

const combined = `${result.stdout}\n${result.stderr}`;
// parser.showHelp() (not the fresh-yargs block) lists the full command set.
expect(combined).toMatch(/install/);
expect(combined).toMatch(/organization/);
expect(combined).toMatch(/auth/);
}, 20_000);

it('--help --json still returns the command tree (regression for the intercept)', () => {
const result = runCli(['--help', '--json'], { WORKOS_MODE: 'agent' });

expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout.trim());
expect(Array.isArray(parsed.commands)).toBe(true);
expect(parsed.commands.length).toBeGreaterThan(0);
}, 20_000);
});
15 changes: 13 additions & 2 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ const installerOptions = {
choices: ['npm', 'pnpm', 'yarn', 'bun'] as const,
type: 'string' as const,
},
router: {
choices: ['app', 'pages'] as const,
describe: 'Next.js router to target when detection is ambiguous (app or pages)',
type: 'string' as const,
},
};

// Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean)
Expand Down Expand Up @@ -2677,9 +2682,15 @@ async function runCli(): Promise<void> {
'WorkOS AuthKit CLI',
(yargs) => yargs.options(insecureStorageOption),
async (argv) => {
// Non-human modes: show help instead of prompting
// Non-human modes: emit machine-readable command tree (JSON) or the
// fully-configured parser help (human non-TTY edge) instead of prompting.
if (!isPromptAllowed()) {
yargs(rawArgs).showHelp();
if (isJsonMode()) {
const { buildCommandTree } = await import('./utils/help-json.js');
outputJson(buildCommandTree());
} else {
parser.showHelp();
}
return;
}

Expand Down
48 changes: 46 additions & 2 deletions src/commands/install.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

vi.mock('../run.js', () => ({
runInstaller: vi.fn(),
Expand Down Expand Up @@ -31,8 +31,9 @@ const { runInstaller } = await import('../run.js');
const { autoInstallSkills } = await import('./install-skill.js');
const { maybeOfferMcpInstall } = await import('../lib/mcp-notice.js');
const clack = (await import('../utils/clack.js')).default;
const { isJsonMode } = await import('../utils/output.js');
const { isJsonMode, exitWithError } = await import('../utils/output.js');
const { CliExit } = await import('../utils/cli-exit.js');
const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js');

const { handleInstall } = await import('./install.js');

Expand All @@ -41,6 +42,10 @@ describe('handleInstall', () => {
vi.clearAllMocks();
});

afterEach(() => {
resetInteractionModeForTests();
});

it('calls autoInstallSkills after successful install', async () => {
vi.mocked(runInstaller).mockResolvedValue(undefined as any);
vi.mocked(autoInstallSkills).mockResolvedValue(null);
Expand Down Expand Up @@ -126,4 +131,43 @@ describe('handleInstall', () => {
expect(runInstaller).toHaveBeenCalledOnce();
expect(autoInstallSkills).toHaveBeenCalledOnce();
});

describe('CI-mode required-arg validation', () => {
it('WORKOS_MODE=ci requires --api-key (validation triggered without the --ci flag)', async () => {
vi.mocked(runInstaller).mockResolvedValue(undefined as any);
vi.mocked(autoInstallSkills).mockResolvedValue(null);
setInteractionMode({ mode: 'ci', source: 'env' });

await handleInstall({ _: ['install'], $0: 'workos' } as any);

expect(exitWithError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'missing_args', message: expect.stringContaining('--api-key') }),
);
});

it('WORKOS_MODE=ci with all required args does not error', async () => {
vi.mocked(runInstaller).mockResolvedValue(undefined as any);
vi.mocked(autoInstallSkills).mockResolvedValue(null);
setInteractionMode({ mode: 'ci', source: 'env' });

await handleInstall({
_: ['install'],
$0: 'workos',
apiKey: 'sk_test',
clientId: 'client_x',
installDir: '/tmp/x',
} as any);

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

it('default (human) mode does not trigger CI validation', async () => {
vi.mocked(runInstaller).mockResolvedValue(undefined as any);
vi.mocked(autoInstallSkills).mockResolvedValue(null);

await handleInstall({ _: ['install'], $0: 'workos' } as any);

expect(exitWithError).not.toHaveBeenCalled();
});
});
});
5 changes: 3 additions & 2 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { InstallerArgs } from '../run.js';
import clack from '../utils/clack.js';
import { exitWithError, isJsonMode } from '../utils/output.js';
import { ExitCode, exitWithCode } from '../utils/exit-codes.js';
import { isCiMode } from '../utils/interaction-mode.js';
import type { ArgumentsCamelCase } from 'yargs';
import { autoInstallSkills } from './install-skill.js';
import { maybeOfferMcpInstall } from '../lib/mcp-notice.js';
Expand All @@ -13,8 +14,8 @@ import { maybeOfferMcpInstall } from '../lib/mcp-notice.js';
export async function handleInstall(argv: ArgumentsCamelCase<InstallerArgs>): Promise<void> {
const options = { ...argv };

// CI mode validation
if (options.ci) {
// CI mode validation — trigger for the hidden --ci flag or WORKOS_MODE=ci.
if (options.ci || isCiMode()) {
if (!options.apiKey) {
exitWithError({ code: 'missing_args', message: 'CI mode requires --api-key (WorkOS API key sk_xxx)' });
}
Expand Down
118 changes: 118 additions & 0 deletions src/integrations/nextjs/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { InteractionMode } from '../../utils/interaction-mode.js';

vi.mock('fast-glob', () => ({ default: vi.fn() }));

vi.mock('../../utils/clack.js', () => ({
default: {
select: vi.fn(),
isCancel: vi.fn(() => false),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), step: vi.fn(), message: vi.fn() },
},
}));

// Passthrough — the guard itself is covered by clack-utils.spec.ts; here we only
// need clack.select's resolved value to flow through in the human path.
vi.mock('../../utils/clack-utils.js', () => ({
abortIfCancelled: vi.fn(async (p) => await p),
}));

const fg = (await import('fast-glob')).default;
const clack = (await import('../../utils/clack.js')).default;
const { getNextJsRouter, NextJsRouter } = await import('./utils.js');
const { setInteractionMode, resetInteractionModeForTests } = await import('../../utils/interaction-mode.js');

/** Configure fast-glob to report presence of pages/ and/or app/ dirs. */
function mockDetection({ pages, app }: { pages: boolean; app: boolean }): void {
vi.mocked(fg).mockImplementation((async (pattern: string) => {
if (String(pattern).includes('pages')) return pages ? ['pages/_app.tsx'] : [];
return app ? ['app/layout.tsx'] : [];
}) as never);
}

const modes: InteractionMode[] = ['human', 'agent', 'ci'];

describe('getNextJsRouter', () => {
beforeEach(() => {
resetInteractionModeForTests();
vi.clearAllMocks();
vi.mocked(clack.isCancel).mockReturnValue(false);
});

afterEach(() => {
resetInteractionModeForTests();
});

it.each(modes)('pages-only detection returns pages router without prompting (%s mode)', async (mode) => {
setInteractionMode({ mode, source: mode === 'human' ? 'default' : 'env' });
mockDetection({ pages: true, app: false });

const result = await getNextJsRouter({ installDir: '/proj' });

expect(result).toBe(NextJsRouter.PAGES_ROUTER);
expect(clack.select).not.toHaveBeenCalled();
});

it.each(modes)('app-only detection returns app router without prompting (%s mode)', async (mode) => {
setInteractionMode({ mode, source: mode === 'human' ? 'default' : 'env' });
mockDetection({ pages: false, app: true });

const result = await getNextJsRouter({ installDir: '/proj' });

expect(result).toBe(NextJsRouter.APP_ROUTER);
expect(clack.select).not.toHaveBeenCalled();
});

it('ambiguous detection in human mode prompts and uses the answer', async () => {
setInteractionMode({ mode: 'human', source: 'default' });
mockDetection({ pages: true, app: true });
vi.mocked(clack.select).mockResolvedValueOnce(NextJsRouter.PAGES_ROUTER as never);

const result = await getNextJsRouter({ installDir: '/proj' });

expect(result).toBe(NextJsRouter.PAGES_ROUTER);
expect(clack.select).toHaveBeenCalledOnce();
});

it('ambiguous detection in agent mode defaults to app router with a warning (no prompt)', async () => {
setInteractionMode({ mode: 'agent', source: 'env' });
mockDetection({ pages: true, app: true });

const result = await getNextJsRouter({ installDir: '/proj' });

expect(result).toBe(NextJsRouter.APP_ROUTER);
expect(clack.select).not.toHaveBeenCalled();
expect(clack.log.warn).toHaveBeenCalled();
});

it('ambiguous detection in ci mode defaults to app router with a warning (no prompt)', async () => {
setInteractionMode({ mode: 'ci', source: 'env' });
mockDetection({ pages: true, app: true });

const result = await getNextJsRouter({ installDir: '/proj' });

expect(result).toBe(NextJsRouter.APP_ROUTER);
expect(clack.select).not.toHaveBeenCalled();
expect(clack.log.warn).toHaveBeenCalled();
});

it('--router pages overrides ambiguous detection with no prompt', async () => {
setInteractionMode({ mode: 'human', source: 'default' });
mockDetection({ pages: true, app: true });

const result = await getNextJsRouter({ installDir: '/proj', router: 'pages' });

expect(result).toBe(NextJsRouter.PAGES_ROUTER);
expect(clack.select).not.toHaveBeenCalled();
});

it('--router app wins over detection with no prompt', async () => {
setInteractionMode({ mode: 'human', source: 'default' });
mockDetection({ pages: true, app: false });

const result = await getNextJsRouter({ installDir: '/proj', router: 'app' });

expect(result).toBe(NextJsRouter.APP_ROUTER);
expect(clack.select).not.toHaveBeenCalled();
});
});
Loading
Loading