clawdie-ai/setup/onboarding.ts
Clawdie AI 8456fcc526 test: expand coverage + fix setup/ TypeScript errors
New test files (83 tests):
- src/agent-identity.test.ts — resolveAgentIdentity across locales/genders
- src/env.test.ts — readEnvFile parsing, quoting, edge cases
- src/jail-registry.test.ts — getJailIp with/without env override
- src/local-hosts.test.ts — block markers, entries, render, upsert
- src/mount-security.test.ts — validateMount allowlist enforcement
- src/transcription.test.ts — initTranscription + transcribeAudio with mocked OpenAI

setup/ TypeScript audit (tsconfig.setup.json):
- agent-jails: JAILS value serialised to JSON string for emitStatus
- environment.test: use import type for pg.Pool type cast
- onboarding: wrap showProfileMenu in normalizePiTuiProfile
- preflight.test: fix process.exit mock type + typed call array casts
- sanoid: execSync → spawnSync for multi-arg zfs invocation
- skills-memory: bracket access for legacy chunking_version field
- upstream: pass process.cwd() to isGitRepo()
- verify: import StripeKeyMode type, annotate stripeKeyMode variable

Full suite: 69 files, 1162 tests passing; tsc --noEmit clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---
Build: pass | Tests: pass — Tests  1162 passed (1162)

---
Build: pass | Tests: pass — Tests  1162 passed (1162)
2026-04-14 09:40:28 +00:00

