Skip to content

fix(slack): decide the setup wizard's prompts by TTY, not by a flag - #483

Merged
lidge-jun merged 1 commit into
devfrom
codex/475-slack-setup-non-tty
Aug 26, 2026
Merged

fix(slack): decide the setup wizard's prompts by TTY, not by a flag#483
lidge-jun merged 1 commit into
devfrom
codex/475-slack-setup-non-tty

Conversation

@parkjs101

Copy link
Copy Markdown
Collaborator

What was wrong

jaw slack setup chose to prompt based on the --non-interactive flag alone. Nothing in that decision looked at whether a terminal was attached, so a piped or redirected run still built a readline interface and awaited an answer that could never arrive.

The failure is not an exception. rl.question() waits for a line; a closed stdin never delivers one; the promise simply never settles. There is no error to catch, so the surrounding try is powerless — node gives up on the event loop, prints Detected unsettled top-level await, and exits 13. Anything already validated is discarded, because the write happens after the prompts.

Two distinct hangs, which is why "just pass --channel-ids" was never the answer:

stdin dies at
< /dev/null Step 1, the "Press Enter" wait
printf '\n' | a later prompt — the single line is consumed by Step 1

The fix

Prompting is a property of the input, not of the flag:

const interactive = !nonInteractive && !!process.stdin.isTTY;
const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;

ask() already returned the default when rl is null, so no other logic changed. --non-interactive keeps its meaning for someone who wants defaults at a real terminal; the absence of a TTY decides the rest.

This also stops an unwanted browser window. The Step 1 conveniences were behind if (!nonInteractive), which reads the flag and not the input — so a headless run still shelled out to open and launched a browser at whoever ran it, plus copied to the clipboard. Both are courtesies aimed at a person sitting at a terminal, so they now sit behind the same interactive guard. A regression test asserts the binaries are never invoked, using recording stubs on PATH rather than trusting the absence of a log line.

Validation is deliberately untouched. A run with no bot token still exits 1 and names the flag to pass. This removes a crash, not a requirement.

Why not the other two options in the issue

  • --non-interactive — already implemented; a no-op for this bug. It works, but only if you know to reach for it, and the failure gives you no hint that you should.
  • Requiring --channel-ids — rejected. An empty channelIds is a valid default that slackChannelScope() in scope-status.ts documents as "the shipped default and a normal way to run"; making it mandatory would contradict that contract. It also would not have fixed anything — the hang is not anchored to that prompt.

Evidence

Both reproductions use --skip-validate --no-notify and a throwaway CLI_JAW_HOME, so no live Slack workspace or real settings file is involved.

Before (at origin/dev, 28b8952):

$ CLI_JAW_HOME=$H jaw slack setup --bot-token xoxb-1-testbot --skip-validate --no-notify < /dev/null
  Press Enter once the app is created and installed to the workspace… Warning: Detected unsettled top-level await at bin/cli-jaw.ts:233
        await import('./commands/slack.js');
EXIT=13
slack.botToken = ""      # nothing saved
$ printf '\n' | CLI_JAW_HOME=$H jaw slack setup --bot-token xoxb-1-testbot --skip-validate --no-notify
  App-level token (xapp-..., Enter to skip) Warning: Detected unsettled top-level await
EXIT=13
slack.botToken = ""      # nothing saved

After:

$ CLI_JAW_HOME=$A jaw slack setup --bot-token xoxb-1-testbot --skip-validate --no-notify < /dev/null
  ✅ Slack settings saved.
    Bot token   : xoxb-1-tes…
    Channels    : all conversations allowed
EXIT=0
SAVED {"enabled":true,"botToken":"xoxb-1-testbot","channelIds":[]}

$ printf '\n' | ...          EXIT=0, botToken "xoxb-1-testbot"
$ ... (no --bot-token)        EXIT=1, "A bot token is required … use: jaw slack setup --bot-token xoxb-..."

Checks

  • Regression tests — four added to tests/unit/slack-setup.test.ts. The three non-TTY ones fail on origin/dev and pass here (verified by stashing only the source change: pass 12 / fail 3pass 15 / fail 0). They cover: a piped run completing and actually persisting tokens; no open/pbcopy on a headless run; a missing token still exiting 1. The fourth pins the terminal path so this cannot regress into "never prompts".
  • npm test — 8203 tests, 8176 pass, 1 fail. The single failure is project git summary rejects repo roots outside the home guard, which fails identically on unmodified origin/dev and does not touch Slack.
  • npx tsc --noEmit — clean.
  • npm run builddist/bin/commands/slack.js:146 contains const interactive = !nonInteractive && !!process.stdin.isTTY;.
  • structure/verify-counts.shslack.ts updated to 404L. One mismatch remains (skills_ref/registry.json, 3350→3324) which is pre-existing submodule drift on origin/dev and is left alone.

Scope

#475 only. Deliberately not touched, both filed separately:

A follow-up worth considering: setup writes settings only after every prompt, so a token that already passed auth.test is thrown away if a later step fails. Moving the write earlier would make the wizard resumable, but it changes failure semantics and belongs in its own PR.

Fixes #475

`jaw slack setup` built a readline interface whenever --non-interactive was
absent, without ever asking whether there was a terminal to answer it. Under a
pipe or </dev/null the first question() therefore waits for a line stdin will
never produce: the promise does not settle, so it is not throwable and no
try/catch can reach it. Node prints "unsettled top-level await" and exits 13,
and the tokens it had already validated go unwritten (#475).

Prompting is a property of the input, not of the flag, so `interactive` now
requires both: no --non-interactive AND process.stdin.isTTY. The flag keeps its
meaning for someone who wants defaults at a real terminal; the absence of a TTY
decides the rest.

The same guard covers the Step 1 conveniences, which is a user-visible fix on
its own. Reading only the flag, a headless run still shelled out to `open` and
threw a browser window at whoever ran it — noise for a host with nobody
watching, and the reason a piped run could pop a window before hanging.

Validation is untouched: a run with no bot token still exits 1 and names the
flag to pass. This removes a crash, not a requirement.

Fixes #475

Evidence: 8203 tests, 8176 pass, 1 pre-existing failure unrelated to Slack
(project-git-summary, reproduced on unmodified origin/dev); tsc --noEmit clean;
the three new non-TTY tests fail on origin/dev and pass here.
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lidge-jun
lidge-jun merged commit 4bcf178 into dev Aug 26, 2026
5 checks passed
@lidge-jun
lidge-jun deleted the codex/475-slack-setup-non-tty branch August 26, 2026 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants