feat(mosaic): IUV-M02 — CORS/FQDN UX polish + skill installer rework (#444)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed

This commit was merged in pull request #444.
This commit is contained in:
2026-04-05 23:44:07 +00:00
parent 43667d7349
commit 172bacb30f
7 changed files with 420 additions and 18 deletions

View File

@@ -7,6 +7,11 @@ SKILLS_REPO_DIR="${MOSAIC_SKILLS_REPO_DIR:-$MOSAIC_HOME/sources/agent-skills}"
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local"
# Colon-separated list of skill names to install. When set, only these skills
# are linked into runtime skill directories. Empty/unset = link all skills
# (the legacy "mosaic sync" full-catalog behavior).
MOSAIC_INSTALL_SKILLS="${MOSAIC_INSTALL_SKILLS:-}"
fetch=1
link_only=0
@@ -25,6 +30,7 @@ Env:
MOSAIC_HOME Default: ~/.config/mosaic
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
USAGE
}
@@ -156,6 +162,27 @@ link_targets=(
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
# Build an associative array from the colon-separated whitelist for O(1) lookup.
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
declare -A _skill_whitelist=()
_whitelist_active=0
if [[ -n "$MOSAIC_INSTALL_SKILLS" ]]; then
_whitelist_active=1
IFS=':' read -ra _wl_items <<< "$MOSAIC_INSTALL_SKILLS"
for _item in "${_wl_items[@]}"; do
[[ -n "$_item" ]] && _skill_whitelist["$_item"]=1
done
fi
is_skill_selected() {
local name="$1"
if [[ $_whitelist_active -eq 0 ]]; then
return 0
fi
[[ -n "${_skill_whitelist[$name]:-}" ]] && return 0
return 1
}
link_skill_into_target() {
local skill_path="$1"
local target_dir="$2"
@@ -168,6 +195,11 @@ link_skill_into_target() {
return
fi
# Respect the install whitelist (set during first-run wizard).
if ! is_skill_selected "$name"; then
return
fi
link_path="$target_dir/$name"
if [[ -L "$link_path" ]]; then

View File

@@ -0,0 +1,186 @@
/**
* Tests for the skill installer rework (IUV-02-03).
*
* We mock `node:child_process` to verify that:
* 1. syncSkills passes MOSAIC_INSTALL_SKILLS with the exact selected subset
* 2. When the script exits non-zero, the failure is surfaced to the user
* 3. When the script is missing, a clear error is shown (not a silent no-op)
* 4. An empty selection is a no-op (script never called)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { WizardState } from '../types.js';
import type { ConfigService } from '../config/config-service.js';
// ── spawnSync mock ─────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spawnSyncMock = vi.fn<any>();
vi.mock('node:child_process', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spawnSync: (...args: any[]) => spawnSyncMock(...args),
}));
// ── platform stub ──────────────────────────────────────────────────────────────
vi.mock('../platform/detect.js', () => ({
getShellProfilePath: () => null,
}));
import { finalizeStage } from './finalize.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeState(mosaicHome: string, selectedSkills: string[] = []): WizardState {
return {
mosaicHome,
sourceDir: mosaicHome,
mode: 'quick',
installAction: 'fresh',
soul: { agentName: 'TestBot', communicationStyle: 'direct' },
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills,
};
}
function buildPrompter() {
return {
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
text: vi.fn(),
confirm: vi.fn(),
select: vi.fn(),
multiselect: vi.fn(),
groupMultiselect: vi.fn(),
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
separator: vi.fn(),
};
}
function makeConfigService(): ConfigService {
return {
readSoul: vi.fn().mockResolvedValue({}),
readUser: vi.fn().mockResolvedValue({}),
readTools: vi.fn().mockResolvedValue({}),
writeSoul: vi.fn().mockResolvedValue(undefined),
writeUser: vi.fn().mockResolvedValue(undefined),
writeTools: vi.fn().mockResolvedValue(undefined),
syncFramework: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
set: vi.fn(),
getSection: vi.fn(),
} as unknown as ConfigService;
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe('finalizeStage — skill installer', () => {
let tmp: string;
let binDir: string;
let syncScript: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
binDir = join(tmp, 'bin');
mkdirSync(binDir, { recursive: true });
syncScript = join(binDir, 'mosaic-sync-skills');
// Default: script exists and succeeds
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
vi.clearAllMocks();
});
function findSkillsSyncCall() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (spawnSyncMock.mock.calls as any[][]).find(
(args) =>
Array.isArray(args[1]) &&
(args[1] as string[]).some((a) => a.includes('mosaic-sync-skills')),
);
}
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const call = findSkillsSyncCall();
expect(call).toBeDefined();
const opts = call![2] as { env?: Record<string, string> };
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
});
it('skips the sync script entirely when no skills are selected', async () => {
const state = makeState(tmp, []);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
expect(findSkillsSyncCall()).toBeUndefined();
});
it('warns the user when the sync script exits non-zero', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stdout: '',
stderr: 'git clone failed: connection refused',
});
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('git clone failed'));
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('mosaic sync'));
});
it('warns the user when the sync script is missing', async () => {
// Remove the script to simulate a missing installation
rmSync(syncScript);
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
// spawnSync should NOT have been called for the skills script
expect(findSkillsSyncCall()).toBeUndefined();
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
});
it('includes skills count in the summary when install succeeds', async () => {
const state = makeState(tmp, ['brainstorming', 'lint']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const noteMock = p.note as ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const summaryCall = (noteMock.mock.calls as any[][]).find(
([, title]) => title === 'Installation Summary',
);
expect(summaryCall).toBeDefined();
expect(summaryCall![0] as string).toContain('2 installed');
});
});

View File

@@ -25,14 +25,68 @@ function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
}
}
function syncSkills(mosaicHome: string): void {
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
if (existsSync(script)) {
try {
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
} catch {
// Non-fatal
interface SyncSkillsResult {
success: boolean;
installedCount: number;
failureReason?: string;
}
/**
* Sync skills from the catalog and link only the user-selected subset.
*
* When `selectedSkills` is non-empty the script receives the list via
* `MOSAIC_INSTALL_SKILLS` (colon-separated) so it can skip unlisted skills
* during the linking phase. An empty selection is a no-op.
*
* Failure modes surfaced here:
* - Script not found → tells the user explicitly
* - Script exits non-zero → stderr is captured and reported
* - Catalog directory missing → detected before exec, reported clearly
*/
function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsResult {
if (selectedSkills.length === 0) {
return { success: true, installedCount: 0 };
}
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
if (!existsSync(script)) {
return {
success: false,
installedCount: 0,
failureReason: `Skills sync script not found at ${script} — run 'mosaic sync' after installation.`,
};
}
try {
const result = spawnSync('bash', [script], {
timeout: 60000,
stdio: 'pipe',
encoding: 'utf-8',
env: {
...process.env,
MOSAIC_HOME: mosaicHome,
MOSAIC_INSTALL_SKILLS: selectedSkills.join(':'),
},
});
if (result.status !== 0) {
const stderr = (result.stderr ?? '').trim();
return {
success: false,
installedCount: 0,
failureReason: stderr
? `Skills sync failed: ${stderr}`
: `Skills sync script exited with code ${(result.status ?? 'unknown').toString()}`,
};
}
return { success: true, installedCount: selectedSkills.length };
} catch (err) {
return {
success: false,
installedCount: 0,
failureReason: `Skills sync threw: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
@@ -124,10 +178,11 @@ export async function finalizeStage(
const skipClaudeHooks = state.hooks?.accepted === false;
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
// 4. Sync skills
// 4. Sync skills (only installs the user-selected subset)
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
if (state.selectedSkills.length > 0) {
spin.update('Syncing skills...');
syncSkills(state.mosaicHome);
spin.update(`Installing ${state.selectedSkills.length.toString()} selected skill(s)...`);
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills);
}
// 5. Run doctor
@@ -136,15 +191,27 @@ export async function finalizeStage(
spin.stop('Installation complete');
// Report skill install failure clearly (non-fatal but user should know)
if (!skillsResult.success && skillsResult.failureReason) {
p.warn(skillsResult.failureReason);
p.warn("Run 'mosaic sync' manually after installation to install skills.");
}
// 6. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
// 7. Summary
const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0
? `${skillsResult.installedCount.toString()} installed`
: 'none selected'
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${state.selectedSkills.length.toString()} selected`,
`Skills: ${skillsSummary}`,
`Config: ${state.mosaicHome}`,
];

View File

@@ -158,7 +158,7 @@ export async function gatewayBootstrapStage(
host,
port,
tier: 'local',
corsOrigin: 'http://localhost:3000',
corsOrigin: `http://${host}:3000`,
}),
admin: { name, email, password },
};

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { deriveCorsOrigin } from './gateway-config.js';
describe('deriveCorsOrigin', () => {
describe('localhost / loopback — always http', () => {
it('localhost port 3000 → http://localhost:3000', () => {
expect(deriveCorsOrigin('localhost', 3000)).toBe('http://localhost:3000');
});
it('127.0.0.1 port 3000 → http://127.0.0.1:3000', () => {
expect(deriveCorsOrigin('127.0.0.1', 3000)).toBe('http://127.0.0.1:3000');
});
it('localhost port 80 omits port suffix', () => {
expect(deriveCorsOrigin('localhost', 80)).toBe('http://localhost');
});
it('localhost port 443 still uses http (loopback overrides), includes port', () => {
// 443 is the https default port, but since localhost forces http, the port
// is NOT the default for http (80), so it must be included.
expect(deriveCorsOrigin('localhost', 443)).toBe('http://localhost:443');
});
it('useHttps=false on localhost keeps http', () => {
expect(deriveCorsOrigin('localhost', 3000, false)).toBe('http://localhost:3000');
});
it('useHttps=true on localhost still uses http (loopback wins)', () => {
// Passing useHttps=true for localhost is unusual but the function honours
// the explicit override — loopback detection only applies when useHttps is
// undefined (auto-detect path).
expect(deriveCorsOrigin('localhost', 3000, true)).toBe('https://localhost:3000');
});
});
describe('remote hostname — defaults to https', () => {
it('example.com port 3000 → https://example.com:3000', () => {
expect(deriveCorsOrigin('example.com', 3000)).toBe('https://example.com:3000');
});
it('example.com port 443 omits port suffix', () => {
expect(deriveCorsOrigin('example.com', 443)).toBe('https://example.com');
});
it('example.com port 80 → https://example.com:80 (non-default port for https)', () => {
expect(deriveCorsOrigin('example.com', 80)).toBe('https://example.com:80');
});
it('useHttps=false on remote host uses http', () => {
expect(deriveCorsOrigin('example.com', 3000, false)).toBe('http://example.com:3000');
});
it('useHttps=false on remote host, port 80 omits suffix', () => {
expect(deriveCorsOrigin('example.com', 80, false)).toBe('http://example.com');
});
});
describe('subdomain and non-standard hostnames', () => {
it('sub.domain.example.com defaults to https', () => {
expect(deriveCorsOrigin('sub.domain.example.com', 3000)).toBe(
'https://sub.domain.example.com:3000',
);
});
it('myserver.local defaults to https (not loopback)', () => {
expect(deriveCorsOrigin('myserver.local', 8080)).toBe('https://myserver.local:8080');
});
});
});

View File

@@ -26,6 +26,25 @@ function isHeadless(): boolean {
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
}
// ── CORS derivation ───────────────────────────────────────────────────────────
/**
* Derive a full CORS origin URL from a user-provided hostname + web UI port.
*
* Rules:
* - "localhost" and "127.0.0.1" always use http (never https)
* - Everything else uses https by default; pass useHttps=false to override
* - Standard ports (80 for http, 443 for https) are omitted from the origin
*/
export function deriveCorsOrigin(hostname: string, webUiPort: number, useHttps?: boolean): string {
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
const proto =
useHttps !== undefined ? (useHttps ? 'https' : 'http') : isLocalhost ? 'http' : 'https';
const defaultPort = proto === 'https' ? 443 : 80;
const portSuffix = webUiPort === defaultPort ? '' : `:${webUiPort.toString()}`;
return `${proto}://${hostname}${portSuffix}`;
}
// ── .env helpers ──────────────────────────────────────────────────────────────
function readEnvVarFromFile(envFile: string, key: string): string | null {
@@ -228,7 +247,9 @@ export async function gatewayConfigStage(
host: existing.host,
port: existing.port,
tier: 'local',
corsOrigin: 'http://localhost:3000',
corsOrigin:
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ??
deriveCorsOrigin('localhost', 3000),
regeneratedConfig: false,
};
return { ready: true, host: existing.host, port: existing.port };
@@ -281,7 +302,8 @@ export async function gatewayConfigStage(
host,
port,
tier: 'local',
corsOrigin: 'http://localhost:3000',
corsOrigin:
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ?? deriveCorsOrigin('localhost', 3000),
regeneratedConfig: false,
};
} else {
@@ -395,6 +417,7 @@ async function collectAndWriteConfig(
let valkeyUrl: string | undefined;
let anthropicKey: string;
let corsOrigin: string;
let hostname: string;
if (isHeadless()) {
p.log('Headless mode detected — reading configuration from environment variables.');
@@ -408,7 +431,13 @@ async function collectAndWriteConfig(
databaseUrl = process.env['MOSAIC_DATABASE_URL'];
valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
// MOSAIC_CORS_ORIGIN is the full override (e.g. from CI).
// MOSAIC_HOSTNAME is the user-friendly alternative — derive from it.
const corsOverride = process.env['MOSAIC_CORS_ORIGIN'];
const hostnameEnv = process.env['MOSAIC_HOSTNAME'] ?? 'localhost';
hostname = hostnameEnv;
corsOrigin = corsOverride ?? deriveCorsOrigin(hostnameEnv, 3000);
if (tier === 'team') {
const missing: string[] = [];
@@ -442,11 +471,24 @@ async function collectAndWriteConfig(
defaultValue: '',
});
corsOrigin = await p.text({
message: 'CORS origin',
initialValue: 'http://localhost:3000',
defaultValue: 'http://localhost:3000',
hostname = await p.text({
message: 'Web UI hostname (for browser access)',
initialValue: 'localhost',
defaultValue: 'localhost',
placeholder: 'e.g. localhost or myserver.example.com',
});
// For non-localhost, ask if HTTPS is in use (defaults to yes for remote hosts)
let useHttps: boolean | undefined;
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
useHttps = await p.confirm({
message: 'Is HTTPS enabled for the web UI?',
initialValue: true,
});
}
corsOrigin = deriveCorsOrigin(hostname, 3000, useHttps);
p.log(`CORS origin set to: ${corsOrigin}`);
}
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
@@ -500,6 +542,7 @@ async function collectAndWriteConfig(
valkeyUrl,
anthropicKey: anthropicKey || undefined,
corsOrigin,
hostname,
regeneratedConfig: true,
};
}

View File

@@ -62,6 +62,11 @@ export interface GatewayState {
valkeyUrl?: string;
anthropicKey?: string;
corsOrigin: string;
/**
* Raw hostname the user entered (e.g. "localhost", "myserver.example.com").
* The full CORS origin (`corsOrigin`) is derived from this + protocol + webUiPort.
*/
hostname?: string;
/** True when .env + mosaic.config.json were (re)generated in this run. */
regeneratedConfig?: boolean;
admin?: GatewayAdminState;