Collapse `mosaic wizard` and `mosaic gateway install` into a single cohesive first-run experience. Gateway config and admin bootstrap now run as terminal stages of `runWizard`, sharing `WizardState` with the framework stages and eliminating the fragile 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json` session-file bridge. - Extract `gatewayConfigStage` and `gatewayBootstrapStage` as first-class wizard stages with full spec coverage (headless + interactive paths). - `mosaic gateway install` becomes a thin wrapper that invokes the same two stages — the CLI entry point is preserved for operators who only need to (re)configure the daemon. - Honor explicit `--port` override even on resume: when the override differs from the saved GATEWAY_PORT, force a config regeneration so `.env` and `meta.json` cannot drift. - Honor `state.hooks.accepted === false` in the finalize stage and in `mosaic-link-runtime-assets`: declined hooks are now actually opted-out, with a stable `mosaic-managed: true` marker in the template so cleanup survives template updates without touching user-owned configs. - Headless rerun of an already-bootstrapped gateway with no local token cache is a successful no-op (no more false-positive install failures). - `tools/install.sh` calls `mosaic wizard` only — the follow-up `mosaic gateway install` auto-launch is removed. Closes mosaicstack/mosaic-stack#427. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
147 lines
4.9 KiB
TypeScript
147 lines
4.9 KiB
TypeScript
/**
|
|
* Unified wizard integration test — exercises the `skipGateway: false` code
|
|
* path so that wiring between `runWizard` and the two gateway stages is
|
|
* covered. The gateway stages themselves are mocked (they require a real
|
|
* daemon + network) but the dynamic imports and option plumbing are real.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
|
import { createConfigService } from '../../src/config/config-service.js';
|
|
|
|
const gatewayConfigMock = vi.fn();
|
|
const gatewayBootstrapMock = vi.fn();
|
|
|
|
vi.mock('../../src/stages/gateway-config.js', () => ({
|
|
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
|
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
|
}));
|
|
|
|
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
|
import { runWizard } from '../../src/wizard.js';
|
|
|
|
describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|
let tmpDir: string;
|
|
const repoRoot = join(import.meta.dirname, '..', '..');
|
|
|
|
const originalIsTTY = process.stdin.isTTY;
|
|
const originalAssumeYes = process.env['MOSAIC_ASSUME_YES'];
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-unified-wizard-'));
|
|
const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')];
|
|
for (const templatesDir of candidates) {
|
|
if (existsSync(templatesDir)) {
|
|
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
|
break;
|
|
}
|
|
}
|
|
gatewayConfigMock.mockReset();
|
|
gatewayBootstrapMock.mockReset();
|
|
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
|
// branch does not call `process.exit(1)` during these tests.
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
delete process.env['MOSAIC_ASSUME_YES'];
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
Object.defineProperty(process.stdin, 'isTTY', {
|
|
value: originalIsTTY,
|
|
configurable: true,
|
|
});
|
|
if (originalAssumeYes === undefined) {
|
|
delete process.env['MOSAIC_ASSUME_YES'];
|
|
} else {
|
|
process.env['MOSAIC_ASSUME_YES'] = originalAssumeYes;
|
|
}
|
|
});
|
|
|
|
it('invokes the gateway config + bootstrap stages by default', async () => {
|
|
gatewayConfigMock.mockResolvedValue({ ready: true, host: 'localhost', port: 14242 });
|
|
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
|
|
|
const prompter = new HeadlessPrompter({
|
|
'Installation mode': 'quick',
|
|
'What name should agents use?': 'TestBot',
|
|
'Communication style': 'direct',
|
|
'Your name': 'Tester',
|
|
'Your pronouns': 'They/Them',
|
|
'Your timezone': 'UTC',
|
|
});
|
|
|
|
await runWizard({
|
|
mosaicHome: tmpDir,
|
|
sourceDir: tmpDir,
|
|
prompter,
|
|
configService: createConfigService(tmpDir, tmpDir),
|
|
gatewayHost: 'localhost',
|
|
gatewayPort: 14242,
|
|
skipGatewayNpmInstall: true,
|
|
});
|
|
|
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
|
expect(gatewayBootstrapMock).toHaveBeenCalledTimes(1);
|
|
const configCall = gatewayConfigMock.mock.calls[0];
|
|
expect(configCall[2]).toMatchObject({
|
|
host: 'localhost',
|
|
defaultPort: 14242,
|
|
skipInstall: true,
|
|
});
|
|
const bootstrapCall = gatewayBootstrapMock.mock.calls[0];
|
|
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
|
});
|
|
|
|
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
|
gatewayConfigMock.mockResolvedValue({ ready: false });
|
|
|
|
const prompter = new HeadlessPrompter({
|
|
'Installation mode': 'quick',
|
|
'What name should agents use?': 'TestBot',
|
|
'Communication style': 'direct',
|
|
'Your name': 'Tester',
|
|
'Your pronouns': 'They/Them',
|
|
'Your timezone': 'UTC',
|
|
});
|
|
|
|
await runWizard({
|
|
mosaicHome: tmpDir,
|
|
sourceDir: tmpDir,
|
|
prompter,
|
|
configService: createConfigService(tmpDir, tmpDir),
|
|
skipGatewayNpmInstall: true,
|
|
});
|
|
|
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('respects skipGateway: true', async () => {
|
|
const prompter = new HeadlessPrompter({
|
|
'Installation mode': 'quick',
|
|
'What name should agents use?': 'TestBot',
|
|
'Communication style': 'direct',
|
|
'Your name': 'Tester',
|
|
'Your pronouns': 'They/Them',
|
|
'Your timezone': 'UTC',
|
|
});
|
|
|
|
await runWizard({
|
|
mosaicHome: tmpDir,
|
|
sourceDir: tmpDir,
|
|
prompter,
|
|
configService: createConfigService(tmpDir, tmpDir),
|
|
skipGateway: true,
|
|
});
|
|
|
|
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|