From 42031b18b0f1bf44958319cb4a287ca0f73d2387 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Fri, 5 Jun 2026 06:25:26 +0000 Subject: [PATCH] sync: update from private repo (8447335) --- Dockerfile | 5 + docs/getting-started.md | 32 ++++-- package.json | 1 + scripts/setup-lib.mjs | 145 ++++++++++++++++++++++++++ scripts/setup-lib.test.mjs | 193 +++++++++++++++++++++++++++++++++++ scripts/setup.mjs | 157 ++++++++++++++++++++++++++++ scripts/setup.smoke.test.mjs | 60 +++++++++++ ui/src/index.css | 4 +- 8 files changed, 589 insertions(+), 8 deletions(-) create mode 100644 scripts/setup-lib.mjs create mode 100644 scripts/setup-lib.test.mjs create mode 100644 scripts/setup.mjs create mode 100644 scripts/setup.smoke.test.mjs diff --git a/Dockerfile b/Dockerfile index 0c16ce3..e31ee69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,11 @@ FROM node:22-alpine AS builder WORKDIR /app +# build:server runs `bash scripts/generate-version.sh` (set -o pipefail), and +# the alpine base ships only ash/sh — without bash the build fails with +# "bash: not found" (exit 127). The runtime stage installs bash separately. +RUN apk add --no-cache bash + COPY package.json package-lock.json* ./ COPY ui/package.json ui/package-lock.json* ./ui/ RUN npm ci --ignore-scripts diff --git a/docs/getting-started.md b/docs/getting-started.md index ed153cc..14d0436 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,20 +16,38 @@ git clone https://gitea.example.com/your-org/maestro.git cd maestro npm ci # バックエンド依存 npm --prefix ui ci # UI 依存 -cp config.yaml.example config.yaml ``` -## 3. 最小設定 +## 3. 最小設定(対話ウィザード) -`config.yaml` で LLM 接続先とワーカーを設定する。最低限、使用するモデルとエンドポイントを指定すればよい(既定は Ollama `http://localhost:11434/v1`)。各項目の意味は [configuration.md](configuration.md) を参照。 - -主要な環境変数で上書きも可能: +`npm run setup` で LLM 接続先を対話的に設定し、最小の `config.yaml` を生成する。 ```bash -export OLLAMA_BASE_URL=http://localhost:11434/v1 -export OLLAMA_MODEL=qwen2.5:14b +npm run setup ``` +- 接続タイプ(`direct` = Ollama/vLLM 等 / `aao_gateway` = 別 MAESTRO Gateway 経由)を選ぶ。 +- LLM endpoint URL(例 `http://localhost:11434/v1`)を入力。接続を確認し、見つかったモデルから選択できる(接続できなくてもモデル名を手入力して続行可能)。 +- `aao_gateway` の場合は API キー(`sk-aao-...`)も入力する(`config.yaml` に保存され、権限は 0600)。 +- 最後に MAESTRO サーバーの listen port(既定 9876)を設定する。 + +非対話(Docker / CI): + +```bash +SETUP_LLM_ENDPOINT=http://localhost:11434/v1 SETUP_MODEL=qwen3:14b npm run setup -- --yes +``` + +```bash +# 別 MAESTRO Gateway 経由の場合 +SETUP_CONNECTION_TYPE=aao_gateway \ + SETUP_LLM_ENDPOINT=http://gateway-host:9876/v1 \ + SETUP_LLM_API_KEY=sk-aao-... \ + SETUP_MODEL=qwen3:14b \ + npm run setup -- --yes +``` + +詳細設定(複数ワーカー・tools・auth など)は生成後に `config.yaml` を直接編集するか、起動後の Settings UI で行う。`config.yaml.example` に全項目の説明がある。 + ## 4. ビルドと起動 ```bash diff --git a/package.json b/package.json index 0da6401..a311ab5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "node": ">=22" }, "scripts": { + "setup": "node scripts/setup.mjs", "build": "npm run build:server", "build:server": "bash scripts/generate-version.sh && tsc && npm run copy:assets", "build:ui": "npm --prefix ui run build", diff --git a/scripts/setup-lib.mjs b/scripts/setup-lib.mjs new file mode 100644 index 0000000..86ab603 --- /dev/null +++ b/scripts/setup-lib.mjs @@ -0,0 +1,145 @@ +// Pure, dependency-free helpers for the setup wizard. Tested by setup-lib.test.mjs. + +export const ALL_ROLES = ['auto', 'fast', 'quality', 'title', 'reflection']; +export const CONNECTION_TYPES = ['direct', 'aao_gateway']; + +// Normalize a user-entered LLM endpoint URL. +// Returns { endpoint, base } or { error }. +// endpoint = scheme://host[:port]/path with no trailing slash (used for {endpoint}/models) +// base = endpoint with a trailing /v1 stripped (used for {base}/api/tags), mirroring src/worker.ts +export function parseEndpoint(input) { + const raw = String(input ?? '').trim(); + if (!raw) return { error: 'endpoint is required' }; + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`; + let url; + try { url = new URL(withScheme); } catch { return { error: `invalid URL: ${raw}` }; } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { error: `unsupported scheme: ${url.protocol.replace(':', '')}` }; + } + if (!url.hostname) return { error: 'missing host' }; + const endpoint = url.toString().replace(/\/+$/, ''); + const base = endpoint.replace(/\/v1$/, ''); + return { endpoint, base }; +} + +// Build the single llm.workers[0] entry the wizard writes. +export function buildWorkerEntry({ connectionType, endpoint, model, apiKey }) { + const entry = { + id: connectionType === 'aao_gateway' ? 'gateway' : 'local-llm', + connection_type: connectionType, + endpoint, + model, + roles: [...ALL_ROLES], + max_concurrency: 1, + enabled: true, + vlm: false, + }; + if (connectionType === 'aao_gateway') entry.api_key = apiKey; + return entry; +} + +// Quote a scalar when a YAML plain scalar would be ambiguous or invalid. +function yamlScalar(v) { + if (typeof v === 'boolean' || typeof v === 'number') return String(v); + const s = String(v); + const needsQuote = + s === '' || + /[:#\[\]{}&*!|>'"%@`,]/.test(s) || // structural / indicator chars (note: ':' covers host:port, model:tag) + /^[\s]|[\s]$/.test(s) || // leading/trailing space + /^[-?]/.test(s) || // leading '-' or '?' + /^(true|false|null|yes|no|on|off|~)$/i.test(s) || + /^[0-9.+-]/.test(s) || // could be parsed as a number + /[\n\r\t]/.test(s); // control chars that would break multiline YAML + if (!needsQuote) return s; + return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`; +} + +// Render a minimal config.yaml (config_version + one llm.workers entry). +// We do NOT edit config.yaml.example (fragile + contradicts the minimal goal). +export function renderConfigYaml(worker) { + const L = []; + L.push('# Generated by `npm run setup`. Full reference: config.yaml.example'); + L.push('config_version: 2'); + L.push(''); + L.push('llm:'); + L.push(' workers:'); + L.push(` - id: ${yamlScalar(worker.id)}`); + L.push(` connection_type: ${yamlScalar(worker.connection_type)}`); + L.push(` endpoint: ${yamlScalar(worker.endpoint)}`); + L.push(` model: ${yamlScalar(worker.model)}`); + if (worker.api_key !== undefined) L.push(` api_key: ${yamlScalar(worker.api_key)}`); + L.push(` roles: [${worker.roles.map(yamlScalar).join(', ')}]`); + L.push(` max_concurrency: ${worker.max_concurrency}`); + L.push(` enabled: ${worker.enabled}`); + L.push(` vlm: ${worker.vlm}`); + L.push(''); + return L.join('\n'); +} + +// Upsert only PORT in a .env file; never write OLLAMA_* (those hit the legacy +// provider.* env override path in src/config.ts and would conflict with llm.workers). +export function renderDotenv(existingText, { port }) { + const line = `PORT=${port}`; + const text = String(existingText ?? ''); + if (!text.trim()) return `${line}\n`; + const kept = text.replace(/\n$/, '').split('\n').filter((l) => !/^\s*PORT\s*=/.test(l)); + kept.push(line); + return `${kept.join('\n')}\n`; +} + +// Resolve + validate non-interactive answers from env. Single endpoint env for both types. +export function parseAnswersFromEnv(env) { + const connectionType = env.SETUP_CONNECTION_TYPE || 'direct'; + if (!CONNECTION_TYPES.includes(connectionType)) { + return { error: `SETUP_CONNECTION_TYPE must be one of ${CONNECTION_TYPES.join(', ')}` }; + } + const ep = parseEndpoint(env.SETUP_LLM_ENDPOINT || ''); + if (ep.error) return { error: `SETUP_LLM_ENDPOINT: ${ep.error}` }; + const model = String(env.SETUP_MODEL || '').trim(); + if (!model) return { error: 'SETUP_MODEL is required in --yes mode' }; + let apiKey; + if (connectionType === 'aao_gateway') { + apiKey = String(env.SETUP_LLM_API_KEY || '').trim(); + if (!apiKey) return { error: 'SETUP_LLM_API_KEY is required for aao_gateway' }; + } + const portRaw = env.PORT || '9876'; + const port = Number(portRaw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { error: `PORT must be an integer 1-65535 (got ${portRaw})` }; + } + return { answers: { connectionType, endpoint: ep.endpoint, base: ep.base, model, apiKey, port } }; +} + +// Probe model availability the same way the runtime worker does (src/worker.ts): +// 1) GET {base}/api/tags -> Ollama { models: [{ name }] } +// 2) GET {endpoint}/models -> OpenAI { data: [{ id }] } +// Bearer is forwarded when apiKey is set (AAO gateway requires it). +export async function probeModels({ endpoint, base, apiKey, fetchImpl = fetch, timeoutMs = 5000 }) { + const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; + const tryFetch = async (url, parse) => { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const r = await fetchImpl(url, { headers, signal: ac.signal }); + if (!r || !r.ok) return { ok: false, error: `HTTP ${r ? r.status : 'no-response'} from ${url}` }; + let data; + try { data = await r.json(); } catch { return { ok: false, error: `non-JSON response from ${url}` }; } + return { ok: true, models: parse(data) }; + } catch (e) { + const why = e && e.name === 'AbortError' ? 'timeout' : (e && e.message) || 'fetch failed'; + return { ok: false, error: `${url}: ${why}` }; + } finally { + clearTimeout(timer); + } + }; + + const ollama = await tryFetch(`${base}/api/tags`, + (d) => (d && Array.isArray(d.models) ? d.models.map((m) => m && m.name).filter(Boolean) : [])); + if (ollama.ok) return { ok: true, models: ollama.models }; + + const openai = await tryFetch(`${endpoint}/models`, + (d) => (d && Array.isArray(d.data) ? d.data.map((m) => m && m.id).filter(Boolean) : [])); + if (openai.ok) return { ok: true, models: openai.models }; + + return { ok: false, models: [], error: openai.error || ollama.error }; +} diff --git a/scripts/setup-lib.test.mjs b/scripts/setup-lib.test.mjs new file mode 100644 index 0000000..61d7259 --- /dev/null +++ b/scripts/setup-lib.test.mjs @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { parseEndpoint, buildWorkerEntry, ALL_ROLES, renderConfigYaml, renderDotenv, parseAnswersFromEnv, probeModels } from './setup-lib.mjs'; +import { parse as parseYaml } from 'yaml'; + +describe('parseEndpoint', () => { + it('keeps a full http URL and derives the /v1-stripped base', () => { + expect(parseEndpoint('http://localhost:11434/v1')).toEqual({ + endpoint: 'http://localhost:11434/v1', + base: 'http://localhost:11434', + }); + }); + it('adds http:// when scheme is missing', () => { + expect(parseEndpoint('localhost:11434/v1').endpoint).toBe('http://localhost:11434/v1'); + }); + it('accepts https and IPv6 literals', () => { + const r = parseEndpoint('https://[::1]:9876/v1'); + expect(r.endpoint).toBe('https://[::1]:9876/v1'); + expect(r.base).toBe('https://[::1]:9876'); + }); + it('strips a trailing slash', () => { + expect(parseEndpoint('http://h:11434/v1/').endpoint).toBe('http://h:11434/v1'); + }); + it('base equals endpoint when there is no /v1 suffix', () => { + const r = parseEndpoint('http://h:11434'); + expect(r.endpoint).toBe('http://h:11434'); + expect(r.base).toBe('http://h:11434'); + }); + it('rejects empty input', () => { + expect(parseEndpoint(' ').error).toMatch(/required/); + }); + it('rejects a non-http scheme', () => { + expect(parseEndpoint('ftp://h/v1').error).toMatch(/scheme/); + }); + it('rejects unparseable input', () => { + expect(parseEndpoint('http://').error).toBeTruthy(); + }); +}); + +describe('buildWorkerEntry', () => { + it('builds a direct worker with all roles and no api_key', () => { + const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://h/v1', model: 'qwen3:32b' }); + expect(w).toEqual({ + id: 'local-llm', connection_type: 'direct', endpoint: 'http://h/v1', model: 'qwen3:32b', + roles: ALL_ROLES, max_concurrency: 1, enabled: true, vlm: false, + }); + expect('api_key' in w).toBe(false); + }); + it('builds a gateway worker with api_key and id "gateway"', () => { + const w = buildWorkerEntry({ connectionType: 'aao_gateway', endpoint: 'http://g/v1', model: 'm', apiKey: 'sk-aao-x' }); + expect(w.id).toBe('gateway'); + expect(w.connection_type).toBe('aao_gateway'); + expect(w.api_key).toBe('sk-aao-x'); + }); + it('returns a fresh roles array (not a shared reference)', () => { + const a = buildWorkerEntry({ connectionType: 'direct', endpoint: 'e', model: 'm' }); + a.roles.push('mutated'); + const b = buildWorkerEntry({ connectionType: 'direct', endpoint: 'e', model: 'm' }); + expect(b.roles).toEqual(ALL_ROLES); + }); +}); + +describe('renderConfigYaml', () => { + it('emits config_version + a single llm.workers entry that re-parses correctly', () => { + const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b' }); + const text = renderConfigYaml(w); + const parsed = parseYaml(text); + expect(parsed.config_version).toBe(2); + expect(parsed.llm.workers).toHaveLength(1); + expect(parsed.llm.workers[0]).toMatchObject({ + id: 'local-llm', connection_type: 'direct', + endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b', + roles: ['auto', 'fast', 'quality', 'title', 'reflection'], + max_concurrency: 1, enabled: true, vlm: false, + }); + }); + it('quotes values containing a colon (model, endpoint) so they do not become maps', () => { + const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://h:11434/v1', model: 'qwen3:32b' }); + const parsed = parseYaml(renderConfigYaml(w)); + expect(parsed.llm.workers[0].model).toBe('qwen3:32b'); + expect(parsed.llm.workers[0].endpoint).toBe('http://h:11434/v1'); + }); + it('includes api_key for gateway and round-trips it', () => { + const w = buildWorkerEntry({ connectionType: 'aao_gateway', endpoint: 'http://g:9876/v1', model: 'm', apiKey: 'sk-aao-abc123' }); + const parsed = parseYaml(renderConfigYaml(w)); + expect(parsed.llm.workers[0].api_key).toBe('sk-aao-abc123'); + }); + it('does not include example comment noise (minimal output)', () => { + const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'e', model: 'm' }); + expect(renderConfigYaml(w)).not.toMatch(/AAO Gateway Server|backends:|metrics:/); + }); + it('quotes and escapes a value containing a newline so YAML stays valid', () => { + const w = buildWorkerEntry({ connectionType: 'direct', endpoint: 'http://h/v1', model: 'a\nb' }); + const parsed = parseYaml(renderConfigYaml(w)); + expect(parsed.llm.workers[0].model).toBe('a\nb'); + }); +}); + +describe('renderDotenv', () => { + it('creates PORT line from empty input', () => { + expect(renderDotenv('', { port: 9876 })).toBe('PORT=9876\n'); + }); + it('upserts an existing PORT line and preserves other lines', () => { + const out = renderDotenv('FOO=bar\nPORT=1000\nBAZ=qux\n', { port: 9876 }); + expect(out).toContain('FOO=bar'); + expect(out).toContain('BAZ=qux'); + expect(out).toContain('PORT=9876'); + expect(out).not.toContain('PORT=1000'); + }); + it('appends PORT when absent and ends with a newline', () => { + const out = renderDotenv('FOO=bar\n', { port: 9876 }); + expect(out).toBe('FOO=bar\nPORT=9876\n'); + }); + it('collapses multiple existing PORT lines into exactly one', () => { + const out = renderDotenv('PORT=1\nFOO=bar\nPORT=2\n', { port: 9876 }); + expect(out.match(/^PORT=/gm)).toHaveLength(1); + expect(out).toContain('PORT=9876'); + expect(out).toContain('FOO=bar'); + }); +}); + +describe('parseAnswersFromEnv', () => { + it('resolves a direct setup from env', () => { + const r = parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'http://localhost:11434/v1', SETUP_MODEL: 'm' }); + expect(r.error).toBeUndefined(); + expect(r.answers).toMatchObject({ connectionType: 'direct', endpoint: 'http://localhost:11434/v1', base: 'http://localhost:11434', model: 'm', port: 9876 }); + }); + it('requires SETUP_LLM_API_KEY for aao_gateway', () => { + const r = parseAnswersFromEnv({ SETUP_CONNECTION_TYPE: 'aao_gateway', SETUP_LLM_ENDPOINT: 'http://g/v1', SETUP_MODEL: 'm' }); + expect(r.error).toMatch(/SETUP_LLM_API_KEY/); + }); + it('requires SETUP_MODEL', () => { + expect(parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'http://h/v1' }).error).toMatch(/SETUP_MODEL/); + }); + it('rejects a bad endpoint and a bad port', () => { + expect(parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'ftp://h', SETUP_MODEL: 'm' }).error).toMatch(/SETUP_LLM_ENDPOINT/); + expect(parseAnswersFromEnv({ SETUP_LLM_ENDPOINT: 'http://h/v1', SETUP_MODEL: 'm', PORT: '70000' }).error).toMatch(/PORT/); + }); + it('rejects an unknown connection type', () => { + expect(parseAnswersFromEnv({ SETUP_CONNECTION_TYPE: 'weird', SETUP_LLM_ENDPOINT: 'http://h/v1', SETUP_MODEL: 'm' }).error).toMatch(/SETUP_CONNECTION_TYPE/); + }); +}); + +function res(status, body, { json = true } = {}) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => { if (!json) throw new Error('not json'); return body; }, + }; +} + +describe('probeModels', () => { + it('uses Ollama /api/tags first and parses {models:[{name}]}', async () => { + const calls = []; + const fetchImpl = async (url) => { calls.push(url); return res(200, { models: [{ name: 'qwen3:32b' }] }); }; + const r = await probeModels({ endpoint: 'http://h:11434/v1', base: 'http://h:11434', fetchImpl }); + expect(r).toEqual({ ok: true, models: ['qwen3:32b'] }); + expect(calls[0]).toBe('http://h:11434/api/tags'); + }); + it('falls back to {endpoint}/models and parses {data:[{id}]}', async () => { + const calls = []; + const fetchImpl = async (url) => { calls.push(url); return url.endsWith('/api/tags') ? res(404, {}) : res(200, { data: [{ id: 'gpt-x' }] }); }; + const r = await probeModels({ endpoint: 'http://g:9876/v1', base: 'http://g:9876', fetchImpl }); + expect(r).toEqual({ ok: true, models: ['gpt-x'] }); + expect(calls).toEqual(['http://g:9876/api/tags', 'http://g:9876/v1/models']); + }); + it('forwards the Bearer header when apiKey is set', async () => { + let seen; + const fetchImpl = async (url, init) => { seen = init?.headers?.Authorization; return res(404, {}); }; + await probeModels({ endpoint: 'http://g/v1', base: 'http://g', apiKey: 'sk-aao-x', fetchImpl }); + expect(seen).toBe('Bearer sk-aao-x'); + }); + it('returns ok:false on non-2xx from both probes', async () => { + const fetchImpl = async () => res(500, {}); + const r = await probeModels({ endpoint: 'http://h/v1', base: 'http://h', fetchImpl }); + expect(r.ok).toBe(false); + expect(r.models).toEqual([]); + expect(r.error).toBeTruthy(); + }); + it('treats non-JSON (HTML error) as failure', async () => { + const fetchImpl = async () => res(200, null, { json: false }); + const r = await probeModels({ endpoint: 'http://h/v1', base: 'http://h', fetchImpl }); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/non-JSON/); + }); + it('reports a timeout via AbortError', async () => { + const fetchImpl = async (_url, init) => new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); + }); + const r = await probeModels({ endpoint: 'http://h/v1', base: 'http://h', fetchImpl, timeoutMs: 10 }); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/timeout/); + }); +}); diff --git a/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..8133713 --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,157 @@ +#!/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); }); diff --git a/scripts/setup.smoke.test.mjs b/scripts/setup.smoke.test.mjs new file mode 100644 index 0000000..c689f6b --- /dev/null +++ b/scripts/setup.smoke.test.mjs @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, copyFileSync, readFileSync, statSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { parse as parseYaml } from 'yaml'; + +const REPO = resolve(import.meta.dirname, '..'); +const SETUP = join(REPO, 'scripts', 'setup.mjs'); + +// Run the wizard in --yes mode inside `dir`. extraArgs lets a case add --force. +function runYes(dir, env, extraArgs = []) { + execFileSync('node', [SETUP, '--yes', ...extraArgs], { + cwd: dir, env: { ...process.env, ...env }, stdio: 'pipe', + }); +} + +describe('setup.mjs --yes smoke', () => { + it('writes a minimal config.yaml (0600) and .env PORT for a direct setup', () => { + const dir = mkdtempSync(join(tmpdir(), 'setup-smoke-')); + copyFileSync(join(REPO, 'config.yaml.example'), join(dir, 'config.yaml.example')); + runYes(dir, { SETUP_LLM_ENDPOINT: 'http://localhost:11434/v1', SETUP_MODEL: 'qwen3:32b', PORT: '9876' }); + + const cfg = parseYaml(readFileSync(join(dir, 'config.yaml'), 'utf-8')); + expect(cfg.config_version).toBe(2); + expect(cfg.llm.workers).toHaveLength(1); + expect(cfg.llm.workers[0]).toMatchObject({ connection_type: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b' }); + expect(readFileSync(join(dir, '.env'), 'utf-8')).toContain('PORT=9876'); + if (process.platform !== 'win32') { + expect((statSync(join(dir, 'config.yaml')).mode & 0o777).toString(8)).toBe('600'); + } + }); + + it('writes api_key for a gateway setup and backs up an existing config (with --force)', () => { + const dir = mkdtempSync(join(tmpdir(), 'setup-smoke-')); + copyFileSync(join(REPO, 'config.yaml.example'), join(dir, 'config.yaml')); // pre-existing -> forces backup + runYes(dir, { + SETUP_CONNECTION_TYPE: 'aao_gateway', SETUP_LLM_ENDPOINT: 'http://g:9876/v1', + SETUP_LLM_API_KEY: 'sk-aao-secret123', SETUP_MODEL: 'm', + }, ['--force']); + + const cfg = parseYaml(readFileSync(join(dir, 'config.yaml'), 'utf-8')); + expect(cfg.llm.workers[0].api_key).toBe('sk-aao-secret123'); + expect(existsSync(join(dir, 'config.yaml.bak'))).toBe(true); + if (process.platform !== 'win32') { + expect((statSync(join(dir, 'config.yaml.bak')).mode & 0o777).toString(8)).toBe('600'); + } + }); + + it('exits non-zero when required env is missing', () => { + const dir = mkdtempSync(join(tmpdir(), 'setup-smoke-')); + expect(() => runYes(dir, { SETUP_LLM_ENDPOINT: 'http://h/v1' /* no SETUP_MODEL */ })).toThrow(); + }); + + it('refuses to overwrite an existing config without --force', () => { + const dir = mkdtempSync(join(tmpdir(), 'setup-smoke-')); + copyFileSync(join(REPO, 'config.yaml.example'), join(dir, 'config.yaml')); + expect(() => runYes(dir, { SETUP_LLM_ENDPOINT: 'http://h/v1', SETUP_MODEL: 'm' })).toThrow(); + }); +}); diff --git a/ui/src/index.css b/ui/src/index.css index 33c0446..5f24f93 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -33,7 +33,9 @@ borders rely on hairline; verify surface/surface-2/hairline separation and WCAG AA text contrast before shipping (spec §4.1 #6). */ --slate-50: #0a0a0c; --slate-100: #131316; --slate-200: #202024; - --slate-300: #2e2e34; --slate-400: #52525b; --slate-500: #8b8b93; + /* slate-400/500 lifted to clear WCAG AA on canvas (#0a0a0c): secondary + text (timestamps, version, meta) was ~3:1 at #52525b — too dim. */ + --slate-300: #383840; --slate-400: #7a7a85; --slate-500: #9b9ba5; --slate-600: #a1a1aa; --slate-700: #c4c4cc; --slate-800: #dedee2; --slate-900: #f1f1f3; --slate-950: #fafafa; --gray-400: #6b7280; --gray-500: #9ca3af; --gray-700: #d1d5db;