@rlabs-inc/create-tui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/commands/create.ts +298 -0
- package/src/index.ts +75 -0
- package/src/utils/colors.ts +132 -0
- package/src/utils/prompts.ts +273 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rlabs-inc/create-tui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create TUI Framework applications - The Terminal UI Framework for TypeScript/Bun",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-tui": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "bun run src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"tui",
|
|
17
|
+
"terminal",
|
|
18
|
+
"ui",
|
|
19
|
+
"cli",
|
|
20
|
+
"create",
|
|
21
|
+
"scaffold",
|
|
22
|
+
"bun",
|
|
23
|
+
"typescript",
|
|
24
|
+
"reactive"
|
|
25
|
+
],
|
|
26
|
+
"author": "Rusty & Watson",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/rlabs-inc/tui.git",
|
|
31
|
+
"directory": "packages/tui-cli"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/rlabs-inc/tui",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create command - scaffold a new TUI Framework project
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, exists } from 'fs/promises'
|
|
6
|
+
import { join, resolve, basename } from 'path'
|
|
7
|
+
import { $ } from 'bun'
|
|
8
|
+
import { c, symbols, box } from '../utils/colors'
|
|
9
|
+
import { text, confirm, spinner } from '../utils/prompts'
|
|
10
|
+
|
|
11
|
+
interface CreateOptions {
|
|
12
|
+
projectName?: string
|
|
13
|
+
template?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function create(options: CreateOptions) {
|
|
17
|
+
console.log()
|
|
18
|
+
console.log(box([
|
|
19
|
+
`${c.brightCyan(symbols.sparkle)} ${c.bold('Create TUI Project')} ${c.brightCyan(symbols.sparkle)}`,
|
|
20
|
+
'',
|
|
21
|
+
c.dim('Scaffold a new TUI Framework application'),
|
|
22
|
+
], { padding: 1 }))
|
|
23
|
+
console.log()
|
|
24
|
+
|
|
25
|
+
// Get project name
|
|
26
|
+
let projectName = options.projectName
|
|
27
|
+
|
|
28
|
+
if (!projectName) {
|
|
29
|
+
projectName = await text({
|
|
30
|
+
message: 'Project name',
|
|
31
|
+
placeholder: 'my-tui-app',
|
|
32
|
+
defaultValue: 'my-tui-app',
|
|
33
|
+
validate: (value) => {
|
|
34
|
+
if (!value) return 'Project name is required'
|
|
35
|
+
if (!/^[a-z0-9-_]+$/i.test(value)) {
|
|
36
|
+
return 'Project name can only contain letters, numbers, dashes, and underscores'
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const projectPath = resolve(process.cwd(), projectName)
|
|
44
|
+
const displayName = basename(projectPath)
|
|
45
|
+
|
|
46
|
+
// Check if directory exists
|
|
47
|
+
if (await exists(projectPath)) {
|
|
48
|
+
const overwrite = await confirm({
|
|
49
|
+
message: `Directory ${c.bold(displayName)} already exists. Overwrite?`,
|
|
50
|
+
defaultValue: false
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!overwrite) {
|
|
54
|
+
console.log(`${c.muted(symbols.cross)} Cancelled`)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log()
|
|
60
|
+
|
|
61
|
+
// Create project
|
|
62
|
+
const spin = spinner('Creating project structure...')
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Create directories
|
|
66
|
+
await mkdir(join(projectPath, 'src', 'components'), { recursive: true })
|
|
67
|
+
|
|
68
|
+
// Write files using Bun.write (faster than fs.writeFile)
|
|
69
|
+
await Promise.all([
|
|
70
|
+
Bun.write(join(projectPath, 'package.json'), PACKAGE_JSON(displayName)),
|
|
71
|
+
Bun.write(join(projectPath, 'bunfig.toml'), BUNFIG_TOML),
|
|
72
|
+
Bun.write(join(projectPath, 'tsconfig.json'), TSCONFIG_JSON),
|
|
73
|
+
Bun.write(join(projectPath, '.gitignore'), GITIGNORE),
|
|
74
|
+
Bun.write(join(projectPath, 'README.md'), README(displayName)),
|
|
75
|
+
Bun.write(join(projectPath, 'src', 'main.ts'), MAIN_TS),
|
|
76
|
+
Bun.write(join(projectPath, 'src', 'App.tui'), APP_TUI),
|
|
77
|
+
Bun.write(join(projectPath, 'src', 'components', 'Counter.tui'), COUNTER_TUI),
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
spin.stop('Project structure created')
|
|
81
|
+
|
|
82
|
+
// Install dependencies using Bun shell
|
|
83
|
+
const installSpin = spinner('Installing dependencies...')
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await $`cd ${projectPath} && bun install`.quiet()
|
|
87
|
+
installSpin.stop('Dependencies installed')
|
|
88
|
+
} catch {
|
|
89
|
+
installSpin.stop()
|
|
90
|
+
console.log(`${c.warning(symbols.bullet)} Run ${c.info('bun install')} manually to install dependencies`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Success!
|
|
94
|
+
console.log()
|
|
95
|
+
console.log(box([
|
|
96
|
+
`${c.success(symbols.check)} ${c.bold('Project created!')}`,
|
|
97
|
+
'',
|
|
98
|
+
`${c.dim('Next steps:')}`,
|
|
99
|
+
'',
|
|
100
|
+
` ${c.muted('$')} ${c.info(`cd ${displayName}`)}`,
|
|
101
|
+
` ${c.muted('$')} ${c.info('bun run dev')}`,
|
|
102
|
+
'',
|
|
103
|
+
c.dim('Happy hacking!'),
|
|
104
|
+
], { title: ` ${symbols.star} Success `, padding: 1 }))
|
|
105
|
+
console.log()
|
|
106
|
+
|
|
107
|
+
} catch (error) {
|
|
108
|
+
spin.stop()
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Template files
|
|
114
|
+
|
|
115
|
+
const PACKAGE_JSON = (name: string) => `{
|
|
116
|
+
"name": "${name}",
|
|
117
|
+
"version": "0.1.0",
|
|
118
|
+
"type": "module",
|
|
119
|
+
"scripts": {
|
|
120
|
+
"dev": "bun run src/main.ts",
|
|
121
|
+
"build": "bun build src/main.ts --outfile dist/main.js --target bun"
|
|
122
|
+
},
|
|
123
|
+
"dependencies": {
|
|
124
|
+
"@rlabs-inc/tui": "latest",
|
|
125
|
+
"@rlabs-inc/signals": "latest"
|
|
126
|
+
},
|
|
127
|
+
"devDependencies": {
|
|
128
|
+
"@rlabs-inc/tui-compiler": "latest",
|
|
129
|
+
"@types/bun": "latest",
|
|
130
|
+
"typescript": "^5.0.0"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
`
|
|
134
|
+
|
|
135
|
+
const BUNFIG_TOML = `# TUI Framework configuration
|
|
136
|
+
# The compiler plugin enables .tui file imports
|
|
137
|
+
|
|
138
|
+
preload = ["@rlabs-inc/tui-compiler/register"]
|
|
139
|
+
`
|
|
140
|
+
|
|
141
|
+
const TSCONFIG_JSON = `{
|
|
142
|
+
"compilerOptions": {
|
|
143
|
+
"target": "ESNext",
|
|
144
|
+
"module": "ESNext",
|
|
145
|
+
"moduleResolution": "bundler",
|
|
146
|
+
"strict": true,
|
|
147
|
+
"esModuleInterop": true,
|
|
148
|
+
"skipLibCheck": true,
|
|
149
|
+
"forceConsistentCasingInFileNames": true,
|
|
150
|
+
"resolveJsonModule": true,
|
|
151
|
+
"declaration": true,
|
|
152
|
+
"declarationMap": true,
|
|
153
|
+
"noEmit": true,
|
|
154
|
+
"types": ["bun-types"]
|
|
155
|
+
},
|
|
156
|
+
"include": ["src/**/*"],
|
|
157
|
+
"exclude": ["node_modules", "dist"]
|
|
158
|
+
}
|
|
159
|
+
`
|
|
160
|
+
|
|
161
|
+
const GITIGNORE = `# Dependencies
|
|
162
|
+
node_modules/
|
|
163
|
+
|
|
164
|
+
# Build output
|
|
165
|
+
dist/
|
|
166
|
+
|
|
167
|
+
# Environment
|
|
168
|
+
.env
|
|
169
|
+
.env.local
|
|
170
|
+
|
|
171
|
+
# IDE
|
|
172
|
+
.vscode/
|
|
173
|
+
.idea/
|
|
174
|
+
*.swp
|
|
175
|
+
*.swo
|
|
176
|
+
|
|
177
|
+
# OS
|
|
178
|
+
.DS_Store
|
|
179
|
+
Thumbs.db
|
|
180
|
+
|
|
181
|
+
# Debug
|
|
182
|
+
*.log
|
|
183
|
+
`
|
|
184
|
+
|
|
185
|
+
const README = (name: string) => `# ${name}
|
|
186
|
+
|
|
187
|
+
A TUI Framework application.
|
|
188
|
+
|
|
189
|
+
## Getting Started
|
|
190
|
+
|
|
191
|
+
\`\`\`bash
|
|
192
|
+
# Run in development
|
|
193
|
+
bun run dev
|
|
194
|
+
|
|
195
|
+
# Build for production
|
|
196
|
+
bun run build
|
|
197
|
+
\`\`\`
|
|
198
|
+
|
|
199
|
+
## Project Structure
|
|
200
|
+
|
|
201
|
+
\`\`\`
|
|
202
|
+
${name}/
|
|
203
|
+
├── src/
|
|
204
|
+
│ ├── main.ts # Entry point
|
|
205
|
+
│ ├── App.tui # Root component
|
|
206
|
+
│ └── components/ # Reusable components
|
|
207
|
+
│ └── Counter.tui
|
|
208
|
+
├── bunfig.toml # Bun configuration (enables .tui compiler)
|
|
209
|
+
├── package.json
|
|
210
|
+
└── tsconfig.json
|
|
211
|
+
\`\`\`
|
|
212
|
+
|
|
213
|
+
## Learn More
|
|
214
|
+
|
|
215
|
+
- [TUI Framework Documentation](https://github.com/rlabs-inc/tui)
|
|
216
|
+
- [Signals Library](https://github.com/rlabs-inc/signals)
|
|
217
|
+
`
|
|
218
|
+
|
|
219
|
+
const MAIN_TS = `/**
|
|
220
|
+
* TUI Application Entry Point
|
|
221
|
+
*/
|
|
222
|
+
|
|
223
|
+
import '@rlabs-inc/tui-compiler/register'
|
|
224
|
+
import { mount, keyboard } from '@rlabs-inc/tui'
|
|
225
|
+
import App from './App.tui'
|
|
226
|
+
|
|
227
|
+
async function main() {
|
|
228
|
+
// Mount the application
|
|
229
|
+
const cleanup = await mount(() => {
|
|
230
|
+
App()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Handle exit
|
|
234
|
+
keyboard.onKey((event) => {
|
|
235
|
+
if (event.key === 'q' || (event.modifiers.ctrl && event.key === 'c')) {
|
|
236
|
+
cleanup().then(() => process.exit(0))
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
main().catch(console.error)
|
|
242
|
+
`
|
|
243
|
+
|
|
244
|
+
const APP_TUI = `<script lang="ts">
|
|
245
|
+
/**
|
|
246
|
+
* Root Application Component
|
|
247
|
+
*/
|
|
248
|
+
import Counter from './components/Counter.tui'
|
|
249
|
+
</script>
|
|
250
|
+
|
|
251
|
+
<box
|
|
252
|
+
width="100%"
|
|
253
|
+
height="100%"
|
|
254
|
+
flexDirection="column"
|
|
255
|
+
justifyContent="center"
|
|
256
|
+
alignItems="center"
|
|
257
|
+
gap={2}
|
|
258
|
+
>
|
|
259
|
+
<text variant="accent">Welcome to TUI Framework</text>
|
|
260
|
+
<Counter initialCount={0} />
|
|
261
|
+
<text variant="muted">Press Q to quit</text>
|
|
262
|
+
</box>
|
|
263
|
+
`
|
|
264
|
+
|
|
265
|
+
const COUNTER_TUI = `<script lang="ts">
|
|
266
|
+
/**
|
|
267
|
+
* Counter Component
|
|
268
|
+
*
|
|
269
|
+
* A simple reactive counter to demonstrate TUI Framework basics.
|
|
270
|
+
*/
|
|
271
|
+
import { keyboard } from '@rlabs-inc/tui'
|
|
272
|
+
|
|
273
|
+
export let initialCount: number = 0
|
|
274
|
+
|
|
275
|
+
const count = signal(initialCount)
|
|
276
|
+
|
|
277
|
+
// Handle keyboard input
|
|
278
|
+
keyboard.onKey((event) => {
|
|
279
|
+
if (event.key === 'ArrowUp' || event.key === '+') {
|
|
280
|
+
count.value++
|
|
281
|
+
}
|
|
282
|
+
if (event.key === 'ArrowDown' || event.key === '-') {
|
|
283
|
+
count.value--
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
</script>
|
|
287
|
+
|
|
288
|
+
<box
|
|
289
|
+
border={1}
|
|
290
|
+
padding={1}
|
|
291
|
+
flexDirection="column"
|
|
292
|
+
alignItems="center"
|
|
293
|
+
gap={1}
|
|
294
|
+
>
|
|
295
|
+
<text>Count: {count}</text>
|
|
296
|
+
<text variant="muted">Use +/- or arrow keys</text>
|
|
297
|
+
</box>
|
|
298
|
+
`
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* create-tui - Create TUI Framework applications
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun create tui my-app
|
|
7
|
+
* bunx create-tui my-app
|
|
8
|
+
* npx create-tui my-app
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseArgs } from 'util'
|
|
12
|
+
import { c, symbols } from './utils/colors'
|
|
13
|
+
import { create } from './commands/create'
|
|
14
|
+
|
|
15
|
+
const VERSION = '0.1.0'
|
|
16
|
+
|
|
17
|
+
const HELP = `
|
|
18
|
+
${c.bold('@rlabs-inc/create-tui')} ${c.muted(`v${VERSION}`)}
|
|
19
|
+
|
|
20
|
+
${c.dim('Create TUI Framework applications')}
|
|
21
|
+
${c.dim('The Terminal UI Framework for TypeScript/Bun')}
|
|
22
|
+
|
|
23
|
+
${c.bold('Usage:')}
|
|
24
|
+
${c.info('bunx @rlabs-inc/create-tui')} ${c.muted('<project-name>')}
|
|
25
|
+
${c.info('npx @rlabs-inc/create-tui')} ${c.muted('<project-name>')}
|
|
26
|
+
|
|
27
|
+
${c.bold('Options:')}
|
|
28
|
+
${c.muted('-h, --help')} Show this help message
|
|
29
|
+
${c.muted('-v, --version')} Show version
|
|
30
|
+
|
|
31
|
+
${c.bold('Examples:')}
|
|
32
|
+
${c.dim('$')} ${c.info('bunx @rlabs-inc/create-tui')} my-app
|
|
33
|
+
${c.dim('$')} ${c.info('bunx @rlabs-inc/create-tui')} dashboard
|
|
34
|
+
|
|
35
|
+
${c.muted('Documentation: https://github.com/rlabs-inc/tui')}
|
|
36
|
+
`
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
try {
|
|
40
|
+
const { values, positionals } = parseArgs({
|
|
41
|
+
args: Bun.argv.slice(2),
|
|
42
|
+
options: {
|
|
43
|
+
help: { type: 'boolean', short: 'h' },
|
|
44
|
+
version: { type: 'boolean', short: 'v' },
|
|
45
|
+
template: { type: 'string', short: 't' },
|
|
46
|
+
},
|
|
47
|
+
allowPositionals: true,
|
|
48
|
+
strict: false,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Help flag
|
|
52
|
+
if (values.help) {
|
|
53
|
+
console.log(HELP)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Version flag
|
|
58
|
+
if (values.version) {
|
|
59
|
+
console.log(`${c.bold('@rlabs-inc/create-tui')} ${c.muted(`v${VERSION}`)}`)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Get project name from positional args
|
|
64
|
+
const projectName = positionals[0]
|
|
65
|
+
|
|
66
|
+
// Run create command (will prompt for name if not provided)
|
|
67
|
+
await create({ projectName, template: values.template as string | undefined })
|
|
68
|
+
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
console.error(`${c.error(symbols.cross)} ${error.message}`)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal colors using Bun's built-in color API
|
|
3
|
+
*
|
|
4
|
+
* - Automatically respects NO_COLOR environment variable
|
|
5
|
+
* - Automatically detects terminal color support (16, 256, or 16m colors)
|
|
6
|
+
* - Uses semantic colors that work with any terminal theme
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const RESET = '\x1b[0m'
|
|
10
|
+
|
|
11
|
+
// Helper to wrap text with ANSI color
|
|
12
|
+
function colorize(text: string, color: string): string {
|
|
13
|
+
const ansi = Bun.color(color, 'ansi')
|
|
14
|
+
if (!ansi) return text
|
|
15
|
+
return `${ansi}${text}${RESET}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Style helpers
|
|
19
|
+
function bold(text: string): string {
|
|
20
|
+
return `\x1b[1m${text}${RESET}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dim(text: string): string {
|
|
24
|
+
return `\x1b[2m${text}${RESET}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function italic(text: string): string {
|
|
28
|
+
return `\x1b[3m${text}${RESET}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function underline(text: string): string {
|
|
32
|
+
return `\x1b[4m${text}${RESET}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Semantic color helpers that work with any terminal theme
|
|
36
|
+
export const c = {
|
|
37
|
+
// Emphasis
|
|
38
|
+
bold,
|
|
39
|
+
dim,
|
|
40
|
+
italic,
|
|
41
|
+
underline,
|
|
42
|
+
|
|
43
|
+
// Semantic colors using Bun.color for automatic terminal detection
|
|
44
|
+
success: (s: string) => colorize(s, '#22c55e'), // Green
|
|
45
|
+
error: (s: string) => colorize(s, '#ef4444'), // Red
|
|
46
|
+
warning: (s: string) => colorize(s, '#eab308'), // Yellow
|
|
47
|
+
info: (s: string) => colorize(s, '#06b6d4'), // Cyan
|
|
48
|
+
accent: (s: string) => colorize(s, '#a855f7'), // Purple
|
|
49
|
+
|
|
50
|
+
// Muted text (gray)
|
|
51
|
+
muted: (s: string) => colorize(s, '#6b7280'),
|
|
52
|
+
|
|
53
|
+
// Bright variants
|
|
54
|
+
brightGreen: (s: string) => colorize(s, '#4ade80'),
|
|
55
|
+
brightCyan: (s: string) => colorize(s, '#22d3ee'),
|
|
56
|
+
brightYellow: (s: string) => colorize(s, '#facc15'),
|
|
57
|
+
brightMagenta: (s: string) => colorize(s, '#c084fc'),
|
|
58
|
+
|
|
59
|
+
// Custom color
|
|
60
|
+
color: (s: string, color: string) => colorize(s, color),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Symbols that work in most terminals
|
|
64
|
+
export const symbols = {
|
|
65
|
+
check: '\u2714', // ✔
|
|
66
|
+
cross: '\u2718', // ✘
|
|
67
|
+
arrow: '\u276F', // ❯
|
|
68
|
+
bullet: '\u25CF', // ●
|
|
69
|
+
line: '\u2500', // ─
|
|
70
|
+
corner: '\u2514', // └
|
|
71
|
+
tee: '\u251C', // ├
|
|
72
|
+
vertical: '\u2502', // │
|
|
73
|
+
star: '\u2605', // ★
|
|
74
|
+
sparkle: '\u2728', // ✨
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strip ANSI codes for length calculation
|
|
78
|
+
function stripAnsi(s: string): string {
|
|
79
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Box drawing for beautiful output
|
|
83
|
+
export function box(content: string[], options: { title?: string; padding?: number } = {}): string {
|
|
84
|
+
const padding = options.padding ?? 1
|
|
85
|
+
const maxWidth = Math.max(...content.map(l => stripAnsi(l).length), stripAnsi(options.title ?? '').length)
|
|
86
|
+
const width = maxWidth + padding * 2
|
|
87
|
+
|
|
88
|
+
const horizontal = symbols.line.repeat(width + 2)
|
|
89
|
+
const empty = `${symbols.vertical}${' '.repeat(width + 2)}${symbols.vertical}`
|
|
90
|
+
const pad = ' '.repeat(padding)
|
|
91
|
+
|
|
92
|
+
const lines: string[] = []
|
|
93
|
+
|
|
94
|
+
// Top border with optional title
|
|
95
|
+
if (options.title) {
|
|
96
|
+
const titleText = options.title
|
|
97
|
+
const strippedTitle = stripAnsi(titleText)
|
|
98
|
+
const leftPad = Math.floor((width + 2 - strippedTitle.length) / 2)
|
|
99
|
+
const rightPad = width + 2 - strippedTitle.length - leftPad
|
|
100
|
+
lines.push(`\u256D${symbols.line.repeat(leftPad)}${titleText}${symbols.line.repeat(rightPad)}\u256E`)
|
|
101
|
+
} else {
|
|
102
|
+
lines.push(`\u256D${horizontal}\u256E`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Top padding
|
|
106
|
+
for (let i = 0; i < padding; i++) lines.push(empty)
|
|
107
|
+
|
|
108
|
+
// Content
|
|
109
|
+
for (const line of content) {
|
|
110
|
+
const stripped = stripAnsi(line)
|
|
111
|
+
const rightPad = width - stripped.length - padding
|
|
112
|
+
lines.push(`${symbols.vertical}${pad}${line}${' '.repeat(Math.max(0, rightPad) + padding)}${symbols.vertical}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Bottom padding
|
|
116
|
+
for (let i = 0; i < padding; i++) lines.push(empty)
|
|
117
|
+
|
|
118
|
+
// Bottom border
|
|
119
|
+
lines.push(`\u2570${horizontal}\u256F`)
|
|
120
|
+
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Spinner frames
|
|
125
|
+
export const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
126
|
+
|
|
127
|
+
// Progress bar
|
|
128
|
+
export function progressBar(percent: number, width = 20): string {
|
|
129
|
+
const filled = Math.round(width * percent)
|
|
130
|
+
const empty = width - filled
|
|
131
|
+
return `${c.success('\u2588'.repeat(filled))}${c.muted('\u2591'.repeat(empty))}`
|
|
132
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple interactive prompts without external dependencies
|
|
3
|
+
*
|
|
4
|
+
* Uses raw mode to capture key presses for a smooth experience
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { c, symbols } from './colors'
|
|
8
|
+
|
|
9
|
+
const stdin = process.stdin
|
|
10
|
+
const stdout = process.stdout
|
|
11
|
+
|
|
12
|
+
// Read a line of input
|
|
13
|
+
export async function text(options: {
|
|
14
|
+
message: string
|
|
15
|
+
placeholder?: string
|
|
16
|
+
defaultValue?: string
|
|
17
|
+
validate?: (value: string) => string | true
|
|
18
|
+
}): Promise<string> {
|
|
19
|
+
const { message, placeholder, defaultValue, validate } = options
|
|
20
|
+
|
|
21
|
+
// Print prompt
|
|
22
|
+
stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)}`)
|
|
23
|
+
if (defaultValue) {
|
|
24
|
+
stdout.write(` ${c.muted(`(${defaultValue})`)}`)
|
|
25
|
+
}
|
|
26
|
+
stdout.write(': ')
|
|
27
|
+
|
|
28
|
+
if (placeholder && !defaultValue) {
|
|
29
|
+
stdout.write(c.muted(placeholder))
|
|
30
|
+
stdout.write('\x1b[' + placeholder.length + 'D') // Move cursor back
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let buffer = ''
|
|
35
|
+
let placeholderCleared = false
|
|
36
|
+
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
stdin.setRawMode(false)
|
|
39
|
+
stdin.removeListener('data', onData)
|
|
40
|
+
stdin.pause()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const onData = (data: Buffer) => {
|
|
44
|
+
const char = data.toString()
|
|
45
|
+
const code = data[0]
|
|
46
|
+
|
|
47
|
+
// Clear placeholder on first input
|
|
48
|
+
if (!placeholderCleared && placeholder && buffer === '') {
|
|
49
|
+
stdout.write('\x1b[' + placeholder.length + 'D')
|
|
50
|
+
stdout.write(' '.repeat(placeholder.length))
|
|
51
|
+
stdout.write('\x1b[' + placeholder.length + 'D')
|
|
52
|
+
placeholderCleared = true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Enter
|
|
56
|
+
if (code === 13) {
|
|
57
|
+
stdout.write('\n')
|
|
58
|
+
const result = buffer || defaultValue || ''
|
|
59
|
+
|
|
60
|
+
// Validate
|
|
61
|
+
if (validate) {
|
|
62
|
+
const validationResult = validate(result)
|
|
63
|
+
if (validationResult !== true) {
|
|
64
|
+
stdout.write(`${c.error(symbols.cross)} ${validationResult}\n`)
|
|
65
|
+
stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)}: `)
|
|
66
|
+
buffer = ''
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
cleanup()
|
|
72
|
+
resolve(result)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Ctrl+C
|
|
77
|
+
if (code === 3) {
|
|
78
|
+
stdout.write('\n')
|
|
79
|
+
cleanup()
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Backspace
|
|
84
|
+
if (code === 127) {
|
|
85
|
+
if (buffer.length > 0) {
|
|
86
|
+
buffer = buffer.slice(0, -1)
|
|
87
|
+
stdout.write('\b \b')
|
|
88
|
+
}
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Printable characters
|
|
93
|
+
if (code >= 32 && code < 127) {
|
|
94
|
+
buffer += char
|
|
95
|
+
stdout.write(char)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
stdin.resume()
|
|
100
|
+
stdin.setRawMode(true)
|
|
101
|
+
stdin.on('data', onData)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Select from options
|
|
106
|
+
export async function select<T extends string>(options: {
|
|
107
|
+
message: string
|
|
108
|
+
options: { value: T; label: string; hint?: string }[]
|
|
109
|
+
}): Promise<T> {
|
|
110
|
+
const { message, options: choices } = options
|
|
111
|
+
let selectedIndex = 0
|
|
112
|
+
|
|
113
|
+
const render = () => {
|
|
114
|
+
// Clear previous render
|
|
115
|
+
stdout.write('\x1b[?25l') // Hide cursor
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < choices.length; i++) {
|
|
118
|
+
const choice = choices[i]
|
|
119
|
+
const isSelected = i === selectedIndex
|
|
120
|
+
const prefix = isSelected ? c.success(symbols.arrow) : ' '
|
|
121
|
+
const label = isSelected ? c.bold(choice.label) : choice.label
|
|
122
|
+
const hint = choice.hint ? c.muted(` ${choice.hint}`) : ''
|
|
123
|
+
|
|
124
|
+
stdout.write(` ${prefix} ${label}${hint}\n`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)}\n`)
|
|
129
|
+
render()
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
stdin.setRawMode(false)
|
|
134
|
+
stdin.removeListener('data', onData)
|
|
135
|
+
stdin.pause()
|
|
136
|
+
stdout.write('\x1b[?25h') // Show cursor
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const onData = (data: Buffer) => {
|
|
140
|
+
const code = data[0]
|
|
141
|
+
|
|
142
|
+
// Ctrl+C
|
|
143
|
+
if (code === 3) {
|
|
144
|
+
stdout.write('\n')
|
|
145
|
+
cleanup()
|
|
146
|
+
process.exit(1)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Up arrow or k
|
|
150
|
+
if ((data[0] === 27 && data[1] === 91 && data[2] === 65) || code === 107) {
|
|
151
|
+
selectedIndex = (selectedIndex - 1 + choices.length) % choices.length
|
|
152
|
+
// Move cursor up and re-render
|
|
153
|
+
stdout.write(`\x1b[${choices.length}A`)
|
|
154
|
+
render()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Down arrow or j
|
|
159
|
+
if ((data[0] === 27 && data[1] === 91 && data[2] === 66) || code === 106) {
|
|
160
|
+
selectedIndex = (selectedIndex + 1) % choices.length
|
|
161
|
+
// Move cursor up and re-render
|
|
162
|
+
stdout.write(`\x1b[${choices.length}A`)
|
|
163
|
+
render()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Enter
|
|
168
|
+
if (code === 13) {
|
|
169
|
+
cleanup()
|
|
170
|
+
// Clear the options
|
|
171
|
+
stdout.write(`\x1b[${choices.length}A`)
|
|
172
|
+
for (let i = 0; i < choices.length; i++) {
|
|
173
|
+
stdout.write('\x1b[2K\n')
|
|
174
|
+
}
|
|
175
|
+
stdout.write(`\x1b[${choices.length}A`)
|
|
176
|
+
stdout.write(` ${c.muted(symbols.check)} ${choices[selectedIndex].label}\n`)
|
|
177
|
+
resolve(choices[selectedIndex].value)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
stdin.resume()
|
|
183
|
+
stdin.setRawMode(true)
|
|
184
|
+
stdin.on('data', onData)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Confirm yes/no
|
|
189
|
+
export async function confirm(options: {
|
|
190
|
+
message: string
|
|
191
|
+
defaultValue?: boolean
|
|
192
|
+
}): Promise<boolean> {
|
|
193
|
+
const { message, defaultValue = true } = options
|
|
194
|
+
const hint = defaultValue ? c.muted('[Y/n]') : c.muted('[y/N]')
|
|
195
|
+
|
|
196
|
+
stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)} ${hint} `)
|
|
197
|
+
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
const cleanup = () => {
|
|
200
|
+
stdin.setRawMode(false)
|
|
201
|
+
stdin.removeListener('data', onData)
|
|
202
|
+
stdin.pause()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const onData = (data: Buffer) => {
|
|
206
|
+
const char = data.toString().toLowerCase()
|
|
207
|
+
const code = data[0]
|
|
208
|
+
|
|
209
|
+
// Ctrl+C
|
|
210
|
+
if (code === 3) {
|
|
211
|
+
stdout.write('\n')
|
|
212
|
+
cleanup()
|
|
213
|
+
process.exit(1)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Enter (use default)
|
|
217
|
+
if (code === 13) {
|
|
218
|
+
stdout.write(defaultValue ? 'Yes' : 'No')
|
|
219
|
+
stdout.write('\n')
|
|
220
|
+
cleanup()
|
|
221
|
+
resolve(defaultValue)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Y
|
|
226
|
+
if (char === 'y') {
|
|
227
|
+
stdout.write('Yes\n')
|
|
228
|
+
cleanup()
|
|
229
|
+
resolve(true)
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// N
|
|
234
|
+
if (char === 'n') {
|
|
235
|
+
stdout.write('No\n')
|
|
236
|
+
cleanup()
|
|
237
|
+
resolve(false)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
stdin.resume()
|
|
243
|
+
stdin.setRawMode(true)
|
|
244
|
+
stdin.on('data', onData)
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Spinner
|
|
249
|
+
export function spinner(message: string): { stop: (finalMessage?: string) => void } {
|
|
250
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
251
|
+
let i = 0
|
|
252
|
+
let stopped = false
|
|
253
|
+
|
|
254
|
+
stdout.write('\x1b[?25l') // Hide cursor
|
|
255
|
+
|
|
256
|
+
const interval = setInterval(() => {
|
|
257
|
+
if (stopped) return
|
|
258
|
+
stdout.write(`\r${c.info(frames[i])} ${message}`)
|
|
259
|
+
i = (i + 1) % frames.length
|
|
260
|
+
}, 80)
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
stop: (finalMessage?: string) => {
|
|
264
|
+
stopped = true
|
|
265
|
+
clearInterval(interval)
|
|
266
|
+
stdout.write('\r\x1b[K') // Clear line
|
|
267
|
+
if (finalMessage) {
|
|
268
|
+
stdout.write(`${c.success(symbols.check)} ${finalMessage}\n`)
|
|
269
|
+
}
|
|
270
|
+
stdout.write('\x1b[?25h') // Show cursor
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|