@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.
Files changed (41) hide show
  1. package/lib/config/configuration.d.ts +16 -3
  2. package/lib/config/configuration.js +88 -18
  3. package/lib/config/literal-secrets.d.ts +3 -0
  4. package/lib/config/literal-secrets.js +4 -1
  5. package/lib/config/patch-path-messages.d.ts +15 -0
  6. package/lib/config/patch-path-messages.js +71 -0
  7. package/lib/config/secret-storage.d.ts +42 -0
  8. package/lib/config/secret-storage.js +140 -0
  9. package/lib/config/validation-messages.d.ts +2 -0
  10. package/lib/config/validation-messages.js +7 -0
  11. package/lib/config.d.ts +1 -0
  12. package/lib/config.js +1 -0
  13. package/lib/database/console-role.js +2 -1
  14. package/lib/http/gateway-console.d.ts +5 -0
  15. package/lib/http/gateway-console.js +25 -0
  16. package/lib/http/gateway-http-server.d.ts +2 -0
  17. package/lib/http/gateway-http-server.js +3 -0
  18. package/lib/http/local-principal.d.ts +1 -1
  19. package/lib/http/local-principal.js +3 -2
  20. package/lib/http/plugin-switch-rpc.js +1 -1
  21. package/lib/http/start-gateway.d.ts +1 -1
  22. package/lib/http/start-gateway.js +2 -0
  23. package/lib/iam/service.js +12 -0
  24. package/lib/iam/setup-service.d.ts +3 -1
  25. package/lib/iam/setup-service.js +2 -1
  26. package/lib/onboarding/ignored-builds.d.ts +18 -0
  27. package/lib/onboarding/ignored-builds.js +63 -0
  28. package/lib/onboarding/index.d.ts +1 -0
  29. package/lib/onboarding/index.js +1 -0
  30. package/lib/packages/service.js +52 -5
  31. package/lib/plugin-management/plugin-candidates.d.ts +9 -0
  32. package/lib/plugin-management/plugin-candidates.js +55 -0
  33. package/lib/plugin-management/workflow.d.ts +5 -0
  34. package/lib/plugin-management/workflow.js +18 -3
  35. package/lib/plugin-management.d.ts +1 -0
  36. package/lib/plugin-management.js +1 -0
  37. package/lib/project/identity.d.ts +2 -0
  38. package/lib/project/identity.js +21 -0
  39. package/lib/project.d.ts +1 -0
  40. package/lib/project.js +1 -0
  41. 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 { ConsoleUserPrincipal } from '@zhin.js/host-http';
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: 'local',
11
+ userId: LOCAL_SOCKET_USER_ID,
11
12
  username: 'local',
12
13
  displayName: '本机 CLI(网关控制套接字)',
13
- credentialId: 'local-socket',
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 ?? '未知错误'}(原因:${result.error ?? '未知错误'})`
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);
@@ -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): Promise<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>;
@@ -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
- await this.#options.config.setKey('database', block);
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
+ }
@@ -2,5 +2,6 @@ export * from './errors.js';
2
2
  export * from './allowlist.js';
3
3
  export * from './state-store.js';
4
4
  export * from './package-manager.js';
5
+ export * from './ignored-builds.js';
5
6
  export * from './install.js';
6
7
  export * from './service.js';
@@ -2,5 +2,6 @@ export * from './errors.js';
2
2
  export * from './allowlist.js';
3
3
  export * from './state-store.js';
4
4
  export * from './package-manager.js';
5
+ export * from './ignored-builds.js';
5
6
  export * from './install.js';
6
7
  export * from './service.js';
@@ -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
- await execute(resolved.command, [...resolved.args], { cwd: this.#root, maxBuffer: 10 * 1024 * 1024, windowsHide: true,
455
- ...(resolved.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) });
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', `插件实例 ${target.instanceKey}(${target.packageName})未在配置中声明;先运行 zhin install ${target.packageName}`);
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}`);
@@ -3,3 +3,4 @@ export * from './plugin-management/activity.js';
3
3
  export * from './plugin-management/config-explanation.js';
4
4
  export * from './plugin-management/use-cases.js';
5
5
  export * from './plugin-management/workflow.js';
6
+ export * from './plugin-management/plugin-candidates.js';
@@ -3,3 +3,4 @@ export * from './plugin-management/activity.js';
3
3
  export * from './plugin-management/config-explanation.js';
4
4
  export * from './plugin-management/use-cases.js';
5
5
  export * from './plugin-management/workflow.js';
6
+ export * from './plugin-management/plugin-candidates.js';
@@ -0,0 +1,2 @@
1
+ /** 32 hex characters of SHA-256 over the canonical project root path. */
2
+ export declare function projectIdentity(projectRoot: string): string;
@@ -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
@@ -5,3 +5,4 @@ export * from './project/instance-declarations.js';
5
5
  export * from './project/legacy-config.js';
6
6
  export * from './project/node-requirements.js';
7
7
  export * from './project/tsx-module-loader.js';
8
+ export * from './project/identity.js';
package/lib/project.js CHANGED
@@ -5,3 +5,4 @@ export * from './project/instance-declarations.js';
5
5
  export * from './project/legacy-config.js';
6
6
  export * from './project/node-requirements.js';
7
7
  export * from './project/tsx-module-loader.js';
8
+ export * from './project/identity.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/gateway",
3
- "version": "1.3.0",
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.3.0",
76
- "@zhin.js/database": "1.2.0",
77
- "@zhin.js/host-http": "1.3.0",
78
- "@zhin.js/logger": "1.1.3",
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.3.0",
81
- "@zhin.js/scaffold-wizard": "1.2.0"
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",