@zhin.js/gateway 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/lib/config/configuration.d.ts +32 -3
  3. package/lib/config/configuration.js +120 -25
  4. package/lib/config/environment-files.d.ts +42 -0
  5. package/lib/config/environment-files.js +347 -7
  6. package/lib/config/literal-secrets.d.ts +3 -0
  7. package/lib/config/literal-secrets.js +4 -1
  8. package/lib/config/patch-path-messages.d.ts +15 -0
  9. package/lib/config/patch-path-messages.js +71 -0
  10. package/lib/config/secret-storage.d.ts +42 -0
  11. package/lib/config/secret-storage.js +140 -0
  12. package/lib/config/validation-messages.d.ts +15 -0
  13. package/lib/config/validation-messages.js +66 -0
  14. package/lib/config.d.ts +1 -0
  15. package/lib/config.js +1 -0
  16. package/lib/database/console-role.js +2 -1
  17. package/lib/http/console-rpc.d.ts +3 -1
  18. package/lib/http/console-rpc.js +31 -4
  19. package/lib/http/gateway-console.d.ts +5 -0
  20. package/lib/http/gateway-console.js +31 -5
  21. package/lib/http/gateway-http-server.d.ts +2 -0
  22. package/lib/http/gateway-http-server.js +42 -1
  23. package/lib/http/local-principal.d.ts +1 -1
  24. package/lib/http/local-principal.js +3 -2
  25. package/lib/http/plugin-switch-rpc.js +7 -6
  26. package/lib/http/start-gateway.d.ts +1 -1
  27. package/lib/http/start-gateway.js +2 -0
  28. package/lib/iam/service.js +12 -0
  29. package/lib/iam/setup-service.d.ts +3 -1
  30. package/lib/iam/setup-service.js +2 -1
  31. package/lib/onboarding/ignored-builds.d.ts +18 -0
  32. package/lib/onboarding/ignored-builds.js +63 -0
  33. package/lib/onboarding/index.d.ts +1 -0
  34. package/lib/onboarding/index.js +1 -0
  35. package/lib/packages/errors.js +1 -0
  36. package/lib/packages/rpc.d.ts +6 -1
  37. package/lib/packages/rpc.js +28 -2
  38. package/lib/packages/service.d.ts +3 -1
  39. package/lib/packages/service.js +74 -9
  40. package/lib/plugin-management/plugin-candidates.d.ts +9 -0
  41. package/lib/plugin-management/plugin-candidates.js +55 -0
  42. package/lib/plugin-management/use-cases.js +8 -8
  43. package/lib/plugin-management/workflow.d.ts +13 -2
  44. package/lib/plugin-management/workflow.js +46 -13
  45. package/lib/plugin-management.d.ts +1 -0
  46. package/lib/plugin-management.js +1 -0
  47. package/lib/project/identity.d.ts +2 -0
  48. package/lib/project/identity.js +21 -0
  49. package/lib/project.d.ts +1 -0
  50. package/lib/project.js +1 -0
  51. package/lib/supervisor/instance-supervisor.d.ts +4 -1
  52. package/lib/supervisor/instance-supervisor.js +18 -1
  53. package/lib/terminal/modules.d.ts +12 -0
  54. package/lib/terminal/modules.js +38 -2
  55. package/lib/usage/aggregate.d.ts +64 -3
  56. package/lib/usage/aggregate.js +199 -26
  57. package/lib/usage/prompt-cache.d.ts +49 -0
  58. package/lib/usage/prompt-cache.js +113 -0
  59. package/lib/usage.d.ts +1 -0
  60. package/lib/usage.js +1 -0
  61. package/package.json +8 -8
@@ -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';
@@ -2,6 +2,7 @@ const STATUS = {
2
2
  invalid_input: 400,
3
3
  forbidden: 403,
4
4
  unmapped: 403,
5
+ unknown_rpc: 404,
5
6
  conflict: 409,
6
7
  unavailable: 503,
7
8
  internal: 500,
@@ -3,7 +3,7 @@
3
3
  * `gateway.instance.*`, ADR 0054 §4 / §5). The HTTP server maps envelopes onto it; the permission
4
4
  * map in `@zhin.js/console-protocol` is checked here, then each service applies its own rules.
5
5
  */
