Merge remote-tracking branch 'origin/main' into setup-root-warning-v2
# Conflicts: # setup/auto.ts
This commit is contained in:
164
setup/auto.ts
164
setup/auto.ts
@@ -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 3–10 minutes.',
|
||||
4,
|
||||
brandBody(
|
||||
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', {
|
||||
@@ -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 30–60 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 30–60 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'], {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user