setup: add ← Back option to Discord, WhatsApp, iMessage channel flows

Picking the wrong messaging channel during setup left users with no way
to bail out — they had to either complete the chosen flow or kill setup
and start over. This adds a Back option to the first prompt of three
channel sub-flows that share the same simple shape (one leading
brightSelect that's easy to extend).

Mechanics:
- New `setup/lib/back-nav.ts` exports a BACK_TO_CHANNEL_SELECTION
  sentinel and ChannelFlowResult type.
- `setup/auto.ts` wraps the channel dispatch in a while-loop; channels
  return BACK_TO_CHANNEL_SELECTION to bounce back to the chooser
  without restarting setup. Channels not yet wired return void and the
  loop exits after one pass, so the change is backwards compatible.
- Discord, WhatsApp, iMessage each add a `← Back to channel selection`
  option to their first prompt.

Telegram, Slack, Teams, and Signal will follow as separate PRs — they
each need a slightly different shape (extra prompt insertions, gating
inside multi-step flows, etc.) and are easier to review independently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-05-05 09:20:17 +00:00
parent 948a0dcada
commit c795ecff6e
5 changed files with 104 additions and 60 deletions
+39 -28
View File
@@ -29,6 +29,7 @@ import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js';
import { runDiscordChannel } from './channels/discord.js';
import { runIMessageChannel } from './channels/imessage.js';
import { runSignalChannel } from './channels/signal.js';
@@ -440,35 +441,45 @@ async function main(): Promise<void> {
let channelChoice: ChannelChoice = 'skip';
if (!skip.has('channel')) {
channelChoice = await askChannelChoice();
if (channelChoice !== 'skip' && channelChoice !== 'other') {
await resolveDisplayName();
}
if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
await runSlackChannel(displayName!);
} else if (channelChoice === 'imessage') {
await runIMessageChannel(displayName!);
} else if (channelChoice === 'other') {
await askOtherChannelName();
} else {
p.log.info(
brandBody(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
// Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on
// its first prompt and bounce the user back to the chooser without
// restarting setup. Channels not yet wired with the back option just
// return void and the loop exits after one pass.
let backed = true;
while (backed) {
backed = false;
channelChoice = await askChannelChoice();
if (channelChoice !== 'skip' && channelChoice !== 'other') {
await resolveDisplayName();
}
let result: void | typeof BACK_TO_CHANNEL_SELECTION;
if (channelChoice === 'telegram') {
await runTelegramChannel(displayName!);
} else if (channelChoice === 'discord') {
result = await runDiscordChannel(displayName!);
} else if (channelChoice === 'whatsapp') {
result = await runWhatsAppChannel(displayName!);
} else if (channelChoice === 'signal') {
await runSignalChannel(displayName!);
} else if (channelChoice === 'teams') {
await runTeamsChannel(displayName!);
} else if (channelChoice === 'slack') {
await runSlackChannel(displayName!);
} else if (channelChoice === 'imessage') {
result = await runIMessageChannel(displayName!);
} else if (channelChoice === 'other') {
await askOtherChannelName();
} else {
p.log.info(
brandBody(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
),
),
);
);
}
if (result === BACK_TO_CHANNEL_SELECTION) backed = true;
}
}
+8 -4
View File
@@ -27,6 +27,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
@@ -48,8 +49,10 @@ interface AppInfo {
owner: { id: string; username: string } | null;
}
export async function runDiscordChannel(displayName: string): Promise<void> {
const hasBot = await askHasBotToken();
export async function runDiscordChannel(displayName: string): Promise<ChannelFlowResult> {
const choice = await askHasBotToken();
if (choice === 'back') return BACK_TO_CHANNEL_SELECTION;
const hasBot = choice === 'yes';
if (!hasBot) {
await walkThroughBotCreation();
}
@@ -142,17 +145,18 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
}
}
async function askHasBotToken(): Promise<boolean> {
async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> {
const answer = ensureAnswer(
await brightSelect({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
{ value: 'no', label: "No, walk me through creating one" },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
return answer === 'yes';
return answer as 'yes' | 'no' | 'back';
}
async function walkThroughBotCreation(): Promise<void> {
+30 -24
View File
@@ -33,6 +33,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
@@ -48,10 +49,11 @@ interface RemoteCreds {
apiKey: string;
}
export async function runIMessageChannel(displayName: string): Promise<void> {
export async function runIMessageChannel(displayName: string): Promise<ChannelFlowResult> {
const isMac = os.platform() === 'darwin';
const mode = await askMode(isMac);
if (mode === 'back') return BACK_TO_CHANNEL_SELECTION;
let remoteCreds: RemoteCreds | null = null;
if (mode === 'local') {
@@ -139,34 +141,38 @@ export async function runIMessageChannel(displayName: string): Promise<void> {
}
}
async function askMode(isMac: boolean): Promise<Mode> {
async function askMode(isMac: boolean): Promise<Mode | 'back'> {
const baseOptions = isMac
? [
{
value: 'local' as const,
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote' as const,
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
];
const choice = ensureAnswer(
await brightSelect<Mode>({
await brightSelect<Mode | 'back'>({
message: 'How should iMessage run?',
initialValue: isMac ? 'local' : 'remote',
options: isMac
? [
{
value: 'local',
label: 'Local (this Mac)',
hint: "uses this machine's iMessage account",
},
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'the bot lives on another server',
},
]
: [
{
value: 'remote',
label: 'Remote (Photon API)',
hint: 'only option off macOS',
},
],
options: [
...baseOptions,
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
setupLog.userInput('imessage_mode', String(choice));
if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice));
return choice;
}
+10 -4
View File
@@ -33,6 +33,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
import {
@@ -53,8 +54,9 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
type AuthMethod = 'qr' | 'pairing-code';
export async function runWhatsAppChannel(displayName: string): Promise<void> {
export async function runWhatsAppChannel(displayName: string): Promise<ChannelFlowResult> {
const method = await askAuthMethod();
if (method === 'back') return BACK_TO_CHANNEL_SELECTION;
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
const install = await runQuietChild(
@@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
}
}
async function askAuthMethod(): Promise<AuthMethod> {
async function askAuthMethod(): Promise<AuthMethod | 'back'> {
const choice = ensureAnswer(
await brightSelect({
message: 'How would you like to authenticate with WhatsApp?',
@@ -163,10 +165,14 @@ async function askAuthMethod(): Promise<AuthMethod> {
label: 'Enter a pairing code on your phone',
hint: 'no camera needed',
},
{
value: 'back',
label: '← Back to channel selection',
},
],
}),
) as AuthMethod;
setupLog.userInput('whatsapp_auth_method', choice);
) as AuthMethod | 'back';
if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice);
return choice;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Channel-flow back-navigation sentinel.
*
* Each `runXxxChannel(displayName)` in `setup/channels/` may return either
* `void` (sub-flow completed normally) or `BACK_TO_CHANNEL_SELECTION` to
* signal "the user picked '← Back to channel selection' on my first
* prompt; please re-run the channel chooser." `setup/auto.ts` catches
* that signal and loops back to `askChannelChoice()`.
*
* Back is only offered on the *first* interactive prompt of each channel
* sub-flow — once the user has answered something, they're committed
* (subsequent steps may have side effects like opening browsers, hitting
* APIs, or installing adapter packages, none of which are easily undone).
*/
export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION');
export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION;