158 lines
6.6 KiB
JavaScript
158 lines
6.6 KiB
JavaScript
#!/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); });
|