From 1a7c8003916e773baa81f73c2c59a7c3ed463eb2 Mon Sep 17 00:00:00 2001 From: shenjianZ Date: Sat, 14 Mar 2026 17:35:41 +0800 Subject: [PATCH] =?UTF-8?q?=20=20feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20Typ?= =?UTF-8?q?eScript=20CLI=20=E6=A8=A1=E6=9D=BF=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 建立完整的 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 文档,包含快速开始、开发约定和演进方向 --- .gitignore | 4 + .npmignore | 8 + LICENSE | 21 ++ README.md | 180 +++++++++++++ cli-starter.config.json | 5 + package.json | 58 ++++ plugins/example-plugin.js | 20 ++ pnpm-lock.yaml | 360 +++++++++++++++++++++++++ src/bootstrap/create-app.ts | 40 +++ src/index.ts | 13 + src/modules/config/command.ts | 47 ++++ src/modules/config/presenter.ts | 34 +++ src/modules/config/use-case.test.ts | 28 ++ src/modules/config/use-case.ts | 44 +++ src/modules/demo/command.ts | 27 ++ src/modules/demo/presenter.ts | 29 ++ src/modules/demo/use-case.ts | 30 +++ src/modules/registry.ts | 12 + src/modules/task/command.ts | 39 +++ src/modules/task/presenter.ts | 15 ++ src/modules/task/tasks/doctor-task.ts | 21 ++ src/modules/task/tasks/registry.ts | 8 + src/modules/task/use-case.ts | 5 + src/plugins/example-plugin.js | 20 ++ src/runtime/config-store.ts | 38 +++ src/runtime/create-runtime-services.ts | 43 +++ src/runtime/errors.ts | 52 ++++ src/runtime/logger.ts | 26 ++ src/runtime/output.ts | 32 +++ src/runtime/plugin-loader.ts | 43 +++ src/runtime/task-registry.test.ts | 19 ++ src/runtime/task-registry.ts | 17 ++ src/runtime/task-runner.ts | 23 ++ src/runtime/terminal-ui.ts | 93 +++++++ src/runtime/types.ts | 102 +++++++ src/test/all.test.ts | 2 + src/ui/renderers/banner.ts | 12 + src/ui/renderers/panel.ts | 26 ++ src/ui/renderers/table.ts | 16 ++ src/ui/renderers/theme.ts | 10 + tsconfig.build.json | 6 + tsconfig.json | 19 ++ 42 files changed, 1647 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cli-starter.config.json create mode 100644 package.json create mode 100644 plugins/example-plugin.js create mode 100644 pnpm-lock.yaml create mode 100644 src/bootstrap/create-app.ts create mode 100644 src/index.ts create mode 100644 src/modules/config/command.ts create mode 100644 src/modules/config/presenter.ts create mode 100644 src/modules/config/use-case.test.ts create mode 100644 src/modules/config/use-case.ts create mode 100644 src/modules/demo/command.ts create mode 100644 src/modules/demo/presenter.ts create mode 100644 src/modules/demo/use-case.ts create mode 100644 src/modules/registry.ts create mode 100644 src/modules/task/command.ts create mode 100644 src/modules/task/presenter.ts create mode 100644 src/modules/task/tasks/doctor-task.ts create mode 100644 src/modules/task/tasks/registry.ts create mode 100644 src/modules/task/use-case.ts create mode 100644 src/plugins/example-plugin.js create mode 100644 src/runtime/config-store.ts create mode 100644 src/runtime/create-runtime-services.ts create mode 100644 src/runtime/errors.ts create mode 100644 src/runtime/logger.ts create mode 100644 src/runtime/output.ts create mode 100644 src/runtime/plugin-loader.ts create mode 100644 src/runtime/task-registry.test.ts create mode 100644 src/runtime/task-registry.ts create mode 100644 src/runtime/task-runner.ts create mode 100644 src/runtime/terminal-ui.ts create mode 100644 src/runtime/types.ts create mode 100644 src/test/all.test.ts create mode 100644 src/ui/renderers/banner.ts create mode 100644 src/ui/renderers/panel.ts create mode 100644 src/ui/renderers/table.ts create mode 100644 src/ui/renderers/theme.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cc8840 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.DS_Store +.npm-pack-cache diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..6a72b77 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +node_modules +src +plugins +.git +.gitignore +pnpm-lock.yaml +tsconfig.json +cli-starter.config.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b05f49 --- /dev/null +++ b/README.md @@ -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//` 下新增模块,至少拆成: + +- `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 与端到端命令测试 +- 发布流程和单文件打包 diff --git a/cli-starter.config.json b/cli-starter.config.json new file mode 100644 index 0000000..04b827a --- /dev/null +++ b/cli-starter.config.json @@ -0,0 +1,5 @@ +{ + "projectName": "my-cli-tool", + "persona": "safe", + "verbose": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..04c811d --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/plugins/example-plugin.js b/plugins/example-plugin.js new file mode 100644 index 0000000..d2c0704 --- /dev/null +++ b/plugins/example-plugin.js @@ -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" + }; + } + }; + } + } + ] +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..0c3e6bf --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/bootstrap/create-app.ts b/src/bootstrap/create-app.ts new file mode 100644 index 0000000..fe0bfd9 --- /dev/null +++ b/src/bootstrap/create-app.ts @@ -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 ", "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); + } + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f2ece5e --- /dev/null +++ b/src/index.ts @@ -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); +}); diff --git a/src/modules/config/command.ts b/src/modules/config/command.ts new file mode 100644 index 0000000..e884264 --- /dev/null +++ b/src/modules/config/command.ts @@ -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); + }); + } + }; +} diff --git a/src/modules/config/presenter.ts b/src/modules/config/presenter.ts new file mode 100644 index 0000000..035893e --- /dev/null +++ b/src/modules/config/presenter.ts @@ -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); +} diff --git a/src/modules/config/use-case.test.ts b/src/modules/config/use-case.test.ts new file mode 100644 index 0000000..bafd4b5 --- /dev/null +++ b/src/modules/config/use-case.test.ts @@ -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/ + ); +}); diff --git a/src/modules/config/use-case.ts b/src/modules/config/use-case.ts new file mode 100644 index 0000000..565618e --- /dev/null +++ b/src/modules/config/use-case.ts @@ -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 { + return services.configStore.patch({ + projectName: draft.projectName, + persona: draft.persona, + verbose: draft.verbose + }); +} + +export async function loadConfig(services: RuntimeServices): Promise & { path: string }> { + const config = await services.configStore.read(); + return { + path: services.configStore.path, + ...config + }; +} diff --git a/src/modules/demo/command.ts b/src/modules/demo/command.ts new file mode 100644 index 0000000..1218383 --- /dev/null +++ b/src/modules/demo/command.ts @@ -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)); + }); + } + }; +} diff --git a/src/modules/demo/presenter.ts b/src/modules/demo/presenter.ts new file mode 100644 index 0000000..b73e2fb --- /dev/null +++ b/src/modules/demo/presenter.ts @@ -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)}`)}` + ); +} diff --git a/src/modules/demo/use-case.ts b/src/modules/demo/use-case.ts new file mode 100644 index 0000000..d06851e --- /dev/null +++ b/src/modules/demo/use-case.ts @@ -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 }; +} diff --git a/src/modules/registry.ts b/src/modules/registry.ts new file mode 100644 index 0000000..b8fd4e0 --- /dev/null +++ b/src/modules/registry.ts @@ -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 } + ]; +} diff --git a/src/modules/task/command.ts b/src/modules/task/command.ts new file mode 100644 index 0000000..c3187dd --- /dev/null +++ b/src/modules/task/command.ts @@ -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 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); + }); + } + }; +} diff --git a/src/modules/task/presenter.ts b/src/modules/task/presenter.ts new file mode 100644 index 0000000..e58cc8f --- /dev/null +++ b/src/modules/task/presenter.ts @@ -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); +} diff --git a/src/modules/task/tasks/doctor-task.ts b/src/modules/task/tasks/doctor-task.ts new file mode 100644 index 0000000..5d2b0e5 --- /dev/null +++ b/src/modules/task/tasks/doctor-task.ts @@ -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}` + }; + } + }; +} diff --git a/src/modules/task/tasks/registry.ts b/src/modules/task/tasks/registry.ts new file mode 100644 index 0000000..9de7703 --- /dev/null +++ b/src/modules/task/tasks/registry.ts @@ -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 } + ]; +} diff --git a/src/modules/task/use-case.ts b/src/modules/task/use-case.ts new file mode 100644 index 0000000..af20840 --- /dev/null +++ b/src/modules/task/use-case.ts @@ -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]); +} diff --git a/src/plugins/example-plugin.js b/src/plugins/example-plugin.js new file mode 100644 index 0000000..d2c0704 --- /dev/null +++ b/src/plugins/example-plugin.js @@ -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" + }; + } + }; + } + } + ] +}; diff --git a/src/runtime/config-store.ts b/src/runtime/config-store.ts new file mode 100644 index 0000000..65368a5 --- /dev/null +++ b/src/runtime/config-store.ts @@ -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 { + 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; + } 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) { + const current = await this.read(); + const next = { ...defaultConfig, ...current, ...value } satisfies AppConfig; + await this.write(next); + return next; + } + }; +} diff --git a/src/runtime/create-runtime-services.ts b/src/runtime/create-runtime-services.ts new file mode 100644 index 0000000..0324fa6 --- /dev/null +++ b/src/runtime/create-runtime-services.ts @@ -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 { + 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 ?? []) + ] + }; +} diff --git a/src/runtime/errors.ts b/src/runtime/errors.ts new file mode 100644 index 0000000..c8fee33 --- /dev/null +++ b/src/runtime/errors.ts @@ -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; +} diff --git a/src/runtime/logger.ts b/src/runtime/logger.ts new file mode 100644 index 0000000..acbd7d9 --- /dev/null +++ b/src/runtime/logger.ts @@ -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)); + } + }; +} diff --git a/src/runtime/output.ts b/src/runtime/output.ts new file mode 100644 index 0000000..09104c4 --- /dev/null +++ b/src/runtime/output.ts @@ -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)); + } + }; +} diff --git a/src/runtime/plugin-loader.ts b/src/runtime/plugin-loader.ts new file mode 100644 index 0000000..590962c --- /dev/null +++ b/src/runtime/plugin-loader.ts @@ -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 { + 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; +} diff --git a/src/runtime/task-registry.test.ts b/src/runtime/task-registry.test.ts new file mode 100644 index 0000000..eecd5de --- /dev/null +++ b/src/runtime/task-registry.test.ts @@ -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); +}); diff --git a/src/runtime/task-registry.ts b/src/runtime/task-registry.ts new file mode 100644 index 0000000..83b7301 --- /dev/null +++ b/src/runtime/task-registry.ts @@ -0,0 +1,17 @@ +import type { PluginTask, TaskRegistry } from "./types.js"; + +export class InMemoryTaskRegistry implements TaskRegistry { + readonly #tasks: Map; + + 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); + } +} diff --git a/src/runtime/task-runner.ts b/src/runtime/task-runner.ts new file mode 100644 index 0000000..4d92f0c --- /dev/null +++ b/src/runtime/task-runner.ts @@ -0,0 +1,23 @@ +import { theme } from "../ui/renderers/theme.js"; +import type { PluginTask, RuntimeServices } from "./types.js"; + +export async function runTask(task: PluginTask, 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; + } +} diff --git a/src/runtime/terminal-ui.ts b/src/runtime/terminal-ui.ts new file mode 100644 index 0000000..f800221 --- /dev/null +++ b/src/runtime/terminal-ui.ts @@ -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); + } + }; + } + }; +} diff --git a/src/runtime/types.ts b/src/runtime/types.ts new file mode 100644 index 0000000..20549e2 --- /dev/null +++ b/src/runtime/types.ts @@ -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; + promptConfirm: (message: string, initialValue?: boolean) => Promise; + promptSelect: (message: string, options: SelectOption[]) => Promise; + createSpinner: (label: string) => SpinnerController; +} + +export interface OutputWriter { + mode: OutputMode; + setMode: (mode: OutputMode) => void; + writeText: (message?: string) => void; + writeData: (value: unknown) => void; +} + +export interface ConfigStore> { + path: string; + read: () => Promise>; + write: (value: TConfig) => Promise; + patch: (value: Partial) => Promise; +} + +export interface PluginTask { + id: string; + title: string; + run: (services: RuntimeServices) => Promise; +} + +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; + 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[]; +} diff --git a/src/test/all.test.ts b/src/test/all.test.ts new file mode 100644 index 0000000..fb5b063 --- /dev/null +++ b/src/test/all.test.ts @@ -0,0 +1,2 @@ +import "../modules/config/use-case.test.js"; +import "../runtime/task-registry.test.js"; diff --git a/src/ui/renderers/banner.ts b/src/ui/renderers/banner.ts new file mode 100644 index 0000000..da1cc20 --- /dev/null +++ b/src/ui/renderers/banner.ts @@ -0,0 +1,12 @@ +import pc from "picocolors"; + +export function renderBanner() { + return pc.cyan(` + ██████╗██╗ ██╗ ███████╗████████╗ █████╗ ██████╗ ████████╗███████╗██████╗ +██╔════╝██║ ██║ ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗╚══██╔══╝██╔════╝██╔══██╗ +██║ ██║ ██║ ███████╗ ██║ ███████║██████╔╝ ██║ █████╗ ██████╔╝ +██║ ██║ ██║ ╚════██║ ██║ ██╔══██║██╔══██╗ ██║ ██╔══╝ ██╔══██╗ +╚██████╗███████╗██║ ███████║ ██║ ██║ ██║██║ ██║ ██║ ███████╗██║ ██║ + ╚═════╝╚══════╝╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ +`); +} diff --git a/src/ui/renderers/panel.ts b/src/ui/renderers/panel.ts new file mode 100644 index 0000000..380b4d0 --- /dev/null +++ b/src/ui/renderers/panel.ts @@ -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))}`; +} diff --git a/src/ui/renderers/table.ts b/src/ui/renderers/table.ts new file mode 100644 index 0000000..acfa244 --- /dev/null +++ b/src/ui/renderers/table.ts @@ -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}`; +} diff --git a/src/ui/renderers/theme.ts b/src/ui/renderers/theme.ts new file mode 100644 index 0000000..09f0eb7 --- /dev/null +++ b/src/ui/renderers/theme.ts @@ -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) +}; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..c2f08f7 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/*.test.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2a2132 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +}