Merge remote-tracking branch 'origin/main' into setup-root-warning-v2

# Conflicts:
#	setup/auto.ts
This commit is contained in:
gavrielc
2026-04-30 23:07:38 +03:00
35 changed files with 950 additions and 369 deletions

View File

@@ -53,7 +53,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau
import * as setupLog from './logs.js';
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
import { emit as phEmit } from './lib/diagnostics.js';
import { brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js';
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
import { isValidTimezone } from '../src/timezone.js';
const CLI_AGENT_NAME = 'Terminal Agent';
@@ -126,51 +126,14 @@ async function main(): Promise<void> {
}
}
// Detect existing .env and offer to reuse it so the user doesn't have to
// paste credentials again on a re-run.
const existingEnv = detectExistingEnv();
if (existingEnv) {
// On sg re-exec, auto-reuse — the user already decided in the first run.
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
let reuseChoice: 'reuse' | 'fresh' = 'reuse';
if (!isReexec) {
const lines = Object.values(existingEnv.groups).map(
(g) => ` ${k.green('✓')} ${g.label}`,
);
note(lines.join('\n'), 'Found existing configuration');
reuseChoice = ensureAnswer(
await brightSelect({
message: 'Use this existing environment?',
options: [
{ value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' },
{ value: 'fresh', label: 'No, start fresh' },
],
initialValue: 'reuse',
}),
) as 'reuse' | 'fresh';
setupLog.userInput('existing_env_choice', reuseChoice);
}
if (reuseChoice === 'reuse') {
for (const [key, value] of Object.entries(existingEnv.raw)) {
if (!process.env[key]) process.env[key] = value;
}
if (existingEnv.groups.onecli) skip.add('onecli');
if (detectRegisteredGroups(process.cwd())) {
skip.add('cli-agent');
skip.add('first-chat');
}
}
}
if (!skip.has('container')) {
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
p.log.message(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
brandBody(
dimWrap(
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 310 minutes.',
4,
),
),
);
const res = await runWindowedStep('container', {
@@ -205,9 +168,11 @@ async function main(): Promise<void> {
if (!skip.has('onecli')) {
p.log.message(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
brandBody(
dimWrap(
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
4,
),
),
);
@@ -331,9 +296,11 @@ async function main(): Promise<void> {
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
}
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
p.log.message(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
brandBody(
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
),
);
}
}
@@ -348,6 +315,11 @@ async function main(): Promise<void> {
return displayName;
}
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
skip.add('cli-agent');
skip.add('first-chat');
}
if (!skip.has('cli-agent')) {
await resolveDisplayName();
const res = await runQuietStep(
@@ -367,16 +339,18 @@ async function main(): Promise<void> {
}
if (!skip.has('first-chat')) {
p.log.message(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
brandBody(
dimWrap(
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 3060 seconds while the sandbox warms up.",
4,
),
),
);
const ping = await confirmAssistantResponds();
if (ping === 'ok') {
phEmit('first_chat_ready');
const next = ensureAnswer(
await p.select({
await brightSelect<'continue' | 'chat'>({
message: 'What next?',
options: [
{
@@ -437,9 +411,11 @@ async function main(): Promise<void> {
await runIMessageChannel(displayName!);
} else {
p.log.info(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
brandBody(
wrapForGutter(
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
4,
),
),
);
}
@@ -579,18 +555,16 @@ async function confirmAssistantResponds(): Promise<PingResult> {
const s = p.spinner();
const start = Date.now();
const label = 'Waking your assistant…';
s.start(fitToWidth(label, ' (999s)'));
s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await pingCliAgent();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result === 'ok') {
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
} else {
@@ -679,7 +653,7 @@ function sendChatMessage(message: string): Promise<void> {
async function runAuthStep(): Promise<void> {
if (anthropicSecretExists()) {
p.log.success('Your Claude account is already connected.');
p.log.success(brandBody('Your Claude account is already connected.'));
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
return;
}
@@ -727,7 +701,7 @@ async function runAuthStep(): Promise<void> {
}
async function runSubscriptionAuth(): Promise<void> {
p.log.step('Opening the Claude sign-in flow…');
p.log.step(brandBody('Opening the Claude sign-in flow…'));
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
console.log();
const start = Date.now();
@@ -746,7 +720,7 @@ async function runSubscriptionAuth(): Promise<void> {
);
}
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
p.log.success('Claude account connected.');
p.log.success(brandBody('Claude account connected.'));
}
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
@@ -970,9 +944,11 @@ async function runTimezoneStep(): Promise<void> {
tz = await resolveTimezoneViaClaude(raw);
} else {
p.log.warn(
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
brandBody(
wrapForGutter(
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
4,
),
),
);
}
@@ -1015,7 +991,7 @@ async function runTimezoneStep(): Promise<void> {
async function askDisplayName(fallback: string): Promise<string> {
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant call you?',
message: `What should your assistant call ${accentGreen('you')}?`,
placeholder: fallback,
defaultValue: fallback,
}),
@@ -1061,56 +1037,6 @@ async function askChannelChoice(): Promise<ChannelChoice> {
// ─── interactive / env helpers ─────────────────────────────────────────
interface ExistingEnvGroup {
label: string;
keys: string[];
}
const ENV_KEY_GROUPS: Record<string, { label: string; keys: string[] }> = {
onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] },
telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] },
discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] },
slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] },
signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] },
teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] },
whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] },
imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] },
};
function detectExistingEnv(): { groups: Record<string, ExistingEnvGroup>; raw: Record<string, string> } | null {
const envPath = path.join(process.cwd(), '.env');
if (!fs.existsSync(envPath)) return null;
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return null;
}
const raw: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 1) continue;
raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
if (Object.keys(raw).length === 0) return null;
const groups: Record<string, ExistingEnvGroup> = {};
for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) {
const found = def.keys.filter((key) => raw[key] !== undefined);
if (found.length > 0) {
groups[id] = { label: def.label, keys: found };
}
}
if (Object.keys(groups).length === 0) return null;
return { groups, raw };
}
function anthropicSecretExists(): boolean {
try {
const res = spawnSync('onecli', ['secrets', 'list'], {
@@ -1187,7 +1113,7 @@ function maybeReexecUnderSg(): void {
if (!/permission denied/i.test(err)) return;
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {

View File

@@ -28,10 +28,11 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { note } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
@@ -164,9 +165,8 @@ async function walkThroughBotCreation(): Promise<void> {
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
@@ -224,9 +224,8 @@ async function walkThroughServerCreation(): Promise<void> {
' 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'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Create a Discord server',
);
await confirmThenOpen(url, 'Press Enter to open Discord');
@@ -240,7 +239,7 @@ async function walkThroughServerCreation(): Promise<void> {
}
async function collectDiscordToken(): Promise<string> {
const existing = process.env.DISCORD_BOT_TOKEN?.trim();
const existing = readEnvKey('DISCORD_BOT_TOKEN');
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
@@ -289,9 +288,8 @@ async function validateDiscordToken(token: string): Promise<string> {
username?: string;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
@@ -309,8 +307,7 @@ async function validateDiscordToken(token: string): Promise<string> {
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -338,7 +335,6 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
team?: unknown;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
@@ -351,7 +347,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
@@ -369,8 +365,7 @@ async function fetchApplicationInfo(token: string): Promise<AppInfo> {
owner,
};
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
@@ -399,7 +394,7 @@ async function resolveOwnerUserId(
}
} else {
p.log.info(
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."),
);
}
return await promptForUserIdWithDevMode();
@@ -450,9 +445,8 @@ async function promptInviteBot(
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
'',
k.dim(url),
].join('\n'),
formatNoteLink(url),
].filter((line): line is string => line !== null).join('\n'),
'Add bot to a server',
);
await confirmThenOpen(url, 'Press Enter to open the invite page');
@@ -479,7 +473,6 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
@@ -492,14 +485,13 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
@@ -520,7 +512,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -36,7 +36,8 @@ import * as setupLog from '../logs.js';
import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { note, wrapForGutter } from '../lib/theme.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -222,8 +223,8 @@ async function walkThroughFullDiskAccess(): Promise<void> {
}
async function collectRemoteCreds(): Promise<RemoteCreds> {
const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim();
const existingKey = process.env.IMESSAGE_API_KEY?.trim();
const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
const existingKey = readEnvKey('IMESSAGE_API_KEY');
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Photon credentials (${existingUrl}). Use them?`,
@@ -317,7 +318,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -44,7 +44,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { note } from '../lib/theme.js';
import { accentGreen, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -324,8 +324,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to connect to signal-cli before
// init-first-agent's welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('signal-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
@@ -347,7 +346,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -25,10 +25,11 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js';
const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps';
@@ -135,9 +136,8 @@ async function walkThroughAppCreation(): Promise<void> {
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
k.dim(SLACK_APPS_URL),
].join('\n'),
formatNoteLink(SLACK_APPS_URL),
].filter((line): line is string => line !== null).join('\n'),
'Create a Slack app',
);
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
@@ -151,7 +151,7 @@ async function walkThroughAppCreation(): Promise<void> {
}
async function collectBotToken(): Promise<string> {
const existing = process.env.SLACK_BOT_TOKEN?.trim();
const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
@@ -185,7 +185,7 @@ async function collectBotToken(): Promise<string> {
}
async function collectSigningSecret(): Promise<string> {
const existing = process.env.SLACK_SIGNING_SECRET?.trim();
const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?',
@@ -241,10 +241,9 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
user_id?: string;
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.team && data.user) {
s.stop(
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`,
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
);
const info: WorkspaceInfo = {
teamName: data.team,
@@ -273,8 +272,7 @@ async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -334,9 +332,8 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
channel?: { id?: string };
error?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.channel?.id) {
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.channel.id,
});
@@ -360,8 +357,7 @@ async function openDmChannel(token: string, userId: string): Promise<string> {
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
ERROR: message,
@@ -382,7 +378,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -42,6 +42,7 @@ import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
@@ -60,8 +61,8 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
const collected: Collected = {};
const completed: string[] = [];
const existingAppId = process.env.TEAMS_APP_ID?.trim();
const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim();
const existingAppId = readEnvKey('TEAMS_APP_ID');
const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
if (existingAppId && existingPassword) {
const reuse = ensureAnswer(await p.confirm({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
@@ -70,9 +71,9 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
if (reuse) {
collected.appId = existingAppId;
collected.appPassword = existingPassword;
collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') {
collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim();
collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
}
setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected);

View File

@@ -21,7 +21,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { confirmThenOpen } from '../lib/browser.js';
import { confirmThenOpen, formatNoteLink } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import {
type Block,
@@ -33,7 +33,8 @@ import {
spawnStep,
writeStepEntry,
} from '../lib/runner.js';
import { brandBold, note } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
@@ -50,9 +51,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
note(
[
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'',
k.dim(botUrl),
].join('\n'),
formatNoteLink(botUrl),
].filter((line): line is string => line !== null).join('\n'),
'Open Telegram',
);
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
@@ -132,7 +132,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
}
async function collectTelegramToken(): Promise<string> {
const existing = process.env.TELEGRAM_BOT_TOKEN?.trim();
const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
@@ -191,10 +191,9 @@ async function validateTelegramToken(token: string): Promise<string> {
result?: { username?: string; id?: number };
description?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.result?.username) {
const username = data.result.username;
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
@@ -212,8 +211,7 @@ async function validateTelegramToken(token: string): Promise<string> {
'Copy the token again from @BotFather and try setup once more.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
@@ -254,11 +252,11 @@ async function runPairTelegram(): Promise<
stopSpinner("Old code expired. Here's a fresh one.");
}
note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
s.start('Waiting for you to send the code from Telegram…');
s.start(fitToWidth('Waiting for you to send the code from Telegram…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
s.start('Waiting for the correct code…');
s.start(fitToWidth('Waiting for the correct code…', ''));
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
@@ -304,7 +302,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -46,7 +46,7 @@ import {
writeStepEntry,
} from '../lib/runner.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { brandBold, note } from '../lib/theme.js';
import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js';
const DEFAULT_AGENT_NAME = 'Nano';
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
@@ -267,7 +267,7 @@ async function runWhatsAppAuth(
if (spinnerActive) {
stopSpinner('WhatsApp linked.');
} else {
p.log.success('WhatsApp linked.');
p.log.success(brandBody('WhatsApp linked.'));
}
} else if (status === 'failed') {
if (qrLinesPrinted > 0) {
@@ -379,8 +379,7 @@ async function restartService(): Promise<void> {
// Give the adapter a moment to reconnect before init-first-agent's
// welcome DM hits the delivery path.
await new Promise((r) => setTimeout(r, 5000));
const elapsed = Math.round((Date.now() - start) / 1000);
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
PLATFORM: platform,
});
@@ -462,7 +461,7 @@ async function resolveAgentName(): Promise<string> {
}
const answer = ensureAnswer(
await p.text({
message: 'What should your assistant be called?',
message: `What should your ${accentGreen('assistant')} be called?`,
placeholder: DEFAULT_AGENT_NAME,
defaultValue: DEFAULT_AGENT_NAME,
}),

View File

@@ -11,6 +11,30 @@ import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js';
/**
* Read a single key from `.env` on disk (not process.env).
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
*/
export function readEnvKey(key: string, projectRoot?: string): string | null {
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return null;
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 1) continue;
if (trimmed.slice(0, eq) === key) {
return trimmed.slice(eq + 1).trim() || null;
}
}
return null;
}
export function detectExistingDisplayName(projectRoot: string): string | null {
const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return null;

View File

@@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core';
import { isCancel } from '@clack/prompts';
import { styleText } from 'node:util';
import { brandBody } from './theme.js';
const BULLET_ACTIVE = '●';
const BULLET_INACTIVE = '○';
const BAR = '│';
@@ -95,7 +97,7 @@ export function brightSelect<T>(
const shown =
st === 'cancel'
? styleText(['strikethrough', 'dim'], selected)
: styleText('dim', selected);
: styleText('dim', brandBody(selected));
lines.push(`${grayBar} ${shown}`);
return lines.join('\n');
}
@@ -104,11 +106,12 @@ export function brightSelect<T>(
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}`);
const isActive = idx === cursor;
const marker = isActive
? styleText('green', BULLET_ACTIVE)
: styleText('dim', BULLET_INACTIVE);
const shownLabel = isActive ? brandBody(label) : label;
lines.push(`${bar} ${marker} ${shownLabel}${hint}`);
});
lines.push(styleText(color, CAP_BOT));
return lines.join('\n');

View File

@@ -9,12 +9,19 @@
* `confirmThenOpen` pauses for the operator before triggering the open —
* the browser tends to steal focus when it pops, and a split-second
* "wait what just happened" moment is worse than letting the user hit
* Enter when they're ready.
* Enter when they're ready. On headless devices (no graphical session
* available) it skips both the prompt and the open: there's no browser
* to launch, the surrounding `note(...)` already shows the URL for
* copy-paste on another device, and the next prompt in the channel
* flow ("Got your bot token?" etc.) provides the natural completion
* confirmation.
*/
import { spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { isHeadless } from '../platform.js';
import { ensureAnswer } from './runner.js';
/** Best-effort open of a URL in the user's default browser. Silent on failure. */
@@ -32,18 +39,43 @@ export function openUrl(url: string): void {
}
}
/**
* Format a URL for inclusion in a setup `note(...)` card. On
* headless devices we surface the URL inside the card with a
* "Get started:" label at full strength — copy-pasting onto
* another device is the actual action, not an incidental
* reference. The leading `\n` acts as a visual separator from
* the body steps above; callers `.filter(line => line !== null)`
* before joining, so on GUI we drop the line entirely (and the
* URL ends up below the next-step confirm prompt as a "if
* browser does not appear, please visit" fallback — see
* `confirmThenOpen`).
*/
export function formatNoteLink(url: string): string | null {
if (isHeadless()) return `\nGet started: ${url}`;
return null;
}
/**
* Gate a browser-open on a confirm so the user is ready for their browser
* to take focus. Proceeds on cancel as well — the user can always copy the
* URL from the note that precedes the prompt.
* to take focus. Proceeds on cancel as well. On headless devices both the
* prompt and the open are skipped — the URL is already surfaced inside
* the surrounding note (via `formatNoteLink`).
*
* On GUI devices the confirm message includes the fallback URL on the
* lines below the action ("If browser does not appear, please visit:
* <url>" in dim) so the user has a copy-paste path right next to the
* action button without needing to scroll back up to the card.
*/
export async function confirmThenOpen(
url: string,
message = 'Press Enter to open your browser',
): Promise<void> {
if (isHeadless()) return;
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
ensureAnswer(
await p.confirm({
message,
message: `${message}${fallback}`,
initialValue: true,
}),
);

View File

@@ -28,7 +28,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { ensureAnswer } from './runner.js';
import { fitToWidth, note } from './theme.js';
import { brandBody, fitToWidth, fmtDuration, note } from './theme.js';
export interface AssistContext {
stepName: string;
@@ -110,7 +110,7 @@ export async function offerClaudeAssist(
const parsed = parseResponse(response);
if (!parsed) {
p.log.warn("Claude responded but I couldn't parse a command out of it.");
p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it."));
p.log.message(k.dim(response.trim().slice(0, 500)));
return false;
}
@@ -295,9 +295,8 @@ async function queryClaudeUnderSpinner(
// Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window).
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 suffix = ` (${fmtDuration(Date.now() - start)})`;
const header = fitToWidth('Asking Claude to diagnose…', suffix);
out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`);
@@ -355,10 +354,9 @@ async function queryClaudeUnderSpinner(
clearBlock();
out.write(SHOW_CURSOR);
process.off('exit', restoreCursorOnExit);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (kind === 'ok') {
p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`);
p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`);
resolve(payload);
} else {
p.log.error(

View File

@@ -27,7 +27,7 @@ import { execSync, spawn } from 'child_process';
import * as p from '@clack/prompts';
import k from 'kleur';
import { note } from './theme.js';
import { brandBody, note } from './theme.js';
export interface HandoffContext {
/** Channel this handoff is happening in (e.g., 'teams'). */
@@ -64,7 +64,7 @@ export interface HandoffContext {
export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean> {
if (!isClaudeUsable()) {
p.log.warn(
"Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.",
brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."),
);
return false;
}
@@ -93,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise<boolean>
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success("Back from Claude. Let's continue.");
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
child.on('error', () => {

View File

@@ -20,7 +20,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import { fitToWidth } from './theme.js';
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
export type Fields = Record<string, string>;
export type Block = { type: string; fields: Fields };
@@ -307,18 +307,16 @@ async function runUnderSpinner<
): Promise<T> {
const s = p.spinner();
const start = Date.now();
s.start(fitToWidth(labels.running, ' (999s)'));
s.start(fitToWidth(labels.running, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`);
}, 1000);
const result = await work();
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
if (result.ok) {
const isSkipped = result.terminal?.fields.STATUS === 'skipped';
const msg = isSkipped && labels.skipped ? labels.skipped : labels.done;
@@ -390,7 +388,7 @@ export async function fail(
const skipList = [
...new Set([...existingSkip, ...setupLog.completedStepNames()]),
].join(',');
p.log.step(`Retrying from ${stepName}`);
p.log.step(brandBody(`Retrying from ${stepName}`));
const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], {
stdio: 'inherit',
env: { ...process.env, NANOCLAW_SKIP: skipList },

View File

@@ -39,6 +39,57 @@ export function brandChip(s: string): string {
return k.bgCyan(k.black(k.bold(s)));
}
/**
* Accent green (#3fba50) for emphasizing a single word inside prompt
* messages — currently the "you" in "What should your assistant call
* you?" so the operator parses at a glance who the question is about.
* Same TTY/NO_COLOR/truecolor gating as the rest of the palette.
*/
export function accentGreen(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`;
return k.green(s);
}
/**
* Format an elapsed-time duration (in milliseconds) for the spinner
* suffixes setup writes everywhere. Sub-minute durations stay in plain
* seconds (`47s`); once the timer crosses 60 seconds we switch to the
* `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or
* similar. The format is consistent above 60s — `4m 0s` over `4m` —
* so live spinner output doesn't change shape at every whole minute.
*/
export function fmtDuration(ms: number): string {
const totalSec = Math.round(ms / 1000);
if (totalSec < 60) return `${totalSec}s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}m ${s}s`;
}
/**
* Brand body color for setup-flow prose. Used for card bodies (via the
* `note()` formatter) and `p.log.*` body arguments — anywhere the
* previous "dim" treatment was making prose hard to read or washing
* out embedded brand emphasis.
*
* Multi-line input is colored line-by-line so embedded line breaks
* don't bleed the SGR sequence across clack's gutter prefix.
*/
export function brandBody(s: string): string {
if (!USE_ANSI) return s;
if (TRUECOLOR) {
return s
.split('\n')
.map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line))
.join('\n');
}
return s
.split('\n')
.map((line) => (line.length > 0 ? k.cyan(line) : line))
.join('\n');
}
/**
* Wrap text so it fits inside clack's gutter without the terminal's soft
* wrap breaking the `│ …` bar on long lines. Works on a single string with
@@ -70,16 +121,13 @@ export function dimWrap(text: string, gutter: number): string {
}
/**
* Wrap clack's `p.note` with the dim formatter disabled. By default
* clack renders note bodies through `styleText("dim", …)`, which the
* project's prose-readability stance (see `dimWrap` above) explicitly
* rejects. Pass-through formatter keeps body text at the terminal's
* regular weight; pre-styled segments (chips, bold, brand color) come
* through unfaded.
* Wrap clack's `p.note` so card bodies render in the brand body color
* (#2b6fdc) instead of clack's default dim. Clack runs the formatter
* on each line individually, so `brandBody` colors each line cleanly
* without bleeding across the gutter prefix.
*/
const passthroughFormat = (s: string): string => s;
export function note(message: string, title?: string): void {
p.note(message, title, { format: passthroughFormat });
p.note(message, title, { format: brandBody });
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;

View File

@@ -17,7 +17,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import { isValidTimezone } from '../../src/timezone.js';
import { fitToWidth } from './theme.js';
import { fitToWidth, fmtDuration } from './theme.js';
export function claudeCliAvailable(): boolean {
try {
@@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude(
const s = p.spinner();
const start = Date.now();
const label = 'Looking up that timezone…';
s.start(fitToWidth(label, ' (999s)'));
s.start(fitToWidth(label, ' (99m 59s)'));
const tick = setInterval(() => {
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
}, 1000);
const reply = await queryClaude(prompt);
clearInterval(tick);
const elapsed = Math.round((Date.now() - start) / 1000);
const suffix = ` (${elapsed}s)`;
const suffix = ` (${fmtDuration(Date.now() - start)})`;
const resolved = reply ? extractTimezone(reply) : null;
if (resolved) {

View File

@@ -23,7 +23,7 @@ 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';
import { brandBody, fitToWidth } from './theme.js';
const WINDOW_SIZE = 3;
const SPINNER_FRAMES = ['◒', '◐', '◓', '◑'];
@@ -169,7 +169,7 @@ async function runUnderWindow(
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)}`);
p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`);
} else {
const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed');
p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`);
@@ -185,7 +185,7 @@ async function handleStall(
): Promise<void> {
render.pauseRender();
p.log.warn(
`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`,
brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`),
);
phEmit('step_stalled', { step: stepName });