@zhin.js/gateway 1.1.1 → 1.2.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 +4 -4
- package/lib/config/configuration-chat-policy.js +2 -2
- package/lib/config/configuration-redaction.d.ts +2 -8
- package/lib/config/configuration-redaction.js +15 -68
- package/lib/config/console-schema.js +4 -3
- package/lib/config/environment-files.d.ts +1 -0
- package/lib/config/environment-files.js +4 -11
- package/lib/config/literal-secrets.d.ts +34 -0
- package/lib/config/literal-secrets.js +136 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -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 +9 -1
- package/lib/http/gateway-console.d.ts +15 -8
- package/lib/http/gateway-console.js +128 -45
- package/lib/http/gateway-events.d.ts +5 -0
- package/lib/http/gateway-events.js +16 -3
- package/lib/http/gateway-http-server.js +11 -7
- package/lib/http/plugin-switch-rpc.d.ts +19 -0
- package/lib/http/plugin-switch-rpc.js +218 -0
- package/lib/http/start-gateway.js +15 -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/handler.js +1 -1
- package/lib/local/protocol.d.ts +6 -0
- package/lib/local/protocol.js +6 -0
- package/lib/packages/authorization.d.ts +2 -1
- package/lib/packages/authorization.js +20 -1
- package/lib/packages/rpc.js +5 -0
- package/lib/packages/service.d.ts +18 -0
- package/lib/packages/service.js +66 -3
- package/lib/plugin-management/config-explanation.js +3 -3
- package/lib/plugin-management/workflow.d.ts +44 -0
- package/lib/plugin-management/workflow.js +109 -3
- package/package.json +7 -7
package/lib/iam/rpc.js
CHANGED
|
@@ -18,7 +18,7 @@ export async function dispatchIamRpc(message, service, principal) {
|
|
|
18
18
|
const ok = (data) => ({ status: 200, body: { success: true, data, type, requestId } });
|
|
19
19
|
try {
|
|
20
20
|
if (!service?.ready)
|
|
21
|
-
throw new IamError('unavailable', '
|
|
21
|
+
throw new IamError('unavailable', service?.publicUnavailable()?.message ?? 'Console 访问控制尚未就绪');
|
|
22
22
|
const id = (key = 'id') => {
|
|
23
23
|
const value = payload[key];
|
|
24
24
|
if (typeof value !== 'string' || !value)
|
package/lib/iam/service.d.ts
CHANGED
|
@@ -83,7 +83,24 @@ export interface ConsoleIamServiceOptions {
|
|
|
83
83
|
* Without it, levels are inferred from the permission suffix.
|
|
84
84
|
*/
|
|
85
85
|
readonly pluginPermissions?: () => readonly ConsolePluginPermissionInfo[];
|
|
86
|
+
/**
|
|
87
|
+
* Why access control is closed right now (gateway.db not open, the import of the old project
|
|
88
|
+
* database tables not finished, ADR 0059 §3), or undefined. While closed the service fails
|
|
89
|
+
* closed: {@link ConsoleIamService.initialized} is true (the bootstrap token is refused),
|
|
90
|
+
* nothing authenticates and `/auth/*` answers 503 — an empty store never reads as "no owner".
|
|
91
|
+
*/
|
|
92
|
+
readonly unavailable?: () => string | undefined;
|
|
93
|
+
/**
|
|
94
|
+
* Category of `unavailable` (`starting`, `store`, `migration`, `store_lost`, …). Anonymous HTTP
|
|
95
|
+
* callers only ever see this code and a generic message; the detail (paths, driver messages)
|
|
96
|
+
* goes to the logs and to the local owner (`zhin doctor`, `zhin console migrate`).
|
|
97
|
+
*/
|
|
98
|
+
readonly unavailableCode?: () => string | undefined;
|
|
99
|
+
/** Called after the first owner was created (the gateway then makes sure its store marker exists). */
|
|
100
|
+
readonly onOwnerCreated?: () => void;
|
|
86
101
|
}
|
|
102
|
+
/** What anonymous callers learn while access control is closed (no paths or driver messages). */
|
|
103
|
+
export declare const IAM_UNAVAILABLE_PUBLIC_MESSAGE = "Console \u8BBF\u95EE\u63A7\u5236\u6682\u4E0D\u53EF\u7528\uFF1A\u8BF7\u5728\u670D\u52A1\u5668\u4E0A\u8FD0\u884C zhin doctor \u67E5\u770B\u539F\u56E0";
|
|
87
104
|
/**
|
|
88
105
|
* What is wrong with a new account's username / password, or null — the rules
|
|
89
106
|
* {@link ConsoleIamService.createUser} / `createOwner` enforce, for callers that must check before
|
|
@@ -104,6 +121,13 @@ export declare class ConsoleIamService implements ConsoleAccessProvider {
|
|
|
104
121
|
#private;
|
|
105
122
|
constructor(options: ConsoleIamServiceOptions);
|
|
106
123
|
get ready(): boolean;
|
|
124
|
+
/** See {@link ConsoleIamServiceOptions.unavailable} (detail: logs and the local owner only). */
|
|
125
|
+
unavailableReason(): string | undefined;
|
|
126
|
+
/** `{ code, message }` safe for any HTTP caller while closed, or undefined. */
|
|
127
|
+
publicUnavailable(): {
|
|
128
|
+
readonly code: string;
|
|
129
|
+
readonly message: string;
|
|
130
|
+
} | undefined;
|
|
107
131
|
/** Seeds built-in roles and loads the owner state; call after the database started. */
|
|
108
132
|
start(options?: Readonly<{
|
|
109
133
|
timers?: boolean;
|
|
@@ -201,3 +225,10 @@ export declare class ConsoleIamService implements ConsoleAccessProvider {
|
|
|
201
225
|
/** Daily: audit older than the retention window and expired sessions. */
|
|
202
226
|
cleanup(): Promise<void>;
|
|
203
227
|
}
|
|
228
|
+
/** Longest stored audit `detail` (characters of JSON). */
|
|
229
|
+
export declare const AUDIT_DETAIL_MAX_LENGTH = 4096;
|
|
230
|
+
/**
|
|
231
|
+
* Audit detail as JSON that always parses back: a detail longer than the column budget is
|
|
232
|
+
* replaced by `{ truncated, length, preview }` instead of being cut mid-string.
|
|
233
|
+
*/
|
|
234
|
+
export declare function auditDetailJson(detail: unknown): string;
|
package/lib/iam/service.js
CHANGED
|
@@ -21,6 +21,8 @@ export class IamError extends Error {
|
|
|
21
21
|
this.status = STATUS[code];
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/** What anonymous callers learn while access control is closed (no paths or driver messages). */
|
|
25
|
+
export const IAM_UNAVAILABLE_PUBLIC_MESSAGE = 'Console 访问控制暂不可用:请在服务器上运行 zhin doctor 查看原因';
|
|
24
26
|
const USERNAME = /^[a-z0-9][a-z0-9._-]{1,63}$/u;
|
|
25
27
|
/**
|
|
26
28
|
* What is wrong with a new account's username / password, or null — the rules
|
|
@@ -48,6 +50,9 @@ export class ConsoleIamService {
|
|
|
48
50
|
#limiter;
|
|
49
51
|
#onError;
|
|
50
52
|
#pluginPermissions;
|
|
53
|
+
#unavailable;
|
|
54
|
+
#unavailableCode;
|
|
55
|
+
#onOwnerCreated;
|
|
51
56
|
#ready = false;
|
|
52
57
|
#initialized;
|
|
53
58
|
#cleanupTimer;
|
|
@@ -64,9 +69,22 @@ export class ConsoleIamService {
|
|
|
64
69
|
this.#limiter = options.rateLimiter ?? new LoginRateLimiter({ now: this.#now });
|
|
65
70
|
this.#onError = options.onError ?? (() => undefined);
|
|
66
71
|
this.#pluginPermissions = options.pluginPermissions ?? (() => []);
|
|
72
|
+
this.#unavailable = options.unavailable ?? (() => undefined);
|
|
73
|
+
this.#unavailableCode = options.unavailableCode ?? (() => undefined);
|
|
74
|
+
this.#onOwnerCreated = options.onOwnerCreated;
|
|
67
75
|
}
|
|
68
76
|
get ready() {
|
|
69
|
-
return this.#ready;
|
|
77
|
+
return this.#ready && this.#unavailable() === undefined;
|
|
78
|
+
}
|
|
79
|
+
/** See {@link ConsoleIamServiceOptions.unavailable} (detail: logs and the local owner only). */
|
|
80
|
+
unavailableReason() {
|
|
81
|
+
return this.#unavailable();
|
|
82
|
+
}
|
|
83
|
+
/** `{ code, message }` safe for any HTTP caller while closed, or undefined. */
|
|
84
|
+
publicUnavailable() {
|
|
85
|
+
if (this.#unavailable() === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
return Object.freeze({ code: this.#unavailableCode() ?? 'unavailable', message: IAM_UNAVAILABLE_PUBLIC_MESSAGE });
|
|
70
88
|
}
|
|
71
89
|
/** Seeds built-in roles and loads the owner state; call after the database started. */
|
|
72
90
|
async start(options = {}) {
|
|
@@ -115,6 +133,8 @@ export class ConsoleIamService {
|
|
|
115
133
|
clearInterval(this.#refreshTimer);
|
|
116
134
|
}
|
|
117
135
|
initialized() {
|
|
136
|
+
if (this.#unavailable() !== undefined)
|
|
137
|
+
return true;
|
|
118
138
|
if (this.#initialized === true)
|
|
119
139
|
return true;
|
|
120
140
|
// Database not up yet, or another process (`zhin console init` beside a running gateway)
|
|
@@ -156,7 +176,7 @@ export class ConsoleIamService {
|
|
|
156
176
|
}
|
|
157
177
|
// ------------------------------------------------------------------ authentication
|
|
158
178
|
async authenticate(token, meta = {}) {
|
|
159
|
-
if (!this
|
|
179
|
+
if (!this.ready || !token || token.length > 256)
|
|
160
180
|
return null;
|
|
161
181
|
const hash = sha256(token);
|
|
162
182
|
const now = this.#now();
|
|
@@ -199,7 +219,7 @@ export class ConsoleIamService {
|
|
|
199
219
|
action: entry.action.slice(0, 256),
|
|
200
220
|
target: entry.target?.slice(0, 256) ?? null,
|
|
201
221
|
outcome: entry.outcome,
|
|
202
|
-
detail: entry.detail ?
|
|
222
|
+
detail: entry.detail ? auditDetailJson(entry.detail) : null,
|
|
203
223
|
ip: entry.ip?.slice(0, 64) ?? null,
|
|
204
224
|
}).catch(this.#onError);
|
|
205
225
|
}
|
|
@@ -312,7 +332,14 @@ export class ConsoleIamService {
|
|
|
312
332
|
this.#requireReady();
|
|
313
333
|
if ((await this.#activeOwnerIds()).size > 0)
|
|
314
334
|
throw new IamError('conflict', '已初始化:所有者已存在');
|
|
315
|
-
|
|
335
|
+
const owner = await this.#createUser({ ...input, bindings: [{ role: 'owner', scopeKind: 'global' }] });
|
|
336
|
+
try {
|
|
337
|
+
this.#onOwnerCreated?.();
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
this.#onError(error);
|
|
341
|
+
}
|
|
342
|
+
return owner;
|
|
316
343
|
});
|
|
317
344
|
}
|
|
318
345
|
/** Issues a session for a user (setup returns one for the new owner). */
|
|
@@ -782,3 +809,21 @@ function auditView(row) {
|
|
|
782
809
|
ip: row.ip === null || row.ip === undefined ? null : String(row.ip),
|
|
783
810
|
});
|
|
784
811
|
}
|
|
812
|
+
/** Longest stored audit `detail` (characters of JSON). */
|
|
813
|
+
export const AUDIT_DETAIL_MAX_LENGTH = 4096;
|
|
814
|
+
/**
|
|
815
|
+
* Audit detail as JSON that always parses back: a detail longer than the column budget is
|
|
816
|
+
* replaced by `{ truncated, length, preview }` instead of being cut mid-string.
|
|
817
|
+
*/
|
|
818
|
+
export function auditDetailJson(detail) {
|
|
819
|
+
const json = JSON.stringify(detail) ?? 'null';
|
|
820
|
+
if (json.length <= AUDIT_DETAIL_MAX_LENGTH)
|
|
821
|
+
return json;
|
|
822
|
+
let keep = AUDIT_DETAIL_MAX_LENGTH - 64;
|
|
823
|
+
for (;;) {
|
|
824
|
+
const replacement = JSON.stringify({ truncated: true, length: json.length, preview: json.slice(0, Math.max(0, keep)) });
|
|
825
|
+
if (replacement.length <= AUDIT_DETAIL_MAX_LENGTH || keep <= 0)
|
|
826
|
+
return replacement;
|
|
827
|
+
keep -= replacement.length - AUDIT_DETAIL_MAX_LENGTH;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
@@ -55,6 +55,8 @@ export interface ConsoleSetupServiceOptions {
|
|
|
55
55
|
readonly driverAvailable?: (dialect: SetupDialect) => Promise<boolean>;
|
|
56
56
|
}
|
|
57
57
|
export declare const SETUP_NO_MIGRATION_NOTICE = "\u5207\u6362\u6570\u636E\u5E93\u4E0D\u4F1A\u8FC1\u79FB\u65E7\u5E93\u6570\u636E\uFF1A\u63D2\u4EF6\u6570\u636E\u4ECD\u7559\u5728\u539F\u6570\u636E\u5E93\u3002";
|
|
58
|
+
/** PostgreSQL / MySQL: raw Console SQL needs the restricted account (ADR 0059 §4). */
|
|
59
|
+
export declare const SETUP_CONSOLE_ROLE_NOTICE = "PostgreSQL / MySQL \u4E0A Console \u7684\u539F\u59CB SQL \u53EA\u7528\u53D7\u9650\u8D26\u53F7\u6267\u884C\uFF1A\u8FD0\u884C zhin db console-role \u521B\u5EFA\uFF08\u65E0\u5EFA\u53F7\u6743\u9650\u65F6\u52A0 --print-sql \u4EA4\u7ED9 DBA\uFF09\u3002";
|
|
58
60
|
/**
|
|
59
61
|
* The one setup flow (ADR 0049 §7): `/auth/setup*` and `zhin console init` are thin wrappers.
|
|
60
62
|
* Database changes are written to the config file / `.env`; restarting is the caller's job.
|
package/lib/iam/setup-service.js
CHANGED
|
@@ -3,7 +3,6 @@ import { isDatabaseDriverAvailable, probeDatabaseConnection, } from '@zhin.js/da
|
|
|
3
3
|
import { expandEnvironmentValue } from '@zhin.js/plugin-runtime';
|
|
4
4
|
import { DATABASE_DIALECT_DEFINITIONS, validateDatabaseConfig, } from '@zhin.js/scaffold-wizard';
|
|
5
5
|
import { IamError } from './service.js';
|
|
6
|
-
import { IAM_DIALECTS } from './tables.js';
|
|
7
6
|
const DEFAULT_SQLITE_FILE = '.zhin/data.sqlite';
|
|
8
7
|
const SECRET_FIELDS = new Set(['password']);
|
|
9
8
|
const ENV_REFERENCE = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/u;
|
|
@@ -34,7 +33,14 @@ function fieldsOf(definition) {
|
|
|
34
33
|
})];
|
|
35
34
|
}));
|
|
36
35
|
}
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Dialects the wizard can test a connection for. Console accounts no longer depend on it: they
|
|
38
|
+
* live in `.zhin/gateway.db` (ADR 0059); a redis / memory project keeps its database block.
|
|
39
|
+
*/
|
|
40
|
+
const SETUP_DIALECTS = new Set(['sqlite', 'mysql', 'pg', 'mongodb']);
|
|
41
|
+
const SETUP_DEFINITIONS = DATABASE_DIALECT_DEFINITIONS.filter(definition => SETUP_DIALECTS.has(definition.dialect));
|
|
42
|
+
/** PostgreSQL / MySQL: raw Console SQL needs the restricted account (ADR 0059 §4). */
|
|
43
|
+
export const SETUP_CONSOLE_ROLE_NOTICE = 'PostgreSQL / MySQL 上 Console 的原始 SQL 只用受限账号执行:运行 zhin db console-role 创建(无建号权限时加 --print-sql 交给 DBA)。';
|
|
38
44
|
/**
|
|
39
45
|
* The one setup flow (ADR 0049 §7): `/auth/setup*` and `zhin console init` are thin wrappers.
|
|
40
46
|
* Database changes are written to the config file / `.env`; restarting is the caller's job.
|
|
@@ -96,12 +102,13 @@ export class ConsoleSetupService {
|
|
|
96
102
|
await this.#options.envFile.write(upsertDotenv(await this.#options.envFile.read(), envUpdates));
|
|
97
103
|
}
|
|
98
104
|
await this.#options.config.setKey('database', block);
|
|
99
|
-
|
|
105
|
+
const notice = prepared.dialect === 'pg' || prepared.dialect === 'mysql' ? `${SETUP_NO_MIGRATION_NOTICE}${SETUP_CONSOLE_ROLE_NOTICE}` : SETUP_NO_MIGRATION_NOTICE;
|
|
106
|
+
return Object.freeze({ restartRequired: true, notice });
|
|
100
107
|
}
|
|
101
108
|
async createOwner(input) {
|
|
102
109
|
const iam = this.#options.iam();
|
|
103
110
|
if (!iam?.ready)
|
|
104
|
-
throw new IamError('unavailable', '
|
|
111
|
+
throw new IamError('unavailable', iam?.publicUnavailable()?.message ?? 'Console 访问控制尚未就绪');
|
|
105
112
|
if (iam.initialized())
|
|
106
113
|
throw new IamError('conflict', '已初始化:请改用 zhin console user 管理用户');
|
|
107
114
|
return { user: await iam.createOwner(input) };
|
package/lib/iam/tables.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Console access-control tables (ADR 0049 §2). Since ADR 0059 they live in the gateway's own store
|
|
3
|
+
* `.zhin/gateway.db` (SQLite), whatever the project database is.
|
|
4
|
+
*/
|
|
2
5
|
export declare const IAM_TABLES: Readonly<{
|
|
3
6
|
readonly users: "console_users";
|
|
4
7
|
readonly roles: "console_roles";
|
|
@@ -8,13 +11,10 @@ export declare const IAM_TABLES: Readonly<{
|
|
|
8
11
|
readonly audit: "console_audit_log";
|
|
9
12
|
}>;
|
|
10
13
|
export declare const IAM_TABLE_DEFINITIONS: Readonly<Record<string, Record<string, unknown>>>;
|
|
11
|
-
/** Dialects that can hold accounts (redis / memory cannot, ADR 0049 §8). */
|
|
12
|
-
export declare const IAM_DIALECTS: ReadonlySet<string>;
|
|
13
14
|
export interface IamTableHost {
|
|
14
|
-
readonly dialect: string;
|
|
15
15
|
readonly started: boolean;
|
|
16
16
|
define(name: string, definition: Record<string, unknown>): void;
|
|
17
17
|
tables(): readonly string[];
|
|
18
18
|
}
|
|
19
|
-
/** Idempotent; must run before the database starts.
|
|
20
|
-
export declare function defineConsoleIamTables(host: IamTableHost):
|
|
19
|
+
/** Idempotent; must run before the database starts. */
|
|
20
|
+
export declare function defineConsoleIamTables(host: IamTableHost): void;
|
package/lib/iam/tables.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Console access-control tables (ADR 0049 §2). Since ADR 0059 they live in the gateway's own store
|
|
3
|
+
* `.zhin/gateway.db` (SQLite), whatever the project database is.
|
|
4
|
+
*/
|
|
2
5
|
export const IAM_TABLES = Object.freeze({
|
|
3
6
|
users: 'console_users',
|
|
4
7
|
roles: 'console_roles',
|
|
@@ -65,18 +68,13 @@ export const IAM_TABLE_DEFINITIONS = Object.freeze({
|
|
|
65
68
|
ip: text(true),
|
|
66
69
|
},
|
|
67
70
|
});
|
|
68
|
-
/**
|
|
69
|
-
export const IAM_DIALECTS = new Set(['sqlite', 'mysql', 'pg', 'mongodb']);
|
|
70
|
-
/** Idempotent; must run before the database starts. Returns false for unsupported dialects. */
|
|
71
|
+
/** Idempotent; must run before the database starts. */
|
|
71
72
|
export function defineConsoleIamTables(host) {
|
|
72
|
-
if (!IAM_DIALECTS.has(host.dialect))
|
|
73
|
-
return false;
|
|
74
73
|
if (host.started)
|
|
75
|
-
return
|
|
74
|
+
return;
|
|
76
75
|
const existing = new Set(host.tables());
|
|
77
76
|
for (const [name, definition] of Object.entries(IAM_TABLE_DEFINITIONS)) {
|
|
78
77
|
if (!existing.has(name))
|
|
79
78
|
host.define(name, definition);
|
|
80
79
|
}
|
|
81
|
-
return true;
|
|
82
80
|
}
|
package/lib/local/handler.js
CHANGED
|
@@ -53,7 +53,7 @@ async function handleHttp(options, data) {
|
|
|
53
53
|
const result = { status: response.status, headers, body: text };
|
|
54
54
|
return { ok: true, data: result };
|
|
55
55
|
}
|
|
56
|
-
const STREAMED = new Set([GATEWAY_RPC.INSTALL, GATEWAY_RPC.UNINSTALL, GATEWAY_RPC.UPDATE, GATEWAY_RPC.DECLARE]);
|
|
56
|
+
const STREAMED = new Set([GATEWAY_RPC.INSTALL, GATEWAY_RPC.UNINSTALL, GATEWAY_RPC.UPDATE, GATEWAY_RPC.DECLARE, GATEWAY_RPC.ENABLE, GATEWAY_RPC.DISABLE]);
|
|
57
57
|
export function createLocalRpcHandler(options) {
|
|
58
58
|
return async (request, emit) => {
|
|
59
59
|
if (request.type === LOCAL_GATEWAY_RPC.INFO)
|
package/lib/local/protocol.d.ts
CHANGED
|
@@ -33,6 +33,12 @@ export declare const LOCAL_GATEWAY_RPC: Readonly<{
|
|
|
33
33
|
readonly CONSOLE_USER_PASSWD: "gateway.console.user.passwd";
|
|
34
34
|
/** `{ username, disabled }`. */
|
|
35
35
|
readonly CONSOLE_USER_DISABLE: "gateway.console.user.disable";
|
|
36
|
+
/**
|
|
37
|
+
* `zhin console migrate` / `zhin doctor` beside a running gateway (ADR 0059 §3): the state of the
|
|
38
|
+
* import of the old `console_*` tables (`{ record, pending?, reappeared?, problem? }`), read by
|
|
39
|
+
* the gateway, the only process that opens `.zhin/gateway.db` while it runs.
|
|
40
|
+
*/
|
|
41
|
+
readonly CONSOLE_MIGRATION_STATUS: "gateway.console.migration.status";
|
|
36
42
|
/**
|
|
37
43
|
* `{base}/…` request on behalf of the local owner ({@link LocalGatewayHttpRequest}): what
|
|
38
44
|
* `zhin config` / `schedule` / `send` / `watch` use while a gateway runs, so they need no
|
package/lib/local/protocol.js
CHANGED
|
@@ -39,6 +39,12 @@ export const LOCAL_GATEWAY_RPC = Object.freeze({
|
|
|
39
39
|
CONSOLE_USER_PASSWD: 'gateway.console.user.passwd',
|
|
40
40
|
/** `{ username, disabled }`. */
|
|
41
41
|
CONSOLE_USER_DISABLE: 'gateway.console.user.disable',
|
|
42
|
+
/**
|
|
43
|
+
* `zhin console migrate` / `zhin doctor` beside a running gateway (ADR 0059 §3): the state of the
|
|
44
|
+
* import of the old `console_*` tables (`{ record, pending?, reappeared?, problem? }`), read by
|
|
45
|
+
* the gateway, the only process that opens `.zhin/gateway.db` while it runs.
|
|
46
|
+
*/
|
|
47
|
+
CONSOLE_MIGRATION_STATUS: 'gateway.console.migration.status',
|
|
42
48
|
/**
|
|
43
49
|
* `{base}/…` request on behalf of the local owner ({@link LocalGatewayHttpRequest}): what
|
|
44
50
|
* `zhin config` / `schedule` / `send` / `watch` use while a gateway runs, so they need no
|
|
@@ -18,6 +18,7 @@ export declare function gatewayPrincipalAllows(principal: GatewayPrincipal | und
|
|
|
18
18
|
* - bootstrap token: `install` of allowlisted database drivers only, before the first owner (ADR 0053 §3);
|
|
19
19
|
* - `plugin.manage` without a global owner binding: allowlisted packages only (ADR 0053 §3);
|
|
20
20
|
* - owners and the local socket: any npm package by name, with the same strict spec rules.
|
|
21
|
-
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall
|
|
21
|
+
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall`;
|
|
22
|
+
* `enable` / `disable` take the declared package (a bare name, or a `./` local plugin for owners).
|
|
22
23
|
*/
|
|
23
24
|
export declare function authorizePackages(principal: GatewayPrincipal | undefined, operation: GatewayTransactionOperation, requests: readonly unknown[] | 'all', options: AuthorizePackagesOptions): readonly GatewayAuthorizedPackage[];
|
|
@@ -3,6 +3,8 @@ import { authorizeInstall, parseInstallRequest } from '../onboarding/allowlist.j
|
|
|
3
3
|
import { OnboardingError } from '../onboarding/errors.js';
|
|
4
4
|
import { GatewayError } from './errors.js';
|
|
5
5
|
const MAX_PACKAGES = 32;
|
|
6
|
+
/** A project-local plugin package reference as declared in `plugins.<instanceKey>`. */
|
|
7
|
+
const LOCAL_PACKAGE = /^\.\/[A-Za-z0-9._@-]+(?:\/[A-Za-z0-9._@-]+)*$/u;
|
|
6
8
|
const EXACT_VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z][0-9A-Za-z.-]{0,63})?$/u;
|
|
7
9
|
/** A global `owner` binding, or the local control socket. */
|
|
8
10
|
export function isGatewayOwner(principal) {
|
|
@@ -32,7 +34,8 @@ function rethrow(error) {
|
|
|
32
34
|
* - bootstrap token: `install` of allowlisted database drivers only, before the first owner (ADR 0053 §3);
|
|
33
35
|
* - `plugin.manage` without a global owner binding: allowlisted packages only (ADR 0053 §3);
|
|
34
36
|
* - owners and the local socket: any npm package by name, with the same strict spec rules.
|
|
35
|
-
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall
|
|
37
|
+
* `'all'` (update every dependency) is owner-only. `declare` takes bare names, like `uninstall`;
|
|
38
|
+
* `enable` / `disable` take the declared package (a bare name, or a `./` local plugin for owners).
|
|
36
39
|
*/
|
|
37
40
|
export function authorizePackages(principal, operation, requests, options) {
|
|
38
41
|
if (!principal)
|
|
@@ -73,7 +76,21 @@ export function authorizePackages(principal, operation, requests, options) {
|
|
|
73
76
|
}
|
|
74
77
|
const seen = new Set();
|
|
75
78
|
const authorized = [];
|
|
79
|
+
const toggle = operation === 'enable' || operation === 'disable';
|
|
76
80
|
for (const request of requests) {
|
|
81
|
+
// enable / disable name the declared package, which may be a project-local plugin directory.
|
|
82
|
+
if (toggle && typeof request === 'string' && request.startsWith('./')) {
|
|
83
|
+
if (!LOCAL_PACKAGE.test(request) || request.split('/').slice(1).some(part => part === '.' || part === '..')) {
|
|
84
|
+
throw new GatewayError('invalid_input', `本地插件路径无效:${request.slice(0, 64)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!owner)
|
|
87
|
+
throw new GatewayError('forbidden', '只有所有者可以启停本地插件');
|
|
88
|
+
if (seen.has(request))
|
|
89
|
+
throw new GatewayError('invalid_input', `重复的包:${request}`);
|
|
90
|
+
seen.add(request);
|
|
91
|
+
authorized.push(Object.freeze({ name: request, spec: request, source: 'local' }));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
77
94
|
let parsed;
|
|
78
95
|
try {
|
|
79
96
|
parsed = parseInstallRequest(request);
|
|
@@ -93,6 +110,8 @@ export function authorizePackages(principal, operation, requests, options) {
|
|
|
93
110
|
throw new GatewayError('invalid_input', `卸载 ${name} 时不能指定版本`);
|
|
94
111
|
if (operation === 'declare' && version !== undefined)
|
|
95
112
|
throw new GatewayError('invalid_input', `声明 ${name} 时不能指定版本`);
|
|
113
|
+
if (toggle && version !== undefined)
|
|
114
|
+
throw new GatewayError('invalid_input', `启停 ${name} 时不能指定版本`);
|
|
96
115
|
if (operation === 'update' && version !== undefined && !EXACT_VERSION.test(version)) {
|
|
97
116
|
throw new GatewayError('invalid_input', `${name} 的更新版本必须是精确 semver 版本`);
|
|
98
117
|
}
|
package/lib/packages/rpc.js
CHANGED
|
@@ -64,6 +64,11 @@ async function handle(type, data, principal, deps) {
|
|
|
64
64
|
principal, ...optionalBoolean(data, 'startInstance'),
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
|
+
case 'gateway.plugins.enable':
|
|
68
|
+
case 'gateway.plugins.disable':
|
|
69
|
+
return packagesOf(deps).setEnabled({ plugin: data.plugin }, type === 'gateway.plugins.enable', {
|
|
70
|
+
principal, ...optionalBoolean(data, 'startInstance'),
|
|
71
|
+
});
|
|
67
72
|
case 'gateway.instance.status': return instanceStatus(instanceOf(deps).status());
|
|
68
73
|
case 'gateway.instance.logs': {
|
|
69
74
|
const lines = data.lines === undefined ? DEFAULT_LOG_LINES : data.lines;
|
|
@@ -36,6 +36,13 @@ export interface PackageWorkflowPort {
|
|
|
36
36
|
declare?(request: string, config?: unknown, options?: {
|
|
37
37
|
readonly instanceKey?: string;
|
|
38
38
|
}): Promise<unknown>;
|
|
39
|
+
/** Validates an enable / disable without writing (throws `PluginEnablementError`). */
|
|
40
|
+
planSetEnabled?(request: string, enabled: boolean): Promise<{
|
|
41
|
+
readonly packageName: string;
|
|
42
|
+
readonly instanceKey: string;
|
|
43
|
+
readonly changed: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
setEnabled?(request: string, enabled: boolean): Promise<unknown>;
|
|
39
46
|
}
|
|
40
47
|
/**
|
|
41
48
|
* Runs the package manager with pnpm-style args (`add|remove|update|install …`), streaming output.
|
|
@@ -88,6 +95,10 @@ export interface GatewayDeclareRequest {
|
|
|
88
95
|
readonly instanceKey?: unknown;
|
|
89
96
|
readonly config?: unknown;
|
|
90
97
|
}
|
|
98
|
+
export interface GatewaySetEnabledRequest {
|
|
99
|
+
/** A declared instance key, or the package name of a package declared exactly once. */
|
|
100
|
+
readonly plugin: unknown;
|
|
101
|
+
}
|
|
91
102
|
export declare const DEFAULT_PACKAGE_READY_TIMEOUT_MS = 60000;
|
|
92
103
|
export declare const DEFAULT_PACKAGE_STABLE_MS = 5000;
|
|
93
104
|
export declare class GatewayPackageService {
|
|
@@ -111,6 +122,13 @@ export declare class GatewayPackageService {
|
|
|
111
122
|
* the configuration file back and starts the previous instance again.
|
|
112
123
|
*/
|
|
113
124
|
declare(request: GatewayDeclareRequest, options: GatewayTransactionOptions): Promise<GatewayPackageTransactionResult>;
|
|
125
|
+
/**
|
|
126
|
+
* `gateway.plugins.enable` / `gateway.plugins.disable`: rewrites `disable_plugins` for one
|
|
127
|
+
* declared instance, then restarts the instance (a failed start restores the configuration file
|
|
128
|
+
* and starts the previous instance again). Validation runs before the instance is touched; an
|
|
129
|
+
* instance already in the requested state succeeds without stopping anything or writing.
|
|
130
|
+
*/
|
|
131
|
+
setEnabled(request: GatewaySetEnabledRequest, enabled: boolean, options: GatewayTransactionOptions): Promise<GatewayPackageTransactionResult>;
|
|
114
132
|
uninstall(packages: readonly unknown[], options: GatewayUninstallOptions): Promise<GatewayPackageTransactionResult>;
|
|
115
133
|
/** Exact versions go through the workflow (verified); bare names / `'all'` run the manager's update. */
|
|
116
134
|
update(packages: readonly unknown[] | 'all', options: GatewayTransactionOptions): Promise<GatewayPackageTransactionResult>;
|
package/lib/packages/service.js
CHANGED
|
@@ -10,7 +10,7 @@ import { CONSOLE_PERMISSIONS } from '@zhin.js/console-protocol';
|
|
|
10
10
|
import { createInstallAllowlist } from '../onboarding/allowlist.js';
|
|
11
11
|
import { InstallOutputRelay, streamEvents } from '../onboarding/install.js';
|
|
12
12
|
import { createStreamingPackageManager, detectPackageManager } from '../onboarding/package-manager.js';
|
|
13
|
-
import { PluginDeclarationError, PluginManagementOperationError, PluginManagementWorkflow } from '../plugin-management/workflow.js';
|
|
13
|
+
import { PluginDeclarationError, PluginEnablementError, PluginManagementOperationError, PluginManagementWorkflow } from '../plugin-management/workflow.js';
|
|
14
14
|
import { InstanceStartError } from '../supervisor/instance-supervisor.js';
|
|
15
15
|
import { authorizePackages, gatewayPrincipalAllows } from './authorization.js';
|
|
16
16
|
import { GatewayError } from './errors.js';
|
|
@@ -20,6 +20,8 @@ export const DEFAULT_PACKAGE_STABLE_MS = 5_000;
|
|
|
20
20
|
const LOCK_FILE = 'gateway-packages.lock';
|
|
21
21
|
const LOG_TAIL_LINES = 50;
|
|
22
22
|
const RUNNING_STATES = new Set(['running', 'starting', 'backoff']);
|
|
23
|
+
/** Transactions that only write the configuration file: no package manager, no reinstall on rollback. */
|
|
24
|
+
const CONFIG_ONLY_OPERATIONS = new Set(['declare', 'enable', 'disable']);
|
|
23
25
|
/** Thrown inside a transaction when the instance does not come up; carries its log tail. */
|
|
24
26
|
class InstanceStartFailure extends Error {
|
|
25
27
|
logTail;
|
|
@@ -145,6 +147,60 @@ export class GatewayPackageService {
|
|
|
145
147
|
await ctx.workflow.declare(authorized[0].name, config, declareOptions);
|
|
146
148
|
});
|
|
147
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* `gateway.plugins.enable` / `gateway.plugins.disable`: rewrites `disable_plugins` for one
|
|
152
|
+
* declared instance, then restarts the instance (a failed start restores the configuration file
|
|
153
|
+
* and starts the previous instance again). Validation runs before the instance is touched; an
|
|
154
|
+
* instance already in the requested state succeeds without stopping anything or writing.
|
|
155
|
+
*/
|
|
156
|
+
async setEnabled(request, enabled, options) {
|
|
157
|
+
const workflow = this.#workflow;
|
|
158
|
+
if (!workflow.planSetEnabled || !workflow.setEnabled)
|
|
159
|
+
throw new GatewayError('unavailable', '插件启停不可用');
|
|
160
|
+
const operation = enabled ? 'enable' : 'disable';
|
|
161
|
+
// Permission before anything about the project is read.
|
|
162
|
+
if (!options.principal || options.principal !== 'local' && options.principal.kind === 'bootstrap'
|
|
163
|
+
|| !gatewayPrincipalAllows(options.principal, CONSOLE_PERMISSIONS.PLUGIN_MANAGE)) {
|
|
164
|
+
throw new GatewayError('forbidden', `需要 ${CONSOLE_PERMISSIONS.PLUGIN_MANAGE} 权限`);
|
|
165
|
+
}
|
|
166
|
+
if (typeof request.plugin !== 'string' || !request.plugin.trim() || request.plugin.length > 256) {
|
|
167
|
+
throw new GatewayError('invalid_input', 'plugin 必须是插件包名或 instanceKey');
|
|
168
|
+
}
|
|
169
|
+
let plan;
|
|
170
|
+
try {
|
|
171
|
+
plan = await workflow.planSetEnabled(request.plugin, enabled);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw enablementError(error);
|
|
175
|
+
}
|
|
176
|
+
const plugin = Object.freeze({ instanceKey: plan.instanceKey, packageName: plan.packageName, enabled, changed: plan.changed });
|
|
177
|
+
if (!plan.changed)
|
|
178
|
+
return this.#unchanged(operation, await this.authorize(options.principal, operation, [plan.packageName]), plugin, options);
|
|
179
|
+
return this.#transaction(operation, [plan.packageName], options, async (ctx) => {
|
|
180
|
+
// The exact instance key: the declaration may not be re-resolved to another instance.
|
|
181
|
+
await ctx.workflow.setEnabled(plan.instanceKey, enabled);
|
|
182
|
+
return { plugin };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** The result of an enable / disable that found nothing to change (instance untouched). */
|
|
186
|
+
#unchanged(operation, authorized, plugin, options) {
|
|
187
|
+
const now = (this.#options.now ?? Date.now)();
|
|
188
|
+
const id = randomUUID();
|
|
189
|
+
const state = this.#instanceState();
|
|
190
|
+
const result = Object.freeze({
|
|
191
|
+
id, operation, packages: authorized, ok: true, status: 'succeeded', plugin,
|
|
192
|
+
instance: Object.freeze({ before: state, after: state }), startedAt: now, finishedAt: now,
|
|
193
|
+
});
|
|
194
|
+
for (const event of [{ type: 'start', id, operation, packages: authorized }, { type: 'phase', id, phase: 'done' }, { type: 'result', id, result }]) {
|
|
195
|
+
for (const listener of [options.onEvent, ...this.#listeners]) {
|
|
196
|
+
try {
|
|
197
|
+
listener?.(event);
|
|
198
|
+
}
|
|
199
|
+
catch { /* listeners never break a request */ }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
148
204
|
uninstall(packages, options) {
|
|
149
205
|
return this.#transaction('uninstall', packages, options, async (ctx, authorized) => {
|
|
150
206
|
for (const item of authorized) {
|
|
@@ -229,7 +285,7 @@ export class GatewayPackageService {
|
|
|
229
285
|
// Rollback log tails only show what this transaction's instances printed.
|
|
230
286
|
const logMark = instance?.logs?.tail(1).at(-1)?.seq ?? -1;
|
|
231
287
|
// A declaration only writes the configuration file: no package manager, no reinstall on rollback.
|
|
232
|
-
const configOnly = operation
|
|
288
|
+
const configOnly = CONFIG_ONLY_OPERATIONS.has(operation);
|
|
233
289
|
let extras = undefined;
|
|
234
290
|
let snapshot;
|
|
235
291
|
let mutated = false;
|
|
@@ -296,6 +352,7 @@ export class GatewayPackageService {
|
|
|
296
352
|
...(rollbackFailure === undefined ? {} : { rollbackError: failureMessage(rollbackFailure) }),
|
|
297
353
|
...(logTail?.length ? { logTail } : {}),
|
|
298
354
|
...(status === 'succeeded' && extras?.needsConfiguration?.length ? { needsConfiguration: extras.needsConfiguration } : {}),
|
|
355
|
+
...(status === 'succeeded' && extras?.plugin ? { plugin: extras.plugin } : {}),
|
|
299
356
|
instance: Object.freeze({ before, after: this.#instanceState() }),
|
|
300
357
|
startedAt,
|
|
301
358
|
finishedAt: now(),
|
|
@@ -424,10 +481,16 @@ function declarationError(error) {
|
|
|
424
481
|
? new GatewayError('invalid_input', error.message, { cause: error, details: { errors: error.errors, missingEnv: error.missingEnv } })
|
|
425
482
|
: error;
|
|
426
483
|
}
|
|
484
|
+
/** A refused enable / disable is the caller's input problem; anything else stays internal. */
|
|
485
|
+
function enablementError(error) {
|
|
486
|
+
return error instanceof PluginEnablementError
|
|
487
|
+
? new GatewayError('invalid_input', error.message, { cause: error, details: { errors: error.errors, missingEnv: error.missingEnv } })
|
|
488
|
+
: error;
|
|
489
|
+
}
|
|
427
490
|
/** Structured feedback of a declaration refused inside the transaction (config changed after preflight). */
|
|
428
491
|
function declarationDetails(error) {
|
|
429
492
|
const cause = error instanceof PluginManagementOperationError ? error.cause : error;
|
|
430
|
-
if (!(cause instanceof PluginDeclarationError))
|
|
493
|
+
if (!(cause instanceof PluginDeclarationError) && !(cause instanceof PluginEnablementError))
|
|
431
494
|
return {};
|
|
432
495
|
return {
|
|
433
496
|
...(cause.errors?.length ? { errors: cause.errors } : {}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
-
import { parsePluginConfigSchema } from '@zhin.js/runtime';
|
|
2
|
+
import { isSecretConfigSchema, parsePluginConfigSchema } from '@zhin.js/runtime';
|
|
3
3
|
import { readStringLiteralOptions } from '../config/console-schema.js';
|
|
4
4
|
const complexKeywords = ['$ref', '$dynamicRef', 'anyOf', 'oneOf', 'allOf', 'not', 'if', 'then', 'else', 'dependentSchemas', 'dependentRequired', 'patternProperties', 'prefixItems', 'unevaluatedProperties'];
|
|
5
5
|
const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
|
@@ -28,7 +28,7 @@ export function explainConfigFields(schema, input, current, committedInput) {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
const metadata = record(node['x-zhin-config']) ? node['x-zhin-config'] : {};
|
|
31
|
-
const secret = inheritedSecret || node
|
|
31
|
+
const secret = inheritedSecret || isSecretConfigSchema(node) || metadata.hidden === true;
|
|
32
32
|
if (node.type === 'object') {
|
|
33
33
|
if (node.properties !== undefined && !record(node.properties)) {
|
|
34
34
|
issue = 'complex-schema';
|
|
@@ -54,7 +54,7 @@ export function explainConfigFields(schema, input, current, committedInput) {
|
|
|
54
54
|
}
|
|
55
55
|
const items = list && record(node.items) ? node.items : {};
|
|
56
56
|
const itemMetadata = record(items['x-zhin-config']) ? items['x-zhin-config'] : {};
|
|
57
|
-
const itemSecret = items
|
|
57
|
+
const itemSecret = isSecretConfigSchema(items) || itemMetadata.hidden === true;
|
|
58
58
|
fields.push({ path: Object.freeze([...path]), secret: secret || itemSecret, list });
|
|
59
59
|
};
|
|
60
60
|
visit(schema, [], false);
|
|
@@ -33,6 +33,36 @@ export declare class PluginDeclarationError extends Error {
|
|
|
33
33
|
message: string;
|
|
34
34
|
}[], missingEnv?: readonly string[]);
|
|
35
35
|
}
|
|
36
|
+
/** Why an enable / disable request was refused before anything was written. */
|
|
37
|
+
export type PluginEnablementErrorCode = 'invalid-request' | 'reserved' | 'root' | 'not-declared' | 'not-installed' | 'invalid-config';
|
|
38
|
+
/** An enable / disable refused before anything was written (invalid target or configuration). */
|
|
39
|
+
export declare class PluginEnablementError extends Error {
|
|
40
|
+
readonly code: PluginEnablementErrorCode;
|
|
41
|
+
readonly errors: readonly {
|
|
42
|
+
readonly path: string;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
}[];
|
|
45
|
+
readonly missingEnv: readonly string[];
|
|
46
|
+
constructor(code: PluginEnablementErrorCode, message: string, errors?: readonly {
|
|
47
|
+
path: string;
|
|
48
|
+
message: string;
|
|
49
|
+
}[], missingEnv?: readonly string[]);
|
|
50
|
+
}
|
|
51
|
+
/** What `setEnabled` would write to `disable_plugins`; changes nothing. */
|
|
52
|
+
export interface PluginEnablementPlan {
|
|
53
|
+
readonly packageName: string;
|
|
54
|
+
readonly instanceKey: string;
|
|
55
|
+
/** The requested state. */
|
|
56
|
+
readonly enabled: boolean;
|
|
57
|
+
/** False when the instance already is in the requested state: nothing would be written. */
|
|
58
|
+
readonly changed: boolean;
|
|
59
|
+
/** `disable_plugins` after the change. */
|
|
60
|
+
readonly disabled: readonly string[];
|
|
61
|
+
readonly projectRevision: string;
|
|
62
|
+
readonly configurationRevision: string;
|
|
63
|
+
/** A running instance must restart to reflect the (current or requested) state. */
|
|
64
|
+
readonly restartRequired: boolean;
|
|
65
|
+
}
|
|
36
66
|
/** One installation authority for CLI and Console. No generation or package parser of its own. */
|
|
37
67
|
export declare class PluginManagementWorkflow implements PluginManagementPort {
|
|
38
68
|
#private;
|
|
@@ -64,6 +94,20 @@ export declare class PluginManagementWorkflow implements PluginManagementPort {
|
|
|
64
94
|
restartRequired: boolean;
|
|
65
95
|
state: PluginManagementState;
|
|
66
96
|
}>>;
|
|
97
|
+
/**
|
|
98
|
+
* Validates enabling / disabling one declared instance (an instance key, or a package name
|
|
99
|
+
* declared exactly once) without writing. Enabling needs an installed plugin package whose
|
|
100
|
+
* dependencies and configuration pass; disabling also accepts a declared package that is no
|
|
101
|
+
* longer installed, so a broken instance can be taken out of the next generation.
|
|
102
|
+
*/
|
|
103
|
+
planSetEnabled(request: string, enabled: boolean): Promise<PluginEnablementPlan>;
|
|
104
|
+
/** Writes `disable_plugins` in one atomic config write; already in the requested state → nothing written. */
|
|
105
|
+
setEnabled(request: string, enabled: boolean, expectedRevision?: string, expectedProjectRevision?: string): Promise<Readonly<{
|
|
106
|
+
plan: PluginEnablementPlan;
|
|
107
|
+
changed: boolean;
|
|
108
|
+
restartRequired: boolean;
|
|
109
|
+
state: PluginManagementState;
|
|
110
|
+
}>>;
|
|
67
111
|
planUninstall(request: string): Promise<PluginUninstallPlan>;
|
|
68
112
|
uninstall(request: string, expectedRevision?: string, expectedProjectRevision?: string, options?: {
|
|
69
113
|
removeDependency?: boolean;
|