docs: ship the deployment Skill with applications and finish the deployment guide (#376)

* docs: ship the deployment Skill with app-skills and close the deployment guide gaps

Move `nocobase-deployment` from the repository-only `.agents/skills/` into
`@nocobase/app-skills`, so `nocobase skills sync` delivers it to every
generated application; the repository keeps a relative symbolic link there
and the Claude mirror follows it. Point each template's AGENTS.md at the
Skill, explain in the READMEs and the Skill that `pnpm build` already runs
`pnpm install --prod` inside `dist/`, tell the workflow runtime's missing
Artifact error how to enable the deployed hash, and fix the tutorial's
archive path and start command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: write the English deployment overview, configuration, standalone and Docker pages

The four pages were "Being written" placeholders while their Chinese
counterparts were complete. Each now carries the same content as the
Chinese page: the deployment modes and how Hub relates to applications,
the shared, standalone and Hub-hosted configuration with the database,
secret, address and reverse proxy details, the build-to-systemd flow with
its acceptance checks, and the Docker image, Compose and update steps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Katherine
2026-09-22 18:19:16 +08:00
committed by GitHub
co-authored by Claude Fable 5.1
parent e612b11183
commit 2ae6b2b722
23 changed files with 714 additions and 74 deletions
+1
View File
@@ -0,0 +1 @@
../../packages/app/app-skills/skills/nocobase-deployment
@@ -0,0 +1,8 @@
---
"@nocobase/app-skills": minor
"@nocobase/app-template-default": patch
"@nocobase/app-template-examples": patch
"@nocobase/app-template-hub": patch
---
Ship the `nocobase-deployment` Skill with `@nocobase/app-skills`, so `nocobase skills sync` delivers it to every generated application instead of leaving it in the source repository where an application's agent never sees it. The Skill now reads the application's own README rather than repository paths, says that `pnpm build` already installs production dependencies into `dist/` and that `pnpm install --prod` there is a repair rather than a deployment step, and names both ways to enable a workflow whose deployed hash changed: **Enable new version** in workflow management, or the enable route called with the new hash. Each template's `AGENTS.md` points at the Skill next to the upgrade Skill, and the README's deployment section explains the same `pnpm install --prod` relationship.
@@ -0,0 +1,5 @@
---
"@nocobase/app-plugin-workflow": patch
---
Say what to do when a workflow's enabled hash is missing from the production build. `Workflow Artifact <key>/<hash> is missing` now explains that the database still points at an earlier build's hash and that the deployed version has to be enabled by its new hash, through **Enable new version** in workflow management or the enable route, because enabling by workflow id keeps the missing hash.
+2
View File
@@ -48,6 +48,8 @@ Every published package lives under `packages/`, grouped into six directories by
This repository's own Skills are committed under `.agents/skills/`, the agent-neutral location every agent can be pointed at. Claude Code does not read that path — it discovers Skills only under `~/.claude/skills/` and `<project>/.claude/skills/` — so `pnpm install` runs `scripts/link-claude-skills.mjs`, which mirrors each committed Skill into `.claude/skills/` as a relative symbolic link. `.claude/skills/` is therefore generated and ignored; `.agents/skills/` remains the single committed source, and editing a Skill through either path edits the same file.
A Skill that also ships to applications is committed with its package and linked from here instead: `nocobase-deployment` lives in `packages/app/app-skills/skills/` so that `nocobase skills sync` delivers it to every generated application, and `.agents/skills/nocobase-deployment` is a relative symbolic link to it, which the mirror follows like any other Skill. Editing it is a change to `@nocobase/app-skills` and needs a changeset.
This is the same arrangement `nocobase skills sync` sets up inside a generated application, with the ownership reversed: an application's `.agents/skills/` is generated from its installed packages and ignored, while this repository writes its Skills by hand and commits them.
The mirror never replaces a path that is not a symbolic link, so a Skill you keep only in `.claude/skills/` is reported and left alone rather than deleted. It also never fails the install: a warning is enough, because the Skills are still readable where they are committed. Adding or removing a Skill takes effect on the next `pnpm install`, or immediately with `node ./scripts/link-claude-skills.mjs`.
+3 -3
View File
@@ -64,7 +64,7 @@ pnpm start
pnpm build --target linux-x64 --tar
```
产物为 `storage/dist.tar.gz`,包含 `dist/` 和 `config.example.yml`。ARM 或 musl 环境需要匹配自己的目标;原生数据库依赖也必须与目标平台和 Node.js 版本一致。
产物为 `storage/exports/dist.tar.gz`,包含 `dist/` 和 `config.example.yml`,其中 `dist/` 已带有生产依赖。ARM 或 musl 环境需要匹配自己的目标;原生数据库依赖也必须与目标平台和 Node.js 版本一致。
不要把本地数据库、测试账号密码或真实 `config.yml` 打入公共仓库。把构建包上传到目标机器的新发布目录并解压:
@@ -87,10 +87,10 @@ export APP_BASE_PATH=/main
export APP_SERVER_HOST=127.0.0.1
export APP_SERVER_PORT=13000
export NODE_ENV=production
node --input-type=module -e "const { startServer } = await import('./dist/server/standalone.js'); startServer();"
node ./dist/server/standalone.js
```
部署包不包含源码目录的 `scripts/start.mjs`,因此解压部署包后使用编译产物入口;源码目录中则使用 `pnpm start`。检查实际产物的入口导出,不要复制不存在的路径。
部署包不包含源码目录的 `scripts/start.mjs`,因此解压部署包后直接用 `node` 运行编译产物入口 `dist/server/standalone.js`;源码目录中则使用 `pnpm start`。
把域名的 HTTPS 流量通过反向代理转发到应用,保留 API、静态资源和 WebSocket 路径;使用服务管理器管理进程、重启和日志。正式迁移前备份已有数据库,并按照目标配置决定启动时迁移还是发布时单独迁移。
+322 -11
View File
@@ -1,19 +1,330 @@
---
title: 'Production configuration'
description: 'How to supply the database address and secrets, and how to confirm they took effect.'
title: Production configuration
description: Shared application settings, standalone deployment settings and Hub-hosted settings, and how they differ.
---
# Production configuration
:::warning Being written
This page is being written.
:::
Application settings such as the database connection and authentication secrets use the same fields in both deployment modes. What differs is how the configuration file, the persistent directory and the network entry point are managed.
How to supply the database address and secrets, and how to confirm they took effect.
| Setting | Standalone application | Hub-hosted application |
| ---------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------- |
| Database, migrations and business service parameters | Written into the runtime configuration | Same fields, submitted to Hub |
| Authentication and session secrets | Provided in the runtime configuration | Hub can fill them in automatically in configuration-file mode |
| Configuration file and persistent directory | You maintain the file and the mounts | Hub and the Host manage them per application |
| Listening port and reverse proxy | Configured for the application | The platform's single entry point; the application has no port of its own |
## This page will cover
**Hub itself also has to be deployed on its own.** Installing Hub still means configuring its own database, secrets, listening port and persistent directory; see [Deploy Hub](./hub). "Hub-hosted" below refers only to applications published through the platform.
- Where production configuration comes from
- How to manage secrets — never commit them
- How to inspect the configuration the application resolved
- How local and production configuration differ
## Shared application configuration
These settings belong to the application itself. A standalone deployment writes them into `config.yml`; a Hub-hosted one enters them on the deployment page or submits them through the CLI's `--config`. Merge the example fields into that application's configuration template and keep the template's other feature settings.
### Configure the database
The application's main database connection lives under `database.connections.main`, and `database.default: main` makes it the default connection.
#### Supported main databases
The create-app command's `--dialect` currently offers the following eight options, and the application runtime has a loading entry for each official driver. Compatibility between a specific database version and your business plugins still has to be verified in the target environment.
| Database | `dialect` | Notes |
| ------------ | ----------- | ----------------------------------------------------------------------------------------------- |
| SQLite | `sqlite` | The default; file-based storage |
| PostgreSQL | `postgres` | Uses the PostgreSQL driver |
| MySQL | `mysql` | Uses the MySQL driver |
| SQL Server | `mssql` | Needs connection options such as `encrypt` and `trustServerCertificate` |
| Oracle | `oracle` | Identifies the service with `serviceName` |
| Dameng | `dameng` | The driver README still marks it experimental; verify the integration contract for your version |
| KingbaseES | `kingbase` | Currently targets the PostgreSQL compatibility mode (`DB_MODE=pg`) |
| OceanBase CE | `oceanbase` | Currently targets MySQL-compatible tenants |
Choose the main database with `--dialect` when creating an application; the scaffold generates the matching connection configuration and declares the driver dependency. Any database other than SQLite still needs its real connection details filled in, and a successfully created project does not mean the connection has been verified. SQLite, PostgreSQL and MySQL examples follow.
#### Using SQLite
The default application template uses SQLite. This configuration keeps the database file path the template provides:
```yaml
database:
default: main
connections:
main:
dialect: sqlite
schemaManagement: managed
migrations:
autoRun: true
seeds:
autoRun: true
```
The database file defaults to `database.sqlite` in the application's persistent directory. A standalone deployment can set an absolute path with `database.connections.main.database`; a Hub-hosted application usually keeps the directory the Host assigns. See [Standalone configuration](#standalone-configuration) and [Hub-hosted application configuration](#hub-hosted-application-configuration) below for the exact paths.
#### Using PostgreSQL
An example main connection for PostgreSQL:
```yaml
database:
default: main
connections:
main:
dialect: postgres
host: db.internal
port: 5432
database: crm
username: crm
password: REPLACE_WITH_DATABASE_PASSWORD
schemaManagement: managed
migrations:
autoRun: true
seeds:
autoRun: true
```
#### Using MySQL
MySQL uses `dialect: mysql`, and its default port is `3306`:
```yaml
database:
default: main
connections:
main:
dialect: mysql
host: db.internal
port: 3306
database: crm
username: crm
password: REPLACE_WITH_DATABASE_PASSWORD
schemaManagement: managed
migrations:
autoRun: true
seeds:
autoRun: true
```
#### Checks before connecting
- **Database and permissions**: create the database and account ahead of time. With automatic migrations enabled, the account needs the privileges migrations use, such as creating and altering tables; prepare Oracle, Dameng and the like according to their own service and schema conventions.
- **Connection address**: `host` must be reachable from where the application runs. Inside a container, `localhost` is the container itself; reach another database service through its service name or network address.
- **Database driver**: creating the application with `--dialect` declares the matching driver dependency. When an existing project switches to another database, confirm the target driver is installed and rebuild; official drivers are named `@nocobase/db-<dialect>`, for example `@nocobase/db-mssql`.
#### Migrations and initialization
The examples above use `schemaManagement: managed`, which lets the application's database migrations manage the schema and runs these tasks at startup:
| Setting | Effect |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `migrations.autoRun: true` | Applies migrations not yet run, from the first table creation through later schema changes |
| `seeds.autoRun: true` | Runs the initialization tasks the application and its plugins provide, such as default accounts and permission data |
Both options sit under the connection they apply to. A first deployment can keep the template's settings; if your release process runs migrations or initialization separately, set the corresponding option to `false` and finish those tasks before starting the application. For upgrades that change the database, see [Backup, recovery and troubleshooting](./operations).
### Configure the initial administrator
Before the first start, the runtime configuration can set the administrator's username and password. Standalone applications, Hub itself and Hub-hosted applications all use the same fields, and each application initializes its own account:
```yaml
users:
initialAdmin:
username: my_admin
password: REPLACE_WITH_INITIAL_ADMIN_PASSWORD
```
The username takes 3 to 30 letters, digits, underscores or dots and is stored in lowercase. When `users.initialAdmin` is configured explicitly, a non-empty password is required; when the username is omitted, `nocobase` is used.
The default template provides the username `nocobase` and the password `admin123`; replace them before deploying. This setting takes effect only when the default initialization task runs against an empty user table. Changing these fields on an existing application does not reset the account or its password.
### Configure authentication and session secrets
The application uses two server-side secrets:
| Setting | Purpose |
| ---------------- | ---------------------------------------------------------------------- |
| `auth.secret` | Lets the authentication component sign and encrypt authentication data |
| `session.secret` | Encrypts the session identifier in the application's session cookie |
Both are application configuration. They are neither a user's login password nor Hub's publishing API key.
#### Where secrets come from
| Source | What to do |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| A `config.yml` generated by the create-app command | Random secrets are already filled in and can be kept; the current scaffold writes the same generated value into both |
| A new file created from the archive's `config.example.yml` | The template holds placeholders only, which must be replaced; the application refuses to start with a placeholder |
| Deploying an application through Hub in configuration-file mode | Hub fills in missing, blank or placeholder secrets and keeps existing valid values |
| Updating an already deployed application | Keep the secrets that environment already has |
Hub's automatic completion applies to the applications it hosts. When deploying Hub itself, go by the actual source of its configuration: keep generated secrets if you have them, and generate them by hand when starting from the template.
#### Setting them by hand
Generate a random secret with the following command; each run prints a 64-character hexadecimal string:
```bash
openssl rand -hex 32
```
Run it once per secret and put the results into `config.yml`:
```yaml
auth:
secret: REPLACE_WITH_GENERATED_AUTH_SECRET
session:
secret: REPLACE_WITH_GENERATED_SESSION_SECRET
```
Replace only the `secret` field inside the existing `auth` and `session` nodes, keep their other settings, and do not add a second node with the same name.
Secrets are stored and backed up with the environment's configuration and never committed to a source repository. Keep them unchanged across restarts and upgrades; replacing a secret can invalidate existing logins or sessions.
## Standalone configuration
This section applies to applications run directly with Node.js or Docker. For Hub-hosted applications, skip to [Hub-hosted application configuration](#hub-hosted-application-configuration).
### Where the configuration file lives
A standalone deployment uses the following layout, with `config.yml` and `storage` beside `dist`:
```text
/srv/nocobase/crm/
├── dist/ build output and production dependencies
├── config.example.yml the configuration template shipped with the archive
├── config.yml the real configuration for this environment
└── storage/ persistent data such as the database and files
```
On the first deployment, create `config.yml` from `config.example.yml`, keep the template's feature settings, and change the environment-specific values such as the database and secrets. On an upgrade, replace `dist` and keep `config.yml` and `storage`; add any settings a new version introduces by comparing against the new template.
Name the configuration file explicitly with `APP_CONFIG_FILE`, preferably as an absolute path:
```bash
export APP_CONFIG_FILE=/srv/nocobase/crm/config.yml
```
A relative path resolves against the `dist/` directory, so use an absolute one. Without `APP_CONFIG_FILE` the application still starts, but authentication and sessions use temporary secrets generated on every start, and every login is lost on restart. Production must set it explicitly.
In Docker, use the path inside the container, such as `/app/config.yml`, and mount the host's configuration file there. The full mount example is in [Standalone: Docker](./docker).
### Environment variable overrides
The default template loads the configuration file first and then applies its environment variable mappings. **When the same setting appears both in the file and in its environment variable, the environment variable wins.** For example, `AUTH_SECRET` and `SESSION_SECRET` override `auth.secret` and `session.secret`.
Only variables with a declared mapping override anything; do not assume a variable such as `DB_HOST` exists just because the name looks plausible. For a customized application, the project's `server/environment.ts` is the authority.
### Persistent directory
`storage` has to stay outside the code that an update replaces, and the application process must be able to write to it. SQLite names its file with `database.connections.main.database`:
| Runtime | Example path |
| ------- | ---------------------------------------------------------------------------------------------- |
| Node.js | `/srv/nocobase/crm/storage/database.sqlite` |
| Docker | `/app/storage/database.sqlite`, with the host's persistent directory mounted at `/app/storage` |
The SQLite file path goes in the `database` field, matching the template's `config.example.yml`; the older `filename` is still accepted, and `database` wins when both are present. The full directory and mount examples are in [Standalone: build and run](./standalone) and [Standalone: Docker](./docker).
### Configure the public address
Take users visiting `https://apps.example.com/crm/` with a reverse proxy connecting to port `13000` on the same machine:
| Variable | Example | Purpose |
| ------------------- | -------------------------- | ----------------------------------------- |
| `APP_PUBLIC_ORIGIN` | `https://apps.example.com` | The external origin, without `/crm` |
| `APP_BASE_PATH` | `/crm` | The application's public mount path |
| `APP_SERVER_HOST` | `127.0.0.1` | The address the Node.js server listens on |
| `APP_SERVER_PORT` | `13000` | The port the Node.js server listens on |
A standalone Node.js deployment can set them in the startup environment:
```bash
export APP_PUBLIC_ORIGIN=https://apps.example.com
export APP_BASE_PATH=/crm
export APP_SERVER_HOST=127.0.0.1
export APP_SERVER_PORT=13000
```
Inside a container, `APP_SERVER_HOST` is usually `0.0.0.0`, with the port mapping controlling exposure on the host. `APP_BASE_PATH` must match the path used at build time; after changing it, rebuild and check the static assets. When unset, the application mounts at `/main`, not at the root.
`NODE_ENV=production` marks the session cookie `Secure`, and browsers send it only over HTTPS or to localhost, so plain HTTP access through the server's IP or domain cannot sign in.
### HTTPS and reverse proxy
The example below assumes Nginx runs on the same server as the application and the certificate is in place. Put the `map` in Nginx's `http` context and change the domain and certificate paths.
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name apps.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
location / {
proxy_pass http://127.0.0.1:13000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
}
}
```
Three parts of this configuration must stay:
- `proxy_pass` appends no path, so the application's `/crm` prefix and its API paths are preserved.
- `Host`, `X-Forwarded-Proto` and the other headers are forwarded for logs and upstream components; the application itself derives the external address from `APP_PUBLIC_ORIGIN`, not from these headers.
- `Upgrade` and `Connection` are forwarded so WebSocket works.
Run `nginx -t` before reloading. When the proxy fronts Hub, forward the whole site and let Hub route between the platform and its applications; the upload size limit is covered in [Deploy Hub](./hub#public-access).
## Hub-hosted application configuration
### Submit the runtime configuration
The following describes Hub's configuration-file mode:
| Operation | Configuration source |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| First deployment | Fill in the configuration template from the Release, or supply a file with the CLI's `--config` |
| Later deployment without a new configuration | Reuses the configuration Hub currently holds |
| Explicit `--config` | Replaces the configuration document, after Hub's secret handling and YAML validation |
Database connections, migration options and business service parameters such as mail still have to be filled in for the target environment. Hub fills in missing, blank or placeholder `auth.secret` and `session.secret` values and keeps existing valid ones. Deployment steps are in [Publish applications with Hub](./hub-publishing).
External configuration mode requires an application already wired to an external configuration source, and the template initialization and secret completion rules of configuration-file mode do not apply to it.
### Configuration file and persistent directory
Hub stores the desired configuration, the Host hands it to the application, and each application gets its own persistent directory. A hosted application does not set `APP_CONFIG_FILE` itself and needs no container mount of its own.
With the default template's SQLite and file storage paths, the data lives in that application's persistent directory. Do not copy absolute paths from a development machine or from the standalone examples. When connecting to an external database, its address must be reachable from where the Host runs; a Hub running in a container follows the container's network configuration.
Platform operators still have to persist the whole Hub storage directory; the mounts are in [Deploy Hub](./hub). A directory assigned by the Host does not mean the platform storage is already persisted or backed up.
### Access path and network entry
Build for, and reach the application at, the path Hub shows for it, such as `/crm`. A hosted application does not set `APP_SERVER_HOST` or `APP_SERVER_PORT` and needs no Nginx entry of its own; external requests are forwarded by Hub.
When a business feature generates external callbacks or links, still check the public address the application uses. `app.publicOrigin` is the external origin without the mount path, such as `https://apps.example.com`. It is a separate setting from the `/crm` path the Host assigns, and changing a listening port is no substitute for it.
## Confirm the configuration took effect
| Check | How |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| Configuration source | Standalone: check `APP_CONFIG_FILE` and the mounts. Hub-hosted: check the App's current configuration and deployment record |
| Environment variable overrides | Standalone: when a change to the file has no effect, look for the matching environment variable |
| Database connection | Read the startup log and fetch a known business record from the target database |
| Public address | Sign in through the real domain, refresh a nested page, and check asset and callback URLs |
| Realtime connection | Confirm the WebSocket features the application uses work |
| Data persistence | Create a test record and a file and confirm they survive a restart; with Docker, also recreate the container |
Never print the whole configuration when reading logs. When something is wrong, see [Troubleshooting](./operations#troubleshooting).
+82 -12
View File
@@ -1,19 +1,89 @@
---
title: 'Deploy with Docker'
description: 'Containerized deployment.'
title: 'Standalone: Docker'
description: Build the application into an image and run it with persistent storage, without Hub.
---
# Deploy with Docker
# Standalone: Docker
:::warning Being written
This page is being written.
:::
To run Hub itself in a container, read [Deploy Hub](./hub). This page builds an image from an application's build output; database and persistent directory settings are in [Production configuration](./configuration).
Containerized deployment.
## 1. Build the image
## This page will cover
First [build the deployment archive](./standalone#build-the-deployment-archive), then extract it into a separate image build directory that holds only `dist`, `config.example.yml` and the Dockerfile below. Never put the real configuration or a database into the image build context.
- How to build the image
- How to pass configuration into the container
- Connecting to the database
- Persisting data
```dockerfile
FROM node:24.15.0-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --chown=node:node dist/ /app/dist/
RUN mkdir -p /app/storage && chown node:node /app/storage
USER node
CMD ["node", "/app/dist/server/standalone.js"]
```
On an x64 build machine targeting Linux x64, run `docker build --platform linux/amd64 -t crm:release-001 .`. Building for another architecture needs a working Buildx or emulation setup, and the application archive must be built for the same architecture as the image. The image does not reinstall the dependencies in `dist`; they arrive with the archive.
## 2. Prepare the runtime configuration
Create a dedicated directory on the server holding `compose.yml`, the target environment's `config.yml` and `storage/`. Configure the secrets as described in [authentication and session secrets](./configuration#configure-authentication-and-session-secrets), but point the SQLite path at `/app/storage/database.sqlite` inside the container. Make sure the runtime user can write to `storage`; the `node` user in the official Node image is usually UID/GID 1000, but go by the `id` output of the image you chose.
## 3. Write the Compose file
Create `compose.yml` in the deployment directory:
```yaml
services:
crm:
image: crm:release-001
restart: unless-stopped
init: true
stop_grace_period: 60s
ports:
- '127.0.0.1:13000:13000'
environment:
NODE_ENV: production
APP_CONFIG_FILE: /app/config.yml
APP_BASE_PATH: /crm
APP_PUBLIC_ORIGIN: https://apps.example.com
APP_SERVER_HOST: 0.0.0.0
APP_SERVER_PORT: '13000'
NOCOBASE_STRICT_STARTUP: 'true'
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://127.0.0.1:13000/crm/api/healthz').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
volumes:
- ./config.yml:/app/config.yml:ro
- ./storage:/app/storage
```
`NOCOBASE_STRICT_STARTUP` makes a container that fails to start exit, so `restart: unless-stopped` retries it. The `healthcheck` calls the application's `/crm/api/healthz` endpoint; the image has no curl, so Node makes the request.
## 4. Start the service
Before starting, confirm `config.yml` exists and is a file, or Docker creates a directory at the missing bind path. In the deployment directory run:
```bash
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs --tail=100 crm
```
If the image was built on another machine, move it to the server through your own registry or with save/load. Inside the container, the database host cannot be the host machine's idea of `localhost`.
## Public access and acceptance
Configure the public address as described in [HTTPS and reverse proxy](./configuration#https-and-reverse-proxy), then follow [Initialize and verify](./standalone#initialize-and-verify) to change the administrator password, verify real business operations and confirm data survives a restart.
## Update and recovery
Use a new image tag or digest and keep the existing configuration and `storage` mounts. Back up and check migration compatibility before releasing, update `image`, run `docker compose up -d`, and verify real business operations again. See [Update and roll back](./standalone#update-and-roll-back) and [Backup and recovery](./operations#backup-and-recovery).
+34 -13
View File
@@ -1,21 +1,42 @@
---
title: 'Deployment'
description: 'How to package the application and put it on a server.'
title: Deployment
description: Choose standalone or Hub deployment, then configure, publish and operate the application.
---
# Deployment
:::warning Being written
This page is being written.
:::
A NocoBase 3 application can be deployed on its own, or published and run through Hub, which manages application releases and runtime centrally.
From running on your own machine to running on a real server.
## What Hub is
## In this section
Hub is NocoBase's platform for deploying and managing applications. Developers build an application into a deployment archive and upload it to Hub; administrators with the right permissions pick a version in the browser, fill in the runtime configuration, start a deployment, and follow its status and logs. Once the application is live, Hub is also where it is updated to a new version, started, stopped or rolled back.
- [Production configuration](./configuration) — How to supply the database address and secrets, and how to confirm they took effect.
- [Standalone: build and run](./standalone) — What the build output looks like, and how to start it on a server.
- [Standalone: Docker](./docker) — Containerized deployment of one application.
- [Deploy Hub](./hub) — Deploying the Hub platform that hosts and manages applications.
- [Publish applications with Hub](./hub-publishing) — Uploading a release and deploying, updating, stopping and rolling back an application through the UI or CLI.
- [Backup, recovery and troubleshooting](./operations) — What to back up, how to restore, and where to look when something fails.
Take a CRM application you have already built:
- **Without Hub**: put the CRM archive on a server, configure the database and secrets, and start it with Node.js or Docker. Later updates mean maintaining the code or image on that server yourself.
- **With Hub**: prepare a working Hub, create an App record for the CRM in it, upload the archive and deploy. Later versions are published through the Hub console or the CLI.
Hub manages one or many applications and is meant for developers and operators. Business users reach each application at its own address.
## How Hub relates to your applications
Hub itself has to be installed on a server. Once a platform administrator has done that, application developers can use it to publish applications; if your team already runs a Hub, all you need is the application and permission to publish.
```text
Developer: build the CRM archive → upload to Hub → configure and deploy
Administrator: open Hub → review versions, deployment results, runtime status and logs
Business user: open the CRM address → work with customers, orders and the rest
```
One server can serve two addresses for two purposes, such as `https://apps.example.com/hub/` for the management platform and `https://apps.example.com/crm/` for the application itself. Hub's administrator accounts and permissions are maintained separately from the CRM's business accounts and data permissions.
## Choosing a deployment mode
| Mode | When it fits | Where to read |
| ---------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Standalone | You manage the application process, containers and release flow yourself | [Production configuration](./configuration), [Build and run](./standalone); for containers continue with [Docker](./docker) |
| Hub | Versions, configuration, deployment records and start/stop are managed centrally through Hub | [Deploy Hub](./hub) → [Publish applications with Hub](./hub-publishing); with an existing Hub, read the latter directly |
## After going live
Backups, recovery steps and common problems are covered in [Backup, recovery and troubleshooting](./operations).
+135 -15
View File
@@ -1,26 +1,122 @@
---
title: 'Build and run'
description: 'What the build output looks like, and how to start it on a server.'
title: 'Standalone: build and run'
description: Build the deployment archive and run the application on a server on its own, without Hub.
---
# Build and run
# Standalone: build and run
:::warning Being written
This page is being written.
:::
This page covers the Node.js deployment flow without Hub. For containers, build as described here and then continue with [Standalone: Docker](./docker); for platform hosting, see [Deploy Hub](./hub).
What the build output looks like, and how to start it on a server.
## Environment and directories
## This page will cover
Build and run on Node.js 24, with the pnpm version the project's `packageManager` names. Confirm the server's CPU architecture, operating system and libc. This page assumes Linux x64 with glibc; choose `linux-arm64` for ARM64, and for musl environments such as Alpine pick the matching target and verify the native dependencies.
- What `pnpm build` produces
- What the server needs installed
- The start command
- Keeping the process alive
Keep three kinds of content apart: `dist` is replaceable code, `config.yml` is the target environment's configuration, and `storage` is data to preserve, such as the database, uploaded files and logs. Sessions are held in memory by default, so a restart signs everyone out. This page uses a fixed deployment root and replaces only `dist` on an upgrade, never the configuration or `storage`.
## Keep the process alive with pm2
```text
/srv/nocobase/crm/
dist/ the running code and its production dependencies
config.example.yml the configuration reference for the current version
config.yml the real runtime configuration
storage/ persistent data
releases/ retained archives, named by whoever operates the server
```
When systemd is not an option, [pm2](https://pm2.keymetrics.io/) can supervise the process. The application project ships an `ecosystem.config.js` that starts `./dist/server/standalone.js` with `node` and sets `NODE_ENV=production`. The file is not part of the deployment archive: copy it from the project into the deployment root, beside `dist`. Pass the remaining runtime settings as environment variables or add them to the file's `env` block, then run the following from the deployment root:
Create `/srv/nocobase/crm` and a dedicated service account first. The account needs to read `dist` and `config.yml` and write to `storage`. Run the extraction steps below as that account; if an administrator does it instead, hand ownership of the configuration file and the persistent directories to the service account afterwards.
## Build the deployment archive
Run the following in the root of your application project, not in the NocoBase monorepo. Install dependencies with the project's lockfile and its pnpm version.
```bash
pnpm install --frozen-lockfile
pnpm typecheck
pnpm test
pnpm lint
APP_BASE_PATH=/crm pnpm build --target linux-x64 --node-version 24 --tar
tar -tzf storage/exports/dist.tar.gz | head -30
```
The output is `storage/exports/dist.tar.gz`, containing `dist/` and `config.example.yml`, and `dist` already carries its production dependencies. The real configuration and local business data do not belong in the artifact. Note that `pnpm build` copies the allow-listed keys of the project's `.env`, such as `DB_PASSWORD` and `APP_BASE_PATH`, into `dist/.env`, which ships with the artifact; before building, make sure it holds nothing that must not reach production. Native dependencies from a default build on macOS cannot be used on Linux. State the mount path explicitly at build time and keep it the same at runtime; after changing the path, rebuild and check the static assets.
## Run with Node.js directly
**First installation.** Copy the archive to the server and extract it into a `/srv/nocobase/crm` that has no application installed yet. These are the first-installation steps; an environment that already runs a version follows [Update and roll back](./standalone#update-and-roll-back) instead.
```bash
cd /srv/nocobase/crm
tar -xzf /path/to/dist.tar.gz
test -f config.yml || cp config.example.yml config.yml
mkdir -p storage
chmod 600 config.yml
```
Keep the template's feature settings, change the database and secrets, and set the [initial administrator](./configuration#configure-the-initial-administrator) before the first start. Run `openssl rand -hex 32` twice and put the two values into `auth.secret` and `session.secret`; never keep the template placeholders. The following are the key parts to merge into the real configuration, not a complete template:
```yaml
auth:
secret: REPLACE_WITH_GENERATED_AUTH_SECRET
session:
secret: REPLACE_WITH_GENERATED_SESSION_SECRET
database:
default: main
connections:
main:
dialect: sqlite
database: /srv/nocobase/crm/storage/database.sqlite
schemaManagement: managed
migrations:
autoRun: true
seeds:
autoRun: true
```
SQLite names its file with `database`. With PostgreSQL, MySQL or another database, also confirm the build output includes the matching driver, fill in the real connection details and verify connectivity; the driver requirements are in [Production configuration](./configuration#checks-before-connecting). To control when a release runs its migrations, turn off the corresponding automatic option and have the release operator run them.
**Verify in the foreground.** In the deployment root, run:
```bash
NODE_ENV=production \
APP_CONFIG_FILE=/srv/nocobase/crm/config.yml \
APP_BASE_PATH=/crm \
APP_PUBLIC_ORIGIN=https://apps.example.com \
APP_SERVER_HOST=127.0.0.1 \
APP_SERVER_PORT=13000 \
node ./dist/server/standalone.js
```
`APP_PUBLIC_ORIGIN` carries no `/crm`; the mount path comes from `APP_BASE_PATH`. Request `http://127.0.0.1:13000/crm/api/healthz`: a JSON response whose `ok` is `true` means the application is ready, and the other fields are the application name and mount path. Signing in from a browser needs localhost or HTTPS, because the session cookie carries the `Secure` flag in production mode. Check the real pages and the logs, then stop the foreground process before handing over to the service manager, so two applications never run against the same data.
**Long-running service.** A Linux systemd example: save the following as `/etc/systemd/system/nocobase-crm.service`. The `nocobase` account must already exist, and the Node path must be replaced with what `command -v node` prints on the server.
```ini
[Unit]
Description=NocoBase CRM
After=network.target
[Service]
Type=simple
User=nocobase
WorkingDirectory=/srv/nocobase/crm
Environment=NODE_ENV=production
Environment=APP_CONFIG_FILE=/srv/nocobase/crm/config.yml
Environment=APP_BASE_PATH=/crm
Environment=APP_PUBLIC_ORIGIN=https://apps.example.com
Environment=APP_SERVER_HOST=127.0.0.1
Environment=APP_SERVER_PORT=13000
Environment=NOCOBASE_STRICT_STARTUP=true
ExecStart=/usr/bin/node /srv/nocobase/crm/dist/server/standalone.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target
```
`NOCOBASE_STRICT_STARTUP=true` makes the application exit non-zero when startup fails, which is what lets `Restart=on-failure` actually restart it. An administrator runs `systemctl daemon-reload` and `systemctl enable --now nocobase-crm`. Check `systemctl status nocobase-crm` and `journalctl -u nocobase-crm`; restart with `systemctl restart nocobase-crm`.
**Running with pm2.** When systemd is not an option, [pm2](https://pm2.keymetrics.io/) can supervise the process. The application project ships an `ecosystem.config.js` that starts `./dist/server/standalone.js` with `node` and sets `NODE_ENV=production`. The file is not part of the deployment archive: copy it from the project into the deployment root, beside `dist`. Pass the remaining runtime settings as environment variables or add them to the file's `env` block, then run the following from the deployment root:
```bash
APP_CONFIG_FILE=/srv/nocobase/crm/config.yml \
@@ -32,4 +128,28 @@ pm2 start ecosystem.config.js
pm2 save
```
The process name is the `name` field in the file, `nocobase-app-template-default` by default; rename it per application. Use `pm2 restart <name>` to restart, `pm2 logs <name>` to read logs, and `pm2 startup` to print the command that starts pm2 at boot.
The process name is the `name` field in the file, `nocobase-app-template-default` by default; rename it per application. The file is an ES module: if pm2 reports a syntax error when loading it from a deployment root that has no `package.json`, rename it to `ecosystem.config.mjs`. Use `pm2 restart <name>` to restart, `pm2 logs <name>` to read logs, and `pm2 startup` to print the command that starts pm2 at boot.
## Configure HTTPS
Once the foreground start works, set up the domain, certificate and forwarding as described in [HTTPS and reverse proxy](./configuration#https-and-reverse-proxy), then carry out the acceptance checks below.
## Initialize and verify
Open `https://apps.example.com/crm/` and sign in with the username and password from the [initial administrator configuration](./configuration#configure-the-initial-administrator). With the default configuration unchanged, the username is `nocobase` and the password `admin123`; the email `admin@nocobase.com` also signs in. When the default password is in use, change it right after the first sign-in and before opening access. An existing application keeps its original account, and changing the initialization settings does not reset its password; customized initialization tasks follow the project's own rules.
Request `https://apps.example.com/crm/api/healthz` first: a JSON response whose `ok` is `true` means the application is ready, and the same address serves as the health check for a service manager or load balancer. Then verify sign-in, sign-out, page refresh, static assets, the API, realtime connections and real business operations. Create a test record and upload a file, restart the service, and confirm both are still there. Confirm external callbacks and notification links use the right domain and mount path.
## Update and roll back
Build the new archive in the build environment first, check migration compatibility, and back up the database, files and configuration. Extract the new archive into a separate temporary directory and inspect it, keeping the old archive. Schedule a maintenance window, stop the application, move the old `dist` into the retained directory and put the new `dist` into the deployment root; keep the original `config.yml` and `storage`. Review the configuration differences against the new template, without overwriting the real configuration. After starting, repeat [Initialize and verify](./standalone#initialize-and-verify).
The Docker route uses a new image tag: back up, update `image` in the Compose file, run `docker compose up -d`, and keep the existing mounts. If acceptance fails, switching straight back to the old image or `dist` is only possible while the database is still compatible with the old code; otherwise restore as a set, as described in [Backup and recovery](./operations#backup-and-recovery).
## Troubleshooting and operations
When a start or an update fails, see [Backup, recovery and troubleshooting](./operations).
## Advanced: App Host without Hub
App Host also supports a standalone mode that discovers build outputs in a local application directory and mounts the applications by path, activating them on demand by default. It differs from the [standalone application deployment](./standalone) on this page and does not provide Hub's management console or publishing flow. Choosing this mode means managing directories, artifacts, configuration and recovery yourself; see `packages/app/app-host/README.md` in the repository.
+3 -3
View File
@@ -64,7 +64,7 @@ Builds target the current machine by default. For a Node.js 24, Linux x64, glibc
pnpm build --target linux-x64 --tar
```
The output is `storage/dist.tar.gz`, containing `dist/` and `config.example.yml`. Use a matching target for ARM or musl. Native database dependencies must also match the target platform and Node.js version.
The output is `storage/exports/dist.tar.gz`, containing `dist/` and `config.example.yml`; `dist/` already carries its production dependencies. Use a matching target for ARM or musl. Native database dependencies must also match the target platform and Node.js version.
Do not publish the local database, test passwords, or real `config.yml`. Upload the build archive to a new release directory and extract it:
@@ -87,10 +87,10 @@ export APP_BASE_PATH=/main
export APP_SERVER_HOST=127.0.0.1
export APP_SERVER_PORT=13000
export NODE_ENV=production
node --input-type=module -e "const { startServer } = await import('./dist/server/standalone.js'); startServer();"
node ./dist/server/standalone.js
```
The archive does not contain the source workspace's `scripts/start.mjs`. Use the compiled entry after extracting an archive, and `pnpm start` in the source workspace. Check the actual artifact exports and paths.
The archive does not contain the source workspace's `scripts/start.mjs`. After extracting an archive, run the compiled entry `dist/server/standalone.js` with `node`; in the source workspace use `pnpm start`.
Configure an HTTPS reverse proxy for the domain, retaining API, static asset, and WebSocket paths. Use a service manager for process startup, restarts, and logs. Back up an existing database before migrations and decide whether the target runs them at startup or as a separate release step.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@nocobase/app-skills",
"version": "0.1.0-beta.11",
"description": "Agent Skills for developing and upgrading NocoBase applications.",
"description": "Agent Skills for developing, upgrading and deploying NocoBase applications.",
"license": "Apache-2.0",
"repository": {
"type": "git",
@@ -5,7 +5,7 @@ description: Plan, build, deploy, verify, upgrade, and troubleshoot NocoBase 3 a
# NocoBase production deployment
Use this Skill to execute a complete deployment workflow. Read the repository `AGENTS.md` and the relevant deployment pages before changing files or running stateful commands. The user-facing deployment guide is under [`docs/docs/cn/deployment/`](../../../docs/docs/cn/deployment/) and is the reference for command details and platform-specific examples.
Use this Skill to execute a complete deployment workflow. Read the application's `AGENTS.md` and `README.MD` before changing files or running stateful commands: the README documents this application's build targets, archive layout, configuration variables and Hub publishing commands. The Deployment section of the NocoBase 3 documentation is the reference for command details and platform-specific examples; in the nocobase3 source repository it is `docs/docs/<lang>/deployment/`.
Do not treat a process being healthy as proof that the application is usable. A deployment is complete only after the database, configuration, application behavior, workflow artifacts, and persistence checks below have evidence.
@@ -44,7 +44,7 @@ APP_BASE_PATH=/crm pnpm build --target linux-x64 --node-version 24 --tar
tar -tzf storage/exports/dist.tar.gz | head -30
```
For a direct server deployment, transfer the archive and extract it into the deployment root. Do not run `pnpm install --prod` as a substitute for the packaged production dependencies unless the project-specific build instructions explicitly require it. For Docker, build the image from the production `dist` tree and keep configuration and storage outside the image. Ensure the build target matches the server or image architecture, libc, and Node ABI.
For a direct server deployment, transfer the archive and extract it into the deployment root. Nothing needs installing there: `pnpm build` already ran `pnpm install --prod` inside `dist/`, and the archive carries the resulting `dist/node_modules`. `dist/package.json` stays in the tree so that the same command can be rerun inside `dist/` on the server if `node_modules` was left out of a copy; that is a repair, not a step of a normal deployment, and it is never run in the application source tree. For Docker, build the image from the production `dist` tree and keep configuration and storage outside the image. Ensure the build target matches the server or image architecture, libc, and Node ABI.
## Decide the data operation
@@ -91,7 +91,7 @@ For network uncertainty, inspect the Hub record before retrying. Reuse the same
If the application contains DSL workflows or other compiled workflow artifacts, treat the production build as a new artifact set. The workflow definition is compiled into production JavaScript and receives a deployment hash; the development artifact or previous hash may not exist in the production package.
After deployment, check that each workflow's active version points to an artifact present in the production build. If the runtime reports `Workflow Artifact <key>/<hash> is missing`, do not enable the workflow by its database flow ID alone. Enable the pending version using its deployed artifact hash (`enable(hash)` or the corresponding management API route), then trigger a real business event and inspect the run result. Keep source checking, artifact building, synchronization, enablement, and invocation as separate checks.
After deployment, check that each workflow's active version points to an artifact present in the production build. If the runtime reports `Workflow Artifact <key>/<hash> is missing`, do not enable the workflow by its database flow ID alone: that keeps the old hash. Enable the pending version by its deployed artifact hash, either with **Enable new version** on the workflow in the management UI or with `POST <APP_BASE_PATH>/api/workflows/<hash>/enable`, then trigger a real business event and inspect the run result. Keep source checking, artifact building, synchronization, enablement, and invocation as separate checks.
## Verify the deployed application
@@ -190,7 +190,10 @@ export class WorkflowService {
const hash = workflow.hash;
if (!hash || !(await this.store.has(workflow.key, hash)))
throw new Error(
`Workflow Artifact ${workflow.key}/${String(hash)} is missing`,
`Workflow Artifact ${workflow.key}/${String(hash)} is missing from this build. ` +
'The database still points at the hash of an earlier build, so enable the version this build deployed: ' +
'choose "Enable new version" on the workflow in workflow management, or call POST /api/workflows/<hash>/enable with the new hash. ' +
'Enabling by workflow id keeps the missing hash.',
);
}
@@ -123,6 +123,30 @@ function createService(
});
}
describe('application workflow Artifact lazy synchronization', () => {
it('names the way out when the enabled hash is absent from the build', async () => {
const f = await fixture();
const v1 = await emit(f.distRoot, 'v1');
const service = createService(f);
const repository = new WorkflowRepository(f.database, service);
await repository.enable(v1);
// A database carried over from an earlier build points at a hash this
// build never produced, so neither dist nor the Artifact store has it.
const stale = 'f'.repeat(64);
await workflowStore(f.database).workflows.updateMany({
filter: { key: 'sample' },
values: { hash: stale },
});
try {
await expect(service.trigger('sample', {})).rejects.toThrow(
new RegExp(
`Workflow Artifact sample/${stale} is missing from this build.*Enable new version.*POST /api/workflows/<hash>/enable`,
),
);
} finally {
await service.dispose();
}
});
it('loads TypeScript resources directly from the workflow package in development', async () => {
const f = await fixture();
const digest = await emit(f.distRoot, 'artifact');
@@ -32,6 +32,8 @@ Read the one page your task needs, not the whole directory.
`.agents/skills/nocobase-app-upgrade/` is a separate Skill for a separate job: merging a newer release of the template this application was generated from. Read it when the task is upgrading the template rather than building a feature, and read it before touching anything — an upgrade done by copying the newest template over this application destroys the work that made it this application.
`.agents/skills/nocobase-deployment/` covers moving this application from source to a production server or a Hub: building for the target platform, what the archive does and does not carry, migrations and business data, production configuration, workflow artifacts after a production build, and what to verify afterwards. Read it before building for deployment, and when an application starts in production but does not work.
## Where things go
Business code goes in these places. This is where you work, and where you should stay unless the task genuinely requires otherwise:
@@ -203,7 +203,7 @@ An older application with the new CLI but without the script can run `pnpm nocob
#### Deploying to a different machine
`pnpm build` produces `dist/`, which is the whole deployment: copy it to a server and run it. It carries its own `package.json`, and `pnpm install --prod` there installs exactly what the server needs.
`pnpm build` produces `dist/`, which is the whole deployment: copy it to a server and run it. The build already ran `pnpm install --prod` inside `dist/`, so the tree ships with its `node_modules` and needs no install step on the server. It also keeps its own `package.json`, so `pnpm install --prod` run inside `dist/` on the server reinstalls exactly the same set: useful when `node_modules` was left out of a copy, never required after a normal build.
Client packages are not among them. Plugins declare what their browser code needs as peer dependencies, so your application installs one shared copy for its Vite build, while `dist/` opts out of peer installation entirely — `lucide-react`, `@base-ui/react`, `sonner` and the rest never reach a server that has no browser in it.
@@ -32,6 +32,8 @@ Read the one page your task needs, not the whole directory.
`.agents/skills/nocobase-app-upgrade/` is a separate Skill for a separate job: merging a newer release of the template this application was generated from. Read it when the task is upgrading the template rather than building a feature, and read it before touching anything — an upgrade done by copying the newest template over this application destroys the work that made it this application.
`.agents/skills/nocobase-deployment/` covers moving this application from source to a production server or a Hub: building for the target platform, what the archive does and does not carry, migrations and business data, production configuration, workflow artifacts after a production build, and what to verify afterwards. Read it before building for deployment, and when an application starts in production but does not work.
## Where things go
Business code goes in these places. This is where you work, and where you should stay unless the task genuinely requires otherwise:
@@ -375,7 +375,7 @@ An older application with the new CLI but without the script can run `pnpm nocob
#### Deploying to a different machine
`pnpm build` produces `dist/`, which is the whole deployment: copy it to a server and run it. It carries its own `package.json`, and `pnpm install --prod` there installs exactly what the server needs.
`pnpm build` produces `dist/`, which is the whole deployment: copy it to a server and run it. The build already ran `pnpm install --prod` inside `dist/`, so the tree ships with its `node_modules` and needs no install step on the server. It also keeps its own `package.json`, so `pnpm install --prod` run inside `dist/` on the server reinstalls exactly the same set: useful when `node_modules` was left out of a copy, never required after a normal build.
Client packages are not among them. Plugins declare what their browser code needs as peer dependencies, so your application installs one shared copy for its Vite build, while `dist/` opts out of peer installation entirely — `lucide-react`, `@base-ui/react`, `sonner` and the rest never reach a server that has no browser in it.
@@ -26,6 +26,8 @@ Read the one page your task needs, not the whole directory.
`.agents/skills/nocobase-app-upgrade/` is a separate Skill for a separate job: merging a newer release of the template this hub was generated from. Read it when the task is upgrading the template rather than building a feature, and read it before touching anything — an upgrade done by copying the newest template over this hub destroys the work that made it this hub.
`.agents/skills/nocobase-deployment/` covers moving this hub from source to a production server: building for the target platform, what the archive does and does not carry, migrations and business data, production configuration, and what to verify afterwards, including each hosted App. Read it before building for deployment, and when the hub starts in production but does not work.
## Where things go
Business code goes in these places. This is where you work, and where you should stay unless the task genuinely requires otherwise:
@@ -202,7 +202,7 @@ An older application with the new CLI but without the script can run `pnpm nocob
#### Deploying to a different machine
`pnpm build` produces `dist/`, which is the whole deployment: copy it to a server and run it. It carries its own `package.json`, and `pnpm install --prod` there installs exactly what the server needs.
`pnpm build` produces `dist/`, which is the whole deployment: copy it to a server and run it. The build already ran `pnpm install --prod` inside `dist/`, so the tree ships with its `node_modules` and needs no install step on the server. It also keeps its own `package.json`, so `pnpm install --prod` run inside `dist/` on the server reinstalls exactly the same set: useful when `node_modules` was left out of a copy, never required after a normal build.
Client packages are not among them. Plugins declare what their browser code needs as peer dependencies, so your application installs one shared copy for its Vite build, while `dist/` opts out of peer installation entirely — `lucide-react`, `@base-ui/react`, `sonner` and the rest never reach a server that has no browser in it.
+25 -9
View File
@@ -11,7 +11,7 @@
//
// Links rather than copies, so editing a Skill through either path edits the one committed file. Relative links, so
// the checkout stays movable.
import { lstat, mkdir, readdir, symlink, unlink } from 'node:fs/promises';
import { lstat, mkdir, readdir, stat, symlink, unlink } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
@@ -71,18 +71,24 @@ async function createLink(root, name, linkPath) {
}
}
// `withFileTypes` reports a symbolic link as a link rather than as the directory it points at, so `.claude/skills/`
// has to accept both: its entries are the links this script writes, and a link whose target is already gone still has
// to be found in order to be pruned.
// `withFileTypes` reports a symbolic link as a link rather than as the directory it points at, so both directories
// need care. `.claude/skills/` accepts links outright: its entries are the links this script writes, and a link whose
// target is already gone still has to be found in order to be pruned. `.agents/skills/` accepts a link only when it
// resolves to a directory: a Skill that is published with a package lives in that package and is committed here as a
// relative link, and a link is still one Skill to mirror.
async function readDirectoryNames(directory, { links = false } = {}) {
try {
const entries = await readdir(directory, { withFileTypes: true });
return entries
.filter(
(entry) => entry.isDirectory() || (links && entry.isSymbolicLink()),
const names = [];
for (const entry of entries) {
if (
entry.isDirectory() ||
(entry.isSymbolicLink() &&
(links || (await isDirectory(path.join(directory, entry.name)))))
)
.map((entry) => entry.name)
.sort();
names.push(entry.name);
}
return names.sort();
} catch (error) {
if (isNodeError(error, 'ENOENT') || isNodeError(error, 'ENOTDIR'))
return [];
@@ -110,6 +116,16 @@ async function removeSymbolicLink(linkPath) {
return true;
}
async function isDirectory(target) {
try {
return (await stat(target)).isDirectory();
} catch (error) {
if (isNodeError(error, 'ENOENT') || isNodeError(error, 'ENOTDIR'))
return false;
throw error;
}
}
function isNodeError(error, code) {
return error !== null && typeof error === 'object' && error.code === code;
}
+53
View File
@@ -5,6 +5,7 @@ import {
mkdtemp,
readlink,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
@@ -37,6 +38,58 @@ test('links every committed skill with a relative target', async (t) => {
);
});
test('mirrors a skill committed as a link to its package', async (t) => {
const root = await createRepository(t, ['nocobase-alpha']);
const packaged = path.join(root, 'packages', 'app', 'app-skills', 'skills');
await mkdir(path.join(packaged, 'nocobase-deployment'), { recursive: true });
await writeFile(
path.join(packaged, 'nocobase-deployment', 'SKILL.md'),
'# deployment\n',
);
await symlink(
path.join(
'..',
'..',
'packages',
'app',
'app-skills',
'skills',
'nocobase-deployment',
),
path.join(root, AGENT_SKILLS_DIRECTORY, 'nocobase-deployment'),
'dir',
);
await symlink(
path.join('..', '..', 'packages', 'nowhere'),
path.join(root, AGENT_SKILLS_DIRECTORY, 'nocobase-dangling'),
'dir',
);
assert.deepEqual(await linkClaudeSkills(root), {
linked: ['nocobase-alpha', 'nocobase-deployment'],
removed: [],
skipped: [],
});
assert.equal(
await readlink(
path.join(root, CLAUDE_SKILLS_DIRECTORY, 'nocobase-deployment'),
),
path.join('..', '..', AGENT_SKILLS_DIRECTORY, 'nocobase-deployment'),
);
assert.ok(
(
await stat(
path.join(
root,
CLAUDE_SKILLS_DIRECTORY,
'nocobase-deployment',
'SKILL.md',
),
)
).isFile(),
);
});
test('repeats without changing the result', async (t) => {
const root = await createRepository(t, ['nocobase-alpha']);