npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fidt/saul-cli

v0.2.0

Published

CLI snapshot/apply YAML definitions & authorization cho Saul modules

Readme

@fidt/saul-cli

CLI snapshot/apply YAML cho definitions (module/service/function/model/error) và authorization config (policy/permission) của một module Saul. Cùng mô hình snapshot → plan → apply mà apps/directus dùng (pnpm save / pnpm plan / pnpm apply): repo của module tự lưu YAML, review qua PR, rồi CI apply vào tenant tương ứng — không cần đăng nhập tay vào Designer UI.

Khác với Directus, saul-cli không nói chuyện trực tiếp với Directus. Nó gọi thẳng các RPC mà Saul backend đã tự expose sẵn (module hệ thống saul, exportModuleYaml/importModuleYaml, exportPoliciesYaml/importPoliciesYaml, exportPermissionsYaml/importPermissionsYaml) — nên mọi rule nghiệp vụ (chặn ghi module hệ thống, giữ baseline PublicPolicy, tenant scoping...) đã được backend đảm bảo, CLI không phải lặp lại.

Cài đặt

Trạng thái hiện tại: package này chưa được publish lên npm registry nào. pnpm add -D @fidt/saul-cli / npx @fidt/saul-cli sẽ báo 404 cho tới khi ai đó chủ động npm publish (xem packages/saul/interfaces để biết cách các package @fidt/* khác đã publish trước đó). Đây là hành động công khai, không tự động làm khi build.

Test/chạy local (không cần publish) — chọn một trong hai cách link tuỳ package manager sẵn có:

cd packages/saul/saul-cli
pnpm install --ignore-workspace   # cài dependency riêng cho package này
pnpm run build

npm link                          # tạo lệnh `saul` global trỏ vào dist/cli.js local
saul --help

# hoặc, nếu dùng Bun:
bun link                          # đăng ký @fidt/saul-cli vào Bun global link registry
bunx saul --help                  # bunx tự resolve ra bin đã link, không cần npm link song song

bun link (không kèm tên package) chạy trong thư mục saul-cli — nó đọc package.json tại đó để biết tên gói (@fidt/saul-cli) và field bin (saul), rồi tạo symlink:

~/.bun/install/global/node_modules/@fidt/saul-cli  →  packages/saul/saul-cli   (nguyên thư mục)
~/.bun/bin/saul                                     →  .../dist/cli.js         (bin)

bunx saul sau đó chạy được từ bất kỳ thư mục nào, vì ~/.bun/bin nằm trong PATH và bunx tự tìm bin đã link trước khi thử tải từ registry. Do symlink trỏ thẳng vào dist/cli.js, mỗi lần sửa code phải pnpm run build lại (hoặc pnpm run dev chạy tsup --watch) — không cần bun link lại. Gỡ bằng bun unlink trong packages/saul/saul-cli.

dist/cli.js có shebang #!/usr/bin/env node, nên bunx saul mặc định vẫn chạy dưới Node, chỉ có bunx (bộ quản lý gói) là Bun. Muốn ép chạy dưới Bun runtime: bunx --bun saul. Việc này chưa được test kỹ với saul login (mở HTTP server loopback) — ưu tiên Node cho các lệnh auth nếu gặp lỗi lạ.

Sau khi publish thật lên registry:

pnpm add -D @fidt/saul-cli
# hoặc chạy không cài
npx @fidt/saul-cli --help
bunx @fidt/saul-cli --help

Hai danh tính: người dùng và M2M

CLI chạy được dưới hai danh tính, và tự chọn theo cấu hình đang có:

Luật chọn, xét theo thứ tự:

| # | Điều kiện | Chế độ | |---|---|---| | 1 | SAUL_CLI_AUTH_MODE=user\|m2m | đúng như khai (ép m2m mà thiếu secret ⇒ báo lỗi) | | 2 | Đã saul login với đúng cặp (issuer, client id) đang dùng | user | | 3 | Có SAUL_CLI_CLIENT_SECRET | m2m | | 4 | Còn lại | user (và báo lỗi bảo chạy saul login) |

Có secret không mặc nhiên là M2M. Client confidential vẫn đăng nhập user được — nó chỉ phải gửi kèm secret ở bước xác thực client. Nếu cứ thấy secret là nhảy sang M2M thì saul login sẽ thành công rồi mọi lệnh sau đó lặng lẽ chạy dưới danh tính máy, sai hẳn ý người dùng. CI không có ~/.saul nên quy tắc #2 không bao giờ khớp, vẫn rơi về m2m như trước.

Backend nhận cả hai loại giống nhau — Authorization: Bearer <token> — nên mọi lệnh snapshot/plan/apply không quan tâm mình đang chạy ở chế độ nào. Khác biệt duy nhất nằm ở phân quyền: token M2M mang sẵn policies của client, token người dùng đi qua membership + role của user trong tenant.

Cấu hình

Biến môi trường:

| Biến | Bắt buộc | Ý nghĩa | |---|---|---| | SAUL_CLI_BACKEND_URL | ✓* | Base URL Saul backend của tenant đích (vd https://saul.<tenant>.example.com) | | SAUL_CLI_TOKEN_URL | ✓* | OIDC token endpoint (identity). Issuer được suy ra từ đây (bỏ hậu tố /token) | | SAUL_CLI_CLIENT_ID | ✓* | Client id OIDC (oidc_client) | | SAUL_CLI_CLIENT_SECRET | chỉ M2M | Client secret. Client public (khuyến nghị cho CLI) không cần | | SAUL_CLI_MODULE | ✓* | Tên module cần snapshot/apply (có thể override bằng --module) | | SAUL_CLI_TENANT | ✓* | Id (UUID, không phải tên) của tenant sở hữu module — xem giải thích bên dưới | | SAUL_CLI_ISSUER_URL | | Ghi đè issuer nếu identity mount ở path khác thường | | SAUL_CLI_SCOPE | | Scope xin lúc đăng nhập. Mặc định openid profile email offline_access | | SAUL_CLI_REDIRECT_URI | | Chỉ cho saul login --use-browser. Mặc định http://localhost:8400/callback | | SAUL_CLI_CONFIG_DIR | | Thư mục lưu phiên đăng nhập. Mặc định ~/.saul | | SAUL_CLI_AUTH_MODE | | Ép user hoặc m2m, bỏ qua luật tự chọn ở mục trên |

* có thể khai thay bằng file saul.config.yaml ở root repo module (giá trị KHÔNG nhạy cảm, commit được — clientId của client public không phải secret):

backendUrl: https://saul.acme.example.com
tokenUrl: https://identity.acme.example.com/api/oidc/token
clientId: saul-cli
module: wfm_bds
tenant: 9f1c2e34-xxxx-xxxx-xxxx-xxxxxxxxxxxx
outDir: saul   # tuỳ chọn, mặc định "saul"

Cấu hình global cho cả máy

saul.config.yaml gắn với thư mục, nên những giá trị không đổi giữa các repo (token endpoint của identity, client id của CLI) phải khai lại ở mọi nơi. Khai một lần cho cả máy bằng saul config — file nằm ở ~/.saul/config.yaml:

saul config set tokenUrl=https://iam.example.com/api/oidc/token clientId=saul-cli
saul config list          # giá trị đang có hiệu lực + NGUỒN của từng giá trị
saul config get tokenUrl
saul config unset tokenUrl
saul config list --global-only   # chỉ nội dung ~/.saul/config.yaml

saul config list in ra thứ CLI thực sự sẽ dùng, kèm nơi giá trị đó đến từ — hữu ích khi một repo có saul.config.yaml riêng hoặc shell đang có biến ghi đè:

tokenUrl     https://iam.example.com/api/oidc/token  ← global (/home/you/.saul/config.yaml)
clientId     app-2f49a1c9-...                        ← env (SAUL_CLI_CLIENT_ID)
issuerUrl    https://iam.example.com/api/oidc        ← default (suy từ tokenUrl)
redirectUri  http://localhost:8400/callback          ← default (mặc định)

Khoá dùng được đúng bằng khoá của saul.config.yaml: backendUrl, tokenUrl, issuerUrl, clientId, redirectUri, scope, module, tenant, outDir. saul config set clientSecret=... bị từ chối — secret chỉ đi qua SAUL_CLI_CLIENT_SECRET để CI quản lý được vòng đời của nó, không nằm trong file config.

Thứ tự ưu tiên đầy đủ

cờ CLI  >  env (kể cả nạp từ .env)  >  saul.config.yaml của repo
        >  ~/.saul/config.yaml  >  phiên đã saul login  >  mặc định

saul.config.yaml của repo đứng trên config global có chủ đích: repo của một module ghim backend/tenant của riêng nó, và lựa chọn đó phải thắng mặc định toàn máy. Phiên đăng nhập gần cuối vì nó là hệ quả của một lần saul login, không phải lựa chọn cấu hình tường minh.

Vì sao SAUL_CLI_TENANT bắt buộc: exportPoliciesYaml/exportPermissionsYaml phía backend là API cho màn Designer admin — trả về tất cả tenant gộp chung trong một map phẳng, không tự lọc theo tenant của M2M client gọi vào (xem AuthorizationService.scope() trong apps/saul/server, luôn chạy ở phạm vi hệ thống để admin xem được toàn cục). Không khai SAUL_CLI_TENANT, saul snapshot sẽ kéo cả policy/permission của những tenant khác vào repo của module — sai và rò rỉ dữ liệu. CLI tự lọc theo SAUL_CLI_TENANT trước khi ghi file, và tự thêm lại đúng id-prefix của tenant đích lúc apply (xem src/yaml/tenantScope.ts). Lấy id qua Designer UI (danh sách tenant) hoặc gọi RPC saul.listTenants.

Ngược lại, module.yaml (definitions) không cần SAUL_CLI_TENANT để lọc — gateway /saul đã tự chặn cross-tenant cho definitions (đối chiếu tenant của M2M token với tenant đang sở hữu module, xem resolveScopeMiddleware). SAUL_CLI_TENANT chỉ ảnh hưởng authorization.

Dùng file .env (khuyến nghị cho chạy local)

CLI tự nạp file .env ở thư mục hiện tại trước khi chạy lệnh (dùng dotenv, cùng convention với apps/saul/server). Tạo .env cạnh saul.config.yaml:

# .env — KHÔNG commit, nhớ thêm vào .gitignore
SAUL_CLI_BACKEND_URL=https://saul.acme.example.com
SAUL_CLI_TOKEN_URL=https://identity.acme.example.com/api/oidc/token
SAUL_CLI_CLIENT_ID=wfm_bds-ci
SAUL_CLI_CLIENT_SECRET=********
SAUL_CLI_MODULE=wfm_bds
SAUL_CLI_TENANT=9f1c2e34-xxxx-xxxx-xxxx-xxxxxxxxxxxx

Rồi chạy thẳng saul snapshot / saul plan / saul apply, không cần export tay từng biến. Muốn trỏ tới file khác .env (vd test nhiều tenant): saul snapshot --env-file .env.staging. Trong CI, secrets nên vẫn đi qua biến môi trường của CI (GitHub Actions env:/secrets:) — .env chỉ để tiện chạy local.

Ví dụ trên là cấu hình M2M. Chạy local dưới danh tính người dùng thì bỏ SAUL_CLI_CLIENT_SECRET, đổi SAUL_CLI_CLIENT_ID sang client public của CLI, rồi saul login một lần — từ đó backendUrl/tenant cũng có thể lấy luôn từ phiên đã lưu.

Đăng nhập bằng tài khoản người dùng

Mô hình giống az login / az account của Azure CLI:

saul login                   # mở trình duyệt tới trang đăng nhập (mặc định)
saul login --use-device-code # nhập mã trên máy khác (SSH/container, không có browser)
saul account show          # đang là ai, tenant nào, token hết hạn khi nào
saul account list          # danh sách tenant nhìn thấy được (saul.listTenants)
saul account set -t <uuid> # chọn tenant mặc định cho các lệnh sau
saul account sessions      # mọi phiên đã lưu ở máy này (nhiều môi trường song song)
saul account refresh       # kiểm tra/làm mới access token
saul logout [--all]        # xoá phiên khỏi máy

Hai luồng đăng nhập

Mặc định — mở trình duyệt (authorization code + PKCE S256, giống az login). CLI dựng một HTTP server loopback, mở trình duyệt tới /api/oidc/auth, identity redirect tới trang đăng nhập …/interaction/login/<uid> (dùng được cả social login), rồi redirect ngược về http://localhost:8400/callback; CLI đổi code lấy token và tự tắt server.

--use-device-code — in ra một mã, user mở trang …/api/oidc/device trên máy bất kỳ để nhập. Dùng khi máy chạy CLI không mở được trình duyệt (SSH vào server, container, WSL), hoặc khi cổng loopback bận. Không cần redirect_uri nào cả.

Ràng buộc cổng của luồng mặc định: client OIDC trong Directus không có cột application_type → oidc-provider coi mọi client là loại web, và client web bị so khớp redirect_uri chính xác từng ký tự, kể cả cổng. Nới lỏng "loopback cổng bất kỳ" của RFC 8252 chỉ áp dụng cho client native, nên CLI không bốc cổng ngẫu nhiên như az làm được: nó bind đúng SAUL_CLI_REDIRECT_URI (mặc định http://localhost:8400/callback), và URI đó phải nằm nguyên văn trong redirect_uris của client. Cổng bận là lỗi cứng — CLI cố tình không đổi cổng vì identity sẽ từ chối; lối thoát là --use-device-code.

Phiên đăng nhập được lưu ở đâu

~/.saul/ (đổi bằng SAUL_CLI_CONFIG_DIR), tách làm hai file, cả hai đều mode 0600:

  • profile.json — metadata không nhạy cảm: account đang active, issuer, client id, backend url, tenant đang chọn, tên/email. saul account show chỉ đọc file này.
  • tokens.json — access / refresh / id token.

Một cặp (issuer, client id) là một account riêng, nên đăng nhập song song nhiều môi trường (dev/staging/prod) không đè lên nhau — xem saul account sessions.

Access token tự được làm mới bằng refresh token khi còn dưới 60 giây là hết hạn, nên phiên sống lâu mà không phải đăng nhập lại. Muốn có refresh token thì scope phải chứa offline_access (đã nằm trong mặc định).

saul logout chỉ xoá credential ở máy này, cố tình không gọi revocation endpoint. Muốn thu hồi thật thì thu hồi phía identity.

saul account set --tenant KHÔNG đổi tenant của token

Tenant nằm trong access token do client OIDC quyết định (junction tenant_apps trong Directus), không phải do caller chọn — một client id ứng với đúng một tenant. account set chỉ đặt tenant mà CLI dùng để lọc/ghi YAML authorization (xem mục SAUL_CLI_TENANT ở trên); backend vẫn áp quyền theo tenant trong token. saul account show in cả hai giá trị và cảnh báo khi chúng lệch nhau.

Muốn làm việc thật sự với tenant khác thì đăng nhập bằng client của tenant đó: saul login --client-id <client-của-tenant-kia>.

OIDC client cho saul login — đã có sẵn

Saul backend tự tạo client saul-cli lúc khởi động nếu chưa có, nên thường bạn không phải làm gì: chỉ cần SAUL_CLI_CLIENT_ID=saul-cli. Xem apps/saul/server/src/saul/system/systemOidcClient.ts.

Seed chỉ tạo khi chưa có — chỉnh tay row đó (đổi cổng redirect chẳng hạn) sẽ không bị lần restart sau ghi đè. Tắt hẳn bằng SEED_CLI_OIDC_CLIENT=false phía backend.

Client được seed không gắn tenant nào: tenant trong access token đến từ chính client OIDC, nên client gắn tenant chỉ dùng được cho đúng một tenant. Không gắn ⇒ Saul suy tenant từ module đang gọi, và một client id dùng chung cho mọi tenant. Client này không tự cấp quyền gì — người đăng nhập vẫn phải có role cấp các permission saul.* tương ứng.

Tạo tay OIDC client (khi tự quản lý)

Nếu tắt seed hoặc muốn client riêng, tạo trong Directus (collection oidc_client) hoặc Designer UI:

| Trường | Giá trị | |---|---| | client_id | vd saul-cli | | token_endpoint_auth_method | none (public client — CLI không giữ được secret) | | grant_types | ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"] | | response_types | ["code"] | | redirect_uris | ["http://localhost:8400/callback"] — phải khớp SAUL_CLI_REDIRECT_URI nguyên văn, kể cả cổng | | tenant (qua tenant_apps) | tenant mà CLI sẽ làm việc |

Dropdown grant_types trong Directus mới liệt kê sẵn client_credentials / authorization_code / refresh_token — URN của device code phải gõ tay vào, không có gì chặn cả. Identity đã bật sẵn features.deviceFlow nên phía provider không cần đổi gì.

Với client public, oidc-provider bắt buộc PKCE — CLI luôn gửi code_challenge S256 nên không phải cấu hình thêm.

Tạo M2M client cho một tenant

M2M client (client_credentials) được quản lý qua oidc_client — nguồn duy nhất cho mọi Client trong hệ thống. Tạo/chỉnh trong Directus (collection oidc_client) hoặc qua Designer UI của Saul (khu vực quản trị OIDC Client), gán đúng tenant + policy cho phép các function saul.DefinitionService.exportModuleYaml, saul.DefinitionService.importModuleYaml, saul.AuthorizationService.exportPoliciesYaml, saul.AuthorizationService.importPoliciesYaml, saul.AuthorizationService.exportPermissionsYaml, saul.AuthorizationService.importPermissionsYaml, saul.TenantModuleService.listUnassignedModules, saul.TenantModuleService.listTenantModules, saul.TenantModuleService.assignModuleToTenant (3 function cuối phục vụ bước tự gán tenant của apply, xem mục Lệnh bên dưới). Không có các quyền này, CLI sẽ nhận lỗi 403 (xem mục Troubleshooting).

Permission định danh theo <module>.<service>.<function>. Policy tạo trước khi Saul chuyển sang định danh này (dạng saul.exportModuleYaml) được script pnpm migrate:permission-service phía backend tự đổi — xem apps/saul/docs/service-scoped-functions-migration.md.

Layout YAML local

saul/
  module.yaml                       # exportModuleYaml — toàn bộ service/function/model/error
  authorization/
    policies.yaml                    # exportPoliciesYaml — flat map {policyId: {...}}
    permissions.yaml                 # exportPermissionsYaml — flat map {permissionId: {...}}

Không có file index/manifest — sự tồn tại của file chính là index (giống cách apps/directus/snapshot/ split theo collection). module.yaml là nguyên văn chuỗi YAML server trả về (CLI không tự parse/re-serialize, tránh format trôi khỏi những gì importModuleYaml chấp nhận). Hai file authorization/*.yaml thì KHÔNG nguyên văn: CLI lọc theo SAUL_CLI_TENANT và bỏ tiền tố <tenantId>: khỏi id trước khi ghi (xem mục cấu hình ở trên) — nhờ vậy cùng một policies.yaml/permissions.yaml áp được cho nhiều tenant khác nhau (dev/staging/prod), không hardcode UUID của một tenant cụ thể.

Principal không được sync qua CLI: backend hiện chỉ có getPrincipals/ createPrincipal, chưa có updatePrincipal hay export/import YAML idempotent cho principal. Principal cũng gắn với danh tính/service-account thật của từng môi trường hơn là cấu hình đi theo module, nên hợp lý để quản lý thủ công qua Designer UI.

Lệnh

# Đăng nhập một lần trên máy dev (bỏ qua nếu chạy CI với SAUL_CLI_CLIENT_SECRET)
saul login

# Kéo state hiện tại của module về YAML local
saul snapshot --module wfm_bds --tenant 9f1c2e34-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Diff YAML local với remote — dùng làm gate trong PR (exit code 1 nếu có drift)
saul plan --module wfm_bds --tenant 9f1c2e34-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Đẩy YAML local lên backend (hỏi xác nhận nếu không có --yes)
saul apply --module wfm_bds --tenant 9f1c2e34-xxxx-xxxx-xxxx-xxxxxxxxxxxx --yes

# Nâng cấp authorization/*.yaml local sang định danh module.service.function (offline)
saul migrate-authz            # ghi lại file
saul migrate-authz --check    # CI gate: exit code 1 nếu còn định danh cũ

Sau khi Saul chuyển sang định danh module.service.function

Permission giờ có dạng <module>.<service>.<function>.<tên> (trước là <module>.<function>.<tên>). YAML đã commit trong repo vẫn ở dạng cũ:

  • saul apply vẫn chạy: server tự nâng cấp permission cũ lúc import (chỉ khi tên function duy nhất trong module; trùng tên thì báo lỗi, không đoán).
  • saul plan sẽ báo drift cho tới khi file local được cập nhật.

Cập nhật một lần rồi commit, bằng một trong hai cách:

  1. saul migrate-authz — offline, đọc module.yaml local để biết function thuộc service nào. Permission cấp module (<module>.wsPush, <module>.wsAgent) giữ nguyên 2 phần.
  2. saul snapshot — kéo lại từ server (sau khi server đã chạy script migrate).

Chi tiết thứ tự rollout: apps/saul/docs/service-scoped-functions-migration.md.

--out-dir đổi thư mục YAML (mặc định saul/). Không truyền --module/--tenant thì CLI đọc SAUL_CLI_MODULE/SAUL_CLI_TENANT (env hoặc .env), module/tenant trong saul.config.yaml, rồi cuối cùng là tenant của phiên đang đăng nhập.

Nhóm lệnh auth (login / logout / account) xem mục Đăng nhập bằng tài khoản người dùng.

saul apply tự gán module vào SAUL_CLI_TENANT: module.yaml chỉ chứa nội dung service/function/model/error — việc module thuộc tenant nào là một dòng dữ liệu RIÊNG (saul_module.tenant) mà importModuleYaml không đụng tới. Sau khi ghi definitions, apply tự gọi saul.assignModuleToTenant để đảm bảo module thuộc đúng SAUL_CLI_TENANT:

  • Module mới/chưa gán tenant → gán vào SAUL_CLI_TENANT.
  • Module đã thuộc đúng SAUL_CLI_TENANT → bỏ qua (idempotent).
  • Module đang thuộc một tenant KHÁC → dừng lại và báo lỗi (không tự ý gỡ/ghi đè) — tự gỡ (detach) qua Designer UI trước nếu thật sự muốn chuyển tenant.

Ví dụ workflow CI (GitHub Actions)

Đây chỉ là ví dụ minh hoạ trong docs — repo module tự thêm workflow thật, saul-cli không đi kèm file .github/workflows sẵn.

name: saul
on:
  pull_request:
    paths: ["saul/**"]
  push:
    branches: [main]
    paths: ["saul/**"]

jobs:
  saul:
    runs-on: ubuntu-latest
    env:
      SAUL_CLI_CLIENT_ID: ${{ secrets.SAUL_CLI_CLIENT_ID }}
      SAUL_CLI_CLIENT_SECRET: ${{ secrets.SAUL_CLI_CLIENT_SECRET }}
      SAUL_CLI_TENANT: ${{ vars.SAUL_CLI_TENANT }}
    steps:
      - uses: actions/checkout@v4
      - run: npx @fidt/saul-cli plan
        if: github.event_name == 'pull_request'
      - run: npx @fidt/saul-cli apply --yes
        if: github.ref == 'refs/heads/main'

Troubleshooting

  • Lỗi 403 / "không có quyền": M2M client chưa được gán policy cho các function saul.exportModuleYaml/importModuleYaml/exportPoliciesYaml/ importPoliciesYaml/exportPermissionsYaml/importPermissionsYaml, hoặc client đang cố ghi vào module hệ thống saul (read-only, không thể import qua CLI).
  • Token hết hạn / 401 giữa chừng: CLI cache access token trong bộ nhớ suốt một lần chạy — mỗi lần chạy CLI vốn ngắn nên bình thường không đụng TTL. Ở chế độ user, token được refresh tự động trước khi hết hạn; 401 lúc này nghĩa là phiên đã bị thu hồi phía identity → chạy lại saul login. Ở chế độ M2M, kiểm tra TTL access token của client.
  • saul login báo invalid_client (401): client đang là confidential (token_endpoint_auth_method khác none) nên device endpoint bắt buộc xác thực client. Đặt none cho client CLI, hoặc khai SAUL_CLI_CLIENT_SECRET. Đừng đổi một client đang dùng client_credentials sang none — grant đó bắt buộc phải xác thực client, đổi là hỏng luồng M2M của nó. Tạo client riêng cho CLI thay vì dùng chung.
  • saul login báo unauthorized_client: client chưa bật grant urn:ietf:params:oauth:grant-type:device_code trong oidc_client (dropdown của Directus không liệt kê sẵn URN này — phải gõ tay), xem mục "Tạo OIDC client cho saul login".
  • Discovery trả endpoint http:// dù site là https: identity chạy sau reverse proxy mà proxy không truyền X-Forwarded-Proto, nên oidc-provider dựng URL sai giao thức. CLI tự nâng các endpoint của chính issuer lên https để không gửi secret/code_verifier qua HTTP trần, nhưng đây là lỗi cấu hình phía server cần sửa ở proxy — mọi OIDC client chuẩn khác đều sẽ vấp phải.
  • TENANT_MISMATCH khi snapshot/apply: client đang đăng nhập gắn với tenant khác tenant sở hữu module. Tenant của token do junction tenant_apps quyết định, không đổi bằng saul account set được — xem mục "saul account set --tenant KHÔNG đổi tenant của token".
  • saul login báo redirect_uri_mismatch: SAUL_CLI_REDIRECT_URI chưa nằm nguyên văn trong redirect_uris của client. Identity so khớp chính xác cả cổng, không chấp nhận cổng khác — hoặc đăng ký đúng URI đó, hoặc dùng saul login --use-device-code vốn không cần redirect URI.
  • saul login báo cổng đang bận: CLI cố tình không tự đổi cổng (đổi là identity từ chối). Đóng tiến trình đang giữ cổng, hoặc dùng --use-device-code.
  • Trình duyệt không tự mở (SSH, container, WSL): CLI vẫn in URL ra để mở tay, nhưng trang callback phải về được localhost:8400 của máy chạy CLI — qua SSH thì cần forward cổng (ssh -L 8400:localhost:8400 …), đơn giản hơn là dùng --use-device-code.
  • Đã saul login nhưng lệnh vẫn chạy dưới danh tính M2M: SAUL_CLI_CLIENT_SECRET vẫn còn trong env hoặc .env — có secret thì M2M luôn thắng (xem mục "Hai danh tính").
  • saul account list báo thiếu backend URL: lệnh này phải gọi RPC saul.listTenants nên cần SAUL_CLI_BACKEND_URL (hoặc backendUrl trong saul.config.yaml, hoặc backend đã lưu kèm phiên đăng nhập).
  • saul apply báo thiếu file local: chưa chạy saul snapshot lần nào, hoặc --out-dir không khớp với lúc snapshot.
  • saul snapshot ra authorization/policies.yaml/permissions.yaml rỗng dù Designer UI có policy: sai SAUL_CLI_TENANT (không phải UUID của tenant đang mở trong Designer), hoặc policy đó đang là row toàn cục (SaulAdmin/PublicPolicy hệ thống — không sync qua CLI, xem mục Layout YAML local).
  • saul apply báo "Module ... đang thuộc một tenant KHÁC": module đã được gán cho tenant khác SAUL_CLI_TENANT từ trước (qua Designer UI hoặc lần apply trước với tenant khác). CLI cố tình KHÔNG tự gỡ/ghi đè — vào Designer UI, gỡ (detach) module khỏi tenant hiện tại rồi apply lại, nếu đúng là bạn muốn chuyển tenant.