Skip to content

Commit 900e8e6

Browse files
committed
feat: add non-interactive CLI with meow arg parsing
Rewrite cli.tsx with meow for arg parsing and routing between interactive and non-interactive modes. Add --info output builder and non-interactive execution path that runs the full setup (clone, install, env, cleanup) without the TUI.
1 parent 1d85809 commit 900e8e6

3 files changed

Lines changed: 243 additions & 4 deletions

File tree

source/cli.tsx

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,99 @@
11
#!/usr/bin/env node
2-
import { render } from 'ink'
3-
import App from './app.js'
2+
import process from 'node:process'
3+
import meow from 'meow'
4+
import { getInfoOutput } from './info.js'
5+
import { runNonInteractive } from './nonInteractive.js'
46

5-
console.clear()
6-
render(<App />)
7+
const cli = meow(
8+
`
9+
Usage
10+
$ dappbooster-starter [options]
11+
12+
Options
13+
--name <string> Project name (alphanumeric, underscores)
14+
--mode <full|custom> Installation mode
15+
--features <list> Comma-separated features (with --mode=custom):
16+
demo Component demos and example pages
17+
subgraph TheGraph subgraph integration (requires API key)
18+
typedoc TypeDoc API documentation generation
19+
vocs Vocs documentation site
20+
husky Git hooks with Husky, lint-staged, commitlint
21+
--non-interactive, --ni Run without prompts (auto-enabled when not a TTY)
22+
--info Output feature metadata as JSON
23+
--help Show this help
24+
--version Show version
25+
26+
Non-interactive mode
27+
Requires --name and --mode. Outputs JSON to stdout.
28+
Activates automatically when stdout is not a TTY.
29+
Use --ni to force non-interactive mode in a TTY environment.
30+
31+
AI agents: non-interactive mode activates automatically. Run --info
32+
to discover available features, then pass --name and --mode flags.
33+
Output is JSON for easy parsing.
34+
35+
Examples
36+
Interactive:
37+
$ dappbooster-starter
38+
39+
Full install (non-interactive):
40+
$ dappbooster-starter --ni --name my_dapp --mode full
41+
42+
Custom install with specific features:
43+
$ dappbooster-starter --ni --name my_dapp --mode custom --features demo,subgraph
44+
45+
Get feature metadata:
46+
$ dappbooster-starter --info
47+
`,
48+
{
49+
importMeta: import.meta,
50+
flags: {
51+
name: {
52+
type: 'string',
53+
},
54+
mode: {
55+
type: 'string',
56+
},
57+
features: {
58+
type: 'string',
59+
},
60+
nonInteractive: {
61+
type: 'boolean',
62+
default: false,
63+
},
64+
ni: {
65+
type: 'boolean',
66+
default: false,
67+
},
68+
info: {
69+
type: 'boolean',
70+
default: false,
71+
},
72+
},
73+
},
74+
)
75+
76+
if (cli.flags.info) {
77+
console.log(getInfoOutput())
78+
process.exit(0)
79+
}
80+
81+
const isNonInteractive = cli.flags.nonInteractive || cli.flags.ni || !process.stdout.isTTY
82+
83+
if (isNonInteractive) {
84+
runNonInteractive({
85+
name: cli.flags.name,
86+
mode: cli.flags.mode,
87+
features: cli.flags.features,
88+
})
89+
} else {
90+
const run = async () => {
91+
console.clear()
92+
const { render } = await import('ink')
93+
const { default: App } = await import('./app.js')
94+
95+
render(<App />)
96+
}
97+
98+
run()
99+
}

source/info.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { featureDefinitions } from './constants/config.js'
2+
3+
export function getInfoOutput(): string {
4+
const features = Object.fromEntries(
5+
Object.entries(featureDefinitions).map(([name, def]) => [
6+
name,
7+
{
8+
description: def.description,
9+
default: def.default,
10+
...(def.postInstall ? { postInstall: def.postInstall } : {}),
11+
},
12+
]),
13+
)
14+
15+
return JSON.stringify(
16+
{
17+
features,
18+
modes: {
19+
full: 'Install all features',
20+
custom: 'Choose features individually',
21+
},
22+
},
23+
null,
24+
2,
25+
)
26+
}

