@zhin.js/gateway 1.3.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/lib/config/configuration.d.ts +16 -3
- package/lib/config/configuration.js +88 -18
- 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 +2 -0
- package/lib/config/validation-messages.js +7 -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/gateway-console.d.ts +5 -0
- package/lib/http/gateway-console.js +25 -0
- package/lib/http/gateway-http-server.d.ts +2 -0
- package/lib/http/gateway-http-server.js +3 -0
- package/lib/http/local-principal.d.ts +1 -1
- package/lib/http/local-principal.js +3 -2
- package/lib/http/plugin-switch-rpc.js +1 -1
- 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/service.js +52 -5
- package/lib/plugin-management/plugin-candidates.d.ts +9 -0
- package/lib/plugin-management/plugin-candidates.js +55 -0
- package/lib/plugin-management/workflow.d.ts +5 -0
- package/lib/plugin-management/workflow.js +18 -3
- 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/package.json +7 -7
|
@@ -86,13 +86,38 @@ export async function startGatewayConsole(options) {
|
|
|
86
86
|
iam,
|
|
87
87
|
logger: { warn: (message) => logger.warn(message), info: (message) => logger.info(message) },
|
|
88
88
|
});
|
|
89
|
+
// ADR 0060 §2.4: a deleted / disabled Console user's chat person link is removed by the
|
|
90
|
+
// instance on `principal-revoked`. Those two reasons are kept while no instance is attached
|
|
91
|
+
// and sent once one reports ready, so a link never outlives its user because the instance was
|
|
92
|
+
// down (bounded; in memory only: a gateway restart forgets them).
|
|
93
|
+
const undelivered = new Map();
|
|
89
94
|
const revoke = (userId, reason) => {
|
|
90
95
|
terminal.terminatePrincipal(`user:${userId}`, 'revoked');
|
|
96
|
+
const ending = reason === 'deleted' || reason === 'disabled';
|
|
97
|
+
if (ending && options.instance?.ready === false) {
|
|
98
|
+
undelivered.delete(userId);
|
|
99
|
+
undelivered.set(userId, reason);
|
|
100
|
+
if (undelivered.size > 1024)
|
|
101
|
+
undelivered.delete(undelivered.keys().next().value);
|
|
102
|
+
}
|
|
91
103
|
try {
|
|
92
104
|
options.instance?.revokePrincipal?.(userId, reason);
|
|
93
105
|
}
|
|
94
106
|
catch { /* bridge gone */ }
|
|
95
107
|
};
|
|
108
|
+
const stopUndelivered = options.instance?.onRoutes?.(() => {
|
|
109
|
+
if (options.instance?.ready !== true)
|
|
110
|
+
return;
|
|
111
|
+
for (const [userId, reason] of [...undelivered]) {
|
|
112
|
+
undelivered.delete(userId);
|
|
113
|
+
try {
|
|
114
|
+
options.instance?.revokePrincipal?.(userId, reason);
|
|
115
|
+
}
|
|
116
|
+
catch { /* bridge gone */ }
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
if (stopUndelivered)
|
|
120
|
+
disposers.push(stopUndelivered);
|
|
96
121
|
const projectDatabase = async () => options.database
|
|
97
122
|
?? resolveProjectDatabaseConfig(projectRoot, await loadExpandedProjectConfig(projectRoot, environment));
|
|
98
123
|
async function connect() {
|
|
@@ -43,6 +43,8 @@ export interface GatewayHttpServerOptions {
|
|
|
43
43
|
* RPC type on that route is forwarded to the instance unchanged.
|
|
44
44
|
*/
|
|
45
45
|
readonly consoleRpc?: GatewayConsoleRpc;
|
|
46
|
+
/** Reported on `/pub/health` as `project.id` (`projectIdentity(projectRoot)`). */
|
|
47
|
+
readonly projectId?: string;
|
|
46
48
|
}
|
|
47
49
|
/** One router, many listeners: every listener serves the same surface. */
|
|
48
50
|
export declare class GatewayHttpServer {
|
|
@@ -58,6 +58,7 @@ export class GatewayHttpServer {
|
|
|
58
58
|
#consoleRpc;
|
|
59
59
|
/** The instance connection (and route table) that has no capability route: asked once. */
|
|
60
60
|
#capabilitiesMissing;
|
|
61
|
+
#projectId;
|
|
61
62
|
#rpcHandlers = new Set();
|
|
62
63
|
#closed = false;
|
|
63
64
|
#closeResult;
|
|
@@ -68,6 +69,7 @@ export class GatewayHttpServer {
|
|
|
68
69
|
this.#retryAfter = String(Math.max(1, Math.round(options.retryAfterSeconds ?? DEFAULT_GATEWAY_RETRY_AFTER_SECONDS)));
|
|
69
70
|
this.#maxBody = options.maxRequestBodyBytes ?? DEFAULT_GATEWAY_MAX_REQUEST_BODY_BYTES;
|
|
70
71
|
this.#consoleRpc = options.consoleRpc;
|
|
72
|
+
this.#projectId = options.projectId;
|
|
71
73
|
this.tokenRegistry = options.tokenRegistry ?? new TokenRegistry({ primaryToken: primary.token });
|
|
72
74
|
this.apiBase = normalizePath(primary.apiBase ?? '/api');
|
|
73
75
|
const routerOptions = {
|
|
@@ -185,6 +187,7 @@ export class GatewayHttpServer {
|
|
|
185
187
|
status: 'ok',
|
|
186
188
|
timestamp: new Date().toISOString(),
|
|
187
189
|
instance: { state: this.#instance.state(), ready: this.#readyTransport() !== undefined },
|
|
190
|
+
...(this.#projectId ? { project: { id: this.#projectId } } : {}),
|
|
188
191
|
};
|
|
189
192
|
// Polled by the Console's instance banner: never cached.
|
|
190
193
|
return jsonResponse(200, body, { 'cache-control': 'no-store' });
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ConsoleUserPrincipal } from '@zhin.js/host-http';
|
|
2
2
|
export declare const LOCAL_OWNER_PRINCIPAL: ConsoleUserPrincipal;
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* entries name it `user:local`.
|
|
6
6
|
*/
|
|
7
7
|
import { CONSOLE_PERMISSION_LIST } from '@zhin.js/console-protocol';
|
|
8
|
+
import { LOCAL_SOCKET_CREDENTIAL_ID, LOCAL_SOCKET_USER_ID } from '@zhin.js/host-http';
|
|
8
9
|
export const LOCAL_OWNER_PRINCIPAL = Object.freeze({
|
|
9
10
|
kind: 'user',
|
|
10
|
-
userId:
|
|
11
|
+
userId: LOCAL_SOCKET_USER_ID,
|
|
11
12
|
username: 'local',
|
|
12
13
|
displayName: '本机 CLI(网关控制套接字)',
|
|
13
|
-
credentialId:
|
|
14
|
+
credentialId: LOCAL_SOCKET_CREDENTIAL_ID,
|
|
14
15
|
grants: Object.freeze([Object.freeze({ role: 'owner', scope: Object.freeze({ kind: 'global' }), permissions: CONSOLE_PERMISSION_LIST })]),
|
|
15
16
|
roles: Object.freeze(['owner']),
|
|
16
17
|
});
|
|
@@ -177,7 +177,7 @@ async function runSwitch(plugin, enabled, principal, options, type) {
|
|
|
177
177
|
return { result };
|
|
178
178
|
const action = enabled ? '启用' : '停用';
|
|
179
179
|
const text = result.status === 'rollbackFailed'
|
|
180
|
-
? `${action} ${plugin} 失败,且回滚未完成:${result.rollbackError
|
|
180
|
+
? `${action} ${plugin} 失败,且回滚未完成:${[result.rollbackError, result.restartError ? `重新启动实例失败:${result.restartError}` : undefined].filter(Boolean).join(';') || '未知错误'}(原因:${result.error ?? '未知错误'})`
|
|
181
181
|
: `${action} ${plugin} 失败,已回滚:${result.error ?? '未知错误'}`;
|
|
182
182
|
return {
|
|
183
183
|
reply: error(500, result.status === 'rollbackFailed' ? 'rollback_failed' : 'transaction_failed', text, type, {
|
|
@@ -14,7 +14,7 @@ import { GatewayHttpServer } from './gateway-http-server.js';
|
|
|
14
14
|
* optional {@link GatewayConsoleInstancePort} methods (plugin catalog, instance audit,
|
|
15
15
|
* `principal-revoked`) when the transport has them (`IpcInstanceTransport` does).
|
|
16
16
|
*/
|
|
17
|
-
export interface GatewayInstanceTransport extends InstanceTransport, GatewayConsoleInstancePort {
|
|
17
|
+
export interface GatewayInstanceTransport extends InstanceTransport, Omit<GatewayConsoleInstancePort, 'ready' | 'onRoutes'> {
|
|
18
18
|
dispose?(): void | Promise<void>;
|
|
19
19
|
/** Told the bound listener addresses after listen (the instance's `HttpHost.address`). */
|
|
20
20
|
setAddresses?(addresses: readonly import('@zhin.js/host-http').HttpHostAddress[]): void;
|
|
@@ -7,6 +7,7 @@ import { join } from 'node:path';
|
|
|
7
7
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
8
|
import { createConfigDocument } from '@zhin.js/runtime';
|
|
9
9
|
import { findConfigFile } from '../project/config-file.js';
|
|
10
|
+
import { projectIdentity } from '../project/identity.js';
|
|
10
11
|
import { loadRuntimeEnvironmentLayers } from '../project/environment.js';
|
|
11
12
|
import { InstanceSupervisor } from '../supervisor/instance-supervisor.js';
|
|
12
13
|
import { GatewayPackageService } from '../packages/service.js';
|
|
@@ -113,6 +114,7 @@ export async function startGateway(options) {
|
|
|
113
114
|
...(options.retryAfterSeconds !== undefined ? { retryAfterSeconds: options.retryAfterSeconds } : {}),
|
|
114
115
|
...(options.maxRequestBodyBytes !== undefined ? { maxRequestBodyBytes: options.maxRequestBodyBytes } : {}),
|
|
115
116
|
consoleRpc: createGatewayConsoleRpc(rpc),
|
|
117
|
+
projectId: projectIdentity(options.projectRoot),
|
|
116
118
|
});
|
|
117
119
|
registry = server.tokenRegistry;
|
|
118
120
|
// The Console's global plugin switch is a gateway transaction (stop → write → start, rollback);
|
package/lib/iam/service.js
CHANGED
|
@@ -460,9 +460,18 @@ export class ConsoleIamService {
|
|
|
460
460
|
row.permissions = JSON.stringify(permissionList(patch.permissions));
|
|
461
461
|
if (Object.keys(row).length)
|
|
462
462
|
await this.#model('roles').update(row).where({ id });
|
|
463
|
+
// Grants of every holder may have shrunk: their long-lived connections and anything the
|
|
464
|
+
// instance derived from their grants (open chat bind codes) re-authorize from scratch.
|
|
465
|
+
if (patch.permissions !== undefined)
|
|
466
|
+
for (const userId of await this.#usersBoundTo(id))
|
|
467
|
+
this.#emitRevocation(userId, 'bindings');
|
|
463
468
|
return withPluginPermissions((await this.#role(id)), this.pluginPermissionCatalog());
|
|
464
469
|
});
|
|
465
470
|
}
|
|
471
|
+
async #usersBoundTo(roleId) {
|
|
472
|
+
const rows = await this.#model('bindings').select('user_id').where({ role_id: roleId });
|
|
473
|
+
return [...new Set(rows.map(row => String(row.user_id)))];
|
|
474
|
+
}
|
|
466
475
|
deleteRole(id) {
|
|
467
476
|
return this.#serialize(async () => {
|
|
468
477
|
const role = await this.#role(id);
|
|
@@ -470,8 +479,11 @@ export class ConsoleIamService {
|
|
|
470
479
|
throw new IamError('not_found', '角色不存在');
|
|
471
480
|
if (role.builtin)
|
|
472
481
|
throw new IamError('forbidden', '内置角色不可删除');
|
|
482
|
+
const holders = await this.#usersBoundTo(id);
|
|
473
483
|
await this.#model('bindings').delete().where({ role_id: id });
|
|
474
484
|
await this.#model('roles').delete().where({ id });
|
|
485
|
+
for (const userId of holders)
|
|
486
|
+
this.#emitRevocation(userId, 'bindings');
|
|
475
487
|
});
|
|
476
488
|
}
|
|
477
489
|
/** Replaces all bindings of a user. */
|
|
@@ -37,7 +37,9 @@ export interface SetupDatabaseInput {
|
|
|
37
37
|
/** Config-file and dotenv access the setup flow needs (ConsoleConfigurationStore satisfies `config`). */
|
|
38
38
|
export interface SetupConfigPort {
|
|
39
39
|
readDocument(): Promise<Record<string, unknown>>;
|
|
40
|
-
setKey(key: string, value: unknown
|
|
40
|
+
setKey(key: string, value: unknown, options?: {
|
|
41
|
+
readonly environmentWrite?: boolean;
|
|
42
|
+
}): Promise<unknown>;
|
|
41
43
|
}
|
|
42
44
|
export interface SetupEnvFilePort {
|
|
43
45
|
read(): Promise<string>;
|
package/lib/iam/setup-service.js
CHANGED
|
@@ -101,7 +101,8 @@ export class ConsoleSetupService {
|
|
|
101
101
|
if (Object.keys(envUpdates).length) {
|
|
102
102
|
await this.#options.envFile.write(upsertDotenv(await this.#options.envFile.read(), envUpdates));
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
// The setup flow writes `.env` itself (bootstrap / owner): secret literals may go there too.
|
|
105
|
+
await this.#options.config.setKey('database', block, { environmentWrite: true });
|
|
105
106
|
const notice = prepared.dialect === 'pg' || prepared.dialect === 'mysql' ? `${SETUP_NO_MIGRATION_NOTICE}${SETUP_CONSOLE_ROLE_NOTICE}` : SETUP_NO_MIGRATION_NOTICE;
|
|
106
107
|
return Object.freeze({ restartRequired: true, notice });
|
|
107
108
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const PNPM_IGNORED_BUILDS_CODE = "ERR_PNPM_IGNORED_BUILDS";
|
|
2
|
+
/** Cap on the package-manager output kept to recognise the error (its summary comes last). */
|
|
3
|
+
export declare const IGNORED_BUILDS_OUTPUT_LIMIT: number;
|
|
4
|
+
/**
|
|
5
|
+
* The packages pnpm reports in `Ignored build scripts: a@1.0.0, @scope/b@2.0.0.`; `undefined` when
|
|
6
|
+
* the output does not carry `ERR_PNPM_IGNORED_BUILDS` (an empty list when the list is unreadable).
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseIgnoredBuilds(output: string): readonly string[] | undefined;
|
|
9
|
+
/** Package names named by an `add` invocation (`add [-w] [-D] <spec>…`); empty for other commands. */
|
|
10
|
+
export declare function addedPackageNames(args: readonly string[]): readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* Whether the dependencies of `args` are in place after pnpm reported ignored builds: pnpm wrote
|
|
13
|
+
* `node_modules/.modules.yaml` (it does so after linking) and every package an `add` named is in
|
|
14
|
+
* `node_modules`. Only then is the exit treated as a warning.
|
|
15
|
+
*/
|
|
16
|
+
export declare function dependenciesInPlace(projectRoot: string, args: readonly string[]): Promise<boolean>;
|
|
17
|
+
/** The hint printed for ignored builds: same allowance the scaffold writes for native packages. */
|
|
18
|
+
export declare function ignoredBuildsHint(names: readonly string[]): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pnpm 10+ skips dependency build scripts that the project did not allow; pnpm 11 (and pnpm 10
|
|
3
|
+
* with `strictDepBuilds`) then exits 1 with `ERR_PNPM_IGNORED_BUILDS` although every dependency
|
|
4
|
+
* was resolved, fetched and linked. That exit is not a failed install: the packages are in place,
|
|
5
|
+
* only their build scripts did not run (#1250). These helpers tell the two apart.
|
|
6
|
+
*/
|
|
7
|
+
import { access } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
export const PNPM_IGNORED_BUILDS_CODE = 'ERR_PNPM_IGNORED_BUILDS';
|
|
10
|
+
/** Cap on the package-manager output kept to recognise the error (its summary comes last). */
|
|
11
|
+
export const IGNORED_BUILDS_OUTPUT_LIMIT = 64 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* The packages pnpm reports in `Ignored build scripts: a@1.0.0, @scope/b@2.0.0.`; `undefined` when
|
|
14
|
+
* the output does not carry `ERR_PNPM_IGNORED_BUILDS` (an empty list when the list is unreadable).
|
|
15
|
+
*/
|
|
16
|
+
export function parseIgnoredBuilds(output) {
|
|
17
|
+
if (!output.includes(PNPM_IGNORED_BUILDS_CODE))
|
|
18
|
+
return undefined;
|
|
19
|
+
const names = new Set();
|
|
20
|
+
for (const match of output.matchAll(/Ignored build scripts:\s*([^\n]*)/gu)) {
|
|
21
|
+
for (const raw of match[1].split(',')) {
|
|
22
|
+
const item = raw.replace(/[│|]/gu, '').trim().replace(/\.$/u, '').trim();
|
|
23
|
+
const at = item.lastIndexOf('@');
|
|
24
|
+
const name = at > 0 ? item.slice(0, at) : item;
|
|
25
|
+
if (/^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/iu.test(name))
|
|
26
|
+
names.add(name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Object.freeze([...names]);
|
|
30
|
+
}
|
|
31
|
+
/** Package names named by an `add` invocation (`add [-w] [-D] <spec>…`); empty for other commands. */
|
|
32
|
+
export function addedPackageNames(args) {
|
|
33
|
+
if (args[0] !== 'add')
|
|
34
|
+
return [];
|
|
35
|
+
return args.slice(1).filter(arg => !arg.startsWith('-')).map((spec) => {
|
|
36
|
+
const bare = spec.replace(/^npm:/u, '');
|
|
37
|
+
const at = bare.indexOf('@', 1);
|
|
38
|
+
return at > 0 ? bare.slice(0, at) : bare;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const exists = (path) => access(path).then(() => true, () => false);
|
|
42
|
+
/**
|
|
43
|
+
* Whether the dependencies of `args` are in place after pnpm reported ignored builds: pnpm wrote
|
|
44
|
+
* `node_modules/.modules.yaml` (it does so after linking) and every package an `add` named is in
|
|
45
|
+
* `node_modules`. Only then is the exit treated as a warning.
|
|
46
|
+
*/
|
|
47
|
+
export async function dependenciesInPlace(projectRoot, args) {
|
|
48
|
+
if (!await exists(join(projectRoot, 'node_modules', '.modules.yaml')))
|
|
49
|
+
return false;
|
|
50
|
+
for (const name of addedPackageNames(args)) {
|
|
51
|
+
if (!await exists(join(projectRoot, 'node_modules', ...name.split('/'), 'package.json')))
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
/** The hint printed for ignored builds: same allowance the scaffold writes for native packages. */
|
|
57
|
+
export function ignoredBuildsHint(names) {
|
|
58
|
+
const list = names.length ? names.join('、') : '部分依赖';
|
|
59
|
+
const example = names[0] ?? '<包名>';
|
|
60
|
+
return `pnpm 跳过了 ${list} 的构建脚本(${PNPM_IGNORED_BUILDS_CODE}),依赖本身已安装。`
|
|
61
|
+
+ `如需运行它们的构建脚本:在 pnpm-workspace.yaml 写 allowBuilds:\n ${example}: true\n`
|
|
62
|
+
+ `(pnpm < 10.26 改为 onlyBuiltDependencies 列表),再运行 pnpm rebuild ${names.join(' ') || example};不需要构建产物的包可以不处理。`;
|
|
63
|
+
}
|
package/lib/onboarding/index.js
CHANGED
package/lib/packages/service.js
CHANGED
|
@@ -10,6 +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 { dependenciesInPlace, IGNORED_BUILDS_OUTPUT_LIMIT, parseIgnoredBuilds } from '../onboarding/ignored-builds.js';
|
|
13
14
|
import { PluginDeclarationError, PluginEnablementError, PluginManagementOperationError, PluginManagementWorkflow } from '../plugin-management/workflow.js';
|
|
14
15
|
import { InstanceStartError } from '../supervisor/instance-supervisor.js';
|
|
15
16
|
import { authorizePackages, gatewayPrincipalAllows } from './authorization.js';
|
|
@@ -39,16 +40,46 @@ export class GatewayPackageService {
|
|
|
39
40
|
#packageManager;
|
|
40
41
|
#workflow;
|
|
41
42
|
#active;
|
|
43
|
+
/** Packages whose build scripts pnpm skipped during the running transaction (#1250). */
|
|
44
|
+
#ignoredBuilds;
|
|
42
45
|
constructor(options) {
|
|
43
46
|
this.#root = resolve(options.projectRoot);
|
|
44
47
|
this.#options = options;
|
|
45
48
|
const streaming = createStreamingPackageManager(this.#relay.output);
|
|
46
|
-
this.#packageManager = options.packageManager ?? ((args, root) => streaming(args, root));
|
|
49
|
+
this.#packageManager = this.#tolerateIgnoredBuilds(options.packageManager ?? ((args, root) => streaming(args, root)));
|
|
47
50
|
this.#workflow = options.workflow ?? new PluginManagementWorkflow(this.#root, {
|
|
48
51
|
...options.workflowOptions,
|
|
49
52
|
installDependencies: (args, root) => this.#packageManager(args, root, this.#relay.output),
|
|
50
53
|
});
|
|
51
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* pnpm 11 exits 1 with `ERR_PNPM_IGNORED_BUILDS` after a complete install whose dependency build
|
|
57
|
+
* scripts were not allowed. With the dependencies in place that exit is recorded (reported as
|
|
58
|
+
* `ignoredBuilds` with an allowBuilds hint) instead of failing — and rolling back — the transaction.
|
|
59
|
+
*/
|
|
60
|
+
#tolerateIgnoredBuilds(manager) {
|
|
61
|
+
return async (args, root, output) => {
|
|
62
|
+
let captured = '';
|
|
63
|
+
const tee = (stream, chunk) => {
|
|
64
|
+
captured = (captured + chunk).slice(-IGNORED_BUILDS_OUTPUT_LIMIT);
|
|
65
|
+
output(stream, chunk);
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
await manager(args, root, tee);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const detail = error && typeof error === 'object'
|
|
72
|
+
? ['message', 'stdout', 'stderr'].map(key => Reflect.get(error, key)).filter((value) => typeof value === 'string').join('\n')
|
|
73
|
+
: String(error);
|
|
74
|
+
const ignored = parseIgnoredBuilds(`${captured}\n${detail}`);
|
|
75
|
+
if (!ignored || !await dependenciesInPlace(root, args))
|
|
76
|
+
throw error;
|
|
77
|
+
this.#ignoredBuilds ??= new Set();
|
|
78
|
+
for (const name of ignored)
|
|
79
|
+
this.#ignoredBuilds.add(name);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
52
83
|
/** Id of the running transaction, if any. */
|
|
53
84
|
get activeTransaction() { return this.#active; }
|
|
54
85
|
/** Progress of every transaction (for `gateway.packages.progress`); returns an unsubscribe function. */
|
|
@@ -298,7 +329,9 @@ export class GatewayPackageService {
|
|
|
298
329
|
let failedPhase;
|
|
299
330
|
let failure;
|
|
300
331
|
let rollbackFailure;
|
|
332
|
+
let restartFailure;
|
|
301
333
|
let logTail;
|
|
334
|
+
this.#ignoredBuilds = undefined;
|
|
302
335
|
emit({ type: 'start', id, operation, packages: authorized });
|
|
303
336
|
try {
|
|
304
337
|
try {
|
|
@@ -335,17 +368,29 @@ export class GatewayPackageService {
|
|
|
335
368
|
await run(snapshot.hasLockfile ? ['install', '--frozen-lockfile'] : ['install']);
|
|
336
369
|
}
|
|
337
370
|
}
|
|
338
|
-
if (wasRunning) {
|
|
339
|
-
enter('restart-instance');
|
|
340
|
-
await this.#startInstance(instance, logMark);
|
|
341
|
-
}
|
|
342
371
|
}
|
|
343
372
|
catch (error) {
|
|
344
373
|
status = 'rollbackFailed';
|
|
345
374
|
rollbackFailure = error;
|
|
346
375
|
}
|
|
376
|
+
// A running instance is brought back even when restoring the files or dependencies failed
|
|
377
|
+
// (#1250): leaving the bot stopped is worse than starting it on what is on disk. A failed
|
|
378
|
+
// start is reported on its own.
|
|
379
|
+
if (wasRunning) {
|
|
380
|
+
enter('restart-instance');
|
|
381
|
+
try {
|
|
382
|
+
await this.#startInstance(instance, logMark);
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
status = 'rollbackFailed';
|
|
386
|
+
restartFailure = error;
|
|
387
|
+
if (error instanceof InstanceStartFailure && !logTail?.length)
|
|
388
|
+
logTail = error.logTail;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
347
391
|
}
|
|
348
392
|
enter('done');
|
|
393
|
+
const ignoredBuilds = this.#ignoredBuilds ? Object.freeze([...this.#ignoredBuilds]) : undefined;
|
|
349
394
|
const result = Object.freeze({
|
|
350
395
|
id,
|
|
351
396
|
operation,
|
|
@@ -355,6 +400,8 @@ export class GatewayPackageService {
|
|
|
355
400
|
...(failedPhase ? { failedPhase } : {}),
|
|
356
401
|
...(failure === undefined ? {} : { error: failureMessage(failure), ...declarationDetails(failure) }),
|
|
357
402
|
...(rollbackFailure === undefined ? {} : { rollbackError: failureMessage(rollbackFailure) }),
|
|
403
|
+
...(restartFailure === undefined ? {} : { restartError: failureMessage(restartFailure) }),
|
|
404
|
+
...(ignoredBuilds ? { ignoredBuilds } : {}),
|
|
358
405
|
...(logTail?.length ? { logTail } : {}),
|
|
359
406
|
...(status === 'succeeded' && extras?.needsConfiguration?.length ? { needsConfiguration: extras.needsConfiguration } : {}),
|
|
360
407
|
...(status === 'succeeded' && extras?.plugin ? { plugin: extras.plugin } : {}),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The offline official catalog: catalog adapters, use-case plugins and Market entries. */
|
|
2
|
+
export declare function officialPluginCatalog(): readonly string[];
|
|
3
|
+
/**
|
|
4
|
+
* Official package names matching `request` (same name once the `@zhin.js/` scope and the
|
|
5
|
+
* `plugin-` / `adapter-` / `service-` / `game-` prefix are dropped). Empty when nothing matches.
|
|
6
|
+
*/
|
|
7
|
+
export declare function officialPluginCandidates(request: string, projectRoot?: string): Promise<readonly string[]>;
|
|
8
|
+
/** The message for an instance that is not declared: install hints only for official candidates. */
|
|
9
|
+
export declare function undeclaredPluginMessage(request: string, candidates: readonly string[]): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official packages a user probably meant by a short or misspelled plugin name (#1250). `zhin enable
|
|
3
|
+
* reminder` must never suggest `zhin install reminder`: that is an unrelated third-party package on
|
|
4
|
+
* npm. Suggestions only come from the official catalog and the project's installed `@zhin.js/*`
|
|
5
|
+
* packages — never from the request itself.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { createInstallAllowlist } from '../onboarding/allowlist.js';
|
|
10
|
+
import { pluginUseCases } from './use-cases.js';
|
|
11
|
+
const OFFICIAL_SCOPE = '@zhin.js/';
|
|
12
|
+
const KIND_PREFIXES = ['plugin-', 'adapter-', 'service-', 'game-'];
|
|
13
|
+
/** `@zhin.js/plugin-reminder`, `plugin-reminder` and `reminder` all reduce to `reminder`. */
|
|
14
|
+
function baseName(name) {
|
|
15
|
+
let base = name.trim().toLowerCase();
|
|
16
|
+
if (base.startsWith(OFFICIAL_SCOPE))
|
|
17
|
+
base = base.slice(OFFICIAL_SCOPE.length);
|
|
18
|
+
for (const prefix of KIND_PREFIXES)
|
|
19
|
+
if (base.startsWith(prefix))
|
|
20
|
+
return base.slice(prefix.length);
|
|
21
|
+
return base;
|
|
22
|
+
}
|
|
23
|
+
/** The offline official catalog: catalog adapters, use-case plugins and Market entries. */
|
|
24
|
+
export function officialPluginCatalog() {
|
|
25
|
+
const names = new Set();
|
|
26
|
+
for (const entry of createInstallAllowlist().entries()) {
|
|
27
|
+
if (entry.kind === 'adapter' && entry.name.startsWith(OFFICIAL_SCOPE))
|
|
28
|
+
names.add(entry.name);
|
|
29
|
+
}
|
|
30
|
+
for (const useCase of pluginUseCases)
|
|
31
|
+
names.add(useCase.packageName);
|
|
32
|
+
return Object.freeze([...names].sort());
|
|
33
|
+
}
|
|
34
|
+
async function installedOfficialPackages(projectRoot) {
|
|
35
|
+
const entries = await readdir(join(projectRoot, 'node_modules', '@zhin.js')).catch(() => []);
|
|
36
|
+
return entries.filter(name => !name.startsWith('.')).map(name => `${OFFICIAL_SCOPE}${name}`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Official package names matching `request` (same name once the `@zhin.js/` scope and the
|
|
40
|
+
* `plugin-` / `adapter-` / `service-` / `game-` prefix are dropped). Empty when nothing matches.
|
|
41
|
+
*/
|
|
42
|
+
export async function officialPluginCandidates(request, projectRoot) {
|
|
43
|
+
const wanted = baseName(request);
|
|
44
|
+
if (!wanted)
|
|
45
|
+
return [];
|
|
46
|
+
const pool = new Set([...officialPluginCatalog(), ...(projectRoot ? await installedOfficialPackages(projectRoot) : [])]);
|
|
47
|
+
return Object.freeze([...pool].filter(name => baseName(name) === wanted).sort());
|
|
48
|
+
}
|
|
49
|
+
/** The message for an instance that is not declared: install hints only for official candidates. */
|
|
50
|
+
export function undeclaredPluginMessage(request, candidates) {
|
|
51
|
+
const head = `找不到插件实例 ${request}(配置中没有声明)`;
|
|
52
|
+
if (!candidates.length)
|
|
53
|
+
return head;
|
|
54
|
+
return `${head}。官方插件中名称相近的有:${candidates.join('、')};如果要用它,运行 ${candidates.map(name => `zhin install ${name}`).join(' 或 ')}`;
|
|
55
|
+
}
|
|
@@ -8,6 +8,11 @@ export interface PluginManagementOptions {
|
|
|
8
8
|
readonly requestRestart?: () => void | Promise<void>;
|
|
9
9
|
/** Real package-manager boundary; tests use independent on-disk packages. */
|
|
10
10
|
readonly installDependencies?: (args: readonly string[], projectRoot: string) => Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* Default package-manager boundary only: pnpm exited with `ERR_PNPM_IGNORED_BUILDS` but the
|
|
13
|
+
* dependencies are in place. Without this callback that exit still fails the operation.
|
|
14
|
+
*/
|
|
15
|
+
readonly onIgnoredBuilds?: (packages: readonly string[]) => void;
|
|
11
16
|
readonly lifecycleFile?: string;
|
|
12
17
|
}
|
|
13
18
|
export interface PluginInstallOptions {
|
|
@@ -9,6 +9,8 @@ import { featureId, pluginVerificationToken, rootPluginId, readPluginConfigurati
|
|
|
9
9
|
import { ConsoleConfigurationStore } from '../config/configuration.js';
|
|
10
10
|
import { explainConfigFields } from './config-explanation.js';
|
|
11
11
|
import { resolveCommand } from '../onboarding/package-manager.js';
|
|
12
|
+
import { dependenciesInPlace, parseIgnoredBuilds } from '../onboarding/ignored-builds.js';
|
|
13
|
+
import { officialPluginCandidates, undeclaredPluginMessage } from './plugin-candidates.js';
|
|
12
14
|
import { inferConventionFeatureReferences, readPluginLifecycleState, resolvePluginLifecycleFile } from '@zhin.js/runtime';
|
|
13
15
|
const execute = promisify(execFile);
|
|
14
16
|
// Narrow local view of the `@zhin.js/adapter` index projection: the gateway must not import IM layers (ADR 0054 §0).
|
|
@@ -451,8 +453,20 @@ export class PluginManagementWorkflow {
|
|
|
451
453
|
else {
|
|
452
454
|
// No shell: on Windows `pnpm.cmd` is resolved and run through `cmd.exe /d /s /c` with quoted arguments.
|
|
453
455
|
const resolved = resolveCommand('pnpm', invocation);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
+
try {
|
|
457
|
+
await execute(resolved.command, [...resolved.args], { cwd: this.#root, maxBuffer: 10 * 1024 * 1024, windowsHide: true,
|
|
458
|
+
...(resolved.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) });
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
// pnpm 11: builds the project did not allow are skipped and pnpm exits 1 after a complete install (#1250).
|
|
462
|
+
const report = this.#options.onIgnoredBuilds;
|
|
463
|
+
const output = ['stdout', 'stderr'].map(key => (error && typeof error === 'object' ? Reflect.get(error, key) : undefined))
|
|
464
|
+
.filter((value) => typeof value === 'string').join('\n');
|
|
465
|
+
const ignored = parseIgnoredBuilds(output);
|
|
466
|
+
if (!report || !ignored || !await dependenciesInPlace(this.#root, invocation))
|
|
467
|
+
throw error;
|
|
468
|
+
report(ignored);
|
|
469
|
+
}
|
|
456
470
|
}
|
|
457
471
|
}
|
|
458
472
|
async install(request, expectedRevision, expectedProjectRevision, options = {}) {
|
|
@@ -625,8 +639,9 @@ export class PluginManagementWorkflow {
|
|
|
625
639
|
catch (error) {
|
|
626
640
|
throw new PluginEnablementError('invalid-request', errorMessage(error), [], []);
|
|
627
641
|
}
|
|
642
|
+
// The fallback target is derived from the request itself: never suggest installing it (#1250).
|
|
628
643
|
if (!declared)
|
|
629
|
-
throw new PluginEnablementError('not-declared',
|
|
644
|
+
throw new PluginEnablementError('not-declared', undeclaredPluginMessage(key, await officialPluginCandidates(key, this.#root)));
|
|
630
645
|
const state = await this.inspect(target.instanceKey);
|
|
631
646
|
if (enabled && (!state.installed || !state.plugin)) {
|
|
632
647
|
throw new PluginEnablementError('not-installed', `${state.packageName} ${state.installed ? `不是可加载的 Plugin 包${state.issues[0] ? `:${state.issues[0]}` : ''}` : '未安装'};先运行 zhin install ${state.packageName}`);
|
package/lib/plugin-management.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stable, non-reversible identifier of a project checkout (#1250). The gateway reports it on
|
|
3
|
+
* `GET /pub/health`; the CLI compares it with the project it runs in before it sends a request —
|
|
4
|
+
* and a Console token — to a gateway on this machine, so a command run in project B never lands
|
|
5
|
+
* on project A's gateway that happens to listen on the same port.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { realpathSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
/** 32 hex characters of SHA-256 over the canonical project root path. */
|
|
11
|
+
export function projectIdentity(projectRoot) {
|
|
12
|
+
let root = resolve(projectRoot);
|
|
13
|
+
try {
|
|
14
|
+
root = realpathSync.native(root);
|
|
15
|
+
}
|
|
16
|
+
catch { /* keep the resolved path */ }
|
|
17
|
+
// Windows paths are case-insensitive.
|
|
18
|
+
if (process.platform === 'win32')
|
|
19
|
+
root = root.toLowerCase();
|
|
20
|
+
return createHash('sha256').update(`zhin-project:${root}`).digest('hex').slice(0, 32);
|
|
21
|
+
}
|
package/lib/project.d.ts
CHANGED
package/lib/project.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/gateway",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Zhin.js management gateway: Console access control, terminal, project configuration and instance supervision (ADR 0054)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -72,13 +72,13 @@
|
|
|
72
72
|
"fs-extra": "^11.4.0",
|
|
73
73
|
"tsx": "^4.23.1",
|
|
74
74
|
"yaml": "^2.9.0",
|
|
75
|
-
"@zhin.js/console-protocol": "1.
|
|
76
|
-
"@zhin.js/database": "1.2.
|
|
77
|
-
"@zhin.js/host-http": "1.
|
|
78
|
-
"@zhin.js/logger": "1.
|
|
75
|
+
"@zhin.js/console-protocol": "1.4.0",
|
|
76
|
+
"@zhin.js/database": "1.2.1",
|
|
77
|
+
"@zhin.js/host-http": "1.4.0",
|
|
78
|
+
"@zhin.js/logger": "1.2.0",
|
|
79
79
|
"@zhin.js/plugin-runtime": "1.3.0",
|
|
80
|
-
"@zhin.js/runtime": "1.
|
|
81
|
-
"@zhin.js/scaffold-wizard": "1.
|
|
80
|
+
"@zhin.js/runtime": "1.4.0",
|
|
81
|
+
"@zhin.js/scaffold-wizard": "1.3.0"
|
|
82
82
|
},
|
|
83
83
|
"optionalDependencies": {
|
|
84
84
|
"node-pty": "^1.0.0",
|