setup: add back-to-channels exit at every Teams step gate

Teams setup is 6+ Azure steps over 30+ minutes. Today, every
"Done / Stuck / Show again" gate forces continuation; the only escape
is Ctrl-C, which kills setup entirely. Add a fourth option at each gate
that returns to the channel picker so a stuck operator can pick a
different channel without losing the rest of setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-05-07 08:05:26 +00:00
parent f2d2ce9aed
commit 88ff54cf83

View File

@@ -95,12 +95,25 @@ export async function runTeamsChannel(_displayName: string): Promise<ChannelFlow
const prereqsResult = await confirmPrereqs({ collected, completed }); const prereqsResult = await confirmPrereqs({ collected, completed });
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION; if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
await stepPublicUrl({ collected, completed }); await stepPublicUrl({ collected, completed });
await stepAppRegistration({ collected, completed }); if (await stepAppRegistration({ collected, completed }) === 'back') {
await stepClientSecret({ collected, completed }); return BACK_TO_CHANNEL_SELECTION;
await stepAzureBot({ collected, completed }); }
await stepEnableTeamsChannel({ collected, completed }); if (await stepClientSecret({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepAzureBot({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
const manifestResult = await stepGenerateManifest({ collected, completed }); const manifestResult = await stepGenerateManifest({ collected, completed });
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath }); if (
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
=== 'back'
) {
return BACK_TO_CHANNEL_SELECTION;
}
await installAdapter(collected); await installAdapter(collected);
completed.push('Adapter installed and service restarted.'); completed.push('Adapter installed and service restarted.');
@@ -229,7 +242,7 @@ async function stepPublicUrl(args: { collected: Collected; completed: string[] }
async function stepAppRegistration(args: { async function stepAppRegistration(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
}): Promise<void> { }): Promise<'continue' | 'back'> {
note( note(
[ [
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
@@ -262,15 +275,17 @@ async function stepAppRegistration(args: {
); );
} }
await stepGate({ const gate = await stepGate({
stepName: 'teams-app-registration', stepName: 'teams-app-registration',
stepDescription: 'registering an app in Azure and collecting App ID + tenant type', stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
reshow: () => stepAppRegistration(args), reshow: () => stepAppRegistration(args),
args, args,
}); });
if (gate === 'back') return 'back';
args.completed.push( args.completed.push(
`App registered: ${args.collected.appId} (${args.collected.appType})`, `App registered: ${args.collected.appId} (${args.collected.appType})`,
); );
return 'continue';
} }
async function askAppType(args: { async function askAppType(args: {
@@ -313,7 +328,7 @@ async function askAppType(args: {
async function stepClientSecret(args: { async function stepClientSecret(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
}): Promise<void> { }): Promise<'continue' | 'back'> {
note( note(
[ [
`1. In your app registration, open "Certificates & secrets"`, `1. In your app registration, open "Certificates & secrets"`,
@@ -356,13 +371,15 @@ async function stepClientSecret(args: {
break; break;
} }
await stepGate({ const gate = await stepGate({
stepName: 'teams-client-secret', stepName: 'teams-client-secret',
stepDescription: 'creating and copying the client secret', stepDescription: 'creating and copying the client secret',
reshow: () => stepClientSecret(args), reshow: () => stepClientSecret(args),
args, args,
}); });
if (gate === 'back') return 'back';
args.completed.push('Client secret captured.'); args.completed.push('Client secret captured.');
return 'continue';
} }
// ─── step: Azure Bot resource ────────────────────────────────────────── // ─── step: Azure Bot resource ──────────────────────────────────────────
@@ -370,7 +387,7 @@ async function stepClientSecret(args: {
async function stepAzureBot(args: { async function stepAzureBot(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
}): Promise<void> { }): Promise<'continue' | 'back'> {
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`; const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
const tenantFlag = const tenantFlag =
args.collected.appType === 'SingleTenant' args.collected.appType === 'SingleTenant'
@@ -405,14 +422,16 @@ async function stepAzureBot(args: {
'Step 3 of 6 — Create Azure Bot resource', 'Step 3 of 6 — Create Azure Bot resource',
); );
await stepGate({ const gate = await stepGate({
stepName: 'teams-azure-bot', stepName: 'teams-azure-bot',
stepDescription: stepDescription:
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint', 'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
reshow: () => stepAzureBot(args), reshow: () => stepAzureBot(args),
args, args,
}); });
if (gate === 'back') return 'back';
args.completed.push('Azure Bot created; messaging endpoint configured.'); args.completed.push('Azure Bot created; messaging endpoint configured.');
return 'continue';
} }
// ─── step: enable Teams channel ──────────────────────────────────────── // ─── step: enable Teams channel ────────────────────────────────────────
@@ -420,7 +439,7 @@ async function stepAzureBot(args: {
async function stepEnableTeamsChannel(args: { async function stepEnableTeamsChannel(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
}): Promise<void> { }): Promise<'continue' | 'back'> {
note( note(
[ [
'1. Open your Azure Bot resource → Channels', '1. Open your Azure Bot resource → Channels',
@@ -431,13 +450,15 @@ async function stepEnableTeamsChannel(args: {
].join('\n'), ].join('\n'),
'Step 4 of 6 — Enable Teams channel on the bot', 'Step 4 of 6 — Enable Teams channel on the bot',
); );
await stepGate({ const gate = await stepGate({
stepName: 'teams-enable-channel', stepName: 'teams-enable-channel',
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource', stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
reshow: () => stepEnableTeamsChannel(args), reshow: () => stepEnableTeamsChannel(args),
args, args,
}); });
if (gate === 'back') return 'back';
args.completed.push('Teams channel enabled on the bot.'); args.completed.push('Teams channel enabled on the bot.');
return 'continue';
} }
// ─── step: manifest zip ──────────────────────────────────────────────── // ─── step: manifest zip ────────────────────────────────────────────────
@@ -490,7 +511,7 @@ async function stepSideload(args: {
collected: Collected; collected: Collected;
completed: string[]; completed: string[];
zipPath: string; zipPath: string;
}): Promise<void> { }): Promise<'continue' | 'back'> {
note( note(
[ [
'1. Open Microsoft Teams', '1. Open Microsoft Teams',
@@ -505,13 +526,15 @@ async function stepSideload(args: {
].join('\n'), ].join('\n'),
'Step 5 of 6 — Sideload the app into Teams', 'Step 5 of 6 — Sideload the app into Teams',
); );
await stepGate({ const gate = await stepGate({
stepName: 'teams-sideload', stepName: 'teams-sideload',
stepDescription: 'uploading the generated zip into Teams as a custom app', stepDescription: 'uploading the generated zip into Teams as a custom app',
reshow: () => stepSideload(args), reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
args, args,
}); });
if (gate === 'back') return 'back';
args.completed.push('App sideloaded into Teams.'); args.completed.push('App sideloaded into Teams.');
return 'continue';
} }
// ─── step: install adapter ───────────────────────────────────────────── // ─── step: install adapter ─────────────────────────────────────────────
@@ -623,9 +646,9 @@ async function finishWithHandoff(
async function stepGate(args: { async function stepGate(args: {
stepName: string; stepName: string;
stepDescription: string; stepDescription: string;
reshow: () => Promise<void> | Promise<unknown>; reshow: () => Promise<'continue' | 'back'>;
args: { collected: Collected; completed: string[] }; args: { collected: Collected; completed: string[] };
}): Promise<void> { }): Promise<'continue' | 'back'> {
while (true) { while (true) {
const choice = ensureAnswer( const choice = ensureAnswer(
await brightSelect({ await brightSelect({
@@ -634,10 +657,12 @@ async function stepGate(args: {
{ value: 'done', label: "Done — let's continue" }, { value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' }, { value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' }, { value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
], ],
}), }),
); );
if (choice === 'done') return; if (choice === 'done') return 'continue';
if (choice === 'back') return 'back';
if (choice === 'help') { if (choice === 'help') {
await offerHandoff({ await offerHandoff({
step: args.stepName, step: args.stepName,
@@ -647,8 +672,7 @@ async function stepGate(args: {
continue; continue;
} }
if (choice === 'reshow') { if (choice === 'reshow') {
await args.reshow(); return args.reshow();
return;
} }
} }
} }