#!/usr/bin/env node // Interactive (and --yes non-interactive) setup wizard. Generates a minimal // config.yaml (+ .env PORT) for a single LLM connection. Run before building: // npm run setup import { createInterface } from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { existsSync, readFileSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, copyFileSync, chmodSync, unlinkSync, } from 'node:fs'; import { join } from 'node:path'; import { parseEndpoint, buildWorkerEntry, renderConfigYaml, renderDotenv, parseAnswersFromEnv, probeModels, } from './setup-lib.mjs'; const ROOT = process.cwd(); const CONFIG_PATH = join(ROOT, 'config.yaml'); const ENV_PATH = join(ROOT, '.env'); const maskKey = (k) => (k ? `${String(k).slice(0, 6)}****` : ''); function atomicWrite(path, content, mode) { const tmp = `${path}.${process.pid}.tmp`; const fd = openSync(tmp, 'w', mode); try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); } try { chmodSync(tmp, mode); } catch (e) { console.warn(`⚠ chmod ${tmp} failed (${e.message}); continuing`); } try { renameSync(tmp, path); } catch (e) { try { unlinkSync(tmp); } catch {} throw e; } } function backupExistingConfig() { const bak = `${CONFIG_PATH}.bak`; copyFileSync(CONFIG_PATH, bak); // copy (not rename) so original survives a later failure try { chmodSync(bak, 0o600); } catch (e) { console.warn(`⚠ chmod ${bak} failed (${e.message})`); } console.log(` 既存 config.yaml を ${bak} (0600) に退避しました。`); } function writeOutputs({ worker, port }) { // config.yaml is always 0600 (may hold an api_key). atomicWrite(CONFIG_PATH, renderConfigYaml(worker), 0o600); const existingEnv = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf-8') : ''; atomicWrite(ENV_PATH, renderDotenv(existingEnv, { port }), 0o644); } function printDone({ port, worker }) { console.log(''); console.log('✅ config.yaml を生成しました(詳細設定は config.yaml.example か Settings UI を参照)。'); if (worker.api_key) console.log(` gateway api_key: ${maskKey(worker.api_key)}(config.yaml に保存、権限 0600)`); console.log('次の手順:'); console.log(' scripts/build-all.sh'); console.log(' scripts/server.sh start'); console.log(` ブラウザで http://localhost:${port}`); } async function runNonInteractive(force) { const { answers, error } = parseAnswersFromEnv(process.env); if (error) { console.error(`setup --yes: ${error}`); process.exit(1); } if (existsSync(CONFIG_PATH) && !force) { console.error('setup --yes: config.yaml already exists; pass --force to overwrite.'); process.exit(1); } const probe = await probeModels(answers); // non-fatal: model already provided via env if (!probe.ok) console.warn(`⚠ LLM probe failed (${probe.error}); continuing (SETUP_MODEL is set).`); const worker = buildWorkerEntry({ connectionType: answers.connectionType, endpoint: answers.endpoint, model: answers.model, apiKey: answers.apiKey, }); if (existsSync(CONFIG_PATH)) backupExistingConfig(); writeOutputs({ worker, port: answers.port }); printDone({ port: answers.port, worker }); } async function ask(rl, question, def) { const suffix = def !== undefined && def !== '' ? ` [${def}]` : ''; const a = (await rl.question(`${question}${suffix}: `)).trim(); return a || (def ?? ''); } async function runInteractive() { const rl = createInterface({ input, output }); try { if (existsSync(CONFIG_PATH)) { const yn = (await ask(rl, '既存の config.yaml を上書きしますか? 既存は config.yaml.bak に退避します (y/N)', 'N')); if (!/^y(es)?$/i.test(yn)) { console.log('中止しました(変更なし)。'); return; } } // connection type let connectionType = 'direct'; const ct = await ask(rl, '接続タイプ 1) direct (Ollama/vLLM 等) 2) aao_gateway (別 MAESTRO Gateway)', '1'); connectionType = ct === '2' || ct === 'aao_gateway' ? 'aao_gateway' : 'direct'; // endpoint let endpoint, base; const defEndpoint = connectionType === 'direct' ? 'http://localhost:11434/v1' : ''; const hint = connectionType === 'aao_gateway' ? '(例: http://gateway-host:9876/v1)' : ''; for (;;) { const raw = await ask(rl, `LLM endpoint URL${hint}`, defEndpoint); const r = parseEndpoint(raw); if (r.error) { console.log(` ⚠ ${r.error}`); continue; } ({ endpoint, base } = r); break; } // api key (gateway only) let apiKey; if (connectionType === 'aao_gateway') { for (;;) { apiKey = await ask(rl, 'gateway API キー (sk-aao-...)', ''); if (apiKey) break; console.log(' ⚠ aao_gateway では API キーが必須です。'); } } // probe + model console.log(' 接続を確認しています...'); const probe = await probeModels({ endpoint, base, apiKey }); let model; if (probe.ok && probe.models.length > 0) { console.log(' 利用可能なモデル:'); probe.models.forEach((m, i) => console.log(` ${i + 1}) ${m}`)); for (;;) { const sel = await ask(rl, `モデルを選択 (1-${probe.models.length}, または名前を直接入力)`, '1'); const idx = Number(sel); if (Number.isInteger(idx) && idx >= 1 && idx <= probe.models.length) { model = probe.models[idx - 1]; break; } if (sel) { model = sel; break; } } } else { if (!probe.ok) console.log(` ⚠ 接続できませんでした (${probe.error})。後で config を直せます。`); else console.log(' ⚠ モデル一覧が空でした。'); for (;;) { model = await ask(rl, 'モデル名を手入力', ''); if (model) break; } } // server port let port; for (;;) { const p = Number(await ask(rl, 'MAESTRO サーバーの listen port', '9876')); if (Number.isInteger(p) && p >= 1 && p <= 65535) { port = p; break; } console.log(' ⚠ port は 1-65535 の整数で入力してください。'); } const worker = buildWorkerEntry({ connectionType, endpoint, model, apiKey }); if (existsSync(CONFIG_PATH)) backupExistingConfig(); writeOutputs({ worker, port }); printDone({ port, worker }); } finally { rl.close(); } } async function main() { const argv = process.argv.slice(2); const yes = argv.includes('--yes'); const force = argv.includes('--force'); if (yes) await runNonInteractive(force); else await runInteractive(); } main().catch((e) => { console.error(`setup failed: ${e && e.message ? e.message : e}`); process.exit(1); });