From 6e7a1087a493e5c72d8a25af9f09ffbf4e9a7dd1 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Wed, 22 Jul 2026 17:41:43 +0900 Subject: [PATCH 01/13] =?UTF-8?q?chore:=20scaffold=20@yuque/cli=20?= =?UTF-8?q?=E2=80=94=20config,=20http=20client,=20output,=20command=20skel?= =?UTF-8?q?eton,=20spec=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 21 + .gitignore | 5 + .prettierrc.json | 10 + LICENSE | 21 + eslint.config.js | 28 + package-lock.json | 3843 ++++++++++++++++++++++++++++ package.json | 69 + scripts/smoke-dist-cli.js | 30 + spec/yuque-openapi.yaml | 4404 +++++++++++++++++++++++++++++++++ src/bin.ts | 4 + src/cli.ts | 60 + src/client/http.ts | 106 + src/client/paginate.ts | 15 + src/client/repo-ref.ts | 28 + src/client/types.ts | 212 ++ src/commands/auth.ts | 6 + src/commands/doc.ts | 6 + src/commands/group.ts | 6 + src/commands/repo.ts | 6 + src/commands/search.ts | 6 + src/commands/stats.ts | 6 + src/commands/toc.ts | 6 + src/commands/user.ts | 6 + src/config.ts | 33 + src/confirm.ts | 22 + src/context.ts | 24 + src/errors.ts | 63 + src/output.ts | 78 + tests/cli.test.ts | 46 + tests/client/http.test.ts | 99 + tests/client/repo-ref.test.ts | 32 + tests/config.test.ts | 48 + tests/spec-coverage.test.ts | 162 ++ tsconfig.json | 26 + vitest.config.ts | 17 + 35 files changed, 9554 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierrc.json create mode 100644 LICENSE create mode 100644 eslint.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/smoke-dist-cli.js create mode 100644 spec/yuque-openapi.yaml create mode 100644 src/bin.ts create mode 100644 src/cli.ts create mode 100644 src/client/http.ts create mode 100644 src/client/paginate.ts create mode 100644 src/client/repo-ref.ts create mode 100644 src/client/types.ts create mode 100644 src/commands/auth.ts create mode 100644 src/commands/doc.ts create mode 100644 src/commands/group.ts create mode 100644 src/commands/repo.ts create mode 100644 src/commands/search.ts create mode 100644 src/commands/stats.ts create mode 100644 src/commands/toc.ts create mode 100644 src/commands/user.ts create mode 100644 src/config.ts create mode 100644 src/confirm.ts create mode 100644 src/context.ts create mode 100644 src/errors.ts create mode 100644 src/output.ts create mode 100644 tests/cli.test.ts create mode 100644 tests/client/http.test.ts create mode 100644 tests/client/repo-ref.test.ts create mode 100644 tests/config.test.ts create mode 100644 tests/spec-coverage.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ec3e846 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e3789d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +*.log +.DS_Store diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..32a2397 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2d501fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cwg + +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/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c785249 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; + +export default [ + { + files: ['src/**/*.ts', 'tests/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + }, + }, + { + ignores: ['dist/', 'node_modules/', '*.js'], + }, +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c5b08f4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3843 @@ +{ + "name": "@yuque/cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@yuque/cli", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "axios": "^1.7.9", + "commander": "^14.0.0" + }, + "bin": { + "yuque": "dist/bin.js" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.5", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", + "@vitest/coverage-v8": "^4.0.18", + "eslint": "^9.18.0", + "js-yaml": "^4.1.0", + "prettier": "^3.4.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", + "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..011b3be --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "@yuque/cli", + "version": "0.1.0", + "description": "Command-line interface for Yuque (语雀) — browse, edit, and manage your knowledge base from the terminal", + "type": "module", + "main": "dist/cli.js", + "bin": { + "yuque": "dist/bin.js" + }, + "files": [ + "dist", + "README.md", + "README.zh-CN.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsc && chmod +x dist/bin.js", + "check": "npm run lint && npm run format:check && npm run typecheck && npm test && npm run build && npm run smoke:dist", + "dev": "tsx src/bin.ts", + "smoke:dist": "node scripts/smoke-dist-cli.js", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src tests", + "lint:fix": "eslint src tests --fix", + "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "yuque", + "cli", + "knowledge-base", + "markdown", + "documentation" + ], + "author": "yuque", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/yuque/yuque-cli.git" + }, + "bugs": { + "url": "https://github.com/yuque/yuque-cli/issues" + }, + "homepage": "https://github.com/yuque/yuque-cli#readme", + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "axios": "^1.7.9", + "commander": "^14.0.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.5", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", + "@vitest/coverage-v8": "^4.0.18", + "eslint": "^9.18.0", + "js-yaml": "^4.1.0", + "prettier": "^3.4.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + } +} diff --git a/scripts/smoke-dist-cli.js b/scripts/smoke-dist-cli.js new file mode 100644 index 0000000..95dfab7 --- /dev/null +++ b/scripts/smoke-dist-cli.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** Smoke-test the built CLI in dist/ the way npx would run it. */ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const bin = fileURLToPath(new URL('../dist/bin.js', import.meta.url)); +let failures = 0; + +function check(name, args, { expectCode = 0, expectStdout } = {}) { + const result = spawnSync('node', [bin, ...args], { encoding: 'utf8', env: { ...process.env } }); + const ok = + result.status === expectCode && (!expectStdout || result.stdout.includes(expectStdout)); + if (ok) { + console.log(`ok ${name}`); + } else { + failures += 1; + console.error(`FAIL ${name}: exit=${result.status} (want ${expectCode})`); + if (expectStdout) console.error(` stdout missing: ${JSON.stringify(expectStdout)}`); + console.error(` stdout: ${result.stdout.slice(0, 300)}`); + console.error(` stderr: ${result.stderr.slice(0, 300)}`); + } +} + +check('--version prints a semver', ['--version'], { expectStdout: '.' }); +check('--help shows command groups', ['--help'], { expectStdout: 'doc' }); +check('doc --help shows subcommands', ['doc', '--help'], { expectStdout: 'list' }); +check('unknown command exits 2', ['no-such-command'], { expectCode: 2 }); +check('missing token exits 3', ['user', 'info'], { expectCode: 3 }); + +process.exit(failures === 0 ? 0 : 1); diff --git a/spec/yuque-openapi.yaml b/spec/yuque-openapi.yaml new file mode 100644 index 0000000..590b870 --- /dev/null +++ b/spec/yuque-openapi.yaml @@ -0,0 +1,4404 @@ +openapi: 3.1.0 +info: + title: 语雀 OpenAPI + description: '' + license: + name: Apache 2.0 + identifier: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + version: 2.0.1 +servers: + - url: https://www.yuque.com + description: 线上访问地址 +components: + securitySchemes: + authToken: + type: apiKey + description: '使用指南: https://www.yuque.com/yuque/developer/api' + name: X-Auth-Token + in: header + responses: + '400': + description: 请求参数非法 + '401': + description: Token/Scope 未通过鉴权 + '403': + description: 无操作权限 + '404': + description: 实体未找到 + '422': + description: 请求参数校验失败 + '429': + description: 访问频率超限 + '500': + description: 内部错误 + requestBodies: + ApiV2GroupMemberUpdatePut: + content: + application/json: + schema: + type: object + properties: + role: + type: integer + enum: + - 0 + - 1 + - 2 + default: 1 + description: |- + 角色 + (0:管理员, 1:成员, 2:只读成员) + ApiV2DocCreateByIdPost: + content: + application/json: + schema: + type: object + properties: + slug: + type: string + description: 路径 + title: + type: string + default: 无标题 + description: 标题 + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |+ + 公开性 + (0:私密, 1:公开, 2:企业内公开) + + + - 不填则继承知识库的公开性 + + format: + type: string + enum: + - markdown + - html + - lake + default: markdown + description: |- + 内容格式 + (markdown:Markdown 格式, html:HTML 标准格式, lake:语雀 Lake 格式) + body: + type: string + description: 正文内容 + required: + - body + ApiV2DocUpdateByIdPut: + content: + application/json: + schema: + type: object + properties: + slug: + type: string + description: 路径 + title: + type: string + description: 标题 + public: + type: integer + enum: + - 0 + - 1 + - 2 + default: 0 + description: |- + 公开性 + (0:私密, 1:公开, 2:企业内公开) + format: + type: string + enum: + - markdown + - html + - lake + default: markdown + description: |- + 内容格式 + (markdown:Markdown 格式, html:HTML 标准格式, lake:语雀 Lake 格式) + body: + type: string + description: 正文内容 + ApiV2DocCreatePost: + content: + application/json: + schema: + type: object + properties: + slug: + type: string + description: 路径 + title: + type: string + default: 无标题 + description: 标题 + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |+ + 公开性 + (0:私密, 1:公开, 2:企业内公开) + + + - 不填则继承知识库的公开性 + + format: + type: string + enum: + - markdown + - html + - lake + default: markdown + description: |- + 内容格式 + (markdown:Markdown 格式, html:HTML 标准格式, lake:语雀 Lake 格式) + body: + type: string + description: 正文内容 + required: + - body + ApiV2DocUpdatePut: + content: + application/json: + schema: + type: object + properties: + slug: + type: string + description: 路径 + title: + type: string + description: 标题 + public: + type: integer + enum: + - 0 + - 1 + - 2 + default: 0 + description: |- + 公开性 + (0:私密, 1:公开, 2:企业内公开) + format: + type: string + enum: + - markdown + - html + - lake + default: markdown + description: |- + 内容格式 + (markdown:Markdown 格式, html:HTML 标准格式, lake:语雀 Lake 格式) + body: + type: string + description: 正文内容 + ApiV2RepoTocUpdateByIdPut: + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: + - appendNode + - prependNode + - editNode + - removeNode + description: >+ + 操作 + + (appendNode:尾插, prependNode:头插, editNode:编辑节点, + removeNode:删除节点) + + + + - action_mode 必填 + + - 创建场景下不支持同级头插 prependNode + + - 删除节点不会删除关联文档 + + - 删除节点时: action_mode=sibling (删除当前节点), action_mode=child + (删除当前节点及子节点) + + action_mode: + type: string + enum: + - sibling + - child + description: |- + 操作模式 + (sibling:同级, child:子级) + target_uuid: + type: string + description: |- + 目标节点 UUID, 不填默认为根节点 + + + - 获取方式: 调用"获取知识库目录"接口获取 + node_uuid: + type: string + description: |- + 操作节点 UUID [移动/更新/删除必填] + + + - 获取方式: 调用"获取知识库目录"接口获取 + doc_id: + type: integer + description: 文档 ID [创建文档必填] + deprecated: true + doc_ids: + type: array + items: + type: integer + description: 文档 ID 数组 [创建文档必填] + type: + type: string + enum: + - DOC + - LINK + - TITLE + default: DOC + description: |- + 节点类型 [创建必填] + (DOC:文档, LINK:外链, TITLE:分组) + title: + type: string + description: 节点名称 [创建分组/外链必填] + url: + type: string + description: 节点 URL [创建外链必填] + open_window: + type: integer + enum: + - 0 + - 1 + default: 0 + description: |- + 是否新窗口打开 [外链选填] + (0:当前页打开, 1:新窗口打开) + visible: + type: integer + enum: + - 0 + - 1 + default: 1 + description: |- + 是否可见 + (0:不可见, 1:可见) + required: + - action + ApiV2RepoTocUpdatePut: + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: + - appendNode + - prependNode + - editNode + - removeNode + description: >+ + 操作 + + (appendNode:尾插, prependNode:头插, editNode:编辑节点, + removeNode:删除节点) + + + + - action_mode 必填 + + - 创建场景下不支持同级头插 prependNode + + - 删除节点不会删除关联文档 + + - 删除节点时: action_mode=sibling (删除当前节点), action_mode=child + (删除当前节点及子节点) + + action_mode: + type: string + enum: + - sibling + - child + description: |- + 操作模式 + (sibling:同级, child:子级) + target_uuid: + type: string + description: |- + 目标节点 UUID, 不填默认为根节点 + + + - 获取方式: 调用"获取知识库目录"接口获取 + node_uuid: + type: string + description: |- + 操作节点 UUID [移动/更新/删除必填] + + + - 获取方式: 调用"获取知识库目录"接口获取 + doc_id: + type: integer + description: 文档 ID [创建文档必填] + deprecated: true + doc_ids: + type: array + items: + type: integer + description: 文档 ID 数组 [创建文档必填] + type: + type: string + enum: + - DOC + - LINK + - TITLE + default: DOC + description: |- + 节点类型 [创建必填] + (DOC:文档, LINK:外链, TITLE:分组) + title: + type: string + description: 节点名称 [创建分组/外链必填] + url: + type: string + description: 节点 URL [创建外链必填] + open_window: + type: integer + enum: + - 0 + - 1 + default: 0 + description: |- + 是否新窗口打开 [外链选填] + (0:当前页打开, 1:新窗口打开) + visible: + type: integer + enum: + - 0 + - 1 + default: 1 + description: |- + 是否可见 + (0:不可见, 1:可见) + required: + - action + ApiV2RepoCreateByGroupPost: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: 名称 + slug: + type: string + description: 路径 + description: + type: string + description: 简介 + public: + type: integer + enum: + - 0 + - 1 + - 2 + default: 0 + description: |- + 公开性 + (0:私密, 1:公开, 2:企业内公开) + enhancedPrivacy: + type: boolean + description: |- + 增强私密性 + - 将除团队管理员之外的团队成员、团队只读成员也设置为无权限 + required: + - name + - slug + ApiV2RepoCreatePost: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: 名称 + slug: + type: string + description: 路径 + description: + type: string + description: 简介 + public: + type: integer + enum: + - 0 + - 1 + - 2 + default: 0 + description: |- + 公开性 + (0:私密, 1:公开, 2:企业内公开) + enhancedPrivacy: + type: boolean + description: |- + 增强私密性 + - 将除团队管理员之外的团队成员、团队只读成员也设置为无权限 + required: + - name + - slug + ApiV2RepoUpdateByIdPut: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: 名称 + slug: + type: string + description: 路径 + description: + type: string + description: 简介 + public: + type: integer + enum: + - 0 + - 1 + - 2 + default: 0 + description: |- + 公开性 + (0:私密, 1:公开, 2:企业内公开) + toc: + type: string + description: |+ + 目录 + + + - 可利用此字段批量更新知识库的目录 + + + - 必须是 Markdown 格式, `[名称](文档路径)` 示例: + ```markdown + - [新手指引]() + - [语雀是什么](about) + - [常见问题](faq) + - [基础功能]() + - [工作台](dashboard) + - [如何设置自定义路径](nkt888) + - [外链](http://www.alipay.com) + ``` + + ApiV2RepoUpdatePut: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: 名称 + slug: + type: string + description: 路径 + description: + type: string + description: 简介 + public: + type: integer + enum: + - 0 + - 1 + - 2 + default: 0 + description: |- + 公开性 + (0:私密, 1:公开, 2:企业内公开) + toc: + type: string + description: |+ + 目录 + + + - 可利用此字段批量更新知识库的目录 + + + - 必须是 Markdown 格式, `[名称](文档路径)` 示例: + ```markdown + - [新手指引]() + - [语雀是什么](about) + - [常见问题](faq) + - [基础功能]() + - [工作台](dashboard) + - [如何设置自定义路径](nkt888) + - [外链](http://www.alipay.com) + ``` + + schemas: + V2User: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 用户 ID + type: + type: string + description: |- + + 类型 + Always 'User' + deprecated: true + login: + type: string + description: |- + + 登录名 + name: + type: string + description: |- + + 昵称 + avatar_url: + type: string + description: |- + + 头像 + books_count: + type: integer + description: |- + + 知识库数量 + public_books_count: + type: integer + description: |- + + 公开的知识库数量 + followers_count: + type: integer + description: |- + + 被关注的人数 + following_count: + type: integer + description: |- + + 关注的人数 + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + + 公开性 + (0:私密, 1:公开, 2:企业内公开) + description: + type: string + description: |- + + 介绍 + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + V2SearchResult: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + ID + type: + type: string + enum: + - doc + - repo + description: |- + + 类型 + (doc:文档, repo:知识库) + title: + type: string + description: |- + + 标题 + `` 高亮关键词 + summary: + type: string + description: |- + + 摘要 + `` 高亮关键词 + url: + type: string + description: |- + + 访问路径 + info: + type: string + description: |- + + 归属信息 + target: + oneOf: + - $ref: '#/components/schemas/V2Doc' + - $ref: '#/components/schemas/V2Book' + V2Doc: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 文档 ID + type: + type: string + enum: + - Doc + - Sheet + - Thread + - Board + - Table + - HtmlDoc + description: >- + + 文档类型 + + (Doc:普通文档, Sheet:表格, Thread:话题, Board:图集, Table:数据表, HtmlDoc:HTML + 文档) + slug: + type: string + description: |- + + 路径 + title: + type: string + description: |- + + 标题 + description: + type: string + description: |- + + 摘要 + cover: + type: string + description: |- + + 封面 + user_id: + type: integer + format: int64 + description: |- + + 归属用户/团队 ID + book_id: + type: integer + format: int64 + description: |- + + 归属知识库 ID + last_editor_id: + type: integer + format: int64 + description: |- + + 最后编辑者 ID + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + + 公开性 + (0:私密, 1:公开, 2:企业内公开) + status: + type: string + description: |- + + 状态 + (0:草稿, 1:发布) + likes_count: + type: integer + description: |- + + 点赞数 + read_count: + type: integer + description: |+ + + 阅读数 + + + - 需要传入 `optional_properties=hits` 获取 + + hits: + type: integer + description: |+ + + 阅读数 + + + - 需要传入 `optional_properties=hits` 获取 + + deprecated: true + comments_count: + type: integer + description: |- + + 评论数 + word_count: + type: integer + description: |- + + 内容字数 + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + content_updated_at: + type: string + format: date-time + description: |- + + 内容更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + deleted_at: + type: string + format: date-time + description: |- + + 删除时间 + 仅在已删除文档视图下返回 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + deleted_slug: + type: string + description: |- + + 删除前路径 + 仅在已删除文档视图下返回 + published_at: + type: string + format: date-time + description: |- + + 发布时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + first_published_at: + type: string + format: date-time + description: |- + + 首次发布时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + book: + $ref: '#/components/schemas/V2Book' + user: + $ref: '#/components/schemas/V2User' + last_editor: + $ref: '#/components/schemas/V2User' + latest_version_id: + type: integer + format: int64 + description: |+ + + 最新已发版本 ID + + + - 需要传入 `optional_properties=latest_version_id` 获取 + + tags: + $ref: '#/components/schemas/V2Tag' + V2Book: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 知识库 ID + type: + type: string + description: |- + + 类型 + (Book:文档, Design:图集, Sheet:表格, Resource:资源) + slug: + type: string + description: |- + + 路径 + name: + type: string + description: |- + + 名称 + user_id: + type: integer + format: int64 + description: |- + + 归属用户/团队 ID + description: + type: string + description: |- + + 简介 + creator_id: + type: integer + format: int64 + description: |- + + 创建者 ID + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + + 公开性 + (0:私密, 1:公开, 2:企业内公开) + items_count: + type: integer + description: |- + + 文档数量 + likes_count: + type: integer + description: |- + + 点赞数量 + watches_count: + type: integer + description: |- + + 订阅数量 + content_updated_at: + type: string + format: date-time + description: |- + + 知识库 META 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + user: + $ref: '#/components/schemas/V2User' + namespace: + type: string + description: |- + + 完整路径 + V2Tag: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + TAG ID + title: + type: string + description: |- + + TAG NAME + doc_id: + type: integer + format: int64 + description: |- + + 文档 ID + book_id: + type: integer + format: int64 + description: |- + + 知识库 ID + user_id: + type: integer + format: int64 + description: |- + + 创建者 ID + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + V2Group: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 团队 ID + type: + type: string + description: |- + + 类型 + Always 'Group' + deprecated: true + login: + type: string + description: |- + + 路径 + name: + type: string + description: |- + + 名称 + avatar_url: + type: string + description: |- + + 头像 + books_count: + type: integer + description: |- + + 知识库数量 + public_books_count: + type: integer + description: |- + + 公开的知识库数量 + members_count: + type: integer + description: |- + + 成员人数 + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + + 公开性 + (0:私密, 1:公开, 2:企业内公开) + description: + type: string + description: |- + + 介绍 + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + V2GroupUser: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + ID + group_id: + type: integer + format: int64 + description: |- + + 团队 ID + user_id: + type: integer + format: int64 + description: |- + + 成员 ID + role: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + + 成员角色 + (0:管理员, 1:成员, 2:只读成员) + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + group: + $ref: '#/components/schemas/V2Group' + user: + $ref: '#/components/schemas/V2User' + V2DocDetail: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 文档 ID + type: + type: string + enum: + - Doc + - Sheet + - Thread + - Board + - Table + - HtmlDoc + description: >- + + 文档类型 + + (Doc:普通文档, Sheet:表格, Thread:话题, Board:图集, Table:数据表, HtmlDoc:HTML + 文档) + slug: + type: string + description: |- + + 路径 + title: + type: string + description: |- + + 标题 + description: + type: string + description: |- + + 摘要 + cover: + type: string + description: |- + + 封面 + user_id: + type: integer + format: int64 + description: |- + + 归属用户/团队 ID + book_id: + type: integer + format: int64 + description: |- + + 归属知识库 ID + last_editor_id: + type: integer + format: int64 + description: |- + + 最后编辑者 ID + format: + type: string + enum: + - markdown + - lake + - html + - lakesheet + description: >- + + 内容格式 + + (markdown:Markdown 格式, lake:语雀 Lake 格式, html:HTML 标准格式, + lakesheet:语雀表格) + body_draft: + type: string + description: |- + + 正文草稿内容 + body: + type: string + description: |- + + 正文原始内容 + body_sheet: + type: string + description: |+ + + 表格正文内容 + + + 用本字段读取表格内容, 在文档为表格 (sheet) 类型时会返回。 + + + 语雀表格 (sheet) 正文格式示例如下, JSON 反序列化后的结构: + (注意: 所有项的值均为字符串, 公式项为计算后的值, 日期格式: yyyy-mm-dd HH:MM:SS) + + + ```json + { + "version": "1.0", + "data": [ + { + "name": "Sheet1", + "index": 0, + "rowCount": 100, + "colCount": 4, + "table": [ + ["参数名", "类型", "必填", "默认值"], + ["name", "string", "1", ""], + ["flag", "boolean", "0", "false"] + ] + }, + { + "name": "Sheet2", + "index": 0, + "rowCount": 100, + "colCount": 8, + "table": [] + } + ] + } + ``` + + body_table: + type: string + description: |- + + 数据表正文内容, 用本字段读取数据表内容, 在文档为数据表 (Table) 类型时会返回。 + + + ```json + { + totalCount: 1000, // 总行数 + records: [{ + { + values为数组,顺序按meta中的columns顺序排列 + values:[{ + 复选框,值为true/false + "value": "true" + },{ + 多选框,对应选中的options + "value": [ + "AUb7o2", + "rkGdKP" + ] + },{ + 评分:0-5 + "value": "4" + }, { + 进度:0-100 + "value": "34" + }, { + 文件 + "value": [ + { + "name": "emails.csv", + "uid": "rc-upload-1722830344900-3", + "src": "https://host/xx.csv", + "size": 148, + "fileKey": "sheet" + } + ] + }, { + "value": "文本" + }, { + 单选 + "value": "waiting" + }, { + 日期 + "value": { + "seconds": 3932799476, + "text": "2024-08-14", + "time": "2024-08-14T04:12:13.318Z" + } + }, { + 用户 + "value": [ + { + "id": 1, + "name": "txy", + "login": "u1", + "avatar_url": "https://host/xx_url", + "work_id": "", + "description": null + } + ] + }, { + 图片 + "value": [ + { + "name": "test.png", + "uid": "rc-upload-1722831237360-3", + "src": "https://host/xx.png", + "size": 19154, + "width": 90, + "height": 88 + } + ] + }], + "createdAt": "2024-08-02T10:14:13.368Z", + "updatedAt": "2024-08-05T11:56:01.102Z", + } + }], + pageSize: 100, + page: 1, + } + ``` + body_html: + type: string + description: |- + + 正文 HTML 标准格式内容 + body_lake: + type: string + description: |- + + 正文语雀 Lake 格式内容 + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + + 公开性 + (0:私密, 1:公开, 2:企业内公开) + status: + type: string + description: |- + + 状态 + (0:草稿, 1:发布) + likes_count: + type: integer + description: |- + + 点赞数 + read_count: + type: integer + description: |+ + + 阅读数 + + hits: + type: integer + description: |+ + + 阅读数 + + deprecated: true + comments_count: + type: integer + description: |- + + 评论数 + word_count: + type: integer + description: |- + + 内容字数 + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + content_updated_at: + type: string + format: date-time + description: |- + + 内容更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + published_at: + type: string + format: date-time + description: |- + + 发布时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + first_published_at: + type: string + format: date-time + description: |- + + 首次发布时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + book: + $ref: '#/components/schemas/V2Book' + user: + $ref: '#/components/schemas/V2User' + creator: + $ref: '#/components/schemas/V2User' + tags: + $ref: '#/components/schemas/V2Tag' + latest_version_id: + type: integer + format: int64 + description: |+ + + 最新已发版本 ID + + V2DocVersion: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 版本 ID + doc_id: + type: integer + format: int64 + description: |- + + 文档 ID + slug: + type: string + description: |- + + 文档路径 + title: + type: string + description: |- + + 文档标题 + user_id: + type: integer + format: int64 + description: |- + + 发版人 ID + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + user: + $ref: '#/components/schemas/V2User' + V2DocVersionDetail: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 版本 ID + doc_id: + type: integer + format: int64 + description: |- + + 文档 ID + slug: + type: string + description: |- + + 文档路径 + title: + type: string + description: |- + + 文档标题 + user_id: + type: integer + format: int64 + description: |- + + 发版人 ID + format: + type: string + enum: + - markdown + - lake + - html + - lakesheet + description: >- + + 内容格式 + + (markdown:Markdown 格式, lake:语雀 Lake 格式, html:HTML 标准格式, + lakesheet:语雀表格) + body: + type: string + description: |- + + 正文原始内容 + body_html: + type: string + description: |- + + 正文 HTML 标准格式内容 + body_md: + type: string + description: |- + + 正文 MD 标准格式内容 + body_asl: + type: string + description: |- + + 正文语雀 Lake 格式内容 + diff: + type: string + description: |- + + 版本 DIFF + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + user: + $ref: '#/components/schemas/V2User' + V2TocItem: + type: object + properties: + uuid: + type: string + description: |- + + 节点唯一 ID + type: + type: string + enum: + - DOC + - LINK + - TITLE + description: |- + + 节点类型 + (DOC:文档, LINK:外链, TITLE:分组) + title: + type: string + description: |- + + 节点名称 + url: + type: string + description: |- + + 节点 URL + slug: + type: string + description: |- + + 节点 URL + deprecated: true + id: + type: integer + format: int64 + description: |- + + 文档 ID + deprecated: true + doc_id: + type: integer + format: int64 + description: |- + + 文档 ID + level: + type: integer + description: |- + + 节点层级 + depth: + type: integer + description: |- + + 节点层级 + deprecated: true + open_window: + type: integer + enum: + - 0 + - 1 + description: |- + + 是否在新窗口打开 + (0:当前页打开, 1:新窗口打开) + visible: + type: integer + enum: + - 0 + - 1 + description: |- + + 是否可见 + (0:不可见, 1:可见) + prev_uuid: + type: string + description: |- + + 同级前一个节点 uuid + sibling_uuid: + type: string + description: |- + + 同级后一个节点 uuid + child_uuid: + type: string + description: |- + + 子级第一个节点 uuid + parent_uuid: + type: string + description: |- + + 父级节点 uuid + V2BookDetail: + type: object + properties: + id: + type: integer + format: int64 + description: |- + + 知识库 ID + type: + type: string + description: |- + + 类型 + (Book:文档, Design:图集, Sheet:表格, Resource:资源) + slug: + type: string + description: |- + + 路径 + name: + type: string + description: |- + + 名称 + user_id: + type: integer + format: int64 + description: |- + + 归属用户/团队 ID + description: + type: string + description: |- + + 简介 + toc_yml: + type: string + description: |- + + 目录 + creator_id: + type: integer + format: int64 + description: |- + + 创建者 ID + public: + type: integer + enum: + - 0 + - 1 + - 2 + description: |- + + 公开性 + (0:私密, 1:公开, 2:企业内公开) + items_count: + type: integer + description: |- + + 文档数量 + likes_count: + type: integer + description: |- + + 点赞数量 + watches_count: + type: integer + description: |- + + 订阅数量 + content_updated_at: + type: string + format: date-time + description: |- + + 知识库 META 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + created_at: + type: string + format: date-time + description: |- + + 创建时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + updated_at: + type: string + format: date-time + description: |- + + 更新时间 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + user: + $ref: '#/components/schemas/V2User' + namespace: + type: string + description: |- + + 完整路径 + V2GroupStatistics: + type: object + properties: + bizdate: + type: string + description: '' + user_id: + type: string + description: 统计日期 (YYYYMMDD) + organization_id: + type: string + description: 团队 ID + member_count: + type: string + description: 归属空间 ID + collaborator_count: + type: string + description: 成员数 + day_read_count: + type: string + description: 协作者数 + day_write_count: + type: string + description: 当日阅读量 + write_count: + type: string + description: 当日编辑次数 + read_count: + type: string + description: 编辑次数 + read_count_30: + type: string + description: 阅读量 + read_count_365: + type: string + description: 阅读量 (30天) + comment_count: + type: string + description: 阅读量 (一年) + comment_count_30: + type: string + description: 评论量 + comment_count_365: + type: string + description: 评论量 (30天) + like_count: + type: string + description: 评论量 (一年) + like_count_30: + type: string + description: 点赞量 + like_count_365: + type: string + description: 点赞量 (30天) + follow_count: + type: string + description: 点赞量 (一年) + collect_count: + type: string + description: 关注量 + doc_count: + type: string + description: 收藏量 + sheet_count: + type: string + description: 文档数量 + board_count: + type: string + description: 表格数量 + show_count: + type: string + description: 画板数量 + resource_count: + type: string + description: 演示文稿数量 + artboard_count: + type: string + description: 资源数量 + attachment_count: + type: string + description: 图集数量 + book_count: + type: string + description: 附件数量 + public_book_count: + type: string + description: 知识库总数量 + private_book_count: + type: string + description: 公开知识库数量 + book_book_count: + type: string + description: 私密知识库数量 + book_resource_count: + type: string + description: 文档知识库数量 + book_design_count: + type: string + description: 资源知识库数量 + book_thread_count: + type: string + description: 图片知识库数量 + data_usage: + type: string + description: 话题知识库数量 + grains_count: + type: string + description: 流量使用量 + grains_count_sum: + type: string + description: 当前稻谷数 + grains_count_consume: + type: string + description: 累计获得稻谷数 + interaction_people_count: + type: string + description: 已消耗稻谷数 + content_count: + type: string + description: 知识交流人数 + collaboration_count: + type: string + description: 知识财富数 + working_hours: + type: string + description: 知识协同次数 + baike: + type: string + description: 协同提效时长 (小时) + table_count: + type: string + description: 百科全书卷数 + V2MemberStatistics: + type: object + properties: + bizdate: + type: string + description: '' + user_id: + type: string + description: 统计日期 (YYYYMMDD) + group_id: + type: string + description: 成员 ID + organization_id: + type: string + description: 团队 ID + write_count: + type: string + description: 空间 ID + write_count_30: + type: string + description: 编辑次数 + write_count_365: + type: string + description: 编辑次数 (30天) + write_doc_count: + type: string + description: 编辑次数 (一年) + write_doc_count_30: + type: string + description: 编辑文档数 + write_doc_count_365: + type: string + description: 编辑文档数 (30天) + read_count: + type: string + description: 编辑文档数 (一年) + read_count_30: + type: string + description: 阅读量 + read_count_365: + type: string + description: 阅读量 (30天) + like_count: + type: string + description: 阅读量 (一年) + like_count_30: + type: string + description: 点赞量 + like_count_365: + type: string + description: 点赞量 (30天) + user: + type: string + description: 点赞量 (一年) + V2BookStatistics: + type: object + properties: + bizdate: + type: string + description: '' + book_id: + type: string + description: 统计日期 (YYYYMMDD) + slug: + type: string + description: 知识库 ID + name: + type: string + description: 知识库 slug + type: + type: string + description: 知识库名称 + is_public: + type: string + description: 知识库类型 + content_updated_at_ms: + type: string + description: 是否公开 + user_id: + type: string + description: 最近更新时间 + organization_id: + type: string + description: 知识库归属 ID + day_read_count: + type: string + description: 知识库归属空间 ID + day_write_count: + type: string + description: 当日阅读量 + day_like_count: + type: string + description: 当日编辑次数 + post_count: + type: string + description: 当日点赞量 + word_count: + type: string + description: 文档数 + write_count: + type: string + description: 字数 + write_count_30: + type: string + description: 编辑次数 + read_count: + type: string + description: 编辑次数 (30天) + read_count_30: + type: string + description: 阅读量 + read_count_365: + type: string + description: 阅读量 (30天) + like_count: + type: string + description: 阅读量 (一年) + like_count_7: + type: string + description: 点赞量 + like_count_30: + type: string + description: 点赞量 (7天) + like_count_365: + type: string + description: 点赞量 (30天) + watch_count: + type: string + description: 点赞量 (一年) + watch_count_7: + type: string + description: 关注量 + watch_count_30: + type: string + description: 关注量 (7天) + watch_count_365: + type: string + description: 关注量 (30天) + comment_count: + type: string + description: 关注量 (一年) + comment_count_30: + type: string + description: 评论量 + comment_count_365: + type: string + description: 评论量 (30天) + like_rank_rate: + type: string + description: 评论量 (一年) + popularity_30: + type: string + description: 知识库点赞数排名 + doc_count: + type: string + description: 30 天热度 + sheet_count: + type: string + description: 文档数量 + board_count: + type: string + description: 表格数量 + show_count: + type: string + description: 画板数量 + resource_count: + type: string + description: 演示文稿数量 + artboard_count: + type: string + description: 资源数量 + attachment_count: + type: string + description: 图集数量 + interaction_people_count: + type: string + description: 附件数量 + content_count: + type: string + description: 知识交流人数 + collaboration_count: + type: string + description: 知识财富数 + working_hours: + type: string + description: 知识协同次数 + baike: + type: string + description: 协同提效时长 (小时) + table_count: + type: string + description: 百科全书卷数 + V2DocStatistics: + type: object + properties: + bizdate: + type: string + description: '' + book_id: + type: string + description: 统计日期 (YYYYMMDD) + doc_id: + type: string + description: 知识库 ID + slug: + type: string + description: 文档 ID + title: + type: string + description: 文档 slug + type: + type: string + description: 文档标题 + is_public: + type: string + description: 知识库类型 + created_at: + type: string + description: 是否公开 + content_updated_at: + type: string + description: 创建时间 + user_id: + type: string + description: 最近更新时间 + organization_id: + type: string + description: 文档归属 ID + day_read_count: + type: string + description: 文档归属空间 ID + day_write_count: + type: string + description: 当日阅读量 + day_like_count: + type: string + description: 当日编辑次数 + word_count: + type: string + description: 当日点赞量 + write_count: + type: string + description: 字数 + read_count: + type: string + description: 编辑次数 + read_count_7: + type: string + description: 阅读量 + read_count_30: + type: string + description: 阅读量 (7天) + read_count_365: + type: string + description: 阅读量 (30天) + like_count: + type: string + description: 阅读量 (一年) + like_count_7: + type: string + description: 点赞量 + like_count_30: + type: string + description: 点赞量 (7天) + like_count_365: + type: string + description: 点赞量 (30天) + comment_count: + type: string + description: 点赞量 (一年) + comment_count_30: + type: string + description: 评论量 + comment_count_365: + type: string + description: 评论量 (30天) + popularity_30: + type: string + description: 评论量 (一年) + attachment_count: + type: string + description: 30 天热度 + user: + type: string + description: 附件数量 +security: + - authToken: [] +paths: + /api/v2/hello: + get: + tags: + - user + summary: 心跳 + operationId: user_api_v2_hello + description: |- + 心跳 + GET /api/v2/hello + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + message: + type: string + description: 欢迎消息 + '400': &ref_0 + $ref: '#/components/responses/400' + '401': &ref_1 + $ref: '#/components/responses/401' + '403': &ref_2 + $ref: '#/components/responses/403' + '404': &ref_3 + $ref: '#/components/responses/404' + '422': &ref_4 + $ref: '#/components/responses/422' + '429': &ref_5 + $ref: '#/components/responses/429' + '500': &ref_6 + $ref: '#/components/responses/500' + /api/v2/user: + get: + tags: + - user + summary: 获取当前 Token 的用户详情 + operationId: user_api_v2_user_info + description: |+ + 获取当前 Token 的用户详情 + GET /api/v2/user + + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2User' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/search: + get: + tags: + - search + summary: 通用搜索 + operationId: search_api_v2_search + description: |+ + 通用搜索 + GET /api/v2/search + + + - 支持分页, PageSize 固定为 20 + + parameters: + - name: q + in: query + description: 搜索关键词 + required: true + deprecated: false + schema: + type: string + maxLength: 200 + - name: type + in: query + description: |- + 搜索类型 + (doc:文档, repo:知识库) + required: true + deprecated: false + schema: + type: string + enum: + - doc + - repo + - name: scope + in: query + description: |- + 搜索范围, 不填默认为搜索当前用户/团队 + + + [例子] + ``` + - 假设: + - 团队 URL = https://yuque_domain/group_a + - 知识库 URL = https://yuque_domain/group_a/book_x + - 则: + - 搜索团队里的文档: { type: 'doc', scope: 'group_a' } + - 搜索团队里的知识库: { type: 'repo', scope: 'group_a' } + - 搜索知识库里的文档: { type: 'doc', scope: 'group_a/book_x' } + ``` + required: false + deprecated: false + schema: + type: string + maxLength: 400 + - name: page + in: query + description: 页码 [分页参数] + required: false + deprecated: false + schema: + type: integer + minimum: 1 + maximum: 100 + - name: offset + in: query + description: 页码, 非偏移量 [分页参数] + required: false + deprecated: true + schema: + type: integer + minimum: 1 + maximum: 100 + - name: creatorId + in: query + description: 仅搜索指定作者 ID [筛选条件] + required: false + deprecated: true + schema: + type: integer + - name: creator + in: query + description: 仅搜索指定作者 login [筛选条件] + required: false + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + meta: + type: object + properties: + total: + type: integer + description: 结果总量 + pageNo: + type: integer + description: 页码 + pageSize: + type: integer + description: 每页数量 + data: + type: array + items: + $ref: '#/components/schemas/V2SearchResult' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/users/{id}/groups: + get: + tags: + - user + summary: 获取用户的团队 + operationId: user_api_v2_user_group_list + description: |+ + 获取用户的团队 + GET /api/v2/users/:id/groups + + + - 支持分页, PageSize 固定为 100 + + parameters: + - name: id + in: path + description: 用户 login 或 ID + required: true + deprecated: false + schema: + type: string + - name: role + in: query + description: |- + 角色 [过滤条件] + (0:管理员, 1:成员) + required: false + deprecated: false + schema: + type: integer + enum: + - 0 + - 1 + - name: offset + in: query + description: 偏移量 [分页条件] + required: false + deprecated: false + schema: + type: integer + default: 0 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2Group' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/groups/{login}/users: + get: + tags: + - group + summary: 获取团队的成员 + operationId: group_api_v2_group_member_list + description: |+ + 获取团队的成员 + GET /api/v2/groups/:login/users + + + - 支持分页, PageSize 固定为 100 + + parameters: + - name: login + in: path + description: 团队 Login or ID + required: true + deprecated: false + schema: + type: string + - name: role + in: query + description: |- + 角色 [筛选条件] + (0:管理员, 1:成员, 2:只读成员) + required: false + deprecated: false + schema: + type: integer + enum: + - 0 + - 1 + - 2 + - name: offset + in: query + description: 偏移量 [分页条件] + required: false + deprecated: false + schema: + type: integer + default: 0 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2GroupUser' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/groups/{login}/users/{id}: + put: + tags: + - group + summary: 变更成员 + operationId: group_api_v2_group_member_update + description: |+ + 变更成员 + PUT /api/v2/groups/:login/users/:id + + parameters: + - name: login + in: path + description: 团队 Login or ID + required: true + deprecated: false + schema: + type: string + - name: id + in: path + description: 用户 Login or ID + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2GroupMemberUpdatePut' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2GroupUser' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + delete: + tags: + - group + summary: 删除成员 + operationId: group_api_v2_group_member_destroy + description: |- + 删除成员 + DELETE /api/v2/groups/:login/users/:id + parameters: + - name: login + in: path + description: 团队 Login or ID + required: true + deprecated: false + schema: + type: string + - name: id + in: path + description: 用户 Login or ID + required: true + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + user_id: + type: string + description: 删除用户 ID + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{book_id}/docs: + get: + tags: + - doc + summary: 获取知识库的文档列表 + operationId: doc_api_v2_doc_list-by_id + description: |+ + 获取知识库的文档列表 + GET /api/v2/repos/:book_id/docs + GET /api/v2/repos/:group_login/:book_slug/docs + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + - name: offset + in: query + description: 偏移量 [分页参数] + required: false + deprecated: false + schema: + type: integer + default: 0 + - name: limit + in: query + description: 每页数量 [分页参数] + required: false + deprecated: false + schema: + type: integer + maximum: 100 + default: 100 + - name: deleted + in: query + description: 是否查询已删除文档 + required: false + deprecated: false + schema: + type: boolean + default: false + - name: changed_at_gte + in: query + description: |- + 变更时间下界 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + required: false + deprecated: false + schema: + type: string + format: date-time + - name: optional_properties + in: query + description: |+ + 获取的额外字段, 多个字段以逗号分隔 + + + - 注意: 每页数量超过 100 本字段会失效 + + + - 支持的字段有: + - hits: 文档阅读数 + - tags: 标签 + - latest_version_id: 最新已发版本 ID + + required: false + deprecated: false + schema: + type: string + default: '' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + meta: + type: object + properties: + total: + type: integer + description: 结果总量 + data: + type: array + items: + $ref: '#/components/schemas/V2Doc' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + post: + tags: + - doc + summary: 创建文档 + operationId: doc_api_v2_doc_create-by_id + description: |+ + 创建文档 + POST /api/v2/repos/:book_id/docs + POST /api/v2/repos/:group_login/:book_slug/docs + + + - 注意: 创建文档后不会自动添加到目录,需要调用"知识库目录更新接口"更新到目录中 + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + requestBody: + $ref: '#/components/requestBodies/ApiV2DocCreateByIdPost' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/docs/{id}: + get: + tags: + - doc + summary: 获取文档详情 + operationId: doc_api_v2_doc_show-by_id + description: |+ + 获取文档详情 + GET /api/v2/repos/docs/:id + GET /api/v2/repos/:book_id/docs/:id + GET /api/v2/repos/:group_login/:book_slug/docs/:id + + parameters: + - name: id + in: path + description: 文档 ID or 路径 + required: true + deprecated: false + schema: + type: string + - name: page_size + in: query + description: 数据表使用,分页大小 + required: false + deprecated: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - name: page + in: query + description: 数据表使用,页码 + required: false + deprecated: false + schema: + type: integer + minimum: 1 + default: 1 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{book_id}/docs/{id}: + get: + tags: + - doc + summary: 获取文档详情 + operationId: doc_api_v2_doc_show-by_book_and_id + description: |+ + 获取文档详情 + GET /api/v2/repos/docs/:id + GET /api/v2/repos/:book_id/docs/:id + GET /api/v2/repos/:group_login/:book_slug/docs/:id + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + - name: id + in: path + description: 文档 ID or 路径 + required: true + deprecated: false + schema: + type: string + - name: page_size + in: query + description: 数据表使用,分页大小 + required: false + deprecated: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - name: page + in: query + description: 数据表使用,页码 + required: false + deprecated: false + schema: + type: integer + minimum: 1 + default: 1 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - doc + summary: 更新文档 + operationId: doc_api_v2_doc_update-by_id + description: |+ + 更新文档 + PUT /api/v2/repos/:book_id/docs/:id + PUT /api/v2/repos/:group_login/:book_slug/docs/:id + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + - name: id + in: path + description: 文档 ID or 路径 + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2DocUpdateByIdPut' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + delete: + tags: + - doc + summary: 删除文档 + operationId: doc_api_v2_doc_destroy-by_id + description: |+ + 删除文档 + DELETE /api/v2/repos/:book_id/docs/:id + DELETE /api/v2/repos/:group_login/:book_slug/docs/:id + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + - name: id + in: path + description: 文档 ID or 路径 + required: true + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{group_login}/{book_slug}/docs: + get: + tags: + - doc + summary: 获取知识库的文档列表 + operationId: doc_api_v2_doc_list + description: |+ + 获取知识库的文档列表 + GET /api/v2/repos/:book_id/docs + GET /api/v2/repos/:group_login/:book_slug/docs + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + - name: offset + in: query + description: 偏移量 [分页参数] + required: false + deprecated: false + schema: + type: integer + default: 0 + - name: limit + in: query + description: 每页数量 [分页参数] + required: false + deprecated: false + schema: + type: integer + maximum: 100 + default: 100 + - name: deleted + in: query + description: 是否查询已删除文档 + required: false + deprecated: false + schema: + type: boolean + default: false + - name: changed_at_gte + in: query + description: |- + 变更时间下界 + 格式: YYYY-MM-DDTHH:mm:ss.sssZ (ISO_8601) + required: false + deprecated: false + schema: + type: string + format: date-time + - name: optional_properties + in: query + description: |+ + 获取的额外字段, 多个字段以逗号分隔 + + + - 注意: 每页数量超过 100 本字段会失效 + + + - 支持的字段有: + - hits: 文档阅读数 + - tags: 标签 + - latest_version_id: 最新已发版本 ID + + required: false + deprecated: false + schema: + type: string + default: '' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + meta: + type: object + properties: + total: + type: integer + description: 结果总量 + data: + type: array + items: + $ref: '#/components/schemas/V2Doc' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + post: + tags: + - doc + summary: 创建文档 + operationId: doc_api_v2_doc_create + description: |+ + 创建文档 + POST /api/v2/repos/:book_id/docs + POST /api/v2/repos/:group_login/:book_slug/docs + + + - 注意: 创建文档后不会自动添加到目录,需要调用"知识库目录更新接口"更新到目录中 + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2DocCreatePost' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{group_login}/{book_slug}/docs/{id}: + get: + tags: + - doc + summary: 获取文档详情 + operationId: doc_api_v2_doc_show + description: |+ + 获取文档详情 + GET /api/v2/repos/docs/:id + GET /api/v2/repos/:book_id/docs/:id + GET /api/v2/repos/:group_login/:book_slug/docs/:id + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + - name: id + in: path + description: 文档 ID or 路径 + required: true + deprecated: false + schema: + type: string + - name: page_size + in: query + description: 数据表使用,分页大小 + required: false + deprecated: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + - name: page + in: query + description: 数据表使用,页码 + required: false + deprecated: false + schema: + type: integer + minimum: 1 + default: 1 + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - doc + summary: 更新文档 + operationId: doc_api_v2_doc_update + description: |+ + 更新文档 + PUT /api/v2/repos/:book_id/docs/:id + PUT /api/v2/repos/:group_login/:book_slug/docs/:id + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + - name: id + in: path + description: 文档 ID or 路径 + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2DocUpdatePut' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + delete: + tags: + - doc + summary: 删除文档 + operationId: doc_api_v2_doc_destroy + description: |+ + 删除文档 + DELETE /api/v2/repos/:book_id/docs/:id + DELETE /api/v2/repos/:group_login/:book_slug/docs/:id + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + - name: id + in: path + description: 文档 ID or 路径 + required: true + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/doc_versions: + get: + tags: + - doc + summary: 获取文档历史版本列表 + operationId: doc_api_v2_doc_version_list + description: |+ + 获取文档历史版本列表 + GET /api/v2/doc_versions + + + - 按时间倒序返回最近 100 个已发布版本 + + parameters: + - name: doc_id + in: query + description: 文档 ID + required: true + deprecated: false + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2DocVersion' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/doc_versions/{id}: + get: + tags: + - doc + summary: 获取文档历史版本详情 + operationId: doc_api_v2_doc_version_show + description: |+ + 获取文档历史版本详情 + GET /api/v2/doc_versions/:id + + parameters: + - name: id + in: path + description: 版本 ID + required: true + deprecated: false + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2DocVersionDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{book_id}/toc: + get: + tags: + - doc + summary: 获取目录 + operationId: doc_api_v2_repo_toc_show-by_id + description: |+ + 获取目录 + GET /api/v2/repos/:book_id/toc + GET /api/v2/repos/:group_login/:book_slug/toc + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2TocItem' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - doc + summary: 更新目录 + operationId: doc_api_v2_repo_toc_update-by_id + description: |+ + 更新目录 + PUT /api/v2/repos/:book_id/toc + PUT /api/v2/repos/:group_login/:book_slug/toc + + + 字段说明: + + + - 所有场景 + - 必填字段 + - action + - action_mode + - 选填字段 + - target_uuid + - visible + - 创建场景 + - 必填字段 + - 创建文档节点 + - type + - doc_ids + - 创建分组节点 + - type + - title + - 创建外链节点 + - type + - title + - url + - open_window + - 移动场景 + - 必填字段 + - target_uuid + - node_uuid + - 编辑场景 + - 必填字段 + - node_uuid + - 选填字段 + - type + - title + - url + - open_window + - 删除场景 + - 必填字段 + - node_uuid + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + requestBody: + $ref: '#/components/requestBodies/ApiV2RepoTocUpdateByIdPut' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2TocItem' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{group_login}/{book_slug}/toc: + get: + tags: + - doc + summary: 获取目录 + operationId: doc_api_v2_repo_toc_show + description: |+ + 获取目录 + GET /api/v2/repos/:book_id/toc + GET /api/v2/repos/:group_login/:book_slug/toc + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2TocItem' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - doc + summary: 更新目录 + operationId: doc_api_v2_repo_toc_update + description: |+ + 更新目录 + PUT /api/v2/repos/:book_id/toc + PUT /api/v2/repos/:group_login/:book_slug/toc + + + 字段说明: + + + - 所有场景 + - 必填字段 + - action + - action_mode + - 选填字段 + - target_uuid + - visible + - 创建场景 + - 必填字段 + - 创建文档节点 + - type + - doc_ids + - 创建分组节点 + - type + - title + - 创建外链节点 + - type + - title + - url + - open_window + - 移动场景 + - 必填字段 + - target_uuid + - node_uuid + - 编辑场景 + - 必填字段 + - node_uuid + - 选填字段 + - type + - title + - url + - open_window + - 删除场景 + - 必填字段 + - node_uuid + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2RepoTocUpdatePut' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2TocItem' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/groups/{login}/repos: + get: + tags: + - repo + summary: 获取知识库列表 + operationId: repo_api_v2_repo_list-by_group + description: |+ + 获取知识库列表 + GET /api/v2/groups/:id/repos + GET /api/v2/groups/:login/repos + + + GET /api/v2/users/:id/repos + GET /api/v2/users/:login/repos + + parameters: + - name: login + in: path + description: 用户/团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + - name: offset + in: query + description: 偏移量 [分页参数] + required: false + deprecated: false + schema: + type: integer + default: 0 + - name: limit + in: query + description: 每页数量 [分页参数] + required: false + deprecated: false + schema: + type: integer + maximum: 100 + default: 100 + - name: type + in: query + description: |- + 类型 [筛选条件] + (Book:文档型知识库, Design: 画板型知识库) + required: false + deprecated: false + schema: + type: string + enum: + - Book + - Design + - name: filterByAbility + in: query + description: |- + 操作权限 [筛选条件] 默认为空 + (create_doc:仅返回具有创建文档权限的知识库) + required: false + deprecated: false + schema: + type: string + default: '' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + post: + tags: + - repo + summary: 创建知识库 + operationId: repo_api_v2_repo_create-by_group + description: |+ + 创建知识库 + POST /api/v2/groups/:id/repos + POST /api/v2/groups/:login/repos + + + POST /api/v2/users/:id/repos + POST /api/v2/users/:login/repos + + parameters: + - name: login + in: path + description: 用户/团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2RepoCreateByGroupPost' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/users/{login}/repos: + get: + tags: + - repo + summary: 获取知识库列表 + operationId: repo_api_v2_repo_list + description: |+ + 获取知识库列表 + GET /api/v2/groups/:id/repos + GET /api/v2/groups/:login/repos + + + GET /api/v2/users/:id/repos + GET /api/v2/users/:login/repos + + parameters: + - name: login + in: path + description: 用户/团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + - name: offset + in: query + description: 偏移量 [分页参数] + required: false + deprecated: false + schema: + type: integer + default: 0 + - name: limit + in: query + description: 每页数量 [分页参数] + required: false + deprecated: false + schema: + type: integer + maximum: 100 + default: 100 + - name: type + in: query + description: |- + 类型 [筛选条件] + (Book:文档型知识库, Design: 画板型知识库) + required: false + deprecated: false + schema: + type: string + enum: + - Book + - Design + - name: filterByAbility + in: query + description: |- + 操作权限 [筛选条件] 默认为空 + (create_doc:仅返回具有创建文档权限的知识库) + required: false + deprecated: false + schema: + type: string + default: '' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + post: + tags: + - repo + summary: 创建知识库 + operationId: repo_api_v2_repo_create + description: |+ + 创建知识库 + POST /api/v2/groups/:id/repos + POST /api/v2/groups/:login/repos + + + POST /api/v2/users/:id/repos + POST /api/v2/users/:login/repos + + parameters: + - name: login + in: path + description: 用户/团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2RepoCreatePost' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{book_id}: + get: + tags: + - repo + summary: 获取知识库详情 + operationId: repo_api_v2_repo_show-by_id + description: |+ + 获取知识库详情 + GET /api/v2/repos/:book_id + GET /api/v2/repos/:group_login/:book_slug + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2BookDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - repo + summary: 更新知识库 + operationId: repo_api_v2_repo_update-by_id + description: |+ + 更新知识库 + PUT /api/v2/repos/:book_id + PUT /api/v2/repos/:group_login/:book_slug + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + requestBody: + $ref: '#/components/requestBodies/ApiV2RepoUpdateByIdPut' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + delete: + tags: + - repo + summary: 删除知识库 + operationId: repo_api_v2_repo_destroy-by_id + description: |+ + 删除知识库 + DELETE /api/v2/repos/:book_id + DELETE /api/v2/repos/:group_login/:book_slug + + parameters: + - name: book_id + in: path + description: 知识库 ID + required: true + deprecated: false + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/repos/{group_login}/{book_slug}: + get: + tags: + - repo + summary: 获取知识库详情 + operationId: repo_api_v2_repo_show + description: |+ + 获取知识库详情 + GET /api/v2/repos/:book_id + GET /api/v2/repos/:group_login/:book_slug + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2BookDetail' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + put: + tags: + - repo + summary: 更新知识库 + operationId: repo_api_v2_repo_update + description: |+ + 更新知识库 + PUT /api/v2/repos/:book_id + PUT /api/v2/repos/:group_login/:book_slug + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/ApiV2RepoUpdatePut' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + delete: + tags: + - repo + summary: 删除知识库 + operationId: repo_api_v2_repo_destroy + description: |+ + 删除知识库 + DELETE /api/v2/repos/:book_id + DELETE /api/v2/repos/:group_login/:book_slug + + parameters: + - name: group_login + in: path + description: 团队 Login + required: true + deprecated: false + schema: + type: string + - name: book_slug + in: path + description: 知识库路径 + required: true + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2Book' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/groups/{login}/statistics: + get: + tags: + - statistic + summary: 团队.汇总统计数据 + operationId: statistic_api_v2_statistic_all + description: |+ + 团队.汇总统计数据 + GET /api/v2/groups/:login/statistics + + parameters: + - name: login + in: path + description: 团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/V2GroupStatistics' + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/groups/{login}/statistics/members: + get: + tags: + - statistic + summary: 团队.成员统计数据 + operationId: statistic_api_v2_statistic_by_members + description: |- + 团队.成员统计数据 + GET /api/v2/groups/:login/statistics/members + parameters: + - name: login + in: path + description: 团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + - name: name + in: query + description: 成员名 [过滤条件] + required: false + deprecated: false + schema: + type: string + - name: range + in: query + description: |- + 时间范围 [过滤条件] + (0:全部, 30:近 30 天, 365:近一年) + required: false + deprecated: false + schema: + type: integer + enum: + - 0 + - 30 + - 365 + default: 0 + - name: page + in: query + description: 页码 + required: false + deprecated: false + schema: + type: integer + default: 1 + - name: limit + in: query + description: 分页数量 + required: false + deprecated: false + schema: + type: integer + maximum: 20 + default: 10 + - name: sortField + in: query + description: 排序字段 + required: false + deprecated: false + schema: + type: string + enum: + - write_doc_count + - write_count + - read_count + - like_count + - name: sortOrder + in: query + description: 排序方向 + required: false + deprecated: false + schema: + type: string + enum: + - desc + - asc + default: desc + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + members: + $ref: '#/components/schemas/V2MemberStatistics' + total: + type: integer + description: 总数量 + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/groups/{login}/statistics/books: + get: + tags: + - statistic + summary: 团队.知识库统计数据 + operationId: statistic_api_v2_statistic_by_books + description: |- + 团队.知识库统计数据 + GET /api/v2/groups/:login/statistics/books + parameters: + - name: login + in: path + description: 团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + - name: name + in: query + description: 知识库名 [过滤条件] + required: false + deprecated: false + schema: + type: string + - name: range + in: query + description: |- + 时间范围 [过滤条件] + (0:全部, 30:近 30 天, 365:近一年) + required: false + deprecated: false + schema: + type: integer + enum: + - 0 + - 30 + - 365 + default: 0 + - name: page + in: query + description: 页码 + required: false + deprecated: false + schema: + type: integer + default: 1 + - name: limit + in: query + description: 分页数量 + required: false + deprecated: false + schema: + type: integer + maximum: 20 + default: 10 + - name: sortField + in: query + description: 排序字段 + required: false + deprecated: false + schema: + type: string + enum: + - content_updated_at_ms + - word_count + - post_count + - read_count + - like_count + - watch_count + - comment_count + - name: sortOrder + in: query + description: 排序方向 + required: false + deprecated: false + schema: + type: string + enum: + - desc + - asc + default: desc + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + books: + $ref: '#/components/schemas/V2BookStatistics' + total: + type: integer + description: 总数量 + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 + /api/v2/groups/{login}/statistics/docs: + get: + tags: + - statistic + summary: 团队.文档统计数据 + operationId: statistic_api_v2_statistic_by_docs + description: |- + 团队.文档统计数据 + GET /api/v2/groups/:login/statistics/docs + parameters: + - name: login + in: path + description: 团队的 Login 或 ID + required: true + deprecated: false + schema: + type: string + - name: bookId + in: query + description: 指定知识库 [过滤条件] + required: false + deprecated: false + schema: + type: integer + - name: name + in: query + description: 文档名 [过滤条件] + required: false + deprecated: false + schema: + type: string + - name: range + in: query + description: |- + 时间范围 [过滤条件] + (0:全部, 30:近 30 天, 365:近一年) + required: false + deprecated: false + schema: + type: integer + enum: + - 0 + - 30 + - 365 + default: 0 + - name: page + in: query + description: 页码 + required: false + deprecated: false + schema: + type: integer + default: 1 + - name: limit + in: query + description: 分页数量 + required: false + deprecated: false + schema: + type: integer + maximum: 20 + default: 10 + - name: sortField + in: query + description: 排序字段 + required: false + deprecated: false + schema: + type: string + enum: + - content_updated_at + - word_count + - read_count + - like_count + - comment_count + - created_at + - name: sortOrder + in: query + description: 排序方向 + required: false + deprecated: false + schema: + type: string + enum: + - desc + - asc + default: desc + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + docs: + $ref: '#/components/schemas/V2DocStatistics' + total: + type: integer + description: 总数量 + '400': *ref_0 + '401': *ref_1 + '403': *ref_2 + '404': *ref_3 + '422': *ref_4 + '429': *ref_5 + '500': *ref_6 +tags: + - name: user + description: user + - name: search + description: search + - name: group + description: group + - name: doc + description: doc + - name: repo + description: repo + - name: statistic + description: statistic diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 0000000..2ef2a0b --- /dev/null +++ b/src/bin.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runCli } from './cli.js'; + +process.exitCode = await runCli(process.argv); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..49b0371 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,60 @@ +import { createRequire } from 'node:module'; +import { Command, CommanderError } from 'commander'; +import { CliError, YuqueError, exitCodeForStatus } from './errors.js'; +import { registerAuthCommands } from './commands/auth.js'; +import { registerUserCommands } from './commands/user.js'; +import { registerSearchCommands } from './commands/search.js'; +import { registerRepoCommands } from './commands/repo.js'; +import { registerDocCommands } from './commands/doc.js'; +import { registerTocCommands } from './commands/toc.js'; +import { registerGroupCommands } from './commands/group.js'; +import { registerStatsCommands } from './commands/stats.js'; + +const require = createRequire(import.meta.url); +const { version: VERSION } = require('../package.json') as { version: string }; + +export function buildProgram(): Command { + const program = new Command('yuque'); + program + .description('Yuque (语雀) from the terminal — browse, edit, and manage your knowledge base') + .version(VERSION, '-v, --version', 'print the CLI version') + .option('--token ', 'Yuque API token (overrides YUQUE_TOKEN)') + .option('--host ', 'Yuque host, e.g. https://your-space.yuque.com (overrides YUQUE_HOST)') + .option('--json', 'print the full API response as JSON') + .showHelpAfterError('(run with --help for usage)'); + + registerAuthCommands(program); + registerUserCommands(program); + registerSearchCommands(program); + registerRepoCommands(program); + registerDocCommands(program); + registerTocCommands(program); + registerGroupCommands(program); + registerStatsCommands(program); + return program; +} + +/** Parse and run; returns the process exit code instead of exiting, for testability. */ +export async function runCli(argv: string[]): Promise { + const program = buildProgram(); + program.exitOverride(); + try { + await program.parseAsync(argv); + return typeof process.exitCode === 'number' ? process.exitCode : 0; + } catch (error) { + if (error instanceof CommanderError) { + // --help / --version resolve with exitCode 0; everything else is a usage error. + return error.exitCode === 0 ? 0 : 2; + } + if (error instanceof CliError) { + process.stderr.write(`yuque: ${error.message}\n`); + return error.exitCode; + } + if (error instanceof YuqueError) { + process.stderr.write(`yuque: ${error.message}\n`); + return exitCodeForStatus(error.statusCode); + } + process.stderr.write(`yuque: ${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} diff --git a/src/client/http.ts b/src/client/http.ts new file mode 100644 index 0000000..d367dda --- /dev/null +++ b/src/client/http.ts @@ -0,0 +1,106 @@ +import axios, { AxiosError, AxiosInstance } from 'axios'; +import { YuqueError, statusHint } from '../errors.js'; + +export interface YuqueHttpOptions { + token: string; + /** Site root, e.g. https://www.yuque.com — /api/v2 is appended here. */ + host: string; + timeoutMs?: number; + /** Max retries for 429/5xx/network failures (default 3). */ + maxRetries?: number; + /** Injectable for tests. */ + sleep?: (ms: number) => Promise; +} + +export interface RequestOptions { + params?: Record; + data?: unknown; +} + +type Method = 'get' | 'post' | 'put' | 'delete'; + +const RETRYABLE_STATUS = new Set([429, 502, 503, 504]); + +const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class YuqueHttp { + private readonly axios: AxiosInstance; + private readonly maxRetries: number; + private readonly sleep: (ms: number) => Promise; + + constructor(options: YuqueHttpOptions) { + this.maxRetries = options.maxRetries ?? 3; + this.sleep = options.sleep ?? defaultSleep; + this.axios = axios.create({ + baseURL: `${options.host}/api/v2`, + timeout: options.timeoutMs ?? 30000, + headers: { + 'X-Auth-Token': options.token, + 'Content-Type': 'application/json', + 'User-Agent': '@yuque/cli', + }, + }); + } + + async request(method: Method, url: string, options: RequestOptions = {}): Promise { + for (let attempt = 0; ; attempt++) { + try { + const response = await this.axios.request({ + method, + url, + params: options.params, + data: options.data, + }); + return response.data; + } catch (error) { + if (attempt < this.maxRetries && this.isRetryable(error)) { + await this.sleep(this.retryDelayMs(error, attempt)); + continue; + } + throw this.normalizeError(error); + } + } + } + + get(url: string, params?: Record): Promise { + return this.request('get', url, { params }); + } + + post(url: string, data?: unknown): Promise { + return this.request('post', url, { data }); + } + + put(url: string, data?: unknown): Promise { + return this.request('put', url, { data }); + } + + delete(url: string, params?: Record): Promise { + return this.request('delete', url, { params }); + } + + private isRetryable(error: unknown): boolean { + if (!axios.isAxiosError(error)) return false; + if (error.response) return RETRYABLE_STATUS.has(error.response.status); + // Network-level failure (no response): connection reset, DNS, timeout. + return error.code !== 'ERR_CANCELED'; + } + + private retryDelayMs(error: unknown, attempt: number): number { + if (axios.isAxiosError(error)) { + const retryAfter = Number(error.response?.headers?.['retry-after']); + if (Number.isFinite(retryAfter) && retryAfter > 0) return Math.min(retryAfter * 1000, 10000); + } + return Math.min(500 * 2 ** attempt, 4000); + } + + private normalizeError(error: unknown): YuqueError { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError<{ message?: string }>; + const status = axiosError.response?.status; + const apiMessage = axiosError.response?.data?.message || axiosError.message; + const hint = statusHint(status); + return new YuqueError(hint ? `${apiMessage} (${hint})` : apiMessage, status, error); + } + return new YuqueError(error instanceof Error ? error.message : String(error), undefined, error); + } +} diff --git a/src/client/paginate.ts b/src/client/paginate.ts new file mode 100644 index 0000000..1bdd876 --- /dev/null +++ b/src/client/paginate.ts @@ -0,0 +1,15 @@ +/** + * Drain an offset-paged list endpoint. The Yuque API caps `limit` at 100, + * so --all style commands loop until a short page comes back. + */ +export async function fetchAllPages( + fetchPage: (offset: number, limit: number) => Promise, + pageSize = 100 +): Promise { + const all: T[] = []; + for (let offset = 0; ; offset += pageSize) { + const page = await fetchPage(offset, pageSize); + all.push(...page); + if (page.length < pageSize) return all; + } +} diff --git a/src/client/repo-ref.ts b/src/client/repo-ref.ts new file mode 100644 index 0000000..0d37d82 --- /dev/null +++ b/src/client/repo-ref.ts @@ -0,0 +1,28 @@ +import { UsageError } from '../errors.js'; + +/** + * A repo (知识库) reference given on the command line: either a numeric id + * or a `group/slug` namespace. Both map onto the same /repos/... URL shape. + */ +export type RepoRef = + | { kind: 'id'; id: string } + | { kind: 'namespace'; group: string; slug: string }; + +export function parseRepoRef(input: string): RepoRef { + const trimmed = input.trim(); + if (/^\d+$/.test(trimmed)) return { kind: 'id', id: trimmed }; + const parts = trimmed.split('/'); + if (parts.length === 2 && parts[0] !== '' && parts[1] !== '') { + return { kind: 'namespace', group: parts[0], slug: parts[1] }; + } + throw new UsageError( + `Invalid repo reference "${input}" — expected a numeric id (e.g. 123456) or a namespace (e.g. group/slug)` + ); +} + +/** URL base for a repo: /repos/{id} or /repos/{group}/{slug}. */ +export function repoBasePath(ref: RepoRef): string { + return ref.kind === 'id' + ? `/repos/${encodeURIComponent(ref.id)}` + : `/repos/${encodeURIComponent(ref.group)}/${encodeURIComponent(ref.slug)}`; +} diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..9b118b3 --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,212 @@ +/** + * Domain types mirroring spec/yuque-openapi.yaml component schemas. + * Fields used by the CLI are typed explicitly; everything else flows through + * the index signature so --json output always carries the full payload. + */ + +export interface ApiEnvelope { + data: T; + [key: string]: unknown; +} + +export interface V2User { + id: number; + type?: string; + login: string; + name: string; + avatar_url?: string; + books_count?: number; + public_books_count?: number; + followers_count?: number; + following_count?: number; + public?: number; + description?: string | null; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface V2Group { + id: number; + type?: string; + login: string; + name: string; + avatar_url?: string; + books_count?: number; + public_books_count?: number; + members_count?: number; + public?: number; + description?: string | null; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface V2GroupUser { + id: number; + group_id?: number; + user_id?: number; + role: number; + created_at?: string; + updated_at?: string; + group?: V2Group; + user?: V2User; + [key: string]: unknown; +} + +export interface V2SearchResult { + id: number; + type: string; + title: string; + summary?: string; + url: string; + info?: string; + target?: Record; + [key: string]: unknown; +} + +export interface V2Book { + id: number; + type?: string; + slug: string; + name: string; + user_id?: number; + description?: string | null; + creator_id?: number; + public?: number; + items_count?: number; + likes_count?: number; + watches_count?: number; + content_updated_at?: string; + created_at?: string; + updated_at?: string; + user?: V2User; + namespace?: string; + [key: string]: unknown; +} + +export interface V2BookDetail extends V2Book { + toc_yml?: string; + [key: string]: unknown; +} + +export interface V2Doc { + id: number; + type?: string; + slug: string; + title: string; + description?: string | null; + user_id?: number; + book_id?: number; + last_editor_id?: number; + public?: number; + status?: number; + likes_count?: number; + read_count?: number; + word_count?: number; + created_at?: string; + updated_at?: string; + content_updated_at?: string; + published_at?: string; + first_published_at?: string; + user?: V2User; + last_editor?: V2User; + book?: V2Book; + [key: string]: unknown; +} + +export interface V2DocDetail extends V2Doc { + format?: string; + body?: string; + body_draft?: string; + body_html?: string; + body_lake?: string; + creator?: V2User; + [key: string]: unknown; +} + +export interface V2DocVersion { + id: number; + doc_id: number; + slug?: string; + title: string; + user_id?: number; + created_at?: string; + updated_at?: string; + user?: V2User; + [key: string]: unknown; +} + +export interface V2DocVersionDetail extends V2DocVersion { + format?: string; + body?: string; + body_html?: string; + body_md?: string; + body_asl?: string; + diff?: string; + [key: string]: unknown; +} + +export interface V2TocItem { + uuid: string; + type: string; + title: string; + url?: string; + slug?: string; + id?: number; + doc_id?: number; + level?: number; + depth?: number; + open_window?: number; + visible?: number; + prev_uuid?: string; + sibling_uuid?: string; + child_uuid?: string; + parent_uuid?: string; + [key: string]: unknown; +} + +export interface V2GroupStatistics { + bizdate?: string; + member_count?: number; + write_count?: number; + read_count?: number; + comment_count?: number; + [key: string]: unknown; +} + +export interface V2MemberStatistics { + bizdate?: string; + user_id?: number; + write_count?: number; + write_doc_count?: number; + read_count?: number; + like_count?: number; + user?: V2User; + [key: string]: unknown; +} + +export interface V2BookStatistics { + bizdate?: string; + book_id?: number; + slug?: string; + name?: string; + post_count?: number; + word_count?: number; + read_count?: number; + like_count?: number; + [key: string]: unknown; +} + +export interface V2DocStatistics { + bizdate?: string; + book_id?: number; + doc_id?: number; + slug?: string; + title?: string; + read_count?: number; + like_count?: number; + comment_count?: number; + word_count?: number; + [key: string]: unknown; +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..ab23db5 --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerAuthCommands(_program: Command): void { + // Placeholder — the auth command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/commands/doc.ts b/src/commands/doc.ts new file mode 100644 index 0000000..7423a50 --- /dev/null +++ b/src/commands/doc.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerDocCommands(_program: Command): void { + // Placeholder — the doc command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/commands/group.ts b/src/commands/group.ts new file mode 100644 index 0000000..fedef5d --- /dev/null +++ b/src/commands/group.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerGroupCommands(_program: Command): void { + // Placeholder — the group command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/commands/repo.ts b/src/commands/repo.ts new file mode 100644 index 0000000..474e295 --- /dev/null +++ b/src/commands/repo.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerRepoCommands(_program: Command): void { + // Placeholder — the repo command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/commands/search.ts b/src/commands/search.ts new file mode 100644 index 0000000..2e5104d --- /dev/null +++ b/src/commands/search.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerSearchCommands(_program: Command): void { + // Placeholder — the search command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/commands/stats.ts b/src/commands/stats.ts new file mode 100644 index 0000000..d0b42bf --- /dev/null +++ b/src/commands/stats.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerStatsCommands(_program: Command): void { + // Placeholder — the stats command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/commands/toc.ts b/src/commands/toc.ts new file mode 100644 index 0000000..8e2e890 --- /dev/null +++ b/src/commands/toc.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerTocCommands(_program: Command): void { + // Placeholder — the toc command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/commands/user.ts b/src/commands/user.ts new file mode 100644 index 0000000..973ffb7 --- /dev/null +++ b/src/commands/user.ts @@ -0,0 +1,6 @@ +import type { Command } from 'commander'; + +export function registerUserCommands(_program: Command): void { + // Placeholder — the user command group is implemented against the surface + // locked in tests/spec-coverage.test.ts. +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..254198f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,33 @@ +export const DEFAULT_HOST = 'https://www.yuque.com'; + +export const MISSING_TOKEN_MESSAGE = + 'A Yuque API token is required. Set the YUQUE_TOKEN environment variable or pass --token=. ' + + 'Create one at https://www.yuque.com/settings/tokens'; + +/** Flag wins over env so scripted one-off overrides behave like gh/glab. */ +export function resolveToken( + flagToken: string | undefined, + env: NodeJS.ProcessEnv = process.env +): string | undefined { + return flagToken || env.YUQUE_TOKEN || env.YUQUE_PERSONAL_TOKEN || undefined; +} + +export function resolveHost( + flagHost: string | undefined, + env: NodeJS.ProcessEnv = process.env +): string { + return normalizeHost(flagHost || env.YUQUE_HOST || DEFAULT_HOST); +} + +/** + * Normalize a host to a site root (no trailing slash, no /api/v2 suffix); + * the HTTP client appends /api/v2 itself. Accepts bare domains and full API URLs. + */ +export function normalizeHost(input: string): string { + let host = input.trim(); + if (host === '') return DEFAULT_HOST; + if (!/^https?:\/\//i.test(host)) host = `https://${host}`; + host = host.replace(/\/+$/, ''); + host = host.replace(/\/api\/v2$/, ''); + return host.replace(/\/+$/, ''); +} diff --git a/src/confirm.ts b/src/confirm.ts new file mode 100644 index 0000000..ca372a1 --- /dev/null +++ b/src/confirm.ts @@ -0,0 +1,22 @@ +import { createInterface } from 'node:readline/promises'; +import { CliError, UsageError } from './errors.js'; + +/** + * Gate for destructive commands (delete, member remove). Resolves when the + * action may proceed; throws otherwise. Non-interactive runs must pass --yes. + */ +export async function confirmDestructive(action: string, yes: boolean): Promise { + if (yes) return; + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new UsageError(`Refusing to ${action} without confirmation — pass --yes to proceed.`); + } + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await readline.question(`About to ${action}. Type "yes" to confirm: `); + if (answer.trim().toLowerCase() !== 'yes') { + throw new CliError('Aborted.', 1); + } + } finally { + readline.close(); + } +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..02056e2 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,24 @@ +import type { Command } from 'commander'; +import { AuthError } from './errors.js'; +import { MISSING_TOKEN_MESSAGE, resolveHost, resolveToken } from './config.js'; +import { YuqueHttp } from './client/http.js'; + +export interface CommandContext { + http: YuqueHttp; + json: boolean; +} + +/** + * Build the per-invocation context from global options (--token/--host/--json + * merged with env). Call from inside command actions only, so `yuque --help` + * never demands a token. + */ +export function getContext(command: Command): CommandContext { + const options = command.optsWithGlobals<{ token?: string; host?: string; json?: boolean }>(); + const token = resolveToken(options.token); + if (!token) throw new AuthError(MISSING_TOKEN_MESSAGE); + return { + http: new YuqueHttp({ token, host: resolveHost(options.host) }), + json: Boolean(options.json), + }; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..d939267 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,63 @@ +/** + * CLI-level errors carry the process exit code they should terminate with. + * + * Exit code contract (stable, scripts may rely on it): + * 0 success · 1 API/unknown error · 2 usage error · 3 auth error · 4 not found · 5 rate limited + */ +export class CliError extends Error { + constructor( + message: string, + public readonly exitCode: number = 1 + ) { + super(message); + this.name = 'CliError'; + } +} + +export class UsageError extends CliError { + constructor(message: string) { + super(message, 2); + this.name = 'UsageError'; + } +} + +export class AuthError extends CliError { + constructor(message: string) { + super(message, 3); + this.name = 'AuthError'; + } +} + +/** Error thrown for any failed Yuque API request, normalized from axios errors. */ +export class YuqueError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly originalError?: unknown + ) { + super(message); + this.name = 'YuqueError'; + } +} + +const STATUS_HINTS: Record = { + 400: 'the request parameters were rejected by the Yuque API', + 401: 'token invalid or expired — set YUQUE_TOKEN or pass --token, see https://www.yuque.com/settings/tokens', + 403: 'the token does not have permission for this resource', + 404: 'the requested resource does not exist — check the id, namespace, or slug', + 410: 'the resource was permanently deleted or the endpoint is deprecated', + 429: 'rate limited by the Yuque API — wait a moment and retry', +}; + +export function statusHint(statusCode?: number): string | undefined { + if (statusCode === undefined) return undefined; + if (statusCode >= 500) return 'the Yuque API had a server-side error — retry later'; + return STATUS_HINTS[statusCode]; +} + +export function exitCodeForStatus(statusCode?: number): number { + if (statusCode === 401 || statusCode === 403) return 3; + if (statusCode === 404) return 4; + if (statusCode === 429) return 5; + return 1; +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..e268e4d --- /dev/null +++ b/src/output.ts @@ -0,0 +1,78 @@ +/** Rendering helpers: human-readable tables/records on a TTY, full JSON with --json. */ + +function colorEnabled(): boolean { + return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; +} + +export function bold(text: string): string { + return colorEnabled() ? `\x1b[1m${text}\x1b[0m` : text; +} + +export function dim(text: string): string { + return colorEnabled() ? `\x1b[2m${text}\x1b[0m` : text; +} + +export function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +export function printOk(message: string): void { + process.stdout.write(`${colorEnabled() ? '\x1b[32m✓\x1b[0m' : '✓'} ${message}\n`); +} + +/** Display width where CJK characters count as 2 columns, for aligned tables. */ +export function displayWidth(text: string): number { + let width = 0; + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + width += code >= 0x2e80 && code <= 0x9fff ? 2 : code >= 0xff00 && code <= 0xffef ? 2 : 1; + } + return width; +} + +function pad(text: string, width: number): string { + return text + ' '.repeat(Math.max(0, width - displayWidth(text))); +} + +export interface Column { + key: string; + header: string; + format?: (row: T) => string; +} + +function cell>(row: T, column: Column): string { + if (column.format) return column.format(row); + const value = row[column.key]; + if (value === null || value === undefined) return ''; + return String(value); +} + +export function printTable>(rows: T[], columns: Column[]): void { + if (rows.length === 0) { + process.stdout.write(dim('(no results)\n')); + return; + } + const widths = columns.map((column) => + Math.max( + displayWidth(column.header), + ...rows.map((row) => displayWidth(cell(row, column))) + ) + ); + const header = columns.map((column, i) => pad(column.header, widths[i])).join(' '); + process.stdout.write(`${bold(header.trimEnd())}\n`); + for (const row of rows) { + const line = columns.map((column, i) => pad(cell(row, column), widths[i])).join(' '); + process.stdout.write(`${line.trimEnd()}\n`); + } +} + +/** Print selected fields of a single object as aligned `key value` lines. */ +export function printRecord(record: Record, fields: string[]): void { + const present = fields.filter((field) => record[field] !== undefined && record[field] !== null); + const width = Math.max(0, ...present.map((field) => displayWidth(field))); + for (const field of present) { + const value = record[field]; + const text = typeof value === 'object' ? JSON.stringify(value) : String(value); + process.stdout.write(`${dim(pad(field, width))} ${text}\n`); + } +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..fb8091c --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { runCli } from '../src/cli.js'; + +function argv(...args: string[]): string[] { + return ['node', 'yuque', ...args]; +} + +describe('runCli', () => { + it('returns 0 for --help', async () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await expect(runCli(argv('--help'))).resolves.toBe(0); + } finally { + write.mockRestore(); + } + }); + + it('returns 0 for --version', async () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await expect(runCli(argv('--version'))).resolves.toBe(0); + } finally { + write.mockRestore(); + } + }); + + it('returns 2 for an unknown command', async () => { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await expect(runCli(argv('definitely-not-a-command'))).resolves.toBe(2); + } finally { + write.mockRestore(); + } + }); + + it('returns 2 when invoked with no arguments', async () => { + const out = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const err = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await expect(runCli(argv())).resolves.toBe(2); + } finally { + out.mockRestore(); + err.mockRestore(); + } + }); +}); diff --git a/tests/client/http.test.ts b/tests/client/http.test.ts new file mode 100644 index 0000000..6af4541 --- /dev/null +++ b/tests/client/http.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { YuqueHttp } from '../../src/client/http.js'; +import { YuqueError } from '../../src/errors.js'; + +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); + +function axiosFailure(status: number, message = 'boom', headers: Record = {}) { + const error = new Error(message) as Error & { + isAxiosError: boolean; + response: { status: number; data: { message: string }; headers: Record }; + }; + error.isAxiosError = true; + error.response = { status, data: { message }, headers }; + return error; +} + +describe('YuqueHttp', () => { + const request = vi.fn(); + const sleep = vi.fn(() => Promise.resolve()); + + beforeEach(() => { + request.mockReset(); + sleep.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedAxios.create.mockReturnValue({ request } as any); + }); + + function makeHttp(maxRetries = 3) { + return new YuqueHttp({ + token: 't', + host: 'https://www.yuque.com', + maxRetries, + sleep, + }); + } + + it('configures baseURL with /api/v2 and the auth header', () => { + makeHttp(); + expect(mockedAxios.create).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: 'https://www.yuque.com/api/v2', + headers: expect.objectContaining({ 'X-Auth-Token': 't' }), + }) + ); + }); + + it('returns response.data on success', async () => { + request.mockResolvedValueOnce({ data: { data: { id: 1 } } }); + await expect(makeHttp().get('/user')).resolves.toEqual({ data: { id: 1 } }); + }); + + it('retries 429 with backoff then succeeds', async () => { + request + .mockRejectedValueOnce(axiosFailure(429, 'rate limited')) + .mockResolvedValueOnce({ data: { data: 'ok' } }); + await expect(makeHttp().get('/user')).resolves.toEqual({ data: 'ok' }); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('honors Retry-After seconds', async () => { + request + .mockRejectedValueOnce(axiosFailure(429, 'rate limited', { 'retry-after': '2' })) + .mockResolvedValueOnce({ data: { data: 'ok' } }); + await makeHttp().get('/user'); + expect(sleep).toHaveBeenCalledWith(2000); + }); + + it('gives up after maxRetries and throws a YuqueError with hint', async () => { + request.mockRejectedValue(axiosFailure(429, 'rate limited')); + const promise = makeHttp(2).get('/user'); + await expect(promise).rejects.toBeInstanceOf(YuqueError); + await expect(makeHttp(0).get('/user')).rejects.toThrow(/rate limited/); + expect(request).toHaveBeenCalledTimes(4); // 3 attempts (maxRetries=2) + 1 (maxRetries=0) + }); + + it('does not retry non-retryable statuses', async () => { + request.mockRejectedValue(axiosFailure(404, 'not found')); + await expect(makeHttp().get('/nope')).rejects.toMatchObject({ statusCode: 404 }); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('appends a status hint to API error messages', async () => { + request.mockRejectedValue(axiosFailure(401, 'invalid token')); + await expect(makeHttp().get('/user')).rejects.toThrow(/token invalid or expired/); + }); +}); diff --git a/tests/client/repo-ref.test.ts b/tests/client/repo-ref.test.ts new file mode 100644 index 0000000..f9bd362 --- /dev/null +++ b/tests/client/repo-ref.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { parseRepoRef, repoBasePath } from '../../src/client/repo-ref.js'; +import { UsageError } from '../../src/errors.js'; + +describe('parseRepoRef', () => { + it('parses a numeric id', () => { + expect(parseRepoRef('123456')).toEqual({ kind: 'id', id: '123456' }); + }); + + it('parses a namespace', () => { + expect(parseRepoRef('yuque/help')).toEqual({ kind: 'namespace', group: 'yuque', slug: 'help' }); + }); + + it('rejects malformed references', () => { + expect(() => parseRepoRef('a/b/c')).toThrow(UsageError); + expect(() => parseRepoRef('/slug')).toThrow(UsageError); + expect(() => parseRepoRef('group/')).toThrow(UsageError); + }); +}); + +describe('repoBasePath', () => { + it('builds id and namespace paths', () => { + expect(repoBasePath(parseRepoRef('42'))).toBe('/repos/42'); + expect(repoBasePath(parseRepoRef('yuque/help'))).toBe('/repos/yuque/help'); + }); + + it('escapes URL-unsafe characters', () => { + expect(repoBasePath(parseRepoRef('团队/知识 库'))).toBe( + '/repos/%E5%9B%A2%E9%98%9F/%E7%9F%A5%E8%AF%86%20%E5%BA%93' + ); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..56a99eb --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_HOST, + normalizeHost, + resolveHost, + resolveToken, +} from '../src/config.js'; + +describe('resolveToken', () => { + it('prefers the flag over env vars', () => { + expect(resolveToken('flag-token', { YUQUE_TOKEN: 'env-token' })).toBe('flag-token'); + }); + + it('falls back to YUQUE_TOKEN then YUQUE_PERSONAL_TOKEN', () => { + expect(resolveToken(undefined, { YUQUE_TOKEN: 'a', YUQUE_PERSONAL_TOKEN: 'b' })).toBe('a'); + expect(resolveToken(undefined, { YUQUE_PERSONAL_TOKEN: 'b' })).toBe('b'); + }); + + it('returns undefined when nothing is set', () => { + expect(resolveToken(undefined, {})).toBeUndefined(); + }); +}); + +describe('resolveHost / normalizeHost', () => { + it('defaults to www.yuque.com', () => { + expect(resolveHost(undefined, {})).toBe(DEFAULT_HOST); + }); + + it('prefers the flag over YUQUE_HOST', () => { + expect(resolveHost('https://a.yuque.com', { YUQUE_HOST: 'https://b.yuque.com' })).toBe( + 'https://a.yuque.com' + ); + }); + + it('adds https:// to bare domains', () => { + expect(normalizeHost('space.yuque.com')).toBe('https://space.yuque.com'); + }); + + it('strips trailing slashes and an /api/v2 suffix', () => { + expect(normalizeHost('https://space.yuque.com/')).toBe('https://space.yuque.com'); + expect(normalizeHost('https://space.yuque.com/api/v2')).toBe('https://space.yuque.com'); + expect(normalizeHost('https://space.yuque.com/api/v2/')).toBe('https://space.yuque.com'); + }); + + it('keeps http:// for private deployments', () => { + expect(normalizeHost('http://yuque.internal:8080/')).toBe('http://yuque.internal:8080'); + }); +}); diff --git a/tests/spec-coverage.test.ts b/tests/spec-coverage.test.ts new file mode 100644 index 0000000..3b2db32 --- /dev/null +++ b/tests/spec-coverage.test.ts @@ -0,0 +1,162 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { load } from 'js-yaml'; +import { describe, expect, it } from 'vitest'; +import { Command } from 'commander'; +import { buildProgram } from '../src/cli.js'; + +/** + * Contract: the CLI command surface is locked 1:1 against spec/yuque-openapi.yaml. + * + * Every OpenAPI operation must be handled by at least one command, and every + * registered leaf command must trace back to at least one operation. Changing + * either side (spec refresh, new command) requires updating this table — that + * is deliberate, the same pinning culture as yuque-mcp-server's contract tests. + */ + +/** operationId -> CLI command path(s) that call it. */ +const OPERATION_TO_COMMANDS: Record = { + user_api_v2_hello: ['ping'], + user_api_v2_user_info: ['auth status', 'user info'], + user_api_v2_user_group_list: ['user groups'], + search_api_v2_search: ['search'], + group_api_v2_group_member_list: ['group members'], + group_api_v2_group_member_update: ['group member set'], + group_api_v2_group_member_destroy: ['group member remove'], + 'doc_api_v2_doc_list-by_id': ['doc list'], + 'doc_api_v2_doc_create-by_id': ['doc create'], + 'doc_api_v2_doc_show-by_id': ['doc get'], + 'doc_api_v2_doc_show-by_book_and_id': ['doc get'], + 'doc_api_v2_doc_update-by_id': ['doc update'], + 'doc_api_v2_doc_destroy-by_id': ['doc delete'], + doc_api_v2_doc_list: ['doc list'], + doc_api_v2_doc_create: ['doc create'], + doc_api_v2_doc_show: ['doc get'], + doc_api_v2_doc_update: ['doc update'], + doc_api_v2_doc_destroy: ['doc delete'], + doc_api_v2_doc_version_list: ['doc versions'], + doc_api_v2_doc_version_show: ['doc version'], + 'doc_api_v2_repo_toc_show-by_id': ['toc get'], + 'doc_api_v2_repo_toc_update-by_id': ['toc update'], + doc_api_v2_repo_toc_show: ['toc get'], + doc_api_v2_repo_toc_update: ['toc update'], + 'repo_api_v2_repo_list-by_group': ['repo list'], + 'repo_api_v2_repo_create-by_group': ['repo create'], + repo_api_v2_repo_list: ['repo list'], + repo_api_v2_repo_create: ['repo create'], + 'repo_api_v2_repo_show-by_id': ['repo get'], + 'repo_api_v2_repo_update-by_id': ['repo update'], + 'repo_api_v2_repo_destroy-by_id': ['repo delete'], + repo_api_v2_repo_show: ['repo get'], + repo_api_v2_repo_update: ['repo update'], + repo_api_v2_repo_destroy: ['repo delete'], + statistic_api_v2_statistic_all: ['stats group'], + statistic_api_v2_statistic_by_members: ['stats members'], + statistic_api_v2_statistic_by_books: ['stats books'], + statistic_api_v2_statistic_by_docs: ['stats docs'], +}; + +const EXPECTED_LEAF_COMMANDS = [ + 'ping', + 'auth status', + 'user info', + 'user groups', + 'search', + 'group members', + 'group member set', + 'group member remove', + 'repo list', + 'repo get', + 'repo create', + 'repo update', + 'repo delete', + 'doc list', + 'doc get', + 'doc create', + 'doc update', + 'doc delete', + 'doc versions', + 'doc version', + 'toc get', + 'toc update', + 'stats group', + 'stats members', + 'stats books', + 'stats docs', +].sort(); + +interface SpecOperation { + operationId: string; + method: string; + path: string; +} + +function loadSpecOperations(): SpecOperation[] { + const specPath = fileURLToPath(new URL('../spec/yuque-openapi.yaml', import.meta.url)); + const spec = load(readFileSync(specPath, 'utf8')) as { + paths: Record>; + }; + const operations: SpecOperation[] = []; + for (const [path, item] of Object.entries(spec.paths)) { + for (const method of ['get', 'post', 'put', 'delete', 'patch']) { + const operation = item[method]; + if (operation?.operationId) { + operations.push({ operationId: operation.operationId, method, path }); + } + } + } + return operations; +} + +function collectLeafCommands(command: Command, prefix: string[] = []): string[] { + const leaves: string[] = []; + for (const sub of command.commands) { + const path = [...prefix, sub.name()]; + if (sub.commands.length === 0) { + leaves.push(path.join(' ')); + } else { + leaves.push(...collectLeafCommands(sub, path)); + } + } + return leaves; +} + +describe('spec coverage contract', () => { + const operations = loadSpecOperations(); + const registeredLeaves = collectLeafCommands(buildProgram()).sort(); + + it('pins the spec identity (38 operations)', () => { + expect(operations).toHaveLength(38); + }); + + it('maps every spec operation to at least one CLI command', () => { + const unmapped = operations.filter((op) => !OPERATION_TO_COMMANDS[op.operationId]); + expect( + unmapped.map((op) => `${op.operationId} (${op.method.toUpperCase()} ${op.path})`) + ).toEqual([]); + }); + + it('has no stale operationIds in the mapping table', () => { + const known = new Set(operations.map((op) => op.operationId)); + const stale = Object.keys(OPERATION_TO_COMMANDS).filter((id) => !known.has(id)); + expect(stale).toEqual([]); + }); + + it('registers exactly the expected leaf commands', () => { + expect(registeredLeaves).toEqual(EXPECTED_LEAF_COMMANDS); + }); + + it('every mapped command exists in the program', () => { + const registered = new Set(registeredLeaves); + const missing = [...new Set(Object.values(OPERATION_TO_COMMANDS).flat())].filter( + (commandPath) => !registered.has(commandPath) + ); + expect(missing).toEqual([]); + }); + + it('every leaf command traces back to a spec operation', () => { + const mapped = new Set(Object.values(OPERATION_TO_COMMANDS).flat()); + const orphans = registeredLeaves.filter((commandPath) => !mapped.has(commandPath)); + expect(orphans).toEqual([]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..981c47f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowJs": false, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9dc6bbd --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + testTimeout: 10000, + hookTimeout: 10000, + exclude: ['**/node_modules/**', '**/dist/**', '**/.claude/**'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'dist/', 'tests/', 'src/client/types.ts'], + }, + }, +}); From 6c46740564ad102edde5add1b068382768cc1abe Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Wed, 22 Jul 2026 17:57:56 +0900 Subject: [PATCH 02/13] feat: implement 26 commands covering all 38 Yuque OpenAPI operations Domains: auth/ping, user, search, repo, doc (incl. versions), toc, group members, team statistics. Bilingual READMEs, docs lock test, exit-code contract fix (exitOverride before subcommand registration). Co-Authored-By: Claude Fable 5 --- AGENTS.md | 36 +++ CHANGELOG.md | 11 + README.md | 156 ++++++++++ README.zh-CN.md | 156 ++++++++++ src/cli.ts | 5 +- src/client/api/doc.ts | 90 ++++++ src/client/api/group.ts | 44 +++ src/client/api/repo.ts | 76 +++++ src/client/api/search.ts | 25 ++ src/client/api/stats.ts | 94 ++++++ src/client/api/toc.ts | 33 ++ src/client/api/user.ts | 38 +++ src/client/repo-ref.ts | 3 +- src/commands/auth.ts | 33 +- src/commands/doc.ts | 308 ++++++++++++++++++- src/commands/group.ts | 101 ++++++- src/commands/repo.ts | 198 +++++++++++- src/commands/search.ts | 52 +++- src/commands/stats.ts | 237 ++++++++++++++- src/commands/toc.ts | 144 ++++++++- src/commands/user.ts | 71 ++++- src/output.ts | 10 +- tests/commands/auth-user-search.test.ts | 250 ++++++++++++++++ tests/commands/doc.test.ts | 382 ++++++++++++++++++++++++ tests/commands/repo.test.ts | 237 +++++++++++++++ tests/commands/stats.test.ts | 254 ++++++++++++++++ tests/commands/toc-group.test.ts | 317 ++++++++++++++++++++ tests/config.test.ts | 7 +- tests/docs/command-surface-docs.test.ts | 45 +++ 29 files changed, 3372 insertions(+), 41 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 src/client/api/doc.ts create mode 100644 src/client/api/group.ts create mode 100644 src/client/api/repo.ts create mode 100644 src/client/api/search.ts create mode 100644 src/client/api/stats.ts create mode 100644 src/client/api/toc.ts create mode 100644 src/client/api/user.ts create mode 100644 tests/commands/auth-user-search.test.ts create mode 100644 tests/commands/doc.test.ts create mode 100644 tests/commands/repo.test.ts create mode 100644 tests/commands/stats.test.ts create mode 100644 tests/commands/toc-group.test.ts create mode 100644 tests/docs/command-surface-docs.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aafb096 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md + +Guidance for AI agents (and humans) working in this repo. + +## What this is + +`@yuque/cli` — a command-line interface for the Yuque (语雀) Open API, sibling of +[yuque-mcp-server](https://github.com/yuque/yuque-mcp-server) and built with the same +engineering conventions. + +## Architecture + +``` +bin.ts → cli.ts (commander program, error → exit code) + └── commands/.ts (flags, confirmation, rendering) + └── client/api/.ts (typed calls, envelope unwrap) + └── client/http.ts (auth header, retry/backoff, YuqueError) +``` + +- Command surface is **locked 1:1 against `spec/yuque-openapi.yaml`** by + `tests/spec-coverage.test.ts`. Adding/renaming a command or refreshing the spec means + updating that table deliberately. +- Both READMEs are locked by `tests/docs/command-surface-docs.test.ts` (exact command + count in the heading, every command mentioned). Keep README.md and README.zh-CN.md + strictly isomorphic. +- Exit codes are a stable contract: 0 ok · 1 API/unknown · 2 usage · 3 auth · 4 not + found · 5 rate limited (`src/errors.ts`). + +## Rules + +- All HTTP goes through `client/http.ts`; command handlers never call axios directly. +- Destructive commands (`delete`, `member remove`) go through `confirmDestructive` and + support `--yes`. +- Human output via `src/output.ts`; `--json` always prints the full raw payload. +- `npm run check` (lint + format + typecheck + tests + build + dist smoke) must exit 0 — + it is the merge gate and matches CI. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec80bbc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.1.0 (unreleased) + +- Initial release: 26 commands covering the full Yuque OpenAPI surface (38 operations) — + auth/ping, user, search, repos, docs (incl. version history), TOC, group members, and + team statistics. +- Token auth via `YUQUE_TOKEN` / `--token`; custom hosts via `YUQUE_HOST` / `--host`. +- Human-readable output with `--json` raw mode, stable exit codes, automatic retry with + backoff on rate limits, `--all` pagination, and confirmation gates on destructive + commands. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd9ee00 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +
+ +Yuque logo + +

Yuque CLI

+ +Your [Yuque (语雀)](https://www.yuque.com/) knowledge base in the terminal —
search, read, write, and manage docs from the command line. + +[![CI][ci-image]][ci-url] [![npm version][npm-image]][npm-url] [![npm downloads][download-image]][download-url] [![License][license-image]][license-url] + +[Quick Start](#quick-start) · [Commands](#commands-26) · [Scripting](#output--scripting) · [Troubleshooting](#troubleshooting) · [中文文档](./README.zh-CN.md) + +
+ +Once authenticated, your knowledge base is one command away: + +```bash +yuque search "canary release" --type doc # find that doc you half-remember +yuque doc get team/handbook onboarding > onboarding.md +yuque doc create team/notes --title "Weekly sync" --body-file weekly.md +yuque repo list my-team --group --all --json | jq '.[].name' +``` + +## Quick Start + +**1. Get a token** — create one at [Yuque Developer Settings](https://www.yuque.com/settings/tokens). If you use a team token bound to a Yuque space, also note the space host (e.g. `https://your-space.yuque.com`) — you will pass it as `--host` or `YUQUE_HOST`. + +**2. Install and sign in:** + +```bash +npm install -g @yuque/cli +export YUQUE_TOKEN=YOUR_TOKEN +yuque auth status +``` + +
+Run without installing (npx) + +```bash +YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status +``` + +
+ +**3. Start exploring** — `yuque repo list your-login`, then `yuque doc list `. + +## Configuration + +| Setting | Env var / CLI flag | Description | +| -------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Token **(required)** | `YUQUE_TOKEN` / `--token` | Personal or team Yuque API token | +| Host (optional) | `YUQUE_HOST` / `--host` | Site or space host, e.g. `https://your-space.yuque.com` — required for space-bound team tokens and private deployments | + +Flags win over env vars, so a one-off `--token` override always works. Site roots are normalized (`/api/v2` is appended automatically); when unset, the host defaults to `https://www.yuque.com`. + +## Commands (26) + +Each command maps to the [Yuque OpenAPI](https://www.yuque.com/yuque/developer/api) — the mapping is locked by a contract test against the vendored spec. + +| Category | Command | Description | +| ---------- | ---------------------------------- | ------------------------------------------------- | +| **Auth** | `ping` | Verify connectivity to the Yuque API | +| | `auth status` | Show who you are signed in as | +| **User** | `user info` | Show the authenticated user | +| | `user groups ` | List groups a user belongs to | +| **Search** | `search ` | Search docs or repos, with paging | +| **Repos** | `repo list ` | List repos (知识库) of a user or `--group` | +| | `repo get ` | Show a repo by id or `owner/slug` | +| | `repo create ` | Create a repo | +| | `repo update ` | Update name, slug, description, or visibility | +| | `repo delete ` | Delete a repo — asks for confirmation | +| **Docs** | `doc list ` | List docs in a repo, `--all` drains paging | +| | `doc get ` | Print a doc's markdown body (pipe-friendly) | +| | `doc create ` | Create a doc from `--body` or `--body-file` | +| | `doc update ` | Update a doc's body or metadata | +| | `doc delete ` | Delete a doc — asks for confirmation | +| | `doc versions ` | List a doc's version history | +| | `doc version ` | Show one version's content | +| **TOC** | `toc get ` | Print a repo's table of contents as a tree | +| | `toc update ` | Append, move, edit, or remove a TOC node | +| **Groups** | `group members ` | List members of a group | +| | `group member set ` | Add a member or change their role | +| | `group member remove ` | Remove a member — asks for confirmation | +| **Stats** | `stats group ` | Group-level statistics | +| | `stats members ` | Per-member statistics | +| | `stats books ` | Per-repo statistics | +| | `stats docs ` | Per-doc statistics | + +Repos accept either a numeric id or an `owner/slug` namespace everywhere. Run `yuque --help` for all flags. + +## Output & scripting + +Human-readable tables and records by default; add `--json` to any command for the full API payload: + +```bash +yuque doc list team/handbook --all --json | jq -r '.[].slug' +``` + +Exit codes are stable, so scripts can branch on them: + +| Code | Meaning | +| ---- | ----------------------------- | +| `0` | Success | +| `1` | API or unknown error | +| `2` | Usage error | +| `3` | Authentication error | +| `4` | Not found | +| `5` | Rate limited | + +Colors are disabled automatically when piping, or force-off with `NO_COLOR=1`. Rate-limited and transient errors are retried with backoff before failing. + +## Write access + +`create`, `update`, and `delete` commands modify real content, and the CLI can do whatever your token can do. Destructive commands prompt for confirmation on a TTY and require `--yes` in scripts. Keep the token secret, and prefer a space-scoped team token (with `YUQUE_HOST`) when you only work within one space. + +## Troubleshooting + +| Error | Solution | +| ---------------------------- | --------------------------------------------------------------------------------- | +| `A Yuque API token is required` | Set `YUQUE_TOKEN=YOUR_TOKEN` or pass `--token=YOUR_TOKEN` | +| `401 Unauthorized` | Token invalid or expired — [regenerate it](https://www.yuque.com/settings/tokens) | +| `429 Rate Limited` | Too many requests — the CLI retries automatically; slow down `--all` loops | +| `404 Not Found` | Check the repo id / `owner/slug` namespace and the doc slug | +| `npm` command not found | Install [Node.js](https://nodejs.org/) v20 or later | + +## Development + +```bash +git clone https://github.com/yuque/yuque-cli.git +cd yuque-cli +npm install +npm test # run tests +npm run build # compile TypeScript +npm run dev -- --help # run from source +``` + +The command surface is pinned to [spec/yuque-openapi.yaml](./spec/yuque-openapi.yaml) by [tests/spec-coverage.test.ts](./tests/spec-coverage.test.ts); `npm run check` is the merge gate. + +## Links + +- [Yuque API docs](https://www.yuque.com/yuque/developer/api) +- [yuque-mcp-server](https://github.com/yuque/yuque-mcp-server) — the same knowledge base for AI assistants, via MCP +- [Yuque AI Ecosystem](https://yuque.github.io/yuque-ecosystem/) + +## License + +[MIT](./LICENSE) + +[ci-image]: https://img.shields.io/github/actions/workflow/status/yuque/yuque-cli/ci.yml?style=flat-square&label=CI +[ci-url]: https://github.com/yuque/yuque-cli/actions/workflows/ci.yml +[npm-image]: https://img.shields.io/npm/v/%40yuque%2Fcli?style=flat-square +[npm-url]: https://www.npmjs.com/package/@yuque/cli +[download-image]: https://img.shields.io/npm/dm/%40yuque%2Fcli?style=flat-square +[download-url]: https://www.npmjs.com/package/@yuque/cli +[license-image]: https://img.shields.io/github/license/yuque/yuque-cli?style=flat-square +[license-url]: ./LICENSE diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..9e40d9d --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,156 @@ +
+ +语雀 logo + +

Yuque CLI

+ +在终端里直接使用你的[语雀](https://www.yuque.com/)知识库 ——
搜索、阅读、写作、管理,一条命令搞定。 + +[![CI][ci-image]][ci-url] [![npm version][npm-image]][npm-url] [![npm downloads][download-image]][download-url] [![License][license-image]][license-url] + +[快速开始](#快速开始) · [命令列表](#命令列表26-个) · [脚本化](#输出与脚本化) · [常见问题](#常见问题) · [English](./README.md) + +
+ +完成认证后,知识库触手可及: + +```bash +yuque search "灰度发布" --type doc # 找回那篇只记得大概的文档 +yuque doc get team/handbook onboarding > onboarding.md +yuque doc create team/notes --title "周会纪要" --body-file weekly.md +yuque repo list my-team --group --all --json | jq '.[].name' +``` + +## 快速开始 + +**第一步:获取 Token** —— 前往[语雀开发者设置](https://www.yuque.com/settings/tokens)创建个人访问令牌。如果使用绑定空间的团队 Token,记下空间地址(例如 `https://your-space.yuque.com`),稍后通过 `--host` 或 `YUQUE_HOST` 传入。 + +**第二步:安装并登录:** + +```bash +npm install -g @yuque/cli +export YUQUE_TOKEN=YOUR_TOKEN +yuque auth status +``` + +
+免安装运行(npx) + +```bash +YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status +``` + +
+ +**第三步:开始探索** —— 先 `yuque repo list your-login`,再 `yuque doc list `。 + +## 配置 + +| 配置项 | 环境变量 / 命令行参数 | 说明 | +| ----------------- | ------------------------- | ------------------------------------------------------------------------------------------ | +| Token **(必填)** | `YUQUE_TOKEN` / `--token` | 语雀个人或团队 API Token | +| Host(可选) | `YUQUE_HOST` / `--host` | 站点或空间地址,例如 `https://your-space.yuque.com` —— 绑定空间的团队 Token 和私有化部署必填 | + +命令行参数优先于环境变量,随手 `--token` 覆盖一次总是生效。站点地址会自动规范化(自动补 `/api/v2`);不设置时默认 `https://www.yuque.com`。 + +## 命令列表(26 个) + +每条命令都对应[语雀 OpenAPI](https://www.yuque.com/yuque/developer/api) —— 映射关系由契约测试锁定在内置规格文件上。 + +| 分类 | 命令 | 说明 | +| ---------- | ------------------------------------ | --------------------------------------- | +| **认证** | `ping` | 验证与语雀 API 的连通性 | +| | `auth status` | 查看当前登录身份 | +| **用户** | `user info` | 查看当前 Token 对应的用户 | +| | `user groups ` | 列出用户加入的团队 | +| **搜索** | `search ` | 搜索文档或知识库,支持分页 | +| **知识库** | `repo list ` | 列出用户或团队(`--group`)的知识库 | +| | `repo get ` | 按 id 或 `owner/slug` 查看知识库 | +| | `repo create ` | 创建知识库 | +| | `repo update ` | 更新名称、路径、简介或可见性 | +| | `repo delete ` | 删除知识库 —— 需要确认 | +| **文档** | `doc list ` | 列出知识库中的文档,`--all` 拉取全量 | +| | `doc get ` | 输出文档 markdown 正文(管道友好) | +| | `doc create ` | 从 `--body` 或 `--body-file` 创建文档 | +| | `doc update ` | 更新文档正文或元信息 | +| | `doc delete ` | 删除文档 —— 需要确认 | +| | `doc versions ` | 列出文档的版本历史 | +| | `doc version ` | 查看某个版本的内容 | +| **目录** | `toc get ` | 以树形输出知识库目录 | +| | `toc update ` | 追加、移动、编辑或删除目录节点 | +| **团队** | `group members ` | 列出团队成员 | +| | `group member set ` | 添加成员或调整角色 | +| | `group member remove ` | 移除成员 —— 需要确认 | +| **统计** | `stats group ` | 团队维度统计 | +| | `stats members ` | 成员维度统计 | +| | `stats books ` | 知识库维度统计 | +| | `stats docs ` | 文档维度统计 | + +知识库参数在所有命令中都同时接受数字 id 和 `owner/slug` 路径。各命令的完整参数见 `yuque <命令> --help`。 + +## 输出与脚本化 + +默认输出人类可读的表格与详情;任何命令加 `--json` 即输出完整 API 数据: + +```bash +yuque doc list team/handbook --all --json | jq -r '.[].slug' +``` + +退出码保持稳定,脚本可以放心分支: + +| 退出码 | 含义 | +| ------ | ---------------- | +| `0` | 成功 | +| `1` | API 或未知错误 | +| `2` | 用法错误 | +| `3` | 认证错误 | +| `4` | 资源不存在 | +| `5` | 触发限流 | + +管道输出时自动关闭颜色,也可用 `NO_COLOR=1` 强制关闭。限流与瞬时错误会自动退避重试后再失败。 + +## 写入权限 + +`create`、`update`、`delete` 命令会修改知识库中的真实内容,CLI 的能力边界就是你 Token 的能力边界。破坏性命令在终端交互时会要求确认,在脚本中必须显式传 `--yes`。请妥善保管 Token;只在单一空间内工作时,建议使用绑定空间的团队 Token(配合 `YUQUE_HOST`)。 + +## 常见问题 + +| 错误 | 解决方案 | +| ------------------------------- | --------------------------------------------------------------------------- | +| `A Yuque API token is required` | 设置 `YUQUE_TOKEN=YOUR_TOKEN` 或传入 `--token=YOUR_TOKEN` | +| `401 Unauthorized` | Token 无效或已过期 —— [重新生成](https://www.yuque.com/settings/tokens) | +| `429 Rate Limited` | 请求过于频繁 —— CLI 会自动重试;`--all` 循环请放慢节奏 | +| `404 Not Found` | 检查知识库 id / `owner/slug` 路径以及文档 slug 是否正确 | +| 找不到 `npm` 命令 | 安装 [Node.js](https://nodejs.org/) v20 或更高版本 | + +## 参与开发 + +```bash +git clone https://github.com/yuque/yuque-cli.git +cd yuque-cli +npm install +npm test # 运行测试 +npm run build # 编译 TypeScript +npm run dev -- --help # 从源码运行 +``` + +命令面由 [tests/spec-coverage.test.ts](./tests/spec-coverage.test.ts) 锁定在 [spec/yuque-openapi.yaml](./spec/yuque-openapi.yaml) 上;`npm run check` 是合并门槛。 + +## 相关链接 + +- [语雀 API 文档](https://www.yuque.com/yuque/developer/api) +- [yuque-mcp-server](https://github.com/yuque/yuque-mcp-server) —— 通过 MCP 让 AI 助手使用同一个知识库 +- [语雀 AI 生态](https://yuque.github.io/yuque-ecosystem/) + +## 开源协议 + +[MIT](./LICENSE) + +[ci-image]: https://img.shields.io/github/actions/workflow/status/yuque/yuque-cli/ci.yml?style=flat-square&label=CI +[ci-url]: https://github.com/yuque/yuque-cli/actions/workflows/ci.yml +[npm-image]: https://img.shields.io/npm/v/%40yuque%2Fcli?style=flat-square +[npm-url]: https://www.npmjs.com/package/@yuque/cli +[download-image]: https://img.shields.io/npm/dm/%40yuque%2Fcli?style=flat-square +[download-url]: https://www.npmjs.com/package/@yuque/cli +[license-image]: https://img.shields.io/github/license/yuque/yuque-cli?style=flat-square +[license-url]: ./LICENSE diff --git a/src/cli.ts b/src/cli.ts index 49b0371..0f805d9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,10 @@ export function buildProgram(): Command { .option('--host ', 'Yuque host, e.g. https://your-space.yuque.com (overrides YUQUE_HOST)') .option('--json', 'print the full API response as JSON') .showHelpAfterError('(run with --help for usage)'); + // Must precede register* calls: commander copies the exit callback into each + // subcommand at .command() creation time, and the exit-code contract (usage + // errors -> 2) depends on subcommand errors throwing instead of process.exit. + program.exitOverride(); registerAuthCommands(program); registerUserCommands(program); @@ -37,7 +41,6 @@ export function buildProgram(): Command { /** Parse and run; returns the process exit code instead of exiting, for testability. */ export async function runCli(argv: string[]): Promise { const program = buildProgram(); - program.exitOverride(); try { await program.parseAsync(argv); return typeof process.exitCode === 'number' ? process.exitCode : 0; diff --git a/src/client/api/doc.ts b/src/client/api/doc.ts new file mode 100644 index 0000000..5033f63 --- /dev/null +++ b/src/client/api/doc.ts @@ -0,0 +1,90 @@ +import type { YuqueHttp } from '../http.js'; +import type { + ApiEnvelope, + V2Doc, + V2DocDetail, + V2DocVersion, + V2DocVersionDetail, +} from '../types.js'; +import { repoBasePath, type RepoRef } from '../repo-ref.js'; + +/** Query parameters of GET {repo}/docs, mirroring the spec. */ +export interface DocListParams { + offset?: number; + limit?: number; + deleted?: boolean; + changed_at_gte?: string; + optional_properties?: string; + // Structural bridge to YuqueHttp's Record params. + [key: string]: unknown; +} + +/** Request body of doc create/update; create requires `body`, update sends only set fields. */ +export interface DocWritePayload { + title?: string; + slug?: string; + body?: string; + format?: string; + public?: number; +} + +export async function listDocs( + http: YuqueHttp, + repo: RepoRef, + params: DocListParams = {} +): Promise { + const res = await http.get>(`${repoBasePath(repo)}/docs`, params); + return res.data; +} + +export async function getDoc(http: YuqueHttp, repo: RepoRef, doc: string): Promise { + const res = await http.get>( + `${repoBasePath(repo)}/docs/${encodeURIComponent(doc)}` + ); + return res.data; +} + +/** Fetch a doc by its globally unique numeric id (no repo needed). */ +export async function getDocById(http: YuqueHttp, id: number): Promise { + const res = await http.get>(`/repos/docs/${id}`); + return res.data; +} + +export async function createDoc( + http: YuqueHttp, + repo: RepoRef, + payload: DocWritePayload +): Promise { + const res = await http.post>(`${repoBasePath(repo)}/docs`, payload); + return res.data; +} + +export async function updateDoc( + http: YuqueHttp, + repo: RepoRef, + doc: string, + payload: DocWritePayload +): Promise { + const res = await http.put>( + `${repoBasePath(repo)}/docs/${encodeURIComponent(doc)}`, + payload + ); + return res.data; +} + +export async function deleteDoc(http: YuqueHttp, repo: RepoRef, doc: string): Promise { + const res = await http.delete>( + `${repoBasePath(repo)}/docs/${encodeURIComponent(doc)}` + ); + return res.data; +} + +export async function listDocVersions(http: YuqueHttp, docId: number): Promise { + const res = await http.get>('/doc_versions', { doc_id: docId }); + return res.data; +} + +export async function getDocVersion(http: YuqueHttp, id: number): Promise { + const res = await http.get>(`/doc_versions/${id}`); + return res.data; +} diff --git a/src/client/api/group.ts b/src/client/api/group.ts new file mode 100644 index 0000000..a7f7840 --- /dev/null +++ b/src/client/api/group.ts @@ -0,0 +1,44 @@ +import type { YuqueHttp } from '../http.js'; +import type { ApiEnvelope, V2GroupUser } from '../types.js'; + +export interface GroupMemberListParams { + /** Role filter: 0 admin, 1 member, 2 read-only. */ + role?: number; + offset?: number; +} + +function groupUsersPath(login: string, user?: string): string { + const base = `/groups/${encodeURIComponent(login)}/users`; + return user === undefined ? base : `${base}/${encodeURIComponent(user)}`; +} + +export async function listGroupMembers( + http: YuqueHttp, + login: string, + params: GroupMemberListParams = {} +): Promise { + const query: Record = {}; + if (params.role !== undefined) query.role = params.role; + if (params.offset !== undefined) query.offset = params.offset; + const res = await http.get>(groupUsersPath(login), query); + return res.data; +} + +export async function updateGroupMember( + http: YuqueHttp, + login: string, + user: string, + role: number +): Promise { + const res = await http.put>(groupUsersPath(login, user), { role }); + return res.data; +} + +export async function removeGroupMember( + http: YuqueHttp, + login: string, + user: string +): Promise<{ user_id?: string }> { + const res = await http.delete>(groupUsersPath(login, user)); + return res.data; +} diff --git a/src/client/api/repo.ts b/src/client/api/repo.ts new file mode 100644 index 0000000..a91385c --- /dev/null +++ b/src/client/api/repo.ts @@ -0,0 +1,76 @@ +import type { YuqueHttp } from '../http.js'; +import { repoBasePath, type RepoRef } from '../repo-ref.js'; +import type { ApiEnvelope, V2Book, V2BookDetail } from '../types.js'; + +/** The spec exposes list/create under both /users/:login and /groups/:login. */ +export type RepoOwner = 'user' | 'group'; + +function ownerReposPath(owner: RepoOwner, login: string): string { + return `/${owner === 'group' ? 'groups' : 'users'}/${encodeURIComponent(login)}/repos`; +} + +export interface ListReposOptions { + offset?: number; + limit?: number; + /** Spec enum: Book | Design. Omit for no server-side filter. */ + type?: string; +} + +export async function listRepos( + http: YuqueHttp, + owner: RepoOwner, + login: string, + options: ListReposOptions = {} +): Promise { + const params: Record = {}; + if (options.offset !== undefined) params.offset = options.offset; + if (options.limit !== undefined) params.limit = options.limit; + if (options.type !== undefined) params.type = options.type; + const res = await http.get>(ownerReposPath(owner, login), params); + return res.data; +} + +export async function getRepo(http: YuqueHttp, ref: RepoRef): Promise { + const res = await http.get>(repoBasePath(ref)); + return res.data; +} + +export interface CreateRepoBody { + name: string; + slug: string; + description?: string; + public?: number; + type?: string; +} + +export async function createRepo( + http: YuqueHttp, + owner: RepoOwner, + login: string, + body: CreateRepoBody +): Promise { + const res = await http.post>(ownerReposPath(owner, login), body); + return res.data; +} + +export interface UpdateRepoBody { + name?: string; + slug?: string; + description?: string; + public?: number; + toc?: string; +} + +export async function updateRepo( + http: YuqueHttp, + ref: RepoRef, + body: UpdateRepoBody +): Promise { + const res = await http.put>(repoBasePath(ref), body); + return res.data; +} + +export async function deleteRepo(http: YuqueHttp, ref: RepoRef): Promise { + const res = await http.delete>(repoBasePath(ref)); + return res.data; +} diff --git a/src/client/api/search.ts b/src/client/api/search.ts new file mode 100644 index 0000000..e77d040 --- /dev/null +++ b/src/client/api/search.ts @@ -0,0 +1,25 @@ +import type { YuqueHttp } from '../http.js'; +import type { ApiEnvelope, V2SearchResult } from '../types.js'; + +export interface SearchParams { + q: string; + type: 'doc' | 'repo'; + /** Namespace scope, e.g. `group` or `group/repo`; defaults to the current user/team. */ + scope?: string; + /** Only results created by this login. */ + creator?: string; + /** Page number (page size is fixed at 20 by the API). */ + page?: number; +} + +/** GET /search — generic search over docs or repos. */ +export async function search(http: YuqueHttp, params: SearchParams): Promise { + const res = await http.get>('/search', { + q: params.q, + type: params.type, + scope: params.scope, + creator: params.creator, + page: params.page, + }); + return res.data; +} diff --git a/src/client/api/stats.ts b/src/client/api/stats.ts new file mode 100644 index 0000000..c8289ce --- /dev/null +++ b/src/client/api/stats.ts @@ -0,0 +1,94 @@ +import type { YuqueHttp } from '../http.js'; +import type { + ApiEnvelope, + V2BookStatistics, + V2DocStatistics, + V2GroupStatistics, + V2MemberStatistics, +} from '../types.js'; + +/** Query filters shared by the members/books/docs statistics list endpoints. */ +export interface StatsListParams { + name?: string; + /** 0 = all time, 30 = last 30 days, 365 = last year. */ + range?: number; + page?: number; + /** The statistics endpoints cap this at 20 (spec maximum). */ + limit?: number; + sortField?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface DocStatsListParams extends StatsListParams { + bookId?: number; +} + +// The spec declares the list payloads' item field as a single object, but the +// live API returns an array; typed as arrays here on purpose. +export interface MemberStatsPage { + members: V2MemberStatistics[]; + total?: number; + [key: string]: unknown; +} + +export interface BookStatsPage { + books: V2BookStatistics[]; + total?: number; + [key: string]: unknown; +} + +export interface DocStatsPage { + docs: V2DocStatistics[]; + total?: number; + [key: string]: unknown; +} + +function statsPath(login: string, suffix = ''): string { + return `/groups/${encodeURIComponent(login)}/statistics${suffix}`; +} + +/** Drop undefined values so unset optional flags never reach the query string. */ +function compact(params: object): Record { + return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined)); +} + +export async function getGroupStatistics( + http: YuqueHttp, + login: string +): Promise { + const res = await http.get>(statsPath(login)); + return res.data; +} + +export async function listMemberStatistics( + http: YuqueHttp, + login: string, + params: StatsListParams = {} +): Promise { + const res = await http.get>( + statsPath(login, '/members'), + compact(params) + ); + return res.data; +} + +export async function listBookStatistics( + http: YuqueHttp, + login: string, + params: StatsListParams = {} +): Promise { + const res = await http.get>( + statsPath(login, '/books'), + compact(params) + ); + return res.data; +} + +export async function listDocStatistics( + http: YuqueHttp, + login: string, + params: DocStatsListParams = {} +): Promise { + const res = await http.get>(statsPath(login, '/docs'), compact(params)); + return res.data; +} diff --git a/src/client/api/toc.ts b/src/client/api/toc.ts new file mode 100644 index 0000000..a906133 --- /dev/null +++ b/src/client/api/toc.ts @@ -0,0 +1,33 @@ +import type { YuqueHttp } from '../http.js'; +import { repoBasePath, type RepoRef } from '../repo-ref.js'; +import type { ApiEnvelope, V2TocItem } from '../types.js'; + +/** Body for PUT /repos/.../toc — mirrors the spec requestBody field-for-field. */ +export interface TocUpdateBody { + action: 'appendNode' | 'prependNode' | 'editNode' | 'removeNode'; + action_mode?: 'sibling' | 'child'; + target_uuid?: string; + node_uuid?: string; + /** Deprecated in the spec in favor of doc_ids. */ + doc_id?: number; + doc_ids?: number[]; + type?: 'DOC' | 'LINK' | 'TITLE'; + title?: string; + url?: string; + open_window?: number; + visible?: number; +} + +export async function getToc(http: YuqueHttp, repo: RepoRef): Promise { + const res = await http.get>(`${repoBasePath(repo)}/toc`); + return res.data; +} + +export async function updateToc( + http: YuqueHttp, + repo: RepoRef, + body: TocUpdateBody +): Promise { + const res = await http.put>(`${repoBasePath(repo)}/toc`, body); + return res.data; +} diff --git a/src/client/api/user.ts b/src/client/api/user.ts new file mode 100644 index 0000000..2ad0ad5 --- /dev/null +++ b/src/client/api/user.ts @@ -0,0 +1,38 @@ +import type { YuqueHttp } from '../http.js'; +import type { ApiEnvelope, V2Group, V2User } from '../types.js'; + +export interface HelloData { + message?: string; + [key: string]: unknown; +} + +/** GET /hello — heartbeat, returns a greeting for the authenticated token. */ +export async function hello(http: YuqueHttp): Promise { + const res = await http.get>('/hello'); + return res.data; +} + +/** GET /user — the user that owns the current token. */ +export async function getCurrentUser(http: YuqueHttp): Promise { + const res = await http.get>('/user'); + return res.data; +} + +export interface ListUserGroupsParams { + /** Role filter: 0 admin, 1 member. */ + role?: number; + offset?: number; +} + +/** GET /users/{id}/groups — groups a user belongs to; `user` is a login or numeric id. */ +export async function listUserGroups( + http: YuqueHttp, + user: string, + params: ListUserGroupsParams = {} +): Promise { + const res = await http.get>(`/users/${encodeURIComponent(user)}/groups`, { + role: params.role, + offset: params.offset, + }); + return res.data; +} diff --git a/src/client/repo-ref.ts b/src/client/repo-ref.ts index 0d37d82..879e756 100644 --- a/src/client/repo-ref.ts +++ b/src/client/repo-ref.ts @@ -5,8 +5,7 @@ import { UsageError } from '../errors.js'; * or a `group/slug` namespace. Both map onto the same /repos/... URL shape. */ export type RepoRef = - | { kind: 'id'; id: string } - | { kind: 'namespace'; group: string; slug: string }; + { kind: 'id'; id: string } | { kind: 'namespace'; group: string; slug: string }; export function parseRepoRef(input: string): RepoRef { const trimmed = input.trim(); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index ab23db5..5d9846c 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,6 +1,33 @@ import type { Command } from 'commander'; +import { getContext } from '../context.js'; +import { resolveHost } from '../config.js'; +import { printJson, printOk } from '../output.js'; +import { getCurrentUser, hello } from '../client/api/user.js'; -export function registerAuthCommands(_program: Command): void { - // Placeholder — the auth command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +export function registerAuthCommands(program: Command): void { + const ping = program + .command('ping') + .description('check connectivity and token validity against the Yuque API'); + ping.action(async () => { + const ctx = getContext(ping); + const data = await hello(ctx.http); + if (ctx.json) { + printJson(data); + return; + } + printOk(data.message ?? 'ok'); + }); + + const auth = program.command('auth').description('authentication status'); + const status = auth.command('status').description('show which account the token belongs to'); + status.action(async () => { + const ctx = getContext(status); + const user = await getCurrentUser(ctx.http); + if (ctx.json) { + printJson(user); + return; + } + const host = resolveHost(status.optsWithGlobals<{ host?: string }>().host); + process.stdout.write(`Logged in to ${host} as ${user.name} (@${user.login})\n`); + }); } diff --git a/src/commands/doc.ts b/src/commands/doc.ts index 7423a50..94131ec 100644 --- a/src/commands/doc.ts +++ b/src/commands/doc.ts @@ -1,6 +1,306 @@ -import type { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { Option, type Command } from 'commander'; +import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; +import { confirmDestructive } from '../confirm.js'; +import { printJson, printOk, printRecord, printTable } from '../output.js'; +import { parseRepoRef } from '../client/repo-ref.js'; +import { fetchAllPages } from '../client/paginate.js'; +import { + createDoc, + deleteDoc, + getDoc, + getDocById, + getDocVersion, + listDocVersions, + listDocs, + updateDoc, + type DocListParams, + type DocWritePayload, +} from '../client/api/doc.js'; +import type { V2DocDetail } from '../client/types.js'; -export function registerDocCommands(_program: Command): void { - // Placeholder — the doc command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +const REPO_ARG_HELP = 'repo id or group/slug namespace'; + +const DOC_META_FIELDS = [ + 'id', + 'slug', + 'title', + 'format', + 'public', + 'status', + 'word_count', + 'read_count', + 'created_at', + 'updated_at', +]; + +const VERSION_META_FIELDS = ['id', 'doc_id', 'slug', 'title', 'format', 'created_at', 'updated_at']; + +function parseIntFlag(value: string | undefined, flag: string): number | undefined { + if (value === undefined) return undefined; + if (!/^\d+$/.test(value)) { + throw new UsageError(`${flag} expects a non-negative integer, got "${value}"`); + } + return Number(value); +} + +interface BodyOpts { + body?: string; + bodyFile?: string; +} + +/** Resolve the doc body from --body / --body-file; the two flags are mutually exclusive. */ +function resolveBody(opts: BodyOpts): string | undefined { + if (opts.body !== undefined && opts.bodyFile !== undefined) { + throw new UsageError('--body and --body-file are mutually exclusive'); + } + if (opts.bodyFile === undefined) return opts.body; + try { + return readFileSync(opts.bodyFile, 'utf8'); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new UsageError(`cannot read --body-file ${opts.bodyFile}: ${reason}`); + } +} + +interface DocWriteOpts extends BodyOpts { + title?: string; + slug?: string; + format?: string; + public?: string; +} + +function buildWritePayload(opts: DocWriteOpts): DocWritePayload { + const payload: DocWritePayload = {}; + if (opts.title !== undefined) payload.title = opts.title; + if (opts.slug !== undefined) payload.slug = opts.slug; + const body = resolveBody(opts); + if (body !== undefined) payload.body = body; + if (opts.format !== undefined) payload.format = opts.format; + if (opts.public !== undefined) payload.public = Number(opts.public); + return payload; +} + +function formatOption(): Option { + return new Option('--format ', 'content format').choices(['markdown', 'html', 'lake']); +} + +function publicOption(): Option { + return new Option('--public ', 'visibility (0 private, 1 public, 2 org-only)').choices([ + '0', + '1', + '2', + ]); +} + +/** Stream the doc body to stdout as-is (markdown pipes cleanly), newline-terminated. */ +function writeBody(body: unknown): void { + const text = typeof body === 'string' ? body : ''; + if (text === '') return; + process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); +} + +export function registerDocCommands(program: Command): void { + const doc = program.command('doc').description('Work with documents (文档)'); + + const list = doc.command('list'); + list + .description('List the docs of a repo') + .argument('', REPO_ARG_HELP) + .option('--offset ', 'pagination offset') + .option('--limit ', 'page size (max 100)') + .option('--all', 'fetch all pages') + .option('--deleted', 'list deleted docs') + .option('--changed-at-gte ', 'only docs changed at or after this ISO 8601 time') + .option( + '--optional-properties ', + 'extra fields, comma-separated: hits, tags, latest_version_id' + ) + .action( + async ( + repo: string, + opts: { + offset?: string; + limit?: string; + all?: boolean; + deleted?: boolean; + changedAtGte?: string; + optionalProperties?: string; + } + ) => { + const ctx = getContext(list); + const ref = parseRepoRef(repo); + const filters: DocListParams = {}; + if (opts.deleted) filters.deleted = true; + if (opts.changedAtGte !== undefined) filters.changed_at_gte = opts.changedAtGte; + if (opts.optionalProperties !== undefined) { + filters.optional_properties = opts.optionalProperties; + } + const docs = opts.all + ? await fetchAllPages((offset, limit) => + listDocs(ctx.http, ref, { ...filters, offset, limit }) + ) + : await listDocs(ctx.http, ref, { + ...filters, + offset: parseIntFlag(opts.offset, '--offset'), + limit: parseIntFlag(opts.limit, '--limit'), + }); + if (ctx.json) { + printJson(docs); + return; + } + printTable(docs, [ + { key: 'slug', header: 'SLUG' }, + { key: 'title', header: 'TITLE' }, + { key: 'word_count', header: 'WORDS' }, + { key: 'updated_at', header: 'UPDATED' }, + ]); + } + ); + + const get = doc.command('get'); + get + .description('Show a doc: `doc get ` or `doc get `') + .argument('', ' , or a single global numeric doc id') + .option('--meta', 'print metadata instead of the body') + .action(async (target: string[], opts: { meta?: boolean }) => { + const ctx = getContext(get); + let detail: V2DocDetail; + if (target.length === 2) { + detail = await getDoc(ctx.http, parseRepoRef(target[0]), target[1]); + } else if (target.length === 1) { + if (!/^\d+$/.test(target[0])) { + throw new UsageError( + `"${target[0]}" is not a numeric doc id — pass or a global numeric doc id` + ); + } + detail = await getDocById(ctx.http, Number(target[0])); + } else { + throw new UsageError('doc get takes or a single numeric '); + } + if (ctx.json) { + printJson(detail); + return; + } + if (opts.meta) { + printRecord(detail, DOC_META_FIELDS); + return; + } + writeBody(detail.body); + }); + + const create = doc.command('create'); + create + .description('Create a doc in a repo') + .argument('', REPO_ARG_HELP) + .requiredOption('--title ', 'doc title') + .option('--slug <slug>', 'doc slug (URL path)') + .option('--body <content>', 'doc body content') + .option('--body-file <path>', 'read the doc body from a file') + .addOption(formatOption()) + .addOption(publicOption()) + .action(async (repo: string, opts: DocWriteOpts) => { + const ctx = getContext(create); + const payload = buildWritePayload(opts); + if (payload.body === undefined) { + throw new UsageError( + 'a doc body is required — pass --body <content> or --body-file <path>' + ); + } + const created = await createDoc(ctx.http, parseRepoRef(repo), payload); + if (ctx.json) { + printJson(created); + return; + } + printOk(`Created doc ${repo}/${created.slug} (id ${created.id}): ${created.title}`); + }); + + const update = doc.command('update'); + update + .description('Update a doc (only the given fields are changed)') + .argument('<repo>', REPO_ARG_HELP) + .argument('<doc>', 'doc slug or id') + .option('--title <title>', 'doc title') + .option('--slug <slug>', 'doc slug (URL path)') + .option('--body <content>', 'doc body content') + .option('--body-file <path>', 'read the doc body from a file') + .addOption(formatOption()) + .addOption(publicOption()) + .action(async (repo: string, docRef: string, opts: DocWriteOpts) => { + const ctx = getContext(update); + const payload = buildWritePayload(opts); + if (Object.keys(payload).length === 0) { + throw new UsageError( + 'nothing to update — pass at least one of --title/--slug/--body/--body-file/--format/--public' + ); + } + const updated = await updateDoc(ctx.http, parseRepoRef(repo), docRef, payload); + if (ctx.json) { + printJson(updated); + return; + } + printOk(`Updated doc ${repo}/${docRef}`); + }); + + const del = doc.command('delete'); + del + .description('Delete a doc') + .argument('<repo>', REPO_ARG_HELP) + .argument('<doc>', 'doc slug or id') + .option('--yes', 'skip the confirmation prompt') + .action(async (repo: string, docRef: string, opts: { yes?: boolean }) => { + await confirmDestructive(`delete doc ${repo}/${docRef}`, Boolean(opts.yes)); + const ctx = getContext(del); + const deleted = await deleteDoc(ctx.http, parseRepoRef(repo), docRef); + if (ctx.json) { + printJson(deleted); + return; + } + printOk(`Deleted doc ${repo}/${docRef}`); + }); + + const versions = doc.command('versions'); + versions + .description('List the published versions of a doc (most recent 100)') + .argument('<doc-id>', 'numeric doc id') + .action(async (docId: string) => { + if (!/^\d+$/.test(docId)) { + throw new UsageError(`<doc-id> expects a numeric doc id, got "${docId}"`); + } + const ctx = getContext(versions); + const items = await listDocVersions(ctx.http, Number(docId)); + if (ctx.json) { + printJson(items); + return; + } + printTable(items, [ + { key: 'id', header: 'ID' }, + { key: 'title', header: 'TITLE' }, + { key: 'updated_at', header: 'UPDATED' }, + { key: 'user', header: 'USER', format: (row) => row.user?.name ?? '' }, + ]); + }); + + const version = doc.command('version'); + version + .description('Show one published version of a doc') + .argument('<version-id>', 'numeric version id') + .option('--meta', 'print metadata instead of the body') + .action(async (versionId: string, opts: { meta?: boolean }) => { + if (!/^\d+$/.test(versionId)) { + throw new UsageError(`<version-id> expects a numeric version id, got "${versionId}"`); + } + const ctx = getContext(version); + const detail = await getDocVersion(ctx.http, Number(versionId)); + if (ctx.json) { + printJson(detail); + return; + } + if (opts.meta) { + printRecord(detail, VERSION_META_FIELDS); + return; + } + writeBody(detail.body_md ?? detail.body); + }); } diff --git a/src/commands/group.ts b/src/commands/group.ts index fedef5d..5e8922d 100644 --- a/src/commands/group.ts +++ b/src/commands/group.ts @@ -1,6 +1,99 @@ -import type { Command } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; +import { getContext } from '../context.js'; +import { confirmDestructive } from '../confirm.js'; +import { printJson, printOk, printTable } from '../output.js'; +import { fetchAllPages } from '../client/paginate.js'; +import { listGroupMembers, removeGroupMember, updateGroupMember } from '../client/api/group.js'; +import type { V2GroupUser } from '../client/types.js'; -export function registerGroupCommands(_program: Command): void { - // Placeholder — the group command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +const ROLE_LABELS: Record<number, string> = { 0: 'admin', 1: 'member', 2: 'read-only' }; + +function roleLabel(role: number): string { + return ROLE_LABELS[role] ?? String(role); +} + +function parseOffset(value: string): number { + if (!/^\d+$/.test(value)) throw new InvalidArgumentError('Expected a non-negative integer.'); + return Number(value); +} + +export function registerGroupCommands(program: Command): void { + // runCli applies exitOverride to the root only after registration, so subcommands + // must opt in themselves for usage errors to surface as CommanderError (exit 2). + const group = program + .command('group') + .description('Manage groups (团队) and their members') + .exitOverride(); + + const members = group + .command('members') + .description('List members of a group') + .argument('<login>', 'group login or numeric id') + .addOption( + new Option('--role <role>', 'filter by role (0: admin, 1: member, 2: read-only)').choices([ + '0', + '1', + '2', + ]) + ) + .option('--offset <n>', 'pagination offset (page size is fixed at 100 by the API)', parseOffset) + .option('--all', 'fetch all pages') + .action(async (login: string) => { + const ctx = getContext(members); + const opts = members.opts<{ role?: string; offset?: number; all?: boolean }>(); + const role = opts.role === undefined ? undefined : Number(opts.role); + const rows = opts.all + ? await fetchAllPages((offset) => listGroupMembers(ctx.http, login, { role, offset })) + : await listGroupMembers(ctx.http, login, { role, offset: opts.offset }); + if (ctx.json) { + printJson(rows); + return; + } + printTable<V2GroupUser>(rows, [ + { key: 'login', header: 'LOGIN', format: (m) => m.user?.login ?? '' }, + { key: 'name', header: 'NAME', format: (m) => m.user?.name ?? '' }, + { key: 'role', header: 'ROLE', format: (m) => roleLabel(m.role) }, + ]); + }); + + const member = group.command('member').description('Manage a single group member'); + + const set = member + .command('set') + .description('Add a member to a group or change their role') + .argument('<login>', 'group login or numeric id') + .argument('<user>', 'user login or numeric id') + .addOption( + new Option('--role <role>', 'role (0: admin, 1: member, 2: read-only)') + .choices(['0', '1', '2']) + .makeOptionMandatory() + ) + .action(async (login: string, user: string) => { + const ctx = getContext(set); + const role = Number(set.opts<{ role: string }>().role); + const result = await updateGroupMember(ctx.http, login, user, role); + if (ctx.json) { + printJson(result); + return; + } + printOk(`Set ${user} in group ${login} to role ${roleLabel(role)}`); + }); + + const remove = member + .command('remove') + .description('Remove a member from a group') + .argument('<login>', 'group login or numeric id') + .argument('<user>', 'user login or numeric id') + .option('--yes', 'skip the confirmation prompt') + .action(async (login: string, user: string) => { + const opts = remove.opts<{ yes?: boolean }>(); + await confirmDestructive(`remove member ${user} from group ${login}`, Boolean(opts.yes)); + const ctx = getContext(remove); + const result = await removeGroupMember(ctx.http, login, user); + if (ctx.json) { + printJson(result); + return; + } + printOk(`Removed ${user} from group ${login}`); + }); } diff --git a/src/commands/repo.ts b/src/commands/repo.ts index 474e295..3c5652e 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -1,6 +1,198 @@ import type { Command } from 'commander'; +import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; +import { confirmDestructive } from '../confirm.js'; +import { printJson, printOk, printRecord, printTable } from '../output.js'; +import { parseRepoRef } from '../client/repo-ref.js'; +import { fetchAllPages } from '../client/paginate.js'; +import { + createRepo, + deleteRepo, + getRepo, + listRepos, + updateRepo, + type CreateRepoBody, + type RepoOwner, + type UpdateRepoBody, +} from '../client/api/repo.js'; +import type { V2Book } from '../client/types.js'; -export function registerRepoCommands(_program: Command): void { - // Placeholder — the repo command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +function parseNonNegativeInt(value: string): number { + if (!/^\d+$/.test(value.trim())) { + throw new UsageError(`Expected a non-negative integer, got "${value}"`); + } + return Number(value); +} + +const LIST_TYPES = ['Book', 'Design', 'all']; + +/** `all` (like omitting the flag) means no server-side filter; the spec enum is Book|Design. */ +function typeFilter(value: string | undefined): string | undefined { + if (value === undefined || value === 'all') return undefined; + if (!LIST_TYPES.includes(value)) { + throw new UsageError(`Invalid --type "${value}" — expected one of: ${LIST_TYPES.join(', ')}`); + } + return value; +} + +function repoLabel(book: V2Book): string { + return String(book.namespace ?? book.slug); +} + +export function registerRepoCommands(program: Command): void { + const repo = program.command('repo').description('Manage knowledge bases (知识库)'); + + const list = repo + .command('list') + .description('List the repos of a user or group') + .argument('<login>', 'user/group login or id') + .option('--group', 'treat <login> as a group instead of a user') + .option('--type <type>', 'filter by repo type: Book, Design, or all') + .option('--offset <n>', 'pagination offset', parseNonNegativeInt) + .option('--limit <n>', 'page size, max 100', parseNonNegativeInt) + .option('--all', 'fetch every page (overrides --offset/--limit)') + .action( + async ( + login: string, + opts: { group?: boolean; type?: string; offset?: number; limit?: number; all?: boolean } + ) => { + const type = typeFilter(opts.type); + const ctx = getContext(list); + const owner: RepoOwner = opts.group ? 'group' : 'user'; + const books = opts.all + ? await fetchAllPages((offset, limit) => + listRepos(ctx.http, owner, login, { offset, limit, type }) + ) + : await listRepos(ctx.http, owner, login, { + offset: opts.offset, + limit: opts.limit, + type, + }); + if (ctx.json) { + printJson(books); + return; + } + printTable(books, [ + { key: 'namespace', header: 'NAMESPACE', format: repoLabel }, + { key: 'name', header: 'NAME' }, + { key: 'items_count', header: 'ITEMS' }, + { key: 'updated_at', header: 'UPDATED' }, + ]); + } + ); + + const get = repo + .command('get') + .description('Show the details of a repo') + .argument('<repo>', 'repo id or group/slug namespace') + .action(async (repoArg: string) => { + const ref = parseRepoRef(repoArg); + const ctx = getContext(get); + const book = await getRepo(ctx.http, ref); + if (ctx.json) { + printJson(book); + return; + } + printRecord(book, [ + 'id', + 'type', + 'namespace', + 'name', + 'slug', + 'description', + 'public', + 'items_count', + 'created_at', + 'updated_at', + ]); + }); + + const create = repo + .command('create') + .description('Create a repo under a user or group') + .argument('<login>', 'owner user/group login or id') + .requiredOption('--name <name>', 'repo name') + .requiredOption('--slug <slug>', 'repo path (slug)') + .option('--group', 'create under a group instead of a user') + .option('--description <description>', 'repo description') + .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parseNonNegativeInt) + .option('--type <type>', 'repo type, e.g. Book') + .action( + async ( + login: string, + opts: { + name: string; + slug: string; + group?: boolean; + description?: string; + public?: number; + type?: string; + } + ) => { + const ctx = getContext(create); + const body: CreateRepoBody = { name: opts.name, slug: opts.slug }; + if (opts.description !== undefined) body.description = opts.description; + if (opts.public !== undefined) body.public = opts.public; + if (opts.type !== undefined) body.type = opts.type; + const book = await createRepo(ctx.http, opts.group ? 'group' : 'user', login, body); + if (ctx.json) { + printJson(book); + return; + } + printOk(`Created repo ${repoLabel(book)} (id: ${book.id})`); + } + ); + + const update = repo + .command('update') + .description('Update the settings of a repo') + .argument('<repo>', 'repo id or group/slug namespace') + .option('--name <name>', 'new name') + .option('--slug <slug>', 'new path (slug)') + .option('--description <description>', 'new description') + .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parseNonNegativeInt) + .option('--toc <markdown>', 'replace the table of contents (Markdown list)') + .action( + async ( + repoArg: string, + opts: { name?: string; slug?: string; description?: string; public?: number; toc?: string } + ) => { + const ref = parseRepoRef(repoArg); + const body: UpdateRepoBody = {}; + if (opts.name !== undefined) body.name = opts.name; + if (opts.slug !== undefined) body.slug = opts.slug; + if (opts.description !== undefined) body.description = opts.description; + if (opts.public !== undefined) body.public = opts.public; + if (opts.toc !== undefined) body.toc = opts.toc; + if (Object.keys(body).length === 0) { + throw new UsageError( + 'Nothing to update — pass at least one of --name/--slug/--description/--public/--toc.' + ); + } + const ctx = getContext(update); + const book = await updateRepo(ctx.http, ref, body); + if (ctx.json) { + printJson(book); + return; + } + printOk(`Updated repo ${repoLabel(book)}`); + } + ); + + const del = repo + .command('delete') + .description('Delete a repo') + .argument('<repo>', 'repo id or group/slug namespace') + .option('--yes', 'skip the confirmation prompt') + .action(async (repoArg: string, opts: { yes?: boolean }) => { + const ref = parseRepoRef(repoArg); + await confirmDestructive(`delete repo ${repoArg}`, Boolean(opts.yes)); + const ctx = getContext(del); + const book = await deleteRepo(ctx.http, ref); + if (ctx.json) { + printJson(book); + return; + } + printOk(`Deleted repo ${repoLabel(book)}`); + }); } diff --git a/src/commands/search.ts b/src/commands/search.ts index 2e5104d..e175d9a 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,6 +1,52 @@ import type { Command } from 'commander'; +import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; +import { printJson, printTable } from '../output.js'; +import { search } from '../client/api/search.js'; -export function registerSearchCommands(_program: Command): void { - // Placeholder — the search command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +const SEARCH_TYPES = ['doc', 'repo']; + +// Commander-level validation (makeOptionMandatory/choices/argParser errors) exits +// via process.exit on subcommands, bypassing runCli's exit-code contract — so +// required/enum/integer checks live here and throw UsageError (exit 2) instead. +function pageFlag(value: string): number { + if (!/^\d+$/.test(value) || Number(value) < 1) { + throw new UsageError(`--page expects a positive integer, got "${value}"`); + } + return Number(value); +} + +export function registerSearchCommands(program: Command): void { + const cmd = program + .command('search') + .description('search docs or repos') + .argument('<query>', 'search keywords') + .option('--type <type>', 'what to search for: doc or repo (required)') + .option('--scope <ns>', 'restrict to a group or group/repo namespace') + .option('--creator <login>', 'only results created by this user') + .option('--page <n>', 'page number (page size is fixed at 20)', pageFlag); + cmd.action(async (query: string) => { + const opts = cmd.opts<{ type?: string; scope?: string; creator?: string; page?: number }>(); + if (!opts.type) throw new UsageError('--type <doc|repo> is required'); + if (!SEARCH_TYPES.includes(opts.type)) { + throw new UsageError(`invalid --type "${opts.type}" — expected doc or repo`); + } + const ctx = getContext(cmd); + const results = await search(ctx.http, { + q: query, + type: opts.type as 'doc' | 'repo', + scope: opts.scope, + creator: opts.creator, + page: opts.page, + }); + if (ctx.json) { + printJson(results); + return; + } + printTable(results, [ + { key: 'type', header: 'TYPE' }, + { key: 'title', header: 'TITLE' }, + { key: 'url', header: 'URL' }, + ]); + }); } diff --git a/src/commands/stats.ts b/src/commands/stats.ts index d0b42bf..a48f8af 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -1,6 +1,237 @@ import type { Command } from 'commander'; +import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; +import { printJson, printRecord, printTable, type Column } from '../output.js'; +import { fetchAllPages } from '../client/paginate.js'; +import type { YuqueHttp } from '../client/http.js'; +import { + getGroupStatistics, + listBookStatistics, + listDocStatistics, + listMemberStatistics, + type DocStatsListParams, +} from '../client/api/stats.js'; +import type { V2BookStatistics, V2DocStatistics, V2MemberStatistics } from '../client/types.js'; -export function registerStatsCommands(_program: Command): void { - // Placeholder — the stats command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +/** Spec maximum for `limit` on the statistics list endpoints. */ +const MAX_PAGE_SIZE = 20; + +const GROUP_FIELDS = [ + 'bizdate', + 'member_count', + 'collaborator_count', + 'doc_count', + 'book_count', + 'write_count', + 'read_count', + 'comment_count', + 'like_count', + 'data_usage', +]; + +const MEMBER_SORT_FIELDS = ['write_doc_count', 'write_count', 'read_count', 'like_count']; + +const BOOK_SORT_FIELDS = [ + 'content_updated_at_ms', + 'word_count', + 'post_count', + 'read_count', + 'like_count', + 'watch_count', + 'comment_count', +]; + +const DOC_SORT_FIELDS = [ + 'content_updated_at', + 'word_count', + 'read_count', + 'like_count', + 'comment_count', + 'created_at', +]; + +const MEMBER_COLUMNS: Column<V2MemberStatistics>[] = [ + { key: 'user', header: 'NAME', format: (row) => row.user?.name ?? String(row.user_id ?? '') }, + { key: 'user_id', header: 'USER ID' }, + { key: 'write_doc_count', header: 'DOCS' }, + { key: 'write_count', header: 'WRITES' }, + { key: 'read_count', header: 'READS' }, + { key: 'like_count', header: 'LIKES' }, +]; + +const BOOK_COLUMNS: Column<V2BookStatistics>[] = [ + { key: 'book_id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'slug', header: 'SLUG' }, + { key: 'post_count', header: 'DOCS' }, + { key: 'word_count', header: 'WORDS' }, + { key: 'read_count', header: 'READS' }, + { key: 'like_count', header: 'LIKES' }, +]; + +const DOC_COLUMNS: Column<V2DocStatistics>[] = [ + { key: 'doc_id', header: 'ID' }, + { key: 'title', header: 'TITLE' }, + { key: 'slug', header: 'SLUG' }, + { key: 'book_id', header: 'BOOK ID' }, + { key: 'read_count', header: 'READS' }, + { key: 'like_count', header: 'LIKES' }, + { key: 'comment_count', header: 'COMMENTS' }, +]; + +interface ListOptions { + name?: string; + range?: number; + page?: number; + limit?: number; + sortField?: string; + sortOrder?: 'asc' | 'desc'; + all?: boolean; + bookId?: number; +} + +function parsePositiveInt(flag: string): (value: string) => number { + return (value) => { + if (!/^[1-9]\d*$/.test(value)) { + throw new UsageError(`${flag} expects a positive integer, got "${value}"`); + } + return Number(value); + }; +} + +// Enum validation lives in argParsers (not Option.choices) so violations throw +// UsageError and exit 2; commander's own choices error path calls process.exit +// on subcommands created before runCli installs exitOverride on the root. +function parseChoice(flag: string, choices: readonly string[]): (value: string) => string { + return (value) => { + if (!choices.includes(value)) { + throw new UsageError(`${flag} must be one of: ${choices.join(', ')} (got "${value}")`); + } + return value; + }; +} + +function parseRange(value: string): number { + return Number(parseChoice('--range', ['0', '30', '365'])(value)); +} + +function withListOptions(cmd: Command, sortFields: string[]): Command { + return cmd + .option('--name <name>', 'filter by name') + .option('--range <days>', 'time range in days: 0 (all time), 30 or 365', parseRange) + .option('--page <n>', 'page number', parsePositiveInt('--page')) + .option('--limit <n>', 'page size (max 20)', parsePositiveInt('--limit')) + .option( + '--sort-field <field>', + `field to sort by: ${sortFields.join(', ')}`, + parseChoice('--sort-field', sortFields) + ) + .option( + '--sort-order <order>', + 'sort direction: desc or asc', + parseChoice('--sort-order', ['desc', 'asc']) + ) + .option('--all', 'fetch every page (takes precedence over --page/--limit)'); +} + +async function runList<T extends Record<string, unknown>>( + cmd: Command, + fetchPage: ( + http: YuqueHttp, + params: DocStatsListParams + ) => Promise<{ rows: T[]; payload: unknown }>, + columns: Column<T>[] +): Promise<void> { + const ctx = getContext(cmd); + const opts = cmd.opts<ListOptions>(); + const filters: DocStatsListParams = { + name: opts.name, + range: opts.range, + sortField: opts.sortField, + sortOrder: opts.sortOrder, + bookId: opts.bookId, + }; + if (opts.all) { + const rows = await fetchAllPages<T>(async (offset, limit) => { + const page = await fetchPage(ctx.http, { ...filters, page: offset / limit + 1, limit }); + return page.rows; + }, MAX_PAGE_SIZE); + if (ctx.json) { + printJson(rows); + } else { + printTable(rows, columns); + } + return; + } + const { rows, payload } = await fetchPage(ctx.http, { + ...filters, + page: opts.page, + limit: opts.limit, + }); + if (ctx.json) { + printJson(payload); + } else { + printTable(rows, columns); + } +} + +export function registerStatsCommands(program: Command): void { + const stats = program.command('stats').description('Team (group) statistics'); + + const groupCmd = stats + .command('group <login>') + .description('Aggregate statistics for a group') + .action(async (login: string) => { + const ctx = getContext(groupCmd); + const data = await getGroupStatistics(ctx.http, login); + if (ctx.json) { + printJson(data); + } else { + printRecord(data, GROUP_FIELDS); + } + }); + + const membersCmd = withListOptions( + stats.command('members <login>').description('Per-member statistics for a group'), + MEMBER_SORT_FIELDS + ).action(async (login: string) => { + await runList( + membersCmd, + async (http, params) => { + const page = await listMemberStatistics(http, login, params); + return { rows: page.members, payload: page }; + }, + MEMBER_COLUMNS + ); + }); + + const booksCmd = withListOptions( + stats.command('books <login>').description('Per-book statistics for a group'), + BOOK_SORT_FIELDS + ).action(async (login: string) => { + await runList( + booksCmd, + async (http, params) => { + const page = await listBookStatistics(http, login, params); + return { rows: page.books, payload: page }; + }, + BOOK_COLUMNS + ); + }); + + const docsCmd = withListOptions( + stats.command('docs <login>').description('Per-doc statistics for a group'), + DOC_SORT_FIELDS + ) + .option('--book-id <id>', 'only docs in this book (repo id)', parsePositiveInt('--book-id')) + .action(async (login: string) => { + await runList( + docsCmd, + async (http, params) => { + const page = await listDocStatistics(http, login, params); + return { rows: page.docs, payload: page }; + }, + DOC_COLUMNS + ); + }); } diff --git a/src/commands/toc.ts b/src/commands/toc.ts index 8e2e890..f88fc49 100644 --- a/src/commands/toc.ts +++ b/src/commands/toc.ts @@ -1,6 +1,142 @@ -import type { Command } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; +import { getContext } from '../context.js'; +import { dim, printJson, printOk } from '../output.js'; +import { parseRepoRef } from '../client/repo-ref.js'; +import { getToc, updateToc, type TocUpdateBody } from '../client/api/toc.js'; +import type { V2TocItem } from '../client/types.js'; -export function registerTocCommands(_program: Command): void { - // Placeholder — the toc command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +interface TocUpdateOptions { + action: TocUpdateBody['action']; + actionMode?: 'sibling' | 'child'; + targetUuid?: string; + nodeUuid?: string; + docId?: number; + docIds?: number[]; + type?: 'DOC' | 'LINK' | 'TITLE'; + title?: string; + url?: string; + openWindow?: string; + visible?: string; +} + +function parseIntFlag(value: string): number { + if (!/^\d+$/.test(value)) throw new InvalidArgumentError('Expected a non-negative integer.'); + return Number(value); +} + +function parseDocIds(value: string): number[] { + const parts = value + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== ''); + if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) { + throw new InvalidArgumentError('Expected comma-separated numeric doc ids, e.g. 123,456.'); + } + return parts.map(Number); +} + +function indentLevel(item: V2TocItem): number { + if (typeof item.level === 'number') return item.level; + // depth is 1-based where present; fall back to a flat list. + if (typeof item.depth === 'number') return Math.max(0, item.depth - 1); + return 0; +} + +function printTocTree(items: V2TocItem[]): void { + if (items.length === 0) { + process.stdout.write(dim('(empty toc)\n')); + return; + } + for (const item of items) { + const ref = item.slug || item.url; + const suffix = ref ? ` ${dim(`(${ref})`)}` : ''; + process.stdout.write(`${' '.repeat(indentLevel(item))}${item.title}${suffix}\n`); + } +} + +export function registerTocCommands(program: Command): void { + // runCli applies exitOverride to the root only after registration, so subcommands + // must opt in themselves for usage errors to surface as CommanderError (exit 2). + const toc = program + .command('toc') + .description('Manage the table of contents (目录) of a repo') + .exitOverride(); + + const get = toc + .command('get') + .description('Show the toc of a repo as an indented tree') + .argument('<repo>', 'repo id or group/slug namespace') + .action(async (repoArg: string) => { + const ctx = getContext(get); + const items = await getToc(ctx.http, parseRepoRef(repoArg)); + if (ctx.json) { + printJson(items); + return; + } + printTocTree(items); + }); + + const update = toc + .command('update') + .description('Update the toc of a repo (append/prepend/edit/remove nodes)') + .argument('<repo>', 'repo id or group/slug namespace') + .addOption( + new Option( + '--action <action>', + 'operation: appendNode (append), prependNode (prepend), editNode, removeNode' + ) + .choices(['appendNode', 'prependNode', 'editNode', 'removeNode']) + .makeOptionMandatory() + ) + .addOption( + new Option('--action-mode <mode>', 'operation mode: sibling or child').choices([ + 'sibling', + 'child', + ]) + ) + .option('--target-uuid <uuid>', 'target node uuid (defaults to the root node)') + .option('--node-uuid <uuid>', 'node uuid to move/edit/remove (from `yuque toc get`)') + .option('--doc-id <id>', 'doc id (deprecated, use --doc-ids)', parseIntFlag) + .option('--doc-ids <ids>', 'comma-separated doc ids, required to create DOC nodes', parseDocIds) + .addOption( + new Option('--type <type>', 'node type: DOC (document), LINK, TITLE (group)').choices([ + 'DOC', + 'LINK', + 'TITLE', + ]) + ) + .option('--title <title>', 'node title, required to create TITLE/LINK nodes') + .option('--url <url>', 'node url, required to create LINK nodes') + .addOption( + new Option('--open-window <n>', 'open LINK in a new window (0: same page, 1: new)').choices([ + '0', + '1', + ]) + ) + .addOption( + new Option('--visible <n>', 'node visibility (0: hidden, 1: visible)').choices(['0', '1']) + ) + .action(async (repoArg: string) => { + const ctx = getContext(update); + const opts = update.opts<TocUpdateOptions>(); + const body: TocUpdateBody = { + action: opts.action, + ...(opts.actionMode !== undefined && { action_mode: opts.actionMode }), + ...(opts.targetUuid !== undefined && { target_uuid: opts.targetUuid }), + ...(opts.nodeUuid !== undefined && { node_uuid: opts.nodeUuid }), + ...(opts.docId !== undefined && { doc_id: opts.docId }), + ...(opts.docIds !== undefined && { doc_ids: opts.docIds }), + ...(opts.type !== undefined && { type: opts.type }), + ...(opts.title !== undefined && { title: opts.title }), + ...(opts.url !== undefined && { url: opts.url }), + ...(opts.openWindow !== undefined && { open_window: Number(opts.openWindow) }), + ...(opts.visible !== undefined && { visible: Number(opts.visible) }), + }; + const items = await updateToc(ctx.http, parseRepoRef(repoArg), body); + if (ctx.json) { + printJson(items); + return; + } + printOk(`Toc updated (${items.length} nodes)`); + }); } diff --git a/src/commands/user.ts b/src/commands/user.ts index 973ffb7..13614c0 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,6 +1,71 @@ import type { Command } from 'commander'; +import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; +import { printJson, printRecord, printTable } from '../output.js'; +import { getCurrentUser, listUserGroups } from '../client/api/user.js'; +import { fetchAllPages } from '../client/paginate.js'; -export function registerUserCommands(_program: Command): void { - // Placeholder — the user command group is implemented against the surface - // locked in tests/spec-coverage.test.ts. +// UsageError (not commander's InvalidArgumentError) so the failure maps to +// exit code 2 through runCli instead of commander's process.exit on subcommands. +function intFlag(flag: string): (value: string) => number { + return (value) => { + if (!/^\d+$/.test(value)) { + throw new UsageError(`${flag} expects a non-negative integer, got "${value}"`); + } + return Number(value); + }; +} + +export function registerUserCommands(program: Command): void { + const user = program.command('user').description('inspect users and their groups'); + + const info = user.command('info').description('show the authenticated user'); + info.action(async () => { + const ctx = getContext(info); + const me = await getCurrentUser(ctx.http); + if (ctx.json) { + printJson(me); + return; + } + printRecord(me, [ + 'id', + 'login', + 'name', + 'description', + 'books_count', + 'public_books_count', + 'followers_count', + 'following_count', + 'created_at', + 'updated_at', + ]); + }); + + const groups = user + .command('groups') + .description('list the groups a user belongs to') + .argument('<user>', 'user login or numeric id') + .option('--role <n>', 'filter by role (0: admin, 1: member)', intFlag('--role')) + .option('--offset <n>', 'pagination offset (page size is fixed at 100)', intFlag('--offset')) + .option('--all', 'fetch all pages'); + groups.action(async (userRef: string) => { + const ctx = getContext(groups); + const opts = groups.opts<{ role?: number; offset?: number; all?: boolean }>(); + const rows = opts.all + ? await fetchAllPages((offset) => + listUserGroups(ctx.http, userRef, { role: opts.role, offset }) + ) + : await listUserGroups(ctx.http, userRef, { role: opts.role, offset: opts.offset }); + if (ctx.json) { + printJson(rows); + return; + } + printTable(rows, [ + { key: 'id', header: 'ID' }, + { key: 'login', header: 'LOGIN' }, + { key: 'name', header: 'NAME' }, + { key: 'members_count', header: 'MEMBERS' }, + { key: 'books_count', header: 'BOOKS' }, + ]); + }); } diff --git a/src/output.ts b/src/output.ts index e268e4d..cbda4de 100644 --- a/src/output.ts +++ b/src/output.ts @@ -47,16 +47,16 @@ function cell<T extends Record<string, unknown>>(row: T, column: Column<T>): str return String(value); } -export function printTable<T extends Record<string, unknown>>(rows: T[], columns: Column<T>[]): void { +export function printTable<T extends Record<string, unknown>>( + rows: T[], + columns: Column<T>[] +): void { if (rows.length === 0) { process.stdout.write(dim('(no results)\n')); return; } const widths = columns.map((column) => - Math.max( - displayWidth(column.header), - ...rows.map((row) => displayWidth(cell(row, column))) - ) + Math.max(displayWidth(column.header), ...rows.map((row) => displayWidth(cell(row, column)))) ); const header = columns.map((column, i) => pad(column.header, widths[i])).join(' '); process.stdout.write(`${bold(header.trimEnd())}\n`); diff --git a/tests/commands/auth-user-search.test.ts b/tests/commands/auth-user-search.test.ts new file mode 100644 index 0000000..b7a9663 --- /dev/null +++ b/tests/commands/auth-user-search.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { runCli } from '../../src/cli.js'; + +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal<typeof import('axios')>(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); + +describe('auth / user / search commands', () => { + const request = vi.fn(); + let stdout: string[]; + let stderr: string[]; + + beforeEach(() => { + stdout = []; + stderr = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + vi.stubEnv('YUQUE_TOKEN', 'test-token'); + vi.stubEnv('YUQUE_PERSONAL_TOKEN', ''); + vi.stubEnv('YUQUE_HOST', ''); + request.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedAxios.create.mockReturnValue({ request } as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + const user = { + id: 42, + login: 'zhangsan', + name: '张三', + description: 'hello', + books_count: 3, + followers_count: 7, + }; + + describe('ping', () => { + it('calls GET /hello and prints the greeting', async () => { + request.mockResolvedValueOnce({ data: { data: { message: 'Hello, zhangsan!' } } }); + const code = await runCli(['node', 'yuque', 'ping']); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'get', url: '/hello' }) + ); + expect(stdout.join('')).toContain('Hello, zhangsan!'); + }); + + it('prints the full payload with --json', async () => { + request.mockResolvedValueOnce({ data: { data: { message: 'Hi' } } }); + const code = await runCli(['node', 'yuque', 'ping', '--json']); + expect(code).toBe(0); + expect(JSON.parse(stdout.join(''))).toEqual({ message: 'Hi' }); + }); + }); + + describe('auth status', () => { + it('calls GET /user and prints host + identity', async () => { + request.mockResolvedValueOnce({ data: { data: user } }); + const code = await runCli(['node', 'yuque', 'auth', 'status']); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'get', url: '/user' }) + ); + expect(stdout.join('')).toBe('Logged in to https://www.yuque.com as 张三 (@zhangsan)\n'); + }); + + it('prints the full user with --json', async () => { + request.mockResolvedValueOnce({ data: { data: user } }); + const code = await runCli(['node', 'yuque', 'auth', 'status', '--json']); + expect(code).toBe(0); + expect(JSON.parse(stdout.join(''))).toEqual(user); + }); + + it('exits 3 when no token is configured', async () => { + vi.stubEnv('YUQUE_TOKEN', ''); + const code = await runCli(['node', 'yuque', 'auth', 'status']); + expect(code).toBe(3); + expect(request).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('token'); + }); + }); + + describe('user info', () => { + it('calls GET /user and prints key fields', async () => { + request.mockResolvedValueOnce({ data: { data: user } }); + const code = await runCli(['node', 'yuque', 'user', 'info']); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'get', url: '/user' }) + ); + const output = stdout.join(''); + expect(output).toContain('zhangsan'); + expect(output).toContain('张三'); + expect(output).toContain('books_count'); + }); + + it('prints the full user with --json', async () => { + request.mockResolvedValueOnce({ data: { data: user } }); + const code = await runCli(['node', 'yuque', 'user', 'info', '--json']); + expect(code).toBe(0); + expect(JSON.parse(stdout.join(''))).toEqual(user); + }); + }); + + describe('user groups', () => { + const groups = [ + { id: 1, login: 'dev', name: 'Dev Team', members_count: 10, books_count: 4 }, + { id: 2, login: 'design', name: '设计组', members_count: 5, books_count: 2 }, + ]; + + it('calls GET /users/{id}/groups and prints a table', async () => { + request.mockResolvedValueOnce({ data: { data: groups } }); + const code = await runCli(['node', 'yuque', 'user', 'groups', 'zhangsan']); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'get', url: '/users/zhangsan/groups', params: {} }) + ); + const output = stdout.join(''); + expect(output).toContain('LOGIN'); + expect(output).toContain('Dev Team'); + expect(output).toContain('设计组'); + }); + + it('passes --role and --offset as query params', async () => { + request.mockResolvedValueOnce({ data: { data: [] } }); + const code = await runCli([ + 'node', + 'yuque', + 'user', + 'groups', + '42', + '--role', + '1', + '--offset', + '5', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/users/42/groups', params: { role: 1, offset: 5 } }) + ); + }); + + it('drains all pages with --all', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + login: `g${i + 1}`, + name: `Group ${i + 1}`, + })); + request + .mockResolvedValueOnce({ data: { data: fullPage } }) + .mockResolvedValueOnce({ data: { data: [{ id: 101, login: 'g101', name: 'Group 101' }] } }); + const code = await runCli(['node', 'yuque', 'user', 'groups', 'zhangsan', '--all', '--json']); + expect(code).toBe(0); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ params: { offset: 0 } }) + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ params: { offset: 100 } }) + ); + expect(JSON.parse(stdout.join(''))).toHaveLength(101); + }); + + it('rejects a non-integer --offset with exit code 2', async () => { + const code = await runCli(['node', 'yuque', 'user', 'groups', 'zhangsan', '--offset', 'x']); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); + + describe('search', () => { + const results = [ + { id: 1, type: 'doc', title: 'Getting started', url: '/yuque/help/start' }, + { id: 2, type: 'doc', title: '入门指南', url: '/yuque/help/intro' }, + ]; + + it('calls GET /search with all query params and prints a table', async () => { + request.mockResolvedValueOnce({ data: { data: results } }); + const code = await runCli([ + 'node', + 'yuque', + 'search', + 'guide', + '--type', + 'doc', + '--scope', + 'yuque/help', + '--creator', + 'zhangsan', + '--page', + '2', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + url: '/search', + params: { q: 'guide', type: 'doc', scope: 'yuque/help', creator: 'zhangsan', page: 2 }, + }) + ); + const output = stdout.join(''); + expect(output).toContain('TITLE'); + expect(output).toContain('Getting started'); + expect(output).toContain('/yuque/help/intro'); + }); + + it('prints the full result list with --json', async () => { + request.mockResolvedValueOnce({ data: { data: results } }); + const code = await runCli(['node', 'yuque', 'search', 'guide', '--type', 'repo', '--json']); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ params: { q: 'guide', type: 'repo' } }) + ); + expect(JSON.parse(stdout.join(''))).toEqual(results); + }); + + it('requires --type (exit code 2)', async () => { + const code = await runCli(['node', 'yuque', 'search', 'guide']); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('--type'); + }); + + it('rejects an invalid --type value (exit code 2)', async () => { + const code = await runCli(['node', 'yuque', 'search', 'guide', '--type', 'user']); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/commands/doc.test.ts b/tests/commands/doc.test.ts new file mode 100644 index 0000000..548146d --- /dev/null +++ b/tests/commands/doc.test.ts @@ -0,0 +1,382 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { runCli } from '../../src/cli.js'; + +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal<typeof import('axios')>(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); + +function argv(...args: string[]): string[] { + return ['node', 'yuque', ...args]; +} + +/** Wrap payload the way axios delivers a Yuque response: { data: <envelope> }. */ +function ok(data: unknown) { + return { data: { data } }; +} + +describe('doc commands', () => { + const request = vi.fn(); + let stdout: ReturnType<typeof vi.spyOn>; + let stderr: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + request.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedAxios.create.mockReturnValue({ request } as any); + vi.stubEnv('YUQUE_TOKEN', 'test-token'); + stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + stdout.mockRestore(); + stderr.mockRestore(); + }); + + function stdoutText(): string { + return stdout.mock.calls.map((call) => String(call[0])).join(''); + } + + function stderrText(): string { + return stderr.mock.calls.map((call) => String(call[0])).join(''); + } + + describe('doc list', () => { + const docs = [ + { id: 1, slug: 'intro', title: 'Intro', word_count: 120, updated_at: '2026-01-01' }, + { id: 2, slug: 'guide', title: 'Guide', word_count: 300, updated_at: '2026-01-02' }, + ]; + + it('GETs {repoBase}/docs for a namespace and prints the full JSON with --json', async () => { + request.mockResolvedValueOnce(ok(docs)); + await expect(runCli(argv('doc', 'list', 'yuque/help', '--json'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/repos/yuque/help/docs', + params: {}, + data: undefined, + }); + expect(JSON.parse(stdoutText())).toEqual(docs); + }); + + it('accepts a numeric repo id and spec query flags', async () => { + request.mockResolvedValueOnce(ok([])); + await expect( + runCli( + argv( + 'doc', + 'list', + '123456', + '--offset', + '10', + '--limit', + '5', + '--optional-properties', + 'hits,tags' + ) + ) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/repos/123456/docs', + params: { offset: 10, limit: 5, optional_properties: 'hits,tags' }, + data: undefined, + }); + }); + + it('renders a human table with slug/title/word_count/updated_at', async () => { + request.mockResolvedValueOnce(ok(docs)); + await expect(runCli(argv('doc', 'list', 'yuque/help'))).resolves.toBe(0); + const out = stdoutText(); + expect(out).toContain('SLUG'); + expect(out).toContain('intro'); + expect(out).toContain('Guide'); + expect(out).toContain('300'); + expect(out).toContain('2026-01-02'); + }); + + it('--all drains pages via offset/limit until a short page', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ id: i, slug: `d${i}` })); + request.mockResolvedValueOnce(ok(fullPage)).mockResolvedValueOnce(ok([{ id: 100 }])); + await expect(runCli(argv('doc', 'list', 'yuque/help', '--all', '--json'))).resolves.toBe(0); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ params: { offset: 0, limit: 100 } }) + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ params: { offset: 100, limit: 100 } }) + ); + expect(JSON.parse(stdoutText())).toHaveLength(101); + }); + + it('rejects a non-integer --limit with exit code 2', async () => { + await expect(runCli(argv('doc', 'list', 'yuque/help', '--limit', 'ten'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); + + describe('doc get', () => { + const detail = { + id: 42, + slug: 'intro', + title: 'Intro', + format: 'markdown', + word_count: 2, + body: '# Hello\n\nWorld.\n', + }; + + it('with <repo> <doc> GETs {repoBase}/docs/{doc} and prints the raw body', async () => { + request.mockResolvedValueOnce(ok(detail)); + await expect(runCli(argv('doc', 'get', 'yuque/help', 'intro'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/repos/yuque/help/docs/intro', + params: undefined, + data: undefined, + }); + expect(stdoutText()).toBe('# Hello\n\nWorld.\n'); + }); + + it('with one numeric arg GETs /repos/docs/{id}', async () => { + request.mockResolvedValueOnce(ok(detail)); + await expect(runCli(argv('doc', 'get', '42'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/repos/docs/42', + params: undefined, + data: undefined, + }); + }); + + it('with one non-numeric arg exits 2 without a request', async () => { + await expect(runCli(argv('doc', 'get', 'intro'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('not a numeric doc id'); + }); + + it('--meta prints metadata and never the body', async () => { + request.mockResolvedValueOnce(ok(detail)); + await expect(runCli(argv('doc', 'get', 'yuque/help', 'intro', '--meta'))).resolves.toBe(0); + const out = stdoutText(); + expect(out).toContain('Intro'); + expect(out).toContain('markdown'); + expect(out).not.toContain('# Hello'); + }); + + it('--json prints the full payload including the body', async () => { + request.mockResolvedValueOnce(ok(detail)); + await expect(runCli(argv('doc', 'get', 'yuque/help', 'intro', '--json'))).resolves.toBe(0); + expect(JSON.parse(stdoutText())).toEqual(detail); + }); + }); + + describe('doc create', () => { + const created = { id: 7, slug: 'new-doc', title: 'New Doc' }; + + it('POSTs {repoBase}/docs with the spec body fields', async () => { + request.mockResolvedValueOnce(ok(created)); + await expect( + runCli( + argv( + 'doc', + 'create', + 'yuque/help', + '--title', + 'New Doc', + '--slug', + 'new-doc', + '--body', + '# hi', + '--format', + 'markdown', + '--public', + '1', + '--json' + ) + ) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'post', + url: '/repos/yuque/help/docs', + params: undefined, + data: { title: 'New Doc', slug: 'new-doc', body: '# hi', format: 'markdown', public: 1 }, + }); + expect(JSON.parse(stdoutText())).toEqual(created); + }); + + it('--body-file reads the body from disk', async () => { + const dir = mkdtempSync(join(tmpdir(), 'yuque-cli-test-')); + const file = join(dir, 'body.md'); + writeFileSync(file, '# from file\n'); + try { + request.mockResolvedValueOnce(ok(created)); + await expect( + runCli(argv('doc', 'create', 'yuque/help', '--title', 'T', '--body-file', file)) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ data: { title: 'T', body: '# from file\n' } }) + ); + expect(stdoutText()).toContain('Created doc'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('rejects --body together with --body-file with exit code 2', async () => { + await expect( + runCli( + argv('doc', 'create', 'yuque/help', '--title', 'T', '--body', 'a', '--body-file', 'b.md') + ) + ).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('mutually exclusive'); + }); + + it('requires a body (spec: body is required) with exit code 2', async () => { + await expect(runCli(argv('doc', 'create', 'yuque/help', '--title', 'T'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); + + describe('doc update', () => { + it('PUTs {repoBase}/docs/{doc} with only the given fields', async () => { + request.mockResolvedValueOnce(ok({ id: 42, slug: 'intro', title: 'Renamed' })); + await expect( + runCli(argv('doc', 'update', 'yuque/help', 'intro', '--title', 'Renamed')) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'put', + url: '/repos/yuque/help/docs/intro', + params: undefined, + data: { title: 'Renamed' }, + }); + expect(stdoutText()).toContain('Updated doc yuque/help/intro'); + }); + + it('rejects an empty update with exit code 2', async () => { + await expect(runCli(argv('doc', 'update', 'yuque/help', 'intro'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('nothing to update'); + }); + }); + + describe('doc delete', () => { + it('--yes skips confirmation and DELETEs {repoBase}/docs/{doc}', async () => { + request.mockResolvedValueOnce(ok({ id: 42, slug: 'intro' })); + await expect(runCli(argv('doc', 'delete', 'yuque/help', 'intro', '--yes'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'delete', + url: '/repos/yuque/help/docs/intro', + params: undefined, + data: undefined, + }); + expect(stdoutText()).toContain('Deleted doc yuque/help/intro'); + }); + + it('without --yes on a non-TTY stdin exits 2 and never calls the API', async () => { + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = false; + try { + await expect(runCli(argv('doc', 'delete', 'yuque/help', 'intro'))).resolves.toBe(2); + } finally { + process.stdin.isTTY = originalIsTTY; + } + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('--yes'); + }); + }); + + describe('doc versions', () => { + const versions = [ + { id: 900, doc_id: 42, title: 'v2', updated_at: '2026-01-02', user: { name: 'Alice' } }, + { id: 899, doc_id: 42, title: 'v1', updated_at: '2026-01-01', user: { name: 'Bob' } }, + ]; + + it('GETs /doc_versions?doc_id=... and prints the full JSON with --json', async () => { + request.mockResolvedValueOnce(ok(versions)); + await expect(runCli(argv('doc', 'versions', '42', '--json'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/doc_versions', + params: { doc_id: 42 }, + data: undefined, + }); + expect(JSON.parse(stdoutText())).toEqual(versions); + }); + + it('renders a human table with id/title/updated_at/user', async () => { + request.mockResolvedValueOnce(ok(versions)); + await expect(runCli(argv('doc', 'versions', '42'))).resolves.toBe(0); + const out = stdoutText(); + expect(out).toContain('900'); + expect(out).toContain('v1'); + expect(out).toContain('Alice'); + }); + + it('rejects a non-numeric doc id with exit code 2', async () => { + await expect(runCli(argv('doc', 'versions', 'intro'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); + + describe('doc version', () => { + const versionDetail = { + id: 900, + doc_id: 42, + title: 'v2', + format: 'lake', + body: '<lake>raw</lake>', + body_md: '# v2 body\n', + }; + + it('GETs /doc_versions/{id} and prints body_md by default', async () => { + request.mockResolvedValueOnce(ok(versionDetail)); + await expect(runCli(argv('doc', 'version', '900'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/doc_versions/900', + params: undefined, + data: undefined, + }); + expect(stdoutText()).toBe('# v2 body\n'); + }); + + it('falls back to body when body_md is absent', async () => { + request.mockResolvedValueOnce(ok({ id: 900, title: 'v2', body: 'plain body' })); + await expect(runCli(argv('doc', 'version', '900'))).resolves.toBe(0); + expect(stdoutText()).toBe('plain body\n'); + }); + + it('--meta prints metadata and --json the full payload', async () => { + request.mockResolvedValueOnce(ok(versionDetail)).mockResolvedValueOnce(ok(versionDetail)); + await expect(runCli(argv('doc', 'version', '900', '--meta'))).resolves.toBe(0); + expect(stdoutText()).toContain('lake'); + expect(stdoutText()).not.toContain('# v2 body'); + + stdout.mockClear(); + await expect(runCli(argv('doc', 'version', '900', '--json'))).resolves.toBe(0); + expect(JSON.parse(stdoutText())).toEqual(versionDetail); + }); + + it('rejects a non-numeric version id with exit code 2', async () => { + await expect(runCli(argv('doc', 'version', 'latest'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/commands/repo.test.ts b/tests/commands/repo.test.ts new file mode 100644 index 0000000..f0e2c06 --- /dev/null +++ b/tests/commands/repo.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { runCli } from '../../src/cli.js'; + +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal<typeof import('axios')>(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); + +function argv(...args: string[]): string[] { + return ['node', 'yuque', ...args]; +} + +/** Axios response wrapping the Yuque `{ data: ... }` envelope. */ +function envelope(data: unknown) { + return { data: { data } }; +} + +const BOOK = { + id: 42, + type: 'Book', + slug: 'help', + name: '帮助中心', + namespace: 'yuque/help', + items_count: 12, + public: 1, + description: 'Product docs', + created_at: '2024-01-01T00:00:00.000Z', + updated_at: '2025-06-01T00:00:00.000Z', + _extra: 'kept in --json output', +}; + +describe('repo commands', () => { + const request = vi.fn(); + let stdoutChunks: string[] = []; + let stderrChunks: string[] = []; + const stdoutText = () => stdoutChunks.join(''); + const stderrText = () => stderrChunks.join(''); + + beforeEach(() => { + request.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedAxios.create.mockReturnValue({ request } as any); + vi.stubEnv('YUQUE_TOKEN', 'test-token'); + stdoutChunks = []; + stderrChunks = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrChunks.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe('repo list', () => { + it('lists user repos and prints a table', async () => { + request.mockResolvedValueOnce(envelope([BOOK])); + await expect(runCli(argv('repo', 'list', 'yuque'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/users/yuque/repos', + params: {}, + data: undefined, + }); + expect(stdoutText()).toContain('NAMESPACE'); + expect(stdoutText()).toContain('yuque/help'); + expect(stdoutText()).toContain('帮助中心'); + }); + + it('lists group repos with filter and pagination params', async () => { + request.mockResolvedValueOnce(envelope([])); + const args = ['repo', 'list', 'mygroup', '--group']; + args.push('--type', 'Book', '--offset', '10', '--limit', '20'); + await expect(runCli(argv(...args))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/groups/mygroup/repos', + params: { offset: 10, limit: 20, type: 'Book' }, + data: undefined, + }); + }); + + it('treats --type all as no server-side filter', async () => { + request.mockResolvedValueOnce(envelope([])); + await expect(runCli(argv('repo', 'list', 'yuque', '--type', 'all'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ params: {} })); + }); + + it('drains every page with --all', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + ...BOOK, + id: i, + namespace: `yuque/r${i}`, + })); + request.mockResolvedValueOnce(envelope(fullPage)).mockResolvedValueOnce(envelope([BOOK])); + await expect(runCli(argv('repo', 'list', 'yuque', '--all', '--json'))).resolves.toBe(0); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ url: '/users/yuque/repos', params: { offset: 0, limit: 100 } }) + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ params: { offset: 100, limit: 100 } }) + ); + expect(JSON.parse(stdoutText())).toHaveLength(101); + }); + + it('rejects an unknown --type', async () => { + await expect(runCli(argv('repo', 'list', 'yuque', '--type', 'Wiki'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('--type'); + }); + + it('rejects a non-numeric --offset', async () => { + await expect(runCli(argv('repo', 'list', 'yuque', '--offset', 'abc'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); + + describe('repo get', () => { + it('gets a repo by id and prints the full payload with --json', async () => { + request.mockResolvedValueOnce(envelope(BOOK)); + await expect(runCli(argv('repo', 'get', '42', '--json'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/repos/42', + params: undefined, + data: undefined, + }); + expect(JSON.parse(stdoutText())).toEqual(BOOK); + }); + + it('gets a repo by namespace and prints a record', async () => { + request.mockResolvedValueOnce(envelope(BOOK)); + await expect(runCli(argv('repo', 'get', 'yuque/help'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ url: '/repos/yuque/help' })); + expect(stdoutText()).toContain('帮助中心'); + expect(stdoutText()).not.toContain('_extra'); + }); + + it('rejects a malformed repo reference', async () => { + await expect(runCli(argv('repo', 'get', 'not-a-ref'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + }); + + describe('repo create', () => { + it('creates a user repo with name and slug', async () => { + request.mockResolvedValueOnce(envelope(BOOK)); + const args = ['repo', 'create', 'yuque', '--name', '帮助中心', '--slug', 'help']; + await expect(runCli(argv(...args))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'post', + url: '/users/yuque/repos', + params: undefined, + data: { name: '帮助中心', slug: 'help' }, + }); + expect(stdoutText()).toContain('Created repo yuque/help'); + }); + + it('creates a group repo with all optional fields', async () => { + request.mockResolvedValueOnce(envelope(BOOK)); + const args = ['repo', 'create', 'mygroup', '--group', '--name', 'Docs', '--slug', 'docs']; + args.push('--description', 'd', '--public', '2', '--type', 'Book', '--json'); + await expect(runCli(argv(...args))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'post', + url: '/groups/mygroup/repos', + params: undefined, + data: { name: 'Docs', slug: 'docs', description: 'd', public: 2, type: 'Book' }, + }); + expect(JSON.parse(stdoutText())).toEqual(BOOK); + }); + }); + + describe('repo update', () => { + it('sends only the provided fields', async () => { + request.mockResolvedValueOnce(envelope(BOOK)); + const args = ['repo', 'update', '42', '--name', 'New name', '--public', '0']; + await expect(runCli(argv(...args))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'put', + url: '/repos/42', + params: undefined, + data: { name: 'New name', public: 0 }, + }); + expect(stdoutText()).toContain('Updated repo yuque/help'); + }); + + it('rejects an update with no fields', async () => { + await expect(runCli(argv('repo', 'update', '42'))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('Nothing to update'); + }); + }); + + describe('repo delete', () => { + it('deletes with --yes without prompting', async () => { + request.mockResolvedValueOnce(envelope(BOOK)); + await expect(runCli(argv('repo', 'delete', 'yuque/help', '--yes'))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'delete', + url: '/repos/yuque/help', + params: undefined, + data: undefined, + }); + expect(stdoutText()).toContain('Deleted repo yuque/help'); + }); + + it('refuses without --yes when stdin is not a TTY', async () => { + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = false; + try { + await expect(runCli(argv('repo', 'delete', 'yuque/help'))).resolves.toBe(2); + } finally { + process.stdin.isTTY = originalIsTTY; + } + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('--yes'); + }); + }); +}); diff --git a/tests/commands/stats.test.ts b/tests/commands/stats.test.ts new file mode 100644 index 0000000..5237634 --- /dev/null +++ b/tests/commands/stats.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { runCli } from '../../src/cli.js'; + +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal<typeof import('axios')>(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); + +const request = vi.fn(); + +function envelope(data: unknown) { + return { data: { data } }; +} + +function run(...args: string[]): Promise<number> { + return runCli(['node', 'yuque', ...args]); +} + +describe('stats commands', () => { + let stdout: ReturnType<typeof vi.spyOn>; + let stderr: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + request.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedAxios.create.mockReturnValue({ request } as any); + vi.stubEnv('YUQUE_TOKEN', 'test-token'); + stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + stdout.mockRestore(); + stderr.mockRestore(); + }); + + function stdoutText(): string { + return stdout.mock.calls.map((call) => String(call[0])).join(''); + } + + function stderrText(): string { + return stderr.mock.calls.map((call) => String(call[0])).join(''); + } + + describe('stats group', () => { + const groupStats = { + bizdate: '20260721', + member_count: 12, + doc_count: 340, + book_count: 9, + write_count: 1500, + read_count: 98765, + comment_count: 42, + }; + + it('GETs /groups/{login}/statistics and prints a record', async () => { + request.mockResolvedValueOnce(envelope(groupStats)); + const code = await run('stats', 'group', 'mygroup'); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/groups/mygroup/statistics', + params: undefined, + data: undefined, + }); + const out = stdoutText(); + expect(out).toContain('member_count'); + expect(out).toContain('12'); + expect(out).toContain('98765'); + }); + + it('--json prints the full payload', async () => { + request.mockResolvedValueOnce(envelope(groupStats)); + const code = await run('stats', 'group', 'mygroup', '--json'); + expect(code).toBe(0); + expect(JSON.parse(stdoutText())).toEqual(groupStats); + }); + + it('URL-encodes the login', async () => { + request.mockResolvedValueOnce(envelope(groupStats)); + await run('stats', 'group', 'my group'); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/groups/my%20group/statistics' }) + ); + }); + }); + + describe('stats members', () => { + const membersPage = { + members: [ + { user_id: 1, write_doc_count: 3, write_count: 10, read_count: 200, user: { name: 'Ann' } }, + { user_id: 2, write_doc_count: 1, write_count: 4, read_count: 50, user: { name: 'Bob' } }, + ], + total: 2, + }; + + it('GETs /statistics/members with spec query params from flags', async () => { + request.mockResolvedValueOnce(envelope(membersPage)); + const code = await run( + 'stats', + 'members', + 'mygroup', + '--name', + 'ann', + '--range', + '30', + '--page', + '2', + '--limit', + '5', + '--sort-field', + 'read_count', + '--sort-order', + 'asc' + ); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/groups/mygroup/statistics/members', + params: { + name: 'ann', + range: 30, + page: 2, + limit: 5, + sortField: 'read_count', + sortOrder: 'asc', + }, + data: undefined, + }); + const out = stdoutText(); + expect(out).toContain('NAME'); + expect(out).toContain('Ann'); + expect(out).toContain('Bob'); + }); + + it('omits unset optional filters from the query', async () => { + request.mockResolvedValueOnce(envelope(membersPage)); + await run('stats', 'members', 'mygroup'); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/groups/mygroup/statistics/members', params: {} }) + ); + }); + + it('--json prints the full page payload including total', async () => { + request.mockResolvedValueOnce(envelope(membersPage)); + const code = await run('stats', 'members', 'mygroup', '--json'); + expect(code).toBe(0); + expect(JSON.parse(stdoutText())).toEqual(membersPage); + }); + + it('--all drains pages at max page size and keeps filters', async () => { + const fullPage = Array.from({ length: 20 }, (_, i) => ({ user_id: i, read_count: i })); + const lastPage = [{ user_id: 20, read_count: 20 }]; + request + .mockResolvedValueOnce(envelope({ members: fullPage, total: 21 })) + .mockResolvedValueOnce(envelope({ members: lastPage, total: 21 })); + const code = await run('stats', 'members', 'mygroup', '--all', '--name', 'ann', '--json'); + expect(code).toBe(0); + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ params: { name: 'ann', page: 1, limit: 20 } }) + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ params: { name: 'ann', page: 2, limit: 20 } }) + ); + expect(JSON.parse(stdoutText())).toHaveLength(21); + }); + + it('rejects a --range value outside the spec enum with exit code 2', async () => { + const code = await run('stats', 'members', 'mygroup', '--range', '7'); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + + it('rejects a non-positive --page with exit code 2', async () => { + const code = await run('stats', 'members', 'mygroup', '--page', '0'); + expect(code).toBe(2); + expect(stderrText()).toContain('--page expects a positive integer'); + expect(request).not.toHaveBeenCalled(); + }); + }); + + describe('stats books', () => { + it('GETs /statistics/books and prints a table', async () => { + const booksPage = { + books: [ + { book_id: 7, name: 'Handbook', slug: 'handbook', post_count: 12, read_count: 300 }, + ], + total: 1, + }; + request.mockResolvedValueOnce(envelope(booksPage)); + const code = await run('stats', 'books', 'mygroup', '--sort-field', 'word_count'); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/groups/mygroup/statistics/books', + params: { sortField: 'word_count' }, + data: undefined, + }); + expect(stdoutText()).toContain('Handbook'); + }); + }); + + describe('stats docs', () => { + it('GETs /statistics/docs with the bookId filter', async () => { + const docsPage = { + docs: [{ doc_id: 55, title: 'Intro', slug: 'intro', book_id: 7, read_count: 90 }], + total: 1, + }; + request.mockResolvedValueOnce(envelope(docsPage)); + const code = await run('stats', 'docs', 'mygroup', '--book-id', '7', '--range', '365'); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/groups/mygroup/statistics/docs', + params: { bookId: 7, range: 365 }, + data: undefined, + }); + expect(stdoutText()).toContain('Intro'); + }); + + it('--json prints the full page payload', async () => { + const docsPage = { docs: [{ doc_id: 55, title: 'Intro' }], total: 1 }; + request.mockResolvedValueOnce(envelope(docsPage)); + const code = await run('stats', 'docs', 'mygroup', '--json'); + expect(code).toBe(0); + expect(JSON.parse(stdoutText())).toEqual(docsPage); + }); + }); + + it('maps API errors to the exit code contract (404 -> 4)', async () => { + const error = new Error('not found') as Error & { + isAxiosError: boolean; + response: { status: number; data: { message: string }; headers: Record<string, string> }; + }; + error.isAxiosError = true; + error.response = { status: 404, data: { message: 'group not found' }, headers: {} }; + request.mockRejectedValue(error); + const code = await run('stats', 'group', 'nope'); + expect(code).toBe(4); + expect(stderrText()).toContain('group not found'); + }); +}); diff --git a/tests/commands/toc-group.test.ts b/tests/commands/toc-group.test.ts new file mode 100644 index 0000000..585448f --- /dev/null +++ b/tests/commands/toc-group.test.ts @@ -0,0 +1,317 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { runCli } from '../../src/cli.js'; + +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal<typeof import('axios')>(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); +const request = vi.fn(); + +let stdoutChunks: string[] = []; +let stderrChunks: string[] = []; +const stdoutText = () => stdoutChunks.join(''); +const stderrText = () => stderrChunks.join(''); + +/** Pin isTTY to false so color codes and confirm prompts behave deterministically. */ +function forceNonTty(stream: NodeJS.WriteStream | NodeJS.ReadStream): () => void { + const descriptor = Object.getOwnPropertyDescriptor(stream, 'isTTY'); + Object.defineProperty(stream, 'isTTY', { value: false, configurable: true }); + return () => { + if (descriptor) Object.defineProperty(stream, 'isTTY', descriptor); + else delete (stream as { isTTY?: boolean }).isTTY; + }; +} + +let restoreTty: Array<() => void> = []; + +beforeEach(() => { + request.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedAxios.create.mockReturnValue({ request } as any); + vi.stubEnv('YUQUE_TOKEN', 'test-token'); + stdoutChunks = []; + stderrChunks = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdoutChunks.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderrChunks.push(String(chunk)); + return true; + }); + restoreTty = [forceNonTty(process.stdin), forceNonTty(process.stdout)]; +}); + +afterEach(() => { + restoreTty.forEach((restore) => restore()); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe('toc get', () => { + it('prints an indented tree with slug/url suffixes', async () => { + request.mockResolvedValueOnce({ + data: { + data: [ + { uuid: 'u1', type: 'TITLE', title: 'Guide', level: 0, depth: 1 }, + { uuid: 'u2', type: 'DOC', title: 'Intro', slug: 'intro', level: 1, depth: 2 }, + { + uuid: 'u3', + type: 'LINK', + title: 'Homepage', + url: 'https://example.com', + level: 1, + depth: 2, + }, + ], + }, + }); + const code = await runCli(['node', 'yuque', 'toc', 'get', 'yuque/help']); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/repos/yuque/help/toc', + params: undefined, + data: undefined, + }); + expect(stdoutText()).toBe('Guide\n Intro (intro)\n Homepage (https://example.com)\n'); + }); + + it('--json prints the full payload for a numeric repo id', async () => { + const items = [{ uuid: 'u1', type: 'DOC', title: 'Intro', slug: 'intro', level: 0 }]; + request.mockResolvedValueOnce({ data: { data: items } }); + const code = await runCli(['node', 'yuque', 'toc', 'get', '123456', '--json']); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/repos/123456/toc', + params: undefined, + data: undefined, + }); + expect(JSON.parse(stdoutText())).toEqual(items); + }); +}); + +describe('toc update', () => { + it('appends DOC nodes with the spec body fields', async () => { + request.mockResolvedValueOnce({ data: { data: [{ uuid: 'a' }, { uuid: 'b' }] } }); + const code = await runCli([ + 'node', + 'yuque', + 'toc', + 'update', + 'yuque/help', + '--action', + 'appendNode', + '--action-mode', + 'child', + '--target-uuid', + 'tu', + '--type', + 'DOC', + '--doc-ids', + '11,22', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'put', + url: '/repos/yuque/help/toc', + params: undefined, + data: { + action: 'appendNode', + action_mode: 'child', + target_uuid: 'tu', + type: 'DOC', + doc_ids: [11, 22], + }, + }); + expect(stdoutText()).toContain('✓ Toc updated (2 nodes)'); + }); + + it('edits a node with title/url/open-window/visible flags', async () => { + const items = [{ uuid: 'nu', type: 'LINK', title: 'Docs' }]; + request.mockResolvedValueOnce({ data: { data: items } }); + const code = await runCli([ + 'node', + 'yuque', + 'toc', + 'update', + '123', + '--action', + 'editNode', + '--node-uuid', + 'nu', + '--type', + 'LINK', + '--title', + 'Docs', + '--url', + 'https://example.com', + '--open-window', + '1', + '--visible', + '0', + '--json', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'put', + url: '/repos/123/toc', + params: undefined, + data: { + action: 'editNode', + node_uuid: 'nu', + type: 'LINK', + title: 'Docs', + url: 'https://example.com', + open_window: 1, + visible: 0, + }, + }); + expect(JSON.parse(stdoutText())).toEqual(items); + }); + + it('rejects an --action outside the spec enum without calling the API', async () => { + const code = await runCli(['node', 'yuque', 'toc', 'update', '123', '--action', 'explode']); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + + it('requires --action', async () => { + const code = await runCli(['node', 'yuque', 'toc', 'update', '123']); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe('group members', () => { + it('lists members with role filter and offset', async () => { + request.mockResolvedValueOnce({ + data: { + data: [ + { id: 1, role: 1, user: { id: 9, login: 'alice', name: 'Alice' } }, + { id: 2, role: 0, user: { id: 10, login: 'bob', name: 'Bob' } }, + ], + }, + }); + const code = await runCli([ + 'node', + 'yuque', + 'group', + 'members', + 'mygroup', + '--role', + '1', + '--offset', + '20', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/groups/mygroup/users', + params: { role: 1, offset: 20 }, + data: undefined, + }); + expect(stdoutText()).toBe('LOGIN NAME ROLE\nalice Alice member\nbob Bob admin\n'); + }); + + it('--all drains pages of 100 and merges the results', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ + id: i, + role: 1, + user: { id: i, login: `u${i}`, name: `User ${i}` }, + })); + const page2 = [{ id: 100, role: 2, user: { id: 100, login: 'last', name: 'Last' } }]; + request + .mockResolvedValueOnce({ data: { data: page1 } }) + .mockResolvedValueOnce({ data: { data: page2 } }); + const code = await runCli(['node', 'yuque', 'group', 'members', 'g', '--all', '--json']); + expect(code).toBe(0); + expect(request).toHaveBeenNthCalledWith(1, { + method: 'get', + url: '/groups/g/users', + params: { offset: 0 }, + data: undefined, + }); + expect(request).toHaveBeenNthCalledWith(2, { + method: 'get', + url: '/groups/g/users', + params: { offset: 100 }, + data: undefined, + }); + expect(JSON.parse(stdoutText())).toHaveLength(101); + }); +}); + +describe('group member set', () => { + it('puts the role to /groups/{login}/users/{id}', async () => { + request.mockResolvedValueOnce({ + data: { data: { id: 1, role: 0, user: { id: 9, login: 'alice', name: 'Alice' } } }, + }); + const code = await runCli([ + 'node', + 'yuque', + 'group', + 'member', + 'set', + 'g', + 'alice', + '--role', + '0', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'put', + url: '/groups/g/users/alice', + params: undefined, + data: { role: 0 }, + }); + expect(stdoutText()).toContain('✓ Set alice in group g to role admin'); + }); + + it('requires --role', async () => { + const code = await runCli(['node', 'yuque', 'group', 'member', 'set', 'g', 'alice']); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe('group member remove', () => { + it('--yes skips the prompt and deletes the member', async () => { + request.mockResolvedValueOnce({ data: { data: { user_id: '9' } } }); + const code = await runCli([ + 'node', + 'yuque', + 'group', + 'member', + 'remove', + 'g', + 'alice', + '--yes', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'delete', + url: '/groups/g/users/alice', + params: undefined, + data: undefined, + }); + expect(stdoutText()).toContain('✓ Removed alice from group g'); + }); + + it('refuses without --yes when stdin is not a TTY', async () => { + const code = await runCli(['node', 'yuque', 'group', 'member', 'remove', 'g', 'alice']); + expect(code).toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('--yes'); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 56a99eb..9af5944 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - DEFAULT_HOST, - normalizeHost, - resolveHost, - resolveToken, -} from '../src/config.js'; +import { DEFAULT_HOST, normalizeHost, resolveHost, resolveToken } from '../src/config.js'; describe('resolveToken', () => { it('prefers the flag over env vars', () => { diff --git a/tests/docs/command-surface-docs.test.ts b/tests/docs/command-surface-docs.test.ts new file mode 100644 index 0000000..80747bc --- /dev/null +++ b/tests/docs/command-surface-docs.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { Command } from 'commander'; +import { buildProgram } from '../../src/cli.js'; + +/** + * Docs lock: both READMEs must state the exact command count in their section + * heading and list every registered leaf command, mirroring the tool-surface + * lock in yuque-mcp-server. + */ + +function collectLeafCommands(command: Command, prefix: string[] = []): string[] { + const leaves: string[] = []; + for (const sub of command.commands) { + const path = [...prefix, sub.name()]; + if (sub.commands.length === 0) leaves.push(path.join(' ')); + else leaves.push(...collectLeafCommands(sub, path)); + } + return leaves; +} + +const leaves = collectLeafCommands(buildProgram()); + +function readReadme(name: string): string { + return readFileSync(fileURLToPath(new URL(`../../${name}`, import.meta.url)), 'utf8'); +} + +describe.each([ + { file: 'README.md', heading: new RegExp(`^## Commands \\((\\d+)\\)$`, 'm') }, + { file: 'README.zh-CN.md', heading: new RegExp(`^## 命令列表((\\d+) 个)$`, 'm') }, +])('$file', ({ file, heading }) => { + const content = readReadme(file); + + it('states the exact command count in the section heading', () => { + const match = content.match(heading); + expect(match, `missing command-count heading in ${file}`).not.toBeNull(); + expect(Number(match?.[1])).toBe(leaves.length); + }); + + it('mentions every registered command', () => { + const missing = leaves.filter((leaf) => !content.includes(`\`${leaf}`)); + expect(missing).toEqual([]); + }); +}); From fa1779e59b336f1238d9e635e32fc1d3bf4dda36 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 11:51:50 +0900 Subject: [PATCH 03/13] fix: align repo create/list flags with spec, enforce spec bounds, unify help style - drop repo create --type (not in spec requestBody); add --enhanced-privacy - add repo list --filter-by-ability; cap --limit/--page/--role per spec bounds - capitalize all command descriptions consistently Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- src/client/api/repo.ts | 5 ++++- src/commands/auth.ts | 6 +++--- src/commands/doc.ts | 6 +++++- src/commands/repo.ts | 32 ++++++++++++++++++++++++++------ src/commands/search.ts | 5 ++++- src/commands/stats.ts | 10 +++++++++- src/commands/user.ts | 17 +++++++++++++---- tests/commands/repo.test.ts | 22 ++++++++++++++++++++-- 8 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/client/api/repo.ts b/src/client/api/repo.ts index a91385c..1ed1dae 100644 --- a/src/client/api/repo.ts +++ b/src/client/api/repo.ts @@ -14,6 +14,8 @@ export interface ListReposOptions { limit?: number; /** Spec enum: Book | Design. Omit for no server-side filter. */ type?: string; + /** e.g. `create_doc` — only repos where the token holds that ability. */ + filterByAbility?: string; } export async function listRepos( @@ -26,6 +28,7 @@ export async function listRepos( if (options.offset !== undefined) params.offset = options.offset; if (options.limit !== undefined) params.limit = options.limit; if (options.type !== undefined) params.type = options.type; + if (options.filterByAbility !== undefined) params.filterByAbility = options.filterByAbility; const res = await http.get<ApiEnvelope<V2Book[]>>(ownerReposPath(owner, login), params); return res.data; } @@ -40,7 +43,7 @@ export interface CreateRepoBody { slug: string; description?: string; public?: number; - type?: string; + enhancedPrivacy?: boolean; } export async function createRepo( diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 5d9846c..5c920ed 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -7,7 +7,7 @@ import { getCurrentUser, hello } from '../client/api/user.js'; export function registerAuthCommands(program: Command): void { const ping = program .command('ping') - .description('check connectivity and token validity against the Yuque API'); + .description('Check connectivity and token validity against the Yuque API'); ping.action(async () => { const ctx = getContext(ping); const data = await hello(ctx.http); @@ -18,8 +18,8 @@ export function registerAuthCommands(program: Command): void { printOk(data.message ?? 'ok'); }); - const auth = program.command('auth').description('authentication status'); - const status = auth.command('status').description('show which account the token belongs to'); + const auth = program.command('auth').description('Authentication status'); + const status = auth.command('status').description('Show which account the token belongs to'); status.action(async () => { const ctx = getContext(status); const user = await getCurrentUser(ctx.http); diff --git a/src/commands/doc.ts b/src/commands/doc.ts index 94131ec..2a285fd 100644 --- a/src/commands/doc.ts +++ b/src/commands/doc.ts @@ -42,7 +42,11 @@ function parseIntFlag(value: string | undefined, flag: string): number | undefin if (!/^\d+$/.test(value)) { throw new UsageError(`${flag} expects a non-negative integer, got "${value}"`); } - return Number(value); + const parsed = Number(value); + if (flag === '--limit' && parsed > 100) { + throw new UsageError(`--limit is capped at 100 by the Yuque API, got ${parsed}`); + } + return parsed; } interface BodyOpts { diff --git a/src/commands/repo.ts b/src/commands/repo.ts index 3c5652e..91a2267 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -24,6 +24,13 @@ function parseNonNegativeInt(value: string): number { return Number(value); } +/** The spec caps `limit` at 100 for repo listing. */ +function parseLimit(value: string): number { + const limit = parseNonNegativeInt(value); + if (limit > 100) throw new UsageError(`--limit is capped at 100 by the Yuque API, got ${limit}`); + return limit; +} + const LIST_TYPES = ['Book', 'Design', 'all']; /** `all` (like omitting the flag) means no server-side filter; the spec enum is Book|Design. */ @@ -48,25 +55,38 @@ export function registerRepoCommands(program: Command): void { .argument('<login>', 'user/group login or id') .option('--group', 'treat <login> as a group instead of a user') .option('--type <type>', 'filter by repo type: Book, Design, or all') + .option( + '--filter-by-ability <ability>', + 'only repos where the token has this ability, e.g. create_doc' + ) .option('--offset <n>', 'pagination offset', parseNonNegativeInt) - .option('--limit <n>', 'page size, max 100', parseNonNegativeInt) + .option('--limit <n>', 'page size, max 100', parseLimit) .option('--all', 'fetch every page (overrides --offset/--limit)') .action( async ( login: string, - opts: { group?: boolean; type?: string; offset?: number; limit?: number; all?: boolean } + opts: { + group?: boolean; + type?: string; + filterByAbility?: string; + offset?: number; + limit?: number; + all?: boolean; + } ) => { const type = typeFilter(opts.type); const ctx = getContext(list); const owner: RepoOwner = opts.group ? 'group' : 'user'; + const filterByAbility = opts.filterByAbility; const books = opts.all ? await fetchAllPages((offset, limit) => - listRepos(ctx.http, owner, login, { offset, limit, type }) + listRepos(ctx.http, owner, login, { offset, limit, type, filterByAbility }) ) : await listRepos(ctx.http, owner, login, { offset: opts.offset, limit: opts.limit, type, + filterByAbility, }); if (ctx.json) { printJson(books); @@ -116,7 +136,7 @@ export function registerRepoCommands(program: Command): void { .option('--group', 'create under a group instead of a user') .option('--description <description>', 'repo description') .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parseNonNegativeInt) - .option('--type <type>', 'repo type, e.g. Book') + .option('--enhanced-privacy', 'restrict access to team admins only (增强私密性)') .action( async ( login: string, @@ -126,14 +146,14 @@ export function registerRepoCommands(program: Command): void { group?: boolean; description?: string; public?: number; - type?: string; + enhancedPrivacy?: boolean; } ) => { const ctx = getContext(create); const body: CreateRepoBody = { name: opts.name, slug: opts.slug }; if (opts.description !== undefined) body.description = opts.description; if (opts.public !== undefined) body.public = opts.public; - if (opts.type !== undefined) body.type = opts.type; + if (opts.enhancedPrivacy) body.enhancedPrivacy = true; const book = await createRepo(ctx.http, opts.group ? 'group' : 'user', login, body); if (ctx.json) { printJson(book); diff --git a/src/commands/search.ts b/src/commands/search.ts index e175d9a..6f192df 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -13,13 +13,16 @@ function pageFlag(value: string): number { if (!/^\d+$/.test(value) || Number(value) < 1) { throw new UsageError(`--page expects a positive integer, got "${value}"`); } + if (Number(value) > 100) { + throw new UsageError(`--page is capped at 100 by the Yuque API, got ${value}`); + } return Number(value); } export function registerSearchCommands(program: Command): void { const cmd = program .command('search') - .description('search docs or repos') + .description('Search docs or repos') .argument('<query>', 'search keywords') .option('--type <type>', 'what to search for: doc or repo (required)') .option('--scope <ns>', 'restrict to a group or group/repo namespace') diff --git a/src/commands/stats.ts b/src/commands/stats.ts index a48f8af..7efc830 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -99,6 +99,14 @@ function parsePositiveInt(flag: string): (value: string) => number { }; } +function limitFlag(value: string): number { + const limit = parsePositiveInt('--limit')(value); + if (limit > MAX_PAGE_SIZE) { + throw new UsageError(`--limit is capped at ${MAX_PAGE_SIZE} by the Yuque API, got ${limit}`); + } + return limit; +} + // Enum validation lives in argParsers (not Option.choices) so violations throw // UsageError and exit 2; commander's own choices error path calls process.exit // on subcommands created before runCli installs exitOverride on the root. @@ -120,7 +128,7 @@ function withListOptions(cmd: Command, sortFields: string[]): Command { .option('--name <name>', 'filter by name') .option('--range <days>', 'time range in days: 0 (all time), 30 or 365', parseRange) .option('--page <n>', 'page number', parsePositiveInt('--page')) - .option('--limit <n>', 'page size (max 20)', parsePositiveInt('--limit')) + .option('--limit <n>', 'page size (max 20)', limitFlag) .option( '--sort-field <field>', `field to sort by: ${sortFields.join(', ')}`, diff --git a/src/commands/user.ts b/src/commands/user.ts index 13614c0..f40507e 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -16,10 +16,19 @@ function intFlag(flag: string): (value: string) => number { }; } +/** Spec enum for the user-groups role filter: 0 (admin) | 1 (member). */ +function roleFlag(value: string): number { + const role = intFlag('--role')(value); + if (role !== 0 && role !== 1) { + throw new UsageError(`--role must be 0 (admin) or 1 (member), got ${value}`); + } + return role; +} + export function registerUserCommands(program: Command): void { - const user = program.command('user').description('inspect users and their groups'); + const user = program.command('user').description('Inspect users and their groups'); - const info = user.command('info').description('show the authenticated user'); + const info = user.command('info').description('Show the authenticated user'); info.action(async () => { const ctx = getContext(info); const me = await getCurrentUser(ctx.http); @@ -43,9 +52,9 @@ export function registerUserCommands(program: Command): void { const groups = user .command('groups') - .description('list the groups a user belongs to') + .description('List the groups a user belongs to') .argument('<user>', 'user login or numeric id') - .option('--role <n>', 'filter by role (0: admin, 1: member)', intFlag('--role')) + .option('--role <n>', 'filter by role (0: admin, 1: member)', roleFlag) .option('--offset <n>', 'pagination offset (page size is fixed at 100)', intFlag('--offset')) .option('--all', 'fetch all pages'); groups.action(async (userRef: string) => { diff --git a/tests/commands/repo.test.ts b/tests/commands/repo.test.ts index f0e2c06..bc92a5c 100644 --- a/tests/commands/repo.test.ts +++ b/tests/commands/repo.test.ts @@ -176,16 +176,34 @@ describe('repo commands', () => { it('creates a group repo with all optional fields', async () => { request.mockResolvedValueOnce(envelope(BOOK)); const args = ['repo', 'create', 'mygroup', '--group', '--name', 'Docs', '--slug', 'docs']; - args.push('--description', 'd', '--public', '2', '--type', 'Book', '--json'); + args.push('--description', 'd', '--public', '2', '--enhanced-privacy', '--json'); await expect(runCli(argv(...args))).resolves.toBe(0); expect(request).toHaveBeenCalledWith({ method: 'post', url: '/groups/mygroup/repos', params: undefined, - data: { name: 'Docs', slug: 'docs', description: 'd', public: 2, type: 'Book' }, + data: { name: 'Docs', slug: 'docs', description: 'd', public: 2, enhancedPrivacy: true }, }); expect(JSON.parse(stdoutText())).toEqual(BOOK); }); + + it('rejects a --limit above the spec cap on repo list', async () => { + const args = ['repo', 'list', 'yuque', '--limit', '101']; + await expect(runCli(argv(...args))).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + + it('passes --filter-by-ability through to the query', async () => { + request.mockResolvedValueOnce(envelope([BOOK])); + const args = ['repo', 'list', 'yuque', '--filter-by-ability', 'create_doc', '--json']; + await expect(runCli(argv(...args))).resolves.toBe(0); + expect(request).toHaveBeenCalledWith({ + method: 'get', + url: '/users/yuque/repos', + params: { filterByAbility: 'create_doc' }, + data: undefined, + }); + }); }); describe('repo update', () => { From 1f1cbb6c8b6f237e8786de505d826c4bb01c916f Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 12:16:48 +0900 Subject: [PATCH 04/13] fix: apply 19 adversarially-verified review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - http: method-aware retry — never silently replay non-idempotent writes on timeout/5xx; flag ambiguity in the error message; pin backoff schedule in tests - docs lock: boundary-aware command matching (no prefix false-positives) plus a reverse lock against phantom README rows; spec table now pins method+path per op - doc/user: validate --offset/--limit regardless of --all; document precedence - output: EastAsianWidth-based display width (Hangul/emoji/CJK ext); EPIPE exits 0 - stats: --json always prints the bare rows array in both paging modes - help/READMEs: mark required flags, fix toc verbs, rekey troubleshooting on real error strings, document YUQUE_PERSONAL_TOKEN fallback Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- README.md | 94 ++++---- README.zh-CN.md | 94 ++++---- src/bin.ts | 7 + src/cli.ts | 2 +- src/client/http.ts | 31 ++- src/commands/doc.ts | 18 +- src/commands/group.ts | 2 +- src/commands/repo.ts | 4 +- src/commands/stats.ts | 47 ++-- src/commands/toc.ts | 3 +- src/commands/user.ts | 2 +- src/config.ts | 2 +- src/output.ts | 19 +- tests/cli.test.ts | 67 +++++- tests/client/http.test.ts | 102 ++++++++- tests/commands/doc.test.ts | 30 ++- tests/commands/stats.test.ts | 26 ++- tests/config.test.ts | 20 +- tests/docs/command-surface-docs.test.ts | 64 +++++- tests/errors.test.ts | 38 ++++ tests/output.test.ts | 77 +++++++ tests/spec-coverage.test.ts | 275 ++++++++++++++++++++---- 22 files changed, 801 insertions(+), 223 deletions(-) create mode 100644 tests/errors.test.ts create mode 100644 tests/output.test.ts diff --git a/README.md b/README.md index dd9ee00..82c1182 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status ## Configuration -| Setting | Env var / CLI flag | Description | -| -------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| Token **(required)** | `YUQUE_TOKEN` / `--token` | Personal or team Yuque API token | -| Host (optional) | `YUQUE_HOST` / `--host` | Site or space host, e.g. `https://your-space.yuque.com` — required for space-bound team tokens and private deployments | +| Setting | Env var / CLI flag | Description | +| -------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Token **(required)** | `YUQUE_TOKEN` / `--token` | Personal or team Yuque API token (also reads `YUQUE_PERSONAL_TOKEN` as a compatibility fallback, e.g. shared with @yuque/mcp-server) | +| Host (optional) | `YUQUE_HOST` / `--host` | Site or space host, e.g. `https://your-space.yuque.com` — required for space-bound team tokens and private deployments | Flags win over env vars, so a one-off `--token` override always works. Site roots are normalized (`/api/v2` is appended automatically); when unset, the host defaults to `https://www.yuque.com`. @@ -57,34 +57,34 @@ Flags win over env vars, so a one-off `--token` override always works. Site root Each command maps to the [Yuque OpenAPI](https://www.yuque.com/yuque/developer/api) — the mapping is locked by a contract test against the vendored spec. -| Category | Command | Description | -| ---------- | ---------------------------------- | ------------------------------------------------- | -| **Auth** | `ping` | Verify connectivity to the Yuque API | -| | `auth status` | Show who you are signed in as | -| **User** | `user info` | Show the authenticated user | -| | `user groups <user>` | List groups a user belongs to | -| **Search** | `search <query>` | Search docs or repos, with paging | -| **Repos** | `repo list <login>` | List repos (知识库) of a user or `--group` | -| | `repo get <repo>` | Show a repo by id or `owner/slug` | -| | `repo create <login>` | Create a repo | -| | `repo update <repo>` | Update name, slug, description, or visibility | -| | `repo delete <repo>` | Delete a repo — asks for confirmation | -| **Docs** | `doc list <repo>` | List docs in a repo, `--all` drains paging | -| | `doc get <repo> <doc>` | Print a doc's markdown body (pipe-friendly) | -| | `doc create <repo>` | Create a doc from `--body` or `--body-file` | -| | `doc update <repo> <doc>` | Update a doc's body or metadata | -| | `doc delete <repo> <doc>` | Delete a doc — asks for confirmation | -| | `doc versions <doc-id>` | List a doc's version history | -| | `doc version <version-id>` | Show one version's content | -| **TOC** | `toc get <repo>` | Print a repo's table of contents as a tree | -| | `toc update <repo>` | Append, move, edit, or remove a TOC node | -| **Groups** | `group members <login>` | List members of a group | -| | `group member set <login> <user>` | Add a member or change their role | -| | `group member remove <login> <user>` | Remove a member — asks for confirmation | -| **Stats** | `stats group <login>` | Group-level statistics | -| | `stats members <login>` | Per-member statistics | -| | `stats books <login>` | Per-repo statistics | -| | `stats docs <login>` | Per-doc statistics | +| Category | Command | Description | +| ---------- | ------------------------------------ | ---------------------------------------------------------------------------------- | +| **Auth** | `ping` | Verify connectivity to the Yuque API | +| | `auth status` | Show who you are signed in as | +| **User** | `user info` | Show the authenticated user | +| | `user groups <user>` | List groups a user belongs to | +| **Search** | `search <query>` | Search docs or repos, with paging | +| **Repos** | `repo list <login>` | List repos (知识库) of a user or `--group` | +| | `repo get <repo>` | Show a repo by id or `owner/slug` | +| | `repo create <login>` | Create a repo | +| | `repo update <repo>` | Update name, slug, description, visibility, or TOC | +| | `repo delete <repo>` | Delete a repo — asks for confirmation | +| **Docs** | `doc list <repo>` | List docs in a repo, `--all` drains paging | +| | `doc get <repo> <doc>` | Print a doc's markdown body; also takes a global `<doc-id>`, `--meta` for metadata | +| | `doc create <repo>` | Create a doc from `--body` or `--body-file` | +| | `doc update <repo> <doc>` | Update a doc's body or metadata | +| | `doc delete <repo> <doc>` | Delete a doc — asks for confirmation | +| | `doc versions <doc-id>` | List a doc's version history | +| | `doc version <version-id>` | Show one version's content | +| **TOC** | `toc get <repo>` | Print a repo's table of contents as a tree | +| | `toc update <repo>` | Append, prepend, edit, or remove a TOC node | +| **Groups** | `group members <login>` | List members of a group | +| | `group member set <login> <user>` | Add a member or change their role | +| | `group member remove <login> <user>` | Remove a member — asks for confirmation | +| **Stats** | `stats group <login>` | Group-level statistics | +| | `stats members <login>` | Per-member statistics | +| | `stats books <login>` | Per-repo statistics | +| | `stats docs <login>` | Per-doc statistics | Repos accept either a numeric id or an `owner/slug` namespace everywhere. Run `yuque <command> --help` for all flags. @@ -98,14 +98,14 @@ yuque doc list team/handbook --all --json | jq -r '.[].slug' Exit codes are stable, so scripts can branch on them: -| Code | Meaning | -| ---- | ----------------------------- | -| `0` | Success | -| `1` | API or unknown error | -| `2` | Usage error | -| `3` | Authentication error | -| `4` | Not found | -| `5` | Rate limited | +| Code | Meaning | +| ---- | -------------------- | +| `0` | Success | +| `1` | API or unknown error | +| `2` | Usage error | +| `3` | Authentication error | +| `4` | Not found | +| `5` | Rate limited | Colors are disabled automatically when piping, or force-off with `NO_COLOR=1`. Rate-limited and transient errors are retried with backoff before failing. @@ -115,13 +115,13 @@ Colors are disabled automatically when piping, or force-off with `NO_COLOR=1`. R ## Troubleshooting -| Error | Solution | -| ---------------------------- | --------------------------------------------------------------------------------- | -| `A Yuque API token is required` | Set `YUQUE_TOKEN=YOUR_TOKEN` or pass `--token=YOUR_TOKEN` | -| `401 Unauthorized` | Token invalid or expired — [regenerate it](https://www.yuque.com/settings/tokens) | -| `429 Rate Limited` | Too many requests — the CLI retries automatically; slow down `--all` loops | -| `404 Not Found` | Check the repo id / `owner/slug` namespace and the doc slug | -| `npm` command not found | Install [Node.js](https://nodejs.org/) v20 or later | +| Error | Solution | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `A Yuque API token is required` | Set `YUQUE_TOKEN=YOUR_TOKEN` or pass `--token=YOUR_TOKEN` | +| `token invalid or expired` (exit `3`) | [Regenerate the token](https://www.yuque.com/settings/tokens) or fix `YUQUE_TOKEN` / `--token` | +| `rate limited by the Yuque API` (exit `5`) | The CLI retries automatically; slow down `--all` loops | +| `the requested resource does not exist` (exit `4`) | Check the repo id / `owner/slug` namespace and the doc slug | +| `npm` command not found | Install [Node.js](https://nodejs.org/) v20 or later | ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e40d9d..8849d66 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,10 +46,10 @@ YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status ## 配置 -| 配置项 | 环境变量 / 命令行参数 | 说明 | -| ----------------- | ------------------------- | ------------------------------------------------------------------------------------------ | -| Token **(必填)** | `YUQUE_TOKEN` / `--token` | 语雀个人或团队 API Token | -| Host(可选) | `YUQUE_HOST` / `--host` | 站点或空间地址,例如 `https://your-space.yuque.com` —— 绑定空间的团队 Token 和私有化部署必填 | +| 配置项 | 环境变量 / 命令行参数 | 说明 | +| ------------------ | ------------------------- | -------------------------------------------------------------------------------------------- | +| Token **(必填)** | `YUQUE_TOKEN` / `--token` | 语雀个人或团队 API Token(同时兼容读取 `YUQUE_PERSONAL_TOKEN`,可与 @yuque/mcp-server 共用) | +| Host(可选) | `YUQUE_HOST` / `--host` | 站点或空间地址,例如 `https://your-space.yuque.com` —— 绑定空间的团队 Token 和私有化部署必填 | 命令行参数优先于环境变量,随手 `--token` 覆盖一次总是生效。站点地址会自动规范化(自动补 `/api/v2`);不设置时默认 `https://www.yuque.com`。 @@ -57,34 +57,34 @@ YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status 每条命令都对应[语雀 OpenAPI](https://www.yuque.com/yuque/developer/api) —— 映射关系由契约测试锁定在内置规格文件上。 -| 分类 | 命令 | 说明 | -| ---------- | ------------------------------------ | --------------------------------------- | -| **认证** | `ping` | 验证与语雀 API 的连通性 | -| | `auth status` | 查看当前登录身份 | -| **用户** | `user info` | 查看当前 Token 对应的用户 | -| | `user groups <user>` | 列出用户加入的团队 | -| **搜索** | `search <query>` | 搜索文档或知识库,支持分页 | -| **知识库** | `repo list <login>` | 列出用户或团队(`--group`)的知识库 | -| | `repo get <repo>` | 按 id 或 `owner/slug` 查看知识库 | -| | `repo create <login>` | 创建知识库 | -| | `repo update <repo>` | 更新名称、路径、简介或可见性 | -| | `repo delete <repo>` | 删除知识库 —— 需要确认 | -| **文档** | `doc list <repo>` | 列出知识库中的文档,`--all` 拉取全量 | -| | `doc get <repo> <doc>` | 输出文档 markdown 正文(管道友好) | -| | `doc create <repo>` | 从 `--body` 或 `--body-file` 创建文档 | -| | `doc update <repo> <doc>` | 更新文档正文或元信息 | -| | `doc delete <repo> <doc>` | 删除文档 —— 需要确认 | -| | `doc versions <doc-id>` | 列出文档的版本历史 | -| | `doc version <version-id>` | 查看某个版本的内容 | -| **目录** | `toc get <repo>` | 以树形输出知识库目录 | -| | `toc update <repo>` | 追加、移动、编辑或删除目录节点 | -| **团队** | `group members <login>` | 列出团队成员 | -| | `group member set <login> <user>` | 添加成员或调整角色 | -| | `group member remove <login> <user>` | 移除成员 —— 需要确认 | -| **统计** | `stats group <login>` | 团队维度统计 | -| | `stats members <login>` | 成员维度统计 | -| | `stats books <login>` | 知识库维度统计 | -| | `stats docs <login>` | 文档维度统计 | +| 分类 | 命令 | 说明 | +| ---------- | ------------------------------------ | ------------------------------------------------------------------ | +| **认证** | `ping` | 验证与语雀 API 的连通性 | +| | `auth status` | 查看当前登录身份 | +| **用户** | `user info` | 查看当前 Token 对应的用户 | +| | `user groups <user>` | 列出用户加入的团队 | +| **搜索** | `search <query>` | 搜索文档或知识库,支持分页 | +| **知识库** | `repo list <login>` | 列出用户或团队(`--group`)的知识库 | +| | `repo get <repo>` | 按 id 或 `owner/slug` 查看知识库 | +| | `repo create <login>` | 创建知识库 | +| | `repo update <repo>` | 更新名称、路径、简介、可见性或目录 | +| | `repo delete <repo>` | 删除知识库 —— 需要确认 | +| **文档** | `doc list <repo>` | 列出知识库中的文档,`--all` 拉取全量 | +| | `doc get <repo> <doc>` | 输出文档 markdown 正文;也接受全局 `<doc-id>`,`--meta` 查看元信息 | +| | `doc create <repo>` | 从 `--body` 或 `--body-file` 创建文档 | +| | `doc update <repo> <doc>` | 更新文档正文或元信息 | +| | `doc delete <repo> <doc>` | 删除文档 —— 需要确认 | +| | `doc versions <doc-id>` | 列出文档的版本历史 | +| | `doc version <version-id>` | 查看某个版本的内容 | +| **目录** | `toc get <repo>` | 以树形输出知识库目录 | +| | `toc update <repo>` | 追加、头插、编辑或删除目录节点 | +| **团队** | `group members <login>` | 列出团队成员 | +| | `group member set <login> <user>` | 添加成员或调整角色 | +| | `group member remove <login> <user>` | 移除成员 —— 需要确认 | +| **统计** | `stats group <login>` | 团队维度统计 | +| | `stats members <login>` | 成员维度统计 | +| | `stats books <login>` | 知识库维度统计 | +| | `stats docs <login>` | 文档维度统计 | 知识库参数在所有命令中都同时接受数字 id 和 `owner/slug` 路径。各命令的完整参数见 `yuque <命令> --help`。 @@ -98,14 +98,14 @@ yuque doc list team/handbook --all --json | jq -r '.[].slug' 退出码保持稳定,脚本可以放心分支: -| 退出码 | 含义 | -| ------ | ---------------- | -| `0` | 成功 | -| `1` | API 或未知错误 | -| `2` | 用法错误 | -| `3` | 认证错误 | -| `4` | 资源不存在 | -| `5` | 触发限流 | +| 退出码 | 含义 | +| ------ | -------------- | +| `0` | 成功 | +| `1` | API 或未知错误 | +| `2` | 用法错误 | +| `3` | 认证错误 | +| `4` | 资源不存在 | +| `5` | 触发限流 | 管道输出时自动关闭颜色,也可用 `NO_COLOR=1` 强制关闭。限流与瞬时错误会自动退避重试后再失败。 @@ -115,13 +115,13 @@ yuque doc list team/handbook --all --json | jq -r '.[].slug' ## 常见问题 -| 错误 | 解决方案 | -| ------------------------------- | --------------------------------------------------------------------------- | -| `A Yuque API token is required` | 设置 `YUQUE_TOKEN=YOUR_TOKEN` 或传入 `--token=YOUR_TOKEN` | -| `401 Unauthorized` | Token 无效或已过期 —— [重新生成](https://www.yuque.com/settings/tokens) | -| `429 Rate Limited` | 请求过于频繁 —— CLI 会自动重试;`--all` 循环请放慢节奏 | -| `404 Not Found` | 检查知识库 id / `owner/slug` 路径以及文档 slug 是否正确 | -| 找不到 `npm` 命令 | 安装 [Node.js](https://nodejs.org/) v20 或更高版本 | +| 错误 | 解决方案 | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `A Yuque API token is required` | 设置 `YUQUE_TOKEN=YOUR_TOKEN` 或传入 `--token=YOUR_TOKEN` | +| `token invalid or expired`(退出码 `3`) | [重新生成 Token](https://www.yuque.com/settings/tokens),或检查 `YUQUE_TOKEN` / `--token` | +| `rate limited by the Yuque API`(退出码 `5`) | CLI 会自动重试;`--all` 循环请放慢节奏 | +| `the requested resource does not exist`(退出码 `4`) | 检查知识库 id / `owner/slug` 路径以及文档 slug 是否正确 | +| 找不到 `npm` 命令 | 安装 [Node.js](https://nodejs.org/) v20 或更高版本 | ## 参与开发 diff --git a/src/bin.ts b/src/bin.ts index 2ef2a0b..b76665a 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,4 +1,11 @@ #!/usr/bin/env node import { runCli } from './cli.js'; +// Piping into a consumer that exits early (`yuque doc list ... | head`) breaks +// the pipe; exit quietly instead of crashing with an unhandled EPIPE 'error'. +process.stdout.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EPIPE') process.exit(0); + throw error; +}); + process.exitCode = await runCli(process.argv); diff --git a/src/cli.ts b/src/cli.ts index 0f805d9..6d89ac7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ export function buildProgram(): Command { program .description('Yuque (语雀) from the terminal — browse, edit, and manage your knowledge base') .version(VERSION, '-v, --version', 'print the CLI version') - .option('--token <token>', 'Yuque API token (overrides YUQUE_TOKEN)') + .option('--token <token>', 'Yuque API token (overrides YUQUE_TOKEN / YUQUE_PERSONAL_TOKEN)') .option('--host <host>', 'Yuque host, e.g. https://your-space.yuque.com (overrides YUQUE_HOST)') .option('--json', 'print the full API response as JSON') .showHelpAfterError('(run with --help for usage)'); diff --git a/src/client/http.ts b/src/client/http.ts index d367dda..cb28b93 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -6,7 +6,11 @@ export interface YuqueHttpOptions { /** Site root, e.g. https://www.yuque.com — /api/v2 is appended here. */ host: string; timeoutMs?: number; - /** Max retries for 429/5xx/network failures (default 3). */ + /** + * Max retries (default 3). 429 is retried for every method; 502/503/504 and + * network failures are retried only for GET, since a write may already have + * been applied server-side. + */ maxRetries?: number; /** Injectable for tests. */ sleep?: (ms: number) => Promise<void>; @@ -53,11 +57,11 @@ export class YuqueHttp { }); return response.data; } catch (error) { - if (attempt < this.maxRetries && this.isRetryable(error)) { + if (attempt < this.maxRetries && this.isRetryable(error, method)) { await this.sleep(this.retryDelayMs(error, attempt)); continue; } - throw this.normalizeError(error); + throw this.normalizeError(error, method); } } } @@ -78,11 +82,16 @@ export class YuqueHttp { return this.request<T>('delete', url, { params }); } - private isRetryable(error: unknown): boolean { + private isRetryable(error: unknown, method: Method): boolean { if (!axios.isAxiosError(error)) return false; - if (error.response) return RETRYABLE_STATUS.has(error.response.status); - // Network-level failure (no response): connection reset, DNS, timeout. - return error.code !== 'ERR_CANCELED'; + if (error.response) { + // 429 means the request was rejected before processing — safe for all methods. + if (error.response.status === 429) return true; + return method === 'get' && RETRYABLE_STATUS.has(error.response.status); + } + // Network-level failure (no response): connection reset, DNS, timeout. The + // original request may already have been applied, so only GET is replayed. + return method === 'get' && error.code !== 'ERR_CANCELED'; } private retryDelayMs(error: unknown, attempt: number): number { @@ -93,13 +102,17 @@ export class YuqueHttp { return Math.min(500 * 2 ** attempt, 4000); } - private normalizeError(error: unknown): YuqueError { + private normalizeError(error: unknown, method: Method): YuqueError { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; const status = axiosError.response?.status; const apiMessage = axiosError.response?.data?.message || axiosError.message; const hint = statusHint(status); - return new YuqueError(hint ? `${apiMessage} (${hint})` : apiMessage, status, error); + let message = hint ? `${apiMessage} (${hint})` : apiMessage; + if (!axiosError.response && method !== 'get') { + message += ' (the request may still have been applied — verify before retrying)'; + } + return new YuqueError(message, status, error); } return new YuqueError(error instanceof Error ? error.message : String(error), undefined, error); } diff --git a/src/commands/doc.ts b/src/commands/doc.ts index 2a285fd..f6927ee 100644 --- a/src/commands/doc.ts +++ b/src/commands/doc.ts @@ -114,7 +114,7 @@ export function registerDocCommands(program: Command): void { .argument('<repo>', REPO_ARG_HELP) .option('--offset <n>', 'pagination offset') .option('--limit <n>', 'page size (max 100)') - .option('--all', 'fetch all pages') + .option('--all', 'fetch all pages (overrides --offset/--limit)') .option('--deleted', 'list deleted docs') .option('--changed-at-gte <datetime>', 'only docs changed at or after this ISO 8601 time') .option( @@ -135,6 +135,10 @@ export function registerDocCommands(program: Command): void { ) => { const ctx = getContext(list); const ref = parseRepoRef(repo); + // Validate unconditionally so `--all --limit banana` still exits 2; + // with --all the paginator overrides these values. + const offset = parseIntFlag(opts.offset, '--offset'); + const limit = parseIntFlag(opts.limit, '--limit'); const filters: DocListParams = {}; if (opts.deleted) filters.deleted = true; if (opts.changedAtGte !== undefined) filters.changed_at_gte = opts.changedAtGte; @@ -142,14 +146,10 @@ export function registerDocCommands(program: Command): void { filters.optional_properties = opts.optionalProperties; } const docs = opts.all - ? await fetchAllPages((offset, limit) => - listDocs(ctx.http, ref, { ...filters, offset, limit }) + ? await fetchAllPages((pageOffset, pageLimit) => + listDocs(ctx.http, ref, { ...filters, offset: pageOffset, limit: pageLimit }) ) - : await listDocs(ctx.http, ref, { - ...filters, - offset: parseIntFlag(opts.offset, '--offset'), - limit: parseIntFlag(opts.limit, '--limit'), - }); + : await listDocs(ctx.http, ref, { ...filters, offset, limit }); if (ctx.json) { printJson(docs); return; @@ -198,7 +198,7 @@ export function registerDocCommands(program: Command): void { create .description('Create a doc in a repo') .argument('<repo>', REPO_ARG_HELP) - .requiredOption('--title <title>', 'doc title') + .requiredOption('--title <title>', 'doc title (required)') .option('--slug <slug>', 'doc slug (URL path)') .option('--body <content>', 'doc body content') .option('--body-file <path>', 'read the doc body from a file') diff --git a/src/commands/group.ts b/src/commands/group.ts index 5e8922d..665dcfa 100644 --- a/src/commands/group.ts +++ b/src/commands/group.ts @@ -64,7 +64,7 @@ export function registerGroupCommands(program: Command): void { .argument('<login>', 'group login or numeric id') .argument('<user>', 'user login or numeric id') .addOption( - new Option('--role <role>', 'role (0: admin, 1: member, 2: read-only)') + new Option('--role <role>', 'role (0: admin, 1: member, 2: read-only) (required)') .choices(['0', '1', '2']) .makeOptionMandatory() ) diff --git a/src/commands/repo.ts b/src/commands/repo.ts index 91a2267..5420084 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -131,8 +131,8 @@ export function registerRepoCommands(program: Command): void { .command('create') .description('Create a repo under a user or group') .argument('<login>', 'owner user/group login or id') - .requiredOption('--name <name>', 'repo name') - .requiredOption('--slug <slug>', 'repo path (slug)') + .requiredOption('--name <name>', 'repo name (required)') + .requiredOption('--slug <slug>', 'repo path (slug) (required)') .option('--group', 'create under a group instead of a user') .option('--description <description>', 'repo description') .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parseNonNegativeInt) diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 7efc830..6f82866 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -142,12 +142,11 @@ function withListOptions(cmd: Command, sortFields: string[]): Command { .option('--all', 'fetch every page (takes precedence over --page/--limit)'); } +// --json always prints the bare rows array (with or without --all) so scripts can +// rely on the same `.[]` shape as every other list command. async function runList<T extends Record<string, unknown>>( cmd: Command, - fetchPage: ( - http: YuqueHttp, - params: DocStatsListParams - ) => Promise<{ rows: T[]; payload: unknown }>, + fetchPage: (http: YuqueHttp, params: DocStatsListParams) => Promise<T[]>, columns: Column<T>[] ): Promise<void> { const ctx = getContext(cmd); @@ -159,25 +158,14 @@ async function runList<T extends Record<string, unknown>>( sortOrder: opts.sortOrder, bookId: opts.bookId, }; - if (opts.all) { - const rows = await fetchAllPages<T>(async (offset, limit) => { - const page = await fetchPage(ctx.http, { ...filters, page: offset / limit + 1, limit }); - return page.rows; - }, MAX_PAGE_SIZE); - if (ctx.json) { - printJson(rows); - } else { - printTable(rows, columns); - } - return; - } - const { rows, payload } = await fetchPage(ctx.http, { - ...filters, - page: opts.page, - limit: opts.limit, - }); + const rows = opts.all + ? await fetchAllPages<T>( + (offset, limit) => fetchPage(ctx.http, { ...filters, page: offset / limit + 1, limit }), + MAX_PAGE_SIZE + ) + : await fetchPage(ctx.http, { ...filters, page: opts.page, limit: opts.limit }); if (ctx.json) { - printJson(payload); + printJson(rows); } else { printTable(rows, columns); } @@ -205,10 +193,7 @@ export function registerStatsCommands(program: Command): void { ).action(async (login: string) => { await runList( membersCmd, - async (http, params) => { - const page = await listMemberStatistics(http, login, params); - return { rows: page.members, payload: page }; - }, + async (http, params) => (await listMemberStatistics(http, login, params)).members, MEMBER_COLUMNS ); }); @@ -219,10 +204,7 @@ export function registerStatsCommands(program: Command): void { ).action(async (login: string) => { await runList( booksCmd, - async (http, params) => { - const page = await listBookStatistics(http, login, params); - return { rows: page.books, payload: page }; - }, + async (http, params) => (await listBookStatistics(http, login, params)).books, BOOK_COLUMNS ); }); @@ -235,10 +217,7 @@ export function registerStatsCommands(program: Command): void { .action(async (login: string) => { await runList( docsCmd, - async (http, params) => { - const page = await listDocStatistics(http, login, params); - return { rows: page.docs, payload: page }; - }, + async (http, params) => (await listDocStatistics(http, login, params)).docs, DOC_COLUMNS ); }); diff --git a/src/commands/toc.ts b/src/commands/toc.ts index f88fc49..4931ece 100644 --- a/src/commands/toc.ts +++ b/src/commands/toc.ts @@ -83,7 +83,8 @@ export function registerTocCommands(program: Command): void { .addOption( new Option( '--action <action>', - 'operation: appendNode (append), prependNode (prepend), editNode, removeNode' + 'operation: appendNode (append), prependNode (prepend), editNode, removeNode (required); ' + + 'move a node: appendNode/prependNode + --node-uuid' ) .choices(['appendNode', 'prependNode', 'editNode', 'removeNode']) .makeOptionMandatory() diff --git a/src/commands/user.ts b/src/commands/user.ts index f40507e..a1208ae 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -56,7 +56,7 @@ export function registerUserCommands(program: Command): void { .argument('<user>', 'user login or numeric id') .option('--role <n>', 'filter by role (0: admin, 1: member)', roleFlag) .option('--offset <n>', 'pagination offset (page size is fixed at 100)', intFlag('--offset')) - .option('--all', 'fetch all pages'); + .option('--all', 'fetch all pages (overrides --offset)'); groups.action(async (userRef: string) => { const ctx = getContext(groups); const opts = groups.opts<{ role?: number; offset?: number; all?: boolean }>(); diff --git a/src/config.ts b/src/config.ts index 254198f..00ba6ab 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ export const DEFAULT_HOST = 'https://www.yuque.com'; export const MISSING_TOKEN_MESSAGE = - 'A Yuque API token is required. Set the YUQUE_TOKEN environment variable or pass --token=<token>. ' + + 'A Yuque API token is required. Set the YUQUE_TOKEN (or YUQUE_PERSONAL_TOKEN) environment variable or pass --token=<token>. ' + 'Create one at https://www.yuque.com/settings/tokens'; /** Flag wins over env so scripted one-off overrides behave like gh/glab. */ diff --git a/src/output.ts b/src/output.ts index cbda4de..487d972 100644 --- a/src/output.ts +++ b/src/output.ts @@ -20,12 +20,27 @@ export function printOk(message: string): void { process.stdout.write(`${colorEnabled() ? '\x1b[32m✓\x1b[0m' : '✓'} ${message}\n`); } -/** Display width where CJK characters count as 2 columns, for aligned tables. */ +/** EastAsianWidth Wide/Fullwidth ranges (plus emoji) that occupy 2 terminal columns. */ +const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [ + [0x1100, 0x115f], // Hangul Jamo + [0x2e80, 0xa4cf], // CJK Radicals .. Yi + [0xa960, 0xa97f], // Hangul Jamo Extended-A + [0xac00, 0xd7a3], // Hangul syllables + [0xf900, 0xfaff], // CJK Compatibility Ideographs + [0xfe10, 0xfe19], // Vertical forms + [0xfe30, 0xfe6f], // CJK Compatibility Forms, Small Form Variants + [0xff00, 0xff60], // Fullwidth forms (excludes halfwidth katakana) + [0xffe0, 0xffe6], // Fullwidth signs + [0x1f300, 0x1faff], // Emoji + [0x20000, 0x3fffd], // CJK Extension B+ +]; + +/** Display width where wide characters (CJK, Hangul, emoji, ...) count as 2 columns. */ export function displayWidth(text: string): number { let width = 0; for (const char of text) { const code = char.codePointAt(0) ?? 0; - width += code >= 0x2e80 && code <= 0x9fff ? 2 : code >= 0xff00 && code <= 0xffef ? 2 : 1; + width += WIDE_RANGES.some(([lo, hi]) => code >= lo && code <= hi) ? 2 : 1; } return width; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index fb8091c..4b9701c 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,6 +1,22 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; import { runCli } from '../src/cli.js'; +vi.mock('axios', async (importOriginal) => { + const actual = await importOriginal<typeof import('axios')>(); + return { + default: { + ...actual.default, + create: vi.fn(), + isAxiosError: actual.default.isAxiosError, + }, + }; +}); + +const mockedAxios = vi.mocked(axios, { partial: true }); + +const request = vi.fn(); + function argv(...args: string[]): string[] { return ['node', 'yuque', ...args]; } @@ -44,3 +60,52 @@ describe('runCli', () => { } }); }); + +describe('runCli exit code contract for API errors', () => { + let stderr: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + request.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedAxios.create.mockReturnValue({ request } as any); + vi.stubEnv('YUQUE_TOKEN', 'test-token'); + stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + stderr.mockRestore(); + }); + + function stderrText(): string { + return stderr.mock.calls.map((call) => String(call[0])).join(''); + } + + function apiFailure(status: number, message: string) { + const error = new Error(message) as Error & { + isAxiosError: boolean; + response: { status: number; data: { message: string }; headers: Record<string, string> }; + }; + error.isAxiosError = true; + error.response = { status, data: { message }, headers: {} }; + return error; + } + + it('maps a 401 API error to exit code 3 (auth error)', async () => { + request.mockRejectedValue(apiFailure(401, 'token expired')); + await expect(runCli(argv('user', 'info'))).resolves.toBe(3); + expect(stderrText()).toContain('token expired'); + }); + + it('maps a 403 API error to exit code 3 (auth error)', async () => { + request.mockRejectedValue(apiFailure(403, 'forbidden')); + await expect(runCli(argv('user', 'info'))).resolves.toBe(3); + expect(stderrText()).toContain('forbidden'); + }); + + it('maps a 500 API error to exit code 1 (generic API error)', async () => { + request.mockRejectedValue(apiFailure(500, 'internal error')); + await expect(runCli(argv('user', 'info'))).resolves.toBe(1); + expect(stderrText()).toContain('internal error'); + }); +}); diff --git a/tests/client/http.test.ts b/tests/client/http.test.ts index 6af4541..ac3c40b 100644 --- a/tests/client/http.test.ts +++ b/tests/client/http.test.ts @@ -26,6 +26,13 @@ function axiosFailure(status: number, message = 'boom', headers: Record<string, return error; } +function networkFailure(code: string, message = 'socket hang up') { + const error = new Error(message) as Error & { isAxiosError: boolean; code: string }; + error.isAxiosError = true; + error.code = code; + return error; +} + describe('YuqueHttp', () => { const request = vi.fn(); const sleep = vi.fn(() => Promise.resolve()); @@ -77,12 +84,97 @@ describe('YuqueHttp', () => { expect(sleep).toHaveBeenCalledWith(2000); }); - it('gives up after maxRetries and throws a YuqueError with hint', async () => { + it('caps Retry-After delays at 10 seconds', async () => { + request + .mockRejectedValueOnce(axiosFailure(429, 'rate limited', { 'retry-after': '60' })) + .mockResolvedValueOnce({ data: { data: 'ok' } }); + await makeHttp().get('/user'); + expect(sleep).toHaveBeenCalledWith(10000); + }); + + it('backs off exponentially and caps the delay at 4000ms', async () => { request.mockRejectedValue(axiosFailure(429, 'rate limited')); - const promise = makeHttp(2).get('/user'); - await expect(promise).rejects.toBeInstanceOf(YuqueError); - await expect(makeHttp(0).get('/user')).rejects.toThrow(/rate limited/); - expect(request).toHaveBeenCalledTimes(4); // 3 attempts (maxRetries=2) + 1 (maxRetries=0) + await expect(makeHttp(4).get('/user')).rejects.toBeInstanceOf(YuqueError); + expect(sleep.mock.calls).toEqual([[500], [1000], [2000], [4000]]); + }); + + it.each([502, 503, 504])('retries GET on %i then succeeds', async (status) => { + request + .mockRejectedValueOnce(axiosFailure(status, 'upstream error')) + .mockResolvedValueOnce({ data: { data: 'ok' } }); + await expect(makeHttp().get('/user')).resolves.toEqual({ data: 'ok' }); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('does not retry 500', async () => { + request.mockRejectedValue(axiosFailure(500, 'server error')); + await expect(makeHttp().get('/user')).rejects.toMatchObject({ statusCode: 500 }); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('retries GET network errors without a response', async () => { + request + .mockRejectedValueOnce(networkFailure('ECONNRESET')) + .mockResolvedValueOnce({ data: { data: 'ok' } }); + await expect(makeHttp().get('/user')).resolves.toEqual({ data: 'ok' }); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('does not retry canceled requests', async () => { + request.mockRejectedValue(networkFailure('ERR_CANCELED', 'canceled')); + await expect(makeHttp().get('/user')).rejects.toBeInstanceOf(YuqueError); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('does not retry a POST that failed without a response and flags the ambiguity', async () => { + request.mockRejectedValue(networkFailure('ECONNABORTED', 'timeout of 30000ms exceeded')); + const error = await makeHttp() + .post('/repos/g/r/docs', { title: 'T' }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(YuqueError); + expect((error as YuqueError).message).toBe( + 'timeout of 30000ms exceeded (the request may still have been applied — verify before retrying)' + ); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it.each(['post', 'put', 'delete'] as const)('does not retry %s on 502', async (method) => { + request.mockRejectedValue(axiosFailure(502, 'bad gateway')); + await expect(makeHttp()[method]('/repos/1')).rejects.toMatchObject({ statusCode: 502 }); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('still retries POST on 429 (request was not processed)', async () => { + request + .mockRejectedValueOnce(axiosFailure(429, 'rate limited')) + .mockResolvedValueOnce({ data: { data: 'ok' } }); + await expect(makeHttp().post('/repos', { name: 'n' })).resolves.toEqual({ data: 'ok' }); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('gives up after maxRetries and throws a YuqueError with the 429 hint', async () => { + request.mockRejectedValue(axiosFailure(429, 'rate limited')); + const error = await makeHttp(2) + .get('/user') + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(YuqueError); + expect(error).toMatchObject({ + statusCode: 429, + message: 'rate limited (rate limited by the Yuque API — wait a moment and retry)', + }); + expect(request).toHaveBeenCalledTimes(3); // 1 attempt + 2 retries + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it('maxRetries=0 disables retries entirely', async () => { + request.mockRejectedValue(axiosFailure(429, 'rate limited')); + await expect(makeHttp(0).get('/user')).rejects.toMatchObject({ statusCode: 429 }); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); }); it('does not retry non-retryable statuses', async () => { diff --git a/tests/commands/doc.test.ts b/tests/commands/doc.test.ts index 548146d..7a33630 100644 --- a/tests/commands/doc.test.ts +++ b/tests/commands/doc.test.ts @@ -85,6 +85,9 @@ describe('doc commands', () => { '10', '--limit', '5', + '--deleted', + '--changed-at-gte', + '2026-01-01T00:00:00.000Z', '--optional-properties', 'hits,tags' ) @@ -93,7 +96,13 @@ describe('doc commands', () => { expect(request).toHaveBeenCalledWith({ method: 'get', url: '/repos/123456/docs', - params: { offset: 10, limit: 5, optional_properties: 'hits,tags' }, + params: { + offset: 10, + limit: 5, + deleted: true, + changed_at_gte: '2026-01-01T00:00:00.000Z', + optional_properties: 'hits,tags', + }, data: undefined, }); }); @@ -129,6 +138,25 @@ describe('doc commands', () => { await expect(runCli(argv('doc', 'list', 'yuque/help', '--limit', 'ten'))).resolves.toBe(2); expect(request).not.toHaveBeenCalled(); }); + + it('rejects a non-integer --limit with exit code 2 even with --all', async () => { + await expect( + runCli(argv('doc', 'list', 'yuque/help', '--all', '--limit', 'banana')) + ).resolves.toBe(2); + expect(request).not.toHaveBeenCalled(); + expect(stderrText()).toContain('--limit expects a non-negative integer'); + }); + + it('--all overrides valid --offset/--limit values with the paginator schedule', async () => { + request.mockResolvedValueOnce(ok([{ id: 1 }])); + await expect( + runCli(argv('doc', 'list', 'yuque/help', '--all', '--offset', '10', '--limit', '5')) + ).resolves.toBe(0); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ params: { offset: 0, limit: 100 } }) + ); + }); }); describe('doc get', () => { diff --git a/tests/commands/stats.test.ts b/tests/commands/stats.test.ts index 5237634..23bb9ec 100644 --- a/tests/commands/stats.test.ts +++ b/tests/commands/stats.test.ts @@ -151,11 +151,11 @@ describe('stats commands', () => { ); }); - it('--json prints the full page payload including total', async () => { + it('--json prints the member rows as a bare array (same shape as --all)', async () => { request.mockResolvedValueOnce(envelope(membersPage)); const code = await run('stats', 'members', 'mygroup', '--json'); expect(code).toBe(0); - expect(JSON.parse(stdoutText())).toEqual(membersPage); + expect(JSON.parse(stdoutText())).toEqual(membersPage.members); }); it('--all drains pages at max page size and keeps filters', async () => { @@ -192,13 +192,12 @@ describe('stats commands', () => { }); describe('stats books', () => { + const booksPage = { + books: [{ book_id: 7, name: 'Handbook', slug: 'handbook', post_count: 12, read_count: 300 }], + total: 1, + }; + it('GETs /statistics/books and prints a table', async () => { - const booksPage = { - books: [ - { book_id: 7, name: 'Handbook', slug: 'handbook', post_count: 12, read_count: 300 }, - ], - total: 1, - }; request.mockResolvedValueOnce(envelope(booksPage)); const code = await run('stats', 'books', 'mygroup', '--sort-field', 'word_count'); expect(code).toBe(0); @@ -210,6 +209,13 @@ describe('stats commands', () => { }); expect(stdoutText()).toContain('Handbook'); }); + + it('--json prints the book rows as a bare array (same shape as --all)', async () => { + request.mockResolvedValueOnce(envelope(booksPage)); + const code = await run('stats', 'books', 'mygroup', '--json'); + expect(code).toBe(0); + expect(JSON.parse(stdoutText())).toEqual(booksPage.books); + }); }); describe('stats docs', () => { @@ -230,12 +236,12 @@ describe('stats commands', () => { expect(stdoutText()).toContain('Intro'); }); - it('--json prints the full page payload', async () => { + it('--json prints the doc rows as a bare array (same shape as --all)', async () => { const docsPage = { docs: [{ doc_id: 55, title: 'Intro' }], total: 1 }; request.mockResolvedValueOnce(envelope(docsPage)); const code = await run('stats', 'docs', 'mygroup', '--json'); expect(code).toBe(0); - expect(JSON.parse(stdoutText())).toEqual(docsPage); + expect(JSON.parse(stdoutText())).toEqual(docsPage.docs); }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 9af5944..d04c668 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_HOST, normalizeHost, resolveHost, resolveToken } from '../src/config.js'; +import { + DEFAULT_HOST, + MISSING_TOKEN_MESSAGE, + normalizeHost, + resolveHost, + resolveToken, +} from '../src/config.js'; describe('resolveToken', () => { it('prefers the flag over env vars', () => { @@ -16,6 +22,18 @@ describe('resolveToken', () => { }); }); +describe('MISSING_TOKEN_MESSAGE', () => { + it('documents every way resolveToken accepts a token', () => { + expect(MISSING_TOKEN_MESSAGE).toContain('YUQUE_TOKEN'); + expect(MISSING_TOKEN_MESSAGE).toContain('YUQUE_PERSONAL_TOKEN'); + expect(MISSING_TOKEN_MESSAGE).toContain('--token'); + }); + + it('keeps the prefix the README troubleshooting table keys on', () => { + expect(MISSING_TOKEN_MESSAGE.startsWith('A Yuque API token is required')).toBe(true); + }); +}); + describe('resolveHost / normalizeHost', () => { it('defaults to www.yuque.com', () => { expect(resolveHost(undefined, {})).toBe(DEFAULT_HOST); diff --git a/tests/docs/command-surface-docs.test.ts b/tests/docs/command-surface-docs.test.ts index 80747bc..d3eb3f2 100644 --- a/tests/docs/command-surface-docs.test.ts +++ b/tests/docs/command-surface-docs.test.ts @@ -6,8 +6,8 @@ import { buildProgram } from '../../src/cli.js'; /** * Docs lock: both READMEs must state the exact command count in their section - * heading and list every registered leaf command, mirroring the tool-surface - * lock in yuque-mcp-server. + * heading, list every registered leaf command, and document nothing beyond the + * registered surface, mirroring the tool-surface lock in yuque-mcp-server. */ function collectLeafCommands(command: Command, prefix: string[] = []): string[] { @@ -26,6 +26,58 @@ function readReadme(name: string): string { return readFileSync(fileURLToPath(new URL(`../../${name}`, import.meta.url)), 'utf8'); } +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * True when the leaf appears backticked and followed by a space (arguments) or + * a closing backtick (bare command). The boundary keeps a prefix command such + * as `doc version` from being satisfied by `doc versions`. + */ +function mentionsCommand(content: string, leaf: string): boolean { + return new RegExp('`' + escapeRegExp(leaf) + '( |`)').test(content); +} + +/** + * The backticked command cells (second column) of the commands-section table, + * with `<arg>` / `[arg]` placeholders stripped down to the leaf path. + */ +function documentedCommands(content: string, heading: RegExp): string[] { + const match = heading.exec(content); + if (!match) throw new Error('missing command-count heading'); + const end = content.indexOf('\n## ', match.index + 1); + const section = content.slice(match.index, end === -1 ? undefined : end); + return [...section.matchAll(/^\|[^|]*\|\s*`([^`]+)`\s*\|/gm)].map((row) => + (row[1] ?? '') + .split(' ') + .filter((token) => !token.startsWith('<') && !token.startsWith('[')) + .join(' ') + ); +} + +describe('docs-lock helpers', () => { + it('rejects prefix matches so `doc version` is not satisfied by `doc versions`', () => { + expect(mentionsCommand('| `doc versions <doc-id>` | history |', 'doc version')).toBe(false); + expect(mentionsCommand('| `doc version <id>` | one version |', 'doc version')).toBe(true); + expect(mentionsCommand('run `ping` to verify', 'ping')).toBe(true); + }); + + it('extracts table command cells and strips argument placeholders', () => { + const section = [ + '## Commands (2)', + '', + '| Category | Command | Description |', + '| --- | --- | --- |', + '| **Docs** | `doc get <repo> <doc>` | body |', + '| | `ping` | connectivity |', + '', + '## Next section with `stale mention`', + ].join('\n'); + expect(documentedCommands(section, /^## Commands \((\d+)\)$/m)).toEqual(['doc get', 'ping']); + }); +}); + describe.each([ { file: 'README.md', heading: new RegExp(`^## Commands \\((\\d+)\\)$`, 'm') }, { file: 'README.zh-CN.md', heading: new RegExp(`^## 命令列表((\\d+) 个)$`, 'm') }, @@ -39,7 +91,13 @@ describe.each([ }); it('mentions every registered command', () => { - const missing = leaves.filter((leaf) => !content.includes(`\`${leaf}`)); + const missing = leaves.filter((leaf) => !mentionsCommand(content, leaf)); expect(missing).toEqual([]); }); + + it('documents exactly the registered commands in the command table', () => { + // Reverse lock: a phantom/renamed row, a duplicate row, or a command + // mentioned only in prose all break this set equality. + expect([...documentedCommands(content, heading)].sort()).toEqual([...leaves].sort()); + }); }); diff --git a/tests/errors.test.ts b/tests/errors.test.ts new file mode 100644 index 0000000..7ce0f93 --- /dev/null +++ b/tests/errors.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { AuthError, CliError, UsageError, exitCodeForStatus } from '../src/errors.js'; + +/** + * Exit code contract (errors.ts): 0 success · 1 API/unknown error · 2 usage + * error · 3 auth error · 4 not found · 5 rate limited. Scripts rely on this, + * so the full status -> exit-code table is pinned here. + */ +describe('exitCodeForStatus', () => { + it.each([ + [401, 3], + [403, 3], + [404, 4], + [429, 5], + [400, 1], + [410, 1], + [500, 1], + [502, 1], + ])('maps HTTP %i to exit code %i', (status, exitCode) => { + expect(exitCodeForStatus(status)).toBe(exitCode); + }); + + it('maps a missing status code to the generic exit code 1', () => { + expect(exitCodeForStatus(undefined)).toBe(1); + expect(exitCodeForStatus()).toBe(1); + }); +}); + +describe('CliError exit codes', () => { + it('defaults CliError to exit code 1', () => { + expect(new CliError('boom').exitCode).toBe(1); + }); + + it('pins UsageError to exit code 2 and AuthError to exit code 3', () => { + expect(new UsageError('bad flag').exitCode).toBe(2); + expect(new AuthError('no token').exitCode).toBe(3); + }); +}); diff --git a/tests/output.test.ts b/tests/output.test.ts new file mode 100644 index 0000000..8c62a80 --- /dev/null +++ b/tests/output.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import { displayWidth } from '../src/output.js'; + +describe('displayWidth', () => { + it('counts ASCII as 1 column per character', () => { + expect(displayWidth('')).toBe(0); + expect(displayWidth('yuque')).toBe(5); + }); + + it('counts CJK ideographs and fullwidth forms as 2 columns', () => { + expect(displayWidth('语雀')).toBe(4); + expect(displayWidth('A')).toBe(2); // U+FF21 fullwidth latin + expect(displayWidth('。')).toBe(2); // U+3002 ideographic full stop + }); + + it('counts Hangul syllables as 2 columns', () => { + expect(displayWidth('한글')).toBe(4); + expect(displayWidth('한글 문서')).toBe(9); // 4 wide chars + 1 space + }); + + it('counts CJK Extension B+ and Compatibility Ideographs as 2 columns', () => { + expect(displayWidth('𠀀')).toBe(2); // U+20000, surrogate pair + expect(displayWidth('豈')).toBe(2); // U+F900 compatibility ideograph + }); + + it('counts emoji as 2 columns', () => { + expect(displayWidth('📄')).toBe(2); // U+1F4C4 + expect(displayWidth('🧪')).toBe(2); // U+1F9EA + }); + + it('counts halfwidth katakana as 1 column', () => { + expect(displayWidth('ア')).toBe(1); // U+FF71 + }); + + it('mixes narrow and wide characters', () => { + expect(displayWidth('doc 한글 📄')).toBe(11); // 3 + 1 + 4 + 1 + 2 + }); +}); + +describe('bin EPIPE guard', () => { + it('exits 0 on stdout EPIPE and rethrows other errors', async () => { + const before = process.stdout.listeners('error'); + const savedArgv = process.argv; + const savedExitCode = process.exitCode; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + class ExitSentinel extends Error {} + const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ExitSentinel(String(code)); + }) as never); + try { + process.argv = ['node', 'yuque', '--help']; + await import('../src/bin.js'); + const listeners = process.stdout.listeners('error').filter((l) => !before.includes(l)); + expect(listeners).toHaveLength(1); + const handler = listeners[0] as (error: NodeJS.ErrnoException) => void; + + const epipe: NodeJS.ErrnoException = Object.assign(new Error('write EPIPE'), { + code: 'EPIPE', + }); + expect(() => handler(epipe)).toThrow(ExitSentinel); + expect(exit).toHaveBeenCalledWith(0); + + const other: NodeJS.ErrnoException = Object.assign(new Error('write EACCES'), { + code: 'EACCES', + }); + expect(() => handler(other)).toThrow('write EACCES'); + expect(exit).toHaveBeenCalledTimes(1); + + process.stdout.removeListener('error', handler); + } finally { + process.argv = savedArgv; + process.exitCode = savedExitCode; + exit.mockRestore(); + write.mockRestore(); + } + }); +}); diff --git a/tests/spec-coverage.test.ts b/tests/spec-coverage.test.ts index 3b2db32..d1235ab 100644 --- a/tests/spec-coverage.test.ts +++ b/tests/spec-coverage.test.ts @@ -9,51 +9,204 @@ import { buildProgram } from '../src/cli.js'; * Contract: the CLI command surface is locked 1:1 against spec/yuque-openapi.yaml. * * Every OpenAPI operation must be handled by at least one command, and every - * registered leaf command must trace back to at least one operation. Changing + * registered leaf command must trace back to at least one operation. Each row + * additionally pins the operation's method + path, so a mapping cannot be + * "satisfied" by pointing an operationId at an unrelated command: repurposing + * an operationId or drifting a path fails the method/path pin below. Changing * either side (spec refresh, new command) requires updating this table — that * is deliberate, the same pinning culture as yuque-mcp-server's contract tests. */ -/** operationId -> CLI command path(s) that call it. */ -const OPERATION_TO_COMMANDS: Record<string, string[]> = { - user_api_v2_hello: ['ping'], - user_api_v2_user_info: ['auth status', 'user info'], - user_api_v2_user_group_list: ['user groups'], - search_api_v2_search: ['search'], - group_api_v2_group_member_list: ['group members'], - group_api_v2_group_member_update: ['group member set'], - group_api_v2_group_member_destroy: ['group member remove'], - 'doc_api_v2_doc_list-by_id': ['doc list'], - 'doc_api_v2_doc_create-by_id': ['doc create'], - 'doc_api_v2_doc_show-by_id': ['doc get'], - 'doc_api_v2_doc_show-by_book_and_id': ['doc get'], - 'doc_api_v2_doc_update-by_id': ['doc update'], - 'doc_api_v2_doc_destroy-by_id': ['doc delete'], - doc_api_v2_doc_list: ['doc list'], - doc_api_v2_doc_create: ['doc create'], - doc_api_v2_doc_show: ['doc get'], - doc_api_v2_doc_update: ['doc update'], - doc_api_v2_doc_destroy: ['doc delete'], - doc_api_v2_doc_version_list: ['doc versions'], - doc_api_v2_doc_version_show: ['doc version'], - 'doc_api_v2_repo_toc_show-by_id': ['toc get'], - 'doc_api_v2_repo_toc_update-by_id': ['toc update'], - doc_api_v2_repo_toc_show: ['toc get'], - doc_api_v2_repo_toc_update: ['toc update'], - 'repo_api_v2_repo_list-by_group': ['repo list'], - 'repo_api_v2_repo_create-by_group': ['repo create'], - repo_api_v2_repo_list: ['repo list'], - repo_api_v2_repo_create: ['repo create'], - 'repo_api_v2_repo_show-by_id': ['repo get'], - 'repo_api_v2_repo_update-by_id': ['repo update'], - 'repo_api_v2_repo_destroy-by_id': ['repo delete'], - repo_api_v2_repo_show: ['repo get'], - repo_api_v2_repo_update: ['repo update'], - repo_api_v2_repo_destroy: ['repo delete'], - statistic_api_v2_statistic_all: ['stats group'], - statistic_api_v2_statistic_by_members: ['stats members'], - statistic_api_v2_statistic_by_books: ['stats books'], - statistic_api_v2_statistic_by_docs: ['stats docs'], +interface OperationMapping { + method: string; + path: string; + commands: string[]; +} + +/** operationId -> spec method + path, and the CLI command path(s) that call it. */ +const OPERATION_TO_COMMANDS: Record<string, OperationMapping> = { + user_api_v2_hello: { method: 'get', path: '/api/v2/hello', commands: ['ping'] }, + user_api_v2_user_info: { + method: 'get', + path: '/api/v2/user', + commands: ['auth status', 'user info'], + }, + user_api_v2_user_group_list: { + method: 'get', + path: '/api/v2/users/{id}/groups', + commands: ['user groups'], + }, + search_api_v2_search: { method: 'get', path: '/api/v2/search', commands: ['search'] }, + group_api_v2_group_member_list: { + method: 'get', + path: '/api/v2/groups/{login}/users', + commands: ['group members'], + }, + group_api_v2_group_member_update: { + method: 'put', + path: '/api/v2/groups/{login}/users/{id}', + commands: ['group member set'], + }, + group_api_v2_group_member_destroy: { + method: 'delete', + path: '/api/v2/groups/{login}/users/{id}', + commands: ['group member remove'], + }, + 'doc_api_v2_doc_list-by_id': { + method: 'get', + path: '/api/v2/repos/{book_id}/docs', + commands: ['doc list'], + }, + 'doc_api_v2_doc_create-by_id': { + method: 'post', + path: '/api/v2/repos/{book_id}/docs', + commands: ['doc create'], + }, + 'doc_api_v2_doc_show-by_id': { + method: 'get', + path: '/api/v2/repos/docs/{id}', + commands: ['doc get'], + }, + 'doc_api_v2_doc_show-by_book_and_id': { + method: 'get', + path: '/api/v2/repos/{book_id}/docs/{id}', + commands: ['doc get'], + }, + 'doc_api_v2_doc_update-by_id': { + method: 'put', + path: '/api/v2/repos/{book_id}/docs/{id}', + commands: ['doc update'], + }, + 'doc_api_v2_doc_destroy-by_id': { + method: 'delete', + path: '/api/v2/repos/{book_id}/docs/{id}', + commands: ['doc delete'], + }, + doc_api_v2_doc_list: { + method: 'get', + path: '/api/v2/repos/{group_login}/{book_slug}/docs', + commands: ['doc list'], + }, + doc_api_v2_doc_create: { + method: 'post', + path: '/api/v2/repos/{group_login}/{book_slug}/docs', + commands: ['doc create'], + }, + doc_api_v2_doc_show: { + method: 'get', + path: '/api/v2/repos/{group_login}/{book_slug}/docs/{id}', + commands: ['doc get'], + }, + doc_api_v2_doc_update: { + method: 'put', + path: '/api/v2/repos/{group_login}/{book_slug}/docs/{id}', + commands: ['doc update'], + }, + doc_api_v2_doc_destroy: { + method: 'delete', + path: '/api/v2/repos/{group_login}/{book_slug}/docs/{id}', + commands: ['doc delete'], + }, + doc_api_v2_doc_version_list: { + method: 'get', + path: '/api/v2/doc_versions', + commands: ['doc versions'], + }, + doc_api_v2_doc_version_show: { + method: 'get', + path: '/api/v2/doc_versions/{id}', + commands: ['doc version'], + }, + 'doc_api_v2_repo_toc_show-by_id': { + method: 'get', + path: '/api/v2/repos/{book_id}/toc', + commands: ['toc get'], + }, + 'doc_api_v2_repo_toc_update-by_id': { + method: 'put', + path: '/api/v2/repos/{book_id}/toc', + commands: ['toc update'], + }, + doc_api_v2_repo_toc_show: { + method: 'get', + path: '/api/v2/repos/{group_login}/{book_slug}/toc', + commands: ['toc get'], + }, + doc_api_v2_repo_toc_update: { + method: 'put', + path: '/api/v2/repos/{group_login}/{book_slug}/toc', + commands: ['toc update'], + }, + 'repo_api_v2_repo_list-by_group': { + method: 'get', + path: '/api/v2/groups/{login}/repos', + commands: ['repo list'], + }, + 'repo_api_v2_repo_create-by_group': { + method: 'post', + path: '/api/v2/groups/{login}/repos', + commands: ['repo create'], + }, + repo_api_v2_repo_list: { + method: 'get', + path: '/api/v2/users/{login}/repos', + commands: ['repo list'], + }, + repo_api_v2_repo_create: { + method: 'post', + path: '/api/v2/users/{login}/repos', + commands: ['repo create'], + }, + 'repo_api_v2_repo_show-by_id': { + method: 'get', + path: '/api/v2/repos/{book_id}', + commands: ['repo get'], + }, + 'repo_api_v2_repo_update-by_id': { + method: 'put', + path: '/api/v2/repos/{book_id}', + commands: ['repo update'], + }, + 'repo_api_v2_repo_destroy-by_id': { + method: 'delete', + path: '/api/v2/repos/{book_id}', + commands: ['repo delete'], + }, + repo_api_v2_repo_show: { + method: 'get', + path: '/api/v2/repos/{group_login}/{book_slug}', + commands: ['repo get'], + }, + repo_api_v2_repo_update: { + method: 'put', + path: '/api/v2/repos/{group_login}/{book_slug}', + commands: ['repo update'], + }, + repo_api_v2_repo_destroy: { + method: 'delete', + path: '/api/v2/repos/{group_login}/{book_slug}', + commands: ['repo delete'], + }, + statistic_api_v2_statistic_all: { + method: 'get', + path: '/api/v2/groups/{login}/statistics', + commands: ['stats group'], + }, + statistic_api_v2_statistic_by_members: { + method: 'get', + path: '/api/v2/groups/{login}/statistics/members', + commands: ['stats members'], + }, + statistic_api_v2_statistic_by_books: { + method: 'get', + path: '/api/v2/groups/{login}/statistics/books', + commands: ['stats books'], + }, + statistic_api_v2_statistic_by_docs: { + method: 'get', + path: '/api/v2/groups/{login}/statistics/docs', + commands: ['stats docs'], + }, }; const EXPECTED_LEAF_COMMANDS = [ @@ -91,21 +244,31 @@ interface SpecOperation { path: string; } -function loadSpecOperations(): SpecOperation[] { +interface SpecOperations { + operations: SpecOperation[]; + /** method + path entries that declare no operationId — must stay empty. */ + missingOperationIds: string[]; +} + +function loadSpecOperations(): SpecOperations { const specPath = fileURLToPath(new URL('../spec/yuque-openapi.yaml', import.meta.url)); const spec = load(readFileSync(specPath, 'utf8')) as { paths: Record<string, Record<string, { operationId?: string }>>; }; const operations: SpecOperation[] = []; + const missingOperationIds: string[] = []; for (const [path, item] of Object.entries(spec.paths)) { for (const method of ['get', 'post', 'put', 'delete', 'patch']) { const operation = item[method]; - if (operation?.operationId) { + if (!operation) continue; + if (operation.operationId) { operations.push({ operationId: operation.operationId, method, path }); + } else { + missingOperationIds.push(`${method.toUpperCase()} ${path}`); } } } - return operations; + return { operations, missingOperationIds }; } function collectLeafCommands(command: Command, prefix: string[] = []): string[] { @@ -121,14 +284,22 @@ function collectLeafCommands(command: Command, prefix: string[] = []): string[] return leaves; } +const mappedCommands = () => Object.values(OPERATION_TO_COMMANDS).flatMap((row) => row.commands); + describe('spec coverage contract', () => { - const operations = loadSpecOperations(); + const { operations, missingOperationIds } = loadSpecOperations(); const registeredLeaves = collectLeafCommands(buildProgram()).sort(); it('pins the spec identity (38 operations)', () => { expect(operations).toHaveLength(38); }); + it('has no spec operation without an operationId', () => { + // A spec refresh adding an operationId-less path must not silently escape + // the contract: every method+path entry has to carry an operationId. + expect(missingOperationIds).toEqual([]); + }); + it('maps every spec operation to at least one CLI command', () => { const unmapped = operations.filter((op) => !OPERATION_TO_COMMANDS[op.operationId]); expect( @@ -136,6 +307,16 @@ describe('spec coverage contract', () => { ).toEqual([]); }); + it('pins each mapped operation to its spec method and path', () => { + const mismatched = operations + .filter((op) => { + const row = OPERATION_TO_COMMANDS[op.operationId]; + return row !== undefined && (row.method !== op.method || row.path !== op.path); + }) + .map((op) => `${op.operationId}: spec has ${op.method.toUpperCase()} ${op.path}`); + expect(mismatched).toEqual([]); + }); + it('has no stale operationIds in the mapping table', () => { const known = new Set(operations.map((op) => op.operationId)); const stale = Object.keys(OPERATION_TO_COMMANDS).filter((id) => !known.has(id)); @@ -148,14 +329,14 @@ describe('spec coverage contract', () => { it('every mapped command exists in the program', () => { const registered = new Set(registeredLeaves); - const missing = [...new Set(Object.values(OPERATION_TO_COMMANDS).flat())].filter( + const missing = [...new Set(mappedCommands())].filter( (commandPath) => !registered.has(commandPath) ); expect(missing).toEqual([]); }); it('every leaf command traces back to a spec operation', () => { - const mapped = new Set(Object.values(OPERATION_TO_COMMANDS).flat()); + const mapped = new Set(mappedCommands()); const orphans = registeredLeaves.filter((commandPath) => !mapped.has(commandPath)); expect(orphans).toEqual([]); }); From a32b85ec28cf791fbc536fa0587e0ab0b69102d5 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 17:26:21 +0900 Subject: [PATCH 05/13] fix: address PR #2 review feedback - stats --json keeps the page object ({rows-key, total}) in both paging modes - repo --public validates the 0|1|2 enum locally (exit 2, no wire call) - doc get renders sheet/table docs via body_sheet/body_table fallback, warns on stderr when nothing is renderable - toc update enforces spec cross-field rules: edit/remove need --node-uuid, creating needs --type plus per-type fields; --target-uuid stays optional (defaults to root) - request timeout configurable via --timeout / YUQUE_TIMEOUT_MS (default 30s, same as yuque-mcp-server) - restore log/env/editor entries dropped from .gitignore in the rewrite Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .gitignore | 21 +++++++++- src/cli.ts | 4 ++ src/client/types.ts | 2 + src/commands/doc.ts | 34 ++++++++++++--- src/commands/repo.ts | 14 ++++++- src/commands/stats.ts | 55 ++++++++++++++++++------- src/commands/toc.ts | 45 ++++++++++++++++---- src/config.ts | 18 ++++++++ src/context.ts | 15 +++++-- tests/commands/stats.test.ts | 16 +++---- tests/commands/toc-group.test.ts | 71 ++++++++++++++++++++++++++++++++ tests/config.test.ts | 19 +++++++++ 12 files changed, 275 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 7e3789d..d976afe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,24 @@ +# Dependencies node_modules/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +*.log + +# Build artifacts dist/ coverage/ -*.log +*.tgz + +# Env files +.env +.env.* + +# OS / Editor files .DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/src/cli.ts b/src/cli.ts index 6d89ac7..24feceb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,10 @@ export function buildProgram(): Command { .option('--token <token>', 'Yuque API token (overrides YUQUE_TOKEN / YUQUE_PERSONAL_TOKEN)') .option('--host <host>', 'Yuque host, e.g. https://your-space.yuque.com (overrides YUQUE_HOST)') .option('--json', 'print the full API response as JSON') + .option( + '--timeout <ms>', + 'API request timeout in milliseconds (overrides YUQUE_TIMEOUT_MS, default 30000)' + ) .showHelpAfterError('(run with --help for usage)'); // Must precede register* calls: commander copies the exit callback into each // subcommand at .command() creation time, and the exit-code contract (usage diff --git a/src/client/types.ts b/src/client/types.ts index 9b118b3..316a6ff 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -121,6 +121,8 @@ export interface V2DocDetail extends V2Doc { body_draft?: string; body_html?: string; body_lake?: string; + body_sheet?: string; + body_table?: string; creator?: V2User; [key: string]: unknown; } diff --git a/src/commands/doc.ts b/src/commands/doc.ts index f6927ee..7c3a2b0 100644 --- a/src/commands/doc.ts +++ b/src/commands/doc.ts @@ -98,10 +98,32 @@ function publicOption(): Option { ]); } +/** + * Sheet/Table docs carry their content in body_sheet/body_table and may leave + * `body` empty, so pick by format first and fall back across the content fields. + */ +function pickBody(detail: Partial<V2DocDetail>): string { + const byFormat = + detail.format === 'sheet' + ? detail.body_sheet + : detail.format === 'table' + ? detail.body_table + : detail.body; + const text = [byFormat, detail.body, detail.body_sheet, detail.body_table].find( + (candidate) => typeof candidate === 'string' && candidate !== '' + ); + return typeof text === 'string' ? text : ''; +} + /** Stream the doc body to stdout as-is (markdown pipes cleanly), newline-terminated. */ -function writeBody(body: unknown): void { - const text = typeof body === 'string' ? body : ''; - if (text === '') return; +function writeBody(detail: Partial<V2DocDetail>): void { + const text = pickBody(detail); + if (text === '') { + process.stderr.write( + `yuque: doc has no renderable body (format: ${detail.format ?? 'unknown'}) — try --json\n` + ); + return; + } process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); } @@ -191,7 +213,7 @@ export function registerDocCommands(program: Command): void { printRecord(detail, DOC_META_FIELDS); return; } - writeBody(detail.body); + writeBody(detail); }); const create = doc.command('create'); @@ -305,6 +327,8 @@ export function registerDocCommands(program: Command): void { printRecord(detail, VERSION_META_FIELDS); return; } - writeBody(detail.body_md ?? detail.body); + // Version payloads carry markdown in body_md; reuse the format-aware + // fallback chain by treating it as the primary body. + writeBody({ ...detail, body: detail.body_md ?? detail.body }); }); } diff --git a/src/commands/repo.ts b/src/commands/repo.ts index 5420084..386a8de 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -31,6 +31,16 @@ function parseLimit(value: string): number { return limit; } +/** Spec enum for repo visibility. */ +function parsePublic(value: string): number { + if (!['0', '1', '2'].includes(value.trim())) { + throw new UsageError( + `--public must be 0 (private), 1 (public), or 2 (org-only), got "${value}"` + ); + } + return Number(value); +} + const LIST_TYPES = ['Book', 'Design', 'all']; /** `all` (like omitting the flag) means no server-side filter; the spec enum is Book|Design. */ @@ -135,7 +145,7 @@ export function registerRepoCommands(program: Command): void { .requiredOption('--slug <slug>', 'repo path (slug) (required)') .option('--group', 'create under a group instead of a user') .option('--description <description>', 'repo description') - .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parseNonNegativeInt) + .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parsePublic) .option('--enhanced-privacy', 'restrict access to team admins only (增强私密性)') .action( async ( @@ -170,7 +180,7 @@ export function registerRepoCommands(program: Command): void { .option('--name <name>', 'new name') .option('--slug <slug>', 'new path (slug)') .option('--description <description>', 'new description') - .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parseNonNegativeInt) + .option('--public <n>', 'visibility: 0 private, 1 public, 2 org-only', parsePublic) .option('--toc <markdown>', 'replace the table of contents (Markdown list)') .action( async ( diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 6f82866..01bc9d9 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -2,7 +2,6 @@ import type { Command } from 'commander'; import { getContext } from '../context.js'; import { UsageError } from '../errors.js'; import { printJson, printRecord, printTable, type Column } from '../output.js'; -import { fetchAllPages } from '../client/paginate.js'; import type { YuqueHttp } from '../client/http.js'; import { getGroupStatistics, @@ -142,11 +141,18 @@ function withListOptions(cmd: Command, sortFields: string[]): Command { .option('--all', 'fetch every page (takes precedence over --page/--limit)'); } -// --json always prints the bare rows array (with or without --all) so scripts can -// rely on the same `.[]` shape as every other list command. +interface StatsPage<T> { + rows: T[]; + total?: number; +} + +// --json prints the same `{ <rows-key>: [...], total }` object in both paging +// modes, preserving the API's pagination metadata (with --all, `total` is the +// server total from the last page and the rows span every page). async function runList<T extends Record<string, unknown>>( cmd: Command, - fetchPage: (http: YuqueHttp, params: DocStatsListParams) => Promise<T[]>, + rowsKey: 'members' | 'books' | 'docs', + fetchPage: (http: YuqueHttp, params: DocStatsListParams) => Promise<StatsPage<T>>, columns: Column<T>[] ): Promise<void> { const ctx = getContext(cmd); @@ -158,14 +164,23 @@ async function runList<T extends Record<string, unknown>>( sortOrder: opts.sortOrder, bookId: opts.bookId, }; - const rows = opts.all - ? await fetchAllPages<T>( - (offset, limit) => fetchPage(ctx.http, { ...filters, page: offset / limit + 1, limit }), - MAX_PAGE_SIZE - ) - : await fetchPage(ctx.http, { ...filters, page: opts.page, limit: opts.limit }); + let rows: T[]; + let total: number | undefined; + if (opts.all) { + rows = []; + for (let page = 1; ; page++) { + const result = await fetchPage(ctx.http, { ...filters, page, limit: MAX_PAGE_SIZE }); + rows.push(...result.rows); + total = result.total ?? total; + if (result.rows.length < MAX_PAGE_SIZE) break; + } + } else { + const result = await fetchPage(ctx.http, { ...filters, page: opts.page, limit: opts.limit }); + rows = result.rows; + total = result.total; + } if (ctx.json) { - printJson(rows); + printJson({ [rowsKey]: rows, ...(total !== undefined && { total }) }); } else { printTable(rows, columns); } @@ -193,7 +208,11 @@ export function registerStatsCommands(program: Command): void { ).action(async (login: string) => { await runList( membersCmd, - async (http, params) => (await listMemberStatistics(http, login, params)).members, + 'members', + async (http, params) => { + const page = await listMemberStatistics(http, login, params); + return { rows: page.members, total: page.total }; + }, MEMBER_COLUMNS ); }); @@ -204,7 +223,11 @@ export function registerStatsCommands(program: Command): void { ).action(async (login: string) => { await runList( booksCmd, - async (http, params) => (await listBookStatistics(http, login, params)).books, + 'books', + async (http, params) => { + const page = await listBookStatistics(http, login, params); + return { rows: page.books, total: page.total }; + }, BOOK_COLUMNS ); }); @@ -217,7 +240,11 @@ export function registerStatsCommands(program: Command): void { .action(async (login: string) => { await runList( docsCmd, - async (http, params) => (await listDocStatistics(http, login, params)).docs, + 'docs', + async (http, params) => { + const page = await listDocStatistics(http, login, params); + return { rows: page.docs, total: page.total }; + }, DOC_COLUMNS ); }); diff --git a/src/commands/toc.ts b/src/commands/toc.ts index 4931ece..4382d2f 100644 --- a/src/commands/toc.ts +++ b/src/commands/toc.ts @@ -1,5 +1,6 @@ import { Command, InvalidArgumentError, Option } from 'commander'; import { getContext } from '../context.js'; +import { UsageError } from '../errors.js'; import { dim, printJson, printOk } from '../output.js'; import { parseRepoRef } from '../client/repo-ref.js'; import { getToc, updateToc, type TocUpdateBody } from '../client/api/toc.js'; @@ -54,13 +55,42 @@ function printTocTree(items: V2TocItem[]): void { } } +/** + * Spec cross-field rules for `toc update` (all flags optional at the parser + * level, but the API requires combinations): edit/remove need --node-uuid; + * append/prepend either move an existing node (--node-uuid) or create one, + * and creating requires --type plus its per-type fields. --target-uuid is + * always optional (defaults to the root node). + */ +function validateTocUpdate(opts: TocUpdateOptions): void { + const creating = opts.action === 'appendNode' || opts.action === 'prependNode'; + if (!creating) { + if (opts.nodeUuid === undefined) { + throw new UsageError( + `--node-uuid is required for --action ${opts.action} (find it via \`yuque toc get\`)` + ); + } + return; + } + if (opts.nodeUuid !== undefined) return; // moving an existing node + if (opts.type === undefined) { + throw new UsageError( + '--type is required when creating a node (append/prepend without --node-uuid)' + ); + } + if (opts.type === 'DOC' && opts.docIds === undefined && opts.docId === undefined) { + throw new UsageError('--doc-ids is required to create DOC nodes'); + } + if (opts.type === 'LINK' && (opts.title === undefined || opts.url === undefined)) { + throw new UsageError('--title and --url are required to create LINK nodes'); + } + if (opts.type === 'TITLE' && opts.title === undefined) { + throw new UsageError('--title is required to create TITLE nodes'); + } +} + export function registerTocCommands(program: Command): void { - // runCli applies exitOverride to the root only after registration, so subcommands - // must opt in themselves for usage errors to surface as CommanderError (exit 2). - const toc = program - .command('toc') - .description('Manage the table of contents (目录) of a repo') - .exitOverride(); + const toc = program.command('toc').description('Manage the table of contents (目录) of a repo'); const get = toc .command('get') @@ -118,8 +148,9 @@ export function registerTocCommands(program: Command): void { new Option('--visible <n>', 'node visibility (0: hidden, 1: visible)').choices(['0', '1']) ) .action(async (repoArg: string) => { - const ctx = getContext(update); const opts = update.opts<TocUpdateOptions>(); + validateTocUpdate(opts); + const ctx = getContext(update); const body: TocUpdateBody = { action: opts.action, ...(opts.actionMode !== undefined && { action_mode: opts.actionMode }), diff --git a/src/config.ts b/src/config.ts index 00ba6ab..0655473 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,10 @@ +import { UsageError } from './errors.js'; + export const DEFAULT_HOST = 'https://www.yuque.com'; +/** Matches the yuque-mcp-server client default. */ +export const DEFAULT_TIMEOUT_MS = 30000; + export const MISSING_TOKEN_MESSAGE = 'A Yuque API token is required. Set the YUQUE_TOKEN (or YUQUE_PERSONAL_TOKEN) environment variable or pass --token=<token>. ' + 'Create one at https://www.yuque.com/settings/tokens'; @@ -19,6 +24,19 @@ export function resolveHost( return normalizeHost(flagHost || env.YUQUE_HOST || DEFAULT_HOST); } +/** Request timeout in ms: --timeout flag > YUQUE_TIMEOUT_MS env > 30000 default. */ +export function resolveTimeoutMs( + flagTimeout: string | undefined, + env: NodeJS.ProcessEnv = process.env +): number { + const raw = flagTimeout ?? env.YUQUE_TIMEOUT_MS; + if (raw === undefined || raw.trim() === '') return DEFAULT_TIMEOUT_MS; + if (!/^\d+$/.test(raw.trim()) || Number(raw) === 0) { + throw new UsageError(`Invalid timeout "${raw}" — expected a positive integer of milliseconds`); + } + return Number(raw); +} + /** * Normalize a host to a site root (no trailing slash, no /api/v2 suffix); * the HTTP client appends /api/v2 itself. Accepts bare domains and full API URLs. diff --git a/src/context.ts b/src/context.ts index 02056e2..32257f2 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import { AuthError } from './errors.js'; -import { MISSING_TOKEN_MESSAGE, resolveHost, resolveToken } from './config.js'; +import { MISSING_TOKEN_MESSAGE, resolveHost, resolveTimeoutMs, resolveToken } from './config.js'; import { YuqueHttp } from './client/http.js'; export interface CommandContext { @@ -14,11 +14,20 @@ export interface CommandContext { * never demands a token. */ export function getContext(command: Command): CommandContext { - const options = command.optsWithGlobals<{ token?: string; host?: string; json?: boolean }>(); + const options = command.optsWithGlobals<{ + token?: string; + host?: string; + json?: boolean; + timeout?: string; + }>(); const token = resolveToken(options.token); if (!token) throw new AuthError(MISSING_TOKEN_MESSAGE); return { - http: new YuqueHttp({ token, host: resolveHost(options.host) }), + http: new YuqueHttp({ + token, + host: resolveHost(options.host), + timeoutMs: resolveTimeoutMs(options.timeout), + }), json: Boolean(options.json), }; } diff --git a/tests/commands/stats.test.ts b/tests/commands/stats.test.ts index 23bb9ec..0672925 100644 --- a/tests/commands/stats.test.ts +++ b/tests/commands/stats.test.ts @@ -151,11 +151,11 @@ describe('stats commands', () => { ); }); - it('--json prints the member rows as a bare array (same shape as --all)', async () => { + it('--json prints the page object with rows and total', async () => { request.mockResolvedValueOnce(envelope(membersPage)); const code = await run('stats', 'members', 'mygroup', '--json'); expect(code).toBe(0); - expect(JSON.parse(stdoutText())).toEqual(membersPage.members); + expect(JSON.parse(stdoutText())).toEqual({ members: membersPage.members, total: 2 }); }); it('--all drains pages at max page size and keeps filters', async () => { @@ -174,7 +174,9 @@ describe('stats commands', () => { 2, expect.objectContaining({ params: { name: 'ann', page: 2, limit: 20 } }) ); - expect(JSON.parse(stdoutText())).toHaveLength(21); + const drained = JSON.parse(stdoutText()); + expect(drained.members).toHaveLength(21); + expect(drained.total).toBe(21); }); it('rejects a --range value outside the spec enum with exit code 2', async () => { @@ -210,11 +212,11 @@ describe('stats commands', () => { expect(stdoutText()).toContain('Handbook'); }); - it('--json prints the book rows as a bare array (same shape as --all)', async () => { + it('--json prints the page object with rows and total', async () => { request.mockResolvedValueOnce(envelope(booksPage)); const code = await run('stats', 'books', 'mygroup', '--json'); expect(code).toBe(0); - expect(JSON.parse(stdoutText())).toEqual(booksPage.books); + expect(JSON.parse(stdoutText())).toEqual({ books: booksPage.books, total: 1 }); }); }); @@ -236,12 +238,12 @@ describe('stats commands', () => { expect(stdoutText()).toContain('Intro'); }); - it('--json prints the doc rows as a bare array (same shape as --all)', async () => { + it('--json prints the page object with rows and total', async () => { const docsPage = { docs: [{ doc_id: 55, title: 'Intro' }], total: 1 }; request.mockResolvedValueOnce(envelope(docsPage)); const code = await run('stats', 'docs', 'mygroup', '--json'); expect(code).toBe(0); - expect(JSON.parse(stdoutText())).toEqual(docsPage.docs); + expect(JSON.parse(stdoutText())).toEqual({ docs: docsPage.docs, total: 1 }); }); }); diff --git a/tests/commands/toc-group.test.ts b/tests/commands/toc-group.test.ts index 585448f..8fec24f 100644 --- a/tests/commands/toc-group.test.ts +++ b/tests/commands/toc-group.test.ts @@ -315,3 +315,74 @@ describe('group member remove', () => { expect(stderrText()).toContain('--yes'); }); }); + +describe('toc update cross-field validation (spec semantics)', () => { + it('rejects editNode/removeNode without --node-uuid (exit 2, no request)', async () => { + for (const action of ['editNode', 'removeNode']) { + const code = await runCli(['node', 'yuque', 'toc', 'update', '1', '--action', action]); + expect(code).toBe(2); + } + expect(stderrText()).toContain('--node-uuid is required'); + expect(request).not.toHaveBeenCalled(); + }); + + it('rejects creating a node without --type', async () => { + const code = await runCli(['node', 'yuque', 'toc', 'update', '1', '--action', 'appendNode']); + expect(code).toBe(2); + expect(stderrText()).toContain('--type is required'); + expect(request).not.toHaveBeenCalled(); + }); + + it('rejects DOC creation without --doc-ids and LINK creation without --title/--url', async () => { + const doc = await runCli([ + 'node', + 'yuque', + 'toc', + 'update', + '1', + '--action', + 'appendNode', + '--type', + 'DOC', + ]); + const link = await runCli([ + 'node', + 'yuque', + 'toc', + 'update', + '1', + '--action', + 'prependNode', + '--type', + 'LINK', + '--title', + 't', + ]); + expect(doc).toBe(2); + expect(link).toBe(2); + expect(request).not.toHaveBeenCalled(); + }); + + it('allows a move: appendNode with --node-uuid and no --type', async () => { + request.mockResolvedValueOnce({ data: { data: [{ uuid: 'nu' }] } }); + const code = await runCli([ + 'node', + 'yuque', + 'toc', + 'update', + '1', + '--action', + 'appendNode', + '--node-uuid', + 'nu', + '--target-uuid', + 'tu', + ]); + expect(code).toBe(0); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + data: { action: 'appendNode', target_uuid: 'tu', node_uuid: 'nu' }, + }) + ); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index d04c668..0f89039 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_HOST, MISSING_TOKEN_MESSAGE, + resolveTimeoutMs, normalizeHost, resolveHost, resolveToken, } from '../src/config.js'; +import { UsageError } from '../src/errors.js'; describe('resolveToken', () => { it('prefers the flag over env vars', () => { @@ -59,3 +61,20 @@ describe('resolveHost / normalizeHost', () => { expect(normalizeHost('http://yuque.internal:8080/')).toBe('http://yuque.internal:8080'); }); }); + +describe('resolveTimeoutMs', () => { + it('defaults to 30000', () => { + expect(resolveTimeoutMs(undefined, {})).toBe(30000); + }); + + it('prefers the flag over YUQUE_TIMEOUT_MS', () => { + expect(resolveTimeoutMs('5000', { YUQUE_TIMEOUT_MS: '60000' })).toBe(5000); + expect(resolveTimeoutMs(undefined, { YUQUE_TIMEOUT_MS: '60000' })).toBe(60000); + }); + + it('rejects non-positive or non-numeric values as usage errors', () => { + expect(() => resolveTimeoutMs('0', {})).toThrow(UsageError); + expect(() => resolveTimeoutMs('abc', {})).toThrow(UsageError); + expect(() => resolveTimeoutMs(undefined, { YUQUE_TIMEOUT_MS: '-1' })).toThrow(UsageError); + }); +}); From 38b740c8133e855a3bc0c2a2645b59efcffeeb41 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 17:26:21 +0900 Subject: [PATCH 06/13] =?UTF-8?q?test:=20add=20functional=20e2e=20workflow?= =?UTF-8?q?=20=E2=80=94=20built=20binary=20vs=20mock=20Yuque=20API,=20gate?= =?UTF-8?q?d=20real-API=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/e2e mock suite: FixtureServer + async runner spawn the real dist/bin.js and assert wire traffic, rendering, exit codes, retry semantics (31 cases) - keep the env-gated real-API suite (cli.e2e.test.ts) and document both layers in tests/e2e/README.md - npm run check now ends with test:e2e; CI gate unchanged (single check) - .github/workflows/real-api-e2e.yml: weekly + manual read-only run, enabled only when the YUQUE_E2E_TOKEN secret exists Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .github/workflows/real-api-e2e.yml | 30 +++ AGENTS.md | 8 +- README.md | 4 +- README.zh-CN.md | 4 +- package.json | 3 +- tests/e2e/README.md | 40 ++++ tests/e2e/cli.e2e.test.ts | 282 +++++++++++++++++++++++++++ tests/e2e/errors.e2e.test.ts | 86 ++++++++ tests/e2e/fixture-server.ts | 94 +++++++++ tests/e2e/read-commands.e2e.test.ts | 194 ++++++++++++++++++ tests/e2e/run-cli.ts | 65 ++++++ tests/e2e/write-commands.e2e.test.ts | 161 +++++++++++++++ vitest.config.ts | 3 +- vitest.e2e.config.ts | 15 ++ 14 files changed, 983 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/real-api-e2e.yml create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/cli.e2e.test.ts create mode 100644 tests/e2e/errors.e2e.test.ts create mode 100644 tests/e2e/fixture-server.ts create mode 100644 tests/e2e/read-commands.e2e.test.ts create mode 100644 tests/e2e/run-cli.ts create mode 100644 tests/e2e/write-commands.e2e.test.ts create mode 100644 vitest.e2e.config.ts diff --git a/.github/workflows/real-api-e2e.yml b/.github/workflows/real-api-e2e.yml new file mode 100644 index 0000000..15fe1c5 --- /dev/null +++ b/.github/workflows/real-api-e2e.yml @@ -0,0 +1,30 @@ +name: Real API E2E + +# Drives the built CLI against the live Yuque API on a schedule and on demand. +# The real-API paths are gated tests inside tests/e2e/cli.e2e.test.ts: without +# the YUQUE_E2E_TOKEN secret they skip and only the mock-server suite runs. +# Write-mode gates (YUQUE_E2E_WRITE / YUQUE_E2E_REPO_LIFECYCLE) are deliberately +# not wired into CI — see tests/e2e/README.md. +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * 1' + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - name: Run e2e (real-API paths enabled when the secret exists) + run: npm run test:e2e + env: + YUQUE_E2E: ${{ secrets.YUQUE_E2E_TOKEN != '' && '1' || '' }} + YUQUE_E2E_TOKEN: ${{ secrets.YUQUE_E2E_TOKEN }} + YUQUE_E2E_LOGIN: ${{ vars.YUQUE_E2E_LOGIN }} + YUQUE_E2E_REPO: ${{ vars.YUQUE_E2E_REPO }} diff --git a/AGENTS.md b/AGENTS.md index aafb096..b0907d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,5 +32,9 @@ bin.ts → cli.ts (commander program, error → exit code) - Destructive commands (`delete`, `member remove`) go through `confirmDestructive` and support `--yes`. - Human output via `src/output.ts`; `--json` always prints the full raw payload. -- `npm run check` (lint + format + typecheck + tests + build + dist smoke) must exit 0 — - it is the merge gate and matches CI. +- `npm run check` (lint + format + typecheck + unit tests + build + dist smoke + + functional e2e) must exit 0 — it is the merge gate and matches CI. +- Functional tests live in `tests/e2e/` (see its README): a mock-server suite that + spawns the built binary (always on), plus an env-gated real-API suite wired to + `.github/workflows/real-api-e2e.yml`. New/changed commands need e2e coverage in the + mock suite, not just unit tests. diff --git a/README.md b/README.md index 82c1182..0184957 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status | -------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Token **(required)** | `YUQUE_TOKEN` / `--token` | Personal or team Yuque API token (also reads `YUQUE_PERSONAL_TOKEN` as a compatibility fallback, e.g. shared with @yuque/mcp-server) | | Host (optional) | `YUQUE_HOST` / `--host` | Site or space host, e.g. `https://your-space.yuque.com` — required for space-bound team tokens and private deployments | +| Timeout (optional) | `YUQUE_TIMEOUT_MS` / `--timeout` | API request timeout in milliseconds — default `30000` | Flags win over env vars, so a one-off `--token` override always works. Site roots are normalized (`/api/v2` is appended automatically); when unset, the host defaults to `https://www.yuque.com`. @@ -129,8 +130,9 @@ Colors are disabled automatically when piping, or force-off with `NO_COLOR=1`. R git clone https://github.com/yuque/yuque-cli.git cd yuque-cli npm install -npm test # run tests +npm test # unit tests npm run build # compile TypeScript +npm run test:e2e # functional tests: the built binary vs a mock Yuque API npm run dev -- --help # run from source ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 8849d66..b042cac 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -50,6 +50,7 @@ YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status | ------------------ | ------------------------- | -------------------------------------------------------------------------------------------- | | Token **(必填)** | `YUQUE_TOKEN` / `--token` | 语雀个人或团队 API Token(同时兼容读取 `YUQUE_PERSONAL_TOKEN`,可与 @yuque/mcp-server 共用) | | Host(可选) | `YUQUE_HOST` / `--host` | 站点或空间地址,例如 `https://your-space.yuque.com` —— 绑定空间的团队 Token 和私有化部署必填 | +| 超时(可选) | `YUQUE_TIMEOUT_MS` / `--timeout` | API 请求超时毫秒数,默认 `30000` | 命令行参数优先于环境变量,随手 `--token` 覆盖一次总是生效。站点地址会自动规范化(自动补 `/api/v2`);不设置时默认 `https://www.yuque.com`。 @@ -129,8 +130,9 @@ yuque doc list team/handbook --all --json | jq -r '.[].slug' git clone https://github.com/yuque/yuque-cli.git cd yuque-cli npm install -npm test # 运行测试 +npm test # 单元测试 npm run build # 编译 TypeScript +npm run test:e2e # 功能测试:构建产物对着 mock 语雀 API 跑 npm run dev -- --help # 从源码运行 ``` diff --git a/package.json b/package.json index 011b3be..4dfb753 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ ], "scripts": { "build": "tsc && chmod +x dist/bin.js", - "check": "npm run lint && npm run format:check && npm run typecheck && npm test && npm run build && npm run smoke:dist", + "check": "npm run lint && npm run format:check && npm run typecheck && npm test && npm run build && npm run smoke:dist && npm run test:e2e", "dev": "tsx src/bin.ts", "smoke:dist": "node scripts/smoke-dist-cli.js", "test": "vitest run", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint src tests", diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..dd66d14 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,40 @@ +# Functional (e2e) tests + +This suite runs the **built binary** (`dist/bin.js`) as a subprocess — argv parsing, +config resolution, HTTP wire format, rendering, retry, and exit codes are all exercised +with zero mocks inside the process under test. Build first, then run: + +```bash +npm run build +npm run test:e2e +``` + +`npm run check` (the merge gate) runs it automatically after the build step. + +## Two layers + +**1. Mock-server suite** — `read-commands` / `write-commands` / `errors` `.e2e.test.ts`. +Always runs, no network, no credentials. Each test boots a local `FixtureServer` +(an in-process mock of the Yuque Open API) and asserts the exact requests the CLI +produced plus its stdout/stderr/exit code. This is the CI reliability gate. + +Note the runner (`run-cli.ts`) is async on purpose: the fixture server lives in the +vitest process, so a synchronous spawn would freeze the event loop and deadlock. + +**2. Real-API suite** — `cli.e2e.test.ts`. Skipped by default; gated on env vars so +`npm test` / `npm run check` never touch the network: + +| Variable | Meaning | +| ------------------------------------------------------------- | ----------------------------------------------------------------- | +| `YUQUE_E2E=1` | Enable read paths + error contract against the live API | +| `YUQUE_E2E_TOKEN` | Personal token used by the read paths | +| `YUQUE_E2E_LOGIN` | Optional login override (otherwise resolved via `auth status`) | +| `YUQUE_E2E_REPO` | Optional repo to read; **required** when write mode is on | +| `YUQUE_E2E_WRITE=1` | Enable the doc create/update/delete lifecycle in the sandbox repo | +| `YUQUE_E2E_TEAM_TOKEN` / `YUQUE_E2E_HOST` / `YUQUE_E2E_GROUP` | Team/space-token paths (groups, stats) | +| `YUQUE_E2E_REPO_LIFECYCLE=1` | Repo create/delete — local only, never wired into CI | + +CI wiring lives in `.github/workflows/real-api-e2e.yml` (weekly + manual): it enables +only the read paths, and only when the `YUQUE_E2E_TOKEN` secret is configured. Write +gates stay off in CI on purpose — they modify real content and belong to a dedicated +sandbox run by a human. diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts new file mode 100644 index 0000000..7886d38 --- /dev/null +++ b/tests/e2e/cli.e2e.test.ts @@ -0,0 +1,282 @@ +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * End-to-end tests that drive the *built* CLI (`dist/bin.js`) as a subprocess + * against the real Yuque API — the true user-facing surface (arg parsing, + * rendering, exit codes, retry, auth), which the mocked suite cannot exercise. + * + * Everything here is gated on env vars and is `describe.skip` otherwise, so the + * default `npm test` / `npm run check` never touches the network. Run with: + * npm run test:e2e + * See tests/e2e/README.md for the required secrets/variables. + * + * Gates: + * YUQUE_E2E=1 enable read paths + error contract + * YUQUE_E2E_WRITE=1 enable the doc create/update/delete lifecycle + * (requires YUQUE_E2E_REPO — a dedicated sandbox) + * YUQUE_E2E_REPO_LIFECYCLE=1 enable repo create/delete (local only; never + * wired into CI because of its larger blast radius) + */ + +const BIN = fileURLToPath(new URL('../../dist/bin.js', import.meta.url)); + +const READ_ENABLED = process.env.YUQUE_E2E === '1'; +const WRITE_ENABLED = READ_ENABLED && process.env.YUQUE_E2E_WRITE === '1'; +const REPO_LIFECYCLE_ENABLED = READ_ENABLED && process.env.YUQUE_E2E_REPO_LIFECYCLE === '1'; + +const personalToken = process.env.YUQUE_E2E_TOKEN; +const teamToken = process.env.YUQUE_E2E_TEAM_TOKEN; +const teamHost = process.env.YUQUE_E2E_HOST; +const teamGroup = process.env.YUQUE_E2E_GROUP ?? ''; +const sandboxRepo = process.env.YUQUE_E2E_REPO; +const configuredLogin = process.env.YUQUE_E2E_LOGIN; + +interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +/** Spawn the built CLI with an env overlay; undefined overrides unset the key. */ +function runCli(args: string[], overrides: Record<string, string | undefined> = {}): RunResult { + const env: Record<string, string | undefined> = { ...process.env, ...overrides }; + for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key]; + const result = spawnSync('node', [BIN, ...args], { + encoding: 'utf8', + timeout: 60000, + env: env as NodeJS.ProcessEnv, + }); + if (result.error) throw result.error; + return { code: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +/** Personal-token invocation (never inherits a stray YUQUE_HOST). */ +function pc(args: string[]): RunResult { + return runCli(args, { YUQUE_TOKEN: personalToken, YUQUE_HOST: undefined }); +} + +/** Team/space-token invocation (carries YUQUE_HOST for space-bound tokens). */ +function tc(args: string[]): RunResult { + return runCli(args, { YUQUE_TOKEN: teamToken, YUQUE_HOST: teamHost }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function okJson(res: RunResult): any { + expect(res.code, `expected exit 0, got ${res.code}\nstderr: ${res.stderr}`).toBe(0); + return JSON.parse(res.stdout); +} + +const describeConfig = READ_ENABLED ? describe : describe.skip; +describeConfig('e2e config sanity', () => { + it('has a personal token', () => { + expect(personalToken, 'set the YUQUE_E2E_TOKEN secret').toBeTruthy(); + }); + + it('has a built binary (run `npm run build` first)', () => { + expect(existsSync(BIN)).toBe(true); + }); + + it('write mode requires an explicit sandbox repo', () => { + // Never let writes fall back to a discovered repo — they must target a + // dedicated throwaway knowledge base, or not run at all. + if (process.env.YUQUE_E2E_WRITE === '1') { + expect( + sandboxRepo, + 'YUQUE_E2E_WRITE=1 requires YUQUE_E2E_REPO (a dedicated sandbox)' + ).toBeTruthy(); + } + }); +}); + +const describeRead = READ_ENABLED && personalToken ? describe : describe.skip; +describeRead('read paths (personal token)', () => { + let login: string; + let readRepo: string; + let sampleDocSlug: string | undefined; + + beforeAll(() => { + login = configuredLogin ?? String(okJson(pc(['auth', 'status', '--json'])).login); + if (sandboxRepo) { + readRepo = sandboxRepo; + } else { + const repos = okJson(pc(['repo', 'list', login, '--json'])); + expect( + Array.isArray(repos) && repos.length, + 'test account has no repos to read' + ).toBeTruthy(); + readRepo = String(repos[0].namespace ?? repos[0].id); + } + const docs = okJson(pc(['doc', 'list', readRepo, '--json'])); + sampleDocSlug = Array.isArray(docs) && docs.length ? String(docs[0].slug) : undefined; + }); + + it('ping exits 0', () => { + expect(pc(['ping']).code).toBe(0); + }); + + it('auth status --json reports a login', () => { + expect(okJson(pc(['auth', 'status', '--json'])).login).toBeTruthy(); + }); + + it('user info --json has id and login', () => { + const user = okJson(pc(['user', 'info', '--json'])); + expect(user.id).toBeTruthy(); + expect(user.login).toBeTruthy(); + }); + + it('user groups --json returns an array', () => { + expect(Array.isArray(okJson(pc(['user', 'groups', login, '--json'])))).toBe(true); + }); + + it('search --json returns an array', () => { + expect(Array.isArray(okJson(pc(['search', 'test', '--type', 'doc', '--json'])))).toBe(true); + }); + + it('repo list --json returns an array', () => { + expect(Array.isArray(okJson(pc(['repo', 'list', login, '--json'])))).toBe(true); + }); + + it('repo get --json returns the repo', () => { + expect(okJson(pc(['repo', 'get', readRepo, '--json'])).id).toBeTruthy(); + }); + + it('doc list --json returns an array', () => { + expect(Array.isArray(okJson(pc(['doc', 'list', readRepo, '--json'])))).toBe(true); + }); + + it('doc get --json returns a doc', () => { + if (!sampleDocSlug) return; // empty repo — nothing to fetch, skip vacuously + expect(okJson(pc(['doc', 'get', readRepo, sampleDocSlug, '--json'])).id).toBeTruthy(); + }); + + it('toc get --json returns an array', () => { + expect(Array.isArray(okJson(pc(['toc', 'get', readRepo, '--json'])))).toBe(true); + }); +}); + +const describeError = READ_ENABLED && personalToken ? describe : describe.skip; +describeError('error contract', () => { + it('an invalid token exits 3 (auth error)', () => { + const res = runCli(['user', 'info'], { + YUQUE_TOKEN: 'e2e-definitely-invalid-token', + YUQUE_HOST: undefined, + }); + expect(res.code).toBe(3); + }); + + it('a nonexistent repo exits 4 (not found)', () => { + expect(pc(['repo', 'get', '999999999']).code).toBe(4); + }); +}); + +const describeTeam = READ_ENABLED && teamToken && teamGroup ? describe : describe.skip; +describeTeam('team + statistics (team/space token)', () => { + it('group members --json returns an array', () => { + expect(Array.isArray(okJson(tc(['group', 'members', teamGroup, '--json'])))).toBe(true); + }); + + it('stats group --json returns an object', () => { + expect(typeof okJson(tc(['stats', 'group', teamGroup, '--json']))).toBe('object'); + }); + + it('stats members --json returns an array', () => { + expect(Array.isArray(okJson(tc(['stats', 'members', teamGroup, '--json'])))).toBe(true); + }); + + it('stats books --json returns an array', () => { + expect(Array.isArray(okJson(tc(['stats', 'books', teamGroup, '--json'])))).toBe(true); + }); + + it('stats docs --json returns an array', () => { + expect(Array.isArray(okJson(tc(['stats', 'docs', teamGroup, '--json'])))).toBe(true); + }); +}); + +const describeWrite = WRITE_ENABLED && personalToken && sandboxRepo ? describe : describe.skip; +describeWrite('write lifecycle (sandbox repo)', () => { + const repo = sandboxRepo as string; + const stamp = `${Date.now()}`; + const createdIds: string[] = []; + + /** Delete any leftover e2e-* docs so scheduled CI never accumulates junk. */ + function sweep(): void { + const docs = okJson(pc(['doc', 'list', repo, '--all', '--json'])); + if (!Array.isArray(docs)) return; + for (const doc of docs) { + if (typeof doc.slug === 'string' && doc.slug.startsWith('e2e-')) { + pc(['doc', 'delete', repo, String(doc.id), '--yes']); + } + } + } + + beforeAll(() => sweep()); + afterAll(() => { + for (const id of createdIds) pc(['doc', 'delete', repo, id, '--yes']); + sweep(); + }); + + it('creates, reads, updates, and deletes a doc', () => { + const slug = `e2e-${stamp}`; + const created = okJson( + pc([ + 'doc', + 'create', + repo, + '--title', + `[E2E] ${stamp}`, + '--slug', + slug, + '--body', + `created at ${stamp}`, + '--json', + ]) + ); + expect(created.id).toBeTruthy(); + const id = String(created.id); + createdIds.push(id); + + const fetched = okJson(pc(['doc', 'get', repo, id, '--json'])); + expect(fetched.id).toBe(created.id); + + const updated = okJson( + pc(['doc', 'update', repo, id, '--body', `updated at ${stamp}`, '--json']) + ); + expect(updated.id).toBe(created.id); + + // Yuque delete may be soft (recycle bin) or hard; assert the command + // succeeds and let afterAll's sweep guarantee removal either way. + expect(pc(['doc', 'delete', repo, id, '--yes']).code).toBe(0); + createdIds.length = 0; + }); +}); + +const describeRepoLifecycle = REPO_LIFECYCLE_ENABLED && personalToken ? describe : describe.skip; +describeRepoLifecycle('repo lifecycle (ephemeral repo, local only)', () => { + it('creates, reads, and deletes a repo', () => { + expect(configuredLogin, 'YUQUE_E2E_LOGIN is required for the repo lifecycle test').toBeTruthy(); + const login = configuredLogin as string; + const stamp = `${Date.now()}`; + const created = okJson( + pc([ + 'repo', + 'create', + login, + '--name', + `[E2E] ${stamp}`, + '--slug', + `e2e-repo-${stamp}`, + '--json', + ]) + ); + expect(created.id).toBeTruthy(); + const id = String(created.id); + try { + expect(okJson(pc(['repo', 'get', id, '--json'])).id).toBeTruthy(); + } finally { + expect(pc(['repo', 'delete', id, '--yes']).code).toBe(0); + } + }); +}); diff --git a/tests/e2e/errors.e2e.test.ts b/tests/e2e/errors.e2e.test.ts new file mode 100644 index 0000000..6623af2 --- /dev/null +++ b/tests/e2e/errors.e2e.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FixtureServer } from './fixture-server.js'; +import { runYuque } from './run-cli.js'; + +let server: FixtureServer; +let host: string; + +beforeEach(async () => { + server = new FixtureServer(); + host = await server.start(); +}); + +afterEach(async () => { + await server.stop(); +}); + +describe('exit-code contract end to end', () => { + it('401 -> 3 with the token hint on stderr', async () => { + server.route('GET', '/api/v2/user', { status: 401, body: { message: 'invalid token' } }); + const result = await runYuque(['user', 'info'], { host }); + expect(result.code).toBe(3); + expect(result.stderr).toContain('token invalid or expired'); + }); + + it('404 -> 4', async () => { + server.route('GET', '/api/v2/repos/1/docs/none', { + status: 404, + body: { message: 'not found' }, + }); + const result = await runYuque(['doc', 'get', '1', 'none'], { host }); + expect(result.code).toBe(4); + }); + + it('missing token -> 3 without any request', async () => { + const result = await runYuque(['user', 'info'], { host, token: null }); + expect(result.code).toBe(3); + expect(result.stderr).toContain('token is required'); + expect(server.requests).toHaveLength(0); + }); + + it('unknown command -> 2', async () => { + const result = await runYuque(['definitely-not-a-command'], { host }); + expect(result.code).toBe(2); + }); + + it('invalid --timeout -> 2 without any request', async () => { + const result = await runYuque(['ping', '--timeout', 'banana'], { host }); + expect(result.code).toBe(2); + expect(server.requests).toHaveLength(0); + }); +}); + +describe('retry semantics through the real binary', () => { + it('GET retries a 429 (with Retry-After) and then succeeds', async () => { + server.route('GET', '/api/v2/hello', (_request, hit) => + hit === 1 + ? { status: 429, body: { message: 'rate limited' }, headers: { 'Retry-After': '1' } } + : { body: { data: { message: 'pong' } } } + ); + const result = await runYuque(['ping'], { host }); + expect(result.code).toBe(0); + expect(server.requestsFor('GET', '/api/v2/hello')).toHaveLength(2); + }); + + it('a POST that fails with 502 is not replayed (single request, exit 1)', async () => { + server.route('POST', '/api/v2/users/me/repos', { + status: 502, + body: { message: 'bad gateway' }, + }); + const result = await runYuque(['repo', 'create', 'me', '--name', 'n', '--slug', 's'], { host }); + expect(result.code).toBe(1); + expect(server.requestsFor('POST', '/api/v2/users/me/repos')).toHaveLength(1); + }); + + it('rate-limit exhaustion surfaces exit 5', async () => { + server.route('GET', '/api/v2/hello', { + status: 429, + body: { message: 'rate limited' }, + headers: { 'Retry-After': '1' }, + }); + const result = await runYuque(['ping'], { host }); + expect(result.code).toBe(5); + // 1 initial attempt + 3 retries (default maxRetries) + expect(server.requestsFor('GET', '/api/v2/hello')).toHaveLength(4); + }); +}); diff --git a/tests/e2e/fixture-server.ts b/tests/e2e/fixture-server.ts new file mode 100644 index 0000000..9f86ed8 --- /dev/null +++ b/tests/e2e/fixture-server.ts @@ -0,0 +1,94 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +/** + * In-process mock of the Yuque Open API for functional tests. Routes are + * registered per `METHOD /api/v2/...` path; every incoming request is recorded + * so tests can assert the exact wire traffic the real CLI binary produced. + */ + +export interface RecordedRequest { + method: string; + path: string; + query: Record<string, string>; + headers: Record<string, string | string[] | undefined>; + body: unknown; +} + +export interface RouteResponse { + status?: number; + /** JSON-serialized as-is; list/detail endpoints expect the { data } envelope. */ + body?: unknown; + headers?: Record<string, string>; +} + +export type RouteHandler = + RouteResponse | ((request: RecordedRequest, hit: number) => RouteResponse); + +export class FixtureServer { + readonly requests: RecordedRequest[] = []; + private readonly routes = new Map<string, { handler: RouteHandler; hits: number }>(); + private server: Server | undefined; + + /** `path` is the pathname without query, e.g. `/api/v2/user`. */ + route(method: string, path: string, handler: RouteHandler): this { + this.routes.set(`${method.toUpperCase()} ${path}`, { handler, hits: 0 }); + return this; + } + + requestsFor(method: string, path: string): RecordedRequest[] { + return this.requests.filter( + (request) => request.method === method.toUpperCase() && request.path === path + ); + } + + async start(): Promise<string> { + this.server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const url = new URL(req.url ?? '/', 'http://localhost'); + const raw = Buffer.concat(chunks).toString('utf8'); + let body: unknown; + try { + body = raw === '' ? undefined : JSON.parse(raw); + } catch { + body = raw; + } + const recorded: RecordedRequest = { + method: (req.method ?? 'GET').toUpperCase(), + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: req.headers, + body, + }; + this.requests.push(recorded); + + const route = this.routes.get(`${recorded.method} ${recorded.path}`); + const response: RouteResponse = route + ? typeof route.handler === 'function' + ? route.handler(recorded, ++route.hits) + : route.handler + : { + status: 404, + body: { message: `no fixture for ${recorded.method} ${recorded.path}` }, + }; + + res.writeHead(response.status ?? 200, { + 'Content-Type': 'application/json', + ...response.headers, + }); + res.end(JSON.stringify(response.body ?? {})); + }); + }); + await new Promise<void>((resolve) => this.server?.listen(0, '127.0.0.1', resolve)); + const { port } = this.server?.address() as AddressInfo; + return `http://127.0.0.1:${port}`; + } + + async stop(): Promise<void> { + await new Promise<void>((resolve, reject) => + this.server ? this.server.close((error) => (error ? reject(error) : resolve())) : resolve() + ); + } +} diff --git a/tests/e2e/read-commands.e2e.test.ts b/tests/e2e/read-commands.e2e.test.ts new file mode 100644 index 0000000..f3b35db --- /dev/null +++ b/tests/e2e/read-commands.e2e.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FixtureServer } from './fixture-server.js'; +import { runYuque } from './run-cli.js'; + +/** + * Functional tests: the built dist/bin.js binary against a mock Yuque API. + * These verify the full chain — argv parsing, config resolution, HTTP wire + * format (paths/query/headers), rendering, and exit codes — with no mocks + * inside the process under test. + */ + +let server: FixtureServer; +let host: string; + +beforeEach(async () => { + server = new FixtureServer(); + host = await server.start(); +}); + +afterEach(async () => { + await server.stop(); +}); + +describe('auth & user', () => { + it('ping hits /hello with the token header and exits 0', async () => { + server.route('GET', '/api/v2/hello', { body: { data: { message: 'Hello e2e-user' } } }); + const result = await runYuque(['ping'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Hello e2e-user'); + expect(server.requests[0].headers['x-auth-token']).toBe('e2e-test-token'); + }); + + it('auth status reports the signed-in identity', async () => { + server.route('GET', '/api/v2/user', { + body: { data: { id: 1, login: 'tester', name: '测试员' } }, + }); + const result = await runYuque(['auth', 'status'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('测试员'); + expect(result.stdout).toContain('@tester'); + }); + + it('user info --json prints the full payload', async () => { + const user = { id: 1, login: 'tester', name: 'Tester', followers_count: 42 }; + server.route('GET', '/api/v2/user', { body: { data: user } }); + const result = await runYuque(['user', 'info', '--json'], { host }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(user); + }); + + it('user groups lists groups with the role filter in the query', async () => { + server.route('GET', '/api/v2/users/testers/groups', { + body: { data: [{ id: 9, login: 'eng', name: 'Engineering', members_count: 8 }] }, + }); + const result = await runYuque(['user', 'groups', 'testers', '--role', '1'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Engineering'); + expect(server.requests[0].query).toEqual({ role: '1' }); + }); +}); + +describe('search & repo', () => { + it('search sends q/type and renders a table', async () => { + server.route('GET', '/api/v2/search', { + body: { data: [{ id: 1, type: 'doc', title: '灰度发布', url: '/x/y' }] }, + }); + const result = await runYuque(['search', '灰度发布', '--type', 'doc'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('灰度发布'); + expect(server.requests[0].query).toEqual({ q: '灰度发布', type: 'doc' }); + }); + + it('repo list --group --all drains pages and prints a JSON array', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + id: i, + slug: `r${i}`, + name: `R${i}`, + })); + server.route('GET', '/api/v2/groups/eng/repos', (request) => + request.query.offset === '0' + ? { body: { data: fullPage } } + : { body: { data: [{ id: 100, slug: 'last', name: 'Last' }] } } + ); + const result = await runYuque(['repo', 'list', 'eng', '--group', '--all', '--json'], { host }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toHaveLength(101); + const offsets = server + .requestsFor('GET', '/api/v2/groups/eng/repos') + .map((r) => r.query.offset); + expect(offsets).toEqual(['0', '100']); + }); + + it('repo get resolves an owner/slug namespace to the namespace path', async () => { + server.route('GET', '/api/v2/repos/yuque/help', { + body: { data: { id: 42, name: '帮助中心', namespace: 'yuque/help', slug: 'help' } }, + }); + const result = await runYuque(['repo', 'get', 'yuque/help'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('帮助中心'); + }); +}); + +describe('doc reading', () => { + it('doc get prints the markdown body verbatim for piping', async () => { + server.route('GET', '/api/v2/repos/yuque/help/docs/intro', { + body: { + data: { id: 7, slug: 'intro', title: 'Intro', format: 'markdown', body: '# Hi\n\nBody.\n' }, + }, + }); + const result = await runYuque(['doc', 'get', 'yuque/help', 'intro'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toBe('# Hi\n\nBody.\n'); + expect(result.stderr).toBe(''); + }); + + it('doc get renders sheet docs from body_sheet when body is empty', async () => { + server.route('GET', '/api/v2/repos/docs/123', { + body: { + data: { + id: 123, + slug: 's', + title: 'Sheet', + format: 'sheet', + body: '', + body_sheet: '{"rows":[1]}', + }, + }, + }); + const result = await runYuque(['doc', 'get', '123'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toBe('{"rows":[1]}\n'); + }); + + it('doc get warns on stderr when no content field is renderable', async () => { + server.route('GET', '/api/v2/repos/docs/124', { + body: { data: { id: 124, slug: 'b', title: 'Board', format: 'board' } }, + }); + const result = await runYuque(['doc', 'get', '124'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('no renderable body'); + }); + + it('doc list forwards the spec query filters', async () => { + server.route('GET', '/api/v2/repos/8/docs', { body: { data: [] } }); + const result = await runYuque( + ['doc', 'list', '8', '--deleted', '--changed-at-gte', '2026-01-01T00:00:00Z', '--limit', '5'], + { host } + ); + expect(result.code).toBe(0); + expect(server.requests[0].query).toEqual({ + deleted: 'true', + changed_at_gte: '2026-01-01T00:00:00Z', + limit: '5', + }); + }); + + it('doc version prints body_md', async () => { + server.route('GET', '/api/v2/doc_versions/55', { + body: { data: { id: 55, doc_id: 7, title: 'v2', body_md: 'old text\n' } }, + }); + const result = await runYuque(['doc', 'version', '55'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toBe('old text\n'); + }); +}); + +describe('toc & stats', () => { + it('toc get renders an indented tree', async () => { + server.route('GET', '/api/v2/repos/9/toc', { + body: { + data: [ + { uuid: 'a', type: 'TITLE', title: 'Part 1', level: 0 }, + { uuid: 'b', type: 'DOC', title: 'Chapter', slug: 'ch1', level: 1 }, + ], + }, + }); + const result = await runYuque(['toc', 'get', '9'], { host }); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Part 1\n Chapter (ch1)'); + }); + + it('stats members --json preserves rows and total', async () => { + server.route('GET', '/api/v2/groups/eng/statistics/members', { + body: { data: { members: [{ user_id: 1, read_count: 5 }], total: 1 } }, + }); + const result = await runYuque(['stats', 'members', 'eng', '--json'], { host }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + members: [{ user_id: 1, read_count: 5 }], + total: 1, + }); + }); +}); diff --git a/tests/e2e/run-cli.ts b/tests/e2e/run-cli.ts new file mode 100644 index 0000000..2a776ff --- /dev/null +++ b/tests/e2e/run-cli.ts @@ -0,0 +1,65 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const BIN = fileURLToPath(new URL('../../dist/bin.js', import.meta.url)); + +export interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +export interface RunOptions { + /** Base URL of the fixture server; becomes YUQUE_HOST. */ + host?: string; + /** null = run without any token in the environment. */ + token?: string | null; + env?: Record<string, string>; + input?: string; + timeoutMs?: number; +} + +/** + * Run the built CLI binary exactly as npx would, against a fixture server. + * Async on purpose: the fixture server lives in this same process, so a + * synchronous spawn would freeze the event loop and deadlock every request. + * The environment is scrubbed of real YUQUE_* variables so a developer's + * personal token can never leak into a test run. + */ +export function runYuque(args: string[], options: RunOptions = {}): Promise<RunResult> { + if (!existsSync(BIN)) { + throw new Error('dist/bin.js not found — run `npm run build` before the e2e suite'); + } + const env: Record<string, string | undefined> = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('YUQUE_')) delete env[key]; + } + if (options.token !== null) env.YUQUE_TOKEN = options.token ?? 'e2e-test-token'; + if (options.host !== undefined) env.YUQUE_HOST = options.host; + env.NO_COLOR = '1'; + Object.assign(env, options.env); + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN, ...args], { + env: env as NodeJS.ProcessEnv, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk)); + child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk)); + if (options.input !== undefined) child.stdin.write(options.input); + child.stdin.end(); + + const killTimer = setTimeout(() => child.kill('SIGKILL'), options.timeoutMs ?? 15000); + child.on('error', (error) => { + clearTimeout(killTimer); + reject(error); + }); + child.on('close', (code) => { + clearTimeout(killTimer); + resolve({ code, stdout, stderr }); + }); + }); +} diff --git a/tests/e2e/write-commands.e2e.test.ts b/tests/e2e/write-commands.e2e.test.ts new file mode 100644 index 0000000..625368a --- /dev/null +++ b/tests/e2e/write-commands.e2e.test.ts @@ -0,0 +1,161 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { FixtureServer } from './fixture-server.js'; +import { runYuque } from './run-cli.js'; + +let server: FixtureServer; +let host: string; + +beforeEach(async () => { + server = new FixtureServer(); + host = await server.start(); +}); + +afterEach(async () => { + await server.stop(); +}); + +describe('repo writes', () => { + it('repo create posts the spec body fields', async () => { + server.route('POST', '/api/v2/users/me/repos', { + body: { data: { id: 1, slug: 'notes', namespace: 'me/notes', name: '笔记' } }, + }); + const result = await runYuque( + [ + 'repo', + 'create', + 'me', + '--name', + '笔记', + '--slug', + 'notes', + '--public', + '0', + '--enhanced-privacy', + ], + { host } + ); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Created repo me/notes'); + expect(server.requests[0].body).toEqual({ + name: '笔记', + slug: 'notes', + public: 0, + enhancedPrivacy: true, + }); + }); + + it('repo create rejects an out-of-enum --public locally (exit 2, no request)', async () => { + const result = await runYuque( + ['repo', 'create', 'me', '--name', 'n', '--slug', 's', '--public', '3'], + { + host, + } + ); + expect(result.code).toBe(2); + expect(result.stderr).toContain('--public must be 0'); + expect(server.requests).toHaveLength(0); + }); + + it('repo delete requires --yes when stdin is not a TTY', async () => { + const refused = await runYuque(['repo', 'delete', '42'], { host }); + expect(refused.code).toBe(2); + expect(server.requests).toHaveLength(0); + + server.route('DELETE', '/api/v2/repos/42', { + body: { data: { id: 42, slug: 'gone', namespace: 'me/gone' } }, + }); + const deleted = await runYuque(['repo', 'delete', '42', '--yes'], { host }); + expect(deleted.code).toBe(0); + expect(deleted.stdout).toContain('Deleted repo me/gone'); + }); +}); + +describe('doc writes', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'yuque-e2e-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('doc create reads the body from --body-file', async () => { + const draft = join(dir, 'draft.md'); + writeFileSync(draft, '# 周会\n\n内容\n'); + server.route('POST', '/api/v2/repos/me/notes/docs', { + body: { data: { id: 7, slug: 'weekly', title: '周会' } }, + }); + const result = await runYuque( + ['doc', 'create', 'me/notes', '--title', '周会', '--slug', 'weekly', '--body-file', draft], + { host } + ); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Created doc me/notes/weekly'); + expect(server.requests[0].body).toEqual({ + title: '周会', + slug: 'weekly', + body: '# 周会\n\n内容\n', + }); + }); + + it('doc update puts only the provided fields', async () => { + server.route('PUT', '/api/v2/repos/8/docs/7', { + body: { data: { id: 7, slug: 'weekly', title: '改名' } }, + }); + const result = await runYuque(['doc', 'update', '8', '7', '--title', '改名'], { host }); + expect(result.code).toBe(0); + expect(server.requests[0].body).toEqual({ title: '改名' }); + }); + + it('doc delete --yes sends the DELETE', async () => { + server.route('DELETE', '/api/v2/repos/8/docs/7', { + body: { data: { id: 7, slug: 'weekly', title: 'Weekly' } }, + }); + const result = await runYuque(['doc', 'delete', '8', '7', '--yes'], { host }); + expect(result.code).toBe(0); + expect(server.requestsFor('DELETE', '/api/v2/repos/8/docs/7')).toHaveLength(1); + }); +}); + +describe('toc & group writes', () => { + it('toc update moves a node (append + --node-uuid, no --type needed)', async () => { + server.route('PUT', '/api/v2/repos/9/toc', { body: { data: [{ uuid: 'n' }] } }); + const result = await runYuque( + ['toc', 'update', '9', '--action', 'appendNode', '--node-uuid', 'n', '--target-uuid', 't'], + { host } + ); + expect(result.code).toBe(0); + expect(server.requests[0].body).toEqual({ + action: 'appendNode', + target_uuid: 't', + node_uuid: 'n', + }); + }); + + it('toc update editNode without --node-uuid fails locally', async () => { + const result = await runYuque(['toc', 'update', '9', '--action', 'editNode', '--title', 'x'], { + host, + }); + expect(result.code).toBe(2); + expect(result.stderr).toContain('--node-uuid is required'); + expect(server.requests).toHaveLength(0); + }); + + it('group member set puts the role and remove requires --yes', async () => { + server.route('PUT', '/api/v2/groups/eng/users/bob', { + body: { data: { id: 1, role: 1, user: { login: 'bob', name: 'Bob' } } }, + }); + const set = await runYuque(['group', 'member', 'set', 'eng', 'bob', '--role', '1'], { host }); + expect(set.code).toBe(0); + expect(server.requests[0].body).toEqual({ role: 1 }); + + const refused = await runYuque(['group', 'member', 'remove', 'eng', 'bob'], { host }); + expect(refused.code).toBe(2); + expect(server.requestsFor('DELETE', '/api/v2/groups/eng/users/bob')).toHaveLength(0); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9dc6bbd..aeefc91 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ environment: 'node', testTimeout: 10000, hookTimeout: 10000, - exclude: ['**/node_modules/**', '**/dist/**', '**/.claude/**'], + // tests/e2e needs a built dist first and runs via vitest.e2e.config.ts. + exclude: ['**/node_modules/**', '**/dist/**', '**/.claude/**', 'tests/e2e/**'], coverage: { provider: 'v8', include: ['src/**/*.ts'], diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..41ad88d --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Functional (e2e) suite: spawns the built dist/bin.js against a local mock + * Yuque API. Requires `npm run build` first; run via `npm run test:e2e`. + */ +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/e2e/**/*.e2e.test.ts'], + testTimeout: 30000, + hookTimeout: 30000, + }, +}); From d820e11a0e226a6379ac348bd910182653b7d125 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 18:48:20 +0900 Subject: [PATCH 07/13] ci: unify real-API e2e into the main CI gate npm run check's e2e step now enables the gated real-API read paths whenever the YUQUE_E2E_TOKEN secret is configured (Node 22 leg only, weekly schedule added for drift detection); the separate real-api workflow is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 18 +++++++++++++++++- .github/workflows/real-api-e2e.yml | 30 ------------------------------ AGENTS.md | 6 +++--- tests/e2e/README.md | 6 ++++-- 4 files changed, 24 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/real-api-e2e.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec3e846..dc1b7bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,19 @@ name: CI +# One unified gate: lint + typecheck + unit tests + build + dist smoke + e2e. +# The e2e step always runs the mock-server suite; the real-API read paths in +# tests/e2e/cli.e2e.test.ts additionally run when the YUQUE_E2E_TOKEN secret is +# configured (repo Settings → Secrets and variables → Actions). They are enabled +# on the Node 22 leg only, so the live API is hit once per run, and they skip +# gracefully on forks (secrets are absent there). The weekly schedule catches +# upstream API drift even when nothing is pushed. on: push: branches: [main] pull_request: + workflow_dispatch: + schedule: + - cron: '0 2 * * 1' jobs: check: @@ -18,4 +28,10 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - - run: npm run check + - name: npm run check (real-API e2e on the Node 22 leg when the token secret exists) + run: npm run check + env: + YUQUE_E2E: ${{ matrix.node-version == 22 && secrets.YUQUE_E2E_TOKEN != '' && '1' || '' }} + YUQUE_E2E_TOKEN: ${{ secrets.YUQUE_E2E_TOKEN }} + YUQUE_E2E_LOGIN: ${{ vars.YUQUE_E2E_LOGIN }} + YUQUE_E2E_REPO: ${{ vars.YUQUE_E2E_REPO }} diff --git a/.github/workflows/real-api-e2e.yml b/.github/workflows/real-api-e2e.yml deleted file mode 100644 index 15fe1c5..0000000 --- a/.github/workflows/real-api-e2e.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Real API E2E - -# Drives the built CLI against the live Yuque API on a schedule and on demand. -# The real-API paths are gated tests inside tests/e2e/cli.e2e.test.ts: without -# the YUQUE_E2E_TOKEN secret they skip and only the mock-server suite runs. -# Write-mode gates (YUQUE_E2E_WRITE / YUQUE_E2E_REPO_LIFECYCLE) are deliberately -# not wired into CI — see tests/e2e/README.md. -on: - workflow_dispatch: - schedule: - - cron: '0 2 * * 1' - -jobs: - e2e: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npm run build - - name: Run e2e (real-API paths enabled when the secret exists) - run: npm run test:e2e - env: - YUQUE_E2E: ${{ secrets.YUQUE_E2E_TOKEN != '' && '1' || '' }} - YUQUE_E2E_TOKEN: ${{ secrets.YUQUE_E2E_TOKEN }} - YUQUE_E2E_LOGIN: ${{ vars.YUQUE_E2E_LOGIN }} - YUQUE_E2E_REPO: ${{ vars.YUQUE_E2E_REPO }} diff --git a/AGENTS.md b/AGENTS.md index b0907d1..5d945d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,6 @@ bin.ts → cli.ts (commander program, error → exit code) - `npm run check` (lint + format + typecheck + unit tests + build + dist smoke + functional e2e) must exit 0 — it is the merge gate and matches CI. - Functional tests live in `tests/e2e/` (see its README): a mock-server suite that - spawns the built binary (always on), plus an env-gated real-API suite wired to - `.github/workflows/real-api-e2e.yml`. New/changed commands need e2e coverage in the - mock suite, not just unit tests. + spawns the built binary (always on), plus an env-gated real-API suite that CI + (`.github/workflows/ci.yml`) enables when the `YUQUE_E2E_TOKEN` secret exists. + New/changed commands need e2e coverage in the mock suite, not just unit tests. diff --git a/tests/e2e/README.md b/tests/e2e/README.md index dd66d14..b16c002 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -34,7 +34,9 @@ vitest process, so a synchronous spawn would freeze the event loop and deadlock. | `YUQUE_E2E_TEAM_TOKEN` / `YUQUE_E2E_HOST` / `YUQUE_E2E_GROUP` | Team/space-token paths (groups, stats) | | `YUQUE_E2E_REPO_LIFECYCLE=1` | Repo create/delete — local only, never wired into CI | -CI wiring lives in `.github/workflows/real-api-e2e.yml` (weekly + manual): it enables -only the read paths, and only when the `YUQUE_E2E_TOKEN` secret is configured. Write +CI wiring lives in `.github/workflows/ci.yml`: every push/PR (plus a weekly schedule +for API-drift detection) runs `npm run check`, and the real-API read paths turn on +automatically — on the Node 22 leg only — when the `YUQUE_E2E_TOKEN` repository secret +is configured. Fork PRs see no secrets, so those paths skip and CI stays green. Write gates stay off in CI on purpose — they modify real content and belong to a dedicated sandbox run by a human. From 6dd3ddf634d2f39ae8c5c77e7e275b54557e7f17 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 18:53:13 +0900 Subject: [PATCH 08/13] test: pick a Book-type repo in the real-API e2e auto-discovery The account's first repo can be a Design board, whose /docs and /toc endpoints 404; filter with repo list --type Book (and point at YUQUE_E2E_REPO in the failure hint). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- tests/e2e/cli.e2e.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts index 7886d38..b805ec8 100644 --- a/tests/e2e/cli.e2e.test.ts +++ b/tests/e2e/cli.e2e.test.ts @@ -102,10 +102,12 @@ describeRead('read paths (personal token)', () => { if (sandboxRepo) { readRepo = sandboxRepo; } else { - const repos = okJson(pc(['repo', 'list', login, '--json'])); + // Only Book repos serve the /docs and /toc endpoints — a Design (board) + // repo as the account's first repo would 404 them, so filter server-side. + const repos = okJson(pc(['repo', 'list', login, '--type', 'Book', '--json'])); expect( Array.isArray(repos) && repos.length, - 'test account has no repos to read' + 'test account has no Book repos to read — create one or set YUQUE_E2E_REPO' ).toBeTruthy(); readRepo = String(repos[0].namespace ?? repos[0].id); } From fad60e2a83544f9a7bc7c590aff8fea10292ee7d Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 18:56:03 +0900 Subject: [PATCH 09/13] test: probe login-then-id for real-API owner routes in e2e discovery /users/{login}/repos 404s for some accounts (private profiles) while the numeric id works; probe both and reuse the working ref for user-groups and repo-list assertions. YUQUE_E2E_LOGIN still pins it explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- tests/e2e/cli.e2e.test.ts | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts index b805ec8..624b815 100644 --- a/tests/e2e/cli.e2e.test.ts +++ b/tests/e2e/cli.e2e.test.ts @@ -93,23 +93,41 @@ describeConfig('e2e config sanity', () => { const describeRead = READ_ENABLED && personalToken ? describe : describe.skip; describeRead('read paths (personal token)', () => { - let login: string; + /** Owner ref that the /users/... routes actually accept for this account. */ + let owner: string; let readRepo: string; let sampleDocSlug: string | undefined; beforeAll(() => { - login = configuredLogin ?? String(okJson(pc(['auth', 'status', '--json'])).login); + const me = okJson(pc(['user', 'info', '--json'])); + // Some accounts 404 on the by-login /users/{login}/... routes (e.g. private + // profiles) while the numeric id works, so probe login first, then fall + // back to id. YUQUE_E2E_LOGIN pins the ref explicitly and skips the probe. + const candidates = configuredLogin ? [configuredLogin] : [String(me.login), String(me.id)]; + let repos: unknown; + for (const candidate of candidates) { + // Only Book repos serve the /docs and /toc endpoints — a Design (board) + // repo as the account's first repo would 404 them, so filter server-side. + const res = pc(['repo', 'list', candidate, '--type', 'Book', '--json']); + if (res.code === 0) { + owner = candidate; + repos = JSON.parse(res.stdout); + break; + } + } + expect( + owner, + `none of [${candidates.join(', ')}] can list repos — set YUQUE_E2E_LOGIN` + ).toBeTruthy(); if (sandboxRepo) { readRepo = sandboxRepo; } else { - // Only Book repos serve the /docs and /toc endpoints — a Design (board) - // repo as the account's first repo would 404 them, so filter server-side. - const repos = okJson(pc(['repo', 'list', login, '--type', 'Book', '--json'])); expect( - Array.isArray(repos) && repos.length, + Array.isArray(repos) && (repos as unknown[]).length, 'test account has no Book repos to read — create one or set YUQUE_E2E_REPO' ).toBeTruthy(); - readRepo = String(repos[0].namespace ?? repos[0].id); + const first = (repos as Array<Record<string, unknown>>)[0]; + readRepo = String(first.namespace ?? first.id); } const docs = okJson(pc(['doc', 'list', readRepo, '--json'])); sampleDocSlug = Array.isArray(docs) && docs.length ? String(docs[0].slug) : undefined; @@ -130,7 +148,7 @@ describeRead('read paths (personal token)', () => { }); it('user groups --json returns an array', () => { - expect(Array.isArray(okJson(pc(['user', 'groups', login, '--json'])))).toBe(true); + expect(Array.isArray(okJson(pc(['user', 'groups', owner, '--json'])))).toBe(true); }); it('search --json returns an array', () => { @@ -138,7 +156,7 @@ describeRead('read paths (personal token)', () => { }); it('repo list --json returns an array', () => { - expect(Array.isArray(okJson(pc(['repo', 'list', login, '--json'])))).toBe(true); + expect(Array.isArray(okJson(pc(['repo', 'list', owner, '--json'])))).toBe(true); }); it('repo get --json returns the repo', () => { From a5db5853eedaa79af9f93b0f3222752c9fe78500 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 19:06:11 +0900 Subject: [PATCH 10/13] test: bootstrap a sandbox Book on the dedicated e2e test account when empty CI's YUQUE_E2E_TOKEN belongs to a dedicated empty test account; first run creates cli-e2e-sandbox with one fixture doc via the CLI itself, later runs discover it through the Book listing. No manual setup or repo variables needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- tests/e2e/README.md | 10 +++++++--- tests/e2e/cli.e2e.test.ts | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index b16c002..736032f 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -37,6 +37,10 @@ vitest process, so a synchronous spawn would freeze the event loop and deadlock. CI wiring lives in `.github/workflows/ci.yml`: every push/PR (plus a weekly schedule for API-drift detection) runs `npm run check`, and the real-API read paths turn on automatically — on the Node 22 leg only — when the `YUQUE_E2E_TOKEN` repository secret -is configured. Fork PRs see no secrets, so those paths skip and CI stays green. Write -gates stay off in CI on purpose — they modify real content and belong to a dedicated -sandbox run by a human. +is configured. Fork PRs see no secrets, so those paths skip and CI stays green. + +`YUQUE_E2E_TOKEN` must belong to a **dedicated test account**: on the first run +against an empty account the suite bootstraps a `cli-e2e-sandbox` Book with one +fixture doc (the only write the read paths ever perform) and reuses it afterwards. +The write-mode gates (`YUQUE_E2E_WRITE` / `YUQUE_E2E_REPO_LIFECYCLE`) stay off in CI +on purpose and belong to a human-run sandbox session. diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts index 624b815..4ae3418 100644 --- a/tests/e2e/cli.e2e.test.ts +++ b/tests/e2e/cli.e2e.test.ts @@ -121,13 +121,40 @@ describeRead('read paths (personal token)', () => { ).toBeTruthy(); if (sandboxRepo) { readRepo = sandboxRepo; - } else { - expect( - Array.isArray(repos) && (repos as unknown[]).length, - 'test account has no Book repos to read — create one or set YUQUE_E2E_REPO' - ).toBeTruthy(); + } else if (Array.isArray(repos) && (repos as unknown[]).length) { const first = (repos as Array<Record<string, unknown>>)[0]; readRepo = String(first.namespace ?? first.id); + } else { + // The token belongs to a dedicated, otherwise-empty test account: + // bootstrap a sandbox Book once so the read paths have a real target. + // Later runs discover it through the Book listing above and skip this. + const created = okJson( + pc([ + 'repo', + 'create', + owner, + '--name', + 'CLI E2E Sandbox', + '--slug', + 'cli-e2e-sandbox', + '--json', + ]) + ); + readRepo = String(created.namespace ?? created.id); + okJson( + pc([ + 'doc', + 'create', + readRepo, + '--title', + 'E2E Fixture', + '--slug', + 'e2e-fixture', + '--body', + '# E2E Fixture\n\nCreated by yuque-cli CI to exercise real-API read paths.\n', + '--json', + ]) + ); } const docs = okJson(pc(['doc', 'list', readRepo, '--json'])); sampleDocSlug = Array.isArray(docs) && docs.length ? String(docs[0].slug) : undefined; From 1a3808432167f7daadc776b1ec867cd00afa76ef Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 19:11:05 +0900 Subject: [PATCH 11/13] chore: rename package to yuque-cli, version 1.0.0 Reuses the org-owned npm name (superseding the 0.1.x interactive CLI) with a major bump for the breaking rewrite; bin stays `yuque`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- AGENTS.md | 2 +- CHANGELOG.md | 24 ++++++++++++++++-------- README.md | 12 ++++++------ README.zh-CN.md | 12 ++++++------ package-lock.json | 33 ++++----------------------------- package.json | 4 ++-- src/client/http.ts | 2 +- 7 files changed, 36 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d945d7..409e8ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidance for AI agents (and humans) working in this repo. ## What this is -`@yuque/cli` — a command-line interface for the Yuque (语雀) Open API, sibling of +`yuque-cli` — a command-line interface for the Yuque (语雀) Open API, sibling of [yuque-mcp-server](https://github.com/yuque/yuque-mcp-server) and built with the same engineering conventions. diff --git a/CHANGELOG.md b/CHANGELOG.md index ec80bbc..741c57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,19 @@ # Changelog -## 0.1.0 (unreleased) +## 1.0.0 -- Initial release: 26 commands covering the full Yuque OpenAPI surface (38 operations) — - auth/ping, user, search, repos, docs (incl. version history), TOC, group members, and - team statistics. -- Token auth via `YUQUE_TOKEN` / `--token`; custom hosts via `YUQUE_HOST` / `--host`. -- Human-readable output with `--json` raw mode, stable exit codes, automatic retry with - backoff on rate limits, `--all` pagination, and confirmation gates on destructive - commands. +Complete rewrite. Replaces the previous interactive REPL implementation +(`yuque-cli@0.1.x`) with a spec-driven, scriptable CLI: + +- 26 noun-verb commands covering all 38 operations of the Yuque OpenAPI — + auth/ping, user, search, repos, docs (incl. version history), TOC, group + members, and team statistics; repos accept a numeric id or `owner/slug` + everywhere. +- Token auth via `YUQUE_TOKEN` / `--token` (flag wins; `YUQUE_PERSONAL_TOKEN` + compatibility fallback); custom hosts via `YUQUE_HOST` / `--host`; timeouts + via `YUQUE_TIMEOUT_MS` / `--timeout`. +- Human-readable output with `--json` full-payload mode, stable exit codes + (0/1/2/3/4/5), `--all` pagination, automatic backoff on rate limits (writes + are never silently replayed), and confirmation gates on destructive commands. +- Command surface locked 1:1 against the vendored OpenAPI spec by contract + tests; functional e2e suite drives the built binary in CI. diff --git a/README.md b/README.md index 0184957..631ba0f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ yuque repo list my-team --group --all --json | jq '.[].name' **2. Install and sign in:** ```bash -npm install -g @yuque/cli +npm install -g yuque-cli export YUQUE_TOKEN=YOUR_TOKEN yuque auth status ``` @@ -37,7 +37,7 @@ yuque auth status <summary><b>Run without installing (npx)</b></summary> ```bash -YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status +YUQUE_TOKEN=YOUR_TOKEN npx yuque-cli auth status ``` </details> @@ -150,9 +150,9 @@ The command surface is pinned to [spec/yuque-openapi.yaml](./spec/yuque-openapi. [ci-image]: https://img.shields.io/github/actions/workflow/status/yuque/yuque-cli/ci.yml?style=flat-square&label=CI [ci-url]: https://github.com/yuque/yuque-cli/actions/workflows/ci.yml -[npm-image]: https://img.shields.io/npm/v/%40yuque%2Fcli?style=flat-square -[npm-url]: https://www.npmjs.com/package/@yuque/cli -[download-image]: https://img.shields.io/npm/dm/%40yuque%2Fcli?style=flat-square -[download-url]: https://www.npmjs.com/package/@yuque/cli +[npm-image]: https://img.shields.io/npm/v/yuque-cli?style=flat-square +[npm-url]: https://www.npmjs.com/package/yuque-cli +[download-image]: https://img.shields.io/npm/dm/yuque-cli?style=flat-square +[download-url]: https://www.npmjs.com/package/yuque-cli [license-image]: https://img.shields.io/github/license/yuque/yuque-cli?style=flat-square [license-url]: ./LICENSE diff --git a/README.zh-CN.md b/README.zh-CN.md index b042cac..70bd9fc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@ yuque repo list my-team --group --all --json | jq '.[].name' **第二步:安装并登录:** ```bash -npm install -g @yuque/cli +npm install -g yuque-cli export YUQUE_TOKEN=YOUR_TOKEN yuque auth status ``` @@ -37,7 +37,7 @@ yuque auth status <summary><b>免安装运行(npx)</b></summary> ```bash -YUQUE_TOKEN=YOUR_TOKEN npx @yuque/cli auth status +YUQUE_TOKEN=YOUR_TOKEN npx yuque-cli auth status ``` </details> @@ -150,9 +150,9 @@ npm run dev -- --help # 从源码运行 [ci-image]: https://img.shields.io/github/actions/workflow/status/yuque/yuque-cli/ci.yml?style=flat-square&label=CI [ci-url]: https://github.com/yuque/yuque-cli/actions/workflows/ci.yml -[npm-image]: https://img.shields.io/npm/v/%40yuque%2Fcli?style=flat-square -[npm-url]: https://www.npmjs.com/package/@yuque/cli -[download-image]: https://img.shields.io/npm/dm/%40yuque%2Fcli?style=flat-square -[download-url]: https://www.npmjs.com/package/@yuque/cli +[npm-image]: https://img.shields.io/npm/v/yuque-cli?style=flat-square +[npm-url]: https://www.npmjs.com/package/yuque-cli +[download-image]: https://img.shields.io/npm/dm/yuque-cli?style=flat-square +[download-url]: https://www.npmjs.com/package/yuque-cli [license-image]: https://img.shields.io/github/license/yuque/yuque-cli?style=flat-square [license-url]: ./LICENSE diff --git a/package-lock.json b/package-lock.json index c5b08f4..f3cf1dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@yuque/cli", - "version": "0.1.0", + "name": "yuque-cli", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@yuque/cli", - "version": "0.1.0", + "name": "yuque-cli", + "version": "1.0.0", "license": "MIT", "dependencies": { "axios": "^1.7.9", @@ -92,31 +92,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", diff --git a/package.json b/package.json index 4dfb753..ac0ac21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@yuque/cli", - "version": "0.1.0", + "name": "yuque-cli", + "version": "1.0.0", "description": "Command-line interface for Yuque (语雀) — browse, edit, and manage your knowledge base from the terminal", "type": "module", "main": "dist/cli.js", diff --git a/src/client/http.ts b/src/client/http.ts index cb28b93..50f6ca2 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -41,7 +41,7 @@ export class YuqueHttp { headers: { 'X-Auth-Token': options.token, 'Content-Type': 'application/json', - 'User-Agent': '@yuque/cli', + 'User-Agent': 'yuque-cli', }, }); } From ad68ba52c87a81d673fdf43f2b6bc8934efcdd5c Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 19:15:55 +0900 Subject: [PATCH 12/13] chore: regenerate lockfile with a full install so npm ci resolves optional deps Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- package-lock.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/package-lock.json b/package-lock.json index f3cf1dd..c7d8170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1094,6 +1094,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", From 4dd701857e330b62500b5738b89d986e58500a51 Mon Sep 17 00:00:00 2001 From: cwg <1227646458@qq.com> Date: Thu, 23 Jul 2026 19:18:13 +0900 Subject: [PATCH 13/13] =?UTF-8?q?ci:=20use=20npm=20install=20=E2=80=94=20c?= =?UTF-8?q?ross-platform=20lockfile=20drift=20on=20optional=20wasm=20bindi?= =?UTF-8?q?ngs=20breaks=20npm=20ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc1b7bc..6389ad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,10 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: npm - - run: npm ci + # npm install, not npm ci: vitest's platform-specific wasm bindings resolve + # different @emnapi versions per OS, and a macOS-generated lockfile trips + # npm ci's strict sync check on Linux (npm/cli#4828 family). + - run: npm install --no-audit --no-fund - name: npm run check (real-API e2e on the Node 22 leg when the token secret exists) run: npm run check env: