sync: update from private repo (8447335)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-05 06:25:26 +00:00
parent e00ea9fb0c
commit 42031b18b0
8 changed files with 589 additions and 8 deletions

View File

@ -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

View File

@ -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

View File

@ -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",

145
scripts/setup-lib.mjs Normal file
View File

@ -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 };
}

193
scripts/setup-lib.test.mjs Normal file
View File

@ -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/);
});
});

157
scripts/setup.mjs Normal file
View File

@ -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); });

View File

@ -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();
});
});

View File

@ -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;