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)
1037 lines
29 KiB
TypeScript
1037 lines
29 KiB
TypeScript
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',
|
||
});
|
||
}
|