feat: TypeScript installation wizard with @clack/prompts TUI (#1)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
109
tests/integration/full-wizard.test.ts
Normal file
109
tests/integration/full-wizard.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
} 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';
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
describe('Full Wizard (headless)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||
// Copy templates to tmp dir
|
||||
const templatesDir = join(repoRoot, 'templates');
|
||||
if (existsSync(templatesDir)) {
|
||||
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('quick start produces valid SOUL.md', 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),
|
||||
});
|
||||
|
||||
const soulPath = join(tmpDir, 'SOUL.md');
|
||||
expect(existsSync(soulPath)).toBe(true);
|
||||
|
||||
const soul = readFileSync(soulPath, 'utf-8');
|
||||
expect(soul).toContain('You are **TestBot**');
|
||||
expect(soul).toContain('Be direct, concise, and concrete');
|
||||
expect(soul).toContain('execution partner and visibility engine');
|
||||
});
|
||||
|
||||
it('quick start produces valid USER.md', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'He/Him',
|
||||
'Your timezone': 'America/Chicago',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
});
|
||||
|
||||
const userPath = join(tmpDir, 'USER.md');
|
||||
expect(existsSync(userPath)).toBe(true);
|
||||
|
||||
const user = readFileSync(userPath, 'utf-8');
|
||||
expect(user).toContain('**Name:** Tester');
|
||||
expect(user).toContain('**Pronouns:** He/Him');
|
||||
expect(user).toContain('**Timezone:** America/Chicago');
|
||||
});
|
||||
|
||||
it('applies CLI overrides', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'Your name': 'FromPrompt',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: 'FromCLI',
|
||||
communicationStyle: 'formal',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const soul = readFileSync(join(tmpDir, 'SOUL.md'), 'utf-8');
|
||||
expect(soul).toContain('You are **FromCLI**');
|
||||
expect(soul).toContain('Use professional, structured language');
|
||||
});
|
||||
});
|
||||
71
tests/stages/detect-install.test.ts
Normal file
71
tests/stages/detect-install.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { detectInstallStage } from '../../src/stages/detect-install.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
import type { ConfigService } from '../../src/config/config-service.js';
|
||||
|
||||
function createState(mosaicHome: string): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
const mockConfig: ConfigService = {
|
||||
readSoul: async () => ({ agentName: 'TestAgent' }),
|
||||
readUser: async () => ({ userName: 'TestUser' }),
|
||||
readTools: async () => ({}),
|
||||
writeSoul: async () => {},
|
||||
writeUser: async () => {},
|
||||
writeTools: async () => {},
|
||||
syncFramework: async () => {},
|
||||
};
|
||||
|
||||
describe('detectInstallStage', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sets fresh for empty directory', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState(join(tmpDir, 'nonexistent'));
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('fresh');
|
||||
});
|
||||
|
||||
it('detects existing install and offers choices', async () => {
|
||||
// Create a mock existing install
|
||||
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
|
||||
writeFileSync(join(tmpDir, 'AGENTS.md'), '# Test');
|
||||
writeFileSync(
|
||||
join(tmpDir, 'SOUL.md'),
|
||||
'You are **Jarvis** in this session.',
|
||||
);
|
||||
|
||||
const p = new HeadlessPrompter({
|
||||
'What would you like to do?': 'keep',
|
||||
});
|
||||
const state = createState(tmpDir);
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('keep');
|
||||
expect(state.soul.agentName).toBe('TestAgent');
|
||||
});
|
||||
});
|
||||
74
tests/stages/soul-setup.test.ts
Normal file
74
tests/stages/soul-setup.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { soulSetupStage } from '../../src/stages/soul-setup.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
|
||||
function createState(overrides: Partial<WizardState> = {}): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/test-mosaic',
|
||||
sourceDir: '/tmp/test-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('soulSetupStage', () => {
|
||||
it('sets agent name and style in quick mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'What name should agents use?': 'Jarvis',
|
||||
'Communication style': 'friendly',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'quick' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Jarvis');
|
||||
expect(state.soul.communicationStyle).toBe('friendly');
|
||||
expect(state.soul.roleDescription).toBe(
|
||||
'execution partner and visibility engine',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses defaults in quick mode with no answers', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ mode: 'quick' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Assistant');
|
||||
expect(state.soul.communicationStyle).toBe('direct');
|
||||
});
|
||||
|
||||
it('skips when install action is keep', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ installAction: 'keep' });
|
||||
state.soul.agentName = 'Existing';
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Existing');
|
||||
});
|
||||
|
||||
it('asks for all fields in advanced mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'What name should agents use?': 'Atlas',
|
||||
'Agent role description': 'memory keeper',
|
||||
'Communication style': 'formal',
|
||||
'Accessibility preferences': 'ADHD-friendly',
|
||||
'Custom guardrails (optional)': 'Never push to main',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'advanced' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Atlas');
|
||||
expect(state.soul.roleDescription).toBe('memory keeper');
|
||||
expect(state.soul.communicationStyle).toBe('formal');
|
||||
expect(state.soul.accessibility).toBe('ADHD-friendly');
|
||||
expect(state.soul.customGuardrails).toBe('Never push to main');
|
||||
});
|
||||
});
|
||||
60
tests/stages/user-setup.test.ts
Normal file
60
tests/stages/user-setup.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { userSetupStage } from '../../src/stages/user-setup.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
|
||||
function createState(overrides: Partial<WizardState> = {}): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/test-mosaic',
|
||||
sourceDir: '/tmp/test-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: { communicationStyle: 'direct' },
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('userSetupStage', () => {
|
||||
it('collects basic info in quick mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'Your name': 'Jason',
|
||||
'Your pronouns': 'He/Him',
|
||||
'Your timezone': 'America/Chicago',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'quick' });
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.userName).toBe('Jason');
|
||||
expect(state.user.pronouns).toBe('He/Him');
|
||||
expect(state.user.timezone).toBe('America/Chicago');
|
||||
expect(state.user.communicationPrefs).toContain('Direct and concise');
|
||||
});
|
||||
|
||||
it('skips when install action is keep', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ installAction: 'keep' });
|
||||
state.user.userName = 'Existing';
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.userName).toBe('Existing');
|
||||
});
|
||||
|
||||
it('derives communication prefs from soul style', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'Your name': 'Test',
|
||||
});
|
||||
|
||||
const state = createState({
|
||||
mode: 'quick',
|
||||
soul: { communicationStyle: 'friendly' },
|
||||
});
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.communicationPrefs).toContain('Warm and conversational');
|
||||
});
|
||||
});
|
||||
99
tests/template/builders.test.ts
Normal file
99
tests/template/builders.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildSoulTemplateVars,
|
||||
buildUserTemplateVars,
|
||||
buildToolsTemplateVars,
|
||||
} from '../../src/template/builders.js';
|
||||
|
||||
describe('buildSoulTemplateVars', () => {
|
||||
it('builds direct style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
agentName: 'Jarvis',
|
||||
communicationStyle: 'direct',
|
||||
});
|
||||
expect(vars.AGENT_NAME).toBe('Jarvis');
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Clarity over performance theater');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Be direct, concise, and concrete');
|
||||
});
|
||||
|
||||
it('builds friendly style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'friendly',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Be helpful and approachable');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Be warm and conversational');
|
||||
});
|
||||
|
||||
it('builds formal style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'formal',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Maintain professional, structured');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Use professional, structured language');
|
||||
});
|
||||
|
||||
it('appends accessibility to principles', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'direct',
|
||||
accessibility: 'ADHD-friendly chunking',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('6. ADHD-friendly chunking.');
|
||||
});
|
||||
|
||||
it('does not append accessibility when "none"', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'direct',
|
||||
accessibility: 'none',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).not.toContain('6.');
|
||||
});
|
||||
|
||||
it('formats custom guardrails', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
customGuardrails: 'Never auto-commit',
|
||||
});
|
||||
expect(vars.CUSTOM_GUARDRAILS).toBe('- Never auto-commit');
|
||||
});
|
||||
|
||||
it('uses defaults when config is empty', () => {
|
||||
const vars = buildSoulTemplateVars({});
|
||||
expect(vars.AGENT_NAME).toBe('Assistant');
|
||||
expect(vars.ROLE_DESCRIPTION).toBe('execution partner and visibility engine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserTemplateVars', () => {
|
||||
it('maps all fields', () => {
|
||||
const vars = buildUserTemplateVars({
|
||||
userName: 'Jason',
|
||||
pronouns: 'He/Him',
|
||||
timezone: 'America/Chicago',
|
||||
});
|
||||
expect(vars.USER_NAME).toBe('Jason');
|
||||
expect(vars.PRONOUNS).toBe('He/Him');
|
||||
expect(vars.TIMEZONE).toBe('America/Chicago');
|
||||
});
|
||||
|
||||
it('uses defaults for missing fields', () => {
|
||||
const vars = buildUserTemplateVars({});
|
||||
expect(vars.PRONOUNS).toBe('They/Them');
|
||||
expect(vars.TIMEZONE).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolsTemplateVars', () => {
|
||||
it('builds git providers table', () => {
|
||||
const vars = buildToolsTemplateVars({
|
||||
gitProviders: [
|
||||
{ name: 'GitHub', url: 'https://github.com', cli: 'gh', purpose: 'OSS' },
|
||||
],
|
||||
});
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('| GitHub |');
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('`gh`');
|
||||
});
|
||||
|
||||
it('uses default table when no providers', () => {
|
||||
const vars = buildToolsTemplateVars({});
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('add your git providers here');
|
||||
});
|
||||
});
|
||||
52
tests/template/engine.test.ts
Normal file
52
tests/template/engine.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderTemplate } from '../../src/template/engine.js';
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
it('replaces all placeholders', () => {
|
||||
const template = 'You are **{{AGENT_NAME}}**, role: {{ROLE_DESCRIPTION}}';
|
||||
const result = renderTemplate(template, {
|
||||
AGENT_NAME: 'Jarvis',
|
||||
ROLE_DESCRIPTION: 'steward',
|
||||
});
|
||||
expect(result).toBe('You are **Jarvis**, role: steward');
|
||||
});
|
||||
|
||||
it('preserves ${ENV_VAR} references', () => {
|
||||
const template = 'Path: ${HOME}/.config, Agent: {{AGENT_NAME}}';
|
||||
const result = renderTemplate(template, { AGENT_NAME: 'Test' });
|
||||
expect(result).toBe('Path: ${HOME}/.config, Agent: Test');
|
||||
});
|
||||
|
||||
it('handles multi-line values', () => {
|
||||
const template = '{{PRINCIPLES}}';
|
||||
const result = renderTemplate(template, {
|
||||
PRINCIPLES: '1. First\n2. Second\n3. Third',
|
||||
});
|
||||
expect(result).toBe('1. First\n2. Second\n3. Third');
|
||||
});
|
||||
|
||||
it('replaces unset vars with empty string by default', () => {
|
||||
const template = 'Before {{MISSING}} After';
|
||||
const result = renderTemplate(template, {});
|
||||
expect(result).toBe('Before After');
|
||||
});
|
||||
|
||||
it('throws in strict mode for missing vars', () => {
|
||||
const template = '{{MISSING}}';
|
||||
expect(() => renderTemplate(template, {}, { strict: true })).toThrow(
|
||||
'Template variable not provided: {{MISSING}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple occurrences of same placeholder', () => {
|
||||
const template = '{{NAME}} says hello, {{NAME}}!';
|
||||
const result = renderTemplate(template, { NAME: 'Jarvis' });
|
||||
expect(result).toBe('Jarvis says hello, Jarvis!');
|
||||
});
|
||||
|
||||
it('preserves non-placeholder curly braces', () => {
|
||||
const template = 'const x = { foo: {{VALUE}} }';
|
||||
const result = renderTemplate(template, { VALUE: '"bar"' });
|
||||
expect(result).toBe('const x = { foo: "bar" }');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user