@zhin.js/gateway 1.2.0 → 1.4.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 +1 -1
- package/lib/config/configuration.d.ts +32 -3
- package/lib/config/configuration.js +120 -25
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/literal-secrets.d.ts +3 -0
- package/lib/config/literal-secrets.js +4 -1
- package/lib/config/patch-path-messages.d.ts +15 -0
- package/lib/config/patch-path-messages.js +71 -0
- package/lib/config/secret-storage.d.ts +42 -0
- package/lib/config/secret-storage.js +140 -0
- package/lib/config/validation-messages.d.ts +15 -0
- package/lib/config/validation-messages.js +66 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/console-role.js +2 -1
- package/lib/http/console-rpc.d.ts +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.d.ts +5 -0
- package/lib/http/gateway-console.js +31 -5
- package/lib/http/gateway-http-server.d.ts +2 -0
- package/lib/http/gateway-http-server.js +42 -1
- package/lib/http/local-principal.d.ts +1 -1
- package/lib/http/local-principal.js +3 -2
- package/lib/http/plugin-switch-rpc.js +7 -6
- package/lib/http/start-gateway.d.ts +1 -1
- package/lib/http/start-gateway.js +2 -0
- package/lib/iam/service.js +12 -0
- package/lib/iam/setup-service.d.ts +3 -1
- package/lib/iam/setup-service.js +2 -1
- package/lib/onboarding/ignored-builds.d.ts +18 -0
- package/lib/onboarding/ignored-builds.js +63 -0
- package/lib/onboarding/index.d.ts +1 -0
- package/lib/onboarding/index.js +1 -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 +74 -9
- package/lib/plugin-management/plugin-candidates.d.ts +9 -0
- package/lib/plugin-management/plugin-candidates.js +55 -0
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +13 -2
- package/lib/plugin-management/workflow.js +46 -13
- package/lib/plugin-management.d.ts +1 -0
- package/lib/plugin-management.js +1 -0
- package/lib/project/identity.d.ts +2 -0
- package/lib/project/identity.js +21 -0
- package/lib/project.d.ts +1 -0
- package/lib/project.js +1 -0
- 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 +8 -8
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ zhin console init # 首次创建 Console 所有者;之后 http.token
|
|
|
29
29
|
|---|---|
|
|
30
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` 使用 |
|
|
@@ -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[];
|
|
@@ -19,6 +20,14 @@ export declare class ConfigChatRequestRejectedError extends Error {
|
|
|
19
20
|
constructor();
|
|
20
21
|
}
|
|
21
22
|
/** Owns one project's Console configuration projection over the Root config authority. */
|
|
23
|
+
/**
|
|
24
|
+
* What the caller of a configuration write may do besides `ops.config.write`. Set by the Console
|
|
25
|
+
* RPC context from the principal's grants, and by trusted in-process / local-owner callers.
|
|
26
|
+
*/
|
|
27
|
+
export interface ConfigWriteAuthority {
|
|
28
|
+
/** The caller may write `.env` (`ops.env.write`): secret literals may be moved there. Default false. */
|
|
29
|
+
readonly environmentWrite?: boolean;
|
|
30
|
+
}
|
|
22
31
|
export declare class ConsoleConfigurationStore {
|
|
23
32
|
#private;
|
|
24
33
|
constructor(options: {
|
|
@@ -32,12 +41,22 @@ export declare class ConsoleConfigurationStore {
|
|
|
32
41
|
readonly validateConfiguration?: (document: RuntimeConfigDocument) => Promise<void>;
|
|
33
42
|
});
|
|
34
43
|
readSource(): Promise<ConsoleConfigSource>;
|
|
44
|
+
/**
|
|
45
|
+
* Secret fields of the stored (unexpanded) configuration that hold literals instead of
|
|
46
|
+
* `${VAR}` references (ADR 0057). Paths only, never values.
|
|
47
|
+
*/
|
|
48
|
+
readLiteralSecrets(): Promise<readonly LiteralConfigSecret[]>;
|
|
35
49
|
readDocument(): Promise<Record<string, unknown>>;
|
|
36
50
|
replaceSource(source: string, expectedRevision: string): Promise<{
|
|
37
51
|
readonly revision: string;
|
|
38
52
|
readonly restartRequired: boolean;
|
|
39
53
|
} & ConsoleConfigApplicationStatus>;
|
|
40
|
-
|
|
54
|
+
/**
|
|
55
|
+
* `options.environmentWrite`: the caller holds `ops.env.write` (or is a trusted in-process / local
|
|
56
|
+
* owner caller). Without it a block whose secret fields hold literals is refused
|
|
57
|
+
* ({@link SecretEnvironmentWriteRequiredError}): those values would be written into `.env`.
|
|
58
|
+
*/
|
|
59
|
+
setKey(pluginName: string, data: unknown, options?: ConfigWriteAuthority): Promise<{
|
|
41
60
|
restartRequired: boolean;
|
|
42
61
|
} & ConsoleConfigApplicationStatus>;
|
|
43
62
|
removeKey(pluginName: string): Promise<{
|
|
@@ -48,14 +67,14 @@ export declare class ConsoleConfigurationStore {
|
|
|
48
67
|
* allowlist are rejected before reading values; a stale revision returns the latest
|
|
49
68
|
* values instead of overwriting; every touched section is validated by its schema.
|
|
50
69
|
*/
|
|
51
|
-
patch(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
|
|
70
|
+
patch(request: ConsoleConfigPatchRequest, options?: ConfigWriteAuthority): Promise<ConsoleConfigPatchResult>;
|
|
52
71
|
/**
|
|
53
72
|
* Trusted project administration (`config:patch-project`, full scope only). Same
|
|
54
73
|
* single-CAS core as {@link patch}, without the page allowlist; it does not widen
|
|
55
74
|
* `config:patch` / `HOST_CONFIG_PATCH_PATHS`. Declaration writes are rejected as
|
|
56
75
|
* `invalid`, conflict values are masked, and a thrown commit is `unconfirmed`.
|
|
57
76
|
*/
|
|
58
|
-
patchProject(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'
|
|
77
|
+
patchProject(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>, options?: ConfigWriteAuthority): Promise<ConsoleConfigProjectPatchResult>;
|
|
59
78
|
/** Host-authenticated owner chat. Policy checks share the mutation's CAS boundary. */
|
|
60
79
|
patchChat(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>): Promise<ConsoleConfigPatchResult>;
|
|
61
80
|
/**
|
|
@@ -73,6 +92,16 @@ export declare class ConsoleConfigurationStore {
|
|
|
73
92
|
apply(expectedRevision: string): Promise<ConsoleConfigApplyResult>;
|
|
74
93
|
readEnvironmentFile(filename: string): Promise<string>;
|
|
75
94
|
writeEnvironmentFile(filename: string, content: string): Promise<void>;
|
|
95
|
+
/** Masked view for the Console (`env:get`); never a value. */
|
|
96
|
+
readEnvironmentView(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
|
|
97
|
+
/** One variable as written (`env:reveal`); the caller authorizes and audits. */
|
|
98
|
+
revealEnvironmentVariable(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
|
|
99
|
+
/** Per-key save (`env:save { changes }`); keys not named keep their lines. */
|
|
100
|
+
updateEnvironmentVariables(filename: string, changes: unknown): Promise<{
|
|
101
|
+
readonly changed: readonly string[];
|
|
102
|
+
}>;
|
|
103
|
+
/** Whole-file save (`env:save { content, mode: 'replace' }`); refused when it holds mask placeholders. */
|
|
104
|
+
replaceEnvironmentFile(filename: string, content: string): Promise<void>;
|
|
76
105
|
readSchema(pluginName?: string): Promise<unknown>;
|
|
77
106
|
validatePluginConfig(pluginName: string, data: unknown, capturedDocument?: RuntimeConfigDocument): Promise<PluginConfigValidation>;
|
|
78
107
|
readAllSchemas(): Promise<Record<string, unknown>>;
|
|
@@ -5,11 +5,15 @@ 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 { declarationKeyMessage, describeArrayPathProblem, unknownConfigKeyMessage } from './patch-path-messages.js';
|
|
11
|
+
import { findProjectLiteralSecrets } from './literal-secrets.js';
|
|
12
|
+
import { planSecretExternalization, SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError, secretEnvironmentWriteRequiredMessage, writeSecretAssignments, } from './secret-storage.js';
|
|
10
13
|
import { hostModelOwnerId } from '@zhin.js/plugin-runtime';
|
|
11
14
|
import { assertChatConfigurationAccess, assertChatConfigurationScope, assertChatValueHasNoEnvironmentReference, assertChatOptionalLeaf, chatConfigurationLeaves, chatHostRoots, chatLeafTypes, ConfigChatAccessError, } from './configuration-chat-policy.js';
|
|
12
15
|
import { isForbiddenConfigurationPathSegment } from './configuration-path.js';
|
|
16
|
+
import { formatSchemaIssue } from './validation-messages.js';
|
|
13
17
|
import { assertPatchesInScope, normalizeConfigPatchOperations, readDeclaredPatchPaths, readPath, resolvePatchScope, } from './configuration-patch.js';
|
|
14
18
|
import { maskConfigValue, REDACTED_CONFIG_VALUE } from './configuration-redaction.js';
|
|
15
19
|
/** A Host-classified rejection before this operation attempted any commit. */
|
|
@@ -47,7 +51,6 @@ function chatApplication(application) {
|
|
|
47
51
|
dependsOn: Object.freeze([]),
|
|
48
52
|
}))) });
|
|
49
53
|
}
|
|
50
|
-
/** Owns one project's Console configuration projection over the Root config authority. */
|
|
51
54
|
export class ConsoleConfigurationStore {
|
|
52
55
|
#projectRoot;
|
|
53
56
|
#document;
|
|
@@ -93,6 +96,13 @@ export class ConsoleConfigurationStore {
|
|
|
93
96
|
});
|
|
94
97
|
});
|
|
95
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Secret fields of the stored (unexpanded) configuration that hold literals instead of
|
|
101
|
+
* `${VAR}` references (ADR 0057). Paths only, never values.
|
|
102
|
+
*/
|
|
103
|
+
readLiteralSecrets() {
|
|
104
|
+
return this.#afterMutations(async () => findProjectLiteralSecrets(this.#projectRoot, (await this.#document.read()).document));
|
|
105
|
+
}
|
|
96
106
|
readDocument() {
|
|
97
107
|
return this.#afterMutations(async () => {
|
|
98
108
|
const snapshot = await this.#document.read();
|
|
@@ -112,16 +122,36 @@ export class ConsoleConfigurationStore {
|
|
|
112
122
|
});
|
|
113
123
|
});
|
|
114
124
|
}
|
|
115
|
-
|
|
125
|
+
/**
|
|
126
|
+
* `options.environmentWrite`: the caller holds `ops.env.write` (or is a trusted in-process / local
|
|
127
|
+
* owner caller). Without it a block whose secret fields hold literals is refused
|
|
128
|
+
* ({@link SecretEnvironmentWriteRequiredError}): those values would be written into `.env`.
|
|
129
|
+
*/
|
|
130
|
+
setKey(pluginName, data, options = {}) {
|
|
116
131
|
return this.#serialize(async () => {
|
|
117
132
|
const current = await this.#document.read();
|
|
118
133
|
await this.#assertUnambiguousKey(current.document, pluginName);
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
134
|
+
const patches = [configKeyPatch(current.document, pluginName, data)];
|
|
135
|
+
// Literal values of secret fields go to `.env`; the block keeps `${VAR}` references (#1250).
|
|
136
|
+
const plan = await planSecretExternalization(this.#projectRoot, this.#environmentName(), current.document, applyConfigPatches(current.document, patches), patches.map(patch => patch.path));
|
|
137
|
+
if (plan.assignments.length > 0 && options.environmentWrite !== true) {
|
|
138
|
+
throw new SecretEnvironmentWriteRequiredError(plan.assignments.map(item => item.path));
|
|
139
|
+
}
|
|
140
|
+
if (plan.errors.length > 0)
|
|
141
|
+
throw new Error(plan.errors.map(error => error.message).join(';'));
|
|
142
|
+
patches.push(...plan.patches);
|
|
143
|
+
const restoreEnvironment = await writeSecretAssignments(this.#projectRoot, this.#environmentName(), plan.assignments);
|
|
144
|
+
let committed;
|
|
145
|
+
try {
|
|
146
|
+
const prepared = await this.#document.prepare(current, patches);
|
|
147
|
+
readApplicationPluginDeclaration(prepared.document);
|
|
148
|
+
await this.#validatePrepared(prepared);
|
|
149
|
+
committed = await prepared.commit();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
await restoreEnvironment();
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
125
155
|
return this.#applySaved(HOST_CONFIG_KEYS.includes(pluginName), committed.revision);
|
|
126
156
|
});
|
|
127
157
|
}
|
|
@@ -143,8 +173,8 @@ export class ConsoleConfigurationStore {
|
|
|
143
173
|
* allowlist are rejected before reading values; a stale revision returns the latest
|
|
144
174
|
* values instead of overwriting; every touched section is validated by its schema.
|
|
145
175
|
*/
|
|
146
|
-
patch(request) {
|
|
147
|
-
return this.#serialize(() => this.#patchCore(request, 'scoped'));
|
|
176
|
+
patch(request, options = {}) {
|
|
177
|
+
return this.#serialize(() => this.#patchCore(request, 'scoped', options));
|
|
148
178
|
}
|
|
149
179
|
/**
|
|
150
180
|
* Trusted project administration (`config:patch-project`, full scope only). Same
|
|
@@ -152,10 +182,11 @@ export class ConsoleConfigurationStore {
|
|
|
152
182
|
* `config:patch` / `HOST_CONFIG_PATCH_PATHS`. Declaration writes are rejected as
|
|
153
183
|
* `invalid`, conflict values are masked, and a thrown commit is `unconfirmed`.
|
|
154
184
|
*/
|
|
155
|
-
patchProject(request) {
|
|
185
|
+
patchProject(request, options = {}) {
|
|
156
186
|
return this.#serialize(async () => {
|
|
157
187
|
try {
|
|
158
|
-
return await this.#patchCore({ expectedRevision: request.expectedRevision, patches: request.patches
|
|
188
|
+
return await this.#patchCore({ expectedRevision: request.expectedRevision, patches: request.patches,
|
|
189
|
+
...(request.secretStorage === undefined ? {} : { secretStorage: request.secretStorage }) }, 'project', options);
|
|
159
190
|
}
|
|
160
191
|
catch (error) {
|
|
161
192
|
if (!(error instanceof ConfigCommitUnconfirmedError))
|
|
@@ -187,11 +218,13 @@ export class ConsoleConfigurationStore {
|
|
|
187
218
|
});
|
|
188
219
|
}
|
|
189
220
|
/** Shared single-CAS write: `scoped` keeps the page allowlist; `project` is the trusted full path. */
|
|
190
|
-
async #patchCore(request, mode) {
|
|
221
|
+
async #patchCore(request, mode, authority = {}) {
|
|
191
222
|
let phase = 'prepare';
|
|
223
|
+
// Restores `.env` when secrets were moved there but the config write does not happen.
|
|
224
|
+
let restoreEnvironment;
|
|
192
225
|
try {
|
|
193
226
|
const instanceKey = request.instanceKey;
|
|
194
|
-
|
|
227
|
+
let patches = normalizeConfigPatchOperations(request.patches);
|
|
195
228
|
const current = await this.#document.read();
|
|
196
229
|
if (mode === 'scoped') {
|
|
197
230
|
const declared = instanceKey === undefined
|
|
@@ -204,8 +237,9 @@ export class ConsoleConfigurationStore {
|
|
|
204
237
|
if (mode !== 'scoped') {
|
|
205
238
|
const rejected = projectDeclarationErrors(current.document, paths);
|
|
206
239
|
if (rejected.length > 0) {
|
|
240
|
+
const declarationWrite = paths.some(path => PROJECT_PATCH_DECLARATION_KEYS.has(path[0]));
|
|
207
241
|
return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(rejected),
|
|
208
|
-
missingEnv: Object.freeze([]), message: '插件声明不能通过项目补丁修改,未保存' });
|
|
242
|
+
missingEnv: Object.freeze([]), message: declarationWrite ? '插件声明不能通过项目补丁修改,未保存' : '没有这个配置项,未保存' });
|
|
209
243
|
}
|
|
210
244
|
}
|
|
211
245
|
const conflict = async (snapshot) => Object.freeze({
|
|
@@ -217,7 +251,44 @@ export class ConsoleConfigurationStore {
|
|
|
217
251
|
});
|
|
218
252
|
if (current.revision !== request.expectedRevision)
|
|
219
253
|
return await conflict(current);
|
|
220
|
-
|
|
254
|
+
// Array indexes outside the array (or a key where an index belongs) are a usage problem:
|
|
255
|
+
// answer with an explanation instead of the document layer's internal exception.
|
|
256
|
+
const arrayProblems = patches.flatMap(patch => {
|
|
257
|
+
const message = describeArrayPathProblem(current.document, patch.path, patch.op);
|
|
258
|
+
return message ? [{ path: `$.${patch.path.join('.')}`, message }] : [];
|
|
259
|
+
});
|
|
260
|
+
if (arrayProblems.length > 0) {
|
|
261
|
+
return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(arrayProblems),
|
|
262
|
+
missingEnv: Object.freeze([]), message: '配置路径无效,未保存' });
|
|
263
|
+
}
|
|
264
|
+
let candidate = applyConfigPatches(current.document, patches);
|
|
265
|
+
// Secret fields keep `${VAR}` references: a literal written here goes to `.env` (#1250),
|
|
266
|
+
// unless the caller explicitly asked for plain storage. Chat writes never carry literals
|
|
267
|
+
// into secret fields (they are refused by the chat policy).
|
|
268
|
+
let envSecrets = [];
|
|
269
|
+
let secretAssignments = [];
|
|
270
|
+
if (mode !== 'chat' && request.secretStorage !== 'plain') {
|
|
271
|
+
const plan = await planSecretExternalization(this.#projectRoot, this.#environmentName(), current.document, candidate, patches.filter(patch => patch.op === 'set').map(patch => patch.path));
|
|
272
|
+
if (plan.errors.length > 0) {
|
|
273
|
+
return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(plan.errors),
|
|
274
|
+
missingEnv: Object.freeze([]), message: '配置校验失败,未保存' });
|
|
275
|
+
}
|
|
276
|
+
// Moving a value into `.env` is an `.env` write: it needs `ops.env.write` (#1250). Without
|
|
277
|
+
// it the write is refused, never stored as plain text behind the caller's back.
|
|
278
|
+
if (plan.assignments.length > 0 && authority.environmentWrite !== true) {
|
|
279
|
+
const paths = plan.assignments.map(item => item.path);
|
|
280
|
+
return Object.freeze({ status: 'invalid', revision: current.revision, code: SECRET_ENVIRONMENT_WRITE_REQUIRED,
|
|
281
|
+
errors: Object.freeze(paths.map(path => Object.freeze({ path: `$.${path.join('.')}`,
|
|
282
|
+
message: '敏感字段的值要写入 .env,需要 ops.env.write 权限' }))),
|
|
283
|
+
missingEnv: Object.freeze([]), message: secretEnvironmentWriteRequiredMessage(paths) });
|
|
284
|
+
}
|
|
285
|
+
if (plan.patches.length > 0) {
|
|
286
|
+
patches = Object.freeze([...patches, ...plan.patches]);
|
|
287
|
+
candidate = applyConfigPatches(current.document, patches);
|
|
288
|
+
secretAssignments = plan.assignments;
|
|
289
|
+
envSecrets = Object.freeze(plan.assignments.map(item => Object.freeze({ path: item.path, variable: item.variable })));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
221
292
|
// Chat writes exactly one leaf (C1/C4); scope and leaf shape are proven before values.
|
|
222
293
|
if (mode === 'chat' && patches.length !== 1)
|
|
223
294
|
throw new ConfigChatAccessError('leaf');
|
|
@@ -258,6 +329,8 @@ export class ConsoleConfigurationStore {
|
|
|
258
329
|
for (const name of validation.missingEnv)
|
|
259
330
|
missingEnv.add(name);
|
|
260
331
|
}
|
|
332
|
+
for (const { variable } of envSecrets)
|
|
333
|
+
missingEnv.delete(variable);
|
|
261
334
|
if (errors.length > 0) {
|
|
262
335
|
return Object.freeze({
|
|
263
336
|
status: 'invalid',
|
|
@@ -268,6 +341,7 @@ export class ConsoleConfigurationStore {
|
|
|
268
341
|
});
|
|
269
342
|
}
|
|
270
343
|
let committed;
|
|
344
|
+
restoreEnvironment = await writeSecretAssignments(this.#projectRoot, this.#environmentName(), secretAssignments);
|
|
271
345
|
try {
|
|
272
346
|
const prepared = await this.#document.prepare(current, patches);
|
|
273
347
|
readApplicationPluginDeclaration(prepared.document);
|
|
@@ -280,6 +354,7 @@ export class ConsoleConfigurationStore {
|
|
|
280
354
|
// Runtime validation may report cross-field constraints without a
|
|
281
355
|
// structured path. Preserve the invalid contract at the document
|
|
282
356
|
// boundary; arbitrary issue strings can contain private values.
|
|
357
|
+
await restoreEnvironment();
|
|
283
358
|
return Object.freeze({ status: 'invalid', revision: current.revision,
|
|
284
359
|
errors: Object.freeze([Object.freeze({ path: '$', message: '完整配置未通过运行校验' })]),
|
|
285
360
|
missingEnv: Object.freeze([...missingEnv].sort()), message: '配置校验失败,未保存' });
|
|
@@ -301,6 +376,8 @@ export class ConsoleConfigurationStore {
|
|
|
301
376
|
}
|
|
302
377
|
}
|
|
303
378
|
catch (error) {
|
|
379
|
+
if (phase === 'prepare')
|
|
380
|
+
await restoreEnvironment();
|
|
304
381
|
if (error instanceof ConfigDocumentConflictError)
|
|
305
382
|
return await conflict(await this.#document.read());
|
|
306
383
|
throw error;
|
|
@@ -313,6 +390,7 @@ export class ConsoleConfigurationStore {
|
|
|
313
390
|
...applied,
|
|
314
391
|
paths,
|
|
315
392
|
missingEnv: Object.freeze([...missingEnv].sort()),
|
|
393
|
+
...(envSecrets.length > 0 ? { envSecrets } : {}),
|
|
316
394
|
message: applied.applicationError === 'superseded' ? '配置已保存,但该次生效申请已被替代'
|
|
317
395
|
: applied.applicationError === 'invalid' ? '配置已保存,但配置校验失败,未生效'
|
|
318
396
|
: applied.applicationError ? '配置已保存,生效结果未确认' : applied.restartRequired
|
|
@@ -551,6 +629,22 @@ export class ConsoleConfigurationStore {
|
|
|
551
629
|
writeEnvironmentFile(filename, content) {
|
|
552
630
|
return writeEnvFile(this.#projectRoot, filename, content);
|
|
553
631
|
}
|
|
632
|
+
/** Masked view for the Console (`env:get`); never a value. */
|
|
633
|
+
readEnvironmentView(filename) {
|
|
634
|
+
return readEnvFileView(this.#projectRoot, filename);
|
|
635
|
+
}
|
|
636
|
+
/** One variable as written (`env:reveal`); the caller authorizes and audits. */
|
|
637
|
+
revealEnvironmentVariable(filename, key) {
|
|
638
|
+
return revealEnvVariable(this.#projectRoot, filename, key);
|
|
639
|
+
}
|
|
640
|
+
/** Per-key save (`env:save { changes }`); keys not named keep their lines. */
|
|
641
|
+
updateEnvironmentVariables(filename, changes) {
|
|
642
|
+
return updateEnvVariables(this.#projectRoot, filename, changes);
|
|
643
|
+
}
|
|
644
|
+
/** Whole-file save (`env:save { content, mode: 'replace' }`); refused when it holds mask placeholders. */
|
|
645
|
+
replaceEnvironmentFile(filename, content) {
|
|
646
|
+
return replaceEnvFileContent(this.#projectRoot, filename, content);
|
|
647
|
+
}
|
|
554
648
|
async readSchema(pluginName) {
|
|
555
649
|
const { document } = await this.#document.read();
|
|
556
650
|
if (pluginName && !HOST_CONFIG_KEYS.includes(pluginName)
|
|
@@ -597,15 +691,15 @@ export class ConsoleConfigurationStore {
|
|
|
597
691
|
for (const issue of candidate.errors) {
|
|
598
692
|
const location = issue.instancePath.replace(/^\/candidate/u, '').split('/').slice(1)
|
|
599
693
|
.map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'));
|
|
694
|
+
// The message names the field relative to the block, in the project locale (`locale`).
|
|
695
|
+
const message = formatSchemaIssue(issue, location, document.locale);
|
|
600
696
|
if (issue.keyword === 'required' && typeof issue.params.missingProperty === 'string') {
|
|
601
697
|
location.push(issue.params.missingProperty);
|
|
602
698
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
: issue.message ?? issue.keyword,
|
|
608
|
-
});
|
|
699
|
+
if (issue.keyword === 'additionalProperties' && typeof issue.params.additionalProperty === 'string') {
|
|
700
|
+
location.push(issue.params.additionalProperty);
|
|
701
|
+
}
|
|
702
|
+
errors.push({ path: `$${location.map(key => `.${key}`).join('')}`, message });
|
|
609
703
|
}
|
|
610
704
|
}
|
|
611
705
|
collectEnvironmentReferences(candidate.value, missingEnv, environment);
|
|
@@ -663,10 +757,11 @@ function projectDeclarationErrors(document, paths) {
|
|
|
663
757
|
const errors = [];
|
|
664
758
|
for (const section of new Set(paths.map(path => path[0]))) {
|
|
665
759
|
if (PROJECT_PATCH_DECLARATION_KEYS.has(section)) {
|
|
666
|
-
errors.push({ path: `$.${section}`, message:
|
|
760
|
+
errors.push({ path: `$.${section}`, message: declarationKeyMessage(section) });
|
|
667
761
|
}
|
|
668
762
|
else if (!HOST_CONFIG_KEYS.includes(section) && !declared.has(section)) {
|
|
669
|
-
|
|
763
|
+
const known = [...HOST_CONFIG_KEYS, ...declared].filter(key => !PROJECT_PATCH_DECLARATION_KEYS.has(key));
|
|
764
|
+
errors.push({ path: `$.${section}`, message: unknownConfigKeyMessage(section, known) });
|
|
670
765
|
}
|
|
671
766
|
}
|
|
672
767
|
return errors;
|
|
@@ -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>;
|