From 7d4426d90420104957d3e922676a7dfe2dc938f2 Mon Sep 17 00:00:00 2001 From: chenos Date: Fri, 17 Apr 2026 09:39:47 +0800 Subject: [PATCH] refactor: nocobase cli (#9122) * chore: update deps * fix: bin * feat: nocobase cli based on oclif * feat: improve code * fix: create-nocobase-app * fix: build error * fix: build error * fix: lru-cache * fix: cli build * fix: registry * fix: build error * fix: examples * feat: improve pm list --- .gitignore | 1 + package.json | 50 +- packages/core/app/package.json | 1 + packages/core/build/src/build.ts | 9 + packages/core/build/src/constant.ts | 1 + packages/core/{cli => cli-v1}/.npmignore | 0 packages/core/{cli => cli-v1}/LICENSE | 0 packages/core/cli-v1/README.md | 99 ++++ packages/core/{cli => cli-v1}/bin/index.js | 0 .../core/{cli => cli-v1}/nocobase.conf.tpl | 0 packages/core/cli-v1/package.json | 37 ++ packages/core/{cli => cli-v1}/src/cli.js | 0 .../{cli => cli-v1}/src/commands/benchmark.js | 0 .../{cli => cli-v1}/src/commands/build.js | 4 +- .../{cli => cli-v1}/src/commands/clean.js | 0 .../{cli => cli-v1}/src/commands/client.js | 0 .../src/commands/create-nginx-conf.js | 0 .../src/commands/create-plugin.js | 0 .../core/{cli => cli-v1}/src/commands/dev.js | 0 .../core/{cli => cli-v1}/src/commands/doc.js | 0 .../core/{cli => cli-v1}/src/commands/e2e.js | 0 .../{cli => cli-v1}/src/commands/global.js | 0 .../{cli => cli-v1}/src/commands/index.js | 0 .../src/commands/instance-id.js | 0 .../{cli => cli-v1}/src/commands/locale.js | 0 .../src/commands/locale/cronstrue.js | 0 .../commands/locale/react-js-cron/en-US.json | 0 .../commands/locale/react-js-cron/index.js | 0 .../commands/locale/react-js-cron/zh-CN.json | 0 .../commands/locale/react-js-cron/zh-TW.json | 0 .../{cli => cli-v1}/src/commands/p-test.js | 0 .../core/{cli => cli-v1}/src/commands/perf.js | 0 .../core/{cli => cli-v1}/src/commands/pkg.js | 0 .../core/{cli => cli-v1}/src/commands/pm2.js | 0 .../src/commands/postinstall.js | 0 .../{cli => cli-v1}/src/commands/start.js | 0 .../core/{cli => cli-v1}/src/commands/tar.js | 0 .../src/commands/test-coverage.js | 0 .../core/{cli => cli-v1}/src/commands/test.js | 0 .../core/{cli => cli-v1}/src/commands/umi.js | 0 .../src/commands/update-deps.js | 6 +- .../{cli => cli-v1}/src/commands/upgrade.js | 0 .../src/commands/view-license-key.js | 0 packages/core/{cli => cli-v1}/src/index.js | 0 packages/core/{cli => cli-v1}/src/license.js | 0 packages/core/{cli => cli-v1}/src/logger.js | 0 .../{cli => cli-v1}/src/plugin-generator.js | 0 packages/core/{cli => cli-v1}/src/util.js | 0 .../templates/bundle-status.html | 0 .../templates/create-app-package.json | 22 +- .../templates/plugin/.npmignore.tpl | 0 .../templates/plugin/README.md.tpl | 0 .../templates/plugin/client-v2.d.ts | 0 .../templates/plugin/client-v2.js | 0 .../templates/plugin/client.d.ts | 0 .../templates/plugin/client.js | 0 .../templates/plugin/package.json.tpl | 0 .../templates/plugin/server.d.ts | 0 .../templates/plugin/server.js | 0 .../plugin/src/client-v2/client.d.ts | 0 .../plugin/src/client-v2/index.tsx.tpl | 0 .../plugin/src/client-v2/plugin.tsx.tpl | 0 .../templates/plugin/src/client/client.d.ts | 0 .../templates/plugin/src/client/index.tsx.tpl | 0 .../templates/plugin/src/client/locale.ts | 2 +- .../plugin/src/client/models/index.ts | 0 .../plugin/src/client/plugin.tsx.tpl | 0 .../templates/plugin/src/index.ts | 0 .../templates/plugin/src/locale/en-US.json | 0 .../templates/plugin/src/locale/zh-CN.json | 0 .../plugin/src/server/collections/.gitkeep | 0 .../templates/plugin/src/server/index.ts.tpl | 0 .../templates/plugin/src/server/plugin.ts.tpl | 0 packages/core/cli/.gitignore | 15 + packages/core/cli/README.md | 197 +++++-- packages/core/cli/bin/run.cmd | 3 + packages/core/cli/bin/run.js | 87 +++ packages/core/cli/nocobase-ctl.config.json | 327 +++++++++++ packages/core/cli/package.json | 74 ++- packages/core/cli/src/commands/api/index.ts | 10 + packages/core/cli/src/commands/env/add.ts | 61 ++ packages/core/cli/src/commands/env/auth.ts | 40 ++ packages/core/cli/src/commands/env/index.ts | 37 ++ packages/core/cli/src/commands/env/list.ts | 37 ++ packages/core/cli/src/commands/env/remove.ts | 64 ++ packages/core/cli/src/commands/env/update.ts | 64 ++ packages/core/cli/src/commands/env/use.ts | 30 + .../core/cli/src/commands/resource/create.ts | 21 + .../core/cli/src/commands/resource/destroy.ts | 21 + .../core/cli/src/commands/resource/get.ts | 21 + .../core/cli/src/commands/resource/index.ts | 9 + .../core/cli/src/commands/resource/list.ts | 22 + .../core/cli/src/commands/resource/query.ts | 21 + .../core/cli/src/commands/resource/update.ts | 21 + .../cli/src/generated/command-registry.ts | 103 ++++ packages/core/cli/src/lib/api-client.ts | 288 +++++++++ packages/core/cli/src/lib/auth-store.ts | 251 ++++++++ packages/core/cli/src/lib/bootstrap.ts | 449 ++++++++++++++ packages/core/cli/src/lib/build-config.ts | 50 ++ packages/core/cli/src/lib/cli-home.ts | 40 ++ packages/core/cli/src/lib/env-auth.ts | 548 ++++++++++++++++++ .../core/cli/src/lib/generated-command.ts | 194 +++++++ packages/core/cli/src/lib/naming.ts | 85 +++ packages/core/cli/src/lib/openapi.ts | 341 +++++++++++ packages/core/cli/src/lib/post-processors.ts | 39 ++ packages/core/cli/src/lib/resource-command.ts | 391 +++++++++++++ packages/core/cli/src/lib/resource-request.ts | 164 ++++++ .../core/cli/src/lib/runtime-generator.ts | 528 +++++++++++++++++ packages/core/cli/src/lib/runtime-store.ts | 78 +++ packages/core/cli/src/lib/ui.ts | 209 +++++++ .../cli/src/post-processors/data-modeling.ts | 77 +++ .../post-processors/data-source-manager.ts | 153 +++++ .../core/cli/src/post-processors/index.ts | 23 + packages/core/cli/src/types/vendor.d.ts | 31 + packages/core/cli/test/auth-store.test.ts | 249 ++++++++ packages/core/cli/test/bootstrap.test.ts | 71 +++ packages/core/cli/test/env-auth.test.ts | 124 ++++ .../test/generated-command-body-modes.test.ts | 122 ++++ packages/core/cli/tsconfig.json | 20 + packages/core/client/package.json | 2 +- .../schema-settings/DataTemplates/utils.tsx | 2 +- .../core/create-nocobase-app/src/generator.js | 2 +- .../templates/app/package.json | 22 +- packages/core/devtools/umiConfig.js | 2 +- packages/core/server/src/commands/pm.ts | 30 +- .../src/plugin-manager/options/resource.ts | 7 +- .../core/server/src/plugin-manager/utils.ts | 33 ++ packages/core/test/setup/server.ts | 2 +- .../src/client/models/utils.tsx | 2 +- .../src/server/llm-providers/kimi/provider.ts | 2 +- .../@nocobase/plugin-workflow/package.json | 2 +- .../plugin-workflow/src/server/Plugin.ts | 2 +- tsconfig.json | 4 +- yarn.lock | 470 ++++++++++++++- 134 files changed, 6420 insertions(+), 206 deletions(-) rename packages/core/{cli => cli-v1}/.npmignore (100%) rename packages/core/{cli => cli-v1}/LICENSE (100%) create mode 100644 packages/core/cli-v1/README.md rename packages/core/{cli => cli-v1}/bin/index.js (100%) rename packages/core/{cli => cli-v1}/nocobase.conf.tpl (100%) create mode 100644 packages/core/cli-v1/package.json rename packages/core/{cli => cli-v1}/src/cli.js (100%) rename packages/core/{cli => cli-v1}/src/commands/benchmark.js (100%) rename packages/core/{cli => cli-v1}/src/commands/build.js (95%) rename packages/core/{cli => cli-v1}/src/commands/clean.js (100%) rename packages/core/{cli => cli-v1}/src/commands/client.js (100%) rename packages/core/{cli => cli-v1}/src/commands/create-nginx-conf.js (100%) rename packages/core/{cli => cli-v1}/src/commands/create-plugin.js (100%) rename packages/core/{cli => cli-v1}/src/commands/dev.js (100%) rename packages/core/{cli => cli-v1}/src/commands/doc.js (100%) rename packages/core/{cli => cli-v1}/src/commands/e2e.js (100%) rename packages/core/{cli => cli-v1}/src/commands/global.js (100%) rename packages/core/{cli => cli-v1}/src/commands/index.js (100%) rename packages/core/{cli => cli-v1}/src/commands/instance-id.js (100%) rename packages/core/{cli => cli-v1}/src/commands/locale.js (100%) rename packages/core/{cli => cli-v1}/src/commands/locale/cronstrue.js (100%) rename packages/core/{cli => cli-v1}/src/commands/locale/react-js-cron/en-US.json (100%) rename packages/core/{cli => cli-v1}/src/commands/locale/react-js-cron/index.js (100%) rename packages/core/{cli => cli-v1}/src/commands/locale/react-js-cron/zh-CN.json (100%) rename packages/core/{cli => cli-v1}/src/commands/locale/react-js-cron/zh-TW.json (100%) rename packages/core/{cli => cli-v1}/src/commands/p-test.js (100%) rename packages/core/{cli => cli-v1}/src/commands/perf.js (100%) rename packages/core/{cli => cli-v1}/src/commands/pkg.js (100%) rename packages/core/{cli => cli-v1}/src/commands/pm2.js (100%) rename packages/core/{cli => cli-v1}/src/commands/postinstall.js (100%) rename packages/core/{cli => cli-v1}/src/commands/start.js (100%) rename packages/core/{cli => cli-v1}/src/commands/tar.js (100%) rename packages/core/{cli => cli-v1}/src/commands/test-coverage.js (100%) rename packages/core/{cli => cli-v1}/src/commands/test.js (100%) rename packages/core/{cli => cli-v1}/src/commands/umi.js (100%) rename packages/core/{cli => cli-v1}/src/commands/update-deps.js (91%) rename packages/core/{cli => cli-v1}/src/commands/upgrade.js (100%) rename packages/core/{cli => cli-v1}/src/commands/view-license-key.js (100%) rename packages/core/{cli => cli-v1}/src/index.js (100%) rename packages/core/{cli => cli-v1}/src/license.js (100%) rename packages/core/{cli => cli-v1}/src/logger.js (100%) rename packages/core/{cli => cli-v1}/src/plugin-generator.js (100%) rename packages/core/{cli => cli-v1}/src/util.js (100%) rename packages/core/{cli => cli-v1}/templates/bundle-status.html (100%) rename packages/core/{cli => cli-v1}/templates/create-app-package.json (61%) rename packages/core/{cli => cli-v1}/templates/plugin/.npmignore.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/README.md.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/client-v2.d.ts (100%) rename packages/core/{cli => cli-v1}/templates/plugin/client-v2.js (100%) rename packages/core/{cli => cli-v1}/templates/plugin/client.d.ts (100%) rename packages/core/{cli => cli-v1}/templates/plugin/client.js (100%) rename packages/core/{cli => cli-v1}/templates/plugin/package.json.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/server.d.ts (100%) rename packages/core/{cli => cli-v1}/templates/plugin/server.js (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client-v2/client.d.ts (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client-v2/index.tsx.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client-v2/plugin.tsx.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client/client.d.ts (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client/index.tsx.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client/locale.ts (93%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client/models/index.ts (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/client/plugin.tsx.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/index.ts (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/locale/en-US.json (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/locale/zh-CN.json (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/server/collections/.gitkeep (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/server/index.ts.tpl (100%) rename packages/core/{cli => cli-v1}/templates/plugin/src/server/plugin.ts.tpl (100%) create mode 100644 packages/core/cli/.gitignore create mode 100644 packages/core/cli/bin/run.cmd create mode 100755 packages/core/cli/bin/run.js create mode 100644 packages/core/cli/nocobase-ctl.config.json create mode 100644 packages/core/cli/src/commands/api/index.ts create mode 100644 packages/core/cli/src/commands/env/add.ts create mode 100644 packages/core/cli/src/commands/env/auth.ts create mode 100644 packages/core/cli/src/commands/env/index.ts create mode 100644 packages/core/cli/src/commands/env/list.ts create mode 100644 packages/core/cli/src/commands/env/remove.ts create mode 100644 packages/core/cli/src/commands/env/update.ts create mode 100644 packages/core/cli/src/commands/env/use.ts create mode 100644 packages/core/cli/src/commands/resource/create.ts create mode 100644 packages/core/cli/src/commands/resource/destroy.ts create mode 100644 packages/core/cli/src/commands/resource/get.ts create mode 100644 packages/core/cli/src/commands/resource/index.ts create mode 100644 packages/core/cli/src/commands/resource/list.ts create mode 100644 packages/core/cli/src/commands/resource/query.ts create mode 100644 packages/core/cli/src/commands/resource/update.ts create mode 100644 packages/core/cli/src/generated/command-registry.ts create mode 100644 packages/core/cli/src/lib/api-client.ts create mode 100644 packages/core/cli/src/lib/auth-store.ts create mode 100644 packages/core/cli/src/lib/bootstrap.ts create mode 100644 packages/core/cli/src/lib/build-config.ts create mode 100644 packages/core/cli/src/lib/cli-home.ts create mode 100644 packages/core/cli/src/lib/env-auth.ts create mode 100644 packages/core/cli/src/lib/generated-command.ts create mode 100644 packages/core/cli/src/lib/naming.ts create mode 100644 packages/core/cli/src/lib/openapi.ts create mode 100644 packages/core/cli/src/lib/post-processors.ts create mode 100644 packages/core/cli/src/lib/resource-command.ts create mode 100644 packages/core/cli/src/lib/resource-request.ts create mode 100644 packages/core/cli/src/lib/runtime-generator.ts create mode 100644 packages/core/cli/src/lib/runtime-store.ts create mode 100644 packages/core/cli/src/lib/ui.ts create mode 100644 packages/core/cli/src/post-processors/data-modeling.ts create mode 100644 packages/core/cli/src/post-processors/data-source-manager.ts create mode 100644 packages/core/cli/src/post-processors/index.ts create mode 100644 packages/core/cli/src/types/vendor.d.ts create mode 100644 packages/core/cli/test/auth-store.test.ts create mode 100644 packages/core/cli/test/bootstrap.test.ts create mode 100644 packages/core/cli/test/env-auth.test.ts create mode 100644 packages/core/cli/test/generated-command-body-modes.test.ts create mode 100644 packages/core/cli/tsconfig.json diff --git a/.gitignore b/.gitignore index badc1e47163..b83836a6e9f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ git-repos.json CLAUDE.md AGENTS.md openspec/ +/.nocobase \ No newline at end of file diff --git a/package.json b/package.json index 277e0db91ec..c8fcc9a6c40 100644 --- a/package.json +++ b/package.json @@ -16,33 +16,33 @@ } ], "scripts": { - "nocobase": "nocobase", - "pm": "nocobase pm", - "pm2": "nocobase pm2", - "dev:umi": "nocobase dev", - "dev": "nocobase dev --rsbuild", - "dev:client-v2": "nocobase dev --client-v2-only", - "dev-server": "nocobase dev --server", - "start": "nocobase start", - "build": "nocobase build", - "build:client-v2": "nocobase build --client-v2-only", - "tar": "nocobase tar", - "test": "nocobase test", - "test:server": "nocobase test:server", - "test:server-coverage": "nocobase test-coverage:server", - "test:client": "nocobase test:client", - "test:client-coverage": "nocobase test-coverage:client", - "e2e": "nocobase e2e", - "ts": "nocobase test:server", - "tc": "nocobase test:client", - "benchmark": "nocobase benchmark", - "perf": "nocobase perf", + "nocobase": "nocobase-v1", + "pm": "nocobase-v1 pm", + "pm2": "nocobase-v1 pm2", + "dev:umi": "nocobase-v1 dev", + "dev": "nocobase-v1 dev --rsbuild", + "dev:client-v2": "nocobase-v1 dev --client-v2-only", + "dev-server": "nocobase-v1 dev --server", + "start": "nocobase-v1 start", + "build": "nocobase-v1 build", + "build:client-v2": "nocobase-v1 build --client-v2-only", + "tar": "nocobase-v1 tar", + "test": "nocobase-v1 test", + "test:server": "nocobase-v1 test:server", + "test:server-coverage": "nocobase-v1 test-coverage:server", + "test:client": "nocobase-v1 test:client", + "test:client-coverage": "nocobase-v1 test-coverage:client", + "e2e": "nocobase-v1 e2e", + "ts": "nocobase-v1 test:server", + "tc": "nocobase-v1 test:client", + "benchmark": "nocobase-v1 benchmark", + "perf": "nocobase-v1 perf", "docs": "yarn --cwd docs", - "doc": "nocobase doc", - "doc:cn": "nocobase doc --lang=zh-CN", - "postinstall": "nocobase postinstall", + "doc": "nocobase-v1 doc", + "doc:cn": "nocobase-v1 doc --lang=zh-CN", + "postinstall": "nocobase-v1 postinstall", "lint": "eslint .", - "clean": "nocobase clean", + "clean": "nocobase-v1 clean", "changelog": "auto-changelog -p -t keepachangelog", "version:alpha": "lerna version prerelease --preid alpha --force-publish=* --no-git-tag-version -m \"chore(versions): publish packages %s\"", "release:force": "lerna publish from-package --yes --no-git-tag-version", diff --git a/packages/core/app/package.json b/packages/core/app/package.json index 86f0ae3039f..955f8dfc95b 100644 --- a/packages/core/app/package.json +++ b/packages/core/app/package.json @@ -6,6 +6,7 @@ "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { + "@nocobase/cli-v1": "2.1.0-alpha.16", "@nocobase/database": "2.1.0-alpha.16", "@nocobase/preset-nocobase": "2.1.0-alpha.16", "@nocobase/server": "2.1.0-alpha.16" diff --git a/packages/core/build/src/build.ts b/packages/core/build/src/build.ts index 874f6e54a09..6eeebcc2f66 100755 --- a/packages/core/build/src/build.ts +++ b/packages/core/build/src/build.ts @@ -65,6 +65,15 @@ export async function build(pkgs: string[]) { if (process.argv.includes('--retry') && cachePkg?.pkg) { packages = packages.slice(packages.findIndex((item) => item.name === cachePkg.pkg)); } + const cliPackages = packages.find((item) => item.name === '@nocobase/cli'); + if (cliPackages) { + const log = getPkgLog(cliPackages.name); + log('running package script "build" (clean + tsc)'); + await runScript(['build'], cliPackages.location); + if (packages.length === 1) { + return; + } + } if (packages.length === 0) { let msg = ''; if (pkgs.length) { diff --git a/packages/core/build/src/constant.ts b/packages/core/build/src/constant.ts index 9fab9abd2dc..7d9018a7f85 100644 --- a/packages/core/build/src/constant.ts +++ b/packages/core/build/src/constant.ts @@ -63,6 +63,7 @@ export const CORE_CLIENT_V2 = path.join(PACKAGES_PATH, 'core/client-v2'); export const ESM_PACKAGES = ['@nocobase/client-v2', '@nocobase/test']; export const CJS_EXCLUDE_PACKAGES = [ path.join(PACKAGES_PATH, 'core/build'), + path.join(PACKAGES_PATH, 'core/cli-v1'), path.join(PACKAGES_PATH, 'core/cli'), CORE_CLIENT, CORE_CLIENT_V2, diff --git a/packages/core/cli/.npmignore b/packages/core/cli-v1/.npmignore similarity index 100% rename from packages/core/cli/.npmignore rename to packages/core/cli-v1/.npmignore diff --git a/packages/core/cli/LICENSE b/packages/core/cli-v1/LICENSE similarity index 100% rename from packages/core/cli/LICENSE rename to packages/core/cli-v1/LICENSE diff --git a/packages/core/cli-v1/README.md b/packages/core/cli-v1/README.md new file mode 100644 index 00000000000..cb97e69c499 --- /dev/null +++ b/packages/core/cli-v1/README.md @@ -0,0 +1,99 @@ +# NocoBase + + + +

+nocobase%2Fnocobase | Trendshift +NocoBase - Scalability-first, open-source no-code platform | Product Hunt +

+ +## What is NocoBase + +NocoBase is the most extensible AI-powered no-code platform. +Total control. Infinite extensibility. AI collaboration. +Enable your team to adapt quickly and cut costs dramatically. +No years of development. No millions wasted. +Deploy NocoBase in minutes — and take control of everything. + +Homepage: +https://www.nocobase.com/ + +Online Demo: +https://demo.nocobase.com/new + +Documents: +https://docs.nocobase.com/ + +Forum: +https://forum.nocobase.com/ + +Use Cases: +https://www.nocobase.com/en/blog/tags/customer-stories + +## Release Notes + +Our [blog](https://www.nocobase.com/en/blog/timeline) is regularly updated with release notes and provides a weekly summary. + +## Distinctive features + +### 1. Data model-driven, not form/table–driven + +Instead of being constrained by forms or tables, NocoBase adopts a data model–driven approach, separating data structure from user interface to unlock unlimited possibilities. + +- UI and data structure are fully decoupled +- Multiple blocks and actions can be created for the same table or record in any quantity or form +- Supports the main database, external databases, and third-party APIs as data sources + +![model](https://static-docs.nocobase.com/model.png) + +### 2. AI employees, integrated into your business systems +Unlike standalone AI demos, NocoBase allows you to embed AI capabilities seamlessly into your interfaces, workflows, and data context, making AI truly useful in real business scenarios. + +- Define AI employees for roles such as translator, analyst, researcher, or assistant +- Seamless AI–human collaboration in interfaces and workflows +- Ensure AI usage is secure, transparent, and customizable for your business needs + +![AI-employee](https://static-docs.nocobase.com/ai-employee-home.png) + +### 3. What you see is what you get, incredibly easy to use + +While enabling the development of complex business systems, NocoBase keeps the experience simple and intuitive. + +- One-click switch between usage mode and configuration mode +- Pages serve as a canvas to arrange blocks and actions, similar to Notion +- Configuration mode is designed for ordinary users, not just programmers + +![wysiwyg](https://static-docs.nocobase.com/wysiwyg.gif) + +### 4. Everything is a plugin, designed for extension +Adding more no-code features will never cover every business case. NocoBase is built for extension through its plugin-based microkernel architecture. + +- All functionalities are plugins, similar to WordPress +- Plugins are ready to use upon installation +- Pages, blocks, actions, APIs, and data sources can all be extended through custom plugins + +![plugins](https://static-docs.nocobase.com/plugins.png) + +## Installation + +NocoBase supports three installation methods: + +- Installing With Docker (👍Recommended) + + Suitable for no-code scenarios, no code to write. When upgrading, just download the latest image and reboot. + +- Installing from create-nocobase-app CLI + + The business code of the project is completely independent and supports low-code development. + +- Installing from Git source code + + If you want to experience the latest unreleased version, or want to participate in the contribution, you need to make changes and debug on the source code, it is recommended to choose this installation method, which requires a high level of development skills, and if the code has been updated, you can git pull the latest code. + +## How NocoBase works + + diff --git a/packages/core/cli/bin/index.js b/packages/core/cli-v1/bin/index.js similarity index 100% rename from packages/core/cli/bin/index.js rename to packages/core/cli-v1/bin/index.js diff --git a/packages/core/cli/nocobase.conf.tpl b/packages/core/cli-v1/nocobase.conf.tpl similarity index 100% rename from packages/core/cli/nocobase.conf.tpl rename to packages/core/cli-v1/nocobase.conf.tpl diff --git a/packages/core/cli-v1/package.json b/packages/core/cli-v1/package.json new file mode 100644 index 00000000000..763117026d7 --- /dev/null +++ b/packages/core/cli-v1/package.json @@ -0,0 +1,37 @@ +{ + "name": "@nocobase/cli-v1", + "version": "2.1.0-alpha.16", + "description": "", + "license": "Apache-2.0", + "main": "./src/index.js", + "bin": { + "nocobase-v1": "./bin/index.js" + }, + "dependencies": { + "@nocobase/license-kit": "^0.3.8", + "@types/fs-extra": "^11.0.1", + "@umijs/utils": "3.5.20", + "chalk": "^4.1.1", + "commander": "^9.2.0", + "deepmerge": "^4.3.1", + "dotenv": "^16.0.0", + "execa": "^5.1.1", + "fast-glob": "^3.3.1", + "fs-extra": "^11.1.1", + "p-all": "3.0.0", + "pm2": "^6.0.5", + "portfinder": "^1.0.28", + "tar": "^7.4.3", + "tree-kill": "^1.2.2", + "tsx": "^4.19.0" + }, + "devDependencies": { + "@nocobase/devtools": "2.1.0-alpha.16" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nocobase/nocobase.git", + "directory": "packages/core/cli" + }, + "gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1" +} diff --git a/packages/core/cli/src/cli.js b/packages/core/cli-v1/src/cli.js similarity index 100% rename from packages/core/cli/src/cli.js rename to packages/core/cli-v1/src/cli.js diff --git a/packages/core/cli/src/commands/benchmark.js b/packages/core/cli-v1/src/commands/benchmark.js similarity index 100% rename from packages/core/cli/src/commands/benchmark.js rename to packages/core/cli-v1/src/commands/benchmark.js diff --git a/packages/core/cli/src/commands/build.js b/packages/core/cli-v1/src/commands/build.js similarity index 95% rename from packages/core/cli/src/commands/build.js rename to packages/core/cli-v1/src/commands/build.js index a5198f4fc24..3c7de33c081 100644 --- a/packages/core/cli/src/commands/build.js +++ b/packages/core/cli-v1/src/commands/build.js @@ -76,6 +76,8 @@ module.exports = (cli) => { options.retry ? '--retry' : '', ]); buildIndexHtml(true); - await buildClientV2(); + if (options.packages && !options.packages.includes('@nocobase/app')) { + await buildClientV2(); + } }); }; diff --git a/packages/core/cli/src/commands/clean.js b/packages/core/cli-v1/src/commands/clean.js similarity index 100% rename from packages/core/cli/src/commands/clean.js rename to packages/core/cli-v1/src/commands/clean.js diff --git a/packages/core/cli/src/commands/client.js b/packages/core/cli-v1/src/commands/client.js similarity index 100% rename from packages/core/cli/src/commands/client.js rename to packages/core/cli-v1/src/commands/client.js diff --git a/packages/core/cli/src/commands/create-nginx-conf.js b/packages/core/cli-v1/src/commands/create-nginx-conf.js similarity index 100% rename from packages/core/cli/src/commands/create-nginx-conf.js rename to packages/core/cli-v1/src/commands/create-nginx-conf.js diff --git a/packages/core/cli/src/commands/create-plugin.js b/packages/core/cli-v1/src/commands/create-plugin.js similarity index 100% rename from packages/core/cli/src/commands/create-plugin.js rename to packages/core/cli-v1/src/commands/create-plugin.js diff --git a/packages/core/cli/src/commands/dev.js b/packages/core/cli-v1/src/commands/dev.js similarity index 100% rename from packages/core/cli/src/commands/dev.js rename to packages/core/cli-v1/src/commands/dev.js diff --git a/packages/core/cli/src/commands/doc.js b/packages/core/cli-v1/src/commands/doc.js similarity index 100% rename from packages/core/cli/src/commands/doc.js rename to packages/core/cli-v1/src/commands/doc.js diff --git a/packages/core/cli/src/commands/e2e.js b/packages/core/cli-v1/src/commands/e2e.js similarity index 100% rename from packages/core/cli/src/commands/e2e.js rename to packages/core/cli-v1/src/commands/e2e.js diff --git a/packages/core/cli/src/commands/global.js b/packages/core/cli-v1/src/commands/global.js similarity index 100% rename from packages/core/cli/src/commands/global.js rename to packages/core/cli-v1/src/commands/global.js diff --git a/packages/core/cli/src/commands/index.js b/packages/core/cli-v1/src/commands/index.js similarity index 100% rename from packages/core/cli/src/commands/index.js rename to packages/core/cli-v1/src/commands/index.js diff --git a/packages/core/cli/src/commands/instance-id.js b/packages/core/cli-v1/src/commands/instance-id.js similarity index 100% rename from packages/core/cli/src/commands/instance-id.js rename to packages/core/cli-v1/src/commands/instance-id.js diff --git a/packages/core/cli/src/commands/locale.js b/packages/core/cli-v1/src/commands/locale.js similarity index 100% rename from packages/core/cli/src/commands/locale.js rename to packages/core/cli-v1/src/commands/locale.js diff --git a/packages/core/cli/src/commands/locale/cronstrue.js b/packages/core/cli-v1/src/commands/locale/cronstrue.js similarity index 100% rename from packages/core/cli/src/commands/locale/cronstrue.js rename to packages/core/cli-v1/src/commands/locale/cronstrue.js diff --git a/packages/core/cli/src/commands/locale/react-js-cron/en-US.json b/packages/core/cli-v1/src/commands/locale/react-js-cron/en-US.json similarity index 100% rename from packages/core/cli/src/commands/locale/react-js-cron/en-US.json rename to packages/core/cli-v1/src/commands/locale/react-js-cron/en-US.json diff --git a/packages/core/cli/src/commands/locale/react-js-cron/index.js b/packages/core/cli-v1/src/commands/locale/react-js-cron/index.js similarity index 100% rename from packages/core/cli/src/commands/locale/react-js-cron/index.js rename to packages/core/cli-v1/src/commands/locale/react-js-cron/index.js diff --git a/packages/core/cli/src/commands/locale/react-js-cron/zh-CN.json b/packages/core/cli-v1/src/commands/locale/react-js-cron/zh-CN.json similarity index 100% rename from packages/core/cli/src/commands/locale/react-js-cron/zh-CN.json rename to packages/core/cli-v1/src/commands/locale/react-js-cron/zh-CN.json diff --git a/packages/core/cli/src/commands/locale/react-js-cron/zh-TW.json b/packages/core/cli-v1/src/commands/locale/react-js-cron/zh-TW.json similarity index 100% rename from packages/core/cli/src/commands/locale/react-js-cron/zh-TW.json rename to packages/core/cli-v1/src/commands/locale/react-js-cron/zh-TW.json diff --git a/packages/core/cli/src/commands/p-test.js b/packages/core/cli-v1/src/commands/p-test.js similarity index 100% rename from packages/core/cli/src/commands/p-test.js rename to packages/core/cli-v1/src/commands/p-test.js diff --git a/packages/core/cli/src/commands/perf.js b/packages/core/cli-v1/src/commands/perf.js similarity index 100% rename from packages/core/cli/src/commands/perf.js rename to packages/core/cli-v1/src/commands/perf.js diff --git a/packages/core/cli/src/commands/pkg.js b/packages/core/cli-v1/src/commands/pkg.js similarity index 100% rename from packages/core/cli/src/commands/pkg.js rename to packages/core/cli-v1/src/commands/pkg.js diff --git a/packages/core/cli/src/commands/pm2.js b/packages/core/cli-v1/src/commands/pm2.js similarity index 100% rename from packages/core/cli/src/commands/pm2.js rename to packages/core/cli-v1/src/commands/pm2.js diff --git a/packages/core/cli/src/commands/postinstall.js b/packages/core/cli-v1/src/commands/postinstall.js similarity index 100% rename from packages/core/cli/src/commands/postinstall.js rename to packages/core/cli-v1/src/commands/postinstall.js diff --git a/packages/core/cli/src/commands/start.js b/packages/core/cli-v1/src/commands/start.js similarity index 100% rename from packages/core/cli/src/commands/start.js rename to packages/core/cli-v1/src/commands/start.js diff --git a/packages/core/cli/src/commands/tar.js b/packages/core/cli-v1/src/commands/tar.js similarity index 100% rename from packages/core/cli/src/commands/tar.js rename to packages/core/cli-v1/src/commands/tar.js diff --git a/packages/core/cli/src/commands/test-coverage.js b/packages/core/cli-v1/src/commands/test-coverage.js similarity index 100% rename from packages/core/cli/src/commands/test-coverage.js rename to packages/core/cli-v1/src/commands/test-coverage.js diff --git a/packages/core/cli/src/commands/test.js b/packages/core/cli-v1/src/commands/test.js similarity index 100% rename from packages/core/cli/src/commands/test.js rename to packages/core/cli-v1/src/commands/test.js diff --git a/packages/core/cli/src/commands/umi.js b/packages/core/cli-v1/src/commands/umi.js similarity index 100% rename from packages/core/cli/src/commands/umi.js rename to packages/core/cli-v1/src/commands/umi.js diff --git a/packages/core/cli/src/commands/update-deps.js b/packages/core/cli-v1/src/commands/update-deps.js similarity index 91% rename from packages/core/cli/src/commands/update-deps.js rename to packages/core/cli-v1/src/commands/update-deps.js index 65503610bf2..943ec90eb0c 100644 --- a/packages/core/cli/src/commands/update-deps.js +++ b/packages/core/cli-v1/src/commands/update-deps.js @@ -44,7 +44,7 @@ module.exports = (cli) => { } else if (pkg.version.includes('beta')) { distTag = 'beta'; } - const { stdout } = await run('npm', ['info', `@nocobase/cli@${distTag}`, 'version'], { + const { stdout } = await run('npm', ['info', `@nocobase/app@${distTag}`, 'version'], { stdio: 'pipe', }); if (!options.force && pkg.version === stdout) { @@ -56,8 +56,8 @@ module.exports = (cli) => { const descJson = await readJSON(descPath, 'utf8'); const sourcePath = resolve(__dirname, '../../templates/create-app-package.json'); const sourceJson = await readJSON(sourcePath, 'utf8'); - if (descJson['dependencies']?.['@nocobase/cli']) { - descJson['dependencies']['@nocobase/cli'] = stdout; + if (descJson['dependencies']?.['@nocobase/app']) { + descJson['dependencies']['@nocobase/app'] = stdout; } if (descJson['devDependencies']?.['@nocobase/devtools']) { descJson['devDependencies']['@nocobase/devtools'] = stdout; diff --git a/packages/core/cli/src/commands/upgrade.js b/packages/core/cli-v1/src/commands/upgrade.js similarity index 100% rename from packages/core/cli/src/commands/upgrade.js rename to packages/core/cli-v1/src/commands/upgrade.js diff --git a/packages/core/cli/src/commands/view-license-key.js b/packages/core/cli-v1/src/commands/view-license-key.js similarity index 100% rename from packages/core/cli/src/commands/view-license-key.js rename to packages/core/cli-v1/src/commands/view-license-key.js diff --git a/packages/core/cli/src/index.js b/packages/core/cli-v1/src/index.js similarity index 100% rename from packages/core/cli/src/index.js rename to packages/core/cli-v1/src/index.js diff --git a/packages/core/cli/src/license.js b/packages/core/cli-v1/src/license.js similarity index 100% rename from packages/core/cli/src/license.js rename to packages/core/cli-v1/src/license.js diff --git a/packages/core/cli/src/logger.js b/packages/core/cli-v1/src/logger.js similarity index 100% rename from packages/core/cli/src/logger.js rename to packages/core/cli-v1/src/logger.js diff --git a/packages/core/cli/src/plugin-generator.js b/packages/core/cli-v1/src/plugin-generator.js similarity index 100% rename from packages/core/cli/src/plugin-generator.js rename to packages/core/cli-v1/src/plugin-generator.js diff --git a/packages/core/cli/src/util.js b/packages/core/cli-v1/src/util.js similarity index 100% rename from packages/core/cli/src/util.js rename to packages/core/cli-v1/src/util.js diff --git a/packages/core/cli/templates/bundle-status.html b/packages/core/cli-v1/templates/bundle-status.html similarity index 100% rename from packages/core/cli/templates/bundle-status.html rename to packages/core/cli-v1/templates/bundle-status.html diff --git a/packages/core/cli/templates/create-app-package.json b/packages/core/cli-v1/templates/create-app-package.json similarity index 61% rename from packages/core/cli/templates/create-app-package.json rename to packages/core/cli-v1/templates/create-app-package.json index 8501a95d685..bd4c5510049 100644 --- a/packages/core/cli/templates/create-app-package.json +++ b/packages/core/cli-v1/templates/create-app-package.json @@ -5,17 +5,17 @@ "node": ">=18" }, "scripts": { - "nocobase": "nocobase", - "pm": "nocobase pm", - "pm2": "nocobase pm2", - "dev": "nocobase dev", - "start": "nocobase start", - "clean": "nocobase clean", - "build": "nocobase build", - "test": "nocobase test", - "e2e": "nocobase e2e", - "tar": "nocobase tar", - "postinstall": "nocobase postinstall", + "nocobase": "nocobase-v1", + "pm": "nocobase-v1 pm", + "pm2": "nocobase-v1 pm2", + "dev": "nocobase-v1 dev", + "start": "nocobase-v1 start", + "clean": "nocobase-v1 clean", + "build": "nocobase-v1 build", + "test": "nocobase-v1 test", + "e2e": "nocobase-v1 e2e", + "tar": "nocobase-v1 tar", + "postinstall": "nocobase-v1 postinstall", "lint": "eslint ." }, "resolutions": { diff --git a/packages/core/cli/templates/plugin/.npmignore.tpl b/packages/core/cli-v1/templates/plugin/.npmignore.tpl similarity index 100% rename from packages/core/cli/templates/plugin/.npmignore.tpl rename to packages/core/cli-v1/templates/plugin/.npmignore.tpl diff --git a/packages/core/cli/templates/plugin/README.md.tpl b/packages/core/cli-v1/templates/plugin/README.md.tpl similarity index 100% rename from packages/core/cli/templates/plugin/README.md.tpl rename to packages/core/cli-v1/templates/plugin/README.md.tpl diff --git a/packages/core/cli/templates/plugin/client-v2.d.ts b/packages/core/cli-v1/templates/plugin/client-v2.d.ts similarity index 100% rename from packages/core/cli/templates/plugin/client-v2.d.ts rename to packages/core/cli-v1/templates/plugin/client-v2.d.ts diff --git a/packages/core/cli/templates/plugin/client-v2.js b/packages/core/cli-v1/templates/plugin/client-v2.js similarity index 100% rename from packages/core/cli/templates/plugin/client-v2.js rename to packages/core/cli-v1/templates/plugin/client-v2.js diff --git a/packages/core/cli/templates/plugin/client.d.ts b/packages/core/cli-v1/templates/plugin/client.d.ts similarity index 100% rename from packages/core/cli/templates/plugin/client.d.ts rename to packages/core/cli-v1/templates/plugin/client.d.ts diff --git a/packages/core/cli/templates/plugin/client.js b/packages/core/cli-v1/templates/plugin/client.js similarity index 100% rename from packages/core/cli/templates/plugin/client.js rename to packages/core/cli-v1/templates/plugin/client.js diff --git a/packages/core/cli/templates/plugin/package.json.tpl b/packages/core/cli-v1/templates/plugin/package.json.tpl similarity index 100% rename from packages/core/cli/templates/plugin/package.json.tpl rename to packages/core/cli-v1/templates/plugin/package.json.tpl diff --git a/packages/core/cli/templates/plugin/server.d.ts b/packages/core/cli-v1/templates/plugin/server.d.ts similarity index 100% rename from packages/core/cli/templates/plugin/server.d.ts rename to packages/core/cli-v1/templates/plugin/server.d.ts diff --git a/packages/core/cli/templates/plugin/server.js b/packages/core/cli-v1/templates/plugin/server.js similarity index 100% rename from packages/core/cli/templates/plugin/server.js rename to packages/core/cli-v1/templates/plugin/server.js diff --git a/packages/core/cli/templates/plugin/src/client-v2/client.d.ts b/packages/core/cli-v1/templates/plugin/src/client-v2/client.d.ts similarity index 100% rename from packages/core/cli/templates/plugin/src/client-v2/client.d.ts rename to packages/core/cli-v1/templates/plugin/src/client-v2/client.d.ts diff --git a/packages/core/cli/templates/plugin/src/client-v2/index.tsx.tpl b/packages/core/cli-v1/templates/plugin/src/client-v2/index.tsx.tpl similarity index 100% rename from packages/core/cli/templates/plugin/src/client-v2/index.tsx.tpl rename to packages/core/cli-v1/templates/plugin/src/client-v2/index.tsx.tpl diff --git a/packages/core/cli/templates/plugin/src/client-v2/plugin.tsx.tpl b/packages/core/cli-v1/templates/plugin/src/client-v2/plugin.tsx.tpl similarity index 100% rename from packages/core/cli/templates/plugin/src/client-v2/plugin.tsx.tpl rename to packages/core/cli-v1/templates/plugin/src/client-v2/plugin.tsx.tpl diff --git a/packages/core/cli/templates/plugin/src/client/client.d.ts b/packages/core/cli-v1/templates/plugin/src/client/client.d.ts similarity index 100% rename from packages/core/cli/templates/plugin/src/client/client.d.ts rename to packages/core/cli-v1/templates/plugin/src/client/client.d.ts diff --git a/packages/core/cli/templates/plugin/src/client/index.tsx.tpl b/packages/core/cli-v1/templates/plugin/src/client/index.tsx.tpl similarity index 100% rename from packages/core/cli/templates/plugin/src/client/index.tsx.tpl rename to packages/core/cli-v1/templates/plugin/src/client/index.tsx.tpl diff --git a/packages/core/cli/templates/plugin/src/client/locale.ts b/packages/core/cli-v1/templates/plugin/src/client/locale.ts similarity index 93% rename from packages/core/cli/templates/plugin/src/client/locale.ts rename to packages/core/cli-v1/templates/plugin/src/client/locale.ts index 9f256d1b5c8..b7e5e71bf75 100644 --- a/packages/core/cli/templates/plugin/src/client/locale.ts +++ b/packages/core/cli-v1/templates/plugin/src/client/locale.ts @@ -9,7 +9,7 @@ import { tExpr as _tExpr, useFlowEngine } from '@nocobase/flow-engine'; // @ts-ignore -import pkg from './../../package.json'; +import pkg from '../../package.json'; export function useT() { const engine = useFlowEngine(); diff --git a/packages/core/cli/templates/plugin/src/client/models/index.ts b/packages/core/cli-v1/templates/plugin/src/client/models/index.ts similarity index 100% rename from packages/core/cli/templates/plugin/src/client/models/index.ts rename to packages/core/cli-v1/templates/plugin/src/client/models/index.ts diff --git a/packages/core/cli/templates/plugin/src/client/plugin.tsx.tpl b/packages/core/cli-v1/templates/plugin/src/client/plugin.tsx.tpl similarity index 100% rename from packages/core/cli/templates/plugin/src/client/plugin.tsx.tpl rename to packages/core/cli-v1/templates/plugin/src/client/plugin.tsx.tpl diff --git a/packages/core/cli/templates/plugin/src/index.ts b/packages/core/cli-v1/templates/plugin/src/index.ts similarity index 100% rename from packages/core/cli/templates/plugin/src/index.ts rename to packages/core/cli-v1/templates/plugin/src/index.ts diff --git a/packages/core/cli/templates/plugin/src/locale/en-US.json b/packages/core/cli-v1/templates/plugin/src/locale/en-US.json similarity index 100% rename from packages/core/cli/templates/plugin/src/locale/en-US.json rename to packages/core/cli-v1/templates/plugin/src/locale/en-US.json diff --git a/packages/core/cli/templates/plugin/src/locale/zh-CN.json b/packages/core/cli-v1/templates/plugin/src/locale/zh-CN.json similarity index 100% rename from packages/core/cli/templates/plugin/src/locale/zh-CN.json rename to packages/core/cli-v1/templates/plugin/src/locale/zh-CN.json diff --git a/packages/core/cli/templates/plugin/src/server/collections/.gitkeep b/packages/core/cli-v1/templates/plugin/src/server/collections/.gitkeep similarity index 100% rename from packages/core/cli/templates/plugin/src/server/collections/.gitkeep rename to packages/core/cli-v1/templates/plugin/src/server/collections/.gitkeep diff --git a/packages/core/cli/templates/plugin/src/server/index.ts.tpl b/packages/core/cli-v1/templates/plugin/src/server/index.ts.tpl similarity index 100% rename from packages/core/cli/templates/plugin/src/server/index.ts.tpl rename to packages/core/cli-v1/templates/plugin/src/server/index.ts.tpl diff --git a/packages/core/cli/templates/plugin/src/server/plugin.ts.tpl b/packages/core/cli-v1/templates/plugin/src/server/plugin.ts.tpl similarity index 100% rename from packages/core/cli/templates/plugin/src/server/plugin.ts.tpl rename to packages/core/cli-v1/templates/plugin/src/server/plugin.ts.tpl diff --git a/packages/core/cli/.gitignore b/packages/core/cli/.gitignore new file mode 100644 index 00000000000..d3946b77f68 --- /dev/null +++ b/packages/core/cli/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +/dist/ +/lib/ + +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +/.nocobase/ +.env +.env.* +/yarn.lock diff --git a/packages/core/cli/README.md b/packages/core/cli/README.md index cb97e69c499..6204aed8e16 100644 --- a/packages/core/cli/README.md +++ b/packages/core/cli/README.md @@ -1,99 +1,170 @@ -# NocoBase +# NocoBase CTL - +NocoBase CTL is a command-line tool for managing and controlling NocoBase applications. Its relationship with multiple NocoBase App instances is shown below: -

-nocobase%2Fnocobase | Trendshift -NocoBase - Scalability-first, open-source no-code platform | Product Hunt -

+``` + +----------------------+ + | NocoBase CTL | + | Controller | + +----------------------+ + | + +----------------+----------------+ + | | | + v v v + +----------------+ +----------------+ +----------------+ + | NocoBase App | | NocoBase App | | NocoBase App | + | Dev | | Test | | Prod | + +----------------+ +----------------+ +----------------+ +``` -## What is NocoBase +NocoBase CTL combines: -NocoBase is the most extensible AI-powered no-code platform. -Total control. Infinite extensibility. AI collaboration. -Enable your team to adapt quickly and cut costs dramatically. -No years of development. No millions wasted. -Deploy NocoBase in minutes — and take control of everything. +- built-in commands for environment management and generic resource access +- runtime-generated commands loaded from your NocoBase application's Swagger schema -Homepage: -https://www.nocobase.com/ +This allows the CLI to stay aligned with the target application instead of relying on a fixed command list. -Online Demo: -https://demo.nocobase.com/new +## Quick Start -Documents: -https://docs.nocobase.com/ +Install NocoBase CTL globally: -Forum: -https://forum.nocobase.com/ +```bash +npm install -g @nocobase/ctl@latest +``` -Use Cases: -https://www.nocobase.com/en/blog/tags/customer-stories +Add an environment: -## Release Notes +```bash +nb env add --name local --base-url http://localhost:13000/api +``` -Our [blog](https://www.nocobase.com/en/blog/timeline) is regularly updated with release notes and provides a weekly summary. +Add an environment with an API key: -## Distinctive features +```bash +nb env add --name local --base-url http://localhost:13000/api --token +``` -### 1. Data model-driven, not form/table–driven +Authenticate an environment with OAuth: -Instead of being constrained by forms or tables, NocoBase adopts a data model–driven approach, separating data structure from user interface to unlock unlimited possibilities. +```bash +nb env auth -e local +``` -- UI and data structure are fully decoupled -- Multiple blocks and actions can be created for the same table or record in any quantity or form -- Supports the main database, external databases, and third-party APIs as data sources +Show the current environment: -![model](https://static-docs.nocobase.com/model.png) +```bash +nb env +``` -### 2. AI employees, integrated into your business systems -Unlike standalone AI demos, NocoBase allows you to embed AI capabilities seamlessly into your interfaces, workflows, and data context, making AI truly useful in real business scenarios. +List configured environments: -- Define AI employees for roles such as translator, analyst, researcher, or assistant -- Seamless AI–human collaboration in interfaces and workflows -- Ensure AI usage is secure, transparent, and customizable for your business needs +```bash +nb env list +``` -![AI-employee](https://static-docs.nocobase.com/ai-employee-home.png) +Switch the current environment: -### 3. What you see is what you get, incredibly easy to use +```bash +nb env use local +``` -While enabling the development of complex business systems, NocoBase keeps the experience simple and intuitive. +Update the runtime command cache from `swagger:get`: -- One-click switch between usage mode and configuration mode -- Pages serve as a canvas to arrange blocks and actions, similar to Notion -- Configuration mode is designed for ordinary users, not just programmers +```bash +nb env update +nb env update -e local +``` -![wysiwyg](https://static-docs.nocobase.com/wysiwyg.gif) +Use the generic resource commands: -### 4. Everything is a plugin, designed for extension -Adding more no-code features will never cover every business case. NocoBase is built for extension through its plugin-based microkernel architecture. +```bash +nb api resource list --resource users +nb api resource get --resource users --filter-by-tk 1 +nb api resource create --resource users --values '{"nickname":"Ada"}' +``` -- All functionalities are plugins, similar to WordPress -- Plugins are ready to use upon installation -- Pages, blocks, actions, APIs, and data sources can all be extended through custom plugins +## Runtime Commands -![plugins](https://static-docs.nocobase.com/plugins.png) +When you execute a runtime command, the CLI will: -## Installation +1. resolve the target environment +2. read the application's Swagger schema from `swagger:get` +3. generate or reuse a cached runtime command set for that application version +4. execute the requested command -NocoBase supports three installation methods: +If the `API documentation plugin` is disabled, the CLI will prompt to enable it. -- Installing With Docker (👍Recommended) +## Environment Selection - Suitable for no-code scenarios, no code to write. When upgrading, just download the latest image and reboot. +Use `-e, --env` to temporarily select an environment: -- Installing from create-nocobase-app CLI +```bash +nb env update -e prod +nb api resource list --resource users -e prod +``` - The business code of the project is completely independent and supports low-code development. +This does not change the current environment unless you explicitly run: -- Installing from Git source code +```bash +nb env use +``` - If you want to experience the latest unreleased version, or want to participate in the contribution, you need to make changes and debug on the source code, it is recommended to choose this installation method, which requires a high level of development skills, and if the code has been updated, you can git pull the latest code. +## Config Scope -## How NocoBase works +The `env` command supports two config scopes: - +- `project`: use `./.nocobase-ctl` in the current working directory +- `global`: use the global `.nocobase-ctl` directory + +Use `-s, --scope` to select one explicitly: + +```bash +nb env list -s project +nb env add -s global --name prod --base-url http://example.com/api --token +nb env auth -e prod -s global +nb env use local -s project +``` + +If you do not pass `--scope`, the CLI uses automatic resolution: + +1. current working directory if `./.nocobase-ctl` exists +2. `NOCOBASE_HOME_CLI` +3. your home directory + +## Built-in Commands + +Current built-in topics: + +- `env` +- `api` + +Check available commands at any time: + +```bash +nb --help +nb env --help +nb api resource --help +``` + +## Common Flags + +- `-e, --env`: temporary environment selection +- `-s, --scope`: config scope for `env` commands +- `--role`: role override, sent as `X-Role` +- `-t, --token`: API key override +- `-j, --json-output`: print raw JSON response + +Example: + +```bash +nb env update -e prod -s global +nb api resource list --resource users -e prod -j +nb api resource list --resource users -e prod --role admin +``` + +## Local Data + +The CLI stores its local state in `.nocobase-ctl`, including: + +- `config.json`: environment definitions and current selection +- `versions//commands.json`: cached runtime commands for a generated version diff --git a/packages/core/cli/bin/run.cmd b/packages/core/cli/bin/run.cmd new file mode 100644 index 00000000000..968fc30758e --- /dev/null +++ b/packages/core/cli/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/packages/core/cli/bin/run.js b/packages/core/cli/bin/run.js new file mode 100755 index 00000000000..2754e6086ac --- /dev/null +++ b/packages/core/cli/bin/run.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const realRoot = fs.realpathSync(root); +const isSourcePackage = realRoot.split(path.sep).join('/').endsWith('/packages/core/cli'); + +/** + * In the monorepo, plain `node` cannot load `.ts`. Re-exec once with `--import tsx` + * (same effect as a dedicated dev entry with `#!/usr/bin/env -S node --import tsx`). + */ +function reexecWithTsx() { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', '--disable-warning=ExperimentalWarning', ...process.argv.slice(1)], + { + stdio: 'inherit', + env: { + ...process.env, + _NOCO_CLI_TSX_CHILD: '1', + NODE_ENV: 'development', + }, + }, + ); + process.exit(result.status === null ? 1 : result.status); +} + +if (isSourcePackage && !process.env._NOCO_CLI_TSX_CHILD) { + reexecWithTsx(); +} + +const bootstrapPath = isSourcePackage + ? path.join(root, 'src/lib/bootstrap.ts') + : path.join(root, 'dist/lib/bootstrap.js'); +const { ensureRuntimeFromArgv } = await import(pathToFileURL(bootstrapPath).href); +const { flush, run, settings } = await import('@oclif/core'); + +if (isSourcePackage) { + settings.debug = true; +} + +function getCommandToken(argv) { + for (const token of argv) { + if (!token || token.startsWith('-')) { + continue; + } + + return token; + } + + return undefined; +} + +function formatCliEntryError(error, argv) { + const message = error instanceof Error ? error.message : String(error); + const missingCommandMatch = message.match(/^Command (.+) not found\.$/); + if (missingCommandMatch) { + const commandToken = getCommandToken(argv) ?? missingCommandMatch[1]; + return [ + `Unknown command: \`${commandToken}\`.`, + 'If this is a built-in command or a typo, run `nb --help` to inspect available commands.', + `If \`${commandToken}\` should be a runtime command from your NocoBase app, run \`nb env update\` and try again.`, + ].join('\n'); + } + + return message; +} + +try { + const argv = process.argv.slice(2); + if (argv[0] === 'api') { + await ensureRuntimeFromArgv(argv, { + configFile: path.join(root, 'nocobase-ctl.config.json'), + }); + } + await run(argv, import.meta.url); + flush(); +} catch (error) { + const message = formatCliEntryError(error, process.argv.slice(2)); + console.error(message); + process.exitCode = 1; +} diff --git a/packages/core/cli/nocobase-ctl.config.json b/packages/core/cli/nocobase-ctl.config.json new file mode 100644 index 00000000000..b6dfad64d21 --- /dev/null +++ b/packages/core/cli/nocobase-ctl.config.json @@ -0,0 +1,327 @@ +{ + "moduleGroups": [ + { + "match": [ + "workflow*", + "@nocobase/plugin-workflow*" + ], + "module": "workflow" + }, + { + "match": [ + "@nocobase/plugin-data-source-main", + "@nocobase/plugin-data-source-manager" + ], + "module": "data-modeling" + } + ], + "modules": { + "core": { + "name": "core", + "description": "NocoBase core resource APIs.", + "include": true, + "resources": { + "includes": [ + "app", + "pm" + ], + "excludes": [], + "overrides": { + "app": { + "name": "app", + "description": "Application management commands.", + "topLevel": true + }, + "pm": { + "name": "pm", + "description": "Plugin manager commands.", + "topLevel": true + } + } + } + }, + "acl": { + "name": "acl", + "description": "Based on roles, resources, and actions, access control can precisely manage interface configuration permissions, data operation permissions, menu access permissions, and plugin permissions.", + "include": true, + "resources": { + "includes": [ + "availableActions", + "dataSources", + "roles", + "rolesResourcesScopes" + ], + "excludes": [], + "overrides": { + "availableActions": { + "name": "available-actions", + "description": "List and inspect configurable ACL actions.", + "topLevel": false + }, + "dataSources": { + "name": "data-sources", + "description": "Manage ACL strategies for data sources.", + "topLevel": false + }, + "roles": { + "name": "roles", + "description": "Manage roles and role-level ACL settings.", + "topLevel": false + }, + "rolesResourcesScopes": { + "name": "roles-resources-scopes", + "description": "Manage role resource scopes and permissions.", + "topLevel": false + } + } + } + }, + "api-keys": { + "name": "api-keys", + "description": "Allow users to access the HTTP API with API keys.", + "include": true, + "resources": { + "includes": [ + "apiKeys" + ], + "excludes": [], + "overrides": { + "apiKeys": { + "name": "api-keys", + "description": "Manage API keys.", + "topLevel": false + } + } + } + }, + "auth": { + "name": "authenticators", + "description": "User authentication management, including password auth, SMS auth, SSO protocols, and extensible providers.", + "include": true, + "resources": { + "includes": [ + "authenticators" + ], + "excludes": [], + "overrides": { + "auth": { + "name": "auth", + "description": "Authenticate users and manage auth sessions.", + "topLevel": false + }, + "authenticators": { + "name": "authenticators", + "description": "Manage authentication providers and authenticators.", + "topLevel": false + } + } + } + }, + "client": { + "name": "client", + "description": "Provide the web client interface for the NocoBase server.", + "include": true, + "resources": { + "includes": [ + "desktopRoutes", + "roles" + ], + "excludes": [], + "overrides": { + "desktopRoutes": { + "name": "desktop-routes", + "description": "Manage desktop route permissions and visibility.", + "topLevel": true + }, + "roles": { + "name": "roles", + "description": "Manage client permissions scoped by role.", + "topLevel": false + } + } + } + }, + "data-modeling": { + "name": "data-modeling", + "description": "Manage data sources, collections, and database modeling resources.", + "include": true, + "resources": { + "includes": [ + "collections", + "fields", + "dbViews" + ], + "excludes": [], + "overrides": { + "collections": { + "name": "collections", + "description": "Manage collections and collection metadata.", + "topLevel": false, + "operations": { + "includes": [ + "apply", + "collections:list", + "collections:get", + "collections:destroy", + "collections/{collectionName}/fields:list" + ] + } + }, + "fields": { + "name": "fields", + "description": "Manage fields with the compact high-level modeling interface.", + "topLevel": false, + "operations": { + "includes": [ + "apply" + ] + } + }, + "dbViews": { + "name": "db-views", + "description": "Inspect and query database views.", + "topLevel": false, + "operations": { + "includes": [ + "dbViews:list", + "dbViews:get" + ] + } + } + } + } + }, + "file-manager": { + "name": "file-manager", + "description": "Provide file storage services, file collections, and attachment fields.", + "include": true, + "resources": { + "includes": [ + "storages" + ], + "excludes": [], + "overrides": { + "storages": { + "name": "storages", + "description": "Manage storage backends and file storage settings.", + "topLevel": false + } + } + } + }, + "flow-engine": { + "name": "flow-engine", + "description": "Manage flow surface composition, configuration, layout, and mutation APIs.", + "include": true, + "resources": { + "includes": [ + "flowSurfaces" + ], + "excludes": [], + "overrides": { + "flowSurfaces": { + "name": "flow-surfaces", + "description": "Compose and mutate page, tab, block, field, and action surfaces.", + "topLevel": true + } + } + } + }, + "map": { + "name": "map", + "description": "Map blocks with support for AMap, Google Maps, and extensible providers.", + "include": true, + "resources": { + "includes": [ + "map-configuration" + ], + "excludes": [], + "overrides": { + "map-configuration": { + "name": "map-configuration", + "description": "Manage map provider configuration.", + "topLevel": false + } + } + } + }, + "system-settings": { + "name": "system-settings", + "description": "Adjust system title, logo, language, and other global settings.", + "include": true, + "resources": { + "includes": [ + "systemSettings" + ], + "excludes": [], + "overrides": { + "systemSettings": { + "name": "system-settings", + "description": "Manage global system settings.", + "topLevel": false + } + } + } + }, + "theme-editor": { + "name": "theme-editor", + "description": "Customize UI colors and dimensions, save themes, and switch between them.", + "include": true, + "resources": { + "includes": [ + "themeConfig" + ], + "excludes": [], + "overrides": { + "themeConfig": { + "name": "theme-config", + "description": "Manage theme configuration.", + "topLevel": false + } + } + } + }, + "workflow": { + "name": "workflow", + "description": "A powerful BPM tool that provides the foundation for business automation and extensible triggers and nodes.", + "include": true, + "resources": { + "includes": [ + "executions", + "flow_nodes", + "jobs", + "userWorkflowTasks", + "workflows" + ], + "excludes": [], + "overrides": { + "executions": { + "name": "executions", + "description": "Manage workflow execution records.", + "topLevel": false + }, + "flow_nodes": { + "name": "flow-nodes", + "description": "Manage workflow nodes.", + "topLevel": false + }, + "jobs": { + "name": "jobs", + "description": "Manage workflow jobs.", + "topLevel": false + }, + "userWorkflowTasks": { + "name": "user-workflow-tasks", + "description": "Query current user workflow tasks.", + "topLevel": false + }, + "workflows": { + "name": "workflows", + "description": "Manage workflows and workflow revisions.", + "topLevel": false + } + } + } + } + } +} diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index ea3e991cc92..0c12eca8a85 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,38 +1,62 @@ { "name": "@nocobase/cli", "version": "2.1.0-alpha.16", - "description": "", + "description": "NocoBase Command Line Tool", + "type": "module", + "main": "dist/generated/command-registry.js", + "scripts": { + "clean": "rm -rf dist", + "build": "yarn clean && tsc -p tsconfig.json", + "test": "node --import tsx --test ./test/*.test.ts" + }, + "keywords": [], + "author": "", "license": "Apache-2.0", - "main": "./src/index.js", + "files": [ + "bin", + "dist", + "nocobase-ctl.config.json" + ], "bin": { - "nocobase": "./bin/index.js" + "nb": "./bin/run.js" + }, + "oclif": { + "bin": "nb", + "helpOptions": { + "flagSortOrder": "none" + }, + "additionalHelpFlags": [ + "-h" + ], + "commands": { + "strategy": "explicit", + "target": "./dist/generated/command-registry.js" + }, + "dirname": "nb", + "topicSeparator": " ", + "topics": { + "env": { + "description": "Manage NocoBase project environments and update command runtimes." + }, + "api": { + "description": "Work with NocoBase API." + } + } }, "dependencies": { - "@nocobase/app": "2.1.0-alpha.16", - "@nocobase/license-kit": "^0.3.8", - "@types/fs-extra": "^11.0.1", - "@umijs/utils": "3.5.20", - "chalk": "^4.1.1", - "commander": "^9.2.0", - "deepmerge": "^4.3.1", - "dotenv": "^16.0.0", - "execa": "^5.1.1", - "fast-glob": "^3.3.1", - "fs-extra": "^11.1.1", - "p-all": "3.0.0", - "pm2": "^6.0.5", - "portfinder": "^1.0.28", - "tar": "^7.4.3", - "tree-kill": "^1.2.2", - "tsx": "^4.19.0" + "@apidevtools/swagger-parser": "^12.1.0", + "@oclif/core": "^4.10.4", + "openapi-types": "^12.1.3", + "ora": "^8.2.0", + "picocolors": "^1.1.1", + "typescript": "^6.0.2" }, "devDependencies": { - "@nocobase/devtools": "2.1.0-alpha.16" + "@types/node": "^18.19.130", + "tsx": "^4.20.6" }, "repository": { "type": "git", - "url": "git+https://github.com/nocobase/nocobase.git", - "directory": "packages/core/cli" - }, - "gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1" + "url": "git+https://github.com/nocobase/nocobase.git" + } } diff --git a/packages/core/cli/src/commands/api/index.ts b/packages/core/cli/src/commands/api/index.ts new file mode 100644 index 00000000000..ee11fc74b26 --- /dev/null +++ b/packages/core/cli/src/commands/api/index.ts @@ -0,0 +1,10 @@ +import { Command } from '@oclif/core'; + +export default class Api extends Command { + static summary = 'Work with NocoBase APIs, environments, resources, and runtime commands'; + static id = 'api'; + + async run(): Promise { + this.log('Use `nb api --help` to view available subcommands.'); + } +} diff --git a/packages/core/cli/src/commands/env/add.ts b/packages/core/cli/src/commands/env/add.ts new file mode 100644 index 00000000000..47a7b684546 --- /dev/null +++ b/packages/core/cli/src/commands/env/add.ts @@ -0,0 +1,61 @@ +import { Command, Flags } from '@oclif/core'; +import { upsertEnv } from '../../lib/auth-store.js'; +import { formatCliHomeScope, type CliHomeScope } from '../../lib/cli-home.js'; +import { isInteractiveTerminal, printVerbose, promptText, setVerboseMode } from '../../lib/ui.js'; + +export default class EnvAdd extends Command { + static summary = 'Add or update a NocoBase environment'; + static id = 'env add'; + + static flags = { + verbose: Flags.boolean({ + description: 'Show detailed progress output', + default: false, + }), + name: Flags.string({ + description: 'Environment name', + default: 'default', + }), + scope: Flags.string({ + char: 's', + description: 'Config scope', + options: ['project', 'global'], + }), + 'base-url': Flags.string({ + description: 'NocoBase API base URL, for example http://localhost:13000/api', + }), + token: Flags.string({ + char: 't', + description: 'API key', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(EnvAdd); + setVerboseMode(flags.verbose); + const name = flags.name || 'default'; + const scope = flags.scope as Exclude | undefined; + const baseUrl = + flags['base-url'] || + (isInteractiveTerminal() + ? await promptText('Base URL', { defaultValue: 'http://localhost:13000/api' }) + : ''); + + if (Object.keys(flags).includes('token') && !flags.token) { + flags.token = isInteractiveTerminal() ? await promptText('API key (optional)', { secret: true }) : ''; + if (!flags.token) { + this.error('API key cannot be empty if --token flag is provided without a value.'); + } + } + + const token = flags.token; + + if (!baseUrl) { + this.error('Missing base URL. Pass `--base-url ` or run in a TTY to enter it interactively.'); + } + + printVerbose(`Saving env "${name}" with base URL ${baseUrl}`); + await upsertEnv(name, baseUrl, token, { scope }); + this.log(`Saved env "${name}" and set it as current${scope ? ` in ${formatCliHomeScope(scope)} scope` : ''}.`); + } +} diff --git a/packages/core/cli/src/commands/env/auth.ts b/packages/core/cli/src/commands/env/auth.ts new file mode 100644 index 00000000000..a74e8957115 --- /dev/null +++ b/packages/core/cli/src/commands/env/auth.ts @@ -0,0 +1,40 @@ +import { Command, Flags } from '@oclif/core'; +import { type CliHomeScope, formatCliHomeScope } from '../../lib/cli-home.js'; +import { authenticateEnvWithOauth } from '../../lib/env-auth.js'; +import { failTask, startTask, succeedTask } from '../../lib/ui.js'; + +export default class EnvAuth extends Command { + static summary = 'Authenticate an environment with OAuth'; + static id = 'env auth'; + + static flags = { + env: Flags.string({ + char: 'e', + description: 'Environment name', + }), + scope: Flags.string({ + char: 's', + description: 'Config scope', + options: ['project', 'global'], + }), + }; + + async run(): Promise { + const { flags } = await this.parse(EnvAuth); + const scope = flags.scope as Exclude | undefined; + const envLabel = flags.env ?? 'current'; + + startTask(`Authenticating env: ${envLabel}${scope ? ` (${formatCliHomeScope(scope)})` : ''}`); + + try { + await authenticateEnvWithOauth({ + envName: flags.env, + scope, + }); + succeedTask(`Authenticated env "${envLabel}" with OAuth${scope ? ` in ${formatCliHomeScope(scope)} scope` : ''}.`); + } catch (error) { + failTask(`Failed to authenticate env "${envLabel}".`); + throw error; + } + } +} diff --git a/packages/core/cli/src/commands/env/index.ts b/packages/core/cli/src/commands/env/index.ts new file mode 100644 index 00000000000..64ec39e7cc6 --- /dev/null +++ b/packages/core/cli/src/commands/env/index.ts @@ -0,0 +1,37 @@ +import { Command, Flags } from '@oclif/core'; +import { getCurrentEnvName, getEnv } from '../../lib/auth-store.js'; +import { formatCliHomeScope, type CliHomeScope } from '../../lib/cli-home.js'; +import { renderTable } from '../../lib/ui.js'; + +export default class Env extends Command { + static summary = 'Show the current environment'; + static id = 'env'; + + static flags = { + scope: Flags.string({ + char: 's', + description: 'Config scope', + options: ['project', 'global'], + }), + }; + + async run(): Promise { + const { flags } = await this.parse(Env); + const scope = flags.scope as Exclude | undefined; + const envName = await getCurrentEnvName({ scope }); + const env = await getEnv(envName, { scope }); + + if (!env?.baseUrl) { + this.log(`No current env is configured${scope ? ` in ${formatCliHomeScope(scope)} scope` : ''}.`); + this.log('Run `nb env add --name --base-url ` to add one.'); + return; + } + + this.log( + renderTable( + ['Name', 'Base URL', 'Auth', 'Runtime'], + [[envName, env?.baseUrl ?? '', env?.auth?.type ?? '', env?.runtime?.version ?? '']], + ), + ); + } +} diff --git a/packages/core/cli/src/commands/env/list.ts b/packages/core/cli/src/commands/env/list.ts new file mode 100644 index 00000000000..012fea93343 --- /dev/null +++ b/packages/core/cli/src/commands/env/list.ts @@ -0,0 +1,37 @@ +import { Command, Flags } from '@oclif/core'; +import { listEnvs } from '../../lib/auth-store.js'; +import { formatCliHomeScope, type CliHomeScope } from '../../lib/cli-home.js'; +import { renderTable } from '../../lib/ui.js'; + +export default class EnvList extends Command { + static summary = 'List configured environments'; + static id = 'env list'; + + static flags = { + scope: Flags.string({ + char: 's', + description: 'Config scope', + options: ['project', 'global'], + }), + }; + + async run(): Promise { + const { flags } = await this.parse(EnvList); + const scope = flags.scope as Exclude | undefined; + const { currentEnv, envs } = await listEnvs({ scope }); + const names = Object.keys(envs).sort(); + + if (!names.length) { + this.log(`No envs configured${scope ? ` in ${formatCliHomeScope(scope)} scope` : ''}.`); + this.log('Run `nb env add --name --base-url ` to add one.'); + return; + } + + const rows = names.map((name) => { + const env = envs[name]; + return [name === currentEnv ? '*' : '', name, env.baseUrl ?? '', env.auth?.type ?? '', env.runtime?.version ?? '']; + }); + + this.log(renderTable(['Current', 'Name', 'Base URL', 'Auth', 'Runtime'], rows)); + } +} diff --git a/packages/core/cli/src/commands/env/remove.ts b/packages/core/cli/src/commands/env/remove.ts new file mode 100644 index 00000000000..9516f7c9fc0 --- /dev/null +++ b/packages/core/cli/src/commands/env/remove.ts @@ -0,0 +1,64 @@ +import { Args, Command, Flags } from '@oclif/core'; +import { getCurrentEnvName, removeEnv } from '../../lib/auth-store.js'; +import { formatCliHomeScope, type CliHomeScope } from '../../lib/cli-home.js'; +import { confirmAction, isInteractiveTerminal, printVerbose, setVerboseMode } from '../../lib/ui.js'; + +export default class EnvRemove extends Command { + static id = 'env remove'; + static summary = 'Remove a configured environment'; + + static flags = { + force: Flags.boolean({ + char: 'f', + description: 'Remove without confirmation', + default: false, + }), + verbose: Flags.boolean({ + description: 'Show detailed progress output', + default: false, + }), + scope: Flags.string({ + char: 's', + description: 'Config scope', + options: ['project', 'global'], + }), + }; + + static args = { + name: Args.string({ + description: 'Configured environment name', + required: true, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(EnvRemove); + setVerboseMode(flags.verbose); + const scope = flags.scope as Exclude | undefined; + const currentEnv = await getCurrentEnvName({ scope }); + + if (args.name === currentEnv && !flags.force) { + if (!isInteractiveTerminal()) { + this.error('Refusing to remove the current env without confirmation. Re-run with `--force`.'); + } + + const confirmed = await confirmAction(`Remove current env "${args.name}"?`, { defaultValue: false }); + if (!confirmed) { + this.log('Canceled.'); + return; + } + } + + printVerbose(`Removing env "${args.name}"`); + const result = await removeEnv(args.name, { scope }); + + this.log(`Removed env "${result.removed}"${scope ? ` from ${formatCliHomeScope(scope)} scope` : ''}.`); + + if (result.hasEnvs) { + this.log(`Current env: ${result.currentEnv}`); + return; + } + + this.log('No envs configured.'); + } +} diff --git a/packages/core/cli/src/commands/env/update.ts b/packages/core/cli/src/commands/env/update.ts new file mode 100644 index 00000000000..cc8d5f2dcbf --- /dev/null +++ b/packages/core/cli/src/commands/env/update.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Command, Flags } from '@oclif/core'; +import { updateEnvRuntime } from '../../lib/bootstrap.js'; +import { formatCliHomeScope, type CliHomeScope } from '../../lib/cli-home.js'; +import { failTask, startTask, succeedTask } from '../../lib/ui.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default class EnvUpdate extends Command { + static summary = 'Refresh an environment runtime from swagger:get and persist connection overrides'; + static id = 'env update'; + + static flags = { + verbose: Flags.boolean({ + description: 'Show detailed progress output', + default: false, + }), + env: Flags.string({ + char: 'e', + description: 'Environment name', + }), + scope: Flags.string({ + char: 's', + description: 'Config scope', + options: ['project', 'global'], + }), + 'base-url': Flags.string({ + description: 'NocoBase API base URL override. When provided, persist it to the target env before saving the refreshed runtime.', + }), + role: Flags.string({ + description: 'Role override, sent as X-Role', + }), + token: Flags.string({ + char: 't', + description: 'API key override. When provided, persist it to the target env before saving the refreshed runtime.', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(EnvUpdate); + const scope = flags.scope as Exclude | undefined; + const envLabel = flags.env ?? 'current'; + + startTask(`Updating env runtime: ${envLabel}${scope ? ` (${formatCliHomeScope(scope)})` : ''}`); + + try { + const runtime = await updateEnvRuntime({ + envName: flags.env, + scope, + baseUrl: flags['base-url'], + role: flags.role, + token: flags.token, + configFile: path.join(path.dirname(path.dirname(path.dirname(__dirname))), 'nocobase-ctl.config.json'), + verbose: flags.verbose, + }); + + succeedTask(`Updated env "${envLabel}" to runtime "${runtime.version}"${scope ? ` in ${formatCliHomeScope(scope)} scope` : ''}.`); + } catch (error) { + failTask(`Failed to update env "${envLabel}".`); + throw error; + } + } +} diff --git a/packages/core/cli/src/commands/env/use.ts b/packages/core/cli/src/commands/env/use.ts new file mode 100644 index 00000000000..025b6bc9bf7 --- /dev/null +++ b/packages/core/cli/src/commands/env/use.ts @@ -0,0 +1,30 @@ +import { Args, Command, Flags } from '@oclif/core'; +import { setCurrentEnv } from '../../lib/auth-store.js'; +import { formatCliHomeScope, type CliHomeScope } from '../../lib/cli-home.js'; + +export default class EnvUse extends Command { + static summary = 'Switch the current environment'; + static id = 'env use'; + + static flags = { + scope: Flags.string({ + char: 's', + description: 'Config scope', + options: ['project', 'global'], + }), + }; + + static args = { + name: Args.string({ + description: 'Configured environment name', + required: true, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(EnvUse); + const scope = flags.scope as Exclude | undefined; + await setCurrentEnv(args.name, { scope }); + this.log(`Current env: ${args.name}${scope ? ` (${formatCliHomeScope(scope)} scope)` : ''}`); + } +} diff --git a/packages/core/cli/src/commands/resource/create.ts b/packages/core/cli/src/commands/resource/create.ts new file mode 100644 index 00000000000..b813ee33c29 --- /dev/null +++ b/packages/core/cli/src/commands/resource/create.ts @@ -0,0 +1,21 @@ +import { Command } from '@oclif/core'; +import { buildCreateArgs, createFlags, runResourceCommand } from '../../lib/resource-command.js'; + +export default class ResourceCreate extends Command { + static summary = 'Create a record in a resource'; + + static description = + 'Create a record in a generic resource. Pass record content through --values as a JSON object.'; + + static examples = [ + `<%= config.bin %> <%= command.id %> --resource users --values '{"nickname":"Ada"}'`, + `<%= config.bin %> <%= command.id %> --resource posts.comments --source-id 1 --values '{"content":"Hello"}'`, + ]; + + static flags = createFlags; + + async run(): Promise { + const { flags } = await this.parse(ResourceCreate); + await runResourceCommand(this, 'create', flags, buildCreateArgs(flags)); + } +} diff --git a/packages/core/cli/src/commands/resource/destroy.ts b/packages/core/cli/src/commands/resource/destroy.ts new file mode 100644 index 00000000000..de4890e9053 --- /dev/null +++ b/packages/core/cli/src/commands/resource/destroy.ts @@ -0,0 +1,21 @@ +import { Command } from '@oclif/core'; +import { buildDestroyArgs, destroyFlags, runResourceCommand } from '../../lib/resource-command.js'; + +export default class ResourceDestroy extends Command { + static summary = 'Delete records from a resource'; + + static description = + 'Delete records from a generic resource. Target records with --filter-by-tk or --filter.'; + + static examples = [ + '<%= config.bin %> <%= command.id %> --resource users --filter-by-tk 1', + `<%= config.bin %> <%= command.id %> --resource posts --filter '{"status":"archived"}'`, + ]; + + static flags = destroyFlags; + + async run(): Promise { + const { flags } = await this.parse(ResourceDestroy); + await runResourceCommand(this, 'destroy', flags, buildDestroyArgs(flags)); + } +} diff --git a/packages/core/cli/src/commands/resource/get.ts b/packages/core/cli/src/commands/resource/get.ts new file mode 100644 index 00000000000..72a399c69fb --- /dev/null +++ b/packages/core/cli/src/commands/resource/get.ts @@ -0,0 +1,21 @@ +import { Command } from '@oclif/core'; +import { buildGetArgs, getFlags, runResourceCommand } from '../../lib/resource-command.js'; + +export default class ResourceGet extends Command { + static summary = 'Get a record from a resource'; + + static description = + 'Get a record from a generic resource. Use --filter-by-tk for the primary key and association resource names with --source-id when needed.'; + + static examples = [ + '<%= config.bin %> <%= command.id %> --resource users --filter-by-tk 1', + '<%= config.bin %> <%= command.id %> --resource posts.comments --source-id 1 --filter-by-tk 2', + ]; + + static flags = getFlags; + + async run(): Promise { + const { flags } = await this.parse(ResourceGet); + await runResourceCommand(this, 'get', flags, buildGetArgs(flags)); + } +} diff --git a/packages/core/cli/src/commands/resource/index.ts b/packages/core/cli/src/commands/resource/index.ts new file mode 100644 index 00000000000..6520f92a9f2 --- /dev/null +++ b/packages/core/cli/src/commands/resource/index.ts @@ -0,0 +1,9 @@ +import { Command } from '@oclif/core'; + +export default class Resource extends Command { + static summary = 'Work with generic collection resources'; + + async run(): Promise { + this.log('Use `nb api resource --help` to view available subcommands.'); + } +} diff --git a/packages/core/cli/src/commands/resource/list.ts b/packages/core/cli/src/commands/resource/list.ts new file mode 100644 index 00000000000..f331ac17591 --- /dev/null +++ b/packages/core/cli/src/commands/resource/list.ts @@ -0,0 +1,22 @@ +import { Command } from '@oclif/core'; +import { buildListArgs, listFlags, runResourceCommand } from '../../lib/resource-command.js'; + +export default class ResourceList extends Command { + static summary = 'List records from a resource'; + + static description = + 'List records from a generic resource. Use association resource names like posts.comments with --source-id when needed.'; + + static examples = [ + '<%= config.bin %> <%= command.id %> --resource users', + '<%= config.bin %> <%= command.id %> --resource posts.comments --source-id 1 --fields id --fields content', + `<%= config.bin %> <%= command.id %> --resource users --filter '{"status":"active"}' --sort=-createdAt`, + ]; + + static flags = listFlags; + + async run(): Promise { + const { flags } = await this.parse(ResourceList); + await runResourceCommand(this, 'list', flags, buildListArgs(flags)); + } +} diff --git a/packages/core/cli/src/commands/resource/query.ts b/packages/core/cli/src/commands/resource/query.ts new file mode 100644 index 00000000000..c44f3ae9b00 --- /dev/null +++ b/packages/core/cli/src/commands/resource/query.ts @@ -0,0 +1,21 @@ +import { Command } from '@oclif/core'; +import { buildQueryArgs, queryFlags, runResourceCommand } from '../../lib/resource-command.js'; + +export default class ResourceQuery extends Command { + static summary = 'Run an aggregate query on a resource'; + + static description = + 'Run an aggregate query on a generic resource. Pass measures, dimensions, and orders as JSON arrays.'; + + static examples = [ + `<%= config.bin %> <%= command.id %> --resource orders --measures '[{"field":["id"],"aggregation":"count","alias":"count"}]'`, + `<%= config.bin %> <%= command.id %> --resource orders --dimensions '[{"field":["status"],"alias":"status"}]' --orders '[{"field":["createdAt"],"order":"desc"}]'`, + ]; + + static flags = queryFlags; + + async run(): Promise { + const { flags } = await this.parse(ResourceQuery); + await runResourceCommand(this, 'query', flags, buildQueryArgs(flags)); + } +} diff --git a/packages/core/cli/src/commands/resource/update.ts b/packages/core/cli/src/commands/resource/update.ts new file mode 100644 index 00000000000..c6301d4de3d --- /dev/null +++ b/packages/core/cli/src/commands/resource/update.ts @@ -0,0 +1,21 @@ +import { Command } from '@oclif/core'; +import { buildUpdateArgs, runResourceCommand, updateFlags } from '../../lib/resource-command.js'; + +export default class ResourceUpdate extends Command { + static summary = 'Update records in a resource'; + + static description = + 'Update records in a generic resource. Target records with --filter-by-tk or --filter, and pass updated values through --values.'; + + static examples = [ + `<%= config.bin %> <%= command.id %> --resource users --filter-by-tk 1 --values '{"nickname":"Grace"}'`, + `<%= config.bin %> <%= command.id %> --resource posts --filter '{"status":"draft"}' --values '{"status":"published"}'`, + ]; + + static flags = updateFlags; + + async run(): Promise { + const { flags } = await this.parse(ResourceUpdate); + await runResourceCommand(this, 'update', flags, buildUpdateArgs(flags)); + } +} diff --git a/packages/core/cli/src/generated/command-registry.ts b/packages/core/cli/src/generated/command-registry.ts new file mode 100644 index 00000000000..dd573442efc --- /dev/null +++ b/packages/core/cli/src/generated/command-registry.ts @@ -0,0 +1,103 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Command } from '@oclif/core'; +import EnvAdd from '../commands/env/add.ts'; +import EnvAuth from '../commands/env/auth.ts'; +import Env from '../commands/env/index.ts'; +import EnvList from '../commands/env/list.ts'; +import EnvRemove from '../commands/env/remove.ts'; +import EnvUpdate from '../commands/env/update.ts'; +import EnvUse from '../commands/env/use.ts'; +import ResourceCreate from '../commands/resource/create.ts'; +import ResourceDestroy from '../commands/resource/destroy.ts'; +import ResourceGet from '../commands/resource/get.ts'; +import Resource from '../commands/resource/index.ts'; +import ResourceList from '../commands/resource/list.ts'; +import ResourceQuery from '../commands/resource/query.ts'; +import ResourceUpdate from '../commands/resource/update.ts'; +import { getCurrentEnvName, getEnv } from '../lib/auth-store.ts'; +import { createGeneratedFlags, GeneratedApiCommand } from '../lib/generated-command.ts'; +import { toKebabCase } from '../lib/naming.ts'; +import { loadRuntimeSync } from '../lib/runtime-store.ts'; + +function readEnvName(argv: string[]) { + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === '--env') { + return argv[index + 1]; + } + if (token === '-e') { + return argv[index + 1]; + } + if (token.startsWith('--env=')) { + return token.slice('--env='.length); + } + } + + return undefined; +} + +function createRuntimeCommand(operation: any) { + return class RuntimeCommand extends GeneratedApiCommand { + static summary = operation.summary; + static description = operation.description; + static examples = operation.examples as any; + static flags = createGeneratedFlags(operation); + static operation = operation; + }; +} + +function createRuntimeIndexCommand(commandId: string, operation: any) { + return class RuntimeIndexCommand extends Command { + static summary = operation.resourceDescription || operation.resourceDisplayName || `Work with ${commandId}`; + static description = operation.resourceDescription; + + async run(): Promise { + this.log(`Use \`nb ${commandId} --help\` to view available subcommands.`); + } + }; +} + +const registry: Record = { + // env: Env, + 'env:add': EnvAdd, + 'env:auth': EnvAuth, + 'env:list': EnvList, + 'env:remove': EnvRemove, + 'env:update': EnvUpdate, + 'env:use': EnvUse, + // 'api:resource': Resource, + 'api:resource:create': ResourceCreate, + 'api:resource:destroy': ResourceDestroy, + 'api:resource:get': ResourceGet, + 'api:resource:list': ResourceList, + 'api:resource:query': ResourceQuery, + 'api:resource:update': ResourceUpdate, +}; + +const envName = readEnvName(process.argv.slice(2)) ?? (await getCurrentEnvName()); +const env = await getEnv(envName); +const runtime = loadRuntimeSync(env?.runtime?.version); + +for (const operation of runtime?.commands ?? []) { + const commandSegments = operation.commandId.split(' '); + const commandKey = commandSegments.join(':'); + registry[`api:${commandKey}`] = createRuntimeCommand(operation); + + // const topLevelCommandId = commandSegments[0]; + // const modulePrefix = toKebabCase(operation.moduleDisplayName || operation.moduleName || ''); + // const isTopLevelResource = Boolean(topLevelCommandId && modulePrefix && topLevelCommandId !== modulePrefix); + + // if (isTopLevelResource && !registry[`api:${topLevelCommandId}`]) { + // registry[`api:${topLevelCommandId}`] = createRuntimeIndexCommand(`api ${topLevelCommandId}`, operation); + // } +} + +export default registry; diff --git a/packages/core/cli/src/lib/api-client.ts b/packages/core/cli/src/lib/api-client.ts new file mode 100644 index 00000000000..53f8ac09c0c --- /dev/null +++ b/packages/core/cli/src/lib/api-client.ts @@ -0,0 +1,288 @@ +import { promises as fs } from 'node:fs'; +import { resolveServerRequestTarget } from './env-auth.js'; + +export interface RequestParameter { + name: string; + flagName: string; + in: 'path' | 'query' | 'header' | 'cookie' | 'body'; + required?: boolean; + type?: string; + isArray?: boolean; + description?: string; + jsonEncoded?: boolean; +} + +export interface RequestOperation { + method: string; + pathTemplate: string; + parameters: RequestParameter[]; + hasBody?: boolean; + bodyRequired?: boolean; +} + +export interface RequestOptions { + envName?: string; + baseUrl?: string; + token?: string; + role?: string; + flags: Record; + operation: RequestOperation; +} + +export interface RawRequestOptions { + envName?: string; + baseUrl?: string; + token?: string; + role?: string; + method: string; + path: string; + query?: Record; + headers?: Record; + body?: unknown; +} + +function normalizeBaseUrl(baseUrl: string) { + return baseUrl.replace(/\/+$/, ''); +} + +async function parseResponse(response: Response) { + const text = await response.text(); + let data: unknown = text; + + if (text) { + try { + data = JSON.parse(text); + } catch (error) { + data = text; + } + } + + return { + ok: response.ok, + status: response.status, + data, + }; +} + +function parseScalarValue(value: any, type?: string) { + if (value === undefined) { + return undefined; + } + + if (type === 'boolean') { + return value; + } + + if (type === 'integer' || type === 'number') { + return Number(value); + } + + if (typeof value !== 'string') { + return value; + } + + const trimmed = value.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + return JSON.parse(trimmed); + } catch (error) { + return value; + } + } + + return value; +} + +function hasParameterValue(flags: Record, parameter: RequestParameter) { + const value = flags[parameter.flagName]; + if (parameter.type === 'boolean') { + return value !== undefined; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + return value !== undefined && value !== ''; +} + +function listProvidedBodyFlags(flags: Record, parameters: RequestParameter[]) { + return parameters + .filter((parameter) => hasParameterValue(flags, parameter)) + .map((parameter) => `--${parameter.flagName}`); +} + +export async function parseBody(flags: Record, operation: RequestOperation) { + const inlineBody = flags.body as string | undefined; + const bodyFile = flags['body-file'] as string | undefined; + const bodyParameters = operation.parameters.filter((parameter) => parameter.in === 'body'); + const hasBodyFlags = bodyParameters.some((parameter) => hasParameterValue(flags, parameter)); + + if ((inlineBody || bodyFile) && hasBodyFlags) { + const providedBodyFlags = listProvidedBodyFlags(flags, bodyParameters); + const rawBodyInput = inlineBody ? '--body' : '--body-file'; + throw new Error( + `Conflicting request body inputs: received ${rawBodyInput} together with body field flags (${providedBodyFlags.join(', ')}). Use either body field flags or --body/--body-file.`, + ); + } + + if (inlineBody) { + return JSON.parse(inlineBody); + } + + if (bodyFile) { + return fs.readFile(bodyFile as string, 'utf8').then((content: string) => JSON.parse(content)); + } + + if (!bodyParameters.length) { + return undefined; + } + + const body: Record = {}; + + for (const parameter of bodyParameters) { + const rawValue = flags[parameter.flagName]; + const value = parameter.isArray && !parameter.jsonEncoded + ? (Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : undefined) + : parseScalarValue(rawValue, parameter.type); + + if (parameter.required && (value === undefined || value === '')) { + throw new Error(`Missing required body field --${parameter.flagName}`); + } + + if (value === undefined) { + continue; + } + + body[parameter.name] = value; + } + + if (Object.keys(body).length > 0) { + return body; + } + + if (operation.hasBody && operation.bodyRequired) { + throw new Error('Missing request body. Use body field flags or --body/--body-file.'); + } + + return undefined; +} + +export async function executeApiRequest(options: RequestOptions) { + const { baseUrl, token } = await resolveServerRequestTarget(options); + + const headers = new Headers(); + if (token) { + headers.set('authorization', `Bearer ${token}`); + } + if (options.role) { + headers.set('x-role', options.role); + } + + const query = new URLSearchParams(); + let requestPath = options.operation.pathTemplate; + + for (const parameter of options.operation.parameters) { + if (parameter.in === 'body') { + continue; + } + + const rawValue = options.flags[parameter.flagName]; + const value = parameter.isArray + ? (Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : undefined) + : parseScalarValue(rawValue, parameter.type); + + if (parameter.required && (value === undefined || value === '')) { + throw new Error(`Missing required parameter --${parameter.flagName}`); + } + + if (value === undefined) { + continue; + } + + if (parameter.in === 'path') { + requestPath = requestPath.replace(`{${parameter.name}}`, encodeURIComponent(String(value))); + continue; + } + + if (parameter.in === 'query') { + if (Array.isArray(value)) { + value.forEach((item) => query.append(parameter.name, String(parseScalarValue(item, parameter.type)))); + } else if (typeof value === 'object') { + query.set(parameter.name, JSON.stringify(value)); + } else { + query.set(parameter.name, String(value)); + } + continue; + } + + if (parameter.in === 'header') { + headers.set(parameter.name, typeof value === 'object' ? JSON.stringify(value) : String(value)); + continue; + } + } + + const body = await parseBody(options.flags, options.operation); + if (body !== undefined) { + headers.set('content-type', 'application/json'); + } + + const url = new URL(`${normalizeBaseUrl(baseUrl)}${requestPath}`); + query.forEach((value, key) => url.searchParams.append(key, value)); + + const response = await fetch(url, { + method: options.operation.method.toUpperCase(), + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + return parseResponse(response); +} + +export async function executeRawApiRequest(options: RawRequestOptions) { + const { baseUrl, token } = await resolveServerRequestTarget(options); + + const headers = new Headers(); + if (token) { + headers.set('authorization', `Bearer ${token}`); + } + if (options.role) { + headers.set('x-role', options.role); + } + + for (const [name, value] of Object.entries(options.headers ?? {})) { + if (value === undefined || value === null || value === '') { + continue; + } + + headers.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + + if (options.body !== undefined) { + headers.set('content-type', 'application/json'); + } + + const url = new URL(`${normalizeBaseUrl(baseUrl)}${options.path}`); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(key, typeof item === 'object' ? JSON.stringify(item) : String(item)); + } + continue; + } + + url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value)); + } + + const response = await fetch(url, { + method: options.method.toUpperCase(), + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + + return parseResponse(response); +} diff --git a/packages/core/cli/src/lib/auth-store.ts b/packages/core/cli/src/lib/auth-store.ts new file mode 100644 index 00000000000..d283c69dca9 --- /dev/null +++ b/packages/core/cli/src/lib/auth-store.ts @@ -0,0 +1,251 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { CliHomeScope } from './cli-home.js'; +import { resolveCliHomeDir } from './cli-home.js'; + +export interface TokenAuthConfig { + type: 'token'; + accessToken: string; +} + +export interface OauthAuthConfig { + type: 'oauth'; + accessToken: string; + refreshToken?: string; + expiresAt?: string; + scope?: string; + issuer?: string; + clientId?: string; + resource?: string; +} + +export interface EnvConfigEntry { + baseUrl?: string; + auth?: TokenAuthConfig | OauthAuthConfig; + runtime?: { + version?: string; + schemaHash?: string; + generatedAt?: string; + }; +} + +export interface AuthConfig { + currentEnv?: string; + envs: Record; +} + +const DEFAULT_CONFIG: AuthConfig = { + currentEnv: 'default', + envs: {}, +}; + +export interface AuthStoreOptions { + scope?: CliHomeScope; +} + +function getConfigFile(options: AuthStoreOptions = {}) { + return path.join(resolveCliHomeDir(options.scope), 'config.json'); +} + +export async function loadAuthConfig(options: AuthStoreOptions = {}): Promise { + try { + const content = await fs.readFile(getConfigFile(options), 'utf8'); + const parsed = JSON.parse(content) as AuthConfig; + return { + currentEnv: parsed.currentEnv || 'default', + envs: parsed.envs || {}, + }; + } catch (_error) { + return DEFAULT_CONFIG; + } +} + +export async function saveAuthConfig(config: AuthConfig, options: AuthStoreOptions = {}) { + const filePath = getConfigFile(options); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify(config, null, 2)); +} + +export async function listEnvs(options: AuthStoreOptions = {}) { + const config = await loadAuthConfig(options); + return { + currentEnv: config.currentEnv || 'default', + envs: config.envs, + }; +} + +export async function getCurrentEnvName(options: AuthStoreOptions = {}) { + const config = await loadAuthConfig(options); + return config.currentEnv || 'default'; +} + +export async function setCurrentEnv(envName: string, options: AuthStoreOptions = {}) { + const config = await loadAuthConfig(options); + if (!config.envs[envName]) { + throw new Error(`Env "${envName}" is not configured`); + } + config.currentEnv = envName; + await saveAuthConfig(config, options); +} + +export async function getEnv(envName?: string, options: AuthStoreOptions = {}) { + const config = await loadAuthConfig(options); + const resolved = envName || config.currentEnv || 'default'; + return config.envs[resolved]; +} + +function areAuthConfigsEquivalent(left?: EnvConfigEntry['auth'], right?: EnvConfigEntry['auth']) { + if (!left && !right) { + return true; + } + + if (!left || !right || left.type !== right.type) { + return false; + } + + if (left.type === 'token' && right.type === 'token') { + return left.accessToken === right.accessToken; + } + + if (left.type === 'oauth' && right.type === 'oauth') { + return ( + left.accessToken === right.accessToken && + left.refreshToken === right.refreshToken && + left.expiresAt === right.expiresAt && + left.scope === right.scope && + left.issuer === right.issuer && + left.clientId === right.clientId && + left.resource === right.resource + ); + } + + return false; +} + +async function writeEnv( + envName: string, + updater: (previous: EnvConfigEntry | undefined) => EnvConfigEntry, + options: AuthStoreOptions = {}, +) { + const config = await loadAuthConfig(options); + const previous = config.envs[envName]; + config.envs[envName] = updater(previous); + config.currentEnv = envName; + await saveAuthConfig(config, options); +} + +export async function upsertEnv( + envName: string, + baseUrl: string, + accessToken?: string, + options: AuthStoreOptions = {}, +) { + await writeEnv( + envName, + (previous) => { + const baseUrlChanged = previous?.baseUrl !== baseUrl; + const nextAuth = accessToken + ? ({ + type: 'token', + accessToken, + } satisfies TokenAuthConfig) + : baseUrlChanged || previous?.auth?.type === 'token' + ? undefined + : previous?.auth; + const authChanged = !areAuthConfigsEquivalent(previous?.auth, nextAuth); + + return { + ...previous, + baseUrl, + auth: nextAuth, + runtime: baseUrlChanged || authChanged ? undefined : previous?.runtime, + }; + }, + options, + ); +} + +export async function updateEnvConnection( + envName: string, + updates: { baseUrl?: string; accessToken?: string }, + options: AuthStoreOptions = {}, +) { + await writeEnv( + envName, + (previous) => { + const nextBaseUrl = updates.baseUrl ?? previous?.baseUrl; + const baseUrlChanged = previous?.baseUrl !== nextBaseUrl; + const nextAuth = updates.accessToken + ? ({ + type: 'token', + accessToken: updates.accessToken, + } satisfies TokenAuthConfig) + : baseUrlChanged || previous?.auth?.type === 'token' + ? undefined + : previous?.auth; + const authChanged = !areAuthConfigsEquivalent(previous?.auth, nextAuth); + + return { + ...previous, + ...(nextBaseUrl !== undefined ? { baseUrl: nextBaseUrl } : {}), + auth: nextAuth, + runtime: baseUrlChanged || authChanged ? undefined : previous?.runtime, + }; + }, + options, + ); +} + +export async function setEnvOauthSession( + envName: string, + auth: OauthAuthConfig, + options: AuthStoreOptions & { preserveRuntime?: boolean } = {}, +) { + await writeEnv( + envName, + (previous) => ({ + ...previous, + auth, + runtime: options.preserveRuntime ? previous?.runtime : undefined, + }), + options, + ); +} + +export async function setEnvRuntime( + envName: string, + runtime: EnvConfigEntry['runtime'], + options: AuthStoreOptions = {}, +) { + const config = await loadAuthConfig(options); + const current = config.envs[envName] ?? {}; + config.envs[envName] = { + ...current, + runtime, + }; + config.currentEnv = envName; + await saveAuthConfig(config, options); +} + +export async function removeEnv(envName: string, options: AuthStoreOptions = {}) { + const config = await loadAuthConfig(options); + + if (!config.envs[envName]) { + throw new Error(`Env "${envName}" is not configured`); + } + + delete config.envs[envName]; + + if (config.currentEnv === envName) { + const nextEnv = Object.keys(config.envs).sort()[0]; + config.currentEnv = nextEnv ?? 'default'; + } + + await saveAuthConfig(config, options); + + return { + removed: envName, + currentEnv: config.currentEnv || 'default', + hasEnvs: Object.keys(config.envs).length > 0, + }; +} diff --git a/packages/core/cli/src/lib/bootstrap.ts b/packages/core/cli/src/lib/bootstrap.ts new file mode 100644 index 00000000000..b62133643d5 --- /dev/null +++ b/packages/core/cli/src/lib/bootstrap.ts @@ -0,0 +1,449 @@ +import { getCurrentEnvName, getEnv, setEnvRuntime, updateEnvConnection } from './auth-store.js'; +import type { CliHomeScope } from './cli-home.js'; +import { resolveAccessToken } from './env-auth.js'; +import { generateRuntime } from './runtime-generator.js'; +import { hasRuntimeSync, saveRuntime } from './runtime-store.js'; +import { confirmAction, printInfo, printVerbose, printWarning, setVerboseMode, stopTask, updateTask } from './ui.js'; + +const APP_RETRY_INTERVAL = 2000; +const APP_RETRY_TIMEOUT = 120000; + +function readFlag(argv: string[], name: string) { + const exact = `--${name}`; + const prefix = `--${name}=`; + const alias = name === 'env' ? '-e' : name === 'scope' ? '-s' : undefined; + + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === exact) { + return argv[index + 1]; + } + if (alias && value === alias) { + return argv[index + 1]; + } + if (value.startsWith(prefix)) { + return value.slice(prefix.length); + } + } + + return undefined; +} + +function hasBooleanFlag(argv: string[], name: string) { + const exact = `--${name}`; + const negated = `--no-${name}`; + const prefix = `--${name}=`; + const alias = name === 'verbose' ? '-V' : undefined; + + for (const value of argv) { + if (value === exact) { + return true; + } + + if (alias && value === alias) { + return true; + } + + if (value === negated) { + return false; + } + + if (value.startsWith(prefix)) { + return value.slice(prefix.length) !== 'false'; + } + } + + return false; +} + +function getCommandToken(argv: string[]) { + for (const token of argv) { + if (!token || token.startsWith('-')) { + continue; + } + return token; + } + + return undefined; +} + +function hasHelpFlag(argv: string[]) { + return argv.includes('--help') || argv.includes('-h'); +} + +function hasVersionFlag(argv: string[]) { + return argv.includes('--version') || argv.includes('-v'); +} + +function isBuiltinCommand(argv: string[]) { + const commandToken = getCommandToken(argv); + return commandToken === 'env' || commandToken === 'resource'; +} + +export function shouldSkipRuntimeBootstrap(argv: string[]) { + return hasVersionFlag(argv) || isBuiltinCommand(argv); +} + +async function requestJson(url: string, options: { method?: string; token?: string; role?: string }) { + const headers = new Headers(); + if (options.token) { + headers.set('authorization', `Bearer ${options.token}`); + } + if (options.role) { + headers.set('x-role', options.role); + } + + let response: Response; + try { + response = await fetch(url, { + method: options.method ?? 'GET', + headers, + }); + } catch (error: any) { + return { + status: 0, + ok: false, + data: { + error: { + message: error?.message ?? 'fetch failed', + }, + }, + }; + } + + const text = await response.text(); + let data: any = undefined; + + if (text) { + try { + data = JSON.parse(text); + } catch (error) { + data = text; + } + } + + return { + status: response.status, + ok: response.ok, + data, + }; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isAppRestarting(response: { status: number; data: any }) { + return response.status === 503 && response.data?.error?.code === 'APP_COMMANDING'; +} + +function shouldRetryAppAvailability(response: { status: number; data: any }) { + return isAppRestarting(response) || response.status === 0; +} + +function getSwaggerUrl(baseUrl: string) { + return `${baseUrl.replace(/\/+$/, '')}/swagger:get`; +} + +function getHealthCheckUrl(baseUrl: string) { + return `${baseUrl.replace(/\/+$/, '')}/__health_check`; +} + +async function waitForServiceReady(baseUrl: string, token?: string, role?: string) { + const healthCheckUrl = getHealthCheckUrl(baseUrl); + const startedAt = Date.now(); + let notified = false; + + while (Date.now() - startedAt < APP_RETRY_TIMEOUT) { + const response = await fetch(healthCheckUrl, { + method: 'GET', + headers: + token || role + ? { + ...(token ? { authorization: `Bearer ${token}` } : undefined), + ...(role ? { 'x-role': role } : undefined), + } + : undefined, + }).catch((error: any) => { + return { + ok: false, + status: 0, + text: async () => error?.message ?? 'fetch failed', + } as Response; + }); + + const text = await response.text(); + if (response.ok && text.trim().toLowerCase() === 'ok') { + return; + } + + if (!notified) { + printVerbose(`Waiting for health check: ${healthCheckUrl}`); + updateTask(`Waiting for application readiness (${healthCheckUrl})`); + notified = true; + } + + await sleep(APP_RETRY_INTERVAL); + } + + throw new Error(`The application did not become ready in time. Expected \`${healthCheckUrl}\` to respond with \`ok\`.`); +} + +async function waitForSwaggerSchema(baseUrl: string, token?: string, role?: string) { + const swaggerUrl = getSwaggerUrl(baseUrl); + const startedAt = Date.now(); + + printVerbose(`Checking swagger schema: ${swaggerUrl}`); + + while (Date.now() - startedAt < APP_RETRY_TIMEOUT) { + const response = await requestJson(swaggerUrl, { token, role }); + if (response.ok) { + return response; + } + + if (!shouldRetryAppAvailability(response)) { + return response; + } + + await waitForServiceReady(baseUrl, token, role); + } + + return await requestJson(swaggerUrl, { token, role }); +} + +async function confirmEnableApiDoc() { + return confirmAction('Enable the API documentation plugin now?', { defaultValue: false }); +} + +async function fetchSwaggerSchema( + baseUrl: string, + token?: string, + role?: string, + context: { + envName?: string; + commandToken?: string; + } = {}, + options: { + allowEnableApiDoc?: boolean; + retryAppAvailability?: boolean; + } = {}, +) { + let response = + options.retryAppAvailability === false + ? await requestJson(getSwaggerUrl(baseUrl), { token, role }) + : await waitForSwaggerSchema(baseUrl, token, role); + + if (response.status === 404) { + if (options.allowEnableApiDoc === false) { + throw new Error('`swagger:get` returned 404. Check the base URL and enable the `API documentation plugin` if needed.'); + } + + printInfo('The API documentation plugin is not enabled.'); + const shouldEnable = await confirmEnableApiDoc(); + if (!shouldEnable) { + throw new Error('`swagger:get` returned 404. Enable the `API documentation plugin` first.'); + } + + const enableUrl = `${baseUrl.replace(/\/+$/, '')}/pm:enable?filterByTk=api-doc`; + printVerbose(`Enabling API documentation plugin via ${enableUrl}`); + const enableResponse = await requestJson(enableUrl, { method: 'POST', token, role }); + if (!enableResponse.ok) { + throw new Error( + `Failed to enable the \`API documentation plugin\` via \`pm:enable\`.\n${JSON.stringify(enableResponse.data, null, 2)}`, + ); + } + + updateTask('Enabled the API documentation plugin. Waiting for application readiness...'); + await waitForServiceReady(baseUrl, token, role); + response = await waitForSwaggerSchema(baseUrl, token, role); + } + + if (!response.ok) { + throw new Error(formatSwaggerSchemaError(response, { baseUrl, token, ...context })); + } + + return (response.data?.data ?? response.data) as any; +} + +function collectErrorEntries(data: any) { + if (Array.isArray(data?.errors)) { + return data.errors.filter(Boolean); + } + + if (data?.error) { + return [data.error]; + } + + return []; +} + +function hasInvalidTokenError(data: any) { + return collectErrorEntries(data).some((entry) => entry?.code === 'INVALID_TOKEN'); +} + +export function formatSwaggerSchemaError( + response: { status: number; data: any }, + context: { baseUrl: string; token?: string; role?: string; envName?: string; commandToken?: string }, +) { + if (hasInvalidTokenError(response.data)) { + const entries = collectErrorEntries(response.data); + const details = entries + .map((entry) => { + const code = entry?.code ? `[${entry.code}] ` : ''; + return `${code}${entry?.message ?? 'Authentication failed.'}`; + }) + .join('\n'); + const envLabel = context.envName ? ` for env "${context.envName}"` : ''; + const commandHint = context.commandToken + ? `If \`${context.commandToken}\` is a runtime command, refresh the runtime after updating the token with \`nb env update\`. If it is a typo, run \`nb --help\` to inspect available commands.` + : 'Run `nb --help` to inspect built-in commands, then refresh runtime commands with `nb env update` after updating the token.'; + + return [ + `Authentication failed while loading the command runtime from \`swagger:get\`${envLabel}.`, + `Base URL: ${context.baseUrl}`, + details, + 'Update the API key with `nb env add --name --base-url --token `, log in with `nb env auth -e `, or rerun the command with `--token `.', + commandHint, + ].join('\n'); + } + + return `Failed to load swagger schema from \`swagger:get\`.\n${JSON.stringify(response.data, null, 2)}`; +} + +export function formatMissingRuntimeEnvError(commandToken?: string) { + if (!commandToken) { + return [ + 'No env is configured for runtime commands.', + 'Run `nb env add --name --base-url ` first.', + 'If you configure multiple environments later, switch with `nb env use `.', + ].join('\n'); + } + + return [ + `Unable to resolve runtime command \`${commandToken}\`.`, + 'No env is configured, so the CLI cannot load runtime commands from `swagger:get`.', + 'If this is a built-in command or a typo, run `nb --help` to inspect available commands.', + 'If this should be an application runtime command, run `nb env add --name --base-url ` and then `nb env update`.', + ].join('\n'); +} + +export async function ensureRuntimeFromArgv(argv: string[], options: { configFile: string }) { + const commandToken = getCommandToken(argv); + const isRootInvocation = !commandToken; + setVerboseMode(hasBooleanFlag(argv, 'verbose')); + + if (shouldSkipRuntimeBootstrap(argv)) { + return; + } + + const envName = readFlag(argv, 'env') ?? (await getCurrentEnvName()); + const env = await getEnv(envName); + const baseUrl = readFlag(argv, 'base-url') ?? env?.baseUrl; + const role = readFlag(argv, 'role'); + const token = await resolveAccessToken({ + envName, + baseUrl, + token: readFlag(argv, 'token'), + }); + const runtimeVersion = env?.runtime?.version; + + if (runtimeVersion && hasRuntimeSync(runtimeVersion)) { + return; + } + + if (!baseUrl) { + if (isRootInvocation) { + return; + } + throw new Error(formatMissingRuntimeEnvError(commandToken)); + } + + updateTask('Loading command runtime...'); + try { + printVerbose(`Runtime source: ${baseUrl}`); + const document = await fetchSwaggerSchema( + baseUrl, + token, + role, + { envName, commandToken }, + isRootInvocation + ? { + allowEnableApiDoc: false, + retryAppAvailability: false, + } + : undefined, + ); + const runtime = await generateRuntime(document, options.configFile, baseUrl); + await saveRuntime(runtime); + await setEnvRuntime(envName, { + version: runtime.version, + schemaHash: runtime.schemaHash, + generatedAt: runtime.generatedAt, + }); + } catch (error) { + if (!isRootInvocation) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + printWarning(`${message}\nContinuing with built-in help because runtime commands could not be loaded.`); + } finally { + stopTask(); + } +} + +export async function updateEnvRuntime(options: { + envName?: string; + baseUrl?: string; + token?: string; + role?: string; + configFile: string; + verbose?: boolean; + scope?: CliHomeScope; +}) { + setVerboseMode(Boolean(options.verbose)); + const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope })); + const env = await getEnv(envName, { scope: options.scope }); + const baseUrl = options.baseUrl ?? env?.baseUrl; + const token = await resolveAccessToken({ + envName, + baseUrl, + token: options.token, + scope: options.scope, + }); + + if (!baseUrl) { + throw new Error( + [ + `Env "${envName}" is missing a base URL.`, + 'Update it with `nb env add --name --base-url ` first.', + ].join('\n'), + ); + } + + updateTask('Loading command runtime...'); + try { + printVerbose(`Runtime source: ${baseUrl}`); + const document = await fetchSwaggerSchema(baseUrl, token, options.role, { envName }); + const runtime = await generateRuntime(document, options.configFile, baseUrl); + await saveRuntime(runtime, { scope: options.scope }); + if (options.baseUrl !== undefined || options.token !== undefined) { + await updateEnvConnection( + envName, + { + baseUrl: options.baseUrl, + accessToken: options.token, + }, + { scope: options.scope }, + ); + } + await setEnvRuntime(envName, { + version: runtime.version, + schemaHash: runtime.schemaHash, + generatedAt: runtime.generatedAt, + }, { scope: options.scope }); + return runtime; + } finally { + stopTask(); + } +} diff --git a/packages/core/cli/src/lib/build-config.ts b/packages/core/cli/src/lib/build-config.ts new file mode 100644 index 00000000000..081a6ec0a7c --- /dev/null +++ b/packages/core/cli/src/lib/build-config.ts @@ -0,0 +1,50 @@ +import {promises as fs} from 'node:fs'; + +export interface ResourceBuildConfig { + name?: string; + description?: string; + topLevel?: boolean; + operations?: OperationBuildConfigSet; +} + +export interface ModuleResourcesConfig { + includes?: string[]; + excludes?: string[]; + overrides?: Record; +} + +export interface OperationBuildConfig { + description?: string; +} + +export interface OperationBuildConfigSet { + includes?: string[]; + excludes?: string[]; + overrides?: Record; +} + +export interface ModuleBuildConfig { + name?: string; + description?: string; + include?: boolean; + resources?: ModuleResourcesConfig; +} + +export interface ModuleGroupConfig { + match: string[]; + module: string; +} + +export interface BuildConfig { + moduleGroups?: ModuleGroupConfig[]; + modules?: Record; +} + +export async function loadBuildConfig(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf8'); + return JSON.parse(content) as BuildConfig; + } catch (error) { + return {}; + } +} diff --git a/packages/core/cli/src/lib/cli-home.ts b/packages/core/cli/src/lib/cli-home.ts new file mode 100644 index 00000000000..2ae3abb9f14 --- /dev/null +++ b/packages/core/cli/src/lib/cli-home.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export const CLI_HOME_DIRNAME = '.nocobase'; +export type CliHomeScope = 'auto' | 'project' | 'global'; + +function resolveGlobalCliHomeRoot() { + if (process.env.NOCOBASE_CTL_HOME) { + return process.env.NOCOBASE_CTL_HOME; + } + + return os.homedir(); +} + +export function resolveCliHomeRoot(scope: CliHomeScope = 'auto') { + const cwdRoot = process.cwd(); + if (scope === 'project') { + return cwdRoot; + } + + if (scope === 'global') { + return resolveGlobalCliHomeRoot(); + } + + const cwdCliHome = path.join(cwdRoot, CLI_HOME_DIRNAME); + if (fs.existsSync(cwdCliHome)) { + return cwdRoot; + } + + return resolveGlobalCliHomeRoot(); +} + +export function resolveCliHomeDir(scope: CliHomeScope = 'auto') { + return path.join(resolveCliHomeRoot(scope), CLI_HOME_DIRNAME); +} + +export function formatCliHomeScope(scope: Exclude) { + return scope === 'project' ? 'project' : 'global'; +} diff --git a/packages/core/cli/src/lib/env-auth.ts b/packages/core/cli/src/lib/env-auth.ts new file mode 100644 index 00000000000..0cd2e81f0f1 --- /dev/null +++ b/packages/core/cli/src/lib/env-auth.ts @@ -0,0 +1,548 @@ +import crypto from 'node:crypto'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { URL } from 'node:url'; +import { + getCurrentEnvName, + getEnv, + setEnvOauthSession, + type AuthStoreOptions, + type OauthAuthConfig, +} from './auth-store.js'; +import { printInfo, printVerbose, printWarning, updateTask } from './ui.js'; + +const ACCESS_TOKEN_REFRESH_WINDOW_MS = 60_000; +const LOOPBACK_HOST = '127.0.0.1'; +const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_OAUTH_SCOPE = 'openid api offline_access'; +const DEFAULT_CLIENT_NAME = 'NocoBase CTL'; + +interface OauthServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; +} + +interface OauthTokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; +} + +function normalizeBaseUrl(baseUrl: string) { + return baseUrl.replace(/\/+$/, ''); +} + +export function getOauthMetadataUrl(baseUrl: string) { + return `${normalizeBaseUrl(baseUrl)}/.well-known/oauth-authorization-server`; +} + +export function getOauthResource(issuerOrBaseUrl: string) { + return `${normalizeBaseUrl(issuerOrBaseUrl)}/`; +} + +export function getDefaultOauthScope() { + return DEFAULT_OAUTH_SCOPE; +} + +export function isOauthAccessTokenExpired(auth: OauthAuthConfig, now = Date.now()) { + if (!auth.expiresAt) { + return false; + } + + const expiresAt = Date.parse(auth.expiresAt); + if (Number.isNaN(expiresAt)) { + return false; + } + + return expiresAt - ACCESS_TOKEN_REFRESH_WINDOW_MS <= now; +} + +function calculateExpiresAt(expiresIn?: number) { + if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) { + return undefined; + } + + return new Date(Date.now() + expiresIn * 1000).toISOString(); +} + +async function parseJsonResponse(response: Response) { + const text = await response.text(); + if (!text) { + return undefined; + } + + try { + return JSON.parse(text); + } catch (_error) { + return text; + } +} + +function formatOauthError(prefix: string, data: any, fallbackStatus?: number) { + if (typeof data === 'string' && data.trim()) { + return `${prefix}: ${data}`; + } + + if (data?.error || data?.error_description) { + const description = [data.error, data.error_description].filter(Boolean).join(': '); + return `${prefix}: ${description}`; + } + + if (typeof fallbackStatus === 'number') { + return `${prefix}: HTTP ${fallbackStatus}`; + } + + return prefix; +} + +async function fetchOauthServerMetadata(baseUrl: string) { + const metadataUrl = getOauthMetadataUrl(baseUrl); + const response = await fetch(metadataUrl); + const data = await parseJsonResponse(response); + + if (!response.ok) { + throw new Error(formatOauthError(`Failed to load OAuth metadata from ${metadataUrl}`, data, response.status)); + } + + if ( + !data || + typeof data !== 'object' || + typeof data.issuer !== 'string' || + typeof data.authorization_endpoint !== 'string' || + typeof data.token_endpoint !== 'string' + ) { + throw new Error(`Invalid OAuth metadata from ${metadataUrl}.`); + } + + return data as OauthServerMetadata; +} + +async function registerOauthClient(metadata: OauthServerMetadata, redirectUri: string) { + if (!metadata.registration_endpoint) { + throw new Error('OAuth server does not expose a dynamic client registration endpoint.'); + } + + const response = await fetch(metadata.registration_endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + client_name: DEFAULT_CLIENT_NAME, + application_type: 'native', + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: DEFAULT_OAUTH_SCOPE, + redirect_uris: [redirectUri], + }), + }); + const data = await parseJsonResponse(response); + + if (!response.ok) { + throw new Error(formatOauthError('Failed to register OAuth client', data, response.status)); + } + + if (!data || typeof data !== 'object' || typeof data.client_id !== 'string') { + throw new Error('OAuth client registration succeeded but no client_id was returned.'); + } + + return { + clientId: data.client_id as string, + }; +} + +function encodeBase64Url(input: Buffer) { + return input + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function buildPkcePair() { + const codeVerifier = encodeBase64Url(crypto.randomBytes(32)); + const codeChallenge = encodeBase64Url(crypto.createHash('sha256').update(codeVerifier).digest()); + + return { + codeVerifier, + codeChallenge, + }; +} + +function maybeOpenBrowser(url: string) { + const candidates = + process.platform === 'darwin' + ? [['open', url]] + : process.platform === 'win32' + ? [['cmd', '/c', 'start', '', url]] + : [['xdg-open', url]]; + + for (const [command, ...args] of candidates) { + try { + const child = spawn(command, args, { + detached: true, + stdio: 'ignore', + }); + child.unref(); + return true; + } catch (_error) { + continue; + } + } + + return false; +} + +async function createLoopbackServer(state: string) { + const result = await new Promise<{ + redirectUri: string; + waitForCode: () => Promise; + close: () => Promise; + }>((resolve, reject) => { + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url || '/', `http://${LOOPBACK_HOST}`); + const receivedState = requestUrl.searchParams.get('state'); + const code = requestUrl.searchParams.get('code'); + const error = requestUrl.searchParams.get('error'); + const errorDescription = requestUrl.searchParams.get('error_description'); + + res.setHeader('content-type', 'text/html; charset=utf-8'); + + if (receivedState !== state) { + res.statusCode = 400; + res.end('

Authentication failed

Invalid state.

'); + return; + } + + if (error) { + res.statusCode = 400; + res.end(`

Authentication failed

${errorDescription || error}

`); + reject(new Error(`OAuth authorization failed: ${errorDescription || error}`)); + return; + } + + if (!code) { + res.statusCode = 400; + res.end('

Authentication failed

Missing authorization code.

'); + reject(new Error('OAuth authorization failed: missing authorization code.')); + return; + } + + res.statusCode = 200; + res.end('

Authentication complete

You can return to the terminal.

'); + resolveWaiter(code); + } catch (error) { + reject(error as Error); + } + }); + + let resolveWaiter!: (code: string) => void; + let rejectWaiter!: (error: Error) => void; + + const waitForCode = () => + new Promise((resolveCode, rejectCode) => { + resolveWaiter = (code) => { + void close(); + resolveCode(code); + }; + rejectWaiter = (error) => { + void close(); + rejectCode(error); + }; + }); + + const close = async () => { + await new Promise((resolveClose) => { + server.close(() => resolveClose()); + }); + }; + + server.on('error', (error) => { + reject(error as Error); + rejectWaiter?.(error as Error); + }); + + server.listen(0, LOOPBACK_HOST, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to open the OAuth callback listener.')); + return; + } + + resolve({ + redirectUri: `http://${LOOPBACK_HOST}:${address.port}/callback`, + waitForCode, + close, + }); + }); + }); + + return result; +} + +async function exchangeAuthorizationCode(options: { + metadata: OauthServerMetadata; + clientId: string; + redirectUri: string; + code: string; + codeVerifier: string; + resource: string; +}) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: options.clientId, + code: options.code, + code_verifier: options.codeVerifier, + redirect_uri: options.redirectUri, + resource: options.resource, + }); + + const response = await fetch(options.metadata.token_endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + body, + }); + const data = await parseJsonResponse(response); + + if (!response.ok) { + throw new Error(formatOauthError('Failed to exchange OAuth authorization code', data, response.status)); + } + + if (!data || typeof data !== 'object' || typeof data.access_token !== 'string') { + throw new Error('OAuth token response is missing access_token.'); + } + + return data as OauthTokenResponse; +} + +async function refreshOauthAccessToken(options: { + envName: string; + baseUrl: string; + auth: OauthAuthConfig; + scope?: AuthStoreOptions['scope']; +}) { + if (!options.auth.refreshToken || !options.auth.clientId) { + throw new Error(`OAuth session for env "${options.envName}" cannot be refreshed. Run \`nb env auth -e ${options.envName}\`.`); + } + + const metadata = await fetchOauthServerMetadata(options.baseUrl); + const resource = options.auth.resource || getOauthResource(metadata.issuer); + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: options.auth.clientId, + refresh_token: options.auth.refreshToken, + resource, + }); + + const response = await fetch(metadata.token_endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + body, + }); + const data = await parseJsonResponse(response); + + if (!response.ok) { + throw new Error( + formatOauthError( + `Failed to refresh OAuth session for env "${options.envName}". Run \`nb env auth -e ${options.envName}\` again`, + data, + response.status, + ), + ); + } + + if (!data || typeof data !== 'object' || typeof data.access_token !== 'string') { + throw new Error(`OAuth refresh response for env "${options.envName}" is missing access_token.`); + } + + const nextAuth: OauthAuthConfig = { + type: 'oauth', + accessToken: data.access_token, + refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : options.auth.refreshToken, + expiresAt: calculateExpiresAt(data.expires_in), + scope: typeof data.scope === 'string' ? data.scope : options.auth.scope, + issuer: metadata.issuer, + clientId: options.auth.clientId, + resource, + }; + + await setEnvOauthSession(options.envName, nextAuth, { + scope: options.scope, + preserveRuntime: true, + }); + + return nextAuth.accessToken; +} + +export async function resolveAccessToken(options: { + envName?: string; + baseUrl?: string; + token?: string; + scope?: AuthStoreOptions['scope']; +}) { + if (options.token) { + return options.token; + } + + const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope })); + const env = await getEnv(envName, { scope: options.scope }); + if (!env?.auth) { + return undefined; + } + + if (env.auth.type === 'token') { + return env.auth.accessToken; + } + + if (!isOauthAccessTokenExpired(env.auth)) { + return env.auth.accessToken; + } + + const baseUrl = options.baseUrl ?? env.baseUrl; + if (!baseUrl) { + throw new Error(`Env "${envName}" is missing a base URL. Run \`nb env add --name ${envName} --base-url \`.`); + } + + printVerbose(`Refreshing OAuth session for env "${envName}"`); + return refreshOauthAccessToken({ + envName, + baseUrl, + auth: env.auth, + scope: options.scope, + }); +} + +export async function resolveServerRequestTarget(options: { + envName?: string; + baseUrl?: string; + token?: string; + scope?: AuthStoreOptions['scope']; +}) { + const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope })); + const env = await getEnv(envName, { scope: options.scope }); + const baseUrl = options.baseUrl ?? env?.baseUrl; + const token = await resolveAccessToken({ + envName, + baseUrl, + token: options.token, + scope: options.scope, + }); + + if (!baseUrl) { + throw new Error('Missing base URL. Use --base-url or configure one with `nb env add`.'); + } + + return { baseUrl, token }; +} + +export async function authenticateEnvWithOauth(options: { + envName?: string; + scope?: AuthStoreOptions['scope']; +}) { + const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope })); + const env = await getEnv(envName, { scope: options.scope }); + const baseUrl = env?.baseUrl; + + if (!baseUrl) { + throw new Error( + [ + `Env "${envName}" is missing a base URL.`, + 'Run `nb env add --name --base-url ` first.', + ].join('\n'), + ); + } + + updateTask(`Loading OAuth metadata for env "${envName}"...`); + const metadata = await fetchOauthServerMetadata(baseUrl); + const state = encodeBase64Url(crypto.randomBytes(16)); + const { codeVerifier, codeChallenge } = buildPkcePair(); + const callback = await createLoopbackServer(state); + const resource = getOauthResource(metadata.issuer); + + try { + updateTask(`Registering OAuth client for env "${envName}"...`); + const registration = await registerOauthClient(metadata, callback.redirectUri); + + const authorizationUrl = new URL(metadata.authorization_endpoint); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('client_id', registration.clientId); + authorizationUrl.searchParams.set('redirect_uri', callback.redirectUri); + authorizationUrl.searchParams.set('scope', DEFAULT_OAUTH_SCOPE); + authorizationUrl.searchParams.set('state', state); + authorizationUrl.searchParams.set('prompt', 'consent'); + authorizationUrl.searchParams.set('code_challenge', codeChallenge); + authorizationUrl.searchParams.set('code_challenge_method', 'S256'); + authorizationUrl.searchParams.set('resource', resource); + + updateTask(`Waiting for OAuth login for env "${envName}"...`); + const opened = maybeOpenBrowser(authorizationUrl.toString()); + if (!opened) { + printWarning('Unable to open the browser automatically. Open this URL manually:'); + } else { + printInfo('Complete the OAuth login in your browser.'); + } + printInfo(authorizationUrl.toString()); + + const code = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('OAuth login timed out.')), OAUTH_LOGIN_TIMEOUT_MS); + timeout.unref?.(); + + callback.waitForCode().then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); + + updateTask(`Exchanging OAuth code for env "${envName}"...`); + const tokenResponse = await exchangeAuthorizationCode({ + metadata, + clientId: registration.clientId, + redirectUri: callback.redirectUri, + code, + codeVerifier, + resource, + }); + + if (!tokenResponse.refresh_token) { + printWarning( + 'OAuth login succeeded but no refresh_token was returned. The server did not grant offline access for this client/session.', + ); + } + + await setEnvOauthSession( + envName, + { + type: 'oauth', + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresAt: calculateExpiresAt(tokenResponse.expires_in), + scope: tokenResponse.scope || DEFAULT_OAUTH_SCOPE, + issuer: metadata.issuer, + clientId: registration.clientId, + resource, + }, + { scope: options.scope }, + ); + } finally { + await callback.close().catch(() => undefined); + } +} diff --git a/packages/core/cli/src/lib/generated-command.ts b/packages/core/cli/src/lib/generated-command.ts new file mode 100644 index 00000000000..82a524e8a37 --- /dev/null +++ b/packages/core/cli/src/lib/generated-command.ts @@ -0,0 +1,194 @@ +import {Command, Flags} from '@oclif/core'; +import type {Interfaces} from '@oclif/core'; +import {executeApiRequest} from './api-client.js'; +import {applyPostProcessor} from './post-processors.js'; +import {registerPostProcessors} from '../post-processors/index.js'; + +export interface GeneratedParameter { + name: string; + flagName: string; + in: 'path' | 'query' | 'header' | 'cookie' | 'body'; + required?: boolean; + description?: string; + type?: string; + isArray?: boolean; + jsonEncoded?: boolean; + jsonShape?: string; +} + +export interface GeneratedOperation { + moduleName: string; + moduleDisplayName?: string; + moduleDescription?: string; + resourceName?: string; + logicalResourceName?: string; + actionName?: string; + resourceDisplayName?: string; + resourceDescription?: string; + commandId: string; + method: string; + pathTemplate: string; + tags?: string[]; + summary?: string; + description?: string; + examples: string[]; + parameters: GeneratedParameter[]; + hasBody?: boolean; + bodyRequired?: boolean; +} + +function buildParameterFlag(parameter: GeneratedParameter, options?: { required?: boolean }) { + const hints: string[] = [parameter.in]; + if (parameter.type === 'object' || parameter.type === 'array' || parameter.jsonEncoded) { + hints.push('JSON'); + } else if (parameter.isArray) { + hints.push('repeatable'); + } else if (parameter.type) { + hints.push(parameter.type); + } + + const description = [ + `${parameter.description ?? ''}${parameter.description ? ' ' : ''}[${hints.join(', ')}]`.trim(), + parameter.jsonShape ? `Shape: ${parameter.jsonShape}` : undefined, + ] + .filter(Boolean) + .join('\n'); + + const required = options?.required ?? parameter.required; + const helpGroup = + parameter.in === 'body' + ? 'Body Field' + : parameter.in === 'path' + ? 'Path Parameter' + : parameter.in === 'query' + ? 'Query Parameter' + : parameter.in === 'header' + ? 'Header Parameter' + : parameter.in === 'cookie' + ? 'Cookie Parameter' + : undefined; + + if (parameter.type === 'boolean') { + return Flags.boolean({ + description, + ...(helpGroup ? {helpGroup} : {}), + ...(required ? {required: true as const} : {}), + }); + } + + if (parameter.isArray && !parameter.jsonEncoded) { + return Flags.string({ + description, + multiple: true, + ...(helpGroup ? {helpGroup} : {}), + ...(required ? {required: true as const} : {}), + }); + } + + return Flags.string({ + description, + ...(helpGroup ? {helpGroup} : {}), + ...(required ? {required: true as const} : {}), + }); +} + +export function createGeneratedFlags(operation: GeneratedOperation): Interfaces.FlagInput { + const flags: Interfaces.FlagInput = {}; + + for (const parameter of operation.parameters) { + flags[parameter.flagName] = buildParameterFlag(parameter, { + // Body flags are an alternative authoring path to --body/--body-file. + // Enforce required body semantics later in parseBody(), after we know + // which input mode the user chose. + required: parameter.in === 'body' ? false : parameter.required, + }); + } + + if (operation.hasBody) { + flags.body = Flags.string({ + description: 'Full JSON request body string. Do not combine with body field flags.', + helpGroup: 'Raw JSON Body', + exclusive: ['body-file'], + }); + flags['body-file'] = Flags.string({ + description: 'Path to a JSON file containing the full request body. Do not combine with body field flags.', + helpGroup: 'Raw JSON Body', + exclusive: ['body'], + }); + } + + flags['base-url'] = Flags.string({ + description: 'NocoBase API base URL, for example http://localhost:13000/api', + helpGroup: 'Global', + }); + flags.verbose = Flags.boolean({ + description: 'Show detailed progress output', + default: false, + helpGroup: 'Global', + }); + flags.env = Flags.string({ + char: 'e', + description: 'Environment name', + helpGroup: 'Global', + }); + flags.role = Flags.string({ + description: 'Role override, sent as X-Role', + helpGroup: 'Global', + }); + flags.token = Flags.string({ + char: 't', + description: 'API key override', + helpGroup: 'Global', + }); + flags['json-output'] = Flags.boolean({ + char: 'j', + description: 'Print raw JSON response', + default: true, + allowNo: true, + helpGroup: 'Global', + }); + + return flags; +} + +export abstract class GeneratedApiCommand extends Command { + static operation: GeneratedOperation; + + async run(): Promise { + registerPostProcessors(); + + const ctor = this.constructor as typeof GeneratedApiCommand; + const {flags} = await this.parse(ctor); + + const response = await executeApiRequest({ + envName: flags.env, + baseUrl: flags['base-url'], + role: flags.role, + token: flags.token, + flags, + operation: { + method: ctor.operation.method, + pathTemplate: ctor.operation.pathTemplate, + parameters: ctor.operation.parameters, + hasBody: ctor.operation.hasBody, + bodyRequired: ctor.operation.bodyRequired, + }, + }); + + if (!response.ok) { + this.error(`Request failed with status ${response.status}\n${JSON.stringify(response.data, null, 2)}`); + } + + const processedData = await applyPostProcessor(response.data, { + flags, + operation: ctor.operation, + }); + + if (flags['json-output']) { + this.log(JSON.stringify(processedData, null, 2)); + return; + } + + this.log(`HTTP ${response.status}`); + } +} diff --git a/packages/core/cli/src/lib/naming.ts b/packages/core/cli/src/lib/naming.ts new file mode 100644 index 00000000000..46549ef9a0b --- /dev/null +++ b/packages/core/cli/src/lib/naming.ts @@ -0,0 +1,85 @@ +import path from 'node:path'; + +export function toKebabCase(value: string) { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); +} + +export function splitPathAction(pathTemplate: string) { + const normalizedPath = pathTemplate.replace(/^\/+/, ''); + const separatorIndex = normalizedPath.lastIndexOf(':'); + + if (separatorIndex === -1) { + return { + resourcePath: normalizedPath, + action: 'call', + }; + } + + return { + resourcePath: normalizedPath.slice(0, separatorIndex), + action: normalizedPath.slice(separatorIndex + 1), + }; +} + +export function toLogicalResourceName(pathTemplate: string) { + const {resourcePath} = splitPathAction(pathTemplate); + return resourcePath + .split('/') + .filter(Boolean) + .filter((segment) => !segment.startsWith('{')) + .map((segment) => toKebabCase(segment)) + .join('.'); +} + +export function toLogicalActionName(pathTemplate: string) { + return toKebabCase(splitPathAction(pathTemplate).action); +} + +export function toResourceSegments(pathTemplate: string, options?: {includeParams?: boolean}) { + const {resourcePath, action} = splitPathAction(pathTemplate); + const pathSegments = resourcePath + .split('/') + .filter(Boolean) + .flatMap((segment) => { + if (!segment.startsWith('{')) { + return [toKebabCase(segment)]; + } + + if (!options?.includeParams) { + return []; + } + + return [`by-${toKebabCase(segment.slice(1, -1))}`]; + }); + + return [...pathSegments, toKebabCase(action)].filter(Boolean); +} + +export function toCommandSegments(moduleName: string, pathTemplate: string, options?: {includeParams?: boolean; omitModule?: boolean}) { + const resourceSegments = toResourceSegments(pathTemplate, options); + const segments = [options?.omitModule ? '' : toKebabCase(moduleName), ...resourceSegments].filter(Boolean); + + return segments.length ? segments : [toKebabCase(moduleName), 'call']; +} + +export function toClassName(segments: string[]) { + return segments + .map((segment) => segment.replace(/(^\w|-\w)/g, (token) => token.replace('-', '').toUpperCase())) + .join(''); +} + +export function toOutputFile(outputRoot: string, segments: string[]) { + const folder = path.join(outputRoot, ...segments.slice(0, -1)); + const filePath = path.join(folder, `${segments.at(-1)}.ts`); + return filePath; +} + +export function toImportPath(fromFile: string, targetFile: string) { + const relative = path.relative(path.dirname(fromFile), targetFile).replace(/\\/g, '/'); + return relative.startsWith('.') ? relative : `./${relative}`; +} diff --git a/packages/core/cli/src/lib/openapi.ts b/packages/core/cli/src/lib/openapi.ts new file mode 100644 index 00000000000..dd8d6d0c8f7 --- /dev/null +++ b/packages/core/cli/src/lib/openapi.ts @@ -0,0 +1,341 @@ +import {createHash} from 'node:crypto'; +import {promises as fs} from 'node:fs'; +import path from 'node:path'; +import SwaggerParser from '@apidevtools/swagger-parser'; +import type {OpenAPIV3} from 'openapi-types'; +import ts from 'typescript'; + +export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; +export type OpenApiSchema = OpenAPIV3.SchemaObject; +export type OpenApiParameter = OpenAPIV3.ParameterObject; +export type OpenApiRequestBody = OpenAPIV3.RequestBodyObject; +export type OpenApiOperation = OpenAPIV3.OperationObject; +export type OpenApiPathItem = OpenAPIV3.PathItemObject; +export type OpenApiDocument = OpenAPIV3.Document; + +export interface SwaggerSource { + moduleName: string; + sourceFile: string; + sourceId: string; + packageFile?: string; + packageName?: string; + format: 'json' | 'ts'; +} + +const SWAGGER_FILENAMES = new Set(['index.json', 'index.ts', 'swagger.json']); +const HTTP_METHODS: HttpMethod[] = ['get', 'post', 'put', 'patch', 'delete']; + +function isPluginSwaggerSource(filePath: string) { + return ( + filePath.includes(`${path.sep}packages${path.sep}plugins${path.sep}`) && + filePath.includes(`${path.sep}src${path.sep}swagger${path.sep}`) + ); +} + +function isCoreSwaggerSource(filePath: string) { + return filePath.includes(`${path.sep}packages${path.sep}core${path.sep}server${path.sep}src${path.sep}swagger${path.sep}`); +} + +function toModuleName(filePath: string) { + if (isCoreSwaggerSource(filePath)) { + return 'core'; + } + + const normalized = filePath.replace(/\\/g, '/'); + const pluginMatch = normalized.match(/packages\/plugins\/@nocobase\/plugin-([^/]+)\//); + return pluginMatch?.[1] ?? ''; +} + +function toPackageFile(filePath: string) { + if (isCoreSwaggerSource(filePath)) { + return ''; + } + + const normalized = filePath.replace(/\\/g, '/'); + const match = normalized.match(/^(.*\/packages\/plugins\/@nocobase\/plugin-[^/]+)\//); + return match ? `${match[1]}/package.json` : ''; +} + +function toPackageName(filePath: string) { + if (isCoreSwaggerSource(filePath)) { + return '@nocobase/server'; + } + + const normalized = filePath.replace(/\\/g, '/'); + const match = normalized.match(/packages\/plugins\/(@nocobase\/plugin-[^/]+)\//); + return match?.[1] ?? ''; +} + +async function walk(dir: string, result: string[]) { + const entries = await fs.readdir(dir, {withFileTypes: true}); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + await walk(fullPath, result); + continue; + } + + if (!SWAGGER_FILENAMES.has(entry.name)) { + continue; + } + + if (!isPluginSwaggerSource(fullPath) && !isCoreSwaggerSource(fullPath)) { + continue; + } + + result.push(fullPath); + } +} + +export async function discoverSwaggerSources(sourceRoot: string) { + const files: string[] = []; + await walk(path.join(sourceRoot, 'packages'), files); + + return files + .sort() + .map((sourceFile): SwaggerSource => ({ + moduleName: toModuleName(sourceFile), + sourceFile, + sourceId: path.relative(sourceRoot, sourceFile).replace(/\\/g, '/'), + packageFile: toPackageFile(sourceFile) || undefined, + packageName: toPackageName(sourceFile) || undefined, + format: sourceFile.endsWith('.json') ? 'json' : 'ts', + })) + .filter((item) => item.moduleName); +} + +function getPropertyName(name: ts.PropertyName): string { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + + if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) { + return name.expression.text; + } + + throw new Error('Unsupported computed property in swagger object.'); +} + +function evaluateExpression(node: ts.Expression): any { + if (ts.isObjectLiteralExpression(node)) { + const value: Record = {}; + + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + value[getPropertyName(property.name)] = evaluateExpression(property.initializer); + continue; + } + + if (ts.isShorthandPropertyAssignment(property)) { + throw new Error(`Unsupported shorthand property "${property.name.text}" in swagger object.`); + } + + if (ts.isSpreadAssignment(property)) { + throw new Error('Unsupported spread assignment in swagger object.'); + } + + if (ts.isMethodDeclaration(property) || ts.isAccessor(property)) { + throw new Error('Unsupported method/accessor in swagger object.'); + } + } + + return value; + } + + if (ts.isArrayLiteralExpression(node)) { + return node.elements.map((element) => { + if (ts.isSpreadElement(element)) { + throw new Error('Unsupported spread element in swagger array.'); + } + + return evaluateExpression(element); + }); + } + + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + + if (ts.isNumericLiteral(node)) { + return Number(node.text); + } + + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } + + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } + + if (node.kind === ts.SyntaxKind.NullKeyword) { + return null; + } + + if (ts.isParenthesizedExpression(node)) { + return evaluateExpression(node.expression); + } + + if (ts.isPropertyAccessExpression(node)) { + return { + __target__: evaluateExpression(node.expression), + __property__: node.name.text, + }; + } + + if (ts.isCallExpression(node)) { + const callee = evaluateExpression(node.expression); + const args = node.arguments.map((argument) => evaluateExpression(argument)); + + if ( + callee && + typeof callee === 'object' && + '__target__' in callee && + '__property__' in callee && + callee.__property__ === 'join' && + Array.isArray(callee.__target__) + ) { + return callee.__target__.join(args[0] ?? ','); + } + + throw new Error('Unsupported call expression in swagger object.'); + } + + if (ts.isPrefixUnaryExpression(node)) { + const operand = evaluateExpression(node.operand); + if (node.operator === ts.SyntaxKind.MinusToken) { + return -operand; + } + if (node.operator === ts.SyntaxKind.PlusToken) { + return +operand; + } + if (node.operator === ts.SyntaxKind.ExclamationToken) { + return !operand; + } + } + + throw new Error(`Unsupported swagger expression kind: ${ts.SyntaxKind[node.kind]}`); +} + +function parseTypeScriptSwagger(sourceText: string, fileName: string): OpenApiDocument { + const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + for (const statement of sourceFile.statements) { + if (!ts.isExportAssignment(statement)) { + continue; + } + + if (!ts.isObjectLiteralExpression(statement.expression)) { + throw new Error('Expected `export default` to be an object literal.'); + } + + return evaluateExpression(statement.expression) as OpenApiDocument; + } + + throw new Error('Missing `export default` in swagger source.'); +} + +function normalizeDocument(document: OpenApiDocument): OpenApiDocument { + return { + ...document, + openapi: document.openapi ?? '3.0.2', + info: { + ...(document.info ?? {}), + title: document.info?.title ?? 'NocoBase API', + version: document.info?.version ?? '1.0.0', + }, + paths: document.paths ?? {}, + components: document.components ?? {}, + }; +} + +export async function loadSwaggerDocument(source: SwaggerSource): Promise { + const content = await fs.readFile(source.sourceFile, 'utf8'); + const document = normalizeDocument( + source.format === 'json' + ? (JSON.parse(content) as OpenApiDocument) + : parseTypeScriptSwagger(content, source.sourceFile), + ); + + try { + await SwaggerParser.validate(document as any); + return (await SwaggerParser.dereference(document as any)) as OpenApiDocument; + } catch (error) { + return document; + } +} + +function resolveLocalRef(document: OpenApiDocument, ref: string) { + if (!ref.startsWith('#/')) { + return undefined; + } + + return ref + .slice(2) + .split('/') + .reduce((current, segment) => current?.[segment], document); +} + +function dereferenceNode(node: T, document: OpenApiDocument, seen = new Set()): T { + if (Array.isArray(node)) { + return node.map((item) => dereferenceNode(item, document, seen)) as unknown as T; + } + + if (!node || typeof node !== 'object') { + return node; + } + + const ref = (node as {$ref?: string}).$ref; + if (typeof ref === 'string') { + if (seen.has(ref)) { + return {} as T; + } + + const resolved = resolveLocalRef(document, ref); + if (!resolved) { + return node; + } + + return dereferenceNode(resolved as T, document, new Set([...seen, ref])); + } + + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [key, dereferenceNode(value, document, seen)]), + ) as T; +} + +export async function sha1File(filePath: string) { + const content = await fs.readFile(filePath); + return createHash('sha1').update(content).digest('hex'); +} + +export function collectOperations(document: OpenApiDocument) { + const operations: Array<{method: HttpMethod; pathTemplate: string; operation: OpenApiOperation}> = []; + + for (const [pathTemplate, pathItem] of Object.entries(document.paths ?? {})) { + for (const method of HTTP_METHODS) { + const operation = pathItem?.[method]; + if (!operation || '$ref' in operation) { + continue; + } + + const parameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])] + .map((parameter) => dereferenceNode(parameter, document)) + .filter((parameter): parameter is OpenApiParameter => Boolean(parameter && !('$ref' in parameter))); + + operations.push({ + method, + pathTemplate, + operation: { + ...operation, + parameters, + requestBody: operation.requestBody ? dereferenceNode(operation.requestBody, document) : undefined, + }, + }); + } + } + + return operations; +} diff --git a/packages/core/cli/src/lib/post-processors.ts b/packages/core/cli/src/lib/post-processors.ts new file mode 100644 index 00000000000..2da6b81d2af --- /dev/null +++ b/packages/core/cli/src/lib/post-processors.ts @@ -0,0 +1,39 @@ +import type {GeneratedOperation} from './generated-command.js'; + +export interface PostProcessorContext { + flags: Record; + operation: GeneratedOperation; +} + +export type PostProcessor = (result: unknown, context: PostProcessorContext) => unknown | Promise; + +function buildKey(resource: string, action: string) { + return `${resource}:${action}`; +} + +class PostProcessorRegistry { + private readonly processors = new Map(); + + register(resource: string, action: string, processor: PostProcessor) { + this.processors.set(buildKey(resource, action), processor); + } + + resolve(resource?: string, action?: string) { + if (!resource || !action) { + return undefined; + } + + return this.processors.get(buildKey(resource, action)); + } +} + +export const postProcessorRegistry = new PostProcessorRegistry(); + +export async function applyPostProcessor(result: unknown, context: PostProcessorContext) { + const processor = postProcessorRegistry.resolve(context.operation.logicalResourceName, context.operation.actionName); + if (!processor) { + return result; + } + + return processor(result, context); +} diff --git a/packages/core/cli/src/lib/resource-command.ts b/packages/core/cli/src/lib/resource-command.ts new file mode 100644 index 00000000000..2b5300871d7 --- /dev/null +++ b/packages/core/cli/src/lib/resource-command.ts @@ -0,0 +1,391 @@ +import { Command, Flags } from '@oclif/core'; +import type { Interfaces } from '@oclif/core'; +import { executeResourceRequest, type ResourceAction, type ResourceRequestArgs } from './resource-request.js'; +import { setVerboseMode } from './ui.js'; + +function parseJson(value: string, flagName: string): T { + try { + return JSON.parse(value) as T; + } catch (error: any) { + throw new Error(`Invalid JSON for --${flagName}: ${error?.message ?? 'parse failed'}`); + } +} + +function parseFlexibleValue(value: string | undefined, flagName: string) { + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return value; + } + + if (trimmed.startsWith('[') || trimmed.startsWith('{') || trimmed === 'null' || trimmed === 'true' || trimmed === 'false') { + return parseJson(trimmed, flagName); + } + + return value; +} + +function parseObjectFlag(value: string | undefined, flagName: string) { + if (value === undefined) { + return undefined; + } + + const parsed = parseJson>(value, flagName); + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error(`--${flagName} must be a JSON object`); + } + + return parsed; +} + +function parseJsonArrayFlag(value: string | undefined, flagName: string) { + if (value === undefined) { + return undefined; + } + + const parsed = parseJson>>(value, flagName); + if (!Array.isArray(parsed)) { + throw new Error(`--${flagName} must be a JSON array`); + } + + return parsed; +} + +function parseStringArrayFlags(value: string[] | string | undefined, flagName: string) { + if (value === undefined) { + return undefined; + } + + if (Array.isArray(value)) { + if (value.length === 1) { + const trimmed = value[0].trim(); + if (trimmed.startsWith('[')) { + const parsed = parseJson(trimmed, flagName); + if (!Array.isArray(parsed)) { + throw new Error(`--${flagName} must be repeated or use a JSON array`); + } + + return parsed.map((item) => String(item)); + } + } + + return value.map((item) => String(item)); + } + + const trimmed = value.trim(); + if (trimmed.startsWith('[')) { + const parsed = parseJson(trimmed, flagName); + if (!Array.isArray(parsed)) { + throw new Error(`--${flagName} must be repeated or use a JSON array`); + } + + return parsed.map((item) => String(item)); + } + + return [String(value)]; +} + +function printResponse(command: Command, response: { ok: boolean; status: number; data: unknown }, jsonOutput: boolean) { + if (!response.ok) { + command.error(`Request failed with status ${response.status}\n${JSON.stringify(response.data, null, 2)}`); + } + + if (jsonOutput) { + command.log(JSON.stringify(response.data, null, 2)); + return; + } + + command.log(`HTTP ${response.status}`); +} + +export const resourceBaseFlags = { + 'base-url': Flags.string({ + description: 'NocoBase API base URL, for example http://localhost:13000/api', + }), + verbose: Flags.boolean({ + description: 'Show detailed progress output', + default: false, + }), + env: Flags.string({ + char: 'e', + description: 'Environment name', + }), + role: Flags.string({ + description: 'Role override, sent as X-Role', + }), + token: Flags.string({ + char: 't', + description: 'API key override', + }), + 'json-output': Flags.boolean({ + char: 'j', + description: 'Print raw JSON response', + default: true, + allowNo: true, + }), + resource: Flags.string({ + description: 'Resource name such as users, orders, or association resources like posts.comments', + required: true, + }), + 'data-source': Flags.string({ + description: 'Data source key. Defaults to main.', + }), +} satisfies Interfaces.FlagInput; + +export const resourceAssociationFlags = { + 'source-id': Flags.string({ + description: 'Source record ID for association resources like posts.comments.', + }), +} satisfies Interfaces.FlagInput; + +export const listFlags = { + ...resourceBaseFlags, + ...resourceAssociationFlags, + filter: Flags.string({ + description: 'Filter object as JSON', + }), + fields: Flags.string({ + description: 'Fields to query. Repeat the flag or pass a JSON array.', + multiple: true, + }), + appends: Flags.string({ + description: 'Association or appended fields to include. Repeat the flag or pass a JSON array.', + multiple: true, + }), + except: Flags.string({ + description: 'Fields to exclude from the result. Repeat the flag or pass a JSON array.', + multiple: true, + }), + sort: Flags.string({ + description: 'Sort fields such as -createdAt. Repeat the flag or pass a JSON array.', + multiple: true, + }), + page: Flags.integer({ + description: 'Page number for list action.', + }), + 'page-size': Flags.integer({ + description: 'Page size for list action.', + }), + paginate: Flags.boolean({ + description: 'Whether to use pagination for list action.', + allowNo: true, + }), +} satisfies Interfaces.FlagInput; + +export const getFlags = { + ...resourceBaseFlags, + ...resourceAssociationFlags, + 'filter-by-tk': Flags.string({ + description: 'Primary key value used by get. Supports JSON arrays for composite or multiple keys.', + }), + fields: Flags.string({ + description: 'Fields to query. Repeat the flag or pass a JSON array.', + multiple: true, + }), + appends: Flags.string({ + description: 'Association or appended fields to include. Repeat the flag or pass a JSON array.', + multiple: true, + }), + except: Flags.string({ + description: 'Fields to exclude from the result. Repeat the flag or pass a JSON array.', + multiple: true, + }), +} satisfies Interfaces.FlagInput; + +export const createFlags = { + ...resourceBaseFlags, + ...resourceAssociationFlags, + values: Flags.string({ + description: 'Record values used by create as a JSON object.', + required: true, + }), + whitelist: Flags.string({ + description: 'Fields allowed to be written. Repeat the flag or pass a JSON array.', + multiple: true, + }), + blacklist: Flags.string({ + description: 'Fields forbidden to be written. Repeat the flag or pass a JSON array.', + multiple: true, + }), +} satisfies Interfaces.FlagInput; + +export const updateFlags = { + ...resourceBaseFlags, + ...resourceAssociationFlags, + 'filter-by-tk': Flags.string({ + description: 'Primary key value used by update. Supports JSON arrays for composite or multiple keys.', + }), + filter: Flags.string({ + description: 'Filter object for update as JSON.', + }), + values: Flags.string({ + description: 'Record values used by update as a JSON object.', + required: true, + }), + whitelist: Flags.string({ + description: 'Fields allowed to be written. Repeat the flag or pass a JSON array.', + multiple: true, + }), + blacklist: Flags.string({ + description: 'Fields forbidden to be written. Repeat the flag or pass a JSON array.', + multiple: true, + }), + 'update-association-values': Flags.string({ + description: 'Association fields that should be updated together. Repeat the flag or pass a JSON array.', + multiple: true, + }), + 'force-update': Flags.boolean({ + description: 'Whether update should force writing unchanged values.', + allowNo: true, + }), +} satisfies Interfaces.FlagInput; + +export const destroyFlags = { + ...resourceBaseFlags, + ...resourceAssociationFlags, + 'filter-by-tk': Flags.string({ + description: 'Primary key value used by destroy. Supports JSON arrays for composite or multiple keys.', + }), + filter: Flags.string({ + description: 'Filter object for destroy as JSON.', + }), +} satisfies Interfaces.FlagInput; + +export const queryFlags = { + ...resourceBaseFlags, + measures: Flags.string({ + description: 'Measure definitions for query aggregation as a JSON array.', + }), + dimensions: Flags.string({ + description: 'Dimension definitions for query aggregation as a JSON array.', + }), + orders: Flags.string({ + description: 'Order definitions for query aggregation as a JSON array.', + }), + filter: Flags.string({ + description: 'Filter object for query as JSON.', + }), + having: Flags.string({ + description: 'Having object for grouped query as JSON.', + }), + limit: Flags.integer({ + description: 'Limit for query result rows.', + }), + offset: Flags.integer({ + description: 'Offset for query result rows.', + }), + timezone: Flags.string({ + description: 'Optional timezone for query formatting.', + }), +} satisfies Interfaces.FlagInput; + +function pickSharedArgs(flags: Record): Pick { + return { + resource: flags.resource, + dataSource: flags['data-source'], + sourceId: parseFlexibleValue(flags['source-id'], 'source-id') as string | number | undefined, + }; +} + +export function buildListArgs(flags: Record): ResourceRequestArgs { + return { + ...pickSharedArgs(flags), + filter: parseObjectFlag(flags.filter, 'filter'), + fields: parseStringArrayFlags(flags.fields, 'fields'), + appends: parseStringArrayFlags(flags.appends, 'appends'), + except: parseStringArrayFlags(flags.except, 'except'), + sort: parseStringArrayFlags(flags.sort, 'sort'), + page: flags.page, + pageSize: flags['page-size'], + paginate: flags.paginate, + }; +} + +export function buildGetArgs(flags: Record): ResourceRequestArgs { + return { + ...pickSharedArgs(flags), + filterByTk: parseFlexibleValue(flags['filter-by-tk'], 'filter-by-tk') as + | string + | number + | Array + | undefined, + fields: parseStringArrayFlags(flags.fields, 'fields'), + appends: parseStringArrayFlags(flags.appends, 'appends'), + except: parseStringArrayFlags(flags.except, 'except'), + }; +} + +export function buildCreateArgs(flags: Record): ResourceRequestArgs { + return { + ...pickSharedArgs(flags), + values: parseObjectFlag(flags.values, 'values'), + whitelist: parseStringArrayFlags(flags.whitelist, 'whitelist'), + blacklist: parseStringArrayFlags(flags.blacklist, 'blacklist'), + }; +} + +export function buildUpdateArgs(flags: Record): ResourceRequestArgs { + return { + ...pickSharedArgs(flags), + filterByTk: parseFlexibleValue(flags['filter-by-tk'], 'filter-by-tk') as + | string + | number + | Array + | undefined, + filter: parseObjectFlag(flags.filter, 'filter'), + values: parseObjectFlag(flags.values, 'values'), + whitelist: parseStringArrayFlags(flags.whitelist, 'whitelist'), + blacklist: parseStringArrayFlags(flags.blacklist, 'blacklist'), + updateAssociationValues: parseStringArrayFlags(flags['update-association-values'], 'update-association-values'), + forceUpdate: flags['force-update'], + }; +} + +export function buildDestroyArgs(flags: Record): ResourceRequestArgs { + return { + ...pickSharedArgs(flags), + filterByTk: parseFlexibleValue(flags['filter-by-tk'], 'filter-by-tk') as + | string + | number + | Array + | undefined, + filter: parseObjectFlag(flags.filter, 'filter'), + }; +} + +export function buildQueryArgs(flags: Record): ResourceRequestArgs { + return { + ...pickSharedArgs(flags), + measures: parseJsonArrayFlag(flags.measures, 'measures'), + dimensions: parseJsonArrayFlag(flags.dimensions, 'dimensions'), + orders: parseJsonArrayFlag(flags.orders, 'orders'), + filter: parseObjectFlag(flags.filter, 'filter'), + having: parseObjectFlag(flags.having, 'having'), + limit: flags.limit, + offset: flags.offset, + timezone: flags.timezone, + }; +} + +export async function runResourceCommand( + command: Command, + action: ResourceAction, + flags: Record, + args: ResourceRequestArgs, +) { + setVerboseMode(Boolean(flags.verbose)); + + const response = await executeResourceRequest({ + envName: flags.env, + baseUrl: flags['base-url'], + role: flags.role, + token: flags.token, + action, + args, + }); + + printResponse(command, response, flags['json-output']); +} diff --git a/packages/core/cli/src/lib/resource-request.ts b/packages/core/cli/src/lib/resource-request.ts new file mode 100644 index 00000000000..0b302dc437a --- /dev/null +++ b/packages/core/cli/src/lib/resource-request.ts @@ -0,0 +1,164 @@ +import { executeRawApiRequest } from './api-client.js'; + +export type ResourceAction = 'list' | 'get' | 'create' | 'update' | 'destroy' | 'query'; + +export interface ResourceRequestArgs { + dataSource?: string; + resource: string; + sourceId?: string | number; + filterByTk?: string | number | Array; + filter?: Record; + fields?: string[]; + appends?: string[]; + except?: string[]; + sort?: string[]; + page?: number; + pageSize?: number; + paginate?: boolean; + values?: Record; + whitelist?: string[]; + blacklist?: string[]; + updateAssociationValues?: string[]; + forceUpdate?: boolean; + measures?: Array>; + dimensions?: Array>; + orders?: Array>; + having?: Record; + limit?: number; + offset?: number; + timezone?: string; +} + +function buildActionUrl(resource: string, action: ResourceAction, sourceId?: string | number) { + if (typeof sourceId === 'undefined' || sourceId === null || !resource.includes('.')) { + return `${resource}:${action}`; + } + + const [parentResource, childResource] = resource.split('.'); + return `${parentResource}/${encodeURIComponent(String(sourceId))}/${childResource}:${action}`; +} + +function buildQueryValue(value: any) { + if (typeof value === 'undefined') { + return undefined; + } + + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + return value; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return value; +} + +function buildRequestQuery(args: ResourceRequestArgs) { + const query: Record = { + filterByTk: buildQueryValue(args.filterByTk), + filter: buildQueryValue(args.filter), + fields: buildQueryValue(args.fields), + appends: buildQueryValue(args.appends), + except: buildQueryValue(args.except), + sort: buildQueryValue(args.sort), + page: buildQueryValue(args.page), + pageSize: buildQueryValue(args.pageSize), + paginate: buildQueryValue(args.paginate), + whitelist: buildQueryValue(args.whitelist), + blacklist: buildQueryValue(args.blacklist), + updateAssociationValues: buildQueryValue(args.updateAssociationValues), + forceUpdate: buildQueryValue(args.forceUpdate), + }; + + for (const key of Object.keys(query)) { + if (typeof query[key] === 'undefined') { + delete query[key]; + } + } + + return query; +} + +function buildQueryPayload(args: ResourceRequestArgs) { + const payload: Record = { + measures: args.measures, + dimensions: args.dimensions, + orders: args.orders, + filter: args.filter, + having: args.having, + limit: args.limit, + offset: args.offset, + }; + + for (const key of Object.keys(payload)) { + if (typeof payload[key] === 'undefined') { + delete payload[key]; + } + } + + return payload; +} + +function buildCrudPayload(action: ResourceAction, args: ResourceRequestArgs) { + if ((action === 'create' || action === 'update') && args.values) { + return args.values; + } +} + +function buildActionQuery(action: ResourceAction, args: ResourceRequestArgs) { + if (action === 'query') { + return undefined; + } + + return buildRequestQuery(args); +} + +function buildActionPayload(action: ResourceAction, args: ResourceRequestArgs) { + if (action === 'query') { + return buildQueryPayload(args); + } + + return buildCrudPayload(action, args); +} + +function buildHeaders(action: ResourceAction, args: ResourceRequestArgs) { + const headers: Record = {}; + + if (args.dataSource && args.dataSource !== 'main') { + headers['x-data-source'] = args.dataSource; + } + + if (action === 'query' && args.timezone) { + headers['x-timezone'] = args.timezone; + } + + return headers; +} + +export async function executeResourceRequest(options: { + envName?: string; + baseUrl?: string; + token?: string; + role?: string; + action: ResourceAction; + args: ResourceRequestArgs; +}) { + const path = `/${buildActionUrl(options.args.resource, options.action, options.args.sourceId)}`; + + return executeRawApiRequest({ + envName: options.envName, + baseUrl: options.baseUrl, + role: options.role, + token: options.token, + method: 'POST', + path, + query: buildActionQuery(options.action, options.args), + body: buildActionPayload(options.action, options.args), + headers: buildHeaders(options.action, options.args), + }); +} diff --git a/packages/core/cli/src/lib/runtime-generator.ts b/packages/core/cli/src/lib/runtime-generator.ts new file mode 100644 index 00000000000..5e0100feb13 --- /dev/null +++ b/packages/core/cli/src/lib/runtime-generator.ts @@ -0,0 +1,528 @@ +import { createHash } from 'node:crypto'; +import { loadBuildConfig } from './build-config.js'; +import type { GeneratedOperation, GeneratedParameter } from './generated-command.js'; +import { toKebabCase, toLogicalActionName, toLogicalResourceName, toResourceSegments } from './naming.js'; +import { collectOperations, type OpenApiDocument } from './openapi.js'; +import type { StoredRuntime } from './runtime-store.js'; + +const RESERVED_FLAG_NAMES = new Set(['base-url', 'env', 'token', 'json-output', 'body', 'body-file']); + +function matchesPattern(value: string, pattern: string) { + if (!value) { + return false; + } + + if (pattern.endsWith('*')) { + return value.startsWith(pattern.slice(0, -1)); + } + + return value === pattern; +} + +function inferParameterType(schema?: { type?: string; items?: { type?: string }; oneOf?: any[]; anyOf?: any[]; allOf?: any[] }) { + if (schema?.type) { + return schema.type; + } + + for (const candidate of [...(schema?.oneOf ?? []), ...(schema?.anyOf ?? []), ...(schema?.allOf ?? [])]) { + const resolved = inferParameterType(candidate); + if (resolved) { + return resolved; + } + } + + return undefined; +} + +function createUniqueFlagName(baseName: string, usedFlagNames: Set) { + let candidate = baseName || 'value'; + if (RESERVED_FLAG_NAMES.has(candidate)) { + candidate = `param-${candidate}`; + } + + if (!usedFlagNames.has(candidate)) { + usedFlagNames.add(candidate); + return candidate; + } + + let index = 2; + while (usedFlagNames.has(`${candidate}-${index}`)) { + index += 1; + } + + const unique = `${candidate}-${index}`; + usedFlagNames.add(unique); + return unique; +} + +function isSupportedParameter(parameter: any) { + return Boolean(parameter && typeof parameter.name === 'string' && typeof parameter.in === 'string'); +} + +function toGeneratedParameter(parameter: any, usedFlagNames: Set): GeneratedParameter { + return { + name: parameter.name, + flagName: createUniqueFlagName(toKebabCase(parameter.name), usedFlagNames), + in: parameter.in, + required: parameter.required, + description: parameter.description, + type: inferParameterType(parameter.schema), + isArray: parameter.schema?.type === 'array', + }; +} + +function getJsonRequestSchema(requestBody: any) { + return requestBody?.content?.['application/json']?.schema; +} + +function normalizeCompositeSchema(schema: any): any { + if (!schema || typeof schema !== 'object') { + return schema; + } + + if (Array.isArray(schema.allOf) && schema.allOf.length > 0) { + return schema.allOf.reduce( + (result: Record, part: any) => { + const normalized = normalizeCompositeSchema(part); + return { + ...result, + ...normalized, + type: normalized?.type ?? result.type ?? 'object', + properties: { + ...(result.properties ?? {}), + ...(normalized?.properties ?? {}), + }, + required: [...new Set([...(result.required ?? []), ...(normalized?.required ?? [])])], + additionalProperties: normalized?.additionalProperties ?? result.additionalProperties, + description: schema.description ?? normalized?.description ?? result.description, + }; + }, + { type: 'object' }, + ); + } + + return schema; +} + +function describeSchemaShape(schema: any, options: { depth?: number; maxDepth?: number; maxProperties?: number } = {}): string | undefined { + if (!schema) { + return undefined; + } + + const normalizedSchema = normalizeCompositeSchema(schema); + + if (Array.isArray(normalizedSchema?.oneOf) || Array.isArray(normalizedSchema?.anyOf)) { + const variants = (normalizedSchema.oneOf ?? normalizedSchema.anyOf) + .map((candidate: any) => describeSchemaShape(candidate, options)) + .filter(Boolean); + return [...new Set(variants)].join(' | ') || 'value'; + } + + if (normalizedSchema.$ref && typeof normalizedSchema.$ref === 'string') { + return normalizedSchema.$ref.split('/').pop(); + } + + const depth = options.depth ?? 0; + const maxDepth = options.maxDepth ?? 2; + const maxProperties = options.maxProperties ?? 6; + const type = + inferParameterType(normalizedSchema) ?? + (normalizedSchema.properties ? 'object' : undefined) ?? + (normalizedSchema.items ? 'array' : undefined) ?? + (normalizedSchema.additionalProperties ? 'object' : undefined); + + if (!type) { + return 'value'; + } + + if (type === 'array') { + const itemShape = describeSchemaShape(normalizedSchema.items, { + depth: depth + 1, + maxDepth, + maxProperties, + }); + return itemShape ? `[${itemShape}]` : '[]'; + } + + if (type === 'object') { + const properties = Object.entries(normalizedSchema.properties ?? {}); + if (!properties.length) { + return undefined; + } + + const required = new Set(normalizedSchema.required ?? []); + const sortedProperties = [...properties].sort(([left], [right]) => Number(required.has(right)) - Number(required.has(left))); + + return `{${sortedProperties + .slice(0, maxProperties) + .map(([name, propertySchema]) => { + const nestedShape = + depth + 1 < maxDepth ? describeSchemaShape(propertySchema, { depth: depth + 1, maxDepth, maxProperties }) : undefined; + return `${name}${required.has(name) ? '' : '?'}: ${nestedShape ?? inferParameterType(propertySchema) ?? 'value'}`; + }) + .join(', ')}${sortedProperties.length > maxProperties ? ', ...' : ''}}`; + } + + return type; +} + +function extractBodyParameters(requestBody: any, usedFlagNames: Set) { + const schema = getJsonRequestSchema(requestBody); + const properties = normalizeCompositeSchema(schema)?.properties; + const required = new Set(normalizeCompositeSchema(schema)?.required ?? []); + + return Object.entries(properties ?? {}).map(([name, propertySchema]) => ({ + name, + flagName: createUniqueFlagName(toKebabCase(name), usedFlagNames), + in: 'body' as const, + required: required.has(name), + description: propertySchema.description, + type: inferParameterType(propertySchema), + isArray: propertySchema.type === 'array', + jsonEncoded: propertySchema.type === 'object' || propertySchema.type === 'array', + jsonShape: describeSchemaShape(propertySchema), + })); +} + +function splitParagraphs(value?: string) { + return (value ?? '') + .split(/\n\s*\n/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function resolveOperationText(operation: { summary?: string; description?: string }) { + const swaggerSummary = operation.summary?.trim() || undefined; + const descriptionParagraphs = splitParagraphs(operation.description?.trim()); + + if (descriptionParagraphs.length > 0) { + const [summary, ...rest] = descriptionParagraphs; + return { + summary, + description: rest.join('\n\n') || (swaggerSummary && swaggerSummary !== summary ? swaggerSummary : undefined), + }; + } + + return { + summary: swaggerSummary, + description: undefined, + }; +} + +function formatFlagExample(parameter: GeneratedParameter) { + if (parameter.type === 'boolean') { + return `--${parameter.flagName}`; + } + + if (parameter.type === 'object') { + return `--${parameter.flagName} '{\"key\":\"value\"}'`; + } + + if (parameter.isArray) { + return `--${parameter.flagName} value1 --${parameter.flagName} value2`; + } + + return `--${parameter.flagName} `; +} + +function getSampleJsonValue(parameter: GeneratedParameter): unknown { + if (parameter.type === 'boolean') { + return true; + } + + if (parameter.type === 'integer' || parameter.type === 'number') { + return 1; + } + + if (parameter.type === 'array') { + return []; + } + + if (parameter.type === 'object') { + return { key: 'value' }; + } + + return 'value'; +} + +function buildSampleBody(parameters: GeneratedParameter[]) { + const requiredBodyParameters = parameters.filter((parameter) => parameter.in === 'body' && parameter.required); + if (!requiredBodyParameters.length) { + return '{"key":"value"}'; + } + + return JSON.stringify( + Object.fromEntries(requiredBodyParameters.map((parameter) => [parameter.name, getSampleJsonValue(parameter)])), + ); +} + +export function buildExamples(commandId: string, operation: { parameters: GeneratedParameter[]; hasBody?: boolean }) { + const requiredParameters = operation.parameters.filter((parameter) => parameter.required); + const requiredFlags = requiredParameters.map(formatFlagExample); + const requiredNonBodyFlags = requiredParameters.filter((parameter) => parameter.in !== 'body').map(formatFlagExample); + const examples = [`nb api ${commandId}${requiredFlags.length ? ` ${requiredFlags.join(' ')}` : ''}`]; + const firstOptional = operation.parameters.find((parameter) => !parameter.required); + + if (firstOptional) { + examples.push(`${examples[0]} ${formatFlagExample(firstOptional)}`); + } + + if (operation.hasBody) { + const prefix = `nb api ${commandId}${requiredNonBodyFlags.length ? ` ${requiredNonBodyFlags.join(' ')}` : ''}`; + examples.push(`${prefix} --body '${buildSampleBody(operation.parameters)}'`); + } + + return [...new Set(examples)]; +} + +function buildDescription(operation: { + moduleDisplayName?: string; + moduleDescription?: string; + resourceDisplayName?: string; + resourceDescription?: string; + method: string; + pathTemplate: string; + tags?: string[]; + description?: string; + hasBody?: boolean; + parameters: GeneratedParameter[]; +}) { + const sections: string[] = []; + + if (operation.description) { + sections.push(operation.description); + } + + if (operation.moduleDisplayName || operation.moduleDescription) { + sections.push( + [operation.moduleDisplayName ? `Module: ${operation.moduleDisplayName}` : undefined, operation.moduleDescription] + .filter(Boolean) + .join('\n'), + ); + } + + if (operation.resourceDisplayName || operation.resourceDescription) { + sections.push( + [operation.resourceDisplayName ? `Resource: ${operation.resourceDisplayName}` : undefined, operation.resourceDescription] + .filter(Boolean) + .join('\n'), + ); + } + + sections.push(`HTTP ${operation.method.toUpperCase()} ${operation.pathTemplate}`); + if (operation.tags?.length) { + sections.push(`Tags: ${operation.tags.join(', ')}`); + } + + if (operation.hasBody) { + const bodyFlags = operation.parameters.filter((parameter) => parameter.in === 'body').map((parameter) => `--${parameter.flagName}`); + sections.push( + bodyFlags.length + ? `Request body: use body field flags (${bodyFlags.join(', ')}) or pass raw JSON via \`--body\` / \`--body-file\`.` + : 'Request body: JSON via `--body` or `--body-file`.', + ); + } + + return sections.join('\n\n'); +} + +function shouldIncludeResource(resourceKey: string | undefined, moduleConfig: { resources?: { includes?: string[]; excludes?: string[] } }) { + if (!resourceKey) { + return false; + } + + const includes = moduleConfig.resources?.includes; + const excludes = moduleConfig.resources?.excludes; + + if (includes?.length && !includes.some((pattern) => matchesPattern(resourceKey, pattern))) { + return false; + } + + if (excludes?.length && excludes.some((pattern) => matchesPattern(resourceKey, pattern))) { + return false; + } + + return true; +} + +function getPrimaryResourceKey(pathTemplate: string) { + return pathTemplate + .replace(/^\/+/, '') + .split(/[/:]/) + .find((segment) => segment && !segment.startsWith('{')); +} + +function getOperationMatchKeys(operation: { method: string; pathTemplate: string; operationId?: string }) { + const normalizedPath = operation.pathTemplate.replace(/^\/+/, ''); + const action = normalizedPath.includes(':') ? normalizedPath.slice(normalizedPath.lastIndexOf(':') + 1) : 'call'; + const resourcePath = normalizedPath.includes(':') ? normalizedPath.slice(0, normalizedPath.lastIndexOf(':')) : normalizedPath; + + return [ + action, + operation.operationId, + operation.pathTemplate, + `${resourcePath}:${action}`, + `${operation.method.toLowerCase()}:${operation.pathTemplate}`, + `${operation.method.toUpperCase()}:${operation.pathTemplate}`, + ].filter((value): value is string => Boolean(value)); +} + +function shouldIncludeOperation( + operation: { method: string; pathTemplate: string; operationId?: string }, + resourceConfig?: { operations?: { includes?: string[]; excludes?: string[] } }, +) { + const includes = resourceConfig?.operations?.includes; + const excludes = resourceConfig?.operations?.excludes; + const matchKeys = getOperationMatchKeys(operation); + + if (includes?.length && !includes.some((pattern) => matchKeys.some((value) => matchesPattern(value, pattern)))) { + return false; + } + + if (excludes?.length && excludes.some((pattern) => matchKeys.some((value) => matchesPattern(value, pattern)))) { + return false; + } + + return true; +} + +function scoreModuleMatch( + moduleKey: string, + moduleConfig: any, + resourceKey: string | undefined, + operation: { method: string; pathTemplate: string; operationId?: string; tags?: string[] }, +) { + if (!shouldIncludeResource(resourceKey, moduleConfig)) { + return -1; + } + + const resourceConfig = resourceKey ? moduleConfig.resources?.overrides?.[resourceKey] : undefined; + if (!shouldIncludeOperation(operation, resourceConfig)) { + return -1; + } + + let score = 10; + if (resourceConfig?.operations?.includes?.length) { + score += 100; + } + + const moduleResourceNames = new Set( + Object.entries(moduleConfig.resources?.overrides ?? {}).flatMap(([resourceName, resourceConfigValue]) => [ + toKebabCase(resourceName), + toKebabCase(resourceConfigValue?.name ?? resourceName), + ]), + ); + const tagTokens = (operation.tags ?? []).flatMap((tag) => tag.split(/[./:]/).map((part) => toKebabCase(part)).filter(Boolean)); + const tokenMatches = tagTokens.filter((token) => moduleResourceNames.has(token)); + score += tokenMatches.length * 5; + + if (operation.pathTemplate.includes('/desktopRoutes')) { + score += moduleKey === 'client' ? 20 : 0; + } + + return score; +} + +function resolveModuleKey(buildConfig: Awaited>, operation: { method: string; pathTemplate: string; operationId?: string; tags?: string[] }) { + const resourceKey = getPrimaryResourceKey(operation.pathTemplate); + const candidates = Object.entries(buildConfig.modules ?? {}) + .filter(([, moduleConfig]) => moduleConfig.include !== false) + .map(([moduleKey, moduleConfig]) => ({ + moduleKey, + moduleConfig, + score: scoreModuleMatch(moduleKey, moduleConfig, resourceKey, operation), + })) + .filter((item) => item.score >= 0) + .sort((left, right) => right.score - left.score); + + return candidates[0]; +} + +export async function generateRuntime(document: OpenApiDocument, configFile: string, baseUrl?: string): Promise { + const buildConfig = await loadBuildConfig(configFile); + const commands: GeneratedOperation[] = []; + + for (const { method, pathTemplate, operation } of collectOperations(document)) { + const resolvedModule = resolveModuleKey(buildConfig, { + method, + pathTemplate, + operationId: operation.operationId, + tags: operation.tags, + }); + + if (!resolvedModule) { + continue; + } + + const { moduleKey, moduleConfig } = resolvedModule; + const resourceKey = getPrimaryResourceKey(pathTemplate); + const resourceConfig = resourceKey ? moduleConfig.resources?.overrides?.[resourceKey] : undefined; + const usedFlagNames = new Set(); + const parameters = (operation.parameters ?? []).filter(isSupportedParameter).map((parameter) => toGeneratedParameter(parameter, usedFlagNames)); + const bodyParameters = extractBodyParameters(operation.requestBody, usedFlagNames); + const allParameters = [...parameters, ...bodyParameters]; + const hasBody = Boolean(operation.requestBody && !('$ref' in operation.requestBody)); + const moduleDisplayName = moduleConfig.name ?? moduleKey; + const moduleDescription = moduleConfig.description; + const resourceDisplayName = resourceConfig?.name ?? resourceKey; + const resourceDescription = resourceConfig?.description; + const operationText = resolveOperationText({ + summary: operation.summary, + description: operation.description, + }); + const resourceSegments = toResourceSegments(pathTemplate); + const mappedResourceSegments = + resourceSegments.length && resourceConfig?.name + ? [toKebabCase(resourceConfig.name), ...resourceSegments.slice(1)] + : resourceSegments; + const segments = [ + ...(resourceConfig?.topLevel ? [] : [toKebabCase(moduleDisplayName)]), + ...mappedResourceSegments, + ]; + + commands.push({ + moduleName: moduleKey, + moduleDisplayName, + moduleDescription, + resourceName: resourceKey, + logicalResourceName: toLogicalResourceName(pathTemplate), + actionName: toLogicalActionName(pathTemplate), + resourceDisplayName, + resourceDescription, + commandId: segments.join(' '), + method, + pathTemplate, + tags: operation.tags, + summary: operationText.summary ?? `${method.toUpperCase()} ${pathTemplate}`, + description: buildDescription({ + moduleDisplayName: resourceConfig?.topLevel ? undefined : moduleDisplayName, + moduleDescription: resourceConfig?.topLevel ? undefined : moduleDescription, + resourceDisplayName, + resourceDescription, + method, + pathTemplate, + tags: operation.tags, + description: operationText.description, + hasBody, + parameters: allParameters, + }), + examples: buildExamples(segments.join(' '), { + parameters: allParameters, + hasBody, + }), + parameters: allParameters, + hasBody, + bodyRequired: operation.requestBody && !('$ref' in operation.requestBody) ? operation.requestBody.required : undefined, + }); + } + + const schemaHash = createHash('sha1').update(JSON.stringify(document)).digest('hex').slice(0, 8); + + return { + version: String(document.info?.version ?? 'unknown'), + schemaHash, + generatedAt: new Date().toISOString(), + baseUrl, + commands: commands.sort((left, right) => left.commandId.localeCompare(right.commandId)), + }; +} diff --git a/packages/core/cli/src/lib/runtime-store.ts b/packages/core/cli/src/lib/runtime-store.ts new file mode 100644 index 00000000000..da487ca5f31 --- /dev/null +++ b/packages/core/cli/src/lib/runtime-store.ts @@ -0,0 +1,78 @@ +import fs, { promises as fsp } from 'node:fs'; +import path from 'node:path'; +import type { GeneratedOperation } from './generated-command.js'; +import type { CliHomeScope } from './cli-home.js'; +import { resolveCliHomeDir } from './cli-home.js'; + +export interface StoredRuntime { + version: string; + schemaHash?: string; + generatedAt: string; + baseUrl?: string; + commands: GeneratedOperation[]; +} + +export interface RuntimeStoreOptions { + scope?: CliHomeScope; +} + +function getHomeDir(options: RuntimeStoreOptions = {}) { + return resolveCliHomeDir(options.scope); +} + +export function getVersionsDir(options: RuntimeStoreOptions = {}) { + return path.join(getHomeDir(options), 'versions'); +} + +export function getVersionDir(version: string, options: RuntimeStoreOptions = {}) { + return path.join(getVersionsDir(options), version); +} + +function getRuntimeFile(version: string, options: RuntimeStoreOptions = {}) { + return path.join(getVersionDir(version, options), 'commands.json'); +} + +export async function saveRuntime(runtime: StoredRuntime, options: RuntimeStoreOptions = {}) { + const versionDir = getVersionDir(runtime.version, options); + await fsp.mkdir(versionDir, { recursive: true }); + await fsp.writeFile(getRuntimeFile(runtime.version, options), JSON.stringify(runtime, null, 2)); +} + +export async function loadRuntime(version: string, options: RuntimeStoreOptions = {}) { + try { + const content = await fsp.readFile(getRuntimeFile(version, options), 'utf8'); + return JSON.parse(content) as StoredRuntime; + } catch (error) { + return undefined; + } +} + +export function loadRuntimeSync(version?: string, options: RuntimeStoreOptions = {}) { + if (!version) { + return undefined; + } + + try { + const content = fs.readFileSync(getRuntimeFile(version, options), 'utf8'); + return JSON.parse(content) as StoredRuntime; + } catch (error) { + return undefined; + } +} + +export async function listRuntimes(options: RuntimeStoreOptions = {}) { + try { + const entries = await fsp.readdir(getVersionsDir(options), { withFileTypes: true }); + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); + } catch (error) { + return []; + } +} + +export async function deleteRuntime(version: string, options: RuntimeStoreOptions = {}) { + await fsp.rm(getVersionDir(version, options), { recursive: true, force: true }); +} + +export function hasRuntimeSync(version?: string, options: RuntimeStoreOptions = {}) { + return version ? fs.existsSync(getRuntimeFile(version, options)) : false; +} diff --git a/packages/core/cli/src/lib/ui.ts b/packages/core/cli/src/lib/ui.ts new file mode 100644 index 00000000000..80b158555d9 --- /dev/null +++ b/packages/core/cli/src/lib/ui.ts @@ -0,0 +1,209 @@ +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import ora, { type Ora } from 'ora'; +import pc from 'picocolors'; + +let activeSpinner: Ora | undefined; +let verboseMode = false; + +function stringWidth(value: string) { + return Array.from(value).length; +} + +function pad(value: string, width: number) { + const padding = Math.max(0, width - stringWidth(value)); + return `${value}${' '.repeat(padding)}`; +} + +export function isInteractiveTerminal() { + return Boolean(input.isTTY && output.isTTY); +} + +export function setVerboseMode(value: boolean) { + verboseMode = value; +} + +export function isVerboseMode() { + return verboseMode; +} + +export async function promptText(message: string, options?: { defaultValue?: string; secret?: boolean }) { + if (!isInteractiveTerminal()) { + return options?.defaultValue ?? ''; + } + + const rl = readline.createInterface({ + input, + output, + terminal: true, + }); + + try { + const suffix = options?.defaultValue ? ` (${options.defaultValue})` : ''; + const hint = options?.secret ? ' [input visible]' : ''; + const prompt = `${message}${suffix}${hint}: `; + const answer = await rl.question(prompt); + return answer.trim() || options?.defaultValue || ''; + } finally { + rl.close(); + } +} + +export async function confirmAction(message: string, options?: { defaultValue?: boolean }) { + if (!isInteractiveTerminal()) { + return Boolean(options?.defaultValue); + } + + stopTask(); + + const rl = readline.createInterface({ + input, + output, + terminal: true, + }); + + try { + const suffix = options?.defaultValue ? pc.dim('[Y/n]') : pc.dim('[y/N]'); + const prompt = `${pc.yellow('?')} ${pc.bold(message)} ${suffix} `; + const answer = await rl.question(prompt); + const normalized = answer.trim().toLowerCase(); + + if (!normalized) { + return Boolean(options?.defaultValue); + } + + return normalized === 'y' || normalized === 'yes'; + } finally { + rl.close(); + } +} + +export function printSection(title: string) { + console.log(pc.bold(title)); +} + +export function printInfo(message: string) { + if (activeSpinner) { + if (!isInteractiveTerminal()) { + activeSpinner = undefined; + console.log(pc.cyan(message)); + return; + } + + activeSpinner.info(pc.cyan(message)); + activeSpinner = undefined; + return; + } + + console.log(pc.cyan(message)); +} + +export function printVerbose(message: string) { + if (!verboseMode) { + return; + } + + printInfo(message); +} + +export function printSuccess(message: string) { + if (activeSpinner) { + if (!isInteractiveTerminal()) { + activeSpinner = undefined; + console.log(pc.green(message)); + return; + } + + activeSpinner.succeed(pc.green(message)); + activeSpinner = undefined; + return; + } + + console.log(pc.green(message)); +} + +export function printWarning(message: string) { + if (activeSpinner) { + if (!isInteractiveTerminal()) { + activeSpinner = undefined; + console.log(pc.yellow(message)); + return; + } + + activeSpinner.warn(pc.yellow(message)); + activeSpinner = undefined; + return; + } + + console.log(pc.yellow(message)); +} + +export function printVerboseWarning(message: string) { + if (!verboseMode) { + return; + } + + printWarning(message); +} + +export function startTask(message: string) { + if (activeSpinner) { + activeSpinner.stop(); + } + + activeSpinner = ora({ + text: pc.cyan(message), + isSilent: !isInteractiveTerminal(), + }).start(); + + if (!isInteractiveTerminal()) { + console.log(pc.cyan(message)); + } +} + +export function updateTask(message: string) { + if (!activeSpinner) { + startTask(message); + return; + } + + activeSpinner.text = pc.cyan(message); +} + +export function succeedTask(message: string) { + if (activeSpinner) { + activeSpinner.succeed(pc.green(message)); + activeSpinner = undefined; + return; + } + + console.log(pc.green(message)); +} + +export function failTask(message: string) { + if (activeSpinner) { + activeSpinner.fail(pc.red(message)); + activeSpinner = undefined; + return; + } + + console.error(pc.red(message)); +} + +export function stopTask() { + if (activeSpinner) { + activeSpinner.stop(); + activeSpinner = undefined; + } +} + +export function renderTable(headers: string[], rows: string[][]) { + const widths = headers.map((header, index) => { + return rows.reduce((max, row) => Math.max(max, stringWidth(row[index] ?? '')), stringWidth(header)); + }); + + const renderRow = (row: string[]) => row.map((cell, index) => pad(cell ?? '', widths[index])).join(' ').trimEnd(); + + const divider = widths.map((width) => '-'.repeat(width)).join(' '); + return [renderRow(headers), divider, ...rows.map(renderRow)].join('\n'); +} diff --git a/packages/core/cli/src/post-processors/data-modeling.ts b/packages/core/cli/src/post-processors/data-modeling.ts new file mode 100644 index 00000000000..94bd75e802d --- /dev/null +++ b/packages/core/cli/src/post-processors/data-modeling.ts @@ -0,0 +1,77 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { postProcessorRegistry } from '../lib/post-processors.js'; + +function toArray(value: any) { + if (Array.isArray(value)) { + return value; + } + + if (Array.isArray(value?.data)) { + return value.data; + } + + return []; +} + +function pickCollectionSummary(item: Record) { + return { + key: item?.key, + name: item?.name, + title: item?.title, + description: item?.description, + }; +} + +function pickFieldSummary(item: Record) { + return { + key: item?.key, + name: item?.name, + type: item?.type, + title: item?.uiSchema?.title, + description: item?.description, + collectionName: item?.collectionName, + }; +} + +function simplifyCollectionsListResult(result: any) { + const items = toArray(result); + + return { + data: items.map((item: Record) => pickCollectionSummary(item)), + meta: result?.meta, + nextActions: [ + 'Use collections:get with filterByTk= to inspect one collection in detail.', + 'Use collections:apply with --verify when you need normalized verification in the same response.', + ], + }; +} + +function simplifyFieldsListResult(result: any) { + const items = toArray(result); + + return { + data: items.map((item: Record) => pickFieldSummary(item)), + meta: result?.meta, + }; +} + +export function registerDataModelingPostProcessors() { + postProcessorRegistry.register('collections', 'list', simplifyCollectionsListResult); + postProcessorRegistry.register('collections', 'apply', (result: any) => ({ + data: pickCollectionSummary(result?.data || {}), + verify: result?.verify, + })); + postProcessorRegistry.register('collections', 'verify', (result: any) => result); + postProcessorRegistry.register('fields', 'apply', (result: any) => ({ + data: pickFieldSummary(result?.data || {}), + })); + postProcessorRegistry.register('collections.fields', 'list', simplifyFieldsListResult); +} diff --git a/packages/core/cli/src/post-processors/data-source-manager.ts b/packages/core/cli/src/post-processors/data-source-manager.ts new file mode 100644 index 00000000000..e43a1e77953 --- /dev/null +++ b/packages/core/cli/src/post-processors/data-source-manager.ts @@ -0,0 +1,153 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { postProcessorRegistry } from '../lib/post-processors.js'; + +function toArray(value: any) { + if (Array.isArray(value)) { + return value; + } + + if (Array.isArray(value?.data)) { + return value.data; + } + + return []; +} + +function parseAssociatedIndex(value: unknown) { + if (typeof value !== 'string') { + return {}; + } + + const separatorIndex = value.indexOf('.'); + if (separatorIndex === -1) { + return {}; + } + + return { + dataSourceKey: value.slice(0, separatorIndex), + collectionName: value.slice(separatorIndex + 1), + }; +} + +function pickCollectionSummary(item: Record) { + return { + name: item?.name, + title: item?.title ?? item?.options?.title ?? item?.displayName, + description: item?.description ?? item?.options?.description, + dataSourceKey: item?.dataSourceKey, + }; +} + +function pickFieldSummary(item: Record) { + return { + name: item?.name, + type: item?.type, + title: item?.uiSchema?.title, + description: item?.description, + dataSourceKey: item?.dataSourceKey, + collectionName: item?.collectionName, + }; +} + +function pickRemoteFieldSummary( + item: Record, + defaults: { + dataSourceKey?: string; + collectionName?: string; + }, +) { + return { + name: item?.name, + type: item?.type, + title: item?.uiSchema?.title, + description: item?.description, + dataSourceKey: item?.dataSourceKey, + collectionName: item?.collectionName, + ...defaults, + }; +} + +function pickDataSourceSummary(item: Record) { + const summary: Record = { + displayName: item?.displayName, + key: item?.key, + type: item?.type, + status: item?.status, + }; + + if (Array.isArray(item?.collections)) { + summary.collections = item.collections.map((collection: Record) => { + const nextCollection: Record = pickCollectionSummary({ + ...collection, + dataSourceKey: collection?.dataSourceKey ?? item?.key, + }); + + if (Array.isArray(collection?.fields)) { + nextCollection.fields = collection.fields.map((field: Record) => + pickRemoteFieldSummary(field, { + dataSourceKey: collection?.dataSourceKey ?? item?.key, + collectionName: collection?.name, + }), + ); + } + + return nextCollection; + }); + } + + return summary; +} + +export function simplifyDataSourceListResult(result: any) { + const items = toArray(result); + + return { + data: items.map((item: Record) => pickDataSourceSummary(item)), + meta: result?.meta, + }; +} + +export function simplifyDataSourceCollectionsListResult(result: any) { + const items = toArray(result); + + return { + data: items.map((item: Record) => pickCollectionSummary(item)), + meta: result?.meta, + }; +} + +export function simplifyDataSourceFieldsListResult( + result: any, + options?: { + flags?: Record; + }, +) { + const items = toArray(result); + const defaults = parseAssociatedIndex(options?.flags?.['associated-index']); + + return { + data: items.map((item: Record) => + item?.dataSourceKey || item?.collectionName ? pickFieldSummary(item) : pickRemoteFieldSummary(item, defaults), + ), + meta: result?.meta, + }; +} + +export function registerDataSourceManagerPostProcessors() { + postProcessorRegistry.register('data-sources', 'list', simplifyDataSourceListResult); + postProcessorRegistry.register('data-sources', 'list-enabled', simplifyDataSourceListResult); + postProcessorRegistry.register('data-sources.collections', 'list', simplifyDataSourceCollectionsListResult); + postProcessorRegistry.register('data-sources-collections.fields', 'list', (result, context) => + simplifyDataSourceFieldsListResult(result, { + flags: context.flags, + }), + ); +} diff --git a/packages/core/cli/src/post-processors/index.ts b/packages/core/cli/src/post-processors/index.ts new file mode 100644 index 00000000000..6e5a843c0f9 --- /dev/null +++ b/packages/core/cli/src/post-processors/index.ts @@ -0,0 +1,23 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { registerDataModelingPostProcessors } from './data-modeling.js'; +import { registerDataSourceManagerPostProcessors } from './data-source-manager.js'; + +let initialized = false; + +export function registerPostProcessors() { + if (initialized) { + return; + } + + registerDataModelingPostProcessors(); + registerDataSourceManagerPostProcessors(); + initialized = true; +} diff --git a/packages/core/cli/src/types/vendor.d.ts b/packages/core/cli/src/types/vendor.d.ts new file mode 100644 index 00000000000..5f46ac24b32 --- /dev/null +++ b/packages/core/cli/src/types/vendor.d.ts @@ -0,0 +1,31 @@ +declare module 'picocolors' { + const pc: { + bold(value: string): string; + cyan(value: string): string; + dim(value: string): string; + green(value: string): string; + yellow(value: string): string; + red(value: string): string; + }; + + export default pc; +} + +declare module 'ora' { + export interface Ora { + text: string; + start(): Ora; + stop(): Ora; + succeed(text?: string): Ora; + fail(text?: string): Ora; + warn(text?: string): Ora; + info(text?: string): Ora; + } + + export interface OraOptions { + text?: string; + isSilent?: boolean; + } + + export default function ora(options?: OraOptions): Ora; +} diff --git a/packages/core/cli/test/auth-store.test.ts b/packages/core/cli/test/auth-store.test.ts new file mode 100644 index 00000000000..c15d5badfaa --- /dev/null +++ b/packages/core/cli/test/auth-store.test.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { getEnv, saveAuthConfig, setEnvOauthSession, updateEnvConnection, upsertEnv } from '../src/lib/auth-store.js'; + +async function withTempCliHome(run: () => Promise) { + const previous = process.env.NOCOBASE_CTL_HOME; + const tempHome = await mkdtemp(path.join(os.tmpdir(), 'nocobase-ctl-test-')); + process.env.NOCOBASE_CTL_HOME = tempHome; + + try { + await run(); + } finally { + if (previous === undefined) { + delete process.env.NOCOBASE_CTL_HOME; + } else { + process.env.NOCOBASE_CTL_HOME = previous; + } + await rm(tempHome, { recursive: true, force: true }); + } +} + +test('upsertEnv clears runtime metadata when base URL or token changes', async () => { + await withTempCliHome(async () => { + await saveAuthConfig( + { + currentEnv: 'test', + envs: { + test: { + baseUrl: 'http://localhost:13000/api', + auth: { + type: 'token', + accessToken: 'old-token', + }, + runtime: { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }, + }, + }, + }, + { scope: 'global' }, + ); + + await upsertEnv('test', 'http://localhost:13000/api', 'new-token', { scope: 'global' }); + + const env = await getEnv('test', { scope: 'global' }); + assert.equal(env?.auth?.accessToken, 'new-token'); + assert.equal(env?.runtime, undefined); + }); +}); + +test('upsertEnv preserves runtime metadata when connection settings are unchanged', async () => { + await withTempCliHome(async () => { + await saveAuthConfig( + { + currentEnv: 'test', + envs: { + test: { + baseUrl: 'http://localhost:13000/api', + auth: { + type: 'token', + accessToken: 'same-token', + }, + runtime: { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }, + }, + }, + }, + { scope: 'global' }, + ); + + await upsertEnv('test', 'http://localhost:13000/api', 'same-token', { scope: 'global' }); + + const env = await getEnv('test', { scope: 'global' }); + assert.deepEqual(env?.runtime, { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }); + }); +}); + +test('upsertEnv allows saving an env without a token', async () => { + await withTempCliHome(async () => { + await upsertEnv('test', 'http://localhost:13000/api', undefined, { scope: 'global' }); + + const env = await getEnv('test', { scope: 'global' }); + assert.equal(env?.baseUrl, 'http://localhost:13000/api'); + assert.equal(env?.auth, undefined); + }); +}); + +test('upsertEnv clears an OAuth session when the base URL changes', async () => { + await withTempCliHome(async () => { + await saveAuthConfig( + { + currentEnv: 'test', + envs: { + test: { + baseUrl: 'http://localhost:13000/api', + auth: { + type: 'oauth', + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + issuer: 'http://localhost:13000/api', + clientId: 'client-1', + resource: 'http://localhost:13000/api/', + }, + }, + }, + }, + { scope: 'global' }, + ); + + await upsertEnv('test', 'http://localhost:14000/api', undefined, { scope: 'global' }); + + const env = await getEnv('test', { scope: 'global' }); + assert.equal(env?.baseUrl, 'http://localhost:14000/api'); + assert.equal(env?.auth, undefined); + }); +}); + +test('updateEnvConnection updates only the token and preserves the current base URL', async () => { + await withTempCliHome(async () => { + await saveAuthConfig( + { + currentEnv: 'test', + envs: { + test: { + baseUrl: 'http://localhost:13000/api', + auth: { + type: 'token', + accessToken: 'old-token', + }, + runtime: { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }, + }, + }, + }, + { scope: 'global' }, + ); + + await updateEnvConnection('test', { accessToken: 'new-token' }, { scope: 'global' }); + + const env = await getEnv('test', { scope: 'global' }); + assert.equal(env?.baseUrl, 'http://localhost:13000/api'); + assert.equal(env?.auth?.accessToken, 'new-token'); + assert.equal(env?.runtime, undefined); + }); +}); + +test('updateEnvConnection preserves runtime metadata when connection settings are unchanged', async () => { + await withTempCliHome(async () => { + await saveAuthConfig( + { + currentEnv: 'test', + envs: { + test: { + baseUrl: 'http://localhost:13000/api', + auth: { + type: 'token', + accessToken: 'same-token', + }, + runtime: { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }, + }, + }, + }, + { scope: 'global' }, + ); + + await updateEnvConnection('test', { accessToken: 'same-token' }, { scope: 'global' }); + + const env = await getEnv('test', { scope: 'global' }); + assert.deepEqual(env?.runtime, { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }); + }); +}); + +test('setEnvOauthSession can preserve runtime metadata during token refresh', async () => { + await withTempCliHome(async () => { + await saveAuthConfig( + { + currentEnv: 'test', + envs: { + test: { + baseUrl: 'http://localhost:13000/api', + auth: { + type: 'oauth', + accessToken: 'old-access-token', + refreshToken: 'refresh-token', + expiresAt: '2026-04-13T00:00:00.000Z', + scope: 'openid api offline_access', + issuer: 'http://localhost:13000/api', + clientId: 'client-1', + resource: 'http://localhost:13000/api/', + }, + runtime: { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }, + }, + }, + }, + { scope: 'global' }, + ); + + await setEnvOauthSession( + 'test', + { + type: 'oauth', + accessToken: 'new-access-token', + refreshToken: 'refresh-token', + expiresAt: '2026-04-14T00:00:00.000Z', + scope: 'openid api offline_access', + issuer: 'http://localhost:13000/api', + clientId: 'client-1', + resource: 'http://localhost:13000/api/', + }, + { scope: 'global', preserveRuntime: true }, + ); + + const env = await getEnv('test', { scope: 'global' }); + assert.equal(env?.auth?.type, 'oauth'); + assert.equal(env?.auth?.accessToken, 'new-access-token'); + assert.deepEqual(env?.runtime, { + version: 'v1', + schemaHash: 'hash', + generatedAt: '2026-04-13T00:00:00.000Z', + }); + }); +}); diff --git a/packages/core/cli/test/bootstrap.test.ts b/packages/core/cli/test/bootstrap.test.ts new file mode 100644 index 00000000000..e506ec94699 --- /dev/null +++ b/packages/core/cli/test/bootstrap.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { formatMissingRuntimeEnvError, formatSwaggerSchemaError, shouldSkipRuntimeBootstrap } from '../src/lib/bootstrap.js'; + +test('shouldSkipRuntimeBootstrap skips root help and no-arg invocations', () => { + assert.equal(shouldSkipRuntimeBootstrap([]), false); + assert.equal(shouldSkipRuntimeBootstrap(['--help']), false); + assert.equal(shouldSkipRuntimeBootstrap(['env', '--help']), true); + assert.equal(shouldSkipRuntimeBootstrap(['resource', 'list']), true); +}); + +test('shouldSkipRuntimeBootstrap still loads runtime for non-builtin commands', () => { + assert.equal(shouldSkipRuntimeBootstrap(['users', 'list']), false); + assert.equal(shouldSkipRuntimeBootstrap(['users', 'list', '--json-output']), false); +}); + +test('formatSwaggerSchemaError returns actionable guidance for invalid tokens', () => { + const message = formatSwaggerSchemaError( + { + status: 401, + data: { + errors: [ + { + message: 'Your session has expired. Please sign in again.', + code: 'INVALID_TOKEN', + }, + ], + }, + }, + { + baseUrl: 'http://localhost:13000/api', + envName: 'local', + commandToken: 'users', + }, + ); + + assert.match(message, /Authentication failed while loading the command runtime/); + assert.match(message, /env "local"/); + assert.match(message, /INVALID_TOKEN/); + assert.match(message, /env add --name --base-url --token /); + assert.match(message, /nb env update/); + assert.match(message, /nb --help/); +}); + +test('formatSwaggerSchemaError falls back to the raw swagger error for non-auth failures', () => { + const message = formatSwaggerSchemaError( + { + status: 500, + data: { + error: { + message: 'Internal Server Error', + }, + }, + }, + { + baseUrl: 'http://localhost:13000/api', + }, + ); + + assert.match(message, /^Failed to load swagger schema from `swagger:get`\./); + assert.match(message, /Internal Server Error/); +}); + +test('formatMissingRuntimeEnvError explains unknown runtime commands without an env', () => { + const message = formatMissingRuntimeEnvError('not-a-real-command'); + + assert.match(message, /Unable to resolve runtime command `not-a-real-command`/); + assert.match(message, /No env is configured/); + assert.match(message, /nb --help/); + assert.match(message, /nb env update/); +}); diff --git a/packages/core/cli/test/env-auth.test.ts b/packages/core/cli/test/env-auth.test.ts new file mode 100644 index 00000000000..6b9a021fa80 --- /dev/null +++ b/packages/core/cli/test/env-auth.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { saveAuthConfig } from '../src/lib/auth-store.js'; +import { getOauthMetadataUrl, getOauthResource, isOauthAccessTokenExpired, resolveAccessToken } from '../src/lib/env-auth.js'; + +async function withTempCliHome(run: () => Promise) { + const previous = process.env.NOCOBASE_CTL_HOME; + const tempHome = await mkdtemp(path.join(os.tmpdir(), 'nocobase-ctl-test-')); + process.env.NOCOBASE_CTL_HOME = tempHome; + + try { + await run(); + } finally { + if (previous === undefined) { + delete process.env.NOCOBASE_CTL_HOME; + } else { + process.env.NOCOBASE_CTL_HOME = previous; + } + await rm(tempHome, { recursive: true, force: true }); + } +} + +test('OAuth helpers derive metadata and resource URLs from base URL', () => { + assert.equal(getOauthMetadataUrl('http://localhost:13000/api/'), 'http://localhost:13000/api/.well-known/oauth-authorization-server'); + assert.equal(getOauthResource('http://localhost:13000/api/'), 'http://localhost:13000/api/'); + assert.equal(getOauthResource('https://demo.example.com/custom/api'), 'https://demo.example.com/custom/api/'); +}); + +test('isOauthAccessTokenExpired uses a refresh window', () => { + const now = Date.parse('2026-04-15T00:00:00.000Z'); + assert.equal( + isOauthAccessTokenExpired( + { + type: 'oauth', + accessToken: 'token', + expiresAt: '2026-04-15T00:00:30.000Z', + }, + now, + ), + true, + ); + assert.equal( + isOauthAccessTokenExpired( + { + type: 'oauth', + accessToken: 'token', + expiresAt: '2026-04-15T00:05:00.000Z', + }, + now, + ), + false, + ); +}); + +test('resolveAccessToken refreshes expired OAuth sessions', async () => { + await withTempCliHome(async () => { + await saveAuthConfig( + { + currentEnv: 'test', + envs: { + test: { + baseUrl: 'http://localhost:13000/api', + auth: { + type: 'oauth', + accessToken: 'expired-token', + refreshToken: 'refresh-token', + expiresAt: '2026-04-14T00:00:00.000Z', + issuer: 'http://localhost:13000/api', + clientId: 'client-1', + resource: 'http://localhost:13000/api/', + scope: 'openid api offline_access', + }, + }, + }, + }, + { scope: 'global' }, + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.endsWith('/.well-known/oauth-authorization-server')) { + return new Response( + JSON.stringify({ + issuer: 'http://localhost:13000/api', + authorization_endpoint: 'http://localhost:13000/api/idpOAuth/authorize', + token_endpoint: 'http://localhost:13000/api/idpOAuth/token', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } + + assert.equal(url, 'http://localhost:13000/api/idpOAuth/token'); + const body = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? '')); + assert.equal(body.get('grant_type'), 'refresh_token'); + assert.equal(body.get('client_id'), 'client-1'); + assert.equal(body.get('refresh_token'), 'refresh-token'); + assert.equal(body.get('resource'), 'http://localhost:13000/api/'); + + return new Response( + JSON.stringify({ + access_token: 'fresh-token', + refresh_token: 'refresh-token', + expires_in: 3600, + scope: 'openid api offline_access', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as typeof fetch; + + try { + const token = await resolveAccessToken({ + envName: 'test', + scope: 'global', + }); + assert.equal(token, 'fresh-token'); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/core/cli/test/generated-command-body-modes.test.ts b/packages/core/cli/test/generated-command-body-modes.test.ts new file mode 100644 index 00000000000..1c4081df2ae --- /dev/null +++ b/packages/core/cli/test/generated-command-body-modes.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Command } from '@oclif/core'; +import { parseBody, type RequestOperation } from '../src/lib/api-client.js'; +import { createGeneratedFlags, type GeneratedOperation } from '../src/lib/generated-command.js'; +import { buildExamples } from '../src/lib/runtime-generator.js'; + +const testApiOperation: GeneratedOperation = { + commandId: 'test api', + method: 'post', + pathTemplate: '/test:api', + parameters: [ + { + name: 'primaryValue', + flagName: 'primary-value', + in: 'body', + required: true, + type: 'string', + }, + { + name: 'items', + flagName: 'items', + in: 'body', + required: true, + type: 'array', + isArray: true, + jsonEncoded: true, + }, + ], + hasBody: true, + bodyRequired: true, + examples: [], +}; + +class ParseOnlyTestApiCommand extends Command { + static override flags = createGeneratedFlags(testApiOperation); + + async run() { + return this.parse(ParseOnlyTestApiCommand); + } +} + +test('body JSON path should not require body field flags at parse time', async () => { + const result = await ParseOnlyTestApiCommand.run(['--body', '{"primaryValue":"ok","items":[]}']); + assert.deepEqual(result.flags, { + body: '{"primaryValue":"ok","items":[]}', + 'json-output': true, + verbose: false, + }); +}); + +test('body-file path should not require inline body or body field flags at parse time', async () => { + const result = await ParseOnlyTestApiCommand.run(['--body-file', '/tmp/test-api.json']); + assert.equal(result.flags['body-file'], '/tmp/test-api.json'); + assert.equal(result.flags.body, undefined); +}); + +test('parseBody should still enforce required body fields when flag mode is used', async () => { + const operation: RequestOperation = { + method: 'post', + pathTemplate: '/test:api', + parameters: testApiOperation.parameters, + hasBody: true, + bodyRequired: true, + }; + + await assert.rejects( + () => parseBody({ 'primary-value': 'ok' }, operation), + /Missing required body field --items/, + ); +}); + +test('parseBody should accept raw body JSON without checking sibling flags', async () => { + const operation: RequestOperation = { + method: 'post', + pathTemplate: '/test:api', + parameters: testApiOperation.parameters, + hasBody: true, + bodyRequired: true, + }; + + const body = await parseBody({ body: '{"primaryValue":"ok","items":[]}' }, operation); + assert.deepEqual(body, { primaryValue: 'ok', items: [] }); +}); + +test('parseBody should describe conflicting raw body and body flags clearly', async () => { + const operation: RequestOperation = { + method: 'post', + pathTemplate: '/test:api', + parameters: testApiOperation.parameters, + hasBody: true, + bodyRequired: true, + }; + + await assert.rejects( + () => parseBody({ body: '{"primaryValue":"ok","items":[]}', 'primary-value': 'ok' }, operation), + /Conflicting request body inputs: received --body together with body field flags \(\-\-primary-value\)/, + ); +}); + +test('buildExamples should not mix required body flags with --body examples', () => { + const examples = buildExamples('test api', { + parameters: testApiOperation.parameters, + hasBody: true, + }); + + assert.deepEqual(examples, [ + 'nb test api --primary-value --items value1 --items value2', + `nb test api --body '{"primaryValue":"value","items":[]}'`, + ]); +}); + +test('createGeneratedFlags should group body, raw JSON body, and global flags separately for help output', () => { + const flags = createGeneratedFlags(testApiOperation); + + assert.equal(flags['primary-value'].helpGroup, 'Body Field'); + assert.equal(flags.items.helpGroup, 'Body Field'); + assert.equal(flags.body.helpGroup, 'Raw JSON Body'); + assert.equal(flags['body-file'].helpGroup, 'Raw JSON Body'); + assert.equal(flags.env.helpGroup, 'Global'); + assert.equal(flags['base-url'].helpGroup, 'Global'); +}); diff --git a/packages/core/cli/tsconfig.json b/packages/core/cli/tsconfig.json new file mode 100644 index 00000000000..a4ba2ed3522 --- /dev/null +++ b/packages/core/cli/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist", + "declaration": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "types": ["node"], + "skipLibCheck": true, + "strict": false + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/core/client/package.json b/packages/core/client/package.json index 2f21cb42db0..98be274ffa5 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -55,7 +55,7 @@ "json5": "^2.2.3", "liquidjs": "^10.0.0", "lodash": "4.17.21", - "lru-cache": "6.0.0", + "lru-cache": "^11.3.5", "markdown-it": "14.1.0", "markdown-it-highlightjs": "3.3.1", "mathjs": "^15.1.0", diff --git a/packages/core/client/src/schema-settings/DataTemplates/utils.tsx b/packages/core/client/src/schema-settings/DataTemplates/utils.tsx index 133128c3389..a420309409a 100644 --- a/packages/core/client/src/schema-settings/DataTemplates/utils.tsx +++ b/packages/core/client/src/schema-settings/DataTemplates/utils.tsx @@ -17,7 +17,7 @@ import { useCollectionManager_deprecated } from '../../collection-manager'; import { useCompile } from '../../schema-component'; import { TreeNode } from './TreeLabel'; import { systemKeys } from './hooks/useCollectionState'; -import LRUCache from 'lru-cache'; +import { LRUCache } from 'lru-cache'; export const useSyncFromForm = (fieldSchema, collection?, callBack?) => { const { getCollectionJoinField, getCollectionFields } = useCollectionManager_deprecated(); diff --git a/packages/core/create-nocobase-app/src/generator.js b/packages/core/create-nocobase-app/src/generator.js index bd347b29862..1c5a6d61abc 100644 --- a/packages/core/create-nocobase-app/src/generator.js +++ b/packages/core/create-nocobase-app/src/generator.js @@ -144,7 +144,7 @@ class AppGenerator extends Generator { ...(await fs.readJSON(join(this.cwd, 'package.json'), 'utf8')), }; - json['dependencies']['@nocobase/cli'] = context.version; + json['dependencies']['@nocobase/app'] = context.version; if (!this.args.skipDevDependencies) { json['devDependencies'] = json['devDependencies'] || {}; diff --git a/packages/core/create-nocobase-app/templates/app/package.json b/packages/core/create-nocobase-app/templates/app/package.json index 0a0545f28b9..b71f736971d 100644 --- a/packages/core/create-nocobase-app/templates/app/package.json +++ b/packages/core/create-nocobase-app/templates/app/package.json @@ -8,17 +8,17 @@ "node": ">=18" }, "scripts": { - "nocobase": "nocobase", - "pm": "nocobase pm", - "pm2": "nocobase pm2", - "dev": "nocobase dev", - "start": "nocobase start", - "clean": "nocobase clean", - "build": "nocobase build", - "test": "nocobase test", - "e2e": "nocobase e2e", - "tar": "nocobase tar", - "postinstall": "nocobase postinstall", + "nocobase": "nocobase-v1", + "pm": "nocobase-v1 pm", + "pm2": "nocobase-v1 pm2", + "dev": "nocobase-v1 dev", + "start": "nocobase-v1 start", + "clean": "nocobase-v1 clean", + "build": "nocobase-v1 build", + "test": "nocobase-v1 test", + "e2e": "nocobase-v1 e2e", + "tar": "nocobase-v1 tar", + "postinstall": "nocobase-v1 postinstall", "lint": "eslint ." }, "resolutions": { diff --git a/packages/core/devtools/umiConfig.js b/packages/core/devtools/umiConfig.js index 28454ff0075..b8aaafe6687 100644 --- a/packages/core/devtools/umiConfig.js +++ b/packages/core/devtools/umiConfig.js @@ -1,5 +1,5 @@ const packageJson = require('./package.json'); -const { resolvePublicPath, resolveV2PublicPath } = require('../cli/src/util'); +const { resolvePublicPath, resolveV2PublicPath } = require('../cli-v1/src/util'); const { getPackagePaths, IndexGenerator, generatePlugins, generateAllPlugins } = require('./common.js'); console.log('VERSION: ', packageJson.version); diff --git a/packages/core/server/src/commands/pm.ts b/packages/core/server/src/commands/pm.ts index 7c50dcbdb1b..d38fbd327ef 100644 --- a/packages/core/server/src/commands/pm.ts +++ b/packages/core/server/src/commands/pm.ts @@ -12,39 +12,13 @@ import { AppSupervisor } from '../app-supervisor'; import Application from '../application'; import { PluginCommandError } from '../errors/plugin-command-error'; -import { PluginManager } from '../plugin-manager'; -import { findBuiltInPlugins, findLocalPlugins } from '../plugin-manager/findPackageNames'; +import { pmListSummary } from '../plugin-manager/utils'; export default (app: Application) => { const pm = app.command('pm'); pm.command('list').action(async () => { - const plugins1 = await findBuiltInPlugins(); - const plugins2 = await findLocalPlugins(); - let enabledPlugins = []; - try { - enabledPlugins = ( - await app.pm.repository.find({ - filter: { - enabled: true, - }, - }) - ).map((item) => item.packageName); - } catch (error) { - // ignore error - } - const items = await Promise.all( - [...plugins1, ...plugins2].map(async (name) => { - const item = await PluginManager.parseName(name); - const json = await PluginManager.getPackageJson(item.packageName); - return { - displayName: json.displayName || name, - packageName: item.packageName, - enabled: enabledPlugins.includes(item.packageName), - description: json.description, - }; - }), - ); + const items = await pmListSummary(app); console.log('--- BEGIN_PLUGIN_LIST_JSON ---'); console.log(JSON.stringify(items)); console.log('--- END_PLUGIN_LIST_JSON ---'); diff --git a/packages/core/server/src/plugin-manager/options/resource.ts b/packages/core/server/src/plugin-manager/options/resource.ts index 0806ea83f7d..093979015ad 100644 --- a/packages/core/server/src/plugin-manager/options/resource.ts +++ b/packages/core/server/src/plugin-manager/options/resource.ts @@ -7,7 +7,6 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Cache } from '@nocobase/cache'; import { uid } from '@nocobase/utils'; import fs from 'fs'; import fse from 'fs-extra'; @@ -15,6 +14,7 @@ import path from 'path'; import Application from '../../application'; import PluginManager from '../plugin-manager'; import crypto from 'crypto'; +import { pmListSummary } from '../utils'; import packageJson from '../../../package.json'; @@ -231,6 +231,11 @@ export default { await next(); }, async list(ctx, next) { + const { mode } = ctx.action.params; + if (mode === 'summary') { + ctx.body = await pmListSummary(ctx.app); + return next(); + } const locale = ctx.getCurrentLocale(); const pm = ctx.app.pm as PluginManager; // ctx.body = await pm.list({ locale, isPreset: false }); diff --git a/packages/core/server/src/plugin-manager/utils.ts b/packages/core/server/src/plugin-manager/utils.ts index 60ccc8b82ad..12a185ab552 100644 --- a/packages/core/server/src/plugin-manager/utils.ts +++ b/packages/core/server/src/plugin-manager/utils.ts @@ -33,6 +33,9 @@ import { import deps from './deps'; import { PluginManagerRepository } from './plugin-manager-repository'; import { PluginData } from './types'; +import Application from '../application'; +import { findBuiltInPlugins, findLocalPlugins } from './findPackageNames'; +import PluginManager from './plugin-manager'; /** * get temp dir @@ -601,3 +604,33 @@ export async function getPluginBasePath(packageName: string) { } return path.dirname(path.dirname(file)); } + +export async function pmListSummary(app: Application) { + const plugins1 = await findBuiltInPlugins(); + const plugins2 = await findLocalPlugins(); + let enabledPlugins = []; + try { + enabledPlugins = ( + await app.pm.repository.find({ + filter: { + enabled: true, + }, + }) + ).map((item) => item.packageName); + } catch (error) { + // ignore error + } + const items = await Promise.all( + [...plugins1, ...plugins2].map(async (name) => { + const item = await PluginManager.parseName(name); + const json = await PluginManager.getPackageJson(item.packageName); + return { + displayName: json.displayName || name, + packageName: item.packageName, + enabled: enabledPlugins.includes(item.packageName), + description: json.description, + }; + }), + ); + return items; +} diff --git a/packages/core/test/setup/server.ts b/packages/core/test/setup/server.ts index d8b62c18bd3..35f23a2077c 100644 --- a/packages/core/test/setup/server.ts +++ b/packages/core/test/setup/server.ts @@ -1,4 +1,4 @@ -import { initEnv } from '@nocobase/cli'; +import { initEnv } from '@nocobase/cli-v1'; process.env.APP_ENV_PATH = process.env.APP_ENV_PATH || '.env.test'; diff --git a/packages/plugins/@nocobase/plugin-action-duplicate/src/client/models/utils.tsx b/packages/plugins/@nocobase/plugin-action-duplicate/src/client/models/utils.tsx index c54ec2b4b07..01cf71fe8d3 100644 --- a/packages/plugins/@nocobase/plugin-action-duplicate/src/client/models/utils.tsx +++ b/packages/plugins/@nocobase/plugin-action-duplicate/src/client/models/utils.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { Tag } from 'antd'; import { ArrayBase } from '@formily/antd-v5'; -import LRUCache from 'lru-cache'; +import { LRUCache } from 'lru-cache'; const TreeNode = (props) => { const { tag, type, displayType = true } = props; diff --git a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/kimi/provider.ts b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/kimi/provider.ts index 9c44a0f4643..d861d3b5fef 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/kimi/provider.ts +++ b/packages/plugins/@nocobase/plugin-ai/src/server/llm-providers/kimi/provider.ts @@ -82,7 +82,7 @@ export class KimiProvider extends LLMProvider { return attachment.mimetype?.startsWith('image/') ?? false; } - private get aiPlugin(): PluginAIServer { + protected get aiPlugin(): PluginAIServer { return this.app.pm.get('ai'); } diff --git a/packages/plugins/@nocobase/plugin-workflow/package.json b/packages/plugins/@nocobase/plugin-workflow/package.json index 95d71cc5912..ff1d6bd405a 100644 --- a/packages/plugins/@nocobase/plugin-workflow/package.json +++ b/packages/plugins/@nocobase/plugin-workflow/package.json @@ -24,7 +24,7 @@ "dayjs": "^1.11.8", "joi": "^17.13.3", "lodash": "4.17.21", - "lru-cache": "8.0.5", + "lru-cache": "^11.3.5", "nodejs-snowflake": "2.0.1", "react": "18.x", "react-i18next": "^11.15.1", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/Plugin.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/Plugin.ts index d1921348338..c34ebe51990 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/server/Plugin.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/Plugin.ts @@ -11,7 +11,7 @@ import path from 'path'; import { Snowflake } from 'nodejs-snowflake'; import { Transactionable } from 'sequelize'; -import LRUCache from 'lru-cache'; +import { LRUCache } from 'lru-cache'; import { Op } from '@nocobase/database'; import { Plugin } from '@nocobase/server'; diff --git a/tsconfig.json b/tsconfig.json index 37d290e9b66..e3a5a9dba72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,8 +30,8 @@ "packages/**/dist", "packages/**/public", "packages/core/build/bin", - "packages/core/cli/src", - "packages/core/cli/bin", + "packages/core/cli-v1/src", + "packages/core/cli-v1/bin", "packages/**/lib", "packages/**/es", "docs/**/*" diff --git a/yarn.lock b/yarn.lock index be0b6ff6bca..05841110759 100644 --- a/yarn.lock +++ b/yarn.lock @@ -973,6 +973,14 @@ "@types/json-schema" "^7.0.15" js-yaml "^4.1.0" +"@apidevtools/json-schema-ref-parser@14.0.1": + version "14.0.1" + resolved "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz#3bc445ed2eddf72bc2f9eb2e295c696bdc5be725" + integrity sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw== + dependencies: + "@types/json-schema" "^7.0.15" + js-yaml "^4.1.0" + "@apidevtools/openapi-schemas@^2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" @@ -996,6 +1004,18 @@ ajv-draft-04 "^1.0.0" call-me-maybe "^1.0.2" +"@apidevtools/swagger-parser@^12.1.0": + version "12.1.0" + resolved "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz#ef73e5f9e32c2becef6d95b90fb4481b0fec8fe4" + integrity sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng== + dependencies: + "@apidevtools/json-schema-ref-parser" "14.0.1" + "@apidevtools/openapi-schemas" "^2.1.0" + "@apidevtools/swagger-methods" "^3.0.2" + ajv "^8.17.1" + ajv-draft-04 "^1.0.0" + call-me-maybe "^1.0.2" + "@arvinxu/layout-kit@^1": version "1.4.0" resolved "https://registry.npmmirror.com/@arvinxu/layout-kit/-/layout-kit-1.4.0.tgz#8bcb4328a19b76a1aed2614730c330616b6ca29f" @@ -4420,6 +4440,11 @@ resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" integrity sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ== +"@esbuild/aix-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" + integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== + "@esbuild/android-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz#bafb75234a5d3d1b690e7c2956a599345e84a2fd" @@ -4445,6 +4470,11 @@ resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz#58565291a1fe548638adb9c584237449e5e14018" integrity sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw== +"@esbuild/android-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" + integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== + "@esbuild/android-arm@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.17.19.tgz#5898f7832c2298bc7d0ab53701c57beb74d78b4d" @@ -4470,6 +4500,11 @@ resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.23.1.tgz#5eb8c652d4c82a2421e3395b808e6d9c42c862ee" integrity sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ== +"@esbuild/android-arm@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" + integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== + "@esbuild/android-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.17.19.tgz#658368ef92067866d95fb268719f98f363d13ae1" @@ -4495,6 +4530,11 @@ resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.23.1.tgz#ae19d665d2f06f0f48a6ac9a224b3f672e65d517" integrity sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg== +"@esbuild/android-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" + integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== + "@esbuild/darwin-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz#584c34c5991b95d4d48d333300b1a4e2ff7be276" @@ -4520,6 +4560,11 @@ resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz#05b17f91a87e557b468a9c75e9d85ab10c121b16" integrity sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q== +"@esbuild/darwin-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" + integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== + "@esbuild/darwin-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz#7751d236dfe6ce136cce343dce69f52d76b7f6cb" @@ -4545,6 +4590,11 @@ resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz#c58353b982f4e04f0d022284b8ba2733f5ff0931" integrity sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw== +"@esbuild/darwin-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" + integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== + "@esbuild/freebsd-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz#cacd171665dd1d500f45c167d50c6b7e539d5fd2" @@ -4570,6 +4620,11 @@ resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz#f9220dc65f80f03635e1ef96cfad5da1f446f3bc" integrity sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA== +"@esbuild/freebsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" + integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== + "@esbuild/freebsd-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz#0769456eee2a08b8d925d7c00b79e861cb3162e4" @@ -4595,6 +4650,11 @@ resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz#69bd8511fa013b59f0226d1609ac43f7ce489730" integrity sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g== +"@esbuild/freebsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" + integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== + "@esbuild/linux-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz#38e162ecb723862c6be1c27d6389f48960b68edb" @@ -4620,6 +4680,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz#8050af6d51ddb388c75653ef9871f5ccd8f12383" integrity sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g== +"@esbuild/linux-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" + integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== + "@esbuild/linux-arm@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz#1a2cd399c50040184a805174a6d89097d9d1559a" @@ -4645,6 +4710,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz#ecaabd1c23b701070484990db9a82f382f99e771" integrity sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ== +"@esbuild/linux-arm@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" + integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== + "@esbuild/linux-ia32@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz#e28c25266b036ce1cabca3c30155222841dc035a" @@ -4670,6 +4740,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz#3ed2273214178109741c09bd0687098a0243b333" integrity sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ== +"@esbuild/linux-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" + integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== + "@esbuild/linux-loong64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz#0f887b8bb3f90658d1a0117283e55dbd4c9dcf72" @@ -4695,6 +4770,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz#a0fdf440b5485c81b0fbb316b08933d217f5d3ac" integrity sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw== +"@esbuild/linux-loong64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" + integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== + "@esbuild/linux-mips64el@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz#f5d2a0b8047ea9a5d9f592a178ea054053a70289" @@ -4720,6 +4800,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz#e11a2806346db8375b18f5e104c5a9d4e81807f6" integrity sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q== +"@esbuild/linux-mips64el@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" + integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== + "@esbuild/linux-ppc64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz#876590e3acbd9fa7f57a2c7d86f83717dbbac8c7" @@ -4745,6 +4830,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz#06a2744c5eaf562b1a90937855b4d6cf7c75ec96" integrity sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw== +"@esbuild/linux-ppc64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" + integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== + "@esbuild/linux-riscv64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz#7f49373df463cd9f41dc34f9b2262d771688bf09" @@ -4770,6 +4860,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz#65b46a2892fc0d1af4ba342af3fe0fa4a8fe08e7" integrity sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA== +"@esbuild/linux-riscv64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" + integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== + "@esbuild/linux-s390x@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz#e2afd1afcaf63afe2c7d9ceacd28ec57c77f8829" @@ -4795,6 +4890,11 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz#e71ea18c70c3f604e241d16e4e5ab193a9785d6f" integrity sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw== +"@esbuild/linux-s390x@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" + integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== + "@esbuild/linux-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz#8a0e9738b1635f0c53389e515ae83826dec22aa4" @@ -4820,6 +4920,16 @@ resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz#d47f97391e80690d4dfe811a2e7d6927ad9eed24" integrity sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ== +"@esbuild/linux-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" + integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== + +"@esbuild/netbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" + integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== + "@esbuild/netbsd-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz#c29fb2453c6b7ddef9a35e2c18b37bda1ae5c462" @@ -4845,6 +4955,11 @@ resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz#44e743c9778d57a8ace4b72f3c6b839a3b74a653" integrity sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA== +"@esbuild/netbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" + integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== + "@esbuild/openbsd-arm64@0.23.0": version "0.23.0" resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz#72fc55f0b189f7a882e3cf23f332370d69dfd5db" @@ -4855,6 +4970,11 @@ resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" integrity sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q== +"@esbuild/openbsd-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" + integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== + "@esbuild/openbsd-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz#95e75a391403cb10297280d524d66ce04c920691" @@ -4880,6 +5000,16 @@ resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz#2e58ae511bacf67d19f9f2dcd9e8c5a93f00c273" integrity sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA== +"@esbuild/openbsd-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" + integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== + +"@esbuild/openharmony-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" + integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== + "@esbuild/sunos-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz#722eaf057b83c2575937d3ffe5aeb16540da7273" @@ -4905,6 +5035,11 @@ resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz#adb022b959d18d3389ac70769cef5a03d3abd403" integrity sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA== +"@esbuild/sunos-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" + integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== + "@esbuild/win32-arm64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz#9aa9dc074399288bdcdd283443e9aeb6b9552b6f" @@ -4930,6 +5065,11 @@ resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz#84906f50c212b72ec360f48461d43202f4c8b9a2" integrity sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A== +"@esbuild/win32-arm64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" + integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== + "@esbuild/win32-ia32@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz#95ad43c62ad62485e210f6299c7b2571e48d2b03" @@ -4955,6 +5095,11 @@ resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz#5e3eacc515820ff729e90d0cb463183128e82fac" integrity sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ== +"@esbuild/win32-ia32@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" + integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== + "@esbuild/win32-x64@0.17.19": version "0.17.19" resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061" @@ -4980,6 +5125,11 @@ resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz#81fd50d11e2c32b2d6241470e3185b70c7b30699" integrity sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg== +"@esbuild/win32-x64@0.27.7": + version "0.27.7" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" + integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -7144,6 +7294,30 @@ node-gyp "^7.1.0" read-package-json-fast "^2.0.1" +"@oclif/core@^4.10.4": + version "4.10.5" + resolved "https://registry.npmjs.org/@oclif/core/-/core-4.10.5.tgz#bcf7c5bb783849ccdce2fd2b5d691a247082ba51" + integrity sha512-qcdCF7NrdWPfme6Kr34wwljRCXbCVpL1WVxiNy0Ep6vbWKjxAjFQwuhqkoyL0yjI+KdwtLcOCGn5z2yzdijc8w== + dependencies: + ansi-escapes "^4.3.2" + ansis "^3.17.0" + clean-stack "^3.0.1" + cli-spinners "^2.9.2" + debug "^4.4.3" + ejs "^3.1.10" + get-package-type "^0.1.0" + indent-string "^4.0.0" + is-wsl "^2.2.0" + lilconfig "^3.1.3" + minimatch "^10.2.5" + semver "^7.7.3" + string-width "^4.2.3" + supports-color "^8" + tinyglobby "^0.2.14" + widest-line "^3.1.0" + wordwrap "^1.0.0" + wrap-ansi "^7.0.0" + "@octokit/auth-token@^2.4.4": version "2.5.0" resolved "https://registry.npmmirror.com/@octokit/auth-token/-/auth-token-2.5.0.tgz#27c37ea26c205f28443402477ffd261311f21e36" @@ -10208,6 +10382,13 @@ resolved "https://registry.npmmirror.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== +"@types/node@^18.19.130": + version "18.19.130" + resolved "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== + dependencies: + undici-types "~5.26.4" + "@types/node@^24.0.13", "@types/node@^24.2.1": version "24.10.13" resolved "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz#2fac25c0e30f3848e19912c3b8791a28370e9e07" @@ -11689,7 +11870,7 @@ ansi-escapes@^3.2.0: resolved "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== -ansi-escapes@^4.2.1: +ansi-escapes@^4.2.1, ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== @@ -11733,6 +11914,11 @@ ansi-regex@^6.0.1: resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -11769,6 +11955,11 @@ ansi-to-html@^0.7.2: dependencies: entities "^2.2.0" +ansis@^3.17.0: + version "3.17.0" + resolved "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7" + integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg== + antd-mobile-icons@^0.3.0: version "0.3.0" resolved "https://registry.npmmirror.com/antd-mobile-icons/-/antd-mobile-icons-0.3.0.tgz#9b29e4588a62370909061f10ff0579aabb0b32a9" @@ -12367,7 +12558,7 @@ async@^3.2.0, async@^3.2.3, async@^3.2.4, async@~3.2.0: resolved "https://registry.npmmirror.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66" integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg== -async@~3.2.6: +async@^3.2.6, async@~3.2.6: version "3.2.6" resolved "https://registry.npmmirror.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== @@ -12665,6 +12856,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + base64-arraybuffer@^1.0.2: version "1.0.2" resolved "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" @@ -12951,6 +13147,13 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.5: + version "5.0.5" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.npmmirror.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" @@ -13827,6 +14030,13 @@ clean-stack@^2.0.0: resolved "https://registry.npmmirror.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== +clean-stack@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz#155bf0b2221bf5f4fba89528d24c5953f17fe3a8" + integrity sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg== + dependencies: + escape-string-regexp "4.0.0" + cli-boxes@^1.0.0: version "1.0.0" resolved "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" @@ -13863,12 +14073,19 @@ cli-cursor@^4.0.0: dependencies: restore-cursor "^4.0.0" +cli-cursor@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38" + integrity sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw== + dependencies: + restore-cursor "^5.0.0" + cli-spinners@^1.0.1: version "1.3.1" resolved "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-1.3.1.tgz#002c1990912d0d59580c93bd36c056de99e4259a" integrity sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg== -cli-spinners@^2.5.0: +cli-spinners@^2.5.0, cli-spinners@^2.9.2: version "2.9.2" resolved "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== @@ -16670,6 +16887,13 @@ ee-first@1.1.1, ee-first@~1.1.1: resolved "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== +ejs@^3.1.10: + version "3.1.10" + resolved "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" + integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== + dependencies: + jake "^10.8.5" + electron-to-chromium@^1.4.601: version "1.4.613" resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.613.tgz#529e4fc65576ecfd055d7d4619fade4fac446af2" @@ -16726,6 +16950,11 @@ emittery@^0.13.0: resolved "https://registry.npmmirror.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== +emoji-regex@^10.3.0: + version "10.6.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" + integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -17394,6 +17623,38 @@ esbuild@~0.23.0: "@esbuild/win32-ia32" "0.23.1" "@esbuild/win32-x64" "0.23.1" +esbuild@~0.27.0: + version "0.27.7" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" + integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.7" + "@esbuild/android-arm" "0.27.7" + "@esbuild/android-arm64" "0.27.7" + "@esbuild/android-x64" "0.27.7" + "@esbuild/darwin-arm64" "0.27.7" + "@esbuild/darwin-x64" "0.27.7" + "@esbuild/freebsd-arm64" "0.27.7" + "@esbuild/freebsd-x64" "0.27.7" + "@esbuild/linux-arm" "0.27.7" + "@esbuild/linux-arm64" "0.27.7" + "@esbuild/linux-ia32" "0.27.7" + "@esbuild/linux-loong64" "0.27.7" + "@esbuild/linux-mips64el" "0.27.7" + "@esbuild/linux-ppc64" "0.27.7" + "@esbuild/linux-riscv64" "0.27.7" + "@esbuild/linux-s390x" "0.27.7" + "@esbuild/linux-x64" "0.27.7" + "@esbuild/netbsd-arm64" "0.27.7" + "@esbuild/netbsd-x64" "0.27.7" + "@esbuild/openbsd-arm64" "0.27.7" + "@esbuild/openbsd-x64" "0.27.7" + "@esbuild/openharmony-arm64" "0.27.7" + "@esbuild/sunos-x64" "0.27.7" + "@esbuild/win32-arm64" "0.27.7" + "@esbuild/win32-ia32" "0.27.7" + "@esbuild/win32-x64" "0.27.7" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.npmmirror.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -17419,16 +17680,16 @@ escape-latex@^1.2.0: resolved "https://registry.npmmirror.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1" integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw== +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - escape-string-regexp@^5.0.0: version "5.0.0" resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" @@ -18301,6 +18562,11 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + fecha@^4.2.0, fecha@^4.2.1, fecha@~4.2.0: version "4.2.3" resolved "https://registry.npmmirror.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" @@ -18401,6 +18667,13 @@ file-type@^6.1.0: resolved "https://registry.npmmirror.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== +filelist@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz#1e8870942a7c636c862f7c49b9394937b6a995a3" + integrity sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA== + dependencies: + minimatch "^5.0.1" + filesize@9.0.11: version "9.0.11" resolved "https://registry.npmmirror.com/filesize/-/filesize-9.0.11.tgz#4ac3a42c084232dd9b2a1da0107f32d42fcfa5e4" @@ -19039,6 +19312,11 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-east-asian-width@^1.0.0: + version "1.5.0" + resolved "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz#ce7008fe345edcf5497a6f557cfa54bc318a9ce7" + integrity sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA== + get-func-name@^2.0.1, get-func-name@^2.0.2: version "2.0.2" resolved "https://registry.npmmirror.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" @@ -21315,6 +21593,11 @@ is-interactive@^1.0.0: resolved "https://registry.npmmirror.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== +is-interactive@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90" + integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ== + is-lambda@^1.0.1: version "1.0.1" resolved "https://registry.npmmirror.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" @@ -21630,6 +21913,16 @@ is-unicode-supported@^0.1.0: resolved "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-unicode-supported@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" + integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== + +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + is-weakmap@^2.0.1: version "2.0.1" resolved "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2" @@ -21865,6 +22158,15 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jake@^10.8.5: + version "10.9.4" + resolved "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz#d626da108c63d5cfb00ab5c25fadc7e0084af8e6" + integrity sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA== + dependencies: + async "^3.2.6" + filelist "^1.0.4" + picocolors "^1.1.1" + javascript-natural-sort@^0.7.1: version "0.7.1" resolved "https://registry.npmmirror.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59" @@ -22957,6 +23259,11 @@ lilconfig@^3.1.1: resolved "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.2.tgz#e4a7c3cb549e3a606c8dcc32e5ae1005e62c05cb" integrity sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow== +lilconfig@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" + integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== + lines-and-columns@2.0.4: version "2.0.4" resolved "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-2.0.4.tgz#d00318855905d2660d8c0822e3f5a4715855fc42" @@ -23324,6 +23631,14 @@ log-symbols@^4.1.0: chalk "^4.1.0" is-unicode-supported "^0.1.0" +log-symbols@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz#bb95e5f05322651cac30c0feb6404f9f2a8a9439" + integrity sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw== + dependencies: + chalk "^5.3.0" + is-unicode-supported "^1.3.0" + log-update@^5.0.1: version "5.0.1" resolved "https://registry.npmmirror.com/log-update/-/log-update-5.0.1.tgz#9e928bf70cb183c1f0c9e91d9e6b7115d597ce09" @@ -23415,18 +23730,6 @@ lowlight@^1.17.0: fault "^1.0.0" highlight.js "~10.7.0" -lru-cache@6.0.0, lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -lru-cache@8.0.5, lru-cache@^8.0.0: - version "8.0.5" - resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-8.0.5.tgz#983fe337f3e176667f8e567cfcce7cb064ea214e" - integrity sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA== - lru-cache@^10.0.1, lru-cache@^10.2.0, lru-cache@^10.4.3: version "10.4.3" resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -23437,10 +23740,10 @@ lru-cache@^10.0.2: resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484" integrity sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag== -lru-cache@^11.1.0: - version "11.2.6" - resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz#356bf8a29e88a7a2945507b31f6429a65a192c58" - integrity sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ== +lru-cache@^11.3.5: + version "11.3.5" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz#29047d348c0b2793e3112a01c739bb7c6d855637" + integrity sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw== lru-cache@^4.0.1, lru-cache@^4.1.1: version "4.1.5" @@ -23457,11 +23760,23 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + lru-cache@^7.14.1, lru-cache@^7.5.1: version "7.18.3" resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== +lru-cache@^8.0.0: + version "8.0.5" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-8.0.5.tgz#983fe337f3e176667f8e567cfcce7cb064ea214e" + integrity sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA== + luxon@^1.28.0: version "1.28.1" resolved "https://registry.npmmirror.com/luxon/-/luxon-1.28.1.tgz#528cdf3624a54506d710290a2341aa8e6e6c61b0" @@ -24943,6 +25258,11 @@ mimic-fn@^4.0.0: resolved "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== +mimic-function@^5.0.0: + version "5.0.1" + resolved "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" + integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== + mimic-response@^1.0.0, mimic-response@^1.0.1: version "1.0.1" resolved "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" @@ -24982,6 +25302,13 @@ minimatch@9.0.3: dependencies: brace-expansion "^2.0.1" +minimatch@^10.2.5: + version "10.2.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + minimatch@^5.0.1, minimatch@^5.1.0, minimatch@^5.1.1: version "5.1.6" resolved "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" @@ -26234,6 +26561,13 @@ onetime@^6.0.0: dependencies: mimic-fn "^4.0.0" +onetime@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz#9f16c92d8c9ef5120e3acd9dd9957cceecc1ab60" + integrity sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ== + dependencies: + mimic-function "^5.0.0" + only@~0.0.2: version "0.0.2" resolved "https://registry.npmmirror.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" @@ -26391,6 +26725,21 @@ ora@^5.4.1: strip-ansi "^6.0.0" wcwidth "^1.0.1" +ora@^8.2.0: + version "8.2.0" + resolved "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz#8fbbb7151afe33b540dd153f171ffa8bd38e9861" + integrity sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw== + dependencies: + chalk "^5.3.0" + cli-cursor "^5.0.0" + cli-spinners "^2.9.2" + is-interactive "^2.0.0" + is-unicode-supported "^2.0.0" + log-symbols "^6.0.0" + stdin-discarder "^0.2.2" + string-width "^7.2.0" + strip-ansi "^7.1.0" + oracledb@6.10.0: version "6.10.0" resolved "https://registry.npmjs.org/oracledb/-/oracledb-6.10.0.tgz#a54f699547e7dfc1f2ecb8a72af0423b4e86e51d" @@ -27249,6 +27598,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + pidtree@0.6.0: version "0.6.0" resolved "https://registry.npmmirror.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" @@ -30483,6 +30837,14 @@ restore-cursor@^4.0.0: onetime "^5.1.0" signal-exit "^3.0.2" +restore-cursor@^5.0.0: + version "5.1.0" + resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz#0766d95699efacb14150993f55baf0953ea1ebe7" + integrity sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA== + dependencies: + onetime "^7.0.0" + signal-exit "^4.1.0" + retry-as-promised@^7.0.4: version "7.0.4" resolved "https://registry.npmmirror.com/retry-as-promised/-/retry-as-promised-7.0.4.tgz#9df73adaeea08cb2948b9d34990549dc13d800a2" @@ -30963,6 +31325,11 @@ semver@^7.6.3: resolved "https://registry.npmmirror.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== +semver@^7.7.3: + version "7.7.4" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + semver@~7.5.0, semver@~7.5.4: version "7.5.4" resolved "https://registry.npmmirror.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" @@ -31924,6 +32291,11 @@ std-env@^3.5.0: resolved "https://registry.npmmirror.com/std-env/-/std-env-3.6.0.tgz#94807562bddc68fa90f2e02c5fd5b6865bb4e98e" integrity sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg== +stdin-discarder@^0.2.2: + version "0.2.2" + resolved "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" + integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== + stop-iteration-iterator@^1.0.0: version "1.0.0" resolved "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" @@ -32056,7 +32428,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -32091,6 +32463,15 @@ string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" +string-width@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + string.prototype.matchall@^4.0.11, string.prototype.matchall@^4.0.8: version "4.0.11" resolved "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a" @@ -32281,6 +32662,13 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.0.1" +strip-ansi@^7.1.0: + version "7.2.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + strip-bom-string@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" @@ -32530,7 +32918,7 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0, supports-color@^8.1.0, supports-color@~8.1.1: +supports-color@^8, supports-color@^8.0.0, supports-color@^8.1.0, supports-color@~8.1.1: version "8.1.1" resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -33024,6 +33412,14 @@ tinycolor2@^1.6.0: resolved "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== +tinyglobby@^0.2.14: + version "0.2.16" + resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + tinypool@^0.8.3: version "0.8.4" resolved "https://registry.npmmirror.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" @@ -33433,6 +33829,16 @@ tsx@^4.19.0: optionalDependencies: fsevents "~2.3.3" +tsx@^4.20.6: + version "4.21.0" + resolved "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz#32aa6cf17481e336f756195e6fe04dae3e6308b1" + integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== + dependencies: + esbuild "~0.27.0" + get-tsconfig "^4.7.5" + optionalDependencies: + fsevents "~2.3.3" + tsx@^4.6.2: version "4.6.2" resolved "https://registry.npmmirror.com/tsx/-/tsx-4.6.2.tgz#8e9c1456ad4f1102c5c42c5be7fd428259b7d39b" @@ -33737,6 +34143,11 @@ typescript@^5.x: resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +typescript@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f" + integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ== + ua-parser-js@^1.0.33: version "1.0.38" resolved "https://registry.npmmirror.com/ua-parser-js/-/ua-parser-js-1.0.38.tgz#66bb0c4c0e322fe48edfe6d446df6042e62f25e2" @@ -34981,6 +35392,13 @@ widest-line@^2.0.0: dependencies: string-width "^2.1.1" +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + widest-line@^4.0.1: version "4.0.1" resolved "https://registry.npmmirror.com/widest-line/-/widest-line-4.0.1.tgz#a0fc673aaba1ea6f0a0d35b3c2795c9a9cc2ebf2"