@zhin.js/gateway 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/lib/config/configuration.d.ts +16 -0
- package/lib/config/configuration.js +32 -7
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/validation-messages.d.ts +13 -0
- package/lib/config/validation-messages.js +59 -0
- package/lib/database/config.js +31 -0
- package/lib/database/console-role.d.ts +35 -0
- package/lib/database/console-role.js +108 -0
- package/lib/database/gateway-store.d.ts +43 -0
- package/lib/database/gateway-store.js +90 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +45 -0
- package/lib/database/project-database.js +212 -6
- package/lib/http/console-rpc.d.ts +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.d.ts +15 -8
- package/lib/http/gateway-console.js +134 -50
- package/lib/http/gateway-http-server.js +39 -1
- package/lib/http/plugin-switch-rpc.js +11 -6
- package/lib/http/start-gateway.js +9 -0
- package/lib/iam/index.d.ts +4 -3
- package/lib/iam/index.js +4 -3
- package/lib/iam/install.d.ts +17 -11
- package/lib/iam/install.js +39 -18
- package/lib/iam/legacy-import.d.ts +151 -0
- package/lib/iam/legacy-import.js +600 -0
- package/lib/iam/local-admin.d.ts +2 -0
- package/lib/iam/local-admin.js +6 -1
- package/lib/iam/routes.js +10 -2
- package/lib/iam/rpc.js +1 -1
- package/lib/iam/service.d.ts +31 -0
- package/lib/iam/service.js +49 -4
- package/lib/iam/setup-service.d.ts +2 -0
- package/lib/iam/setup-service.js +11 -4
- package/lib/iam/tables.d.ts +6 -6
- package/lib/iam/tables.js +6 -8
- package/lib/local/protocol.d.ts +6 -0
- package/lib/local/protocol.js +6 -0
- package/lib/packages/errors.js +1 -0
- package/lib/packages/rpc.d.ts +6 -1
- package/lib/packages/rpc.js +28 -2
- package/lib/packages/service.d.ts +3 -1
- package/lib/packages/service.js +22 -4
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +8 -2
- package/lib/plugin-management/workflow.js +28 -10
- package/lib/supervisor/instance-supervisor.d.ts +4 -1
- package/lib/supervisor/instance-supervisor.js +18 -1
- package/lib/terminal/modules.d.ts +12 -0
- package/lib/terminal/modules.js +38 -2
- package/lib/usage/aggregate.d.ts +64 -3
- package/lib/usage/aggregate.js +199 -26
- package/lib/usage/prompt-cache.d.ts +49 -0
- package/lib/usage/prompt-cache.js +113 -0
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +1 -0
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Zhin.js 管理网关([ADR 0054](../../../docs/adr/0054-management-gateway.md))。网关负责宿主层面的事:Console 账号与权限、终端、
|
|
4
4
|
项目配置与 `.env`、依赖事务、实例监管。它**不依赖 `zhin.js` 与 IM 层**,不加载插件代码(`pnpm check:architecture` 强制)。
|
|
5
5
|
|
|
6
|
-
网关进程(`zhin start`;前台运行用 `zhin start --foreground
|
|
6
|
+
网关进程(`zhin start`;前台运行用 `zhin start --foreground`)把 `console_*` 账号表放在自己的 `.zhin/gateway.db`(ADR 0059,与项目数据库方言无关;升级时自动从项目数据库迁入):登录、初始化、`iam.*`、`onboarding.update`、终端与审计
|
|
7
7
|
都在网关里,没有实例时 Console 照样可用;受管实例只按转发的主体做授权。`--standalone`(开发 / 测试,无网关)的实例仍在进程内
|
|
8
8
|
装配 `installConsoleIam` / `installHostConsoleTerminal`。
|
|
9
9
|
|
|
@@ -27,17 +27,17 @@ zhin console init # 首次创建 Console 所有者;之后 http.token
|
|
|
27
27
|
|
|
28
28
|
| 子路径 | 内容 |
|
|
29
29
|
|---|---|
|
|
30
|
-
| `@zhin.js/gateway/iam` | Console 账号、角色、会话、API 令牌、审计与初始化(ADR 0049):`ConsoleIamService`、`ConsoleSetupService`、`installConsoleIam`、`registerConsoleAuthRoutes`、`dispatchIamRpc` |
|
|
30
|
+
| `@zhin.js/gateway/iam` | Console 账号、角色、会话、API 令牌、审计与初始化(ADR 0049):`ConsoleIamService`、`ConsoleSetupService`、`installConsoleIam`、`registerConsoleAuthRoutes`、`dispatchIamRpc`、`importLegacyIamTables` / `revertLegacyIamImport` / `inspectLegacyIamImport`(旧版项目库账号表迁入 gateway.db 与回退,ADR 0059) |
|
|
31
31
|
| `@zhin.js/gateway/terminal` | Console 终端(ADR 0051):`TerminalService`、`installHostConsoleTerminal`、`parseTerminalConfig` |
|
|
32
|
-
| `@zhin.js/gateway/config` | 原始配置文件与 `.env` 读写:`ConsoleConfigurationStore`、`readEnvFile` / `writeEnvFile
|
|
32
|
+
| `@zhin.js/gateway/config` | 原始配置文件与 `.env` 读写:`ConsoleConfigurationStore`、`readEnvFile` / `writeEnvFile`(Host 内部用)、Console 用的打码视图与逐键保存(`readEnvFileView` / `revealEnvVariable` / `updateEnvVariables` / `replaceEnvFileContent`)、配置路径与补丁、Console Schema |
|
|
33
33
|
| `@zhin.js/gateway/plugin-management` | 项目级依赖事务:`PluginManagementWorkflow`、`PluginManagementActivity`、插件用例 |
|
|
34
34
|
| `@zhin.js/gateway/project` | 项目文件工具:`findConfigFile`、`loadRuntimeEnvironmentLayers`、`createTsxModuleLoader`、实例声明迁移、旧配置检测、Node 版本要求 |
|
|
35
35
|
| `@zhin.js/gateway/supervisor` | 实例进程监管 `InstanceSupervisor`:IPC 控制通道(`ready` / `shutdown` / `restart-request`)、崩溃重启与风暴保护、日志采集(环形缓冲 + `.zhin/logs/instance.log` 按大小轮转);`runSupervisedInstance` 供 `zhin start` 使用 |
|
|
36
36
|
| `@zhin.js/gateway/usage` | 用量统计聚合 |
|
|
37
37
|
| `@zhin.js/gateway/onboarding` | Console 引导(ADR 0053):`.zhin/onboarding.json` 状态(`OnboardingStateStore`、自动识别已配置步骤)、`OnboardingService`、安装白名单与 `authorizeInstall`、流式 `installPackages` |
|
|
38
38
|
| `@zhin.js/gateway/http` | HTML / SSE 响应工具(实例也导入,不加载 Node HTTP 栈) |
|
|
39
|
-
| `@zhin.js/gateway/server` | 网关 HTTP 服务器(`GatewayHttpServer`:自有路由、`{base}` 认证后转发、其他路由原样转发、实例离线 503 / 超时 504 / 其他 502;`useConsoleRpc` 登记更多网关 RPC)、`gateway.*` Console RPC 路由、`startGatewayConsole
|
|
40
|
-
| `@zhin.js/gateway/database` |
|
|
39
|
+
| `@zhin.js/gateway/server` | 网关 HTTP 服务器(`GatewayHttpServer`:自有路由、`{base}` 认证后转发、其他路由原样转发、实例离线 503 / 超时 504 / 其他 502;`useConsoleRpc` 登记更多网关 RPC)、`gateway.*` Console RPC 路由、`startGatewayConsole`(gateway.db、旧表迁移、本地认证、`/auth/*`、`iam.*`、`onboarding.update`、初始化向导、终端、审计)、`registerGatewayEventRoutes`(合并 `{base}/events`、`{base}/gateway/packages/progress`)、`startGateway`(服务器 + Console + 实例监管 + `@zhin.js/host-http/bridge` 实例传输)、`runGateway`(`zhin start` 的监管 + 网关入口) |
|
|
40
|
+
| `@zhin.js/gateway/database` | 网关自己的存储与临时项目库连接(ADR 0054 §7、ADR 0059):`openGatewayStore`(`.zhin/gateway.db`,`0600`,`gateway_meta`)、`loadProjectDatabaseConfig` / `resolveProjectDatabaseConfig`(与实例 `resolveDatabaseConfig` 同一规则,含 `database.console`)、`ProjectDatabase`(SQLite WAL;不用同步的 `busy_timeout`,`SQLITE_BUSY` 以异步退避重试,默认 5 秒预算,超时返回 `503 database_busy` 与 `Retry-After`;`tableExists` / `readSnapshot` / `dropTables` 供迁移使用)、`setupConsoleRole`(`zhin db console-role`:PostgreSQL / MySQL 的 Console SQL 受限账号) |
|
|
41
41
|
| `@zhin.js/gateway` | 以上全部 |
|
|
42
42
|
|
|
43
43
|
```ts
|
|
@@ -2,6 +2,7 @@ import { type ConsoleConfigApplication, type ConsoleConfigApplicationStatus, typ
|
|
|
2
2
|
import type { PluginConfigValidation } from '@zhin.js/host-http';
|
|
3
3
|
import { type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
|
|
4
4
|
import { type ConfigFileDocument, type EnvStore } from '@zhin.js/runtime';
|
|
5
|
+
import type { LiteralConfigSecret } from '@zhin.js/runtime';
|
|
5
6
|
export interface ChatConfigurationField {
|
|
6
7
|
readonly path: readonly string[];
|
|
7
8
|
readonly type: readonly string[];
|
|
@@ -32,6 +33,11 @@ export declare class ConsoleConfigurationStore {
|
|
|
32
33
|
readonly validateConfiguration?: (document: RuntimeConfigDocument) => Promise<void>;
|
|
33
34
|
});
|
|
34
35
|
readSource(): Promise<ConsoleConfigSource>;
|
|
36
|
+
/**
|
|
37
|
+
* Secret fields of the stored (unexpanded) configuration that hold literals instead of
|
|
38
|
+
* `${VAR}` references (ADR 0057). Paths only, never values.
|
|
39
|
+
*/
|
|
40
|
+
readLiteralSecrets(): Promise<readonly LiteralConfigSecret[]>;
|
|
35
41
|
readDocument(): Promise<Record<string, unknown>>;
|
|
36
42
|
replaceSource(source: string, expectedRevision: string): Promise<{
|
|
37
43
|
readonly revision: string;
|
|
@@ -73,6 +79,16 @@ export declare class ConsoleConfigurationStore {
|
|
|
73
79
|
apply(expectedRevision: string): Promise<ConsoleConfigApplyResult>;
|
|
74
80
|
readEnvironmentFile(filename: string): Promise<string>;
|
|
75
81
|
writeEnvironmentFile(filename: string, content: string): Promise<void>;
|
|
82
|
+
/** Masked view for the Console (`env:get`); never a value. */
|
|
83
|
+
readEnvironmentView(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
|
|
84
|
+
/** One variable as written (`env:reveal`); the caller authorizes and audits. */
|
|
85
|
+
revealEnvironmentVariable(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
|
|
86
|
+
/** Per-key save (`env:save { changes }`); keys not named keep their lines. */
|
|
87
|
+
updateEnvironmentVariables(filename: string, changes: unknown): Promise<{
|
|
88
|
+
readonly changed: readonly string[];
|
|
89
|
+
}>;
|
|
90
|
+
/** Whole-file save (`env:save { content, mode: 'replace' }`); refused when it holds mask placeholders. */
|
|
91
|
+
replaceEnvironmentFile(filename: string, content: string): Promise<void>;
|
|
76
92
|
readSchema(pluginName?: string): Promise<unknown>;
|
|
77
93
|
validatePluginConfig(pluginName: string, data: unknown, capturedDocument?: RuntimeConfigDocument): Promise<PluginConfigValidation>;
|
|
78
94
|
readAllSchemas(): Promise<Record<string, unknown>>;
|
|
@@ -5,11 +5,13 @@ import { ConfigDocumentConflictError, ConfigValidationError, ConfigurationSupers
|
|
|
5
5
|
import { loadRuntimeEnvironmentLayers } from '../project/environment.js';
|
|
6
6
|
import { readPluginPackageMap } from './plugin-package-map.js';
|
|
7
7
|
import { assertUnambiguousConfigKey, configKeyPatch, configKeyRemovePatch, flattenConfigDocument } from './configuration-projection.js';
|
|
8
|
-
import { readEnvFile, writeEnvFile } from './environment-files.js';
|
|
8
|
+
import { readEnvFile, readEnvFileView, replaceEnvFileContent, revealEnvVariable, updateEnvVariables, writeEnvFile, } from './environment-files.js';
|
|
9
9
|
import { PluginSchemaCatalog } from './plugin-schema-catalog.js';
|
|
10
|
+
import { findProjectLiteralSecrets } from './literal-secrets.js';
|
|
10
11
|
import { hostModelOwnerId } from '@zhin.js/plugin-runtime';
|
|
11
12
|
import { assertChatConfigurationAccess, assertChatConfigurationScope, assertChatValueHasNoEnvironmentReference, assertChatOptionalLeaf, chatConfigurationLeaves, chatHostRoots, chatLeafTypes, ConfigChatAccessError, } from './configuration-chat-policy.js';
|
|
12
13
|
import { isForbiddenConfigurationPathSegment } from './configuration-path.js';
|
|
14
|
+
import { formatSchemaIssue } from './validation-messages.js';
|
|
13
15
|
import { assertPatchesInScope, normalizeConfigPatchOperations, readDeclaredPatchPaths, readPath, resolvePatchScope, } from './configuration-patch.js';
|
|
14
16
|
import { maskConfigValue, REDACTED_CONFIG_VALUE } from './configuration-redaction.js';
|
|
15
17
|
/** A Host-classified rejection before this operation attempted any commit. */
|
|
@@ -93,6 +95,13 @@ export class ConsoleConfigurationStore {
|
|
|
93
95
|
});
|
|
94
96
|
});
|
|
95
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Secret fields of the stored (unexpanded) configuration that hold literals instead of
|
|
100
|
+
* `${VAR}` references (ADR 0057). Paths only, never values.
|
|
101
|
+
*/
|
|
102
|
+
readLiteralSecrets() {
|
|
103
|
+
return this.#afterMutations(async () => findProjectLiteralSecrets(this.#projectRoot, (await this.#document.read()).document));
|
|
104
|
+
}
|
|
96
105
|
readDocument() {
|
|
97
106
|
return this.#afterMutations(async () => {
|
|
98
107
|
const snapshot = await this.#document.read();
|
|
@@ -551,6 +560,22 @@ export class ConsoleConfigurationStore {
|
|
|
551
560
|
writeEnvironmentFile(filename, content) {
|
|
552
561
|
return writeEnvFile(this.#projectRoot, filename, content);
|
|
553
562
|
}
|
|
563
|
+
/** Masked view for the Console (`env:get`); never a value. */
|
|
564
|
+
readEnvironmentView(filename) {
|
|
565
|
+
return readEnvFileView(this.#projectRoot, filename);
|
|
566
|
+
}
|
|
567
|
+
/** One variable as written (`env:reveal`); the caller authorizes and audits. */
|
|
568
|
+
revealEnvironmentVariable(filename, key) {
|
|
569
|
+
return revealEnvVariable(this.#projectRoot, filename, key);
|
|
570
|
+
}
|
|
571
|
+
/** Per-key save (`env:save { changes }`); keys not named keep their lines. */
|
|
572
|
+
updateEnvironmentVariables(filename, changes) {
|
|
573
|
+
return updateEnvVariables(this.#projectRoot, filename, changes);
|
|
574
|
+
}
|
|
575
|
+
/** Whole-file save (`env:save { content, mode: 'replace' }`); refused when it holds mask placeholders. */
|
|
576
|
+
replaceEnvironmentFile(filename, content) {
|
|
577
|
+
return replaceEnvFileContent(this.#projectRoot, filename, content);
|
|
578
|
+
}
|
|
554
579
|
async readSchema(pluginName) {
|
|
555
580
|
const { document } = await this.#document.read();
|
|
556
581
|
if (pluginName && !HOST_CONFIG_KEYS.includes(pluginName)
|
|
@@ -597,15 +622,15 @@ export class ConsoleConfigurationStore {
|
|
|
597
622
|
for (const issue of candidate.errors) {
|
|
598
623
|
const location = issue.instancePath.replace(/^\/candidate/u, '').split('/').slice(1)
|
|
599
624
|
.map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'));
|
|
625
|
+
// The message names the field relative to the block, in the project locale (`locale`).
|
|
626
|
+
const message = formatSchemaIssue(issue, location, document.locale);
|
|
600
627
|
if (issue.keyword === 'required' && typeof issue.params.missingProperty === 'string') {
|
|
601
628
|
location.push(issue.params.missingProperty);
|
|
602
629
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
: issue.message ?? issue.keyword,
|
|
608
|
-
});
|
|
630
|
+
if (issue.keyword === 'additionalProperties' && typeof issue.params.additionalProperty === 'string') {
|
|
631
|
+
location.push(issue.params.additionalProperty);
|
|
632
|
+
}
|
|
633
|
+
errors.push({ path: `$${location.map(key => `.${key}`).join('')}`, message });
|
|
609
634
|
}
|
|
610
635
|
}
|
|
611
636
|
collectEnvironmentReferences(candidate.value, missingEnv, environment);
|
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
import { type ConsoleEnvChange, type ConsoleEnvFileView, type ConsoleEnvRevealResult } from '@zhin.js/console-protocol';
|
|
2
|
+
/** The file as written. Only for the Host itself (setup, doctor); the Console reads {@link readEnvFileView}. */
|
|
1
3
|
export declare function readEnvFile(projectRoot: string, filename: string): Promise<string>;
|
|
2
4
|
/** Locked, atomic replacement that keeps the file mode (`0600` when new); ADR 0057 §4. */
|
|
3
5
|
export declare function writeEnvFile(projectRoot: string, filename: string, content: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Masked view of `content` (`env:get`): exactly the variables dotenv reads, each masked; comments
|
|
8
|
+
* that carry a value are masked too. Never contains a value, its length, or a line's length.
|
|
9
|
+
*/
|
|
10
|
+
export declare function maskEnvFileContent(file: string, content: string): ConsoleEnvFileView;
|
|
11
|
+
export declare function readEnvFileView(projectRoot: string, filename: string): Promise<ConsoleEnvFileView>;
|
|
12
|
+
export declare class EnvVariableNotFoundError extends Error {
|
|
13
|
+
readonly code = "env_variable_not_found";
|
|
14
|
+
constructor(filename: string, key: string);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One variable (its effective, i.e. last, assignment) as written in the file: exactly the text
|
|
18
|
+
* dotenv reads as that variable's value, so it never carries another line of the file.
|
|
19
|
+
*/
|
|
20
|
+
export declare function revealEnvVariable(projectRoot: string, filename: string, key: string): Promise<ConsoleEnvRevealResult>;
|
|
21
|
+
export declare class EnvChangeError extends Error {
|
|
22
|
+
readonly code = "invalid_env_change";
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
/** Validated `env:save` changes; throws {@link EnvChangeError} (nothing is written). */
|
|
26
|
+
export declare function validateEnvChanges(changes: unknown): readonly ConsoleEnvChange[];
|
|
27
|
+
/**
|
|
28
|
+
* Applies validated changes to `content`; every line not naming a changed key keeps its bytes.
|
|
29
|
+
* Throws {@link EnvChangeError} when dotenv would read anything but the submitted changes.
|
|
30
|
+
*/
|
|
31
|
+
export declare function applyEnvChanges(content: string, changes: readonly ConsoleEnvChange[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* `env:save { changes }`: applies the changes to the file as it is on disk, inside the dotenv
|
|
34
|
+
* lock. Keys not named keep their lines byte for byte (they are never rebuilt from a masked view).
|
|
35
|
+
*/
|
|
36
|
+
export declare function updateEnvVariables(projectRoot: string, filename: string, changes: unknown): Promise<{
|
|
37
|
+
readonly changed: readonly string[];
|
|
38
|
+
}>;
|
|
39
|
+
/** Keys of `content` whose value (or commented value) is a mask placeholder. */
|
|
40
|
+
export declare function maskedPlaceholderKeys(content: string): readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* `env:save { content, mode: 'replace' }`: the whole-file form. Refused when any value is a mask
|
|
43
|
+
* placeholder, so a masked view can never be written back over the real values.
|
|
44
|
+
*/
|
|
45
|
+
export declare function replaceEnvFileContent(projectRoot: string, filename: string, content: string): Promise<void>;
|
|
@@ -1,14 +1,354 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { parse } from 'dotenv';
|
|
3
|
+
import { ENV_FILE_NAMES, ENV_MASK_CHAR, ENV_MASK_RUN, ENV_KEY_PATTERN, ENV_SAVE_LIMITS, isConsoleEnvMaskPlaceholder, maskConsoleEnvValue, } from '@zhin.js/console-protocol';
|
|
4
|
+
import { readDotenvFile, updateDotenvFile, writeDotenvFile } from '@zhin.js/runtime';
|
|
5
|
+
const ENV_FILES = new Set(ENV_FILE_NAMES);
|
|
6
|
+
function envPath(projectRoot, filename) {
|
|
5
7
|
if (!ENV_FILES.has(filename))
|
|
6
8
|
throw new Error(`Invalid env file: ${filename}`);
|
|
7
|
-
return
|
|
9
|
+
return join(projectRoot, filename);
|
|
10
|
+
}
|
|
11
|
+
/** The file as written. Only for the Host itself (setup, doctor); the Console reads {@link readEnvFileView}. */
|
|
12
|
+
export async function readEnvFile(projectRoot, filename) {
|
|
13
|
+
return readDotenvFile(envPath(projectRoot, filename));
|
|
8
14
|
}
|
|
9
15
|
/** Locked, atomic replacement that keeps the file mode (`0600` when new); ADR 0057 §4. */
|
|
10
16
|
export async function writeEnvFile(projectRoot, filename, content) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
await writeDotenvFile(envPath(projectRoot, filename), content);
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------------------------
|
|
20
|
+
// Parsing. The process reads `.env` with dotenv's `parse`, so the Console's view of the file must be
|
|
21
|
+
// exactly dotenv's: the same assignments, with the same values. `DOTENV_LINE` is dotenv's own line
|
|
22
|
+
// pattern (dotenv 17, `lib/main.js` `LINE`) with two extra capture groups (the separator and the
|
|
23
|
+
// value) and match indices; `scanEnv` runs it the way `parse` does (`\r\n` / `\r` read as `\n`) and
|
|
24
|
+
// maps every match back to the bytes of the file. Anything dotenv does not read as an assignment is
|
|
25
|
+
// text (comments, blank and stray lines). The tests compare this scan with `parse` itself.
|
|
26
|
+
// No `u` flag: the pattern is kept exactly as dotenv has it.
|
|
27
|
+
const DOTENV_LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/dgm;
|
|
28
|
+
const REFERENCE = /^\$\{[^}]+\}$/u;
|
|
29
|
+
/** dotenv's value rules, applied to the captured value (see `parse` in dotenv's `lib/main.js`). */
|
|
30
|
+
function dotenvValue(captured) {
|
|
31
|
+
let value = captured.trim();
|
|
32
|
+
const maybeQuote = value[0];
|
|
33
|
+
value = value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2');
|
|
34
|
+
if (maybeQuote === '"')
|
|
35
|
+
value = value.replace(/\\n/gu, '\n').replace(/\\r/gu, '\r');
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function scanEnv(content) {
|
|
39
|
+
// dotenv reads `\r\n` and `\r` as `\n`; `toOriginal[i]` is the offset in `content` of normalised offset `i`.
|
|
40
|
+
let normalized = '';
|
|
41
|
+
const toOriginal = [];
|
|
42
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
43
|
+
toOriginal.push(index);
|
|
44
|
+
if (content[index] === '\r') {
|
|
45
|
+
normalized += '\n';
|
|
46
|
+
if (content[index + 1] === '\n')
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
normalized += content[index];
|
|
51
|
+
}
|
|
52
|
+
toOriginal.push(content.length);
|
|
53
|
+
const entries = [];
|
|
54
|
+
const texts = [];
|
|
55
|
+
let cursor = 0;
|
|
56
|
+
const pattern = new RegExp(DOTENV_LINE.source, DOTENV_LINE.flags);
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = pattern.exec(normalized)) !== null) {
|
|
59
|
+
const indices = match.indices;
|
|
60
|
+
const first = match.index + match[0].search(/\S/u);
|
|
61
|
+
const lineStart = normalized.lastIndexOf('\n', first - 1) + 1;
|
|
62
|
+
const separatorEnd = indices[2][1];
|
|
63
|
+
let valueStart = separatorEnd;
|
|
64
|
+
let valueEnd = separatorEnd;
|
|
65
|
+
if (match[3] !== undefined) {
|
|
66
|
+
const [groupStart, groupEnd] = indices[3];
|
|
67
|
+
const leading = /^\s*/u.exec(match[3])[0];
|
|
68
|
+
// A value on a line of its own (`KEY=` then `"value"`): replacing it replaces that line break too.
|
|
69
|
+
valueStart = leading.includes('\n') ? groupStart : groupStart + leading.length;
|
|
70
|
+
valueEnd = groupStart + match[3].trimEnd().length;
|
|
71
|
+
if (valueEnd < valueStart)
|
|
72
|
+
valueEnd = valueStart;
|
|
73
|
+
}
|
|
74
|
+
const last = Math.max(valueEnd, separatorEnd) - 1;
|
|
75
|
+
const lineBreak = normalized.indexOf('\n', last);
|
|
76
|
+
const lineEnd = lineBreak < 0 ? normalized.length : lineBreak + 1;
|
|
77
|
+
if (lineStart > cursor)
|
|
78
|
+
texts.push({ start: toOriginal[cursor], end: toOriginal[lineStart] });
|
|
79
|
+
const rawStart = groupValueStart(match[3], indices[3]);
|
|
80
|
+
entries.push(Object.freeze({
|
|
81
|
+
key: match[1],
|
|
82
|
+
exported: /^export\s/u.test(normalized.slice(first, indices[1][0])),
|
|
83
|
+
start: toOriginal[lineStart],
|
|
84
|
+
end: toOriginal[lineEnd],
|
|
85
|
+
valueStart: toOriginal[valueStart],
|
|
86
|
+
valueEnd: toOriginal[valueEnd],
|
|
87
|
+
raw: content.slice(toOriginal[rawStart ?? valueStart], toOriginal[valueEnd]),
|
|
88
|
+
value: dotenvValue(match[3] ?? ''),
|
|
89
|
+
}));
|
|
90
|
+
cursor = lineEnd;
|
|
91
|
+
}
|
|
92
|
+
if (cursor < normalized.length)
|
|
93
|
+
texts.push({ start: toOriginal[cursor], end: content.length });
|
|
94
|
+
return { entries, texts };
|
|
95
|
+
}
|
|
96
|
+
/** Where the captured value starts once its leading whitespace is left out. */
|
|
97
|
+
function groupValueStart(captured, span) {
|
|
98
|
+
if (captured === undefined || !span)
|
|
99
|
+
return undefined;
|
|
100
|
+
return span[0] + /^\s*/u.exec(captured)[0].length;
|
|
101
|
+
}
|
|
102
|
+
/** What the process reads from `content`: dotenv's own `parse` (the last assignment of a key wins). */
|
|
103
|
+
function dotenvValues(content) {
|
|
104
|
+
return new Map(Object.entries(parse(content)));
|
|
105
|
+
}
|
|
106
|
+
/** The value as the process reads it (dotenv's own rules on that one assignment). */
|
|
107
|
+
function effectiveValue(key, rawValue) {
|
|
108
|
+
return parse(`${key}=${rawValue}`)[key] ?? '';
|
|
109
|
+
}
|
|
110
|
+
function maskedVariable(entry) {
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
key: entry.key,
|
|
113
|
+
masked: maskConsoleEnvValue(entry.value),
|
|
114
|
+
hasValue: entry.value.length > 0,
|
|
115
|
+
...(REFERENCE.test(entry.value) ? { isReference: true } : {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** A comment that names a key: `# KEY=value`, `## KEY=value`, `#- KEY: value`, `# export KEY=value`. */
|
|
119
|
+
const COMMENTED_ASSIGNMENT = /^\s*#[^\w\r\n]*?(?:export\s+)?([\w.-]+)\s*[=:](.*\S.*)$/u;
|
|
120
|
+
/** Any other sign of a value in a comment: `=` or `:` followed by text (`# old token is: sk-…`). */
|
|
121
|
+
const COMMENTED_VALUE = /[=:](?=\s*\S)/u;
|
|
122
|
+
const unquote = (text) => text.trim().replace(/^(["'`])([\s\S]*)\1$/u, '$2');
|
|
123
|
+
function textLines(raw) {
|
|
124
|
+
return raw.split(/\r\n|\n|\r/u).slice(0, raw.match(/\r\n|\n|\r/u) ? -1 : undefined).map((line) => {
|
|
125
|
+
if (!line.trim())
|
|
126
|
+
return Object.freeze({ kind: 'blank' });
|
|
127
|
+
if (!/^\s*#/u.test(line))
|
|
128
|
+
return Object.freeze({ kind: 'other' });
|
|
129
|
+
const commented = COMMENTED_ASSIGNMENT.exec(line);
|
|
130
|
+
if (commented)
|
|
131
|
+
return Object.freeze({ kind: 'comment', key: commented[1], masked: maskConsoleEnvValue(unquote(commented[2])) });
|
|
132
|
+
const separator = COMMENTED_VALUE.exec(line);
|
|
133
|
+
if (separator) {
|
|
134
|
+
const head = line.slice(0, separator.index + 1);
|
|
135
|
+
return Object.freeze({ kind: 'comment', text: `${head} ${maskConsoleEnvValue(unquote(line.slice(separator.index + 1))) || ENV_MASK_CHAR.repeat(ENV_MASK_RUN)}` });
|
|
136
|
+
}
|
|
137
|
+
return Object.freeze({ kind: 'comment', text: line });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Masked view of `content` (`env:get`): exactly the variables dotenv reads, each masked; comments
|
|
142
|
+
* that carry a value are masked too. Never contains a value, its length, or a line's length.
|
|
143
|
+
*/
|
|
144
|
+
export function maskEnvFileContent(file, content) {
|
|
145
|
+
const { entries, texts } = scanEnv(content);
|
|
146
|
+
const lines = [];
|
|
147
|
+
const variables = new Map();
|
|
148
|
+
let text = 0;
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
while (text < texts.length && texts[text].start < entry.start)
|
|
151
|
+
lines.push(...textLines(content.slice(texts[text].start, texts[text++].end)));
|
|
152
|
+
lines.push(Object.freeze({ kind: 'entry', key: entry.key, ...(entry.exported ? { export: true } : {}) }));
|
|
153
|
+
// A key assigned twice: the last assignment is the one the process reads; keep first position.
|
|
154
|
+
variables.set(entry.key, maskedVariable(entry));
|
|
155
|
+
}
|
|
156
|
+
while (text < texts.length)
|
|
157
|
+
lines.push(...textLines(content.slice(texts[text].start, texts[text++].end)));
|
|
158
|
+
const order = [...new Set(entries.map(entry => entry.key))];
|
|
159
|
+
const masked = lines.map((line) => {
|
|
160
|
+
switch (line.kind) {
|
|
161
|
+
case 'blank': return '';
|
|
162
|
+
case 'comment': return 'text' in line ? line.text : `# ${line.key}=${line.masked}`;
|
|
163
|
+
case 'entry': return `${line.export ? 'export ' : ''}${line.key}=${variables.get(line.key).masked}`;
|
|
164
|
+
case 'other': return ENV_MASK_CHAR.repeat(ENV_MASK_RUN);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
format: 'masked',
|
|
169
|
+
file,
|
|
170
|
+
variables: Object.freeze(order.map(key => variables.get(key))),
|
|
171
|
+
lines: Object.freeze(lines),
|
|
172
|
+
content: masked.length ? `${masked.join('\n')}\n` : '',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export async function readEnvFileView(projectRoot, filename) {
|
|
176
|
+
return maskEnvFileContent(filename, await readDotenvFile(envPath(projectRoot, filename)));
|
|
177
|
+
}
|
|
178
|
+
export class EnvVariableNotFoundError extends Error {
|
|
179
|
+
code = 'env_variable_not_found';
|
|
180
|
+
constructor(filename, key) {
|
|
181
|
+
super(`${filename} 中没有变量 ${key}`);
|
|
182
|
+
this.name = 'EnvVariableNotFoundError';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* One variable (its effective, i.e. last, assignment) as written in the file: exactly the text
|
|
187
|
+
* dotenv reads as that variable's value, so it never carries another line of the file.
|
|
188
|
+
*/
|
|
189
|
+
export async function revealEnvVariable(projectRoot, filename, key) {
|
|
190
|
+
if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key) || key.length > ENV_SAVE_LIMITS.maxKeyLength) {
|
|
191
|
+
throw new EnvChangeError('变量名无效');
|
|
192
|
+
}
|
|
193
|
+
const content = await readDotenvFile(envPath(projectRoot, filename));
|
|
194
|
+
const entry = scanEnv(content).entries.filter(item => item.key === key).at(-1);
|
|
195
|
+
if (!entry)
|
|
196
|
+
throw new EnvVariableNotFoundError(filename, key);
|
|
197
|
+
return Object.freeze({ file: filename, key, value: entry.raw });
|
|
198
|
+
}
|
|
199
|
+
export class EnvChangeError extends Error {
|
|
200
|
+
code = 'invalid_env_change';
|
|
201
|
+
constructor(message) {
|
|
202
|
+
super(message);
|
|
203
|
+
this.name = 'EnvChangeError';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Whether a raw value (as written after `KEY=`) is read back by dotenv as one assignment of `KEY`
|
|
208
|
+
* and nothing else: a value spanning lines must be quoted; after a closing quote only spaces or an
|
|
209
|
+
* inline comment (`# …`) may follow, on the same line. dotenv reads `"x"zz` as the unquoted `"x`
|
|
210
|
+
* and the next line as a new assignment, so such a value is refused.
|
|
211
|
+
*/
|
|
212
|
+
function assignmentProblem(value) {
|
|
213
|
+
const written = `K=${value}`;
|
|
214
|
+
const { entries, texts } = scanEnv(written);
|
|
215
|
+
const quoted = /^\s*["'`]/u.test(value);
|
|
216
|
+
if (/[\r\n]/u.test(value) && !/^[ \t]*["'`]/u.test(value))
|
|
217
|
+
return '多行值必须用引号包裹';
|
|
218
|
+
const entry = entries[0];
|
|
219
|
+
if (entries.length !== 1 || texts.length !== 0 || entry.key !== 'K' || entry.end !== written.length) {
|
|
220
|
+
return quoted ? '引号没有闭合,或闭合引号之后还有注释以外的内容' : '值会被读成多行或多个变量';
|
|
221
|
+
}
|
|
222
|
+
const tail = written.slice(entry.valueEnd);
|
|
223
|
+
if (/[\r\n]/u.test(tail))
|
|
224
|
+
return '闭合引号之后还有换行';
|
|
225
|
+
if (quoted) {
|
|
226
|
+
const quote = value.trimStart()[0];
|
|
227
|
+
const raw = entry.raw;
|
|
228
|
+
if (raw.length < 2 || raw[0] !== quote || raw.at(-1) !== quote || !/^\s*(?:#.*)?$/u.test(tail)) {
|
|
229
|
+
return '引号没有闭合,或闭合引号之后还有注释以外的内容';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/** Validated `env:save` changes; throws {@link EnvChangeError} (nothing is written). */
|
|
235
|
+
export function validateEnvChanges(changes) {
|
|
236
|
+
if (!Array.isArray(changes) || changes.length === 0)
|
|
237
|
+
throw new EnvChangeError('changes 必须是非空数组');
|
|
238
|
+
if (changes.length > ENV_SAVE_LIMITS.maxChanges)
|
|
239
|
+
throw new EnvChangeError(`一次最多修改 ${ENV_SAVE_LIMITS.maxChanges} 个变量`);
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
return Object.freeze(changes.map((item, index) => {
|
|
242
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
243
|
+
throw new EnvChangeError(`changes[${index}] 必须是对象`);
|
|
244
|
+
const { key, value } = item;
|
|
245
|
+
const remove = item.delete === true;
|
|
246
|
+
if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key) || key.length > ENV_SAVE_LIMITS.maxKeyLength) {
|
|
247
|
+
throw new EnvChangeError(`changes[${index}].key 不是合法变量名`);
|
|
248
|
+
}
|
|
249
|
+
if (seen.has(key))
|
|
250
|
+
throw new EnvChangeError(`变量 ${key} 在 changes 中出现了多次`);
|
|
251
|
+
seen.add(key);
|
|
252
|
+
if (remove) {
|
|
253
|
+
if (value !== undefined)
|
|
254
|
+
throw new EnvChangeError(`changes[${index}] 不能同时删除并设置值`);
|
|
255
|
+
return Object.freeze({ key, delete: true });
|
|
256
|
+
}
|
|
257
|
+
if (typeof value !== 'string')
|
|
258
|
+
throw new EnvChangeError(`changes[${index}].value 必须是字符串`);
|
|
259
|
+
if (value.length > ENV_SAVE_LIMITS.maxValueLength)
|
|
260
|
+
throw new EnvChangeError(`变量 ${key} 的值过长`);
|
|
261
|
+
if (isConsoleEnvMaskPlaceholder(value)) {
|
|
262
|
+
throw new EnvChangeError(`变量 ${key} 的值是打码占位(●),不能保存;请填写真实值,或不修改这个变量`);
|
|
263
|
+
}
|
|
264
|
+
const problem = assignmentProblem(value);
|
|
265
|
+
if (problem)
|
|
266
|
+
throw new EnvChangeError(`变量 ${key}:${problem}`);
|
|
267
|
+
return Object.freeze({ key, value });
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
/** `content` with one change applied; every line not naming `change.key` keeps its bytes. */
|
|
271
|
+
function applyEnvChange(content, change) {
|
|
272
|
+
const entries = scanEnv(content).entries.filter(entry => entry.key === change.key);
|
|
273
|
+
let text = content;
|
|
274
|
+
// From the last assignment backwards, so earlier offsets stay valid.
|
|
275
|
+
for (const entry of [...entries].reverse()) {
|
|
276
|
+
text = 'delete' in change
|
|
277
|
+
? text.slice(0, entry.start) + text.slice(entry.end)
|
|
278
|
+
: text.slice(0, entry.valueStart) + change.value + text.slice(entry.valueEnd);
|
|
279
|
+
}
|
|
280
|
+
if ('delete' in change || entries.length)
|
|
281
|
+
return text;
|
|
282
|
+
const eol = text.match(/\r\n|\n|\r/u)?.[0] ?? '\n';
|
|
283
|
+
const separator = text && !/[\r\n]$/u.test(text) ? eol : '';
|
|
284
|
+
return `${text}${separator}${change.key}=${change.value}${eol}`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Checks what dotenv reads after the changes: every key not submitted keeps its value (none appears
|
|
288
|
+
* or disappears), every set key reads as its submitted value and every deleted key is gone.
|
|
289
|
+
* Throws {@link EnvChangeError} otherwise (the file is not written).
|
|
290
|
+
*/
|
|
291
|
+
function assertOnlySubmittedKeysChanged(before, after, changes) {
|
|
292
|
+
const old = dotenvValues(before);
|
|
293
|
+
const next = dotenvValues(after);
|
|
294
|
+
const submitted = new Map(changes.map(change => [change.key, change]));
|
|
295
|
+
const problems = [];
|
|
296
|
+
for (const key of new Set([...old.keys(), ...next.keys()])) {
|
|
297
|
+
const change = submitted.get(key);
|
|
298
|
+
if (!change) {
|
|
299
|
+
if (old.get(key) !== next.get(key) || old.has(key) !== next.has(key))
|
|
300
|
+
problems.push(key);
|
|
301
|
+
}
|
|
302
|
+
else if ('delete' in change ? next.has(key) : next.get(key) !== effectiveValue(key, change.value)) {
|
|
303
|
+
problems.push(key);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const change of changes) {
|
|
307
|
+
if (!('delete' in change) && !next.has(change.key))
|
|
308
|
+
problems.push(change.key);
|
|
309
|
+
}
|
|
310
|
+
if (problems.length) {
|
|
311
|
+
throw new EnvChangeError(`保存会改变未提交的变量或读不回提交的值:${[...new Set(problems)].slice(0, 8).join('、')};文件没有修改`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Applies validated changes to `content`; every line not naming a changed key keeps its bytes.
|
|
316
|
+
* Throws {@link EnvChangeError} when dotenv would read anything but the submitted changes.
|
|
317
|
+
*/
|
|
318
|
+
export function applyEnvChanges(content, changes) {
|
|
319
|
+
const next = changes.reduce(applyEnvChange, content);
|
|
320
|
+
assertOnlySubmittedKeysChanged(content, next, changes);
|
|
321
|
+
return next;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* `env:save { changes }`: applies the changes to the file as it is on disk, inside the dotenv
|
|
325
|
+
* lock. Keys not named keep their lines byte for byte (they are never rebuilt from a masked view).
|
|
326
|
+
*/
|
|
327
|
+
export async function updateEnvVariables(projectRoot, filename, changes) {
|
|
328
|
+
const valid = validateEnvChanges(changes);
|
|
329
|
+
await updateDotenvFile(envPath(projectRoot, filename), current => applyEnvChanges(current, valid));
|
|
330
|
+
return Object.freeze({ changed: Object.freeze(valid.map(change => change.key)) });
|
|
331
|
+
}
|
|
332
|
+
/** Keys of `content` whose value (or commented value) is a mask placeholder. */
|
|
333
|
+
export function maskedPlaceholderKeys(content) {
|
|
334
|
+
const keys = new Set();
|
|
335
|
+
const { entries, texts } = scanEnv(content);
|
|
336
|
+
for (const entry of entries)
|
|
337
|
+
if (isConsoleEnvMaskPlaceholder(entry.raw))
|
|
338
|
+
keys.add(entry.key);
|
|
339
|
+
for (const text of texts)
|
|
340
|
+
if (isConsoleEnvMaskPlaceholder(content.slice(text.start, text.end)))
|
|
341
|
+
keys.add('(非变量行)');
|
|
342
|
+
return [...keys];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* `env:save { content, mode: 'replace' }`: the whole-file form. Refused when any value is a mask
|
|
346
|
+
* placeholder, so a masked view can never be written back over the real values.
|
|
347
|
+
*/
|
|
348
|
+
export async function replaceEnvFileContent(projectRoot, filename, content) {
|
|
349
|
+
const masked = maskedPlaceholderKeys(content);
|
|
350
|
+
if (masked.length) {
|
|
351
|
+
throw new EnvChangeError(`内容里有打码占位(●),不能整文件保存:${masked.slice(0, 8).join('、')}。请改用逐键保存`);
|
|
352
|
+
}
|
|
353
|
+
await writeEnvFile(projectRoot, filename, content);
|
|
14
354
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Locale } from '@zhin.js/plugin-runtime';
|
|
2
|
+
/** The parts of an Ajv `ErrorObject` a configuration message needs. */
|
|
3
|
+
export interface SchemaIssue {
|
|
4
|
+
readonly keyword: string;
|
|
5
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
6
|
+
readonly message?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Localized text for one Ajv issue of a Plugin configuration, in the project `locale`
|
|
10
|
+
* (`zh-CN` by default). `location` is the field path inside the configuration block; it is named in
|
|
11
|
+
* the message so a reader sees which field is wrong without matching it to the separate path.
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatSchemaIssue(issue: SchemaIssue, location: readonly string[], locale: Locale | unknown): string;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createMessages, resolveLocale } from '@zhin.js/plugin-runtime';
|
|
2
|
+
const messages = createMessages({
|
|
3
|
+
type: { 'zh-CN': '{field}类型应为 {type}', en: '{field}must be of type {type}' },
|
|
4
|
+
maximum: { 'zh-CN': '{field}不能大于 {limit}', en: '{field}must be at most {limit}' },
|
|
5
|
+
minimum: { 'zh-CN': '{field}不能小于 {limit}', en: '{field}must be at least {limit}' },
|
|
6
|
+
exclusiveMaximum: { 'zh-CN': '{field}必须小于 {limit}', en: '{field}must be less than {limit}' },
|
|
7
|
+
exclusiveMinimum: { 'zh-CN': '{field}必须大于 {limit}', en: '{field}must be greater than {limit}' },
|
|
8
|
+
minLength: { 'zh-CN': '{field}长度不能少于 {limit} 个字符', en: '{field}must be at least {limit} characters long' },
|
|
9
|
+
maxLength: { 'zh-CN': '{field}长度不能超过 {limit} 个字符', en: '{field}must be at most {limit} characters long' },
|
|
10
|
+
enum: { 'zh-CN': '{field}必须是以下值之一:{values}', en: '{field}must be one of: {values}' },
|
|
11
|
+
const: { 'zh-CN': '{field}必须等于 {value}', en: '{field}must equal {value}' },
|
|
12
|
+
pattern: { 'zh-CN': '{field}格式不符合 {pattern}', en: '{field}must match the pattern {pattern}' },
|
|
13
|
+
required: { 'zh-CN': '缺少必填字段 {property}', en: 'missing required field {property}' },
|
|
14
|
+
additionalProperties: { 'zh-CN': '不支持的字段 {property}', en: 'unknown field {property}' },
|
|
15
|
+
format: { 'zh-CN': '{field}应为有效的 {format}', en: '{field}must be a valid {format}' },
|
|
16
|
+
minItems: { 'zh-CN': '{field}至少需要 {limit} 项', en: '{field}must have at least {limit} items' },
|
|
17
|
+
maxItems: { 'zh-CN': '{field}最多只能有 {limit} 项', en: '{field}must have at most {limit} items' },
|
|
18
|
+
fallback: { 'zh-CN': '{field}不满足 {keyword} 约束({detail})', en: '{field}does not satisfy {keyword} ({detail})' },
|
|
19
|
+
field: { 'zh-CN': '字段 {path} ', en: 'Field {path} ' },
|
|
20
|
+
});
|
|
21
|
+
const NUMERIC_LIMITS = new Set(['maximum', 'minimum', 'exclusiveMaximum', 'exclusiveMinimum', 'minLength', 'maxLength', 'minItems', 'maxItems']);
|
|
22
|
+
function display(value) {
|
|
23
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Localized text for one Ajv issue of a Plugin configuration, in the project `locale`
|
|
27
|
+
* (`zh-CN` by default). `location` is the field path inside the configuration block; it is named in
|
|
28
|
+
* the message so a reader sees which field is wrong without matching it to the separate path.
|
|
29
|
+
*/
|
|
30
|
+
export function formatSchemaIssue(issue, location, locale) {
|
|
31
|
+
const lang = resolveLocale(locale);
|
|
32
|
+
const field = location.length > 0 ? messages(lang, 'field', { path: location.join('.') }) : '';
|
|
33
|
+
const params = issue.params;
|
|
34
|
+
switch (issue.keyword) {
|
|
35
|
+
case 'type': {
|
|
36
|
+
const type = Array.isArray(params.type) ? params.type.join(' | ') : String(params.type);
|
|
37
|
+
return messages(lang, 'type', { field, type: type === 'array' ? 'list' : type });
|
|
38
|
+
}
|
|
39
|
+
case 'enum': {
|
|
40
|
+
const allowed = Array.isArray(params.allowedValues) ? params.allowedValues.map(display).join(', ') : display(params.allowedValues);
|
|
41
|
+
return messages(lang, 'enum', { field, values: allowed });
|
|
42
|
+
}
|
|
43
|
+
case 'const':
|
|
44
|
+
return messages(lang, 'const', { field, value: display(params.allowedValue) });
|
|
45
|
+
case 'pattern':
|
|
46
|
+
return messages(lang, 'pattern', { field, pattern: String(params.pattern) });
|
|
47
|
+
case 'format':
|
|
48
|
+
return messages(lang, 'format', { field, format: String(params.format) });
|
|
49
|
+
case 'required':
|
|
50
|
+
return messages(lang, 'required', { property: [...location, String(params.missingProperty)].join('.') });
|
|
51
|
+
case 'additionalProperties':
|
|
52
|
+
return messages(lang, 'additionalProperties', { property: [...location, String(params.additionalProperty)].join('.') });
|
|
53
|
+
default:
|
|
54
|
+
if (NUMERIC_LIMITS.has(issue.keyword) && (typeof params.limit === 'number' || typeof params.limit === 'string')) {
|
|
55
|
+
return messages(lang, issue.keyword, { field, limit: params.limit });
|
|
56
|
+
}
|
|
57
|
+
return messages(lang, 'fallback', { field, keyword: issue.keyword, detail: issue.message ?? issue.keyword });
|
|
58
|
+
}
|
|
59
|
+
}
|