@zhin.js/gateway 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/config/configuration.d.ts +32 -3
- package/lib/config/configuration.js +120 -25
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/literal-secrets.d.ts +3 -0
- package/lib/config/literal-secrets.js +4 -1
- package/lib/config/patch-path-messages.d.ts +15 -0
- package/lib/config/patch-path-messages.js +71 -0
- package/lib/config/secret-storage.d.ts +42 -0
- package/lib/config/secret-storage.js +140 -0
- package/lib/config/validation-messages.d.ts +15 -0
- package/lib/config/validation-messages.js +66 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/console-role.js +2 -1
- package/lib/http/console-rpc.d.ts +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.d.ts +5 -0
- package/lib/http/gateway-console.js +31 -5
- package/lib/http/gateway-http-server.d.ts +2 -0
- package/lib/http/gateway-http-server.js +42 -1
- package/lib/http/local-principal.d.ts +1 -1
- package/lib/http/local-principal.js +3 -2
- package/lib/http/plugin-switch-rpc.js +7 -6
- package/lib/http/start-gateway.d.ts +1 -1
- package/lib/http/start-gateway.js +2 -0
- package/lib/iam/service.js +12 -0
- package/lib/iam/setup-service.d.ts +3 -1
- package/lib/iam/setup-service.js +2 -1
- package/lib/onboarding/ignored-builds.d.ts +18 -0
- package/lib/onboarding/ignored-builds.js +63 -0
- package/lib/onboarding/index.d.ts +1 -0
- package/lib/onboarding/index.js +1 -0
- package/lib/packages/errors.js +1 -0
- package/lib/packages/rpc.d.ts +6 -1
- package/lib/packages/rpc.js +28 -2
- package/lib/packages/service.d.ts +3 -1
- package/lib/packages/service.js +74 -9
- package/lib/plugin-management/plugin-candidates.d.ts +9 -0
- package/lib/plugin-management/plugin-candidates.js +55 -0
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +13 -2
- package/lib/plugin-management/workflow.js +46 -13
- package/lib/plugin-management.d.ts +1 -0
- package/lib/plugin-management.js +1 -0
- package/lib/project/identity.d.ts +2 -0
- package/lib/project/identity.js +21 -0
- package/lib/project.d.ts +1 -0
- package/lib/project.js +1 -0
- package/lib/supervisor/instance-supervisor.d.ts +4 -1
- package/lib/supervisor/instance-supervisor.js +18 -1
- package/lib/terminal/modules.d.ts +12 -0
- package/lib/terminal/modules.js +38 -2
- package/lib/usage/aggregate.d.ts +64 -3
- package/lib/usage/aggregate.js +199 -26
- package/lib/usage/prompt-cache.d.ts +49 -0
- package/lib/usage/prompt-cache.js +113 -0
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +1 -0
- package/package.json +8 -8
|
@@ -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/errors.js
CHANGED
package/lib/packages/rpc.d.ts
CHANGED
|
@@ -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;
|
package/lib/packages/rpc.js
CHANGED
|
@@ -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('
|
|
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':
|
|
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;
|
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. */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
|
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: '
|
|
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
|
-
'
|
|
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.
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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',
|
|
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}`);
|
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