feat: 初始化 TypeScript CLI 模板项目
建立完整的 CLI 产品基础骨架,支持多种演进方向 - 添加项目基础配置(package.json、tsconfig、pnpm-lock.yaml、.gitignore、.npmignore) - 实现运行时基础设施(RuntimeServices 统一注入、日志、输出模式、配置存储、插件加载、任务注册表) - 采用 command/use-case/presenter 三层架构组织业务模块(demo、config、task) - 添加 UI 渲染层(banner、panel、table、主题样式) - 实现插件机制,支持运行时扫描加载外部模块和任务 - 支持 text/json 双输出模式,便于集成与调试 - 添加统一错误模型 AppError 和 CLI 错误边界处理 - 基于 node:test 建立测试基座,包含示例测试用例 - 配置 npm 发布脚本和包元数据 - 编写完整 README 文档,包含快速开始、开发约定和演进方向
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.npm-pack-cache
|
||||
8
.npmignore
Normal file
8
.npmignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
src
|
||||
plugins
|
||||
.git
|
||||
.gitignore
|
||||
pnpm-lock.yaml
|
||||
tsconfig.json
|
||||
cli-starter.config.json
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
180
README.md
Normal file
180
README.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# CLI Template V1
|
||||
|
||||
一个面向长期演进的 TypeScript CLI 模板。它不是只服务 AI 编程场景,而是可以作为多种 CLI 产品的基础骨架:
|
||||
|
||||
- 桌面软件控制 CLI
|
||||
- 配置初始化与环境诊断 CLI
|
||||
- 工作流与自动化任务 CLI
|
||||
- 再进一步扩展成 Agent / AI 助手型 CLI
|
||||
|
||||
## 当前能力
|
||||
|
||||
- TypeScript + Node 20
|
||||
- `RuntimeServices` 统一注入运行时能力
|
||||
- `command / use-case / presenter` 分层
|
||||
- 模块注册表、任务注册表、运行时插件加载
|
||||
- `text / json` 双输出模式
|
||||
- 配置文件持久化服务
|
||||
- 统一错误模型与 CLI 错误边界
|
||||
- `node:test` 测试基座
|
||||
- 可作为 npm CLI 包发布
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├─ plugins/ # 运行时插件目录,启动时会扫描这里的 *.js
|
||||
├─ src/
|
||||
│ ├─ bootstrap/ # CLI 应用装配
|
||||
│ ├─ runtime/ # services/errors/output/config/plugin/task-registry
|
||||
│ ├─ modules/ # 业务模块,按 command/use-case/presenter 划分
|
||||
│ ├─ test/ # 测试聚合入口
|
||||
│ ├─ plugins/ # 插件示例源码
|
||||
│ └─ ui/renderers/ # 纯渲染层
|
||||
└─ dist/ # 编译产物
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev -- --help
|
||||
pnpm dev -- demo
|
||||
pnpm dev -- config init
|
||||
pnpm dev -- --output json config show
|
||||
pnpm dev -- --output json task list
|
||||
pnpm dev -- --output json task run doctor
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 全局安装后的命令名
|
||||
|
||||
包名和命令名现在统一为:
|
||||
|
||||
```bash
|
||||
npm install -g cli-template-v1
|
||||
cli-template-v1 --help
|
||||
cli-template-v1 task list
|
||||
cli-template-v1 --output json config show
|
||||
```
|
||||
|
||||
这样用户安装的包名和实际执行命令一致,不会出现安装了 `cli-template-v1`,却要执行 `cli-starter` 的割裂体验。
|
||||
|
||||
## CLI 示例
|
||||
|
||||
文本输出:
|
||||
|
||||
```bash
|
||||
pnpm dev -- task list
|
||||
```
|
||||
|
||||
JSON 输出:
|
||||
|
||||
```bash
|
||||
pnpm dev -- --output json task run doctor
|
||||
```
|
||||
|
||||
读取配置:
|
||||
|
||||
```bash
|
||||
pnpm dev -- --output json config show
|
||||
```
|
||||
|
||||
## 插件机制
|
||||
|
||||
运行时会扫描当前工作目录下的 `plugins/*.js` 文件,并尝试加载插件。
|
||||
|
||||
插件需要导出一个 `CliPlugin` 对象,可提供:
|
||||
|
||||
- `modules`: 新命令模块
|
||||
- `tasks`: 新任务定义
|
||||
|
||||
项目里有两个相关位置:
|
||||
|
||||
- [plugins/example-plugin.js](/D:/VScodeProject/cli-codex/plugins/example-plugin.js)
|
||||
这是当前真正会被运行时扫描到的示例插件。
|
||||
- [src/plugins/example-plugin.js](/D:/VScodeProject/cli-codex/src/plugins/example-plugin.js)
|
||||
这是源码层面的示例参考。
|
||||
|
||||
加载成功后,插件任务会自动出现在 `task list` 里。
|
||||
|
||||
## 开发约定
|
||||
|
||||
### 1. 新增命令模块
|
||||
|
||||
在 `src/modules/<name>/` 下新增模块,至少拆成:
|
||||
|
||||
- `command.ts`: 只负责 CLI 参数解析和流程编排
|
||||
- `use-case.ts`: 只负责业务逻辑
|
||||
- `presenter.ts`: 只负责输出格式
|
||||
|
||||
然后在 [registry.ts](/D:/VScodeProject/cli-codex/src/modules/registry.ts) 注册。
|
||||
|
||||
### 2. 新增任务
|
||||
|
||||
在 `src/modules/task/tasks/` 下新增一个 `PluginTask`,然后在 [registry.ts](/D:/VScodeProject/cli-codex/src/modules/task/tasks/registry.ts) 注册。
|
||||
|
||||
### 3. 使用配置存储
|
||||
|
||||
通过 `services.configStore.read/write/patch` 读写 `cli-template-v1.config.json`,不要在业务模块里直接操作文件系统。
|
||||
|
||||
### 4. 使用输出模式
|
||||
|
||||
通过 `services.output.mode` 判断当前模式,通过 `services.output.writeText/writeData` 输出,避免在 presenter 或 use-case 外随意 `console.log`。
|
||||
|
||||
### 5. 使用统一错误
|
||||
|
||||
抛出 `AppError`,携带:
|
||||
|
||||
- `code`
|
||||
- `exitCode`
|
||||
- `details`
|
||||
|
||||
CLI 会自动按 text/json 两种模式输出错误结果。
|
||||
|
||||
## 测试
|
||||
|
||||
当前测试基座基于 Node 内置测试框架,入口在:
|
||||
|
||||
- [all.test.ts](/D:/VScodeProject/cli-codex/src/test/all.test.ts)
|
||||
|
||||
已有示例测试:
|
||||
|
||||
- [use-case.test.ts](/D:/VScodeProject/cli-codex/src/modules/config/use-case.test.ts)
|
||||
- [task-registry.test.ts](/D:/VScodeProject/cli-codex/src/runtime/task-registry.test.ts)
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 发布 npm 包
|
||||
|
||||
发布前建议先检查打包内容:
|
||||
|
||||
```bash
|
||||
pnpm pack:check
|
||||
```
|
||||
|
||||
正式发布:
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
当前发布配置已限制只发布:
|
||||
|
||||
- `dist/`
|
||||
- `README.md`
|
||||
- `LICENSE`
|
||||
|
||||
注意:`repository`、`homepage`、`bugs` 目前是占位地址,发布前应替换成你的真实仓库链接。
|
||||
|
||||
## 适合继续演进的方向
|
||||
|
||||
- 配置 schema 校验与迁移
|
||||
- 插件签名与权限模型
|
||||
- 错误码和 exit code 文档化
|
||||
- fixture runner 与端到端命令测试
|
||||
- 发布流程和单文件打包
|
||||
5
cli-starter.config.json
Normal file
5
cli-starter.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projectName": "my-cli-tool",
|
||||
"persona": "safe",
|
||||
"verbose": true
|
||||
}
|
||||
58
package.json
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "cli-template-v1",
|
||||
"version": "0.1.0",
|
||||
"description": "A scalable TypeScript CLI template with rich terminal UI patterns.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"bin": {
|
||||
"cli-template-v1": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/your-org/cli-template-v1.git"
|
||||
},
|
||||
"homepage": "https://github.com/your-org/cli-template-v1#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/your-org/cli-template-v1/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
|
||||
"start": "node dist/index.js",
|
||||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && node ./node_modules/typescript/bin/tsc -p tsconfig.json && node --test dist/test/all.test.js",
|
||||
"pack:check": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && node ./node_modules/typescript/bin/tsc -p tsconfig.build.json && npm pack --dry-run --cache .npm-pack-cache",
|
||||
"prepublishOnly": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && node ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node ./node_modules/typescript/bin/tsc -p tsconfig.build.json"
|
||||
},
|
||||
"keywords": [
|
||||
"cli",
|
||||
"typescript",
|
||||
"template",
|
||||
"terminal-ui"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^14.0.3",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
20
plugins/example-plugin.js
Normal file
20
plugins/example-plugin.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
name: "example-plugin",
|
||||
tasks: [
|
||||
{
|
||||
id: "hello-plugin",
|
||||
create() {
|
||||
return {
|
||||
id: "hello-plugin",
|
||||
title: "Hello From Plugin",
|
||||
async run() {
|
||||
return {
|
||||
plugin: "example-plugin",
|
||||
message: "Plugin task executed"
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
360
pnpm-lock.yaml
generated
Normal file
360
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,360 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
commander:
|
||||
specifier: ^14.0.3
|
||||
version: 14.0.3
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.12.0
|
||||
version: 24.12.0
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.4':
|
||||
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.4':
|
||||
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.4':
|
||||
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.4':
|
||||
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.4':
|
||||
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.4':
|
||||
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.4':
|
||||
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.4':
|
||||
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.4':
|
||||
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.4':
|
||||
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.4':
|
||||
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.4':
|
||||
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.4':
|
||||
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.4':
|
||||
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.4':
|
||||
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.4':
|
||||
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.4':
|
||||
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/node@24.12.0':
|
||||
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
esbuild@0.27.4:
|
||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
get-tsconfig@4.13.6:
|
||||
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
tsx@4.21.0:
|
||||
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.4':
|
||||
optional: true
|
||||
|
||||
'@types/node@24.12.0':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
esbuild@0.27.4:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.4
|
||||
'@esbuild/android-arm': 0.27.4
|
||||
'@esbuild/android-arm64': 0.27.4
|
||||
'@esbuild/android-x64': 0.27.4
|
||||
'@esbuild/darwin-arm64': 0.27.4
|
||||
'@esbuild/darwin-x64': 0.27.4
|
||||
'@esbuild/freebsd-arm64': 0.27.4
|
||||
'@esbuild/freebsd-x64': 0.27.4
|
||||
'@esbuild/linux-arm': 0.27.4
|
||||
'@esbuild/linux-arm64': 0.27.4
|
||||
'@esbuild/linux-ia32': 0.27.4
|
||||
'@esbuild/linux-loong64': 0.27.4
|
||||
'@esbuild/linux-mips64el': 0.27.4
|
||||
'@esbuild/linux-ppc64': 0.27.4
|
||||
'@esbuild/linux-riscv64': 0.27.4
|
||||
'@esbuild/linux-s390x': 0.27.4
|
||||
'@esbuild/linux-x64': 0.27.4
|
||||
'@esbuild/netbsd-arm64': 0.27.4
|
||||
'@esbuild/netbsd-x64': 0.27.4
|
||||
'@esbuild/openbsd-arm64': 0.27.4
|
||||
'@esbuild/openbsd-x64': 0.27.4
|
||||
'@esbuild/openharmony-arm64': 0.27.4
|
||||
'@esbuild/sunos-x64': 0.27.4
|
||||
'@esbuild/win32-arm64': 0.27.4
|
||||
'@esbuild/win32-ia32': 0.27.4
|
||||
'@esbuild/win32-x64': 0.27.4
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
get-tsconfig@4.13.6:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
get-tsconfig: 4.13.6
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
40
src/bootstrap/create-app.ts
Normal file
40
src/bootstrap/create-app.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Command } from "commander";
|
||||
import { createRuntimeBootstrap } from "../runtime/create-runtime-services.js";
|
||||
import type { OutputMode } from "../runtime/types.js";
|
||||
import { renderBanner } from "../ui/renderers/banner.js";
|
||||
|
||||
export async function createApp() {
|
||||
const { services, moduleDefinitions } = await createRuntimeBootstrap();
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("cli-template-v1")
|
||||
.description("A reusable TypeScript CLI template for desktop tools, automation, and workflow assistants.")
|
||||
.version("0.1.0")
|
||||
.option("-d, --debug", "enable debug logging")
|
||||
.option("-o, --output <mode>", "output mode: text | json", "text")
|
||||
.hook("preAction", (command) => {
|
||||
const options = command.optsWithGlobals<{ debug?: boolean; output?: OutputMode }>();
|
||||
if (options.debug) {
|
||||
services.logger.setDebug(true);
|
||||
services.logger.debug("debug mode enabled");
|
||||
}
|
||||
services.output.setMode(options.output === "json" ? "json" : "text");
|
||||
});
|
||||
|
||||
program.addHelpText("beforeAll", `${renderBanner()}\n`);
|
||||
program.addHelpText(
|
||||
"afterAll",
|
||||
`\nExamples:\n $ cli-template-v1 demo\n $ cli-template-v1 config init\n $ cli-template-v1 --output json config show\n $ cli-template-v1 --output json task run doctor\n`
|
||||
);
|
||||
|
||||
for (const definition of moduleDefinitions) {
|
||||
definition.create().register(program, services);
|
||||
}
|
||||
|
||||
return {
|
||||
async run(argv: string[]) {
|
||||
await program.parseAsync(argv);
|
||||
}
|
||||
};
|
||||
}
|
||||
13
src/index.ts
Normal file
13
src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createApp } from "./bootstrap/create-app.js";
|
||||
import { writeCliError } from "./runtime/errors.js";
|
||||
|
||||
async function main() {
|
||||
const app = await createApp();
|
||||
await app.run(process.argv);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.exitCode = writeCliError(error, process.argv);
|
||||
});
|
||||
47
src/modules/config/command.ts
Normal file
47
src/modules/config/command.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandModule, RuntimeServices } from "../../runtime/types.js";
|
||||
import { presentConfigDraft, presentConfigSaved, presentConfigView } from "./presenter.js";
|
||||
import { createConfigDraft, loadConfig, saveConfigDraft } from "./use-case.js";
|
||||
|
||||
export function createConfigModule(): CommandModule {
|
||||
return {
|
||||
register(program: Command, services: RuntimeServices) {
|
||||
const config = program.command("config").description("Manage local CLI configuration.");
|
||||
|
||||
config
|
||||
.command("init")
|
||||
.description("Run an interactive config bootstrap flow and persist the result.")
|
||||
.action(async () => {
|
||||
const projectName = await services.ui.promptText("Project name", "my-cli-tool");
|
||||
const persona = await services.ui.promptSelect("Default operating style", [
|
||||
{ value: "safe", label: "Safe and guided" },
|
||||
{ value: "fast", label: "Fast and direct" },
|
||||
{ value: "expert", label: "Expert automation" }
|
||||
]);
|
||||
const verbose = await services.ui.promptConfirm("Enable verbose logging in the starter config?", true);
|
||||
|
||||
const draft = createConfigDraft({
|
||||
cwd: services.cwd,
|
||||
projectName,
|
||||
persona,
|
||||
verbose
|
||||
});
|
||||
|
||||
if (services.output.mode === "text") {
|
||||
presentConfigDraft(services, draft);
|
||||
}
|
||||
|
||||
const saved = await saveConfigDraft(services, draft);
|
||||
presentConfigSaved(services, { path: services.configStore.path, config: saved });
|
||||
});
|
||||
|
||||
config
|
||||
.command("show")
|
||||
.description("Show the persisted CLI configuration.")
|
||||
.action(async () => {
|
||||
const value = await loadConfig(services);
|
||||
presentConfigView(services, value);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
34
src/modules/config/presenter.ts
Normal file
34
src/modules/config/presenter.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RuntimeServices } from "../../runtime/types.js";
|
||||
import { renderPanel } from "../../ui/renderers/panel.js";
|
||||
import { theme } from "../../ui/renderers/theme.js";
|
||||
import type { ConfigDraft } from "./use-case.js";
|
||||
|
||||
export function presentConfigDraft(services: RuntimeServices, draft: ConfigDraft) {
|
||||
if (services.output.mode === "json") {
|
||||
services.output.writeData(draft);
|
||||
return;
|
||||
}
|
||||
|
||||
services.output.writeText(
|
||||
renderPanel("Generated Config", [
|
||||
{ label: "project", value: theme.brand(draft.projectName) },
|
||||
{ label: "persona", value: draft.persona },
|
||||
{ label: "verbose", value: String(draft.verbose) },
|
||||
{ label: "root", value: draft.root }
|
||||
])
|
||||
);
|
||||
services.output.writeText(theme.muted("Next step: persist this structure into a config file or profile registry."));
|
||||
}
|
||||
|
||||
export function presentConfigSaved(services: RuntimeServices, value: unknown) {
|
||||
if (services.output.mode === "json") {
|
||||
services.output.writeData(value);
|
||||
return;
|
||||
}
|
||||
|
||||
services.output.writeText(theme.success(`Config saved to ${services.configStore.path}`));
|
||||
}
|
||||
|
||||
export function presentConfigView(services: RuntimeServices, value: unknown) {
|
||||
services.output.writeData(value);
|
||||
}
|
||||
28
src/modules/config/use-case.test.ts
Normal file
28
src/modules/config/use-case.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createConfigDraft } from "./use-case.js";
|
||||
|
||||
test("createConfigDraft trims project name", () => {
|
||||
const draft = createConfigDraft({
|
||||
cwd: "/workspace",
|
||||
projectName: " demo-cli ",
|
||||
persona: "safe",
|
||||
verbose: true
|
||||
});
|
||||
|
||||
assert.equal(draft.projectName, "demo-cli");
|
||||
assert.equal(draft.verbose, true);
|
||||
});
|
||||
|
||||
test("createConfigDraft rejects blank project name", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createConfigDraft({
|
||||
cwd: "/workspace",
|
||||
projectName: " ",
|
||||
persona: "safe",
|
||||
verbose: false
|
||||
}),
|
||||
/Project name is required/
|
||||
);
|
||||
});
|
||||
44
src/modules/config/use-case.ts
Normal file
44
src/modules/config/use-case.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { AppError } from "../../runtime/errors.js";
|
||||
import type { AppConfig, RuntimeServices } from "../../runtime/types.js";
|
||||
|
||||
export interface ConfigDraft {
|
||||
projectName: string;
|
||||
persona: string;
|
||||
root: string;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export function createConfigDraft(input: {
|
||||
cwd: string;
|
||||
projectName: string;
|
||||
persona: string;
|
||||
verbose: boolean;
|
||||
}): ConfigDraft {
|
||||
const projectName = input.projectName.trim();
|
||||
if (!projectName) {
|
||||
throw new AppError("Project name is required.", { code: "CONFIG_VALIDATION_ERROR", exitCode: 2 });
|
||||
}
|
||||
|
||||
return {
|
||||
projectName,
|
||||
persona: input.persona,
|
||||
root: input.cwd,
|
||||
verbose: input.verbose
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveConfigDraft(services: RuntimeServices, draft: ConfigDraft): Promise<AppConfig> {
|
||||
return services.configStore.patch({
|
||||
projectName: draft.projectName,
|
||||
persona: draft.persona,
|
||||
verbose: draft.verbose
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadConfig(services: RuntimeServices): Promise<Partial<AppConfig> & { path: string }> {
|
||||
const config = await services.configStore.read();
|
||||
return {
|
||||
path: services.configStore.path,
|
||||
...config
|
||||
};
|
||||
}
|
||||
27
src/modules/demo/command.ts
Normal file
27
src/modules/demo/command.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandModule, RuntimeServices } from "../../runtime/types.js";
|
||||
import { presentDemoIntro, presentDemoSelection } from "./presenter.js";
|
||||
import { createDemoViewModel, selectDemoProfile } from "./use-case.js";
|
||||
|
||||
export function createDemoModule(): CommandModule {
|
||||
return {
|
||||
register(program: Command, services: RuntimeServices) {
|
||||
program
|
||||
.command("demo")
|
||||
.description("Preview the reusable terminal UI patterns in this template.")
|
||||
.action(async () => {
|
||||
const viewModel = createDemoViewModel(services);
|
||||
presentDemoIntro(services, viewModel);
|
||||
|
||||
const profile = await services.ui.promptSelect("What kind of CLI do you want to extend this into?", [
|
||||
{ value: "desktop", label: "Desktop software control" },
|
||||
{ value: "config", label: "Configuration workflow" },
|
||||
{ value: "assistant", label: "AI or workflow assistant" }
|
||||
]);
|
||||
const verbose = await services.ui.promptConfirm("Enable verbose logging in the starter config?", true);
|
||||
|
||||
presentDemoSelection(services, selectDemoProfile(profile, verbose));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
29
src/modules/demo/presenter.ts
Normal file
29
src/modules/demo/presenter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import pc from "picocolors";
|
||||
import type { RuntimeServices } from "../../runtime/types.js";
|
||||
import { renderPanel } from "../../ui/renderers/panel.js";
|
||||
import { renderTable } from "../../ui/renderers/table.js";
|
||||
import { theme } from "../../ui/renderers/theme.js";
|
||||
import type { DemoSelection, DemoViewModel } from "./use-case.js";
|
||||
|
||||
export function presentDemoIntro(services: RuntimeServices, viewModel: DemoViewModel) {
|
||||
if (services.output.mode === "json") {
|
||||
services.output.writeData(viewModel);
|
||||
return;
|
||||
}
|
||||
|
||||
services.output.writeText(renderPanel("CLI Overview", viewModel.overview));
|
||||
services.output.writeText();
|
||||
services.output.writeText(renderTable(["UI Pattern", "Purpose", "Where it fits"], viewModel.patterns));
|
||||
services.output.writeText();
|
||||
}
|
||||
|
||||
export function presentDemoSelection(services: RuntimeServices, selection: DemoSelection) {
|
||||
if (services.output.mode === "json") {
|
||||
services.output.writeData(selection);
|
||||
return;
|
||||
}
|
||||
|
||||
services.output.writeText(
|
||||
`${pc.bold("Selected profile:")} ${theme.accent(selection.profile)} ${theme.muted(`verbose=${String(selection.verbose)}`)}`
|
||||
);
|
||||
}
|
||||
30
src/modules/demo/use-case.ts
Normal file
30
src/modules/demo/use-case.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RuntimeServices } from "../../runtime/types.js";
|
||||
|
||||
export interface DemoViewModel {
|
||||
overview: Array<{ label: string; value: string }>;
|
||||
patterns: string[][];
|
||||
}
|
||||
|
||||
export interface DemoSelection {
|
||||
profile: string;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export function createDemoViewModel(services: RuntimeServices): DemoViewModel {
|
||||
return {
|
||||
overview: [
|
||||
{ label: "cwd", value: services.cwd },
|
||||
{ label: "runtime", value: process.version },
|
||||
{ label: "mode", value: "interactive template" }
|
||||
],
|
||||
patterns: [
|
||||
["Panel", "Summaries and status cards", "Desktop app control / dev tools"],
|
||||
["Prompt", "Decision making", "Config setup / guided flows"],
|
||||
["Task", "Progress feedback", "Automation / repair / sync"]
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function selectDemoProfile(profile: string, verbose: boolean): DemoSelection {
|
||||
return { profile, verbose };
|
||||
}
|
||||
12
src/modules/registry.ts
Normal file
12
src/modules/registry.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ModuleDefinition } from "../runtime/types.js";
|
||||
import { createConfigModule } from "./config/command.js";
|
||||
import { createDemoModule } from "./demo/command.js";
|
||||
import { createTaskModule } from "./task/command.js";
|
||||
|
||||
export function listModuleDefinitions(): ModuleDefinition[] {
|
||||
return [
|
||||
{ id: "demo", create: createDemoModule },
|
||||
{ id: "config", create: createConfigModule },
|
||||
{ id: "task", create: createTaskModule }
|
||||
];
|
||||
}
|
||||
39
src/modules/task/command.ts
Normal file
39
src/modules/task/command.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Command } from "commander";
|
||||
import { AppError } from "../../runtime/errors.js";
|
||||
import { runTask } from "../../runtime/task-runner.js";
|
||||
import type { CommandModule, RuntimeServices } from "../../runtime/types.js";
|
||||
import { presentTaskList, presentTaskResult } from "./presenter.js";
|
||||
import { listTasks } from "./use-case.js";
|
||||
|
||||
export function createTaskModule(): CommandModule {
|
||||
return {
|
||||
register(program: Command, services: RuntimeServices) {
|
||||
const task = program.command("task").description("Run pluggable automation tasks.");
|
||||
|
||||
task
|
||||
.command("list")
|
||||
.description("List registered tasks.")
|
||||
.action(() => {
|
||||
presentTaskList(services, listTasks(services));
|
||||
});
|
||||
|
||||
task
|
||||
.command("run")
|
||||
.description("Run a registered task by id.")
|
||||
.argument("<task-id>", "task identifier")
|
||||
.action(async (taskId: string) => {
|
||||
const selectedTask = services.taskRegistry.find(taskId);
|
||||
if (!selectedTask) {
|
||||
throw new AppError(`Unknown task: ${taskId}`, {
|
||||
code: "TASK_NOT_FOUND",
|
||||
exitCode: 2,
|
||||
details: { taskId }
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runTask(selectedTask, services);
|
||||
presentTaskResult(services, result);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
15
src/modules/task/presenter.ts
Normal file
15
src/modules/task/presenter.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { RuntimeServices } from "../../runtime/types.js";
|
||||
import { renderTable } from "../../ui/renderers/table.js";
|
||||
|
||||
export function presentTaskList(services: RuntimeServices, rows: string[][]) {
|
||||
if (services.output.mode === "json") {
|
||||
services.output.writeData(rows.map(([id, title]) => ({ id, title })));
|
||||
return;
|
||||
}
|
||||
|
||||
services.output.writeText(renderTable(["ID", "Title"], rows));
|
||||
}
|
||||
|
||||
export function presentTaskResult(services: RuntimeServices, result: unknown) {
|
||||
services.output.writeData(result);
|
||||
}
|
||||
21
src/modules/task/tasks/doctor-task.ts
Normal file
21
src/modules/task/tasks/doctor-task.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import process from "node:process";
|
||||
import type { PluginTask } from "../../../runtime/types.js";
|
||||
|
||||
export function createDoctorTask(): PluginTask<{
|
||||
nodeVersion: string;
|
||||
cwd: string;
|
||||
platform: string;
|
||||
}> {
|
||||
return {
|
||||
id: "doctor",
|
||||
title: "Environment Doctor",
|
||||
async run(services) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return {
|
||||
nodeVersion: process.version,
|
||||
cwd: services.cwd,
|
||||
platform: `${process.platform}/${process.arch}`
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
8
src/modules/task/tasks/registry.ts
Normal file
8
src/modules/task/tasks/registry.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { TaskDefinition } from "../../../runtime/types.js";
|
||||
import { createDoctorTask } from "./doctor-task.js";
|
||||
|
||||
export function listTaskDefinitions(): TaskDefinition[] {
|
||||
return [
|
||||
{ id: "doctor", create: createDoctorTask }
|
||||
];
|
||||
}
|
||||
5
src/modules/task/use-case.ts
Normal file
5
src/modules/task/use-case.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { RuntimeServices } from "../../runtime/types.js";
|
||||
|
||||
export function listTasks(services: RuntimeServices) {
|
||||
return services.taskRegistry.list().map((task) => [task.id, task.title]);
|
||||
}
|
||||
20
src/plugins/example-plugin.js
Normal file
20
src/plugins/example-plugin.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
name: "example-plugin",
|
||||
tasks: [
|
||||
{
|
||||
id: "hello-plugin",
|
||||
create() {
|
||||
return {
|
||||
id: "hello-plugin",
|
||||
title: "Hello From Plugin",
|
||||
async run() {
|
||||
return {
|
||||
plugin: "example-plugin",
|
||||
message: "Plugin task executed"
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
38
src/runtime/config-store.ts
Normal file
38
src/runtime/config-store.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { AppConfig, ConfigStore } from "./types.js";
|
||||
|
||||
const defaultConfig: AppConfig = {
|
||||
projectName: "cli-template-v1",
|
||||
persona: "safe",
|
||||
verbose: false
|
||||
};
|
||||
|
||||
export function createConfigStore(cwd: string): ConfigStore<AppConfig> {
|
||||
const filePath = path.join(cwd, "cli-template-v1.config.json");
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
async read() {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(content) as Partial<AppConfig>;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? String(error.code) : "";
|
||||
if (code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async write(value: AppConfig) {
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
},
|
||||
async patch(value: Partial<AppConfig>) {
|
||||
const current = await this.read();
|
||||
const next = { ...defaultConfig, ...current, ...value } satisfies AppConfig;
|
||||
await this.write(next);
|
||||
return next;
|
||||
}
|
||||
};
|
||||
}
|
||||
43
src/runtime/create-runtime-services.ts
Normal file
43
src/runtime/create-runtime-services.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import process from "node:process";
|
||||
import { listModuleDefinitions } from "../modules/registry.js";
|
||||
import { listTaskDefinitions } from "../modules/task/tasks/registry.js";
|
||||
import { createConfigStore } from "./config-store.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { loadPlugins } from "./plugin-loader.js";
|
||||
import { createOutputWriter } from "./output.js";
|
||||
import { InMemoryTaskRegistry } from "./task-registry.js";
|
||||
import { createTerminalUi } from "./terminal-ui.js";
|
||||
import type { RuntimeBootstrap } from "./types.js";
|
||||
|
||||
export async function createRuntimeBootstrap(): Promise<RuntimeBootstrap> {
|
||||
const logger = createLogger();
|
||||
const ui = createTerminalUi();
|
||||
const output = createOutputWriter(ui);
|
||||
const configStore = createConfigStore(process.cwd());
|
||||
const pluginLoadResult = await loadPlugins(process.cwd(), logger);
|
||||
const builtinTasks = listTaskDefinitions();
|
||||
const pluginTasks = pluginLoadResult.plugins.flatMap((plugin) => plugin.tasks ?? []);
|
||||
const taskRegistry = new InMemoryTaskRegistry([...builtinTasks, ...pluginTasks].map((item) => item.create()));
|
||||
const services = {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
logger,
|
||||
ui,
|
||||
output,
|
||||
configStore,
|
||||
taskRegistry,
|
||||
loadedPlugins: pluginLoadResult.plugins.map((plugin) => plugin.name)
|
||||
};
|
||||
|
||||
if (pluginLoadResult.failures.length > 0) {
|
||||
logger.debug(`plugin failures: ${String(pluginLoadResult.failures.length)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
services,
|
||||
moduleDefinitions: [
|
||||
...listModuleDefinitions(),
|
||||
...pluginLoadResult.plugins.flatMap((plugin) => plugin.modules ?? [])
|
||||
]
|
||||
};
|
||||
}
|
||||
52
src/runtime/errors.ts
Normal file
52
src/runtime/errors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export class AppError extends Error {
|
||||
readonly code: string;
|
||||
readonly exitCode: number;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(message: string, options?: { code?: string; exitCode?: number; details?: unknown }) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
this.code = options?.code ?? "APP_ERROR";
|
||||
this.exitCode = options?.exitCode ?? 1;
|
||||
this.details = options?.details;
|
||||
}
|
||||
}
|
||||
|
||||
export function toAppError(error: unknown): AppError {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new AppError(error.message, { details: { name: error.name } });
|
||||
}
|
||||
|
||||
return new AppError(String(error));
|
||||
}
|
||||
|
||||
export function writeCliError(error: unknown, argv: string[]) {
|
||||
const appError = toAppError(error);
|
||||
const jsonMode = argv.includes("--output") && argv.includes("json");
|
||||
|
||||
if (jsonMode) {
|
||||
console.error(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
code: appError.code,
|
||||
message: appError.message,
|
||||
details: appError.details ?? null
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(pc.red(`[${appError.code}] ${appError.message}`));
|
||||
}
|
||||
|
||||
return appError.exitCode;
|
||||
}
|
||||
26
src/runtime/logger.ts
Normal file
26
src/runtime/logger.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import pc from "picocolors";
|
||||
import type { Logger } from "./types.js";
|
||||
|
||||
export function createLogger(): Logger {
|
||||
let debugEnabled = false;
|
||||
|
||||
return {
|
||||
get debugEnabled() {
|
||||
return debugEnabled;
|
||||
},
|
||||
setDebug(enabled: boolean) {
|
||||
debugEnabled = enabled;
|
||||
},
|
||||
debug(message: string) {
|
||||
if (debugEnabled) {
|
||||
console.log(pc.gray(message));
|
||||
}
|
||||
},
|
||||
info(message: string) {
|
||||
console.log(message);
|
||||
},
|
||||
error(message: string) {
|
||||
console.error(pc.red(message));
|
||||
}
|
||||
};
|
||||
}
|
||||
32
src/runtime/output.ts
Normal file
32
src/runtime/output.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { OutputMode, OutputWriter, UiAdapter } from "./types.js";
|
||||
|
||||
export function createOutputWriter(ui: UiAdapter): OutputWriter {
|
||||
let mode: OutputMode = "text";
|
||||
|
||||
return {
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
setMode(nextMode: OutputMode) {
|
||||
mode = nextMode;
|
||||
},
|
||||
writeText(message = "") {
|
||||
if (mode === "text") {
|
||||
ui.writeLine(message);
|
||||
}
|
||||
},
|
||||
writeData(value: unknown) {
|
||||
if (mode === "json") {
|
||||
ui.writeLine(JSON.stringify(value, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
ui.writeLine(value);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.writeLine(JSON.stringify(value, null, 2));
|
||||
}
|
||||
};
|
||||
}
|
||||
43
src/runtime/plugin-loader.ts
Normal file
43
src/runtime/plugin-loader.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { CliPlugin, Logger, PluginLoadResult } from "./types.js";
|
||||
|
||||
export async function loadPlugins(cwd: string, logger: Logger): Promise<PluginLoadResult> {
|
||||
const pluginsDir = path.join(cwd, "plugins");
|
||||
const result: PluginLoadResult = { plugins: [], failures: [] };
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(pluginsDir, entry.name);
|
||||
try {
|
||||
const module = (await import(pathToFileURL(filePath).href)) as {
|
||||
default?: CliPlugin;
|
||||
plugin?: CliPlugin;
|
||||
};
|
||||
const plugin = module.default ?? module.plugin;
|
||||
if (!plugin || !plugin.name) {
|
||||
throw new Error("Plugin must export a default CliPlugin object.");
|
||||
}
|
||||
result.plugins.push(plugin);
|
||||
logger.debug(`loaded plugin: ${plugin.name}`);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
result.failures.push({ path: filePath, reason });
|
||||
logger.error(`plugin load failed: ${filePath} (${reason})`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? String(error.code) : "";
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
19
src/runtime/task-registry.test.ts
Normal file
19
src/runtime/task-registry.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { InMemoryTaskRegistry } from "./task-registry.js";
|
||||
|
||||
test("InMemoryTaskRegistry finds registered task", () => {
|
||||
const registry = new InMemoryTaskRegistry([
|
||||
{
|
||||
id: "doctor",
|
||||
title: "Doctor",
|
||||
async run() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(registry.list().length, 1);
|
||||
assert.equal(registry.find("doctor")?.title, "Doctor");
|
||||
assert.equal(registry.find("missing"), undefined);
|
||||
});
|
||||
17
src/runtime/task-registry.ts
Normal file
17
src/runtime/task-registry.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { PluginTask, TaskRegistry } from "./types.js";
|
||||
|
||||
export class InMemoryTaskRegistry implements TaskRegistry {
|
||||
readonly #tasks: Map<string, PluginTask>;
|
||||
|
||||
constructor(tasks: PluginTask[]) {
|
||||
this.#tasks = new Map(tasks.map((task) => [task.id, task]));
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.#tasks.values()];
|
||||
}
|
||||
|
||||
find(id: string) {
|
||||
return this.#tasks.get(id);
|
||||
}
|
||||
}
|
||||
23
src/runtime/task-runner.ts
Normal file
23
src/runtime/task-runner.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { theme } from "../ui/renderers/theme.js";
|
||||
import type { PluginTask, RuntimeServices } from "./types.js";
|
||||
|
||||
export async function runTask<Result>(task: PluginTask<Result>, services: RuntimeServices) {
|
||||
if (services.output.mode === "json") {
|
||||
return task.run(services);
|
||||
}
|
||||
|
||||
services.ui.writeLine(theme.brand(`== ${task.title} ==`));
|
||||
const spinner = services.ui.createSpinner("Running task");
|
||||
spinner.start();
|
||||
|
||||
try {
|
||||
const result = await task.run(services);
|
||||
spinner.stop(theme.success("Task finished"));
|
||||
services.ui.writeLine(theme.muted(`Task: ${task.id}`));
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
spinner.stop(theme.danger(`Task failed: ${message}`));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
93
src/runtime/terminal-ui.ts
Normal file
93
src/runtime/terminal-ui.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import readline from "node:readline/promises";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { theme } from "../ui/renderers/theme.js";
|
||||
import type { SelectOption, SpinnerController, UiAdapter } from "./types.js";
|
||||
|
||||
export function createTerminalUi(): UiAdapter {
|
||||
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
|
||||
return {
|
||||
isInteractive,
|
||||
writeLine(message = "") {
|
||||
console.log(message);
|
||||
},
|
||||
async promptText(message: string, placeholder?: string) {
|
||||
if (!isInteractive) {
|
||||
return placeholder ?? "";
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const suffix = placeholder ? theme.muted(` (${placeholder})`) : "";
|
||||
const answer = await rl.question(`${message}${suffix}: `);
|
||||
rl.close();
|
||||
return answer.trim() || placeholder || "";
|
||||
},
|
||||
async promptConfirm(message: string, initialValue = true) {
|
||||
if (!isInteractive) {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const hint = initialValue ? "Y/n" : "y/N";
|
||||
const answer = await rl.question(`${message} ${theme.muted(`[${hint}]`)}: `);
|
||||
rl.close();
|
||||
|
||||
if (!answer.trim()) {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
return ["y", "yes"].includes(answer.trim().toLowerCase());
|
||||
},
|
||||
async promptSelect(message: string, options: SelectOption[]) {
|
||||
if (!isInteractive) {
|
||||
return options[0]?.value ?? "";
|
||||
}
|
||||
|
||||
console.log(message);
|
||||
for (const [index, option] of options.entries()) {
|
||||
console.log(` ${theme.brand(String(index + 1))}. ${option.label}`);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input, output });
|
||||
const answer = await rl.question(`${theme.muted("Choose a number")}: `);
|
||||
rl.close();
|
||||
|
||||
const index = Number(answer.trim()) - 1;
|
||||
return options[index]?.value ?? options[0]?.value ?? "";
|
||||
},
|
||||
createSpinner(label: string): SpinnerController {
|
||||
const frames = ["-", "\\", "|", "/"];
|
||||
let index = 0;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (!process.stdout.isTTY) {
|
||||
console.log(`${theme.brand("...")} ${label}`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write("\u001B[?25l");
|
||||
timer = setInterval(() => {
|
||||
const frame = frames[index % frames.length] ?? "-";
|
||||
index += 1;
|
||||
process.stdout.write(`\r${theme.brand(frame)} ${label}`);
|
||||
}, 80);
|
||||
},
|
||||
stop(message: string) {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write(`\r${message}\n`);
|
||||
process.stdout.write("\u001B[?25h");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
102
src/runtime/types.ts
Normal file
102
src/runtime/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
export type OutputMode = "text" | "json";
|
||||
|
||||
export interface Logger {
|
||||
debugEnabled: boolean;
|
||||
setDebug: (enabled: boolean) => void;
|
||||
debug: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SpinnerController {
|
||||
start: () => void;
|
||||
stop: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface UiAdapter {
|
||||
isInteractive: boolean;
|
||||
writeLine: (message?: string) => void;
|
||||
promptText: (message: string, placeholder?: string) => Promise<string>;
|
||||
promptConfirm: (message: string, initialValue?: boolean) => Promise<boolean>;
|
||||
promptSelect: (message: string, options: SelectOption[]) => Promise<string>;
|
||||
createSpinner: (label: string) => SpinnerController;
|
||||
}
|
||||
|
||||
export interface OutputWriter {
|
||||
mode: OutputMode;
|
||||
setMode: (mode: OutputMode) => void;
|
||||
writeText: (message?: string) => void;
|
||||
writeData: (value: unknown) => void;
|
||||
}
|
||||
|
||||
export interface ConfigStore<TConfig extends object = Record<string, unknown>> {
|
||||
path: string;
|
||||
read: () => Promise<Partial<TConfig>>;
|
||||
write: (value: TConfig) => Promise<void>;
|
||||
patch: (value: Partial<TConfig>) => Promise<TConfig>;
|
||||
}
|
||||
|
||||
export interface PluginTask<Result = unknown> {
|
||||
id: string;
|
||||
title: string;
|
||||
run: (services: RuntimeServices) => Promise<Result>;
|
||||
}
|
||||
|
||||
export interface TaskRegistry {
|
||||
list: () => PluginTask[];
|
||||
find: (id: string) => PluginTask | undefined;
|
||||
}
|
||||
|
||||
export interface RuntimeServices {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: Logger;
|
||||
ui: UiAdapter;
|
||||
output: OutputWriter;
|
||||
configStore: ConfigStore<AppConfig>;
|
||||
taskRegistry: TaskRegistry;
|
||||
loadedPlugins: string[];
|
||||
}
|
||||
|
||||
export interface CommandModule {
|
||||
register: (program: Command, services: RuntimeServices) => void;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
projectName: string;
|
||||
persona: string;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export interface ModuleDefinition {
|
||||
id: string;
|
||||
create: () => CommandModule;
|
||||
}
|
||||
|
||||
export interface TaskDefinition {
|
||||
id: string;
|
||||
create: () => PluginTask;
|
||||
}
|
||||
|
||||
export interface CliPlugin {
|
||||
name: string;
|
||||
modules?: ModuleDefinition[];
|
||||
tasks?: TaskDefinition[];
|
||||
}
|
||||
|
||||
export interface PluginLoadResult {
|
||||
plugins: CliPlugin[];
|
||||
failures: Array<{ path: string; reason: string }>;
|
||||
}
|
||||
|
||||
export interface RuntimeBootstrap {
|
||||
services: RuntimeServices;
|
||||
moduleDefinitions: ModuleDefinition[];
|
||||
}
|
||||
2
src/test/all.test.ts
Normal file
2
src/test/all.test.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "../modules/config/use-case.test.js";
|
||||
import "../runtime/task-registry.test.js";
|
||||
12
src/ui/renderers/banner.ts
Normal file
12
src/ui/renderers/banner.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export function renderBanner() {
|
||||
return pc.cyan(`
|
||||
██████╗██╗ ██╗ ███████╗████████╗ █████╗ ██████╗ ████████╗███████╗██████╗
|
||||
██╔════╝██║ ██║ ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝██╔══██╗
|
||||
██║ ██║ ██║ ███████╗ ██║ ███████║██████╔╝ ██║ █████╗ ██████╔╝
|
||||
██║ ██║ ██║ ╚════██║ ██║ ██╔══██║██╔══██╗ ██║ ██╔══╝ ██╔══██╗
|
||||
╚██████╗███████╗██║ ███████║ ██║ ██║ ██║██║ ██║ ██║ ███████╗██║ ██║
|
||||
╚═════╝╚══════╝╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
|
||||
`);
|
||||
}
|
||||
26
src/ui/renderers/panel.ts
Normal file
26
src/ui/renderers/panel.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { theme } from "./theme.js";
|
||||
|
||||
export interface PanelItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function renderPanel(title: string, items: PanelItem[]) {
|
||||
const lines = items.map((item) => `${theme.muted(item.label.padEnd(12))} ${item.value}`);
|
||||
const contentWidth = Math.max(title.length, ...lines.map((line) => stripAnsi(line).length));
|
||||
const width = contentWidth + 4;
|
||||
const top = `┌${"─".repeat(width)}┐`;
|
||||
const bottom = `└${"─".repeat(width)}┘`;
|
||||
const head = `│ ${theme.brand(title.padEnd(width - 2))} │`;
|
||||
const body = lines.map((line) => `│ ${padAnsi(line, width - 2)} │`);
|
||||
return [top, head, `├${"─".repeat(width)}┤`, ...body, bottom].join("\n");
|
||||
}
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/\x1B\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function padAnsi(value: string, width: number) {
|
||||
const visible = stripAnsi(value).length;
|
||||
return `${value}${" ".repeat(Math.max(0, width - visible))}`;
|
||||
}
|
||||
16
src/ui/renderers/table.ts
Normal file
16
src/ui/renderers/table.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { theme } from "./theme.js";
|
||||
|
||||
export function renderTable(headers: string[], rows: string[][]) {
|
||||
const widths = headers.map((header, index) =>
|
||||
Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))
|
||||
);
|
||||
|
||||
const renderRow = (cells: string[]) =>
|
||||
cells.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ");
|
||||
|
||||
const head = renderRow(headers.map((header) => theme.brand(header)));
|
||||
const divider = widths.map((width) => "-".repeat(width)).join(" ");
|
||||
const body = rows.map(renderRow).join("\n");
|
||||
|
||||
return `${head}\n${theme.muted(divider)}\n${body}`;
|
||||
}
|
||||
10
src/ui/renderers/theme.ts
Normal file
10
src/ui/renderers/theme.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export const theme = {
|
||||
brand: (value: string) => pc.cyan(value),
|
||||
muted: (value: string) => pc.gray(value),
|
||||
success: (value: string) => pc.green(value),
|
||||
warning: (value: string) => pc.yellow(value),
|
||||
danger: (value: string) => pc.red(value),
|
||||
accent: (value: string) => pc.magentaBright(value)
|
||||
};
|
||||
6
tsconfig.build.json
Normal file
6
tsconfig.build.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user