Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,057 changes: 0 additions & 1,057 deletions platform/packages/cli/package-lock.json

This file was deleted.

9 changes: 7 additions & 2 deletions platform/packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"clean": "rm -rf dist",
"prepublishOnly": "pnpm run build"
"prepublishOnly": "pnpm run build && pnpm run test"
},
"dependencies": {
"@inquirer/prompts": "^8.7.1",
Expand All @@ -47,7 +50,9 @@
},
"devDependencies": {
"@types/node": "^26.4.1",
"typescript": "^6.0.3"
"@vitest/coverage-v8": "^4.1.11",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
},
"publishConfig": {
"access": "public",
Expand Down
155 changes: 125 additions & 30 deletions platform/packages/cli/src/commands/new.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,85 @@
import { input, select, confirm } from '@inquirer/prompts'
import { join } from 'node:path'
import { existsSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { execa } from 'execa'
import { loadConfig, type CliConfig } from '../utils/config.js'
import { runChecks } from '../utils/env.js'
import { banner, info, error, beta, errorMessage, errorWithCode, success, warn } from '../utils/logger.js'
import { generateBackend } from '../generators/backend.js'
import { generateFrontend } from '../generators/frontend.js'

// ---------------------------------------------------------------------------
// Non-interactive flags
// ---------------------------------------------------------------------------

type ScaffoldChoice = 'both' | 'backend' | 'frontend'
type PackageManager = 'pnpm' | 'npm' | 'yarn'

export interface NewCommandFlags {
name?: string
scaffold?: ScaffoldChoice
backendLang?: string
groupId?: string
artifactId?: string
version?: string
description?: string
frontend?: string
pm?: PackageManager
yes?: boolean
git?: boolean
}

/**
* Parse CLI flags for `dynamia new`, allowing scripted/CI usage without prompts.
* Any flag left unset falls back to its interactive prompt.
*/
export function parseNewFlags(argv: string[]): NewCommandFlags {
const { values } = parseArgs({
args: argv,
options: {
name: { type: 'string' },
scaffold: { type: 'string' },
'backend-lang': { type: 'string' },
'group-id': { type: 'string' },
'artifact-id': { type: 'string' },
version: { type: 'string' },
description: { type: 'string' },
frontend: { type: 'string' },
pm: { type: 'string' },
yes: { type: 'boolean' },
git: { type: 'boolean' },
'no-git': { type: 'boolean' },
},
allowPositionals: true,
strict: false,
})

const scaffold = values.scaffold as string | undefined
if (scaffold !== undefined && scaffold !== 'both' && scaffold !== 'backend' && scaffold !== 'frontend') {
errorWithCode('DT-COMMAND-003', `--scaffold must be one of: both, backend, frontend (got "${scaffold}")`)
}

const pm = values.pm as string | undefined
if (pm !== undefined && pm !== 'pnpm' && pm !== 'npm' && pm !== 'yarn') {
errorWithCode('DT-COMMAND-003', `--pm must be one of: pnpm, npm, yarn (got "${pm}")`)
}

return {
name: values.name as string | undefined,
scaffold: scaffold as ScaffoldChoice | undefined,
backendLang: values['backend-lang'] as string | undefined,
groupId: values['group-id'] as string | undefined,
artifactId: values['artifact-id'] as string | undefined,
version: values.version as string | undefined,
description: values.description as string | undefined,
frontend: values.frontend as string | undefined,
pm: pm as PackageManager | undefined,
yes: values.yes as boolean | undefined,
git: values['no-git'] ? false : (values.git as boolean | undefined),
}
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand All @@ -20,7 +92,9 @@ function normalizeSpringBootVersion(version: string): string {

async function fetchSpringBootVersion(_config: CliConfig): Promise<string> {
try {
const metaRes = await fetch('https://start.spring.io/metadata/client')
const metaRes = await fetch('https://start.spring.io/metadata/client', {
signal: AbortSignal.timeout(4000),
})
if (metaRes.ok) {
const meta = await metaRes.json() as Record<string, unknown>
const bootVersion = (meta as { bootVersion?: { default?: string } }).bootVersion?.default
Expand Down Expand Up @@ -96,9 +170,11 @@ function printSuccessMessage(opts: {
// Command entry point
// ---------------------------------------------------------------------------

export async function runNew(): Promise<void> {
export async function runNew(argv: string[] = []): Promise<void> {
banner()

const flags = parseNewFlags(argv)

// Load configuration from cli.properties
let config: CliConfig
try {
Expand All @@ -113,23 +189,28 @@ export async function runNew(): Promise<void> {
}

// Environment checks (git missing = hard stop)
await runChecks()
await runChecks(config)

// --- Step 1: Project name ---
const projectName = await input({
message: 'Project name:',
validate: (value: string) => {
if (!value.trim()) return 'Project name cannot be empty'
if (!/^[a-z0-9-]+$/.test(value.trim())) {
return 'Only lowercase letters, numbers, and hyphens are allowed'
}
return true
},
})
const validateProjectName = (value: string): true | string => {
if (!value.trim()) return 'Project name cannot be empty'
if (!/^[a-z0-9-]+$/.test(value.trim())) {
return 'Only lowercase letters, numbers, and hyphens are allowed'
}
return true
}

let projectName: string
if (flags.name !== undefined) {
const validation = validateProjectName(flags.name)
if (validation !== true) errorWithCode('DT-COMMAND-003', `--name: ${validation}`)
projectName = flags.name.trim()
} else {
projectName = await input({ message: 'Project name:', validate: validateProjectName })
}

// --- Step 2: What to generate ---
type ScaffoldChoice = 'both' | 'backend' | 'frontend'
const scaffoldChoice = await select<ScaffoldChoice>({
const scaffoldChoice: ScaffoldChoice = flags.scaffold ?? await select<ScaffoldChoice>({
message: 'What do you want to scaffold?',
choices: [
{ name: 'Backend + Frontend', value: 'both' },
Expand Down Expand Up @@ -161,28 +242,35 @@ export async function runNew(): Promise<void> {
errorWithCode('DT-BACKEND-005', 'Backend templates are not available yet. Please try frontend only for now.')
}

language = await select({
message: 'Backend language:',
choices: languageChoices,
})
if (flags.backendLang !== undefined) {
const chosen = languageChoices.find((choice) => choice.value === flags.backendLang)
if (!chosen) errorWithCode('DT-COMMAND-003', `--backend-lang "${flags.backendLang}" is not a known backend template.`)
if (chosen.disabled) errorWithCode('DT-COMMAND-003', `--backend-lang "${flags.backendLang}": ${chosen.disabled}`)
language = flags.backendLang
} else {
language = await select({
message: 'Backend language:',
choices: languageChoices,
})
}

// --- Step 4: Maven coordinates ---
groupId = await input({
groupId = flags.groupId ?? await input({
message: 'Group ID:',
default: 'com.example',
})

artifactId = await input({
artifactId = flags.artifactId ?? await input({
message: 'Artifact ID:',
default: projectName,
})

version = await input({
version = flags.version ?? await input({
message: 'Version:',
default: '1.0.0-SNAPSHOT',
})

description = await input({
description = flags.description ?? await input({
message: 'Description (optional):',
default: '',
})
Expand All @@ -205,13 +293,20 @@ export async function runNew(): Promise<void> {
errorWithCode('DT-FRONTEND-005', 'Frontend templates are not available yet. Please try again soon.')
}

framework = await select({
message: 'Frontend framework:',
choices: frameworkChoices,
})
if (flags.frontend !== undefined) {
const chosen = frameworkChoices.find((choice) => choice.value === flags.frontend)
if (!chosen) errorWithCode('DT-COMMAND-003', `--frontend "${flags.frontend}" is not a known frontend template.`)
if (chosen.disabled) errorWithCode('DT-COMMAND-003', `--frontend "${flags.frontend}": ${chosen.disabled}`)
framework = flags.frontend
} else {
framework = await select({
message: 'Frontend framework:',
choices: frameworkChoices,
})
}

// --- Step 6: Package manager ---
packageManager = await select({
packageManager = flags.pm ?? await select({
message: 'Package manager:',
choices: [
{ name: 'pnpm', value: 'pnpm' },
Expand Down Expand Up @@ -239,7 +334,7 @@ export async function runNew(): Promise<void> {
}
console.log('')

const confirmed = await confirm({
const confirmed = flags.yes ?? await confirm({
message: 'Generate project?',
default: true,
})
Expand Down Expand Up @@ -294,7 +389,7 @@ export async function runNew(): Promise<void> {
springBootVersion,
})

const initGit = await confirm({
const initGit = flags.git ?? await confirm({
message: 'Initialize a Git repository in the project root?',
default: true,
})
Expand Down
2 changes: 1 addition & 1 deletion platform/packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const command = args[0] ?? 'new'

switch (command) {
case 'new':
await runNew()
await runNew(args.slice(1))
break
default:
notAvailableYet(`Command "${command}"`, 'DT-COMMAND-001')
Expand Down
2 changes: 1 addition & 1 deletion platform/packages/cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface CliConfig {
// Parser
// ---------------------------------------------------------------------------

function parseProperties(content: string): Record<string, string> {
export function parseProperties(content: string): Record<string, string> {
const result: Record<string, string> = {}
for (const raw of content.split('\n')) {
const line = raw.trim()
Expand Down
38 changes: 26 additions & 12 deletions platform/packages/cli/src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { execa } from 'execa'
import { warn, error, info } from './logger.js'
import { type CliConfig } from './config.js'

/**
* Parse a major version number out of a raw version string (e.g. "25.0.1" -> 25,
* "v24.3.0" -> 24). Returns null when no leading integer can be extracted.
*/
function parseMajorVersion(raw: string): number | null {
const match = raw.trim().replace(/^v/, '').match(/^(\d+)/)
if (!match) return null
const major = parseInt(match[1]!, 10)
return isNaN(major) ? null : major
}

/**
* Check that JDK 25 is available.
Expand All @@ -8,9 +20,11 @@ import { warn, error, info } from './logger.js'
export async function checkJava(): Promise<boolean> {
try {
const result = await execa('java', ['-version'], { stderr: 'pipe', stdout: 'pipe' })
// java -version writes to stderr
// java -version writes to stderr, in the form: openjdk version "25.0.1" ...
const output = result.stderr + result.stdout
if (output.includes('25')) {
const versionMatch = output.match(/version "(\d+)/)
const major = versionMatch ? parseInt(versionMatch[1]!, 10) : null
if (major === 25) {
return true
}
warn('JDK 25 not detected. A different Java version is installed. Some features may not work as expected.')
Expand All @@ -22,19 +36,19 @@ export async function checkJava(): Promise<boolean> {
}

/**
* Check that Node.js >= 22 is available.
* Check that Node.js satisfies the configured minimum version.
* Returns true if the requirement is satisfied.
*/
export async function checkNode(): Promise<boolean> {
export async function checkNode(config: Pick<CliConfig, 'node'>): Promise<boolean> {
const minimum = parseMajorVersion(config.node.minimumVersion) ?? 24
try {
const result = await execa('node', ['--version'], { stdout: 'pipe' })
const version = result.stdout.trim().replace(/^v/, '')
const majorStr = version.split('.')[0]
const major = majorStr && majorStr.length > 0 ? parseInt(majorStr, 10) : 0
if (!isNaN(major) && major >= 22) {
const major = parseMajorVersion(version)
if (major !== null && major >= minimum) {
return true
}
warn(`Node.js ${version} found but version >=22 is required. Please upgrade Node.js.`)
warn(`Node.js ${version} found but version >=${minimum} is required. Please upgrade Node.js.`)
return false
} catch {
warn('Node.js is not installed. Install it via fnm: curl -fsSL https://fnm.vercel.app/install | bash')
Expand Down Expand Up @@ -68,9 +82,9 @@ export async function checkGit(): Promise<boolean> {
* Run all environment checks at startup.
* git missing = hard stop; other warnings are non-fatal.
*/
export async function runChecks(): Promise<void> {
export async function runChecks(config: Pick<CliConfig, 'node'>): Promise<void> {
info('Checking environment...')
await checkGit() // exits if missing
await checkJava() // warning only
await checkNode() // warning only
await checkGit() // exits if missing
await checkJava() // warning only
await checkNode(config) // warning only
}
1 change: 1 addition & 0 deletions platform/packages/cli/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type CliErrorCode =
| 'DT-CONFIG-001'
| 'DT-COMMAND-001'
| 'DT-COMMAND-002'
| 'DT-COMMAND-003'
| 'DT-RUN-001'
| 'DT-BACKEND-001'
| 'DT-BACKEND-002'
Expand Down
Loading
Loading