diff --git a/.agents/rules/cli-commands.mdc b/.agents/rules/cli-commands.mdc index b60d46f..53c80b4 100644 --- a/.agents/rules/cli-commands.mdc +++ b/.agents/rules/cli-commands.mdc @@ -10,41 +10,54 @@ description: CLI command patterns and development The main CLI is defined in [bin/koapp.js](mdc:bin/koapp.js) and provides: - `koapp init ` - Initialize new project -- `koapp gen` - Generate code/components -- `koapp http` - HTTP-related utilities +- `koapp http [dir]` - Serve a static directory over HTTP +- `koapp skills -i ` - Install the bundled AI skills into a project ## Command Structure -Commands are implemented in the `commands/` directory: +Commands are implemented in the `commands/` directory and auto-loaded by +`@axiosleo/cli-tool` through the `commands_dir` option: - [commands/init.js](mdc:commands/init.js) - Project initialization -- [commands/gen.js](mdc:commands/gen.js) - Code generation -- [commands/http.js](mdc:commands/http.js) - HTTP utilities +- [commands/http.js](mdc:commands/http.js) - Static HTTP server +- [commands/skills.js](mdc:commands/skills.js) - AI skills installer + +Shared, testable helpers live in `src/cli/` (e.g. [src/cli/pnpm.js](mdc:src/cli/pnpm.js) +for Corepack / pnpm detection, [src/cli/pkg.js](mdc:src/cli/pkg.js) for +package-manager detection). Keep command files thin and put logic there. ## Project Initialization -The init command creates new projects using templates from `assets/tmpl/`: +The init command asks which template to generate (or takes `-t`): ```bash -npx @axiosleo/koapp init -d +npx @axiosleo/koapp init [-d ] [-t simple-ts|simple-js|monorepo] ``` -This generates: +| Template | Source | Result | +| --- | --- | --- | +| `simple-ts` (default) | `assets/simple/ts/` | One TypeScript HTTP service and sample routes. No database. | +| `simple-js` | `assets/simple/js/` | The same service in CommonJS. No compile step. | +| `monorepo` | `assets/monorepo/` | pnpm workspace: `apps/services` (admin Session auth, `/openapi` Bearer Api-Key, SQLite), `packages/commands`, `bin/.js` | + +Every template also receives `.agents/skills/` copied from `assets/skills/`. +After writing files, init probes for pnpm (offering to install the pinned +version) and then offers to run `pnpm install` and `pnpm dev`. -- TypeScript service structure -- Database configuration -- Docker compose setup -- Package.json with dependencies -- Basic application structure +Template selection and file rendering live in [src/cli/scaffold.js](mdc:src/cli/scaffold.js) +so they can be tested without a prompt. ## Template System -Templates use `.tmpl` extension and are processed during project generation: +Templates use the `.tmpl` extension and are processed during project generation: -- `assets/tmpl/services/` - Service templates -- `assets/tmpl/database/` - Database setup -- `assets/tmpl/docker-compose.yml.tmpl` - Docker configuration -- `assets/tmpl/package.json.tmpl` - Package configuration +- `assets/simple/{ts,js}/**/*.tmpl` and `assets/monorepo/**/*.tmpl` - rendered + with `_render_with_file`, which replaces `${name}` with the app name; the + `.tmpl` suffix is stripped on output +- `assets/monorepo/bin/monoapp.js.tmpl` - renamed to `bin/.js` + (monorepo template only) +- `assets/skills/` - copied verbatim into `/.agents/skills/` + (`_sync` reback must stay off, or the copy writes back into this package) ## Command Development @@ -53,15 +66,20 @@ When creating new CLI commands: 1. Create command file in `commands/` directory 2. Extend base Command class from `@axiosleo/cli-tool` 3. Implement `exec(args, options)` method -4. Register command in main CLI file +4. No manual registration is needed - every file under `commands/` is picked up + via `commands_dir` in `bin/koapp.js` ## Usage Examples ```bash -# Initialize new project +# Initialize new project (prompts for a template; default is simple-ts) npx @axiosleo/koapp init my-app -# Initialize in specific directory +# Skip the prompt +npx @axiosleo/koapp init my-app -t simple-js +npx @axiosleo/koapp init my-app -t monorepo + +# Initialize in a specific directory npx @axiosleo/koapp init my-app -d ./projects # Show help diff --git a/.agents/rules/development-workflow.mdc b/.agents/rules/development-workflow.mdc index f642c14..990409d 100644 --- a/.agents/rules/development-workflow.mdc +++ b/.agents/rules/development-workflow.mdc @@ -38,9 +38,10 @@ Available in [package.json](mdc:package.json): - Use clear, descriptive file names - Follow existing patterns for consistency -### Templates (`assets/tmpl/`) +### Templates (`assets/monorepo/`, `assets/skills/`) -- Template files use `.tmpl` extension +- Template files use `.tmpl` extension; `${name}` is replaced with the app name during `koapp init` +- `assets/skills/` is copied verbatim into generated projects (`.agents/skills/`) - Maintain template structure for project scaffolding - Update templates when adding new features diff --git a/.agents/rules/socket-application.mdc b/.agents/rules/socket-application.mdc index 584fcff..776ede8 100644 --- a/.agents/rules/socket-application.mdc +++ b/.agents/rules/socket-application.mdc @@ -27,7 +27,7 @@ const router = new Router("/", { /* ... */ }); const app = new SocketApplication({ routers: [router], - port: 8081, + port: 8801, ping: { open: true, interval: 1000 * 10, @@ -55,7 +55,7 @@ const router = new Router("/", { /* ... */ }); const app = new WebSocketApplication({ routers: [router], - port: 8081, + port: 8801, ping: { open: false, interval: 1000 * 3, diff --git a/README.md b/README.md index 3079751..a3e36de 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,16 @@ npm install @axiosleo/koapp ## Initialization +`koapp init` asks which project to generate. Pressing enter selects the simple TypeScript service. Pass `-t` to skip the prompt. + +| Template | Flag | What you get | +| --- | --- | --- | +| Simple TypeScript app (default) | `-t simple-ts` | One koapp HTTP service and sample routes. No database. | +| Simple JavaScript app | `-t simple-js` | The same service in CommonJS, with no compile step. | +| Full monorepo | `-t monorepo` | pnpm workspace: admin Session API, Api-Keys, and SQLite. | + ```bash -npx @axiosleo/koapp init -d +npx @axiosleo/koapp init [-d ] [-t simple-ts|simple-js|monorepo] # Show help information # npx @axiosleo/koapp init -h diff --git a/assets/monorepo/README.en.md.tmpl b/assets/monorepo/README.en.md.tmpl index e526885..2dcacab 100644 --- a/assets/monorepo/README.en.md.tmpl +++ b/assets/monorepo/README.en.md.tmpl @@ -28,14 +28,14 @@ pnpm install cp .env.example .env # Edit .env and set at least ADMIN_PASSWORD -# Start the API in dev mode (default 0.0.0.0:8082) +# Start the API in dev mode (default 0.0.0.0:8801) pnpm --filter ${name}-services run dev ``` Verify the service: ```bash -curl http://127.0.0.1:8082/api/health +curl http://127.0.0.1:8801/api/health ``` ## Directory layout @@ -159,7 +159,7 @@ Copy [`.env.example`](.env.example) to the monorepo root `.env` and adjust as ne | Variable | Default | Description | | --- | --- | --- | -| `API_PORT` | `8082` | HTTP listen port | +| `API_PORT` | `8801` | HTTP listen port | | `APP_SECRET` | `${name}-dev-secret-change-me` | Application secret (config) | | `SQLITE_PATH` | `./data/${name}.db` | Metadata DB path (relative to monorepo root, or the binary directory) | | `ADMIN_USERNAME` | `admin` | Admin username | diff --git a/assets/monorepo/README.md.tmpl b/assets/monorepo/README.md.tmpl index 6f9fba3..eae6d34 100644 --- a/assets/monorepo/README.md.tmpl +++ b/assets/monorepo/README.md.tmpl @@ -28,14 +28,14 @@ pnpm install cp .env.example .env # 编辑 .env,至少设置 ADMIN_PASSWORD -# 启动开发服务(默认 0.0.0.0:8082) +# 启动开发服务(默认 0.0.0.0:8801) pnpm --filter ${name}-services run dev ``` 验证服务: ```bash -curl http://127.0.0.1:8082/api/health +curl http://127.0.0.1:8801/api/health ``` ## 目录结构 @@ -159,7 +159,7 @@ CLI 会读取仓库根目录 `.env`(如 `SQLITE_PATH`),与 API 进程共 | 变量 | 默认值 | 说明 | | --- | --- | --- | -| `API_PORT` | `8082` | HTTP 监听端口 | +| `API_PORT` | `8801` | HTTP 监听端口 | | `APP_SECRET` | `${name}-dev-secret-change-me` | 应用密钥(配置项) | | `SQLITE_PATH` | `./data/${name}.db` | 元数据库路径(相对路径锚定到仓库根,或二进制同目录) | | `ADMIN_USERNAME` | `admin` | 管理员用户名 | diff --git a/assets/simple/js/.env.example.tmpl b/assets/simple/js/.env.example.tmpl new file mode 100644 index 0000000..b8503da --- /dev/null +++ b/assets/simple/js/.env.example.tmpl @@ -0,0 +1,5 @@ +# HTTP listen port +# API_PORT=8801 + +# prod enables production mode (debug off). Anything else is local/dev. +# DEPLOY_ENV=local diff --git a/assets/simple/js/.gitignore.tmpl b/assets/simple/js/.gitignore.tmpl new file mode 100644 index 0000000..0900635 --- /dev/null +++ b/assets/simple/js/.gitignore.tmpl @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +coverage/ diff --git a/assets/simple/js/.npmrc.tmpl b/assets/simple/js/.npmrc.tmpl new file mode 100644 index 0000000..c6ac720 --- /dev/null +++ b/assets/simple/js/.npmrc.tmpl @@ -0,0 +1,3 @@ +registry=https://registry.npmmirror.com +shamefully-hoist=false +strict-peer-dependencies=false diff --git a/assets/simple/js/.nvmrc.tmpl b/assets/simple/js/.nvmrc.tmpl new file mode 100644 index 0000000..b404027 --- /dev/null +++ b/assets/simple/js/.nvmrc.tmpl @@ -0,0 +1 @@ +24.8.0 diff --git a/assets/simple/js/AGENTS.md.tmpl b/assets/simple/js/AGENTS.md.tmpl new file mode 100644 index 0000000..2dd6d86 --- /dev/null +++ b/assets/simple/js/AGENTS.md.tmpl @@ -0,0 +1,9 @@ +# ${name} + +简易 koapp HTTP 服务(JavaScript / CommonJS,pnpm,Node >= 20)。无数据库,无需编译。 + +- 入口:`src/bootstrap.js`,应用类 `src/app.js`,配置 `src/config.js` +- 路由:`src/routes/index.js` 挂载 `src/routes/example.router.js` +- 默认端口 `8801`(`API_PORT`) +- `pnpm dev` 或 `pnpm start` +- koapp API 见 `.agents/skills/koapp*` diff --git a/assets/simple/js/README.md.tmpl b/assets/simple/js/README.md.tmpl new file mode 100644 index 0000000..ff6251e --- /dev/null +++ b/assets/simple/js/README.md.tmpl @@ -0,0 +1,62 @@ +# ${name} + +基于 [@axiosleo/koapp](https://github.com/AxiosLeo/node-koapp) 的简易 HTTP 接口服务(JavaScript / CommonJS)。只包含示例路由,不包含数据库,无需编译。 + +## 环境要求 + +- Node.js `>= 20` +- pnpm `>= 10` + +## 快速开始 + +```bash +pnpm install +cp .env.example .env # 可选 +pnpm dev # http://127.0.0.1:8801 +``` + +直接启动: + +```bash +pnpm start +``` + +## 目录 + +``` +src/ + bootstrap.js # 进程入口 + app.js # KoaApplication + config.js # 读取 .env + routes/index.js # 根路由:请求日志、404 兜底 + routes/example.router.js +``` + +## 示例路由 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/` | `success('hello, world!')` | +| `GET` | `/health` | `{ status: 'ok' }` | +| `GET`/`POST` | `/multi` | 多方法,回显 method | +| `GET` | `/a/{:a}/b/{:b}` | 路径参数 | +| `ANY` | `/any` | 任意方法,回显 params / query / body | +| `ANY` | `/error` | 抛错,框架转为 500 | +| `ANY` | `/failed` | `failed()`,HTTP 403 | +| `ANY` | `/result` | `result()` 自定义 header | +| `GET` | `/result/obj` | `result()` 返回对象 | +| `ANY` | `/success` | `success()` | +| `ANY` | `/html` | `text/html` | +| `ANY` | `/model` | `Model` 序列化 | +| `ANY` | `/model/error` | `Model` 校验失败 | +| `ANY` | `/validate/{:param1}/{:param2}` | params / query / body 校验 | +| `ANY` | `/sse` | Server-Sent Events | + +未匹配的路径返回 404。 + +## 环境变量 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `API_PORT` | `8801` | 监听端口 | +| `DEPLOY_ENV` | `local` | `prod` 时关闭 debug | diff --git a/assets/simple/js/eslint.config.mjs.tmpl b/assets/simple/js/eslint.config.mjs.tmpl new file mode 100644 index 0000000..f1da040 --- /dev/null +++ b/assets/simple/js/eslint.config.mjs.tmpl @@ -0,0 +1,47 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +export default [ + js.configs.recommended, + { + languageOptions: { + globals: { + ...globals.node + }, + ecmaVersion: 2020, + sourceType: 'commonjs' + }, + rules: { + indent: [2, 2, { SwitchCase: 1 }], + quotes: [2, 'single'], + 'linebreak-style': [2, 'unix'], + semi: [2, 'always'], + curly: 2, + eqeqeq: 2, + 'no-eval': 2, + 'guard-for-in': 2, + 'no-caller': 2, + 'no-else-return': 2, + 'no-eq-null': 2, + 'no-extend-native': 2, + 'no-extra-bind': 2, + 'no-floating-decimal': 2, + 'no-implied-eval': 2, + 'no-labels': 2, + 'no-with': 2, + 'no-loop-func': 2, + 'no-redeclare': [2, { builtinGlobals: true }], + 'no-delete-var': 2, + 'no-shadow-restricted-names': 2, + 'no-undef-init': 2, + 'no-use-before-define': 2, + 'no-unused-vars': [2, { args: 'none' }], + 'no-undefined': 2, + 'no-undef': 2, + 'global-require': 0, + 'no-console': 2, + 'key-spacing': [2, { beforeColon: false, afterColon: true }], + 'eol-last': [2, 'always'] + } + } +]; diff --git a/assets/simple/js/nodemon.json.tmpl b/assets/simple/js/nodemon.json.tmpl new file mode 100644 index 0000000..7d15dbe --- /dev/null +++ b/assets/simple/js/nodemon.json.tmpl @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "exec": "node src/bootstrap.js", + "ext": "js" +} diff --git a/assets/simple/js/package.json.tmpl b/assets/simple/js/package.json.tmpl new file mode 100644 index 0000000..db71a71 --- /dev/null +++ b/assets/simple/js/package.json.tmpl @@ -0,0 +1,28 @@ +{ + "name": "${name}", + "version": "0.0.1", + "private": true, + "description": "Simple koapp HTTP service", + "main": "src/bootstrap.js", + "packageManager": "pnpm@10.33.4", + "engines": { + "node": ">=20", + "pnpm": ">=10" + }, + "scripts": { + "dev": "nodemon", + "start": "node src/bootstrap.js", + "lint": "eslint src/" + }, + "dependencies": { + "@axiosleo/cli-tool": "^1.6.4", + "@axiosleo/koapp": "^1.3.3", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "globals": "^15.14.0", + "nodemon": "^3.1.0" + } +} diff --git a/assets/simple/js/src/app.js.tmpl b/assets/simple/js/src/app.js.tmpl new file mode 100644 index 0000000..25b38e0 --- /dev/null +++ b/assets/simple/js/src/app.js.tmpl @@ -0,0 +1,24 @@ +'use strict'; + +const { KoaApplication } = require('@axiosleo/koapp'); +const config = require('./config'); +const routers = require('./routes'); + +class App extends KoaApplication { + constructor() { + const debugMode = config.envs.deploy !== 'prod'; + super({ + app_id: '${name}', + listen_host: '0.0.0.0', + debug: debugMode, + port: config.envs.port, + routers, + body_parser: { + enableTypes: ['json', 'form', 'text'], + encode: 'utf-8' + } + }); + } +} + +module.exports = App; diff --git a/assets/simple/js/src/bootstrap.js.tmpl b/assets/simple/js/src/bootstrap.js.tmpl new file mode 100644 index 0000000..6ba1789 --- /dev/null +++ b/assets/simple/js/src/bootstrap.js.tmpl @@ -0,0 +1,6 @@ +'use strict'; + +const App = require('./app'); + +const app = new App(); +app.start(); diff --git a/assets/simple/js/src/config.js.tmpl b/assets/simple/js/src/config.js.tmpl new file mode 100644 index 0000000..02dd69a --- /dev/null +++ b/assets/simple/js/src/config.js.tmpl @@ -0,0 +1,13 @@ +'use strict'; + +const path = require('path'); +const dotenv = require('dotenv'); + +dotenv.config({ path: path.join(__dirname, '../.env') }); + +module.exports = { + envs: { + deploy: process.env.DEPLOY_ENV || 'local', + port: process.env.API_PORT ? parseInt(process.env.API_PORT, 10) : 8801 + } +}; diff --git a/assets/simple/js/src/routes/example.router.js.tmpl b/assets/simple/js/src/routes/example.router.js.tmpl new file mode 100644 index 0000000..d05ed4a --- /dev/null +++ b/assets/simple/js/src/routes/example.router.js.tmpl @@ -0,0 +1,138 @@ +'use strict'; + +const { _sleep } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { failed, middlewares, Model, result, Router, success, error } = require('@axiosleo/koapp'); + +const router = new Router(''); + +function echo(context) { + success({ + body: context.body, + query: context.query, + params: context.params, + method: context.method + }); +} + +router.get('/', async () => { + success('hello, world!'); +}); + +router.get('/health', async () => { + success({ status: 'ok' }); +}); + +router.push('get|post', '/multi', async (context) => { + success({ method: context.method }); +}); + +router.get('/a/{:a}/b/{:b}', echo); + +router.any('/any', echo); + +router.any('/error', async () => { + throw new Error('error'); +}); + +router.any('/failed', async () => { + failed({ data: 'something' }, '403;Unauthorized', 403); +}); + +router.any('/result', async () => { + result(JSON.stringify({ hello: 'world!' }), 200, { + 'Content-Type': 'application/json' + }); +}); + +router.get('/result/obj', async () => { + result([{ test: 123 }]); +}); + +router.any('/success', async () => { + success('Hello, World!'); +}); + +router.any('/html', async () => { + result('hello, world!', 200, { + 'Content-Type': 'text/html' + }); +}); + +router.any('/model', async () => { + const model = new Model({ + submodel: new Model({ + submodel: new Model({ + a: 'A' + }) + }) + }); + success({ + obj: model.toObj(), + json: model.toJson(), + properties: model.properties(), + count: model.count() + }); +}); + +router.any('/model/error', async () => { + Model.create({}, { param: 'required' }); +}); + +router.any('/validate/{:param1}/{:param2}', async (context) => { + success({ + result: 'all params is valid', + params: context.params, + query: context.query, + body: context.body + }); +}, { + params: { + rules: { + param1: 'required', + param2: 'required' + } + }, + query: { + rules: { + a: 'required', + b: 'integer' + } + }, + body: { + rules: { + bodyA: 'required', + bodyB: 'integer' + }, + messages: { + required: 'The :attribute field is required......' + } + } +}); + +router.any('/sse', async (context) => { + const func = middlewares.KoaSSEMiddleware(); + await func(context.koa, async () => { + return; + }); + const sse = context.koa.sse; + if (!sse) { + error(500, 'SSE is not available'); + } + // The response body is the SSE stream only after this handler returns, + // so further events are pushed on a later turn. + sse.send({ data: 'hello, world!' }); + process.nextTick(() => { + const pump = async () => { + for (let index = 0; index < 5; index++) { + sse.send({ data: { index } }); + await _sleep(200); + } + sse.end(); + }; + pump().catch(() => { + sse.end(); + }); + }); +}); + +module.exports = router; diff --git a/assets/simple/js/src/routes/index.js.tmpl b/assets/simple/js/src/routes/index.js.tmpl new file mode 100644 index 0000000..5505de4 --- /dev/null +++ b/assets/simple/js/src/routes/index.js.tmpl @@ -0,0 +1,23 @@ +'use strict'; + +const { debug } = require('@axiosleo/cli-tool'); +const { error, Router } = require('@axiosleo/koapp'); +const example = require('./example.router'); + +const root = new Router('', { + middlewares: [async (context) => { + const pathinfo = context.router ? context.router.pathinfo : ''; + debug.log(`[${context.app_id}] ${context.method}: ${pathinfo}`); + }], + afters: [async (context) => { + debug.log(context.response); + }] +}); + +root.any('/***', async () => { + error(404, 'Not Found'); +}); + +root.add(example); + +module.exports = [root]; diff --git a/assets/simple/ts/.env.example.tmpl b/assets/simple/ts/.env.example.tmpl new file mode 100644 index 0000000..b8503da --- /dev/null +++ b/assets/simple/ts/.env.example.tmpl @@ -0,0 +1,5 @@ +# HTTP listen port +# API_PORT=8801 + +# prod enables production mode (debug off). Anything else is local/dev. +# DEPLOY_ENV=local diff --git a/assets/simple/ts/.gitignore.tmpl b/assets/simple/ts/.gitignore.tmpl new file mode 100644 index 0000000..722d979 --- /dev/null +++ b/assets/simple/ts/.gitignore.tmpl @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +coverage/ +*.tsbuildinfo diff --git a/assets/simple/ts/.npmrc.tmpl b/assets/simple/ts/.npmrc.tmpl new file mode 100644 index 0000000..c6ac720 --- /dev/null +++ b/assets/simple/ts/.npmrc.tmpl @@ -0,0 +1,3 @@ +registry=https://registry.npmmirror.com +shamefully-hoist=false +strict-peer-dependencies=false diff --git a/assets/simple/ts/.nvmrc.tmpl b/assets/simple/ts/.nvmrc.tmpl new file mode 100644 index 0000000..b404027 --- /dev/null +++ b/assets/simple/ts/.nvmrc.tmpl @@ -0,0 +1 @@ +24.8.0 diff --git a/assets/simple/ts/AGENTS.md.tmpl b/assets/simple/ts/AGENTS.md.tmpl new file mode 100644 index 0000000..4e4185c --- /dev/null +++ b/assets/simple/ts/AGENTS.md.tmpl @@ -0,0 +1,9 @@ +# ${name} + +简易 koapp HTTP 服务(TypeScript,pnpm,Node >= 20)。无数据库。 + +- 入口:`src/bootstrap.ts`,应用类 `src/app.ts`,配置 `src/config.ts` +- 路由:`src/routes/index.ts` 挂载 `src/routes/example.router.ts` +- 默认端口 `8801`(`API_PORT`) +- `pnpm dev` 开发,`pnpm build` 后 `pnpm start` 运行编译结果 +- koapp API 见 `.agents/skills/koapp*` diff --git a/assets/simple/ts/README.md.tmpl b/assets/simple/ts/README.md.tmpl new file mode 100644 index 0000000..5e70f9d --- /dev/null +++ b/assets/simple/ts/README.md.tmpl @@ -0,0 +1,63 @@ +# ${name} + +基于 [@axiosleo/koapp](https://github.com/AxiosLeo/node-koapp) 的简易 HTTP 接口服务(TypeScript)。只包含示例路由,不包含数据库。 + +## 环境要求 + +- Node.js `>= 20` +- pnpm `>= 10` + +## 快速开始 + +```bash +pnpm install +cp .env.example .env # 可选 +pnpm dev # http://127.0.0.1:8801 +``` + +生产构建: + +```bash +pnpm build +pnpm start +``` + +## 目录 + +``` +src/ + bootstrap.ts # 进程入口 + app.ts # KoaApplication + config.ts # 读取 .env + routes/index.ts # 根路由:请求日志、404 兜底 + routes/example.router.ts +``` + +## 示例路由 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/` | `success('hello, world!')` | +| `GET` | `/health` | `{ status: 'ok' }` | +| `GET`/`POST` | `/multi` | 多方法,回显 method | +| `GET` | `/a/{:a}/b/{:b}` | 路径参数 | +| `ANY` | `/any` | 任意方法,回显 params / query / body | +| `ANY` | `/error` | 抛错,框架转为 500 | +| `ANY` | `/failed` | `failed()`,HTTP 403 | +| `ANY` | `/result` | `result()` 自定义 header | +| `GET` | `/result/obj` | `result()` 返回对象 | +| `ANY` | `/success` | `success()` | +| `ANY` | `/html` | `text/html` | +| `ANY` | `/model` | `Model` 序列化 | +| `ANY` | `/model/error` | `Model` 校验失败 | +| `ANY` | `/validate/{:param1}/{:param2}` | params / query / body 校验 | +| `ANY` | `/sse` | Server-Sent Events | + +未匹配的路径返回 404。 + +## 环境变量 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `API_PORT` | `8801` | 监听端口 | +| `DEPLOY_ENV` | `local` | `prod` 时关闭 debug | diff --git a/assets/simple/ts/eslint.config.mjs.tmpl b/assets/simple/ts/eslint.config.mjs.tmpl new file mode 100644 index 0000000..470dbd9 --- /dev/null +++ b/assets/simple/ts/eslint.config.mjs.tmpl @@ -0,0 +1,45 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist/**'] }, + js.configs.recommended, + tseslint.configs.recommended, + { + languageOptions: { + globals: { + ...globals.node + }, + ecmaVersion: 2020, + sourceType: 'module' + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-this-alias': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-undef': 'off', + indent: [2, 2, { SwitchCase: 1 }], + quotes: [2, 'single'], + 'linebreak-style': [0, 'unix'], + semi: [2, 'always'], + curly: 2, + eqeqeq: 2, + 'no-eval': 2, + 'guard-for-in': 2, + 'no-caller': 2, + 'no-else-return': 2, + 'no-eq-null': 2, + 'no-extend-native': 2, + 'no-extra-bind': 2, + 'no-floating-decimal': 2, + 'no-implied-eval': 2, + 'no-labels': 2, + 'no-with': 2, + 'no-loop-func': 1, + 'no-console': 2, + 'eol-last': [2, 'always'] + } + } +); diff --git a/assets/simple/ts/nodemon.json.tmpl b/assets/simple/ts/nodemon.json.tmpl new file mode 100644 index 0000000..3fb96ea --- /dev/null +++ b/assets/simple/ts/nodemon.json.tmpl @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "exec": "node -r @swc-node/register src/bootstrap.ts", + "ext": "ts" +} diff --git a/assets/simple/ts/package.json.tmpl b/assets/simple/ts/package.json.tmpl new file mode 100644 index 0000000..c763699 --- /dev/null +++ b/assets/simple/ts/package.json.tmpl @@ -0,0 +1,39 @@ +{ + "name": "${name}", + "version": "0.0.1", + "private": true, + "description": "Simple koapp HTTP service", + "main": "dist/bootstrap.js", + "packageManager": "pnpm@10.33.4", + "engines": { + "node": ">=20", + "pnpm": ">=10" + }, + "scripts": { + "dev": "nodemon", + "build": "tsc -p tsconfig.json", + "start": "node ./dist/bootstrap.js", + "lint": "eslint src/" + }, + "dependencies": { + "@axiosleo/cli-tool": "^1.6.4", + "@axiosleo/koapp": "^1.3.3", + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@swc-node/register": "^1.9.0", + "@swc/core": "^1.4.14", + "@types/koa": "^2.15.0", + "@types/koa-session": "^6.4.5", + "@types/koa-static-server": "^1.5.3", + "@types/node": "^20.12.12", + "@types/validatorjs": "^3.15.5", + "@types/ws": "^8.5.10", + "eslint": "^9.17.0", + "globals": "^15.14.0", + "nodemon": "^3.1.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.19.0" + } +} diff --git a/assets/simple/ts/pnpm-workspace.yaml.tmpl b/assets/simple/ts/pnpm-workspace.yaml.tmpl new file mode 100644 index 0000000..91ac346 --- /dev/null +++ b/assets/simple/ts/pnpm-workspace.yaml.tmpl @@ -0,0 +1,2 @@ +allowBuilds: + '@swc/core': true diff --git a/assets/simple/ts/src/app.ts.tmpl b/assets/simple/ts/src/app.ts.tmpl new file mode 100644 index 0000000..de14397 --- /dev/null +++ b/assets/simple/ts/src/app.ts.tmpl @@ -0,0 +1,20 @@ +import { KoaApplication } from '@axiosleo/koapp'; +import config from './config'; +import routers from './routes'; + +export default class App extends KoaApplication { + constructor() { + const debugMode = config.envs.deploy !== 'prod'; + super({ + app_id: '${name}', + listen_host: '0.0.0.0', + debug: debugMode, + port: config.envs.port, + routers, + body_parser: { + enableTypes: ['json', 'form', 'text'], + encode: 'utf-8' + } + }); + } +} diff --git a/assets/simple/ts/src/bootstrap.ts.tmpl b/assets/simple/ts/src/bootstrap.ts.tmpl new file mode 100644 index 0000000..56bb60b --- /dev/null +++ b/assets/simple/ts/src/bootstrap.ts.tmpl @@ -0,0 +1,4 @@ +import App from './app'; + +const app = new App(); +app.start(); diff --git a/assets/simple/ts/src/config.ts.tmpl b/assets/simple/ts/src/config.ts.tmpl new file mode 100644 index 0000000..7c7df52 --- /dev/null +++ b/assets/simple/ts/src/config.ts.tmpl @@ -0,0 +1,11 @@ +import path from 'path'; +import dotenv from 'dotenv'; + +dotenv.config({ path: path.join(__dirname, '../.env') }); + +export default { + envs: { + deploy: process.env.DEPLOY_ENV || 'local', + port: process.env.API_PORT ? parseInt(process.env.API_PORT, 10) : 8801 + } +}; diff --git a/assets/simple/ts/src/routes/example.router.ts.tmpl b/assets/simple/ts/src/routes/example.router.ts.tmpl new file mode 100644 index 0000000..592865b --- /dev/null +++ b/assets/simple/ts/src/routes/example.router.ts.tmpl @@ -0,0 +1,139 @@ +import { _sleep } from '@axiosleo/cli-tool/src/helper/cmd'; +import type { KoaContext } from '@axiosleo/koapp'; +import { failed, middlewares, Model, result, Router, success, error } from '@axiosleo/koapp'; + +const router = new Router(''); + +async function echo(context: KoaContext): Promise { + success({ + body: context.body, + query: context.query, + params: context.params, + method: context.method + }); +} + +router.get('/', async () => { + success('hello, world!'); +}); + +router.get('/health', async () => { + success({ status: 'ok' }); +}); + +router.push('get|post', '/multi', async (context: KoaContext) => { + success({ method: context.method }); +}); + +router.get('/a/{:a}/b/{:b}', echo); + +router.any('/any', echo); + +router.any('/error', async () => { + throw new Error('error'); +}); + +router.any('/failed', async () => { + failed({ data: 'something' }, '403;Unauthorized', 403); +}); + +router.any('/result', async () => { + result(JSON.stringify({ hello: 'world!' }), 200, { + 'Content-Type': 'application/json' + }); +}); + +router.get('/result/obj', async () => { + result([{ test: 123 }]); +}); + +router.any('/success', async () => { + success('Hello, World!'); +}); + +router.any('/html', async () => { + result('hello, world!', 200, { + 'Content-Type': 'text/html' + }); +}); + +router.any('/model', async () => { + const model = new Model({ + submodel: new Model({ + submodel: new Model({ + a: 'A' + }) + }) + }); + // toObj() exists at runtime; @axiosleo/koapp declarations do not list it yet. + const view = model as Model & { toObj(): Record }; + success({ + obj: view.toObj(), + json: model.toJson(), + properties: model.properties(), + count: model.count() + }); +}); + +router.any('/model/error', async () => { + Model.create({}, { param: 'required' }); +}); + +router.any('/validate/{:param1}/{:param2}', async (context: KoaContext) => { + success({ + result: 'all params is valid', + params: context.params, + query: context.query, + body: context.body + }); +}, { + params: { + rules: { + param1: 'required', + param2: 'required' + } + }, + query: { + rules: { + a: 'required', + b: 'integer' + } + }, + body: { + rules: { + bodyA: 'required', + bodyB: 'integer' + }, + messages: { + required: 'The :attribute field is required......' + } + } +}); + +router.any('/sse', async (context: KoaContext) => { + const func = middlewares.KoaSSEMiddleware(); + await func(context.koa, async () => { + return; + }); + const sse = context.koa.sse; + if (!sse) { + error(500, 'SSE is not available'); + } + // The response body is the SSE stream only after this handler returns, + // so further events are pushed on a later turn. + sse.send({ data: 'hello, world!' }); + process.nextTick(() => { + const pump = async () => { + for (let index = 0; index < 5; index++) { + sse.send({ data: { index } }); + await _sleep(200); + } + sse.end(); + }; + pump().catch(() => { + sse.end(); + }); + }); +}); + +export default router; diff --git a/assets/simple/ts/src/routes/index.ts.tmpl b/assets/simple/ts/src/routes/index.ts.tmpl new file mode 100644 index 0000000..b1b645a --- /dev/null +++ b/assets/simple/ts/src/routes/index.ts.tmpl @@ -0,0 +1,22 @@ +import { debug } from '@axiosleo/cli-tool'; +import type { KoaContext } from '@axiosleo/koapp'; +import { error, Router } from '@axiosleo/koapp'; +import example from './example.router'; + +const root = new Router('', { + middlewares: [async (context: KoaContext) => { + const pathinfo = context.router ? context.router.pathinfo : ''; + debug.log(`[${context.app_id}] ${context.method}: ${pathinfo}`); + }], + afters: [async (context: KoaContext) => { + debug.log(context.response); + }] +}); + +root.any('/***', async () => { + error(404, 'Not Found'); +}); + +root.add(example); + +export default [root]; diff --git a/assets/simple/ts/src/types/cli-tool.d.ts.tmpl b/assets/simple/ts/src/types/cli-tool.d.ts.tmpl new file mode 100644 index 0000000..5a0a3e0 --- /dev/null +++ b/assets/simple/ts/src/types/cli-tool.d.ts.tmpl @@ -0,0 +1,39 @@ +/** + * @axiosleo/cli-tool ships no declarations. This ambient module also + * satisfies the same import inside @axiosleo/koapp's index.d.ts. + */ +declare module '@axiosleo/cli-tool' { + import { EventEmitter } from 'events'; + + export class Workflow extends EventEmitter { + constructor(config?: Configuration); + context?: T; + } + + export interface Configuration { + [key: string]: unknown; + } + + export interface Context { + [key: string]: unknown; + } + + export const debug: { + log(...args: unknown[]): void; + }; + + export const printer: { + print(text?: string): typeof printer; + println(text?: string): typeof printer; + yellow(text?: string): typeof printer; + green(text?: string): typeof printer; + info(message?: string): void; + warning(message?: string): void; + error(message?: string): void; + success(message?: string): void; + }; +} + +declare module '@axiosleo/cli-tool/src/helper/cmd' { + export function _sleep(ms: number): Promise; +} diff --git a/assets/simple/ts/tsconfig.json.tmpl b/assets/simple/ts/tsconfig.json.tmpl new file mode 100644 index 0000000..d396cdb --- /dev/null +++ b/assets/simple/ts/tsconfig.json.tmpl @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "sourceMap": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "moduleResolution": "node", + "noUnusedLocals": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/assets/skills/koapp-apps/SKILL.md b/assets/skills/koapp-apps/SKILL.md index f920cfc..e611d67 100644 --- a/assets/skills/koapp-apps/SKILL.md +++ b/assets/skills/koapp-apps/SKILL.md @@ -81,7 +81,7 @@ Socket apps support opt-in periodic ping: ```javascript new SocketApplication({ - port: 8081, + port: 8801, routers: [root], ping: { open: true, // default false diff --git a/assets/skills/koapp-apps/examples.md b/assets/skills/koapp-apps/examples.md index d656084..cfbad31 100644 --- a/assets/skills/koapp-apps/examples.md +++ b/assets/skills/koapp-apps/examples.md @@ -76,7 +76,7 @@ router.any('/chat/{:room}', async (context) => { }); const app = new SocketApplication({ - port: 8081, + port: 8801, routers: [router], ping: { open: true, @@ -97,7 +97,7 @@ Matching TCP client: ```javascript const net = require('net'); -const client = net.createConnection({ port: 8081 }); +const client = net.createConnection({ port: 8801 }); client.write(JSON.stringify({ path: '/chat/general', method: 'POST', diff --git a/assets/skills/koapp-apps/socket-server.md b/assets/skills/koapp-apps/socket-server.md index 5c4a172..d0aa870 100644 --- a/assets/skills/koapp-apps/socket-server.md +++ b/assets/skills/koapp-apps/socket-server.md @@ -11,7 +11,7 @@ routes inbound frames through the same `Router` stack as HTTP. const { SocketApplication } = require('@axiosleo/koapp'); const app = new SocketApplication({ - port: 8081, + port: 8801, routers: [router], app_id: 'my-tcp-service', debug: false, @@ -40,7 +40,7 @@ Client side (example in Node): ```javascript const net = require('net'); -const client = net.createConnection({ port: 8081 }); +const client = net.createConnection({ port: 8801 }); client.write(JSON.stringify({ path: '/chat/42', method: 'GET', diff --git a/assets/skills/koapp-apps/websocket-server.md b/assets/skills/koapp-apps/websocket-server.md index fbb646f..1c17f11 100644 --- a/assets/skills/koapp-apps/websocket-server.md +++ b/assets/skills/koapp-apps/websocket-server.md @@ -14,7 +14,7 @@ to WebSocket semantics. const { WebSocketApplication } = require('@axiosleo/koapp'); const app = new WebSocketApplication({ - port: 8081, + port: 8801, routers: [router], app_id: 'my-ws-service', ping: { @@ -42,7 +42,7 @@ Outbound frames are written via `ws.send(json)`. Browser client example: ```javascript -const ws = new WebSocket('ws://localhost:8081'); +const ws = new WebSocket('ws://localhost:8801'); ws.onopen = () => { ws.send(JSON.stringify({ path: '/chat/42', diff --git a/assets/skills/koapp-controller/SKILL.md b/assets/skills/koapp-controller/SKILL.md index ad15d19..0c8125e 100644 --- a/assets/skills/koapp-controller/SKILL.md +++ b/assets/skills/koapp-controller/SKILL.md @@ -59,7 +59,7 @@ router.get('/version', () => health.version()); constructor and reuse across methods. - **Inheritance**: define a `BaseController` with common middlewares or helpers, then extend it per module (the `init` scaffolding template at - [`assets/tmpl/services/src/modules/controller.ts.tmpl`](../../tmpl/services/src/modules/controller.ts.tmpl) does this). + [`assets/monorepo/apps/services/src/modules/controller.ts.tmpl`](../../monorepo/apps/services/src/modules/controller.ts.tmpl) does this). - **Testability**: easier to mock a controller instance than free-floating functions. @@ -132,22 +132,52 @@ router.push('post', '/users', (ctx) => controller.create(ctx), { The validator runs before the handler, so by the time `controller.create` executes you already have a validated `context.body`. -## Generator output +## Scaffold output -Running `npx @axiosleo/koapp gen -d ./meta -o ./services/src/modules` -produces `.controller.ts` files that extend a project-local -`BaseController`. That `BaseController` typically extends `Controller` and -adds database handles: +There is no code generator; modules are written by hand following the +layout produced by `npx @axiosleo/koapp init ` (monorepo template). +It ships a project-local `BaseController` at +`apps/services/src/modules/controller.ts` that extends `Controller` and +adds request-scoped helpers (the template source is +[`assets/monorepo/apps/services/src/modules/controller.ts.tmpl`](../../monorepo/apps/services/src/modules/controller.ts.tmpl)): ```typescript +import type { KoaContext } from '@axiosleo/koapp'; import { Controller } from '@axiosleo/koapp'; -import { mainDB } from '../services/db'; export class BaseController extends Controller { - protected mainDB = mainDB; + /** Resolve the app scope for the current request (Api-Key vs admin session). */ + protected appId(context: KoaContext): string | null { /* ... */ } + + protected isAdmin(context: KoaContext): boolean { + return context.auth?.is_admin === true; + } } ``` +Each business module then follows the three-file convention +`src/modules//.router.ts` + `.controller.ts` + +`.model.ts`, where the controller extends `BaseController` and is +exported as a singleton bound to routes with arrow wrappers: + +```typescript +// src/modules/app/app.controller.ts +import { BaseController } from '../controller'; + +export class AppController extends BaseController { + async list(context: KoaContext) { + this.success({ list: [] }); + } +} + +export default new AppController(); + +// src/modules/app/app.router.ts +import controller from './app.controller'; +const router = new Router('/apps'); +router.get('', (ctx) => controller.list(ctx)); +``` + ## Common pitfalls - Forgetting to pass `context` into instance methods: `router.get('/x', controller.handler)` loses `this` when the router calls the method as a free function. Use arrow wrappers. diff --git a/assets/skills/koapp-typescript/SKILL.md b/assets/skills/koapp-typescript/SKILL.md index 4d6ad54..3756750 100644 --- a/assets/skills/koapp-typescript/SKILL.md +++ b/assets/skills/koapp-typescript/SKILL.md @@ -237,7 +237,7 @@ new KoaApplication(config).start(); type UserRouter = Router; const strict: TypedKoaApplicationConfig = { listen_host: 'localhost', - port: 8081, + port: 8801, routers: [userRouter1, userRouter2], }; ``` diff --git a/assets/skills/koapp-typescript/examples.md b/assets/skills/koapp-typescript/examples.md index b47097f..23834ca 100644 --- a/assets/skills/koapp-typescript/examples.md +++ b/assets/skills/koapp-typescript/examples.md @@ -367,7 +367,7 @@ new KoaApplication(config).start(); type UserRouter = Router; const strictConfig: TypedKoaApplicationConfig = { listen_host: 'localhost', - port: 8081, + port: 8801, routers: [userRouter], }; ``` diff --git a/assets/skills/koapp/SKILL.md b/assets/skills/koapp/SKILL.md index f9aef0a..0fbd723 100644 --- a/assets/skills/koapp/SKILL.md +++ b/assets/skills/koapp/SKILL.md @@ -92,7 +92,8 @@ implicit - no need to `return` the result of `success(...)`. ## Conventions enforced by the framework 1. Code uses `'use strict'`, CommonJS, and async/await - follow suit in user - code unless the target project uses TypeScript (see `assets/tmpl/`). + code unless the target project uses TypeScript (see **koapp-typescript** + and the `init` scaffold under `assets/monorepo/apps/services/`). 2. Response payloads (JSON) are always wrapped as `{ request_id, timestamp, code, message, data }`. `code` is a `";"` string. diff --git a/assets/skills/koapp/quick-start.md b/assets/skills/koapp/quick-start.md index aa6e66b..60babea 100644 --- a/assets/skills/koapp/quick-start.md +++ b/assets/skills/koapp/quick-start.md @@ -40,29 +40,27 @@ node server.js # open http://localhost:8088/ ``` -## 3. Scaffolding a full project +## 3. Scaffolding a project -The CLI ships a project generator that wires up TypeScript, a DB layer, Docker, -and module scaffolding: +`npx @axiosleo/koapp init ` asks which template to write. The default +is a single HTTP service. There is no code generator; routes are the samples +shipped with the template, and further modules are written by hand. -```bash -npx @axiosleo/koapp init my-app -cd my-app -npm install -npm run start:services -``` - -## 4. Generating CRUD modules from JSON Schema - -After `init`, place `*.schema.json` files under `./meta/` and run: +| Flag | Result | +| --- | --- | +| `-t simple-ts` (default) | TypeScript koapp service, sample routes, no database | +| `-t simple-js` | The same service in CommonJS, no compile step | +| `-t monorepo` | pnpm workspace. `apps/services` is a TypeScript API with Session admin routes, an `/openapi` surface guarded by Bearer Api-Keys, and a SQLite metadata store. Business modules follow `src/modules//.{router,controller,model}.ts` | ```bash -npx @axiosleo/koapp gen -d ./meta -o ./services/src/modules +npx @axiosleo/koapp init my-app -t simple-ts +# init offers to run `pnpm install` and `pnpm dev` for you; otherwise: +cd my-app +pnpm install +pnpm dev ``` -Each schema produces `name.model.ts`, `name.controller.ts`, `name.router.ts`. - -## 5. Next steps +## 4. Next steps Once the skeleton is running, consult the specialized skills: diff --git a/commands/init.js b/commands/init.js index 78566c0..8a3fcd1 100644 --- a/commands/init.js +++ b/commands/init.js @@ -2,10 +2,7 @@ const path = require('path'); const { spawn, spawnSync } = require('child_process'); -const { _search, _write, _mkdir, _move, _sync } = require('@axiosleo/cli-tool/src/helper/fs'); -const { _foreach } = require('@axiosleo/cli-tool/src/helper/cmd'); const { Command, printer } = require('@axiosleo/cli-tool'); -const { _render_with_file } = require('@axiosleo/cli-tool/src/helper/str'); const { DEFAULT_PNPM_PIN, readPnpmPin, @@ -13,6 +10,11 @@ const { isCorepackLoadError, globalInstallCommand } = require('../src/cli/pnpm'); +const { + TEMPLATES, + templateChoices, + scaffold +} = require('../src/cli/scaffold'); function probePnpm(dir, env) { return spawnSync('pnpm', ['--version'], { @@ -62,6 +64,7 @@ class InitCommand extends Command { }); this.addArgument('name', 'Application name', 'required'); this.addOption('dir', 'd', 'output dir', 'optional', process.cwd()); + this.addOption('template', 't', 'Project template: simple-ts (default) | simple-js | monorepo', 'optional', null); } /** @@ -69,28 +72,23 @@ class InitCommand extends Command { * @param {*} options */ async exec(args, options) { - let name = args.name; + const name = args.name; + const ids = TEMPLATES.map((item) => item.id); + let template = options.template; + if (!template) { + // Object choices ignore select()'s default argument; index 0 (simple-ts) is highlighted. + template = await this.select('Choose a template', templateChoices()); + } else if (ids.indexOf(template) === -1) { + printer.error(`Unknown template "${template}". Choose one of: ${ids.join(', ')}`); + return; + } - printer.info(`Will initialize ${name} application`); + printer.info(`Will initialize ${name} application (${template})`); - let dir = path.join(options.dir, name + '/'); + const dir = path.join(options.dir, name + '/'); printer.info(`Output dir: ${dir}`); printer.info('Initializing...'); - await _mkdir(dir); - - const tmplDir = path.join(__dirname, '../assets/monorepo/'); - const files = await _search(tmplDir, 'tmpl'); - - await _foreach(files, async (file) => { - let f = file.substring(0, file.length - 5); - f = f.replace(tmplDir, ''); - f = path.join(dir, f); - let c = await _render_with_file(file, { name }); - await _write(f, c); - }); - - await _move(path.join(dir, 'bin/monoapp.js'), path.join(dir, `bin/${name}.js`)); - await _sync(path.join(__dirname, '../assets/skills'), path.join(dir, '.agents/skills')); + await scaffold({ template, name, dir }); printer.success('Initialized successfully'); diff --git a/docs/websocket.md b/docs/websocket.md index 1ba85e3..1bc7134 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -26,7 +26,7 @@ router.any('/hello', async (context) => { }); const app = new WebSocketApplication({ - port: 8081, + port: 8801, routers: [router], }); @@ -41,7 +41,7 @@ app.start(); | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `port` | `number` | `8081` | 监听端口 | +| `port` | `number` | `8801` | 监听端口 | | `debug` | `boolean` | `false` | 是否开启调试模式 | | `app_id` | `string` | 自动生成 UUID | 应用唯一标识 | | `routers` | `Router[]` | `[]` | 路由列表 | @@ -71,7 +71,7 @@ app.start(); ```javascript const app = new WebSocketApplication({ - port: 8081, + port: 8801, debug: true, app_id: 'my-ws-app', routers: [router], @@ -140,7 +140,7 @@ apiRouter.any('/rooms/{:id}', async (context) => { }); const app = new WebSocketApplication({ - port: 8081, + port: 8801, routers: [apiRouter], }); ``` @@ -221,7 +221,7 @@ const router = new Router('/api', { **浏览器端:** ```javascript -const ws = new WebSocket('ws://localhost:8081/api/v1/chat?token=abc123'); +const ws = new WebSocket('ws://localhost:8801/api/v1/chat?token=abc123'); ws.onopen = () => { ws.send(JSON.stringify({ @@ -241,7 +241,7 @@ ws.onmessage = (event) => { ```javascript const WebSocket = require('ws'); -const ws = new WebSocket('ws://localhost:8081/api/v1/chat?token=abc123'); +const ws = new WebSocket('ws://localhost:8801/api/v1/chat?token=abc123'); ws.on('open', () => { ws.send(JSON.stringify({ @@ -512,7 +512,7 @@ WebSocketApplication 继承自 `EventEmitter`,支持丰富的事件监听。 ```javascript const app = new WebSocketApplication({ - port: 8081, + port: 8801, routers: [router], }); @@ -646,7 +646,7 @@ router.get('/status', async () => { // 创建应用 const app = new WebSocketApplication({ - port: 8081, + port: 8801, debug: true, routers: [router], ping: { @@ -681,7 +681,7 @@ app.start(); ### 对应的客户端代码 ```javascript -const ws = new WebSocket('ws://localhost:8081/chat/room/general/join?token=my-token'); +const ws = new WebSocket('ws://localhost:8801/chat/room/general/join?token=my-token'); ws.onopen = () => { // 加入房间 diff --git a/examples/socket.web.html b/examples/socket.web.html index c578624..1c62b0f 100644 --- a/examples/socket.web.html +++ b/examples/socket.web.html @@ -85,7 +85,7 @@

