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
This commit is contained in:
chenos
2026-04-17 09:39:47 +08:00
committed by GitHub
parent 931a6c142a
commit 7d4426d904
134 changed files with 6420 additions and 206 deletions
+1
View File
@@ -46,3 +46,4 @@ git-repos.json
CLAUDE.md
AGENTS.md
openspec/
/.nocobase
+25 -25
View File
@@ -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",
+1
View File
@@ -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"
+9
View File
@@ -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) {
+1
View File
@@ -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,
+99
View File
@@ -0,0 +1,99 @@
# NocoBase
<video width="100%" controls>
<source src="https://github.com/user-attachments/assets/4d11a87b-00e2-48f3-9bf7-389d21072d13" type="video/mp4">
</video>
<p align="center">
<a href="https://trendshift.io/repositories/4112" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4112" alt="nocobase%2Fnocobase | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/nocobase?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-nocobase" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=456520&theme=light&period=weekly&topic_id=267" alt="NocoBase - Scalability&#0045;first&#0044;&#0032;open&#0045;source&#0032;no&#0045;code&#0032;platform | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p>
## 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/tabledriven
Instead of being constrained by forms or tables, NocoBase adopts a data modeldriven 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 AIhuman 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:
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/docker-compose">Installing With Docker (👍Recommended)</a>
Suitable for no-code scenarios, no code to write. When upgrading, just download the latest image and reboot.
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/create-nocobase-app">Installing from create-nocobase-app CLI</a>
The business code of the project is completely independent and supports low-code development.
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/git-clone">Installing from Git source code</a>
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
<video width="100%" controls>
<source src="https://github.com/user-attachments/assets/8d183b44-9bb5-4792-b08f-bc08fe8dfaaf" type="video/mp4">
</video>
+37
View File
@@ -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"
}
@@ -76,6 +76,8 @@ module.exports = (cli) => {
options.retry ? '--retry' : '',
]);
buildIndexHtml(true);
await buildClientV2();
if (options.packages && !options.packages.includes('@nocobase/app')) {
await buildClientV2();
}
});
};
@@ -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;
@@ -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": {
@@ -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();
+15
View File
@@ -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
+134 -63
View File
@@ -1,99 +1,170 @@
# NocoBase
# NocoBase CTL
<video width="100%" controls>
<source src="https://github.com/user-attachments/assets/4d11a87b-00e2-48f3-9bf7-389d21072d13" type="video/mp4">
</video>
NocoBase CTL is a command-line tool for managing and controlling NocoBase applications. Its relationship with multiple NocoBase App instances is shown below:
<p align="center">
<a href="https://trendshift.io/repositories/4112" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4112" alt="nocobase%2Fnocobase | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://www.producthunt.com/posts/nocobase?embed=true&utm_source=badge-top-post-topic-badge&utm_medium=badge&utm_souce=badge-nocobase" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=456520&theme=light&period=weekly&topic_id=267" alt="NocoBase - Scalability&#0045;first&#0044;&#0032;open&#0045;source&#0032;no&#0045;code&#0032;platform | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</p>
```
+----------------------+
| 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 <api-key>
```
### 1. Data model-driven, not form/tabledriven
Authenticate an environment with OAuth:
Instead of being constrained by forms or tables, NocoBase adopts a data modeldriven 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 AIhuman 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.
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/docker-compose">Installing With Docker (👍Recommended)</a>
## 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:
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/create-nocobase-app">Installing from create-nocobase-app CLI</a>
```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:
- <a target="_blank" href="https://docs.nocobase.com/welcome/getting-started/installation/git-clone">Installing from Git source code</a>
```bash
nb env use <name>
```
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:
<video width="100%" controls>
<source src="https://github.com/user-attachments/assets/8d183b44-9bb5-4792-b08f-bc08fe8dfaaf" type="video/mp4">
</video>
- `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 <api-key>
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/<version>/commands.json`: cached runtime commands for a generated version
+3
View File
@@ -0,0 +1,3 @@
@echo off
node "%~dp0\run" %*
+87
View File
@@ -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;
}
+327
View File
@@ -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
}
}
}
}
}
}
+49 -25
View File
@@ -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"
}
}
@@ -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<void> {
this.log('Use `nb api --help` to view available subcommands.');
}
}
+61
View File
@@ -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<void> {
const { flags } = await this.parse(EnvAdd);
setVerboseMode(flags.verbose);
const name = flags.name || 'default';
const scope = flags.scope as Exclude<CliHomeScope, 'auto'> | 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 <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` : ''}.`);
}
}
+40
View File
@@ -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<void> {
const { flags } = await this.parse(EnvAuth);
const scope = flags.scope as Exclude<CliHomeScope, 'auto'> | 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;
}
}
}
+37
View File
@@ -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<void> {
const { flags } = await this.parse(Env);
const scope = flags.scope as Exclude<CliHomeScope, 'auto'> | 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 <name> --base-url <url>` to add one.');
return;
}
this.log(
renderTable(
['Name', 'Base URL', 'Auth', 'Runtime'],
[[envName, env?.baseUrl ?? '', env?.auth?.type ?? '', env?.runtime?.version ?? '']],
),
);
}
}
+37
View File
@@ -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<void> {
const { flags } = await this.parse(EnvList);
const scope = flags.scope as Exclude<CliHomeScope, 'auto'> | 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 <name> --base-url <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));
}
}
+64
View File
@@ -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<void> {
const { args, flags } = await this.parse(EnvRemove);
setVerboseMode(flags.verbose);
const scope = flags.scope as Exclude<CliHomeScope, 'auto'> | 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.');
}
}
+64
View File
@@ -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<void> {
const { flags } = await this.parse(EnvUpdate);
const scope = flags.scope as Exclude<CliHomeScope, 'auto'> | 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;
}
}
}
+30
View File
@@ -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<void> {
const { args, flags } = await this.parse(EnvUse);
const scope = flags.scope as Exclude<CliHomeScope, 'auto'> | undefined;
await setCurrentEnv(args.name, { scope });
this.log(`Current env: ${args.name}${scope ? ` (${formatCliHomeScope(scope)} scope)` : ''}`);
}
}
@@ -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<void> {
const { flags } = await this.parse(ResourceCreate);
await runResourceCommand(this, 'create', flags, buildCreateArgs(flags));
}
}
@@ -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<void> {
const { flags } = await this.parse(ResourceDestroy);
await runResourceCommand(this, 'destroy', flags, buildDestroyArgs(flags));
}
}
@@ -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<void> {
const { flags } = await this.parse(ResourceGet);
await runResourceCommand(this, 'get', flags, buildGetArgs(flags));
}
}
@@ -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<void> {
this.log('Use `nb api resource --help` to view available subcommands.');
}
}
@@ -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<void> {
const { flags } = await this.parse(ResourceList);
await runResourceCommand(this, 'list', flags, buildListArgs(flags));
}
}
@@ -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<void> {
const { flags } = await this.parse(ResourceQuery);
await runResourceCommand(this, 'query', flags, buildQueryArgs(flags));
}
}
@@ -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<void> {
const { flags } = await this.parse(ResourceUpdate);
await runResourceCommand(this, 'update', flags, buildUpdateArgs(flags));
}
}
@@ -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<void> {
this.log(`Use \`nb ${commandId} --help\` to view available subcommands.`);
}
};
}
const registry: Record<string, any> = {
// 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;
+288
View File
@@ -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<string, any>;
operation: RequestOperation;
}
export interface RawRequestOptions {
envName?: string;
baseUrl?: string;
token?: string;
role?: string;
method: string;
path: string;
query?: Record<string, any>;
headers?: Record<string, any>;
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<string, any>, 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<string, any>, parameters: RequestParameter[]) {
return parameters
.filter((parameter) => hasParameterValue(flags, parameter))
.map((parameter) => `--${parameter.flagName}`);
}
export async function parseBody(flags: Record<string, any>, 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<string, unknown> = {};
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);
}
+251
View File
@@ -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<string, EnvConfigEntry>;
}
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<AuthConfig> {
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,
};
}
+449
View File
@@ -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 <name> --base-url <url> --token <api-key>`, log in with `nb env auth -e <name>`, or rerun the command with `--token <api-key>`.',
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 <name> --base-url <url>` first.',
'If you configure multiple environments later, switch with `nb env use <name>`.',
].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 <name> --base-url <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 <name> --base-url <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();
}
}
+50
View File
@@ -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<string, ResourceBuildConfig>;
}
export interface OperationBuildConfig {
description?: string;
}
export interface OperationBuildConfigSet {
includes?: string[];
excludes?: string[];
overrides?: Record<string, OperationBuildConfig>;
}
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<string, ModuleBuildConfig>;
}
export async function loadBuildConfig(filePath: string): Promise<BuildConfig> {
try {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content) as BuildConfig;
} catch (error) {
return {};
}
}
+40
View File
@@ -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<CliHomeScope, 'auto'>) {
return scope === 'project' ? 'project' : 'global';
}

Some files were not shown because too many files have changed in this diff Show More