sync: update from private repo (8447335)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
e00ea9fb0c
commit
42031b18b0
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
145
scripts/setup-lib.mjs
Normal 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
193
scripts/setup-lib.test.mjs
Normal 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
157
scripts/setup.mjs
Normal 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); });
|
||||
60
scripts/setup.smoke.test.mjs
Normal file
60
scripts/setup.smoke.test.mjs
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user