1037 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { execFileSync, spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { stdin as input, stdout as output } from 'process';
import * as readline from 'readline/promises';
import { logger } from '../src/logger.js';
import {
normalizeLocaleTag,
normalizeTimeZone,
toSystemLocale,
} from '../src/locale-profile.js';
import { normalizePiTuiProfile, PI_TUI_PROFILES } from '../src/pi-profile.js';
import { resolveAgentIdentity } from '../src/agent-identity.js';
import type { AgentGender } from '../src/config.js';
import {
getStripeKeyMode,
isValidStripeTestKey,
} from '../src/stripe-config.js';
import { commandExists, getPlatform } from './platform.js';
import {
requireAtLeastOneAgentCli,
NoAgentCliError,
} from './agent-cli-check.js';
import {
detectProfile,
ensureEnvFile,
extractEnvValue,
run as runProfile,
writeEnvLine,
} from './profile.js';
import {
enumerateFreeBSDLocales,
prioritizeLocaleOptions,
type LocaleOption,
} from './freebsd-locales.js';
import {
getTimezoneOptions,
prioritizeTimezones,
type TimezoneOption,
} from './freebsd-timezones.js';
import { ensureScreenshotSecrets, ensureSplitBrainSecrets } from './secrets.js';
import { emitStatus } from './status.js';
interface OnboardingArgs {
locale?: string;
timezone?: string;
agentName?: string;
assistantName?: string;
agentGender?: AgentGender;
stripeSecretKey?: string;
piProfile?: string;
nonInteractive: boolean;
acceptDetected: boolean;
}
function parseArgs(args: string[]): OnboardingArgs {
const result: OnboardingArgs = {
nonInteractive: false,
acceptDetected: false,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--locale':
result.locale = args[++i] || '';
break;
case '--timezone':
result.timezone = args[++i] || '';
break;
case '--agent-name':
result.agentName = args[++i] || '';
break;
case '--assistant-name':
result.assistantName = args[++i] || '';
break;
case '--agent-gender':
result.agentGender = normalizeGender(args[++i] || '');
break;
case '--stripe-secret-key':
result.stripeSecretKey = args[++i] || '';
break;
case '--pi-profile':
result.piProfile = args[++i] || '';
break;
case '--non-interactive':
result.nonInteractive = true;
break;
case '--accept-detected':
result.acceptDetected = true;
break;
}
}
return result;
}
const CYRILLIC_TO_LATIN: Record<string, string> = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
ђ: 'dj',
е: 'e',
ё: 'e',
ж: 'zh',
з: 'z',
и: 'i',
й: 'j',
ј: 'j',
к: 'k',
л: 'l',
љ: 'lj',
м: 'm',
н: 'n',
њ: 'nj',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
ћ: 'c',
у: 'u',
ф: 'f',
х: 'h',
ц: 'c',
ч: 'ch',
џ: 'dz',
ш: 'sh',
щ: 'sh',
ы: 'y',
э: 'e',
ю: 'yu',
я: 'ya',
ь: '',
ъ: '',
};
function transliterateForSlug(value: string): string {
let result = '';
for (const char of value.normalize('NFKD')) {
const lower = char.toLocaleLowerCase('en');
if (CYRILLIC_TO_LATIN[lower] !== undefined) {
result += CYRILLIC_TO_LATIN[lower];
continue;
}
if (/[\u0300-\u036f]/u.test(char)) {
continue;
}
result += char;
}
return result;
}
function fallbackAgentName(value?: string | null): string {
const codepoints = Array.from((value || '').trim())
.filter((char) => /[\p{L}\p{N}]/u.test(char))
.slice(0, 4)
.map((char) => char.codePointAt(0)?.toString(16))
.filter((part): part is string => !!part);
return codepoints.length > 0 ? `agent-${codepoints.join('-')}` : 'clawdie';
}
export function normalizeAgentName(value?: string | null): string {
const normalized = transliterateForSlug(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/gu, '-')
.replace(/^-+|-+$/gu, '');
return normalized || fallbackAgentName(value);
}
export function deriveAgentName(assistantName?: string | null): string {
return normalizeAgentName(assistantName);
}
export function hasCustomAgentNameOverride(
agentName?: string | null,
assistantName?: string | null,
): boolean {
return normalizeAgentName(agentName) !== deriveAgentName(assistantName);
}
function quoteEnvValue(value: string): string {
return JSON.stringify(value);
}
function defaultInternalDomain(agentName: string): string {
return `${agentName}.home.arpa`;
}
function defaultPublicDomain(agentName: string): string {
return 'home.arpa';
}
function detectOriginRemote(projectRoot: string): string {
try {
return execFileSync(
'git',
['-C', projectRoot, 'config', '--get', 'remote.origin.url'],
{
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
},
).trim();
} catch {
return 'https://codeberg.org/Clawdie/Clawdie-AI.git';
}
}
function writeCodeHostingDefaults(envFile: string, projectRoot: string): void {
const content = fs.readFileSync(envFile, 'utf-8');
const subnetBase =
extractEnvValue(content, 'AGENT_SUBNET_BASE') ||
extractEnvValue(content, 'WARDEN_SUBNET_BASE') ||
'10.0.0';
if (!extractEnvValue(content, 'CODE_HOSTING_MODE')) {
writeEnvLine(envFile, 'CODE_HOSTING_MODE', 'git');
}
if (!extractEnvValue(content, 'FEATURE_GIT')) {
writeEnvLine(envFile, 'FEATURE_GIT', 'YES');
}
if (!extractEnvValue(content, 'FEATURE_GITEA')) {
writeEnvLine(envFile, 'FEATURE_GITEA', 'NO');
}
if (!extractEnvValue(content, 'LOCAL_LLM_PROVIDER')) {
writeEnvLine(envFile, 'LOCAL_LLM_PROVIDER', 'none');
}
if (!extractEnvValue(content, 'FEATURE_OLLAMA')) {
writeEnvLine(envFile, 'FEATURE_OLLAMA', 'NO');
}
if (!extractEnvValue(content, 'FEATURE_LLAMA_CPP')) {
writeEnvLine(envFile, 'FEATURE_LLAMA_CPP', 'NO');
}
if (!extractEnvValue(content, 'FEATURE_OLLAMA_HPP')) {
writeEnvLine(envFile, 'FEATURE_OLLAMA_HPP', 'NO');
}
if (!extractEnvValue(content, 'REMOTE_GIT_URL')) {
writeEnvLine(envFile, 'REMOTE_GIT_URL', detectOriginRemote(projectRoot));
}
if (!extractEnvValue(content, 'WARDEN_GIT_IP')) {
writeEnvLine(envFile, 'WARDEN_GIT_IP', `${subnetBase}.6`);
}
if (!extractEnvValue(content, 'WARDEN_OLLAMA_IP')) {
writeEnvLine(envFile, 'WARDEN_OLLAMA_IP', `${subnetBase}.5`);
}
if (!extractEnvValue(content, 'WARDEN_LLAMA_CPP_IP')) {
writeEnvLine(envFile, 'WARDEN_LLAMA_CPP_IP', `${subnetBase}.5`);
}
}
function writeIdentity(
envFile: string,
agentName: string,
assistantName: string,
): void {
writeEnvLine(envFile, 'AGENT_NAME', agentName);
writeEnvLine(envFile, 'ASSISTANT_NAME', quoteEnvValue(assistantName));
const content = fs.readFileSync(envFile, 'utf-8');
const currentInternalDomain = extractEnvValue(
content,
'AGENT_INTERNAL_DOMAIN',
);
if (!currentInternalDomain || currentInternalDomain.endsWith('.local')) {
writeEnvLine(
envFile,
'AGENT_INTERNAL_DOMAIN',
defaultInternalDomain(agentName),
);
}
const withInternalDomain = fs.readFileSync(envFile, 'utf-8');
const effectiveInternalDomain =
extractEnvValue(withInternalDomain, 'AGENT_INTERNAL_DOMAIN') ||
defaultInternalDomain(agentName);
const currentDomain = extractEnvValue(content, 'AGENT_DOMAIN');
if (
!currentDomain ||
currentDomain === 'agent.local' ||
currentDomain.endsWith('.local')
) {
writeEnvLine(envFile, 'AGENT_DOMAIN', defaultPublicDomain(agentName));
}
const updatedContent = fs.readFileSync(envFile, 'utf-8');
const currentControlPlaneHostname = extractEnvValue(
updatedContent,
'WARDEN_CONTROL_PLANE_HOSTNAME',
);
if (
currentControlPlaneHostname &&
currentControlPlaneHostname.endsWith('.local')
) {
writeEnvLine(
envFile,
'WARDEN_CONTROL_PLANE_HOSTNAME',
`controlplane.${effectiveInternalDomain}`,
);
}
}
function applyHostLocale(systemLocale: string): boolean {
try {
// Always normalise to UTF-8 — strip any non-UTF-8 charset suffix and
// rewrite the locale tag with .UTF-8 so ~/.login_conf never locks the
// user into a legacy encoding like ISO8859-2.
const dotIdx = systemLocale.indexOf('.');
const baseLocale =
dotIdx !== -1 ? systemLocale.slice(0, dotIdx) : systemLocale;
const normalizedLocale = `${baseLocale}.UTF-8`;
const content = `me:\\\n\t:charset=UTF-8:\\\n\t:lang=${normalizedLocale}:\n`;
const loginConfPath = path.join(os.homedir(), '.login_conf');
fs.writeFileSync(loginConfPath, content, { encoding: 'utf-8' });
execFileSync('cap_mkdb', [loginConfPath], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function runBsddialog(args: string[]): string {
// bsddialog writes the selected item to stderr (its default, no --stdout).
// Use spawnSync so we can capture stderr independently.
const result = spawnSync('bsddialog', args, {
encoding: 'utf-8',
stdio: ['inherit', 'inherit', 'pipe'], // stdin+stdout → TTY, stderr → captured
});
const status = result.status ?? 1;
if (result.error) throw result.error;
if (status === 1 || status === 255) {
emitStatus('SETUP_ONBOARDING', {
PLATFORM: getPlatform(),
STATUS: 'cancelled',
LOG: 'logs/setup.log',
});
process.exit(130);
}
if (status !== 0) {
throw new Error(`bsddialog exited with status ${status}`);
}
return (result.stderr ?? '').trim();
}
function showMenu(
title: string,
text: string,
options: LocaleOption[],
): LocaleOption {
const rawItems = options.flatMap((option) => [
option.displayLocale,
option.reviewed ? option.label : `${option.label} [fallback copy]`,
]);
const selected = runBsddialog([
'--title',
title,
'--menu',
text,
'0',
'0',
'12',
...rawItems,
]);
const option = options.find((entry) => entry.displayLocale === selected);
if (!option) {
throw new Error(`Unknown locale selected: ${selected}`);
}
return option;
}
function showTimezoneMenu(
title: string,
text: string,
options: TimezoneOption[],
): TimezoneOption {
const rawItems = options.flatMap((option) => [option.timezone, option.label]);
const selected = runBsddialog([
'--title',
title,
'--menu',
text,
'0',
'0',
'12',
...rawItems,
]);
const option = options.find((entry) => entry.timezone === selected);
if (!option) {
throw new Error(`Unknown timezone selected: ${selected}`);
}
return option;
}
function showInputBox(title: string, text: string, value: string): string {
return runBsddialog(['--title', title, '--inputbox', text, '0', '0', value]);
}
function showPasswordBox(title: string, text: string, value = ''): string {
return runBsddialog([
'--title',
title,
'--passwordbox',
text,
'0',
'0',
value,
]);
}
function showMessageBox(title: string, text: string): void {
runBsddialog(['--title', title, '--msgbox', text, '0', '0']);
}
function showYesNo(title: string, text: string): boolean {
try {
execFileSync('bsddialog', ['--title', title, '--yesno', text, '0', '0'], {
stdio: 'inherit',
});
return true;
} catch (error) {
const status =
typeof error === 'object' && error && 'status' in error
? Number(error.status)
: 1;
if (status === 1 || status === 255) {
return false;
}
throw error;
}
}
function showProfileMenu(currentProfile: string): string {
const rawItems = Object.entries(PI_TUI_PROFILES).flatMap(([name, def]) => [
name,
def.label,
]);
const selected = runBsddialog([
'--title',
'Operator Profile',
'--default-item',
currentProfile,
'--menu',
'Choose the default operator profile for this installation.\nSets PI_TUI_PROFILE in .env — change at any time with --step pi-config.',
'0',
'0',
'10',
...rawItems,
]);
return normalizePiTuiProfile(selected);
}
function isTtyInteractive(opts: OnboardingArgs): boolean {
return (
!opts.nonInteractive &&
!opts.acceptDetected &&
getPlatform() === 'freebsd' &&
!!input.isTTY &&
!!output.isTTY
);
}
async function promptWithDefault(
rl: readline.Interface,
label: string,
defaultValue: string,
): Promise<string> {
const answer = await rl.question(`${label} [${defaultValue}]: `);
return answer.trim() || defaultValue;
}
async function promptYesNo(
rl: readline.Interface,
label: string,
defaultValue: boolean,
): Promise<boolean> {
const suffix = defaultValue ? 'Y/n' : 'y/N';
while (true) {
const answer = (await rl.question(`${label} [${suffix}]: `))
.trim()
.toLowerCase();
if (!answer) return defaultValue;
if (['y', 'yes'].includes(answer)) return true;
if (['n', 'no'].includes(answer)) return false;
}
}
function writeStripeConfig(envFile: string, stripeSecretKey: string): void {
writeEnvLine(envFile, 'STRIPE_SECRET_KEY', stripeSecretKey.trim());
const content = fs.readFileSync(envFile, 'utf-8');
if (!extractEnvValue(content, 'STRIPE_ENABLE_REFUNDS')) {
writeEnvLine(envFile, 'STRIPE_ENABLE_REFUNDS', 'NO');
}
}
function normalizeGender(input: string): AgentGender {
const g = input.trim().toLowerCase();
if (g === 'm') return 'm';
if (g === 'f' || g === 'ž' || g === 'z') return 'f';
return 'n';
}
function genderPreview(
name: string,
gender: AgentGender,
locale: string,
): string {
const id = resolveAgentIdentity(name, name, gender, locale);
return `${name}${id.titlePossessive} (${id.pronoun})`;
}
function showGenderMenu(
suggestedGender: AgentGender,
assistantName: string,
locale: string,
): AgentGender {
const items = [
'f',
`ženski — ${genderPreview(assistantName, 'f', locale)}`,
'm',
`moški — ${genderPreview(assistantName, 'm', locale)}`,
'n',
'nevtralno — neutral / English',
];
const selected = runBsddialog([
'--title',
'Spol asistenta / Assistant Gender',
'--default-item',
suggestedGender,
'--menu',
'Izberite spol. Vpliva na slovnično obliko in samopredstavitev asistenta.\nSelect gender. Affects grammar and self-description.',
'0',
'0',
'3',
...items,
]);
return normalizeGender(selected);
}
function formatStripeSummary(mode: string): string {
if (mode === 'test') return 'configured (test)';
if (mode === 'live') return 'configured (live)';
if (mode === 'invalid') return 'invalid';
return 'skipped';
}
export async function run(args: string[]): Promise<void> {
const opts = parseArgs(args);
// Fail-fast: at least one agent CLI must resolve on PATH. Mirrors
// the standard per-adapter ensureCommandResolvable pattern, but as a single gate.
try {
const clis = requireAtLeastOneAgentCli();
const present = clis.filter((c) => c.present).map((c) => c.name);
const missing = clis.filter((c) => !c.present).map((c) => c.name);
logger.info({ present, missing }, 'Agent CLIs detected');
} catch (err) {
if (err instanceof NoAgentCliError) {
emitStatus('SETUP_ONBOARDING', {
STATUS: 'failed',
ERROR: 'no_agent_cli',
});
logger.error(err.message);
}
throw err;
}
const projectRoot = process.cwd();
const envFile = ensureEnvFile(projectRoot);
const envContent = fs.readFileSync(envFile, 'utf-8');
const detected = detectProfile(envContent);
const existingAgentName =
extractEnvValue(envContent, 'AGENT_NAME') || 'clawdie';
const existingAssistantName =
extractEnvValue(envContent, 'ASSISTANT_NAME') || 'Clawdie';
const existingCustomAgentOverride = hasCustomAgentNameOverride(
existingAgentName,
existingAssistantName,
);
let displayLocale = normalizeLocaleTag(
opts.locale ||
extractEnvValue(envContent, 'DISPLAY_LOCALE') ||
detected.displayLocale,
);
let systemLocale =
extractEnvValue(envContent, 'SYSTEM_LOCALE') || detected.systemLocale;
let timeZone = normalizeTimeZone(
opts.timezone || extractEnvValue(envContent, 'TZ') || detected.timeZone,
);
let assistantName =
(opts.assistantName || existingAssistantName).trim() || 'Clawdie';
let stripeSecretKey = (
opts.stripeSecretKey ||
extractEnvValue(envContent, 'STRIPE_SECRET_KEY') ||
''
).trim();
let stripeMode = getStripeKeyMode(stripeSecretKey);
let agentName = opts.agentName
? normalizeAgentName(opts.agentName)
: opts.assistantName
? deriveAgentName(opts.assistantName)
: existingCustomAgentOverride
? normalizeAgentName(existingAgentName)
: deriveAgentName(existingAssistantName);
let derivedAgentName = deriveAgentName(assistantName);
let agentNameSource = opts.agentName
? 'explicit'
: existingCustomAgentOverride && !opts.assistantName
? 'existing-override'
: 'derived';
let piProfile = normalizePiTuiProfile(
opts.piProfile || extractEnvValue(envContent, 'PI_TUI_PROFILE'),
);
let agentGender: AgentGender =
opts.agentGender ||
normalizeGender(extractEnvValue(envContent, 'AGENT_GENDER') || 'n');
let mode = 'noninteractive';
if (normalizeLocaleTag(systemLocale, '') !== displayLocale) {
systemLocale = toSystemLocale(displayLocale);
}
if (isTtyInteractive(opts) && commandExists('bsddialog')) {
const locales = prioritizeLocaleOptions(
enumerateFreeBSDLocales([
'sl-SI', // Slovenian default for prototype
detected.systemLocale,
process.env.LANG,
process.env.LC_ALL,
extractEnvValue(envContent, 'SYSTEM_LOCALE'),
]),
displayLocale,
);
const localeOption = showMenu(
'Clawdie Onboarding',
'Select the base locale for setup, display dates, and default assistant replies.',
locales,
);
displayLocale = localeOption.displayLocale;
systemLocale = localeOption.systemLocale;
const timezones = prioritizeTimezones(
getTimezoneOptions(),
timeZone || 'Europe/Ljubljana',
);
const timezoneOption = showTimezoneMenu(
'Clawdie Onboarding',
'Select your timezone for system date/time and scheduling.',
timezones,
);
timeZone = normalizeTimeZone(timezoneOption.timezone, timeZone);
assistantName =
showInputBox(
'Assistant Name',
'Choose the user-facing assistant name.',
assistantName,
).trim() || assistantName;
derivedAgentName = deriveAgentName(assistantName);
agentGender = showGenderMenu(
opts.agentGender || agentGender,
assistantName,
displayLocale,
);
piProfile = normalizePiTuiProfile(showProfileMenu(piProfile));
let promptForStripeKey = false;
let skipStripeForNow = false;
if (stripeMode === 'test' || stripeMode === 'live') {
const keepExistingStripeKey = showYesNo(
'Stripe',
[
'Stripe is built into Clawdie and ready when configured.',
`Current Stripe status: ${stripeMode}`,
'',
'Keep the current Stripe configuration?',
].join('\n'),
);
if (!keepExistingStripeKey) {
stripeSecretKey = '';
stripeMode = 'missing';
}
}
if (stripeMode === 'invalid') {
const configureInvalidStripeNow = showYesNo(
'Stripe',
[
'A Stripe key is present but invalid.',
'',
'Configure Stripe now with a restricted test key?',
'Select No to skip Stripe for now.',
].join('\n'),
);
if (!configureInvalidStripeNow) {
stripeSecretKey = '';
stripeMode = 'missing';
skipStripeForNow = true;
} else {
promptForStripeKey = true;
}
}
if (stripeMode === 'missing' && !skipStripeForNow) {
const configureStripeNow = showYesNo(
'Stripe',
[
'Stripe is built into Clawdie and loads automatically when configured.',
'',
'Configure Stripe now with a restricted test key?',
'Select No to skip Stripe for now.',
].join('\n'),
);
if (configureStripeNow) {
promptForStripeKey = true;
}
}
while (
promptForStripeKey ||
(stripeSecretKey && !isValidStripeTestKey(stripeSecretKey))
) {
promptForStripeKey = false;
stripeSecretKey = showPasswordBox(
'Stripe Test Key',
[
'Enter a Stripe restricted test key for this installation.',
'',
'Expected format: rk_test_...',
'Use Stripe Dashboard -> Developers -> API Keys -> Restricted Keys.',
'Choose Cancel or leave it empty only if you want to skip Stripe for now.',
].join('\n'),
).trim();
if (!stripeSecretKey) {
break;
}
if (!isValidStripeTestKey(stripeSecretKey)) {
showMessageBox(
'Invalid Stripe Key',
'Expected a restricted Stripe test key that starts with rk_test_.',
);
}
}
stripeMode = getStripeKeyMode(stripeSecretKey);
if (existingCustomAgentOverride && !opts.agentName) {
const keepExistingOverride = showYesNo(
'System Namespace',
[
`Assistant name: ${assistantName}`,
`Current custom namespace: ${existingAgentName}`,
`Auto-generated namespace: ${derivedAgentName}`,
'',
'Keep the current custom namespace?',
].join('\n'),
);
if (keepExistingOverride) {
agentName = normalizeAgentName(existingAgentName);
agentNameSource = 'existing-override';
} else if (
showYesNo(
'System Namespace',
[
`Use auto-generated namespace "${derivedAgentName}" for jails, services, domains, and datasets?`,
].join('\n'),
)
) {
agentName = derivedAgentName;
agentNameSource = 'derived';
} else {
agentName = normalizeAgentName(
showInputBox(
'System Namespace',
'Override the derived namespace used for jails, services, domains, and datasets.',
existingAgentName,
),
);
agentNameSource = 'override';
}
} else {
const useDerivedAgentName = showYesNo(
'System Namespace',
[
`Assistant name: ${assistantName}`,
`Auto-generated system namespace: ${derivedAgentName}`,
'This value is used for jails, services, domains, and datasets.',
'',
'Use the auto-generated system namespace?',
].join('\n'),
);
if (useDerivedAgentName) {
agentName = derivedAgentName;
agentNameSource = 'derived';
} else {
agentName = normalizeAgentName(
showInputBox(
'System Namespace',
'Override the derived namespace used for jails, services, domains, and datasets.',
derivedAgentName,
),
);
agentNameSource = 'override';
}
}
const confirmed = showYesNo(
'Apply Onboarding',
[
`Locale: ${displayLocale}`,
`Timezone: ${timeZone}`,
`Assistant: ${genderPreview(assistantName, agentGender, displayLocale)}`,
`System namespace: ${agentName}`,
`PI Profile: ${piProfile} (${PI_TUI_PROFILES[piProfile].label})`,
`Stripe: ${formatStripeSummary(stripeMode)}`,
'',
'Write these values to .env and continue?',
].join('\n'),
);
if (!confirmed) {
emitStatus('SETUP_ONBOARDING', {
PLATFORM: getPlatform(),
STATUS: 'cancelled',
LOG: 'logs/setup.log',
});
process.exit(130);
}
const hostLocaleApplied = applyHostLocale(systemLocale);
if (hostLocaleApplied) {
runBsddialog([
'--title',
'Host Locale Set',
'--msgbox',
`Locale set to ${systemLocale} (normalised to UTF-8).\n\nWritten to ~/.login_conf.\n\nOpen a new tmux window or fresh SSH login to activate.`,
'0',
'0',
]);
}
mode = 'bsddialog';
} else if (isTtyInteractive(opts)) {
const rl = readline.createInterface({ input, output });
try {
const locales = prioritizeLocaleOptions(
enumerateFreeBSDLocales([
detected.systemLocale,
process.env.LANG,
process.env.LC_ALL,
extractEnvValue(envContent, 'SYSTEM_LOCALE'),
]),
displayLocale,
);
console.log(
'Interactive onboarding: bsddialog not found, using plain TTY prompts.',
);
displayLocale = normalizeLocaleTag(
await promptWithDefault(rl, 'Base locale', displayLocale),
displayLocale,
);
systemLocale =
locales.find((option) => option.displayLocale === displayLocale)
?.systemLocale || toSystemLocale(displayLocale);
timeZone = normalizeTimeZone(
await promptWithDefault(
rl,
'Timezone (IANA format, e.g., Europe/Ljubljana)',
timeZone,
),
timeZone,
);
assistantName =
(await promptWithDefault(rl, 'Assistant name', assistantName)).trim() ||
assistantName;
derivedAgentName = deriveAgentName(assistantName);
const genderRaw = await promptWithDefault(
rl,
'Gender [m/f/n] (ž=f)',
opts.agentGender || agentGender,
);
agentGender = normalizeGender(genderRaw);
console.log(
`${genderPreview(assistantName, agentGender, displayLocale)}`,
);
console.log(`PI profiles: ${Object.keys(PI_TUI_PROFILES).join(', ')}`);
piProfile = normalizePiTuiProfile(
await promptWithDefault(rl, 'PI profile', piProfile),
);
const namespaceDefault =
existingCustomAgentOverride && !opts.agentName
? normalizeAgentName(existingAgentName)
: derivedAgentName;
agentName = normalizeAgentName(
await promptWithDefault(rl, 'System namespace', namespaceDefault),
);
agentNameSource =
agentName === derivedAgentName
? 'derived'
: existingCustomAgentOverride &&
!opts.agentName &&
agentName === normalizeAgentName(existingAgentName)
? 'existing-override'
: 'override';
if (stripeMode === 'invalid') {
console.log(
'Stripe key is currently invalid. Leaving it unchanged; update .env later or rerun onboarding with --stripe-secret-key.',
);
} else if (stripeMode === 'missing') {
console.log(
'Stripe is not configured. Add STRIPE_SECRET_KEY later or rerun onboarding with --stripe-secret-key.',
);
} else {
console.log(
`Stripe status: ${formatStripeSummary(stripeMode)}. Keeping current value.`,
);
}
const confirmed = await promptYesNo(
rl,
[
'Write these values to .env and continue?',
`Locale=${displayLocale}`,
`Timezone=${timeZone}`,
`Assistant=${genderPreview(assistantName, agentGender, displayLocale)}`,
`Namespace=${agentName}`,
`PI profile=${piProfile}`,
].join('\n'),
true,
);
if (!confirmed) {
emitStatus('SETUP_ONBOARDING', {
PLATFORM: getPlatform(),
STATUS: 'cancelled',
LOG: 'logs/setup.log',
});
process.exit(130);
}
} finally {
rl.close();
}
const hostLocaleApplied = applyHostLocale(systemLocale);
if (hostLocaleApplied) {
console.log(
`Locale set to ${systemLocale} (normalised to UTF-8) in ~/.login_conf. Open a new tmux window or fresh SSH login to activate.`,
);
}
mode = 'tty';
}
if (agentNameSource === 'derived') {
agentName = deriveAgentName(assistantName);
}
stripeMode = getStripeKeyMode(stripeSecretKey);
if (opts.stripeSecretKey && stripeMode === 'invalid') {
throw new Error('invalid_stripe_test_key');
}
await runProfile([
'--locale',
displayLocale,
'--setup-locale',
displayLocale,
'--assistant-locale',
displayLocale,
'--system-locale',
systemLocale,
'--timezone',
timeZone,
'--accept-detected',
]);
writeIdentity(envFile, agentName, assistantName);
writeEnvLine(envFile, 'AGENT_GENDER', agentGender);
writeEnvLine(envFile, 'PI_TUI_PROFILE', piProfile);
writeCodeHostingDefaults(envFile, projectRoot);
writeStripeConfig(envFile, stripeSecretKey);
const screenshotSecrets = ensureScreenshotSecrets(projectRoot);
ensureSplitBrainSecrets(projectRoot);
logger.info(
{
mode,
displayLocale,
systemLocale,
timeZone,
agentName,
agentNameSource,
derivedAgentName,
assistantName,
agentGender,
piProfile,
stripe: formatStripeSummary(stripeMode),
},
'Onboarding complete',
);
emitStatus('SETUP_ONBOARDING', {
PLATFORM: getPlatform(),
MODE: mode,
DISPLAY_LOCALE: displayLocale,
SYSTEM_LOCALE: systemLocale,
TZ: timeZone,
ASSISTANT_NAME: assistantName,
AGENT_NAME: agentName,
AGENT_NAME_SOURCE: agentNameSource,
DERIVED_AGENT_NAME: derivedAgentName,
SCREENSHOTS_AUTH: 'configured',
SCREENSHOTS_USER: screenshotSecrets.screenshotsUser,
SCREENSHOTS_PASSWORD_CREATED: screenshotSecrets.passwordCreated,
PI_PROFILE: piProfile,
STRIPE: formatStripeSummary(stripeMode),
ENV_FILE: path.relative(projectRoot, envFile),
STATUS: 'success',
LOG: 'logs/setup.log',
});
}