mirror of
https://github.com/langgenius/dify.git
synced 2026-05-20 08:46:57 +08:00
Replaces the hand-written src/commands/tree.ts with a build-time-generated
artifact derived from src/commands/**/index.ts. tree.ts becomes a one-line
re-export of tree.generated.ts. Determinism: lexicographic sort, LF pinned
via .gitattributes, atomic write (tmp + rename), CI-gated by `pnpm tree:check`.
Codegen script (cli/scripts/generate-command-tree.ts) walks the commands
tree, derives canonical PascalCase identifiers (with reserved-word + hyphen
handling), and emits a static ESM module with sorted default imports and a
nested literal of shape CommandTree. Shared exclusion predicate
(isExcludedCommandPath) consumed by both codegen and coverage.test.ts so
underscore-prefixed segments stay non-commands.
Wired pre* lifecycle hooks (prebuild/predev/pretest) and ci composite
gating `tree:check` first. Pack now emits .js outputs (fixedExtension:false)
to drop .mjs; bin/run.js stays on .js. Vitest test.include extended to
cover scripts/.
Framework additions bundled in:
- static hidden = true omits command from printTopLevelHelp listing
(still resolves and runs when invoked)
- static deprecated = '...' prints "deprecated: <msg>" to stderr before
constructing the command
Verified: pnpm ci green (tree:check ok, tsc clean, lint clean, 702 tests
pass, build complete). Smoke: node bin/run.js version + auth login --help,
add-a-command flow, loose-file error case all behave as expected.
215 lines
8.3 KiB
TypeScript
215 lines
8.3 KiB
TypeScript
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()
|
|
})
|
|
})
|