WebSocket Example

diff --git a/examples/websocket.server.js b/examples/websocket.server.js index c736b70..e335886 100644 --- a/examples/websocket.server.js +++ b/examples/websocket.server.js @@ -4,7 +4,7 @@ const root = require('./api.router'); const app = new WebSocketApplication({ routers: [root], - port: 8081, + port: 8801, ping: { open: false, interval: 1000 * 3, diff --git a/src/apps/socket.js b/src/apps/socket.js index 38bafb9..58d9eef 100644 --- a/src/apps/socket.js +++ b/src/apps/socket.js @@ -69,7 +69,7 @@ class SocketApplication extends Application { super(options); this.event = new EventEmitter(); - this.port = this.config.port || 8081; + this.port = this.config.port || 8801; this.connections = {}; this.on('response', handleRes); this.workflow = new Workflow(operator); diff --git a/src/cli/scaffold.js b/src/cli/scaffold.js new file mode 100644 index 0000000..1d4932f --- /dev/null +++ b/src/cli/scaffold.js @@ -0,0 +1,121 @@ +'use strict'; + +const path = require('path'); +const { _search, _write, _mkdir, _move, _sync } = require('@axiosleo/cli-tool/src/helper/fs'); +const { _foreach } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { _render_with_file } = require('@axiosleo/cli-tool/src/helper/str'); + +const ASSETS_ROOT = path.join(__dirname, '../../assets'); + +/** + * Ordered template catalog. The first entry is the interactive default: + * cli-tool's select prompt highlights index 0 for object choices. + */ +const TEMPLATES = [ + { + id: 'simple-ts', + dir: 'simple/ts', + message: 'Simple TypeScript app (default)' + }, + { + id: 'simple-js', + dir: 'simple/js', + message: 'Simple JavaScript app' + }, + { + id: 'monorepo', + dir: 'monorepo', + message: 'Full monorepo project' + } +]; + +const DEFAULT_TEMPLATE = TEMPLATES[0].id; + +/** + * Choices for `Command#select`. `name` is the value returned to the caller. + * @returns {{ name: string, message: string }[]} + */ +function templateChoices() { + return TEMPLATES.map((item) => ({ + name: item.id, + message: item.message + })); +} + +/** + * @param {string} template + * @returns {{ id: string, dir: string, message: string }|null} + */ +function findTemplate(template) { + for (let i = 0; i < TEMPLATES.length; i++) { + if (TEMPLATES[i].id === template) { + return TEMPLATES[i]; + } + } + return null; +} + +/** + * Absolute directory of `*.tmpl` files for a template id. + * @param {string} template + * @returns {string} + */ +function resolveTemplateDir(template) { + const found = findTemplate(template); + if (!found) { + const ids = TEMPLATES.map((item) => item.id).join(', '); + throw new Error(`Unknown template "${template}". Choose one of: ${ids}`); + } + return path.join(ASSETS_ROOT, found.dir); +} + +/** + * Render every `*.tmpl` file under `tmplDir` into `outDir`. + * `${name}` is substituted; the `.tmpl` suffix is stripped. + * @param {string} tmplDir + * @param {string} outDir + * @param {{ name: string }} params + */ +async function renderTemplates(tmplDir, outDir, params) { + const files = await _search(tmplDir, 'tmpl'); + await _foreach(files, async (file) => { + const relative = path.relative(tmplDir, file).replace(/\.tmpl$/, ''); + const dest = path.join(outDir, relative); + const content = await _render_with_file(file, params); + await _write(dest, content); + }); +} + +/** + * Write a project from a template and copy the bundled koapp skills. + * `_sync` is called with reback disabled so the package's `assets/skills` + * is never overwritten from the generated tree. + * + * @param {{ template: string, name: string, dir: string }} options + */ +async function scaffold(options) { + const tmplDir = resolveTemplateDir(options.template); + await _mkdir(options.dir); + await renderTemplates(tmplDir, options.dir, { name: options.name }); + if (options.template === 'monorepo') { + await _move( + path.join(options.dir, 'bin/monoapp.js'), + path.join(options.dir, 'bin', `${options.name}.js`) + ); + } + await _sync( + path.join(ASSETS_ROOT, 'skills'), + path.join(options.dir, '.agents/skills'), + '*', + false + ); +} + +module.exports = { + TEMPLATES, + DEFAULT_TEMPLATE, + templateChoices, + resolveTemplateDir, + renderTemplates, + scaffold +}; diff --git a/src/utils.js b/src/utils.js index 79e7b2a..a278c41 100644 --- a/src/utils.js +++ b/src/utils.js @@ -54,7 +54,7 @@ function _debug(context, location, error) { class SocketClient { constructor(options = {}) { this.options = Object.assign({ - port: 8081, + port: 8801, host: 'localhost', name: 'default', }, options); diff --git a/tests/pnpm.tests.js b/tests/pnpm.tests.js index a5eb2ee..27b5e8d 100644 --- a/tests/pnpm.tests.js +++ b/tests/pnpm.tests.js @@ -47,6 +47,17 @@ describe('cli/pnpm', () => { expect(tmpl).to.include('"pnpm": ">=10"'); }); + it('pins the same pnpm version in the simple templates', () => { + ['ts', 'js'].forEach((lang) => { + const tmpl = fs.readFileSync( + path.join(__dirname, `../assets/simple/${lang}/package.json.tmpl`), + 'utf8' + ); + expect(tmpl).to.include(`"packageManager": "pnpm@${DEFAULT_PNPM_PIN}"`); + expect(tmpl).to.include('"pnpm": ">=10"'); + }); + }); + it('reads packageManager from package.json', () => { const dir = mkdtemp('koapp-pnpm-pin-'); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ diff --git a/tests/scaffold.tests.js b/tests/scaffold.tests.js new file mode 100644 index 0000000..4685309 --- /dev/null +++ b/tests/scaffold.tests.js @@ -0,0 +1,139 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { expect } = require('chai'); +const { + TEMPLATES, + DEFAULT_TEMPLATE, + templateChoices, + resolveTemplateDir, + scaffold +} = require('../src/cli/scaffold'); + +const APP_NAME = 'demo-app'; + +function walk(dir, acc) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + entries.forEach((entry) => { + if (entry.name === '.agents' || entry.name === 'node_modules') { + return; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, acc); + return; + } + acc.push(full); + }); + return acc; +} + +describe('cli/scaffold', function () { + this.timeout(10000); + + const roots = []; + + after(() => { + roots.forEach((dir) => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + }); + + function outputDir(template) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `koapp-scaffold-${template}-`)); + roots.push(dir); + return path.join(dir, APP_NAME); + } + + it('lists simple-ts first as the default template', () => { + expect(DEFAULT_TEMPLATE).to.equal('simple-ts'); + expect(TEMPLATES.map((item) => item.id)).to.deep.equal([ + 'simple-ts', + 'simple-js', + 'monorepo' + ]); + expect(templateChoices()[0]).to.deep.equal({ + name: 'simple-ts', + message: 'Simple TypeScript app (default)' + }); + }); + + it('rejects an unknown template before writing files', () => { + expect(() => resolveTemplateDir('nope')).to.throw(/Unknown template/); + }); + + it('renders the simple TypeScript app without a database', async () => { + const dir = outputDir('simple-ts'); + await scaffold({ template: 'simple-ts', name: APP_NAME, dir }); + + expect(fs.existsSync(path.join(dir, 'package.json'))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'tsconfig.json'))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'src/bootstrap.ts'))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'src/routes/example.router.ts'))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))).to.equal(true); + expect(fs.existsSync(path.join(dir, '.agents/skills/koapp/SKILL.md'))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'bin/monoapp.js'))).to.equal(false); + + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkg.name).to.equal(APP_NAME); + expect(pkg.dependencies).to.not.have.property('better-sqlite3'); + + const app = fs.readFileSync(path.join(dir, 'src/app.ts'), 'utf8'); + expect(app).to.include(`app_id: '${APP_NAME}'`); + assertNoPlaceholders(dir); + assertNoDatabase(dir); + }); + + it('renders the simple JavaScript app without a compile step', async () => { + const dir = outputDir('simple-js'); + await scaffold({ template: 'simple-js', name: APP_NAME, dir }); + + expect(fs.existsSync(path.join(dir, 'src/bootstrap.js'))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'src/routes/example.router.js'))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'tsconfig.json'))).to.equal(false); + expect(fs.existsSync(path.join(dir, '.agents/skills/koapp/SKILL.md'))).to.equal(true); + + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkg.name).to.equal(APP_NAME); + expect(pkg.scripts.start).to.equal('node src/bootstrap.js'); + expect(pkg.dependencies).to.not.have.property('better-sqlite3'); + assertNoPlaceholders(dir); + assertNoDatabase(dir); + }); + + it('renders the monorepo and renames the cli entry', async () => { + const dir = outputDir('monorepo'); + await scaffold({ template: 'monorepo', name: APP_NAME, dir }); + + expect(fs.existsSync(path.join(dir, 'bin', `${APP_NAME}.js`))).to.equal(true); + expect(fs.existsSync(path.join(dir, 'bin/monoapp.js'))).to.equal(false); + expect(fs.existsSync(path.join(dir, 'apps/services/src/services/sqlite.ts'))).to.equal(true); + expect(fs.existsSync(path.join(dir, '.agents/skills/koapp-router/SKILL.md'))).to.equal(true); + + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'apps/services/package.json'), 'utf8')); + expect(pkg.name).to.equal(`${APP_NAME}-services`); + expect(pkg.dependencies).to.have.property('better-sqlite3'); + + const bin = fs.readFileSync(path.join(dir, 'bin', `${APP_NAME}.js`), 'utf8'); + expect(bin).to.include(`name: '${APP_NAME}'`); + assertNoPlaceholders(dir); + }); +}); + +function assertNoPlaceholders(dir) { + const files = walk(dir, []); + expect(files.length).to.be.greaterThan(0); + files.forEach((file) => { + const content = fs.readFileSync(file, 'utf8'); + expect(content, file).to.not.include('${name}'); + }); +} + +function assertNoDatabase(dir) { + const joined = walk(dir, []).map((file) => fs.readFileSync(file, 'utf8')).join('\n'); + expect(joined).to.not.include('better-sqlite3'); + expect(joined).to.not.include('sqlite'); + expect(joined).to.not.match(/\bcluster\b/); +}