6
- import { type GatewayInstanceStatus, type GatewayRpcRequest, type GatewayRpcResponse, type GatewayRpcType } from '@zhin.js/console-protocol';
6
+ import { type GatewayInstanceStatus, type GatewayRpcRequest, type GatewayRpcResponse, type GatewayRpcType, type ConsoleProcessSample } from '@zhin.js/console-protocol';
7
7
  import type { InstanceStatus, InstanceStopOptions } from '../supervisor/instance-supervisor.js';
8
8
  import type { InstanceLogLine } from '../supervisor/instance-logs.js';
9
9
  import { type GatewayPrincipal } from './authorization.js';
@@ -28,3 +28,8 @@ export declare function createGatewayRpcDispatcher(dependencies: GatewayRpcDepen
28
28
  /** Dispatches one gateway RPC; never throws (errors become `{ ok: false }`). */
29
29
  export declare function dispatchGatewayRpc(request: GatewayRpcRequest, principal: GatewayPrincipal | undefined, dependencies: GatewayRpcDependencies): Promise<GatewayRpcResponse>;
30
30
  export declare function instanceStatus(status: InstanceStatus): GatewayInstanceStatus;
31
+ /**
32
+ * The gateway's own process sample (#1250): one `process.memoryUsage()` call, labeled
33
+ * `role: 'gateway'`, so the Console never mixes it with the instance's numbers.
34
+ */
35
+ export declare function sampleGatewayProcess(): ConsoleProcessSample | undefined;
@@ -20,8 +20,9 @@ export function createGatewayRpcDispatcher(dependencies) {
20
20
  export async function dispatchGatewayRpc(request, principal, dependencies) {
21
21
  try {
22
22
  const type = request?.type;
23
+ // zhinjs/zhin#1250: a `gateway.*` type this gateway does not have is not a refusal (not audited).
23
24
  if (!isGatewayRpcType(type))
24
- throw new GatewayError('unmapped', `RPC "${String(type ?? '').slice(0, 64)}" 未登记权限`);
25
+ throw new GatewayError('unknown_rpc', `RPC "${String(type ?? '').slice(0, 64)}" 不存在`);
25
26
  if (!principal)
26
27
  throw new GatewayError('forbidden', '未登录');
27
28
  const packageMutation = type === GATEWAY_RPC.PLAN || type === GATEWAY_RPC.INSTALL;
@@ -69,7 +70,10 @@ async function handle(type, data, principal, deps) {
69
70
  return packagesOf(deps).setEnabled({ plugin: data.plugin }, type === 'gateway.plugins.enable', {
70
71
  principal, ...optionalBoolean(data, 'startInstance'),
71
72
  });
72
- case 'gateway.instance.status': return instanceStatus(instanceOf(deps).status());
73
+ case 'gateway.instance.status': {
74
+ const gateway = sampleGatewayProcess();
75
+ return Object.freeze({ ...instanceStatus(instanceOf(deps).status()), ...(gateway ? { gateway } : {}) });
76
+ }
73
77
  case 'gateway.instance.logs': {
74
78
  const lines = data.lines === undefined ? DEFAULT_LOG_LINES : data.lines;
75
79
  if (typeof lines !== 'number' || !Number.isInteger(lines) || lines < 1 || lines > MAX_LOG_LINES) {
@@ -126,6 +130,28 @@ export function instanceStatus(status) {
126
130
  recentRestarts: status.recentRestarts,
127
131
  });
128
132
  }
133
+ /**
134
+ * The gateway's own process sample (#1250): one `process.memoryUsage()` call, labeled
135
+ * `role: 'gateway'`, so the Console never mixes it with the instance's numbers.
136
+ */
137
+ export function sampleGatewayProcess() {
138
+ try {
139
+ const memory = process.memoryUsage();
140
+ return Object.freeze({
141
+ role: 'gateway',
142
+ pid: process.pid,
143
+ uptime: process.uptime(),
144
+ memory: Object.freeze({
145
+ rss: memory.rss, heapUsed: memory.heapUsed, heapTotal: memory.heapTotal,
146
+ external: memory.external, arrayBuffers: memory.arrayBuffers,
147
+ }),
148
+ sampledAt: Date.now(),
149
+ });
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ }
129
155
  function record(value) {
130
156
  if (value === undefined || value === null)
131
157
  return {};
@@ -1,4 +1,4 @@
1
- import type { GatewayAuthorizedPackage, GatewayPackageEvent, GatewayPackageList, GatewayPackageOperation, GatewayPackagePlan, GatewayPackageTransactionResult } from '@zhin.js/console-protocol';
1
+ import type { GatewayAuthorizedPackage, GatewayInstalledPackageKind, GatewayPackageEvent, GatewayPackageList, GatewayPackageOperation, GatewayPackagePlan, GatewayPackageTransactionResult } from '@zhin.js/console-protocol';
2
2
  import { type InstallAllowlist, type MarketCatalogEntry } from '../onboarding/allowlist.js';
3
3
  import { type PackageManagerOutput } from '../onboarding/package-manager.js';
4
4
  import { type PluginManagementOptions } from '../plugin-management/workflow.js';
@@ -137,3 +137,5 @@ export declare class GatewayPackageService {
137
137
  readonly result: Promise<GatewayPackageTransactionResult>;
138
138
  };
139
139
  }
140
+ /** `package.json#zhin` → what the dependency is to Zhin; anything without a Zhin manifest is a library. */
141
+ export declare function installedPackageKind(zhin: unknown): GatewayInstalledPackageKind;
@@ -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. */
@@ -90,7 +121,12 @@ export class GatewayPackageService {
90
121
  if (typeof range !== 'string')
91
122
  continue;
92
123
  const catalog = allowlist.get(name)?.kind;
93
- packages.push(Object.freeze({ name, range, dependencyType, installedVersion: await this.#installedVersion(name), ...(catalog ? { catalog } : {}) }));
124
+ const installed = await this.#installedManifest(name);
125
+ packages.push(Object.freeze({
126
+ name, range, dependencyType, installedVersion: installed?.version ?? null,
127
+ ...(installed ? { kind: installed.kind } : {}),
128
+ ...(catalog ? { catalog } : {}),
129
+ }));
94
130
  }
95
131
  }
96
132
  packages.sort((a, b) => a.name.localeCompare(b.name));
@@ -293,7 +329,9 @@ export class GatewayPackageService {
293
329
  let failedPhase;
294
330
  let failure;
295
331
  let rollbackFailure;
332
+ let restartFailure;
296
333
  let logTail;
334
+ this.#ignoredBuilds = undefined;
297
335
  emit({ type: 'start', id, operation, packages: authorized });
298
336
  try {
299
337
  try {
@@ -330,17 +368,29 @@ export class GatewayPackageService {
330
368
  await run(snapshot.hasLockfile ? ['install', '--frozen-lockfile'] : ['install']);
331
369
  }
332
370
  }
333
- if (wasRunning) {
334
- enter('restart-instance');
335
- await this.#startInstance(instance, logMark);
336
- }
337
371
  }
338
372
  catch (error) {
339
373
  status = 'rollbackFailed';
340
374
  rollbackFailure = error;
341
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
+ }
342
391
  }
343
392
  enter('done');
393
+ const ignoredBuilds = this.#ignoredBuilds ? Object.freeze([...this.#ignoredBuilds]) : undefined;
344
394
  const result = Object.freeze({
345
395
  id,
346
396
  operation,
@@ -350,6 +400,8 @@ export class GatewayPackageService {
350
400
  ...(failedPhase ? { failedPhase } : {}),
351
401
  ...(failure === undefined ? {} : { error: failureMessage(failure), ...declarationDetails(failure) }),
352
402
  ...(rollbackFailure === undefined ? {} : { rollbackError: failureMessage(rollbackFailure) }),
403
+ ...(restartFailure === undefined ? {} : { restartError: failureMessage(restartFailure) }),
404
+ ...(ignoredBuilds ? { ignoredBuilds } : {}),
353
405
  ...(logTail?.length ? { logTail } : {}),
354
406
  ...(status === 'succeeded' && extras?.needsConfiguration?.length ? { needsConfiguration: extras.needsConfiguration } : {}),
355
407
  ...(status === 'succeeded' && extras?.plugin ? { plugin: extras.plugin } : {}),
@@ -416,13 +468,19 @@ export class GatewayPackageService {
416
468
  #instanceState() {
417
469
  return this.#options.instance?.state ?? 'unmanaged';
418
470
  }
419
- async #installedVersion(name) {
471
+ /**
472
+ * Installed version plus what the package is to Zhin (#1250): `package.json#zhin.type` `plugin`
473
+ * → `plugin`, `feature` → `feature`, no `zhin` manifest → `library`. Undefined when not installed.
474
+ */
475
+ async #installedManifest(name) {
420
476
  try {
421
477
  const manifest = JSON.parse(await readFile(join(this.#root, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'));
422
- return manifest.name === name && typeof manifest.version === 'string' ? manifest.version : null;
478
+ if (manifest.name !== name || typeof manifest.version !== 'string')
479
+ return undefined;
480
+ return { version: manifest.version, kind: installedPackageKind(manifest.zhin) };
423
481
  }
424
482
  catch {
425
- return null;
483
+ return undefined;
426
484
  }
427
485
  }
428
486
  /** Cross-process exclusion (CLI and gateway); separate from the workflow's own lock file. */
@@ -502,3 +560,10 @@ function failureMessage(error) {
502
560
  const cause = error instanceof PluginManagementOperationError ? error.cause : error;
503
561
  return cause instanceof Error ? cause.message : String(cause);
504
562
  }
563
+ /** `package.json#zhin` → what the dependency is to Zhin; anything without a Zhin manifest is a library. */
564
+ export function installedPackageKind(zhin) {
565
+ if (!zhin || typeof zhin !== 'object' || Array.isArray(zhin))
566
+ return 'library';
567
+ const type = zhin.type;
568
+ return type === 'plugin' ? 'plugin' : type === 'feature' ? 'feature' : 'library';
569
+ }
@@ -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
+ }
@@ -1,11 +1,11 @@
1
1
  export const pluginUseCases = Object.freeze([
2
2
  {
3
- id: 'rss-subscription', title: '订阅 RSS 新文章', summary: '在任务中心选择精确目标,创建独立管理员 RSS 订阅。',
3
+ id: 'rss-subscription', title: '订阅 RSS 新文章', summary: '在 Console「运维 → 定时任务 → 管理员任务」选择精确目标,创建独立管理员 RSS 订阅。',
4
4
  packageName: '@zhin.js/plugin-rss', mode: 'administrator-task',
5
5
  steps: ['通过现有安装计划安装 RSS 插件,配置持久 Database Host、Schedule Host 与支持精确目标的 Outbound Host,保存并应用。',
6
- '在 Console 任务中心选择当前已加载的 RSS 实例,核对账号、会话、父容器和线程。',
6
+ '在 Console「运维 → 定时任务」的「管理员任务」页签选择当前已加载的 RSS 实例,核对账号、会话、父容器和线程。',
7
7
  '填写 HTTP(S) RSS / Atom 地址,阅读抓取和发送影响后创建;保存不会抓取源站或发送测试消息。',
8
- '等待实例轮询首次成功抓取建立基线;后续新文章才推送,可在任务中心暂停、编辑、恢复或取消。'],
8
+ '等待实例轮询首次成功抓取建立基线;后续新文章才推送,可在「运维 → 定时任务 → 管理员任务」暂停、编辑、恢复或取消。'],
9
9
  limitations: ['首次和暂停恢复后的首次成功抓取只建基线,不补发历史;每轮只检查源前 20 条,最多展示 maxItems 条,其余提示查看源站,不续轮补发。',
10
10
  '管理员订阅与聊天订阅独立;聊天 rss-list、rss-remove 和 rss-check 不管理或触发管理员任务。',
11
11
  '投递未确认时不自动重试或恢复,只能取消;先核实目标会话,确需继续时再新建。'],
@@ -14,7 +14,7 @@ export const pluginUseCases = Object.freeze([
14
14
  id: 'reminder', title: '创建定时提醒', summary: '指定账号、会话与时间,创建可查看、暂停和取消的管理员提醒。',
15
15
  packageName: '@zhin.js/plugin-reminder', mode: 'administrator-task',
16
16
  steps: ['通过现有安装计划安装持久提醒插件,配置 Database Host 并应用。',
17
- '在 Console 任务中心选择当前已加载的提醒实例,检查当前版本与可用表单。',
17
+ '在 Console「运维 → 定时任务」的「管理员任务」页签选择当前已加载的提醒实例,检查当前版本与可用表单。',
18
18
  '选择精确投递账号及会话,填写含时区的时间和内容,确认未来发送的效果后创建。',
19
19
  '查看下次执行时间与实际结果;投递不确定时先核对目标会话,不盲目重建。'],
20
20
  limitations: ['Console 只管理管理员任务;聊天创建的提醒仍归原发送者。',
@@ -54,11 +54,11 @@ export const pluginUseCases = Object.freeze([
54
54
  ].map(item => Object.freeze({ ...item, steps: Object.freeze(item.steps), limitations: Object.freeze(item.limitations) })));
55
55
  const englishUseCases = {
56
56
  'rss-subscription': {
57
- title: 'Subscribe to RSS updates', summary: 'Choose an exact delivery target and create a separate administrator RSS subscription.',
57
+ title: 'Subscribe to RSS updates', summary: 'In Operations → Scheduled tasks → Administrator tasks, choose an exact delivery target and create a separate administrator RSS subscription.',
58
58
  steps: ['Install the RSS plugin through the existing installation plan. Configure persistent Database, Schedule and Outbound Hosts with exact-target support, then save and apply.',
59
- 'Select a loaded RSS instance in Console Tasks. Check its account, conversation, parent container and thread.',
59
+ 'In Console, open Operations → Scheduled tasks → Administrator tasks and select a loaded RSS instance. Check its account, conversation, parent container and thread.',
60
60
  'Enter an HTTP(S) RSS or Atom URL, review fetching and delivery effects, then create. Saving does not fetch the feed or send a test message.',
61
- 'Wait for the first successful poll to establish a baseline. Only later articles are delivered. Use Tasks to pause, edit, resume or cancel.'],
61
+ 'Wait for the first successful poll to establish a baseline. Only later articles are delivered. Pause, edit, resume or cancel it under Operations → Scheduled tasks → Administrator tasks.'],
62
62
  limitations: ['The first successful fetch after creation or resuming only establishes a baseline; it does not replay history. Each poll examines the first 20 feed entries and displays at most maxItems, with a link to the feed for the remainder; there is no later catch-up.',
63
63
  'Administrator subscriptions and chat subscriptions are separate. Chat rss-list, rss-remove and rss-check do not manage or trigger administrator tasks.',
64
64
  'An unconfirmed delivery is not automatically retried or resumed. Cancel it, check the destination, and create a new task only if needed.'],
@@ -66,7 +66,7 @@ const englishUseCases = {
66
66
  reminder: {
67
67
  title: 'Create a scheduled reminder', summary: 'Choose an account, conversation and time, then manage an administrator reminder.',
68
68
  steps: ['Install the persistent reminder plugin, configure a Database Host and apply.',
69
- 'Select a loaded reminder instance in Console Tasks and check its current version and form.',
69
+ 'In Console, open Operations → Scheduled tasks → Administrator tasks, select a loaded reminder instance and check its current version and form.',
70
70
  'Choose the exact account and conversation, enter a time with an offset and the content, and confirm the future delivery effect.',
71
71
  'Check the next execution time and actual result. If delivery is uncertain, inspect the destination before creating another task.'],
72
72
  limitations: ['Console manages administrator tasks only. Chat reminders still belong to the actual sender.',
@@ -1,5 +1,5 @@
1
1
  import { type SnapshotReader } from '@zhin.js/plugin-runtime';
2
- import type { PluginInstallPlan, PluginManagementPort, PluginManagementState, PluginUninstallPlan, PluginUpdatePlan, PluginVerificationResult, PluginVerificationPlan } from '@zhin.js/host-http';
2
+ import type { PluginDependencyCheck, PluginInspectOptions, PluginInstallPlan, PluginManagementPort, PluginManagementState, PluginUninstallPlan, PluginUpdatePlan, PluginVerificationResult, PluginVerificationPlan } from '@zhin.js/host-http';
3
3
  import type { ConsoleConfigExplanation, GatewayPluginConfigurationNeed } from '@zhin.js/console-protocol';
4
4
  import type { PluginManagementActivity } from './activity.js';
5
5
  export interface PluginManagementOptions {
@@ -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 {
@@ -67,7 +72,13 @@ export interface PluginEnablementPlan {
67
72
  export declare class PluginManagementWorkflow implements PluginManagementPort {
68
73
  #private;
69
74
  constructor(projectRoot: string, options?: PluginManagementOptions);
70
- inspect(request: string): Promise<PluginManagementState>;
75
+ inspect(request: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
76
+ /**
77
+ * The project-wide dependency graph check `inspect` runs for every declared, enabled plugin.
78
+ * Callers inspecting many plugins (Console attention) run it once here and pass
79
+ * `{ dependencies: 'skip' }` to `inspect`.
80
+ */
81
+ checkDependencies(): Promise<PluginDependencyCheck>;
71
82
  /** Full-scope, metadata-only projection over the same file and generation observation as inspect. */
72
83
  explainConfiguration(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
73
84
  planInstall(request: string, options?: Pick<PluginInstallOptions, 'enable'>): Promise<PluginInstallPlan>;
@@ -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).
@@ -209,16 +211,32 @@ export class PluginManagementWorkflow {
209
211
  const revision = config ? (await config.document.read()).revision : null;
210
212
  return { revision, projectRevision: createHash('sha256').update(JSON.stringify([source.revision, revision])).digest('hex') };
211
213
  }
212
- async inspect(request) {
214
+ async inspect(request, options = {}) {
213
215
  const lease = this.#options.snapshots?.acquire();
214
216
  try {
215
- return await this.#inspect(request, lease?.value);
217
+ return await this.#inspect(request, lease?.value, options);
216
218
  }
217
219
  finally {
218
220
  lease?.release();
219
221
  }
220
222
  }
221
- async #inspect(request, snapshot) {
223
+ /**
224
+ * The project-wide dependency graph check `inspect` runs for every declared, enabled plugin.
225
+ * Callers inspecting many plugins (Console attention) run it once here and pass
226
+ * `{ dependencies: 'skip' }` to `inspect`.
227
+ */
228
+ async checkDependencies() {
229
+ const configuration = this.#configuration();
230
+ const document = configuration ? (await configuration.document.read()).document : {};
231
+ try {
232
+ await this.#checkDependencies(undefined, document);
233
+ return Object.freeze({ status: 'passed' });
234
+ }
235
+ catch (error) {
236
+ return Object.freeze({ status: 'failed', error: errorMessage(error) });
237
+ }
238
+ }
239
+ async #inspect(request, snapshot, options = {}) {
222
240
  const { configuration, source, document, declaration, projectRevision } = await this.#configurationObservation(snapshot);
223
241
  const entries = declaration.instances;
224
242
  const target = await this.#resolveTarget(request, entries);
@@ -236,13 +254,15 @@ export class PluginManagementWorkflow {
236
254
  throw new Error('Legacy plugin lifecycle state detected; run zhin migrate instances');
237
255
  const disabled = declaration.disabled.includes(target.instanceKey);
238
256
  if (installed.plugin && declared && !disabled) {
239
- try {
240
- await this.#checkDependencies(undefined, document);
241
- dependencies = 'passed';
242
- }
243
- catch (error) {
244
- dependencies = 'failed';
245
- issues.push(errorMessage(error));
257
+ if (options.dependencies !== 'skip') {
258
+ try {
259
+ await this.#checkDependencies(undefined, document);
260
+ dependencies = 'passed';
261
+ }
262
+ catch (error) {
263
+ dependencies = 'failed';
264
+ issues.push(errorMessage(error));
265
+ }
246
266
  }
247
267
  validation = await configuration.store.validatePluginConfig(target.instanceKey, config[target.instanceKey] ?? {}, document);
248
268
  }
@@ -433,8 +453,20 @@ export class PluginManagementWorkflow {
433
453
  else {
434
454
  // No shell: on Windows `pnpm.cmd` is resolved and run through `cmd.exe /d /s /c` with quoted arguments.
435
455
  const resolved = resolveCommand('pnpm', invocation);
436
- await execute(resolved.command, [...resolved.args], { cwd: this.#root, maxBuffer: 10 * 1024 * 1024, windowsHide: true,
437
- ...(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
+ }
438
470
  }
439
471
  }
440
472
  async install(request, expectedRevision, expectedProjectRevision, options = {}) {
@@ -607,8 +639,9 @@ export class PluginManagementWorkflow {
607
639
  catch (error) {
608
640
  throw new PluginEnablementError('invalid-request', errorMessage(error), [], []);
609
641
  }
642
+ // The fallback target is derived from the request itself: never suggest installing it (#1250).
610
643
  if (!declared)
611
- 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)));
612
645
  const state = await this.inspect(target.instanceKey);
613
646
  if (enabled && (!state.installed || !state.plugin)) {
614
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';