Merge pull request #1927 from qwibitai/setup-feedback-fixes
Clarify setup flow from user-feedback session
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clack/core": "^1.2.0",
|
||||||
"@clack/prompts": "^1.2.0",
|
"@clack/prompts": "^1.2.0",
|
||||||
"@onecli-sh/sdk": "^0.3.1",
|
"@onecli-sh/sdk": "^0.3.1",
|
||||||
"better-sqlite3": "11.10.0",
|
"better-sqlite3": "11.10.0",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@clack/core':
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0
|
||||||
'@clack/prompts':
|
'@clack/prompts':
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
|||||||
156
setup/auto.ts
156
setup/auto.ts
@@ -31,7 +31,9 @@ import { runTeamsChannel } from './channels/teams.js';
|
|||||||
import { runTelegramChannel } from './channels/telegram.js';
|
import { runTelegramChannel } from './channels/telegram.js';
|
||||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
|
import { brightSelect } from './lib/bright-select.js';
|
||||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||||
|
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||||
import {
|
import {
|
||||||
claudeCliAvailable,
|
claudeCliAvailable,
|
||||||
@@ -79,7 +81,13 @@ async function main(): Promise<void> {
|
|||||||
4,
|
4,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const res = await runQuietStep('container', {
|
p.log.message(
|
||||||
|
dimWrap(
|
||||||
|
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const res = await runWindowedStep('container', {
|
||||||
running: "Preparing your assistant's sandbox…",
|
running: "Preparing your assistant's sandbox…",
|
||||||
done: 'Sandbox ready.',
|
done: 'Sandbox ready.',
|
||||||
failed: "Couldn't prepare the sandbox.",
|
failed: "Couldn't prepare the sandbox.",
|
||||||
@@ -124,7 +132,7 @@ async function main(): Promise<void> {
|
|||||||
let reuse = false;
|
let reuse = false;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
@@ -266,15 +274,17 @@ async function main(): Promise<void> {
|
|||||||
await runTimezoneStep();
|
await runTimezoneStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' =
|
||||||
|
'skip';
|
||||||
if (!skip.has('channel')) {
|
if (!skip.has('channel')) {
|
||||||
const choice = await askChannelChoice();
|
channelChoice = await askChannelChoice();
|
||||||
if (choice === 'telegram') {
|
if (channelChoice === 'telegram') {
|
||||||
await runTelegramChannel(displayName!);
|
await runTelegramChannel(displayName!);
|
||||||
} else if (choice === 'discord') {
|
} else if (channelChoice === 'discord') {
|
||||||
await runDiscordChannel(displayName!);
|
await runDiscordChannel(displayName!);
|
||||||
} else if (choice === 'whatsapp') {
|
} else if (channelChoice === 'whatsapp') {
|
||||||
await runWhatsAppChannel(displayName!);
|
await runWhatsAppChannel(displayName!);
|
||||||
} else if (choice === 'teams') {
|
} else if (channelChoice === 'teams') {
|
||||||
await runTeamsChannel(displayName!);
|
await runTeamsChannel(displayName!);
|
||||||
} else {
|
} else {
|
||||||
p.log.info(
|
p.log.info(
|
||||||
@@ -359,9 +369,51 @@ async function main(): Promise<void> {
|
|||||||
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
p.note(nextSteps, 'Try these');
|
p.note(nextSteps, 'Try these');
|
||||||
|
|
||||||
|
// Always-on warning goes before the "check your DMs" directive so the
|
||||||
|
// caveat doesn't land after the user's already looked away at their phone.
|
||||||
|
p.note(
|
||||||
|
wrapForGutter(
|
||||||
|
"NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.",
|
||||||
|
6,
|
||||||
|
),
|
||||||
|
'Heads up',
|
||||||
|
);
|
||||||
|
|
||||||
setupLog.complete(Date.now() - RUN_START);
|
setupLog.complete(Date.now() - RUN_START);
|
||||||
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
|
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
|
||||||
p.outro(k.green("You're ready! Enjoy NanoClaw."));
|
|
||||||
|
const dmTarget = channelDmLabel(channelChoice);
|
||||||
|
if (dmTarget) {
|
||||||
|
// Bright framed banner (not dim) — the whole point of the feedback was
|
||||||
|
// that the welcome-message signal was too easy to miss. Use p.note so it
|
||||||
|
// renders with a visible box, cyan-bold the directive line, and put it
|
||||||
|
// as the last thing before outro.
|
||||||
|
p.note(
|
||||||
|
`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`,
|
||||||
|
'Go say hi',
|
||||||
|
);
|
||||||
|
p.outro(k.green("You're set."));
|
||||||
|
} else {
|
||||||
|
p.outro(k.green("You're ready! Chat with `pnpm run chat hi`."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelDmLabel(
|
||||||
|
choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip',
|
||||||
|
): string | null {
|
||||||
|
switch (choice) {
|
||||||
|
case 'telegram':
|
||||||
|
return 'Telegram';
|
||||||
|
case 'discord':
|
||||||
|
return 'Discord DMs';
|
||||||
|
case 'whatsapp':
|
||||||
|
return 'WhatsApp';
|
||||||
|
case 'teams':
|
||||||
|
return 'Teams';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── first-chat step ───────────────────────────────────────────────────
|
// ─── first-chat step ───────────────────────────────────────────────────
|
||||||
@@ -424,15 +476,39 @@ function renderPingFailureNote(result: PingResult): void {
|
|||||||
* Chat loop. Each message is piped through `pnpm run chat`, which uses
|
* Chat loop. Each message is piped through `pnpm run chat`, which uses
|
||||||
* the same Unix-socket path the ping just exercised, so output streams
|
* the same Unix-socket path the ping just exercised, so output streams
|
||||||
* back inline as the agent replies. An empty input ends the loop.
|
* back inline as the agent replies. An empty input ends the loop.
|
||||||
|
*
|
||||||
|
* The intro note teaches the sandbox mental model — users reported being
|
||||||
|
* confused about what the terminal chat *is* (vs the phone channel they'd
|
||||||
|
* set up next) and what happens to the agent when they walk away. We
|
||||||
|
* explain once, then offer "message or Enter to continue" so the chat is
|
||||||
|
* clearly optional.
|
||||||
*/
|
*/
|
||||||
async function runFirstChat(): Promise<void> {
|
async function runFirstChat(): Promise<void> {
|
||||||
|
p.note(
|
||||||
|
wrapForGutter(
|
||||||
|
[
|
||||||
|
'Your assistant runs in a sandbox on this machine.',
|
||||||
|
'It wakes up when you send a message and goes back to sleep when',
|
||||||
|
"you're not talking — so it isn't burning resources in the background.",
|
||||||
|
'Its memory and environment persist between conversations.',
|
||||||
|
].join(' '),
|
||||||
|
6,
|
||||||
|
),
|
||||||
|
'How this works',
|
||||||
|
);
|
||||||
|
let first = true;
|
||||||
while (true) {
|
while (true) {
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.text({
|
await p.text({
|
||||||
message: 'Say something to your assistant',
|
message: first
|
||||||
placeholder: 'press Enter with nothing to continue',
|
? 'Try a quick hello — or press Enter to continue setup'
|
||||||
|
: 'Another message? Press Enter to continue setup',
|
||||||
|
placeholder: first
|
||||||
|
? 'e.g. "hi, what can you do?"'
|
||||||
|
: 'press Enter to continue',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
first = false;
|
||||||
const text = ((answer as string | undefined) ?? '').trim();
|
const text = ((answer as string | undefined) ?? '').trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
await sendChatMessage(text);
|
await sendChatMessage(text);
|
||||||
@@ -465,7 +541,7 @@ async function runAuthStep(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = ensureAnswer(
|
const method = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: 'How would you like to connect to Claude?',
|
message: 'How would you like to connect to Claude?',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
@@ -593,31 +669,49 @@ async function runTimezoneStep(): Promise<void> {
|
|||||||
resolvedTz === 'Etc/UTC' ||
|
resolvedTz === 'Etc/UTC' ||
|
||||||
resolvedTz === 'Universal';
|
resolvedTz === 'Universal';
|
||||||
|
|
||||||
|
// Three branches:
|
||||||
|
// - no TZ detected: ask where they are (or leave as UTC)
|
||||||
|
// - detected UTC: confirm (likely VPS, but worth checking)
|
||||||
|
// - detected specific zone: confirm explicitly rather than silently
|
||||||
|
// persisting — users shouldn't be surprised the agent "already knew"
|
||||||
|
// their timezone from system settings they didn't think about.
|
||||||
if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') {
|
if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') {
|
||||||
return;
|
const confirmed = ensureAnswer(
|
||||||
|
await p.confirm({
|
||||||
|
message: `I detected ${resolvedTz} from your computer settings. Is that right?`,
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setupLog.userInput('timezone_confirm_detected', String(confirmed));
|
||||||
|
if (confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Either autodetect failed outright, or it landed on UTC and we should
|
|
||||||
// check that's really what the user wants before leaving it there.
|
|
||||||
const message = needsInput
|
const message = needsInput
|
||||||
? "Your system didn't expose a timezone. Which one are you in?"
|
? "Your system didn't expose a timezone. Which one are you in?"
|
||||||
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
: !isUtc
|
||||||
|
? "Where are you, then?"
|
||||||
|
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
||||||
|
|
||||||
const choice = ensureAnswer(
|
// For the non-UTC "detected-but-wrong" branch we skip the select and jump
|
||||||
await p.select({
|
// straight to the free-text prompt — the user already said "not that".
|
||||||
message,
|
let choice: 'keep' | 'answer' = 'answer';
|
||||||
options: needsInput
|
if (needsInput || isUtc) {
|
||||||
? [
|
choice = ensureAnswer(
|
||||||
{ value: 'answer', label: "I'll tell you where I am" },
|
await brightSelect({
|
||||||
{ value: 'keep', label: 'Leave it as UTC' },
|
message,
|
||||||
]
|
options: needsInput
|
||||||
: [
|
? [
|
||||||
{ value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' },
|
{ value: 'answer', label: "I'll tell you where I am" },
|
||||||
{ value: 'answer', label: "I'm somewhere else" },
|
{ value: 'keep', label: 'Leave it as UTC' },
|
||||||
],
|
]
|
||||||
}),
|
: [
|
||||||
) as 'keep' | 'answer';
|
{ value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' },
|
||||||
setupLog.userInput('timezone_choice', choice);
|
{ value: 'answer', label: "I'm somewhere else" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
) as 'keep' | 'answer';
|
||||||
|
setupLog.userInput('timezone_choice', choice);
|
||||||
|
}
|
||||||
|
|
||||||
if (choice === 'keep') return;
|
if (choice === 'keep') return;
|
||||||
|
|
||||||
@@ -696,7 +790,7 @@ async function askChannelChoice(): Promise<
|
|||||||
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
|
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
|
||||||
> {
|
> {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: 'Want to chat with your assistant from your phone?',
|
message: 'Want to chat with your assistant from your phone?',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
@@ -46,9 +47,14 @@ interface AppInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runDiscordChannel(displayName: string): Promise<void> {
|
export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||||
if (!(await askHasBotToken())) {
|
const hasBot = await askHasBotToken();
|
||||||
|
if (!hasBot) {
|
||||||
await walkThroughBotCreation();
|
await walkThroughBotCreation();
|
||||||
}
|
}
|
||||||
|
// Even users who said "yes" often can't find the token on demand — the
|
||||||
|
// Dev Portal resets it if you don't store it, and people forget which
|
||||||
|
// app it belongs to. A quick reminder before the paste prompt is cheap.
|
||||||
|
showTokenLocationReminder(hasBot);
|
||||||
|
|
||||||
const token = await collectDiscordToken();
|
const token = await collectDiscordToken();
|
||||||
const botUsername = await validateDiscordToken(token);
|
const botUsername = await validateDiscordToken(token);
|
||||||
@@ -56,6 +62,13 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
|||||||
|
|
||||||
const ownerUserId = await resolveOwnerUserId(app.owner);
|
const ownerUserId = await resolveOwnerUserId(app.owner);
|
||||||
|
|
||||||
|
// Before inviting: do they have a server to invite into? Walkthrough if
|
||||||
|
// not — a fresh Discord account without a server makes the invite page a
|
||||||
|
// dead end.
|
||||||
|
if (!(await askHasDiscordServer())) {
|
||||||
|
await walkThroughServerCreation();
|
||||||
|
}
|
||||||
|
|
||||||
await promptInviteBot(app.applicationId, botUsername);
|
await promptInviteBot(app.applicationId, botUsername);
|
||||||
|
|
||||||
const install = await runQuietChild(
|
const install = await runQuietChild(
|
||||||
@@ -129,7 +142,7 @@ export async function runDiscordChannel(displayName: string): Promise<void> {
|
|||||||
|
|
||||||
async function askHasBotToken(): Promise<boolean> {
|
async function askHasBotToken(): Promise<boolean> {
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: 'Do you already have a Discord bot?',
|
message: 'Do you already have a Discord bot?',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
||||||
@@ -165,6 +178,66 @@ async function walkThroughBotCreation(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showTokenLocationReminder(hasExistingBot: boolean): void {
|
||||||
|
// If we just walked them through creating a bot, they're staring at the
|
||||||
|
// token. If they came in with an existing one, they may still need a nudge
|
||||||
|
// to find it — tokens in the Dev Portal aren't visible after first reveal,
|
||||||
|
// and "Reset Token" issues a new one.
|
||||||
|
if (hasExistingBot) {
|
||||||
|
p.note(
|
||||||
|
[
|
||||||
|
"Where to find your bot token:",
|
||||||
|
'',
|
||||||
|
' 1. discord.com/developers/applications → pick your app',
|
||||||
|
' 2. "Bot" tab → "Reset Token" (the old one stops working)',
|
||||||
|
' 3. Copy the new token',
|
||||||
|
].join('\n'),
|
||||||
|
'Reminder',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askHasDiscordServer(): Promise<boolean> {
|
||||||
|
const answer = ensureAnswer(
|
||||||
|
await brightSelect({
|
||||||
|
message: 'Do you have a Discord server you can add the bot to?',
|
||||||
|
options: [
|
||||||
|
{ value: 'yes', label: 'Yes, I have a server' },
|
||||||
|
{ value: 'no', label: "No, walk me through creating one" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setupLog.userInput('discord_has_server', String(answer));
|
||||||
|
return answer === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkThroughServerCreation(): Promise<void> {
|
||||||
|
// Discord doesn't have a stable deep-link for "create server" so we open
|
||||||
|
// the web client and rely on the + button being visible. The steps below
|
||||||
|
// are the same whether they're in the desktop app or the browser.
|
||||||
|
const url = 'https://discord.com/channels/@me';
|
||||||
|
p.note(
|
||||||
|
[
|
||||||
|
"A Discord server is just a private space for you and the bot. Free and takes 30 seconds.",
|
||||||
|
'',
|
||||||
|
' 1. In Discord, click the "+" at the bottom of the server list',
|
||||||
|
' 2. Choose "Create My Own" → "For me and my friends"',
|
||||||
|
' 3. Give it any name (e.g. "NanoClaw")',
|
||||||
|
'',
|
||||||
|
k.dim(url),
|
||||||
|
].join('\n'),
|
||||||
|
'Create a Discord server',
|
||||||
|
);
|
||||||
|
await confirmThenOpen(url, 'Press Enter to open Discord');
|
||||||
|
|
||||||
|
ensureAnswer(
|
||||||
|
await p.confirm({
|
||||||
|
message: "Server created?",
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function collectDiscordToken(): Promise<string> {
|
async function collectDiscordToken(): Promise<string> {
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.password({
|
await p.password({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import path from 'path';
|
|||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { confirmThenOpen } from '../lib/browser.js';
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
import {
|
import {
|
||||||
isHelpEscape,
|
isHelpEscape,
|
||||||
@@ -223,7 +224,7 @@ async function askAppType(args: {
|
|||||||
}): Promise<'SingleTenant' | 'MultiTenant'> {
|
}): Promise<'SingleTenant' | 'MultiTenant'> {
|
||||||
while (true) {
|
while (true) {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: 'Which account type did you pick?',
|
message: 'Which account type did you pick?',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
@@ -515,7 +516,7 @@ async function finishWithHandoff(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: 'Ready to finish?',
|
message: 'Ready to finish?',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
@@ -571,7 +572,7 @@ async function stepGate(args: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
while (true) {
|
while (true) {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: 'How did that go?',
|
message: 'How did that go?',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'done', label: "Done — let's continue" },
|
{ value: 'done', label: "Done — let's continue" },
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
|
import { brightSelect } from '../lib/bright-select.js';
|
||||||
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js';
|
||||||
import {
|
import {
|
||||||
type Block,
|
type Block,
|
||||||
@@ -149,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
|||||||
|
|
||||||
async function askAuthMethod(): Promise<AuthMethod> {
|
async function askAuthMethod(): Promise<AuthMethod> {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect({
|
||||||
message: 'How would you like to authenticate with WhatsApp?',
|
message: 'How would you like to authenticate with WhatsApp?',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -175,19 +175,31 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
// .env is optional; absence is normal on a fresh checkout
|
// .env is optional; absence is normal on a fresh checkout
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build
|
// Build — stdio inherit so the parent setup runner can tail docker's
|
||||||
|
// per-step output and render it in a rolling window. Previously we used
|
||||||
|
// execSync which buffered everything; users couldn't tell whether a
|
||||||
|
// 3–10 minute build was making progress or hung.
|
||||||
let buildOk = false;
|
let buildOk = false;
|
||||||
log.info('Building container', { runtime, buildArgs });
|
log.info('Building container', { runtime, buildArgs });
|
||||||
try {
|
const buildRes = spawnSync(
|
||||||
const argsStr = buildArgs.length > 0 ? ' ' + buildArgs.join(' ') : '';
|
buildCmd.split(' ')[0],
|
||||||
execSync(`${buildCmd}${argsStr} -t ${image} .`, {
|
[
|
||||||
|
...buildCmd.split(' ').slice(1),
|
||||||
|
...buildArgs.flatMap((a) => a.split(' ')),
|
||||||
|
'-t',
|
||||||
|
image,
|
||||||
|
'.',
|
||||||
|
],
|
||||||
|
{
|
||||||
cwd: path.join(projectRoot, 'container'),
|
cwd: path.join(projectRoot, 'container'),
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: 'inherit',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
if (buildRes.status === 0) {
|
||||||
buildOk = true;
|
buildOk = true;
|
||||||
log.info('Container build succeeded');
|
log.info('Container build succeeded');
|
||||||
} catch (err) {
|
} else {
|
||||||
log.error('Container build failed', { err });
|
log.error('Container build failed', { exitCode: buildRes.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test
|
// Test
|
||||||
|
|||||||
119
setup/lib/bright-select.ts
Normal file
119
setup/lib/bright-select.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* A drop-in alternative to `@clack/prompts`' `p.select` that renders
|
||||||
|
* unselected option labels at full brightness instead of dim gray.
|
||||||
|
*
|
||||||
|
* Why this exists: clack styles inactive options with `styleText("dim", …)`
|
||||||
|
* inline in its render function. There is no configuration hook to override
|
||||||
|
* it, and the feedback was clear — non-selected options in the setup flow
|
||||||
|
* were "too light, need stronger font weight". So we write our own render
|
||||||
|
* against `@clack/core`'s `SelectPrompt`, keeping the visual shell of clack
|
||||||
|
* (diamond header, `│` gutter, cyan in-progress / green on submit) but
|
||||||
|
* leaving the label un-dimmed. Only the bullet and hint stay dim, which
|
||||||
|
* gives enough contrast for the cursor to read as "active".
|
||||||
|
*
|
||||||
|
* Not a full clack-feature clone: no search, no maxItems paging, no custom
|
||||||
|
* bar characters. Just the bits the NanoClaw setup menus actually use.
|
||||||
|
*/
|
||||||
|
import { SelectPrompt } from '@clack/core';
|
||||||
|
import { isCancel } from '@clack/prompts';
|
||||||
|
import { styleText } from 'node:util';
|
||||||
|
|
||||||
|
const BULLET_ACTIVE = '●';
|
||||||
|
const BULLET_INACTIVE = '○';
|
||||||
|
const BAR = '│';
|
||||||
|
const CAP_BOT = '└';
|
||||||
|
const DIAMOND = '◆';
|
||||||
|
const DIAMOND_CANCEL = '■';
|
||||||
|
const DIAMOND_SUBMIT = '◇';
|
||||||
|
|
||||||
|
type PromptState = 'initial' | 'active' | 'error' | 'cancel' | 'submit';
|
||||||
|
|
||||||
|
function stateColor(state: PromptState): 'cyan' | 'green' | 'red' | 'yellow' {
|
||||||
|
switch (state) {
|
||||||
|
case 'submit':
|
||||||
|
return 'green';
|
||||||
|
case 'cancel':
|
||||||
|
return 'red';
|
||||||
|
case 'error':
|
||||||
|
return 'yellow';
|
||||||
|
default:
|
||||||
|
return 'cyan';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerIcon(state: PromptState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'submit':
|
||||||
|
return styleText('green', DIAMOND_SUBMIT);
|
||||||
|
case 'cancel':
|
||||||
|
return styleText('red', DIAMOND_CANCEL);
|
||||||
|
default:
|
||||||
|
return styleText('cyan', DIAMOND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrightSelectOption<T> {
|
||||||
|
value: T;
|
||||||
|
label?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrightSelectOptions<T> {
|
||||||
|
message: string;
|
||||||
|
options: BrightSelectOption<T>[];
|
||||||
|
initialValue?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the return shape of `p.select` — resolves to the selected value
|
||||||
|
* on submit, or to clack's cancel symbol on Ctrl-C / Esc. Callers pass
|
||||||
|
* the result through `ensureAnswer(...)` the same way they do for
|
||||||
|
* `p.select`.
|
||||||
|
*/
|
||||||
|
export function brightSelect<T>(
|
||||||
|
opts: BrightSelectOptions<T>,
|
||||||
|
): Promise<T | symbol> {
|
||||||
|
const { message, options, initialValue } = opts;
|
||||||
|
|
||||||
|
return new SelectPrompt({
|
||||||
|
options: options as Array<{ value: T; label?: string; hint?: string }>,
|
||||||
|
initialValue,
|
||||||
|
render() {
|
||||||
|
const st = this.state as PromptState;
|
||||||
|
const color = stateColor(st);
|
||||||
|
const bar = styleText(color, BAR);
|
||||||
|
const grayBar = styleText('gray', BAR);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(grayBar);
|
||||||
|
lines.push(`${headerIcon(st)} ${message}`);
|
||||||
|
|
||||||
|
if (st === 'submit' || st === 'cancel') {
|
||||||
|
const selected =
|
||||||
|
options.find((o) => o.value === this.value)?.label ??
|
||||||
|
String(this.value ?? '');
|
||||||
|
const shown =
|
||||||
|
st === 'cancel'
|
||||||
|
? styleText(['strikethrough', 'dim'], selected)
|
||||||
|
: styleText('dim', selected);
|
||||||
|
lines.push(`${grayBar} ${shown}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = (this as unknown as { cursor: number }).cursor;
|
||||||
|
options.forEach((opt, idx) => {
|
||||||
|
const label = opt.label ?? String(opt.value);
|
||||||
|
const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : '';
|
||||||
|
const marker =
|
||||||
|
idx === cursor
|
||||||
|
? styleText('green', BULLET_ACTIVE)
|
||||||
|
: styleText('dim', BULLET_INACTIVE);
|
||||||
|
lines.push(`${bar} ${marker} ${label}${hint}`);
|
||||||
|
});
|
||||||
|
lines.push(styleText(color, CAP_BOT));
|
||||||
|
return lines.join('\n');
|
||||||
|
},
|
||||||
|
}).prompt() as Promise<T | symbol>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isCancel };
|
||||||
@@ -8,8 +8,7 @@
|
|||||||
* surfaces admin/member for the edge cases (shared instance, collaborators
|
* surfaces admin/member for the edge cases (shared instance, collaborators
|
||||||
* with limited access), but hitting Enter assigns owner.
|
* with limited access), but hitting Enter assigns owner.
|
||||||
*/
|
*/
|
||||||
import * as p from '@clack/prompts';
|
import { brightSelect } from './bright-select.js';
|
||||||
|
|
||||||
import { ensureAnswer } from './runner.js';
|
import { ensureAnswer } from './runner.js';
|
||||||
|
|
||||||
export type OperatorRole = 'owner' | 'admin' | 'member';
|
export type OperatorRole = 'owner' | 'admin' | 'member';
|
||||||
@@ -18,7 +17,7 @@ export async function askOperatorRole(
|
|||||||
channelLabel: string,
|
channelLabel: string,
|
||||||
): Promise<OperatorRole> {
|
): Promise<OperatorRole> {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await brightSelect<OperatorRole>({
|
||||||
message: `How should this ${channelLabel} account be registered?`,
|
message: `How should this ${channelLabel} account be registered?`,
|
||||||
initialValue: 'owner',
|
initialValue: 'owner',
|
||||||
options: [
|
options: [
|
||||||
@@ -39,6 +38,6 @@ export async function askOperatorRole(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
) as OperatorRole;
|
);
|
||||||
return choice;
|
return choice;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,12 +102,19 @@ export class StatusStream {
|
|||||||
* raw log file (level 3) and parsed for status blocks (level 2 summary).
|
* raw log file (level 3) and parsed for status blocks (level 2 summary).
|
||||||
* The onBlock callback fires per status block as they close so the UI can
|
* The onBlock callback fires per status block as they close so the UI can
|
||||||
* react mid-stream.
|
* react mid-stream.
|
||||||
|
*
|
||||||
|
* `onLine`, if provided, fires for every line from stdout + stderr (minus
|
||||||
|
* status-block control lines) so callers can render a rolling tail. Status
|
||||||
|
* block lines are still parsed by the `StatusStream` — they're just
|
||||||
|
* excluded from the line feed so they don't fill the user-facing window
|
||||||
|
* with `=== NANOCLAW SETUP: …` noise.
|
||||||
*/
|
*/
|
||||||
export function spawnStep(
|
export function spawnStep(
|
||||||
stepName: string,
|
stepName: string,
|
||||||
extra: string[],
|
extra: string[],
|
||||||
onBlock: (block: Block) => void,
|
onBlock: (block: Block) => void,
|
||||||
rawLogPath: string,
|
rawLogPath: string,
|
||||||
|
onLine?: (line: string) => void,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
|
const args = ['exec', 'tsx', 'setup/index.ts', '--step', stepName];
|
||||||
@@ -118,13 +125,34 @@ export function spawnStep(
|
|||||||
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
|
const raw = fs.createWriteStream(rawLogPath, { flags: 'w' });
|
||||||
raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`);
|
raw.write(`# ${stepName} — ${new Date().toISOString()}\n\n`);
|
||||||
|
|
||||||
|
// Per-line forwarder for the optional onLine callback. We keep our own
|
||||||
|
// buffer (separate from StatusStream's) so the parser still gets raw
|
||||||
|
// chunks and isn't forced through a line-by-line path it doesn't need.
|
||||||
|
let lineBuf = '';
|
||||||
|
const pushLines = (chunk: string): void => {
|
||||||
|
if (!onLine) return;
|
||||||
|
lineBuf += chunk;
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = lineBuf.indexOf('\n')) !== -1) {
|
||||||
|
const line = lineBuf.slice(0, idx).replace(/\r/g, '');
|
||||||
|
lineBuf = lineBuf.slice(idx + 1);
|
||||||
|
if (line.startsWith('=== NANOCLAW SETUP:')) continue;
|
||||||
|
if (line.startsWith('=== END ===')) continue;
|
||||||
|
if (line.trim()) onLine(line);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
child.stdout.on('data', (chunk: Buffer) => {
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
stream.write(chunk.toString('utf-8'));
|
const s = chunk.toString('utf-8');
|
||||||
|
stream.write(s);
|
||||||
raw.write(chunk);
|
raw.write(chunk);
|
||||||
|
pushLines(s);
|
||||||
});
|
});
|
||||||
child.stderr.on('data', (chunk: Buffer) => {
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
stream.transcript += chunk.toString('utf-8');
|
const s = chunk.toString('utf-8');
|
||||||
|
stream.transcript += s;
|
||||||
raw.write(chunk);
|
raw.write(chunk);
|
||||||
|
pushLines(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
|
|||||||
229
setup/lib/windowed-runner.ts
Normal file
229
setup/lib/windowed-runner.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Windowed step runner: shows a fixed-height rolling tail of a long step's
|
||||||
|
* output so the user can see it's making progress, plus a stall detector
|
||||||
|
* that interrupts with a "keep waiting or ask for help?" prompt when the
|
||||||
|
* output stream goes silent for too long.
|
||||||
|
*
|
||||||
|
* Used for the container build (3–10 minutes on a fresh machine, no user
|
||||||
|
* feedback with a plain spinner). Models the UI on claude-assist.ts's
|
||||||
|
* 3-line action window — a single-line spinner header sitting above three
|
||||||
|
* gutter-prefixed lines of the most recent output, redrawn in place via
|
||||||
|
* ANSI cursor controls.
|
||||||
|
*
|
||||||
|
* Stall detection: a silence timer resets on every new line. When it hits
|
||||||
|
* STALL_THRESHOLD_MS we pause the render, show `offerClaudeAssist` with
|
||||||
|
* the step's raw log, and either resume (user said "keep waiting") or
|
||||||
|
* let the step run its course while giving them the exit path.
|
||||||
|
*/
|
||||||
|
import * as p from '@clack/prompts';
|
||||||
|
import k from 'kleur';
|
||||||
|
|
||||||
|
import { offerClaudeAssist } from './claude-assist.js';
|
||||||
|
import { emit as phEmit } from './diagnostics.js';
|
||||||
|
import type { StepResult, SpinnerLabels } from './runner.js';
|
||||||
|
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
|
||||||
|
import * as setupLog from '../logs.js';
|
||||||
|
import { fitToWidth } from './theme.js';
|
||||||
|
|
||||||
|
const WINDOW_SIZE = 3;
|
||||||
|
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
|
||||||
|
const HIDE_CURSOR = '\x1b[?25l';
|
||||||
|
const SHOW_CURSOR = '\x1b[?25h';
|
||||||
|
const STALL_THRESHOLD_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a step with a 3-line rolling tail + stall detector. Same signature
|
||||||
|
* shape as `runQuietStep` (so auto.ts can swap them), but tails the
|
||||||
|
* child's stdout/stderr into a fixed-height window.
|
||||||
|
*/
|
||||||
|
export async function runWindowedStep(
|
||||||
|
stepName: string,
|
||||||
|
labels: SpinnerLabels,
|
||||||
|
extra: string[] = [],
|
||||||
|
): Promise<StepResult & { rawLog: string; durationMs: number }> {
|
||||||
|
const rawLog = setupLog.stepRawLog(stepName);
|
||||||
|
const start = Date.now();
|
||||||
|
phEmit('step_started', { step: stepName });
|
||||||
|
|
||||||
|
const result = await runUnderWindow(stepName, labels, extra, rawLog);
|
||||||
|
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
writeStepEntry(stepName, result, durationMs, rawLog);
|
||||||
|
phEmit('step_completed', {
|
||||||
|
step: stepName,
|
||||||
|
status: outcomeStatus(result),
|
||||||
|
duration_ms: durationMs,
|
||||||
|
});
|
||||||
|
return { ...result, rawLog, durationMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeStatus(result: StepResult): 'success' | 'skipped' | 'failed' {
|
||||||
|
const rawStatus = result.terminal?.fields.STATUS;
|
||||||
|
if (!result.ok) return 'failed';
|
||||||
|
return rawStatus === 'skipped' ? 'skipped' : 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core render + spawn loop. Kept separate from `runWindowedStep` so
|
||||||
|
* the logging bookkeeping (writeStepEntry, phEmit) lives with the
|
||||||
|
* public-facing wrapper and this function stays focused on terminal IO.
|
||||||
|
*/
|
||||||
|
async function runUnderWindow(
|
||||||
|
stepName: string,
|
||||||
|
labels: SpinnerLabels,
|
||||||
|
extra: string[],
|
||||||
|
rawLog: string,
|
||||||
|
): Promise<StepResult> {
|
||||||
|
const out = process.stdout;
|
||||||
|
const start = Date.now();
|
||||||
|
const actions: string[] = [];
|
||||||
|
let frameIdx = 0;
|
||||||
|
let lastLineAt = Date.now();
|
||||||
|
let stallPromptActive = false;
|
||||||
|
let handledStall = false;
|
||||||
|
|
||||||
|
const redraw = (): void => {
|
||||||
|
if (stallPromptActive) return;
|
||||||
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||||
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||||
|
const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length];
|
||||||
|
const suffix = ` (${elapsed}s)`;
|
||||||
|
const header = fitToWidth(labels.running, suffix);
|
||||||
|
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
|
||||||
|
|
||||||
|
for (let i = 0; i < WINDOW_SIZE; i++) {
|
||||||
|
const idx = actions.length - WINDOW_SIZE + i;
|
||||||
|
const action = idx >= 0 ? actions[idx] : '';
|
||||||
|
out.write('\x1b[2K');
|
||||||
|
if (action) {
|
||||||
|
out.write(`${k.gray('│')} ${k.dim(fitToWidth(action, ''))}`);
|
||||||
|
} else {
|
||||||
|
out.write(k.gray('│'));
|
||||||
|
}
|
||||||
|
out.write('\n');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBlock = (): void => {
|
||||||
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||||
|
for (let i = 0; i < WINDOW_SIZE + 1; i++) {
|
||||||
|
out.write('\x1b[2K\n');
|
||||||
|
}
|
||||||
|
out.write(`\x1b[${WINDOW_SIZE + 1}A`);
|
||||||
|
};
|
||||||
|
|
||||||
|
out.write(HIDE_CURSOR);
|
||||||
|
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
|
||||||
|
redraw();
|
||||||
|
|
||||||
|
const restoreCursorOnExit = (): void => {
|
||||||
|
out.write(SHOW_CURSOR);
|
||||||
|
};
|
||||||
|
process.once('exit', restoreCursorOnExit);
|
||||||
|
|
||||||
|
const frameTick = setInterval(() => {
|
||||||
|
frameIdx++;
|
||||||
|
redraw();
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
const stallCheck = setInterval(() => {
|
||||||
|
if (handledStall || stallPromptActive) return;
|
||||||
|
if (Date.now() - lastLineAt < STALL_THRESHOLD_MS) return;
|
||||||
|
handledStall = true;
|
||||||
|
void handleStall(stepName, rawLog, {
|
||||||
|
pauseRender: () => {
|
||||||
|
stallPromptActive = true;
|
||||||
|
clearBlock();
|
||||||
|
out.write(SHOW_CURSOR);
|
||||||
|
},
|
||||||
|
resumeRender: () => {
|
||||||
|
out.write(HIDE_CURSOR);
|
||||||
|
for (let i = 0; i < WINDOW_SIZE + 1; i++) out.write('\n');
|
||||||
|
stallPromptActive = false;
|
||||||
|
lastLineAt = Date.now();
|
||||||
|
redraw();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
|
const onLine = (line: string): void => {
|
||||||
|
lastLineAt = Date.now();
|
||||||
|
// Strip ANSI escape sequences — Docker Buildx writes color codes that
|
||||||
|
// mangle the rolling window layout when replayed in a narrow cell.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim();
|
||||||
|
if (clean) actions.push(clean);
|
||||||
|
redraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await spawnStep(stepName, extra, () => {}, rawLog, onLine);
|
||||||
|
|
||||||
|
clearInterval(frameTick);
|
||||||
|
clearInterval(stallCheck);
|
||||||
|
clearBlock();
|
||||||
|
out.write(SHOW_CURSOR);
|
||||||
|
process.off('exit', restoreCursorOnExit);
|
||||||
|
|
||||||
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||||
|
const suffix = ` (${elapsed}s)`;
|
||||||
|
if (result.ok) {
|
||||||
|
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
|
||||||
|
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
|
||||||
|
p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`);
|
||||||
|
} else {
|
||||||
|
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
|
||||||
|
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
|
||||||
|
dumpTranscriptOnFailure(result.transcript);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStall(
|
||||||
|
stepName: string,
|
||||||
|
rawLog: string,
|
||||||
|
render: { pauseRender: () => void; resumeRender: () => void },
|
||||||
|
): Promise<void> {
|
||||||
|
render.pauseRender();
|
||||||
|
p.log.warn(
|
||||||
|
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
|
||||||
|
);
|
||||||
|
phEmit('step_stalled', { step: stepName });
|
||||||
|
|
||||||
|
const { ensureAnswer } = await import('./runner.js');
|
||||||
|
const { brightSelect } = await import('./bright-select.js');
|
||||||
|
|
||||||
|
const choice = ensureAnswer(
|
||||||
|
await brightSelect<'wait' | 'help'>({
|
||||||
|
message: "What now?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: 'wait',
|
||||||
|
label: "Keep waiting",
|
||||||
|
hint: "large images can take 5–10 minutes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'help',
|
||||||
|
label: 'Ask Claude to take a look',
|
||||||
|
hint: 'reads the raw build log and suggests a fix',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (choice === 'help') {
|
||||||
|
// offerClaudeAssist runs its own spinner and may propose a fix command.
|
||||||
|
// We don't attempt to restart the stalled build from here — if Claude
|
||||||
|
// proposes a command the user accepts, they can retry setup afterwards.
|
||||||
|
await offerClaudeAssist({
|
||||||
|
stepName,
|
||||||
|
msg: `The ${stepName} step has produced no output for 60 seconds.`,
|
||||||
|
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',
|
||||||
|
rawLogPath: rawLog,
|
||||||
|
});
|
||||||
|
// Keep the spinner going — the underlying process is still running,
|
||||||
|
// and cancelling it here would race with Claude's investigation. The
|
||||||
|
// user can Ctrl-C if they want to bail.
|
||||||
|
}
|
||||||
|
|
||||||
|
render.resumeRender();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user