source/nonInteractive.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import process from 'node:process'
2+
import { type FeatureName, featureNames } from './constants/config.js'
3+
import { cleanupFiles, cloneRepo, createEnvFile, installPackages } from './operations/index.js'
4+
import type { InstallationType } from './types/types.js'
5+
import {
6+
getPostInstallMessages,
7+
getProjectFolder,
8+
isValidName,
9+
projectDirectoryExists,
10+
} from './utils/utils.js'
11+
12+
type SuccessResult = {
13+
success: true
14+
projectName: string
15+
mode: InstallationType
16+
features: FeatureName[]
17+
path: string
18+
postInstall: string[]
19+
}
20+
21+
type ErrorResult = {
22+
success: false
23+
error: string
24+
}
25+
26+
function fail(error: string): never {
27+
const result: ErrorResult = { success: false, error }
28+
console.log(JSON.stringify(result, null, 2))
29+
process.exit(1)
30+
}
31+
32+
function parseFeatures(featuresFlag: string | undefined): FeatureName[] {
33+
if (!featuresFlag) {
34+
return []
35+
}
36+
37+
return featuresFlag.split(',').map((f) => f.trim()) as FeatureName[]
38+
}
39+
40+
function validate(flags: {
41+
name?: string
42+
mode?: string
43+
features?: string
44+
}): { name: string; mode: InstallationType; features: FeatureName[] } {
45+
if (!flags.name) {
46+
fail('Missing required flag: --name')
47+
}
48+
49+
if (!flags.mode) {
50+
fail('Missing required flag: --mode')
51+
}
52+
53+
if (!isValidName(flags.name)) {
54+
fail('Invalid project name: only letters, numbers, and underscores are allowed')
55+
}
56+
57+
if (flags.mode !== 'full' && flags.mode !== 'custom') {
58+
fail("Invalid mode: must be 'full' or 'custom'")
59+
}
60+
61+
// --mode=full ignores --features (everything is installed)
62+
if (flags.mode === 'full') {
63+
if (projectDirectoryExists(flags.name)) {
64+
fail(`Project directory '${flags.name}' already exists`)
65+
}
66+
67+
return { name: flags.name, mode: flags.mode, features: [] }
68+
}
69+
70+
if (!flags.features) {
71+
fail('--mode custom requires --features. Use --info to see available features.')
72+
}
73+
74+
const features = parseFeatures(flags.features)
75+
const invalidFeatures = features.filter((f) => !featureNames.includes(f))
76+
77+
if (invalidFeatures.length > 0) {
78+
fail(
79+
`Unknown features: ${invalidFeatures.join(', ')}. Valid features: ${featureNames.join(', ')}`,
80+
)
81+
}
82+
83+
if (projectDirectoryExists(flags.name)) {
84+
fail(`Project directory '${flags.name}' already exists`)
85+
}
86+
87+
return { name: flags.name, mode: flags.mode, features }
88+
}
89+
90+
export async function runNonInteractive(flags: {
91+
name?: string
92+
mode?: string
93+
features?: string
94+
}): Promise<void> {
95+
const { name, mode, features } = validate(flags)
96+
97+
try {
98+
await cloneRepo(name)
99+
100+
const projectFolder = getProjectFolder(name)
101+
102+
await createEnvFile(projectFolder)
103+
await installPackages(projectFolder, mode, features)
104+
await cleanupFiles(projectFolder, mode, features)
105+
106+
const result: SuccessResult = {
107+
success: true,
108+
projectName: name,
109+
mode,
110+
features,
111+
path: projectFolder,
112+
postInstall: getPostInstallMessages(mode, features),
113+
}
114+
115+
console.log(JSON.stringify(result, null, 2))
116+
} catch (error) {
117+
const message = error instanceof Error ? error.message : String(error)
118+
fail(message)
119+
}
120+
}

0 commit comments

Comments
 (0)