import type { CommandEntry } from './generate-command-tree.js' import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildTree, discoverCommands, formatModule, generate, pathToTokens, tokensToIdentifier, } from './generate-command-tree.js' describe('pathToTokens', () => { it('extracts tokens for nested command', () => { expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands')) .toEqual(['auth', 'devices', 'list']) }) it('extracts tokens for top-level command', () => { expect(pathToTokens('src/commands/version/index.ts', 'src/commands')) .toEqual(['version']) }) it('normalizes backslashes (windows-style paths)', () => { expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')) .toEqual(['auth', 'login']) }) }) describe('tokensToIdentifier', () => { it('pascal-cases joined tokens', () => { expect(tokensToIdentifier(['auth', 'devices', 'list'])).toBe('AuthDevicesList') expect(tokensToIdentifier(['version'])).toBe('Version') expect(tokensToIdentifier(['run', 'app', 'resume'])).toBe('RunAppResume') }) it('splits hyphenated tokens', () => { expect(tokensToIdentifier(['agent-chat'])).toBe('AgentChat') expect(tokensToIdentifier(['my-cmd', 'sub-thing'])).toBe('MyCmdSubThing') }) it('prefixes underscore on reserved words', () => { expect(tokensToIdentifier(['delete'])).toBe('_Delete') }) }) describe('buildTree', () => { it('assembles a nested tree from entries', () => { const entries = [ { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' }, { tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' }, { tokens: ['version'], identifier: 'Version', importPath: './version/index.js' }, ] const tree = buildTree(entries) expect(tree.subcommands.get('auth')?.command).toBeUndefined() expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin') expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command) .toBe('AuthDevicesList') expect(tree.subcommands.get('version')?.command).toBe('Version') }) it('supports a parent command with its own children', () => { const entries = [ { tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' }, { tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' }, ] const tree = buildTree(entries) const runApp = tree.subcommands.get('run')?.subcommands.get('app') expect(runApp?.command).toBe('RunApp') expect(runApp?.subcommands.get('resume')?.command).toBe('RunAppResume') }) }) describe('formatModule', () => { it('produces a deterministic ESM file with imports + tree literal', () => { const entries: CommandEntry[] = [ { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' }, { tokens: ['version'], identifier: 'Version', importPath: './version/index.js' }, { tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' }, ] const tree = buildTree(entries) const out = formatModule(entries, tree) expect(out).toBe( `// @generated by scripts/generate-command-tree.ts — DO NOT EDIT. // Regenerate via \`pnpm tree:gen\`. Drift gated by \`pnpm tree:check\` in CI. import type { CommandTree } from '../framework/registry.js' import AuthDevicesList from './auth/devices/list/index.js' import AuthLogin from './auth/login/index.js' import Version from './version/index.js' export const commandTree: CommandTree = { auth: { subcommands: { devices: { subcommands: { list: { command: AuthDevicesList, subcommands: {} }, }, }, login: { command: AuthLogin, subcommands: {} }, }, }, version: { command: Version, subcommands: {} }, } `, ) }) it('emits parent-with-own-command shape', () => { const entries: CommandEntry[] = [ { tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' }, { tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' }, ] const tree = buildTree(entries) const out = formatModule(entries, tree) expect(out).toContain(`run: { subcommands: { app: { command: RunApp, subcommands: { resume: { command: RunAppResume, subcommands: {} }, }, }, }, },`) }) it('imports sorted alphabetically by import path', () => { const entries: CommandEntry[] = [ { tokens: ['version'], identifier: 'Version', importPath: './version/index.js' }, { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' }, ] const out = formatModule(entries, buildTree(entries)) const authIdx = out.indexOf('AuthLogin') const verIdx = out.indexOf('Version') expect(authIdx).toBeLessThan(verIdx) }) }) function makeFixture(): string { const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-')) const commands = join(root, 'src', 'commands') mkdirSync(join(commands, 'auth', 'login'), { recursive: true }) writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n') mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true }) writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n') mkdirSync(join(commands, '_shared'), { recursive: true }) writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n') mkdirSync(join(commands, 'version'), { recursive: true }) writeFileSync(join(commands, 'version', 'index.ts'), 'export default class Version {}\n') return root } describe('discoverCommands', () => { it('returns sorted entries, skipping _-prefixed segments', async () => { const root = makeFixture() const entries = await discoverCommands(join(root, 'src', 'commands')) expect(entries.map(e => e.tokens.join('/'))).toEqual([ 'auth/devices/list', 'auth/login', 'version', ]) expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined() }) it('errors on a loose .ts file under commands/', async () => { const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-loose-')) const commands = join(root, 'src', 'commands') mkdirSync(commands, { recursive: true }) writeFileSync(join(commands, 'foo.ts'), 'export default class Foo {}\n') await expect(discoverCommands(commands)).rejects.toThrow(/must live under their own folder/) }) }) describe('generate', () => { it('writes tree.generated.ts on default mode', async () => { const root = makeFixture() const commandsDir = join(root, 'src', 'commands') const result = await generate({ commandsDir, mode: 'write' }) expect(result.mode).toBe('write') const target = join(commandsDir, 'tree.generated.ts') expect(existsSync(target)).toBe(true) const content = readFileSync(target, 'utf8') expect(content).toContain('@generated') expect(content).toContain('import AuthLogin') expect(content).toContain('import AuthDevicesList') expect(content).toContain('import Version') }) it('returns ok: true on --check when file matches', async () => { const root = makeFixture() const commandsDir = join(root, 'src', 'commands') await generate({ commandsDir, mode: 'write' }) const result = await generate({ commandsDir, mode: 'check' }) if (result.mode !== 'check') throw new Error('expected check mode') expect(result.ok).toBe(true) }) it('returns ok: false on --check when file is stale', async () => { const root = makeFixture() const commandsDir = join(root, 'src', 'commands') await generate({ commandsDir, mode: 'write' }) writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n') const result = await generate({ commandsDir, mode: 'check' }) if (result.mode !== 'check') throw new Error('expected check mode') expect(result.ok).toBe(false) if (!result.ok) expect(result.diff).toBeDefined() }) })