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: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ the run. It resolves when the wizard is done, and only rejects if the
wizard could not run at all: a step that fails reports itself to the
developer and does not reject.

Every run reaches a prompt, so the wizard needs an interactive terminal.
Where stdin is not a TTY — a pipe, a CI job, a non-interactive shell — it
writes a notice to stderr, sets a non-zero exit code, and resolves
without rendering anything.
`--help` and `--version` still work there.

Pass `cwd` to set the project root the wizard sets up.
It defaults to `process.cwd()`,
which is the project the command was run in.
Expand Down
33 changes: 32 additions & 1 deletion src/lib/wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ const wizard = async (options: WizardOptions = {}): Promise<void> => {
return
}

// Every run reaches a prompt — the connect method, the SDK, the offer to
// write the integration — and a prompt needs stdin in raw mode, which only a
// TTY allows. Without one the first prompt throws from inside Ink's render
// and the developer gets a React stack trace, so say what is wrong instead.
if (!isInteractive()) {
writeError(nonInteractiveNotice(commandName))
process.exitCode = 1
return
}

// Dev-only: preview a screen's layout without the auth/agent flow. Passing
// `--debug-screen` with no name opens a chooser of every available screen.
const debugScreen = args['debug-screen']
Expand Down Expand Up @@ -117,7 +127,28 @@ const usage = (commandName: string): string =>
'',
].join('\n')

// TODO: Replace this with a logger wrapper.
const nonInteractiveNotice = (commandName: string): string =>
[
'Seam Wizard needs an interactive terminal.',
'',
' It asks which account to connect, which SDK to install, and whether to',
' write the integration, so it cannot run with stdin redirected — in a',
' pipe, a CI job, or a non-interactive shell.',
'',
` Run '${commandName}' in your terminal instead.`,
'',
].join('\n')

// Ink reads stdin, so this is the condition Ink itself checks before allowing
// raw mode. Where stdout is redirected the wizard still runs: it skips the
// alternate screen and leaves its frames in the log.
const isInteractive = (): boolean => process.stdin.isTTY === true

// TODO: Replace these with a logger wrapper.
const write = (message: string): void => {
process.stdout.write(`${message}\n`)
}

const writeError = (message: string): void => {
process.stderr.write(`${message}\n`)
}
60 changes: 59 additions & 1 deletion test/wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,40 @@ import wizard from 'lib/wizard.js'

vi.mock('lib/render.js', () => ({ renderApp: vi.fn() }))

// The wizard refuses to run without a TTY, so a test that expects it to render
// has to look like a terminal. Tests for the refusal set this to undefined.
const setInteractive = (isTty: boolean | undefined): void => {
Object.defineProperty(process.stdin, 'isTTY', {
value: isTty,
configurable: true,
})
}

const initialIsTty = process.stdin.isTTY

beforeEach(() => {
vi.mocked(renderApp).mockClear()
setInteractive(true)
})

afterEach(resetAdapter)
afterEach(() => {
setInteractive(initialIsTty)
// Set by the refusal path; left alone it would fail the whole test run.
process.exitCode = 0
resetAdapter()
})

const captureError = async (
options: Parameters<typeof wizard>[0],
): Promise<string> => {
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
try {
await wizard(options)
return write.mock.calls.map(([chunk]) => String(chunk)).join('')
} finally {
write.mockRestore()
}
}

const captureOutput = async (
options: Parameters<typeof wizard>[0],
Expand Down Expand Up @@ -134,3 +163,32 @@ test('wizard: asks no adapter anything to display usage', async () => {

expect(getAuthSpy).not.toHaveBeenCalled()
})

test('wizard: refuses to run without an interactive terminal', async () => {
setInteractive(undefined)

const output = await captureError({ argv: [], commandName: 'seam wizard' })

expect(output).toContain('needs an interactive terminal')
expect(output).toContain("Run 'seam wizard' in your terminal instead.")
expect(renderApp).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
})

test('wizard: still displays usage without an interactive terminal', async () => {
setInteractive(undefined)

const output = await captureOutput({ argv: ['--help'] })

expect(output).toContain('$ wizard [options]')
expect(process.exitCode).not.toBe(1)
})

test('wizard: still displays the version without an interactive terminal', async () => {
setInteractive(undefined)

const output = await captureOutput({ argv: ['--version'] })

expect(output.trim()).toBe(seamapiWizardVersion)
expect(process.exitCode).not.toBe(1)
})
Loading