@zhin.js/command 1.1.8 → 1.1.9

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.
@@ -56,6 +56,11 @@ export declare class CommandIndex {
56
56
  dispatch(input: CommandMatchInput, source?: unknown, interactionFactory?: UserInteractionFactory, commandPrefix?: string, operationSnapshot?: RuntimeSnapshot): Promise<CommandDispatchResult>;
57
57
  }
58
58
  export declare function isCommandIndex(value: unknown): value is CommandIndex;
59
+ /**
60
+ * Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
61
+ * Shared by the runtime index and {@link bindCommandSource}.
62
+ */
63
+ export declare function assertParameterSegment(segments: readonly string[], parameter: CommandParameterDefinition | undefined, source: string): void;
59
64
  export declare class CommandParameterValueError extends TypeError {
60
65
  constructor(name: string, type: CommandParameterType, value: string);
61
66
  }
@@ -5,7 +5,7 @@ import { permissionHostToken, } from '@zhin.js/permission';
5
5
  import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
6
6
  import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp } from './arguments.js';
7
7
  import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu } from './command-menu.js';
8
- import { admitCommandExecution } from './execution-runtime.js';
8
+ import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
9
9
  import { CommandExecutionLimitedError, normalizeExecutionPolicy } from './execution-policy.js';
10
10
  export { CommandExecutionLimitedError } from './execution-policy.js';
11
11
  import { createCommandContext, resolveCommandSession, resolveDynamicParams, } from './definition.js';
@@ -45,7 +45,15 @@ export class CommandIndex {
45
45
  for (const key of keys)
46
46
  occupancy.set(key, source);
47
47
  };
48
+ const disabledByOwner = new Map();
48
49
  for (const slot of slots) {
50
+ let disabled = disabledByOwner.get(slot.owner);
51
+ if (!disabled) {
52
+ disabled = disabledCommands(this.snapshot, slot.owner);
53
+ disabledByOwner.set(slot.owner, disabled);
54
+ }
55
+ if (isDisabledCommand(slot.localName, disabled))
56
+ continue;
49
57
  const namespace = commandNamespace(this.snapshot, slot.owner);
50
58
  const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
51
59
  const parameter = slot.definition.$parameter;
@@ -148,10 +156,16 @@ export class CommandIndex {
148
156
  const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
149
157
  if (parsed.issues.length > 0)
150
158
  throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
151
- const limit = admitCommandExecution(undefined, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
152
- if (limit)
153
- throw new CommandExecutionLimitedError(limit);
154
- return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
159
+ // No inbound source: execution policy applies per command, never as a blanket refusal.
160
+ const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
161
+ if (admission.limit)
162
+ throw new CommandExecutionLimitedError(admission.limit);
163
+ try {
164
+ return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner, textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
165
+ }
166
+ finally {
167
+ admission.release();
168
+ }
155
169
  }
156
170
  async dispatch(input, source = undefined, interactionFactory, commandPrefix = '', operationSnapshot = this.snapshot) {
157
171
  if (this.#menu) {
@@ -440,6 +454,27 @@ function commandNamespace(snapshot, owner) {
440
454
  }
441
455
  return Object.freeze([...segments]);
442
456
  }
457
+ /**
458
+ * 插件实例配置 disabledCommands 列出的指令不进入路由表。条目写插件本地的静态路径
459
+ * (不含 commandNamespace 与参数段,如 `fanyi`、`netease login`);写上级路径会连同
460
+ * 其子命令一起禁用。未匹配任何指令的条目被忽略。
461
+ */
462
+ function disabledCommands(snapshot, owner) {
463
+ const config = snapshot.config.get(owner);
464
+ const value = config?.disabledCommands;
465
+ if (value === undefined)
466
+ return Object.freeze([]);
467
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
468
+ throw new TypeError(`Invalid disabledCommands for ${owner}: expected an array of command names`);
469
+ }
470
+ return Object.freeze(value.map((entry) => splitCommand(entry).join(' ')).filter(Boolean));
471
+ }
472
+ function isDisabledCommand(localName, disabled) {
473
+ if (disabled.length === 0)
474
+ return false;
475
+ const path = runtimeSegments(localName).filter((segment) => !segment.startsWith('$')).join(' ');
476
+ return disabled.some((entry) => path === entry || path.startsWith(`${entry} `));
477
+ }
443
478
  function isCommandNamespaceSegment(value) {
444
479
  return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
445
480
  }
@@ -566,7 +601,11 @@ function exactMessageText(input) {
566
601
  const trimmed = only.data.text.trim();
567
602
  return trimmed || undefined;
568
603
  }
569
- function assertParameterSegment(segments, parameter, source) {
604
+ /**
605
+ * Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
606
+ * Shared by the runtime index and {@link bindCommandSource}.
607
+ */
608
+ export function assertParameterSegment(segments, parameter, source) {
570
609
  const dynamicSegments = segments.filter((segment) => segment.startsWith('$'));
571
610
  if (!parameter && dynamicSegments.length === 0)
572
611
  return;
@@ -282,7 +282,16 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
282
282
  readonly params: keyof P extends never ? Readonly<Record<string, CommandParameterValue>> : InferCommandParams<P>;
283
283
  readonly options: InferCommandOptions<O>;
284
284
  };
285
+ /**
286
+ * `defineCommand.withConfig<Config>()` 返回的定义函数:config 类型已固定,params / options / 返回值
287
+ * 仍从定义推断。
288
+ * @public
289
+ */
290
+ export type CommandDefiner<TConfig, TInput extends CommandMessage = CommandMessage> = <TResult = unknown, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>) => Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
285
291
  export declare function defineCommand<TConfig = unknown, TResult = unknown, TInput extends CommandMessage = CommandMessage, const P extends CommandParams = {}, const O extends CommandOptions = {}>(definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>): Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
292
+ export declare namespace defineCommand {
293
+ var withConfig: <TConfig = unknown, TInput extends CommandMessage = CommandMessage>() => CommandDefiner<TConfig, TInput>;
294
+ }
286
295
  /** @internal Convention-loader assembly helper. */
287
296
  export declare function bindCommandParameter<TConfig, TResult, TInput extends CommandMessage, TAdapter extends string | undefined>(definition: CommandDefinition<TConfig, TResult, TInput, TAdapter>, parameter: CommandParameterDefinition | undefined): Readonly<CommandDefinition<TConfig, TResult, TInput, TAdapter>>;
288
297
  /** @internal Runtime validation for convention-discovered modules. */
package/lib/definition.js CHANGED
@@ -43,6 +43,24 @@ export function defineCommand(definition) {
43
43
  const execution = normalizeExecutionPolicy(definition.execution);
44
44
  return Object.freeze({ $feature: commandBrand, ...definition, ...(execution ? { execution } : {}) });
45
45
  }
46
+ /**
47
+ * 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
48
+ *
49
+ * ```ts
50
+ * export default defineCommand.withConfig<Config>()({
51
+ * params: { count: { type: 'integer' } },
52
+ * execute({ params, config }) { params.count; config.prefix },
53
+ * })
54
+ * ```
55
+ *
56
+ * `defineCommand<Config>({...})` 仍然可用,但 TypeScript 不支持部分推断,显式写了
57
+ * 类型参数后 params 会退化成 `Record<string, CommandParameterValue>`。
58
+ * 这里是独立的属性而不是 `defineCommand()` 重载:`defineCommand` 常被当作值传给
59
+ * `createEndpointCommands(spec, defineCommand)` 这类工厂,重载会让它推断错签名。
60
+ */
61
+ defineCommand.withConfig = function withConfig() {
62
+ return (definition) => defineCommand(definition);
63
+ };
46
64
  function validateAdapterName(adapter) {
47
65
  if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
48
66
  throw new TypeError('Command adapter must be a non-empty string');
@@ -16,7 +16,20 @@ interface ConversationIdentity {
16
16
  }
17
17
  interface AdmissionReader {
18
18
  admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
19
+ /**
20
+ * Programmatic call with no inbound source (e.g. `CommandIndex.execute`). The policy is keyed by command only:
21
+ * there is no sender or conversation to budget, and no reply channel for progress feedback.
22
+ * On success the caller must invoke `release` once the execution settles.
23
+ */
24
+ admitDirect(capability: string, policy: CommandExecutionPolicy): DirectAdmission;
19
25
  }
26
+ type DirectAdmission = {
27
+ readonly limit: CommandExecutionLimit;
28
+ readonly release?: undefined;
29
+ } | {
30
+ readonly limit?: undefined;
31
+ readonly release: () => void;
32
+ };
20
33
  /** @internal Admission-only Root resource; no bind or finish authority is published. */
21
34
  export declare const commandExecutionAdmissionToken: import("@zhin.js/plugin-runtime").Token<AdmissionReader>;
22
35
  /** @internal Held only by Core's actual leased operation. */
@@ -47,4 +60,6 @@ export declare class CommandExecutionBudget {
47
60
  }
48
61
  /** @internal Projection reads the Root resource; ordinary authoring values cannot bind an operation. */
49
62
  export declare function admitCommandExecution(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
63
+ /** @internal Programmatic (source-less) admission: keyed by command only, never refused for lacking a source. */
64
+ export declare function admitDirectCommandExecution(capability: string, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): DirectAdmission;
50
65
  export {};
@@ -14,7 +14,8 @@ export class CommandExecutionBudget {
14
14
  this.#capacity = options.capacity ?? 10_000;
15
15
  if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1)
16
16
  throw new TypeError('Invalid command budget capacity');
17
- this.admission = Object.freeze({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot) });
17
+ this.admission = Object.freeze({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot),
18
+ admitDirect: (capability, policy) => this.#admitDirect(capability, policy) });
18
19
  }
19
20
  openOperation(input) {
20
21
  const c = input.conversation;
@@ -67,41 +68,66 @@ export class CommandExecutionBudget {
67
68
  },
68
69
  });
69
70
  }
70
- #admit(source, capability, owner, policy, snapshot) {
71
- const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
72
- const operation = binding?.operation;
73
- if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted)
74
- return limited('operation_unavailable');
75
- if (!operation.key)
76
- return limited('identity_unavailable');
77
- const key = JSON.stringify([capability, ...operation.key]);
78
- // Re-entering the same capability is a second attempt, rather than a second free execution.
71
+ #admitDirect(capability, policy) {
72
+ return this.#acquire(JSON.stringify([capability, 'direct']), policy);
73
+ }
74
+ /** Shared entry accounting: concurrency, cooldown and capacity. Returns a one-shot release on success. */
75
+ #acquire(key, policy) {
79
76
  const now = this.#clock();
80
77
  let entry = this.#entries.get(key);
81
78
  if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent)
82
- return limited('busy');
79
+ return { limit: limited('busy') };
83
80
  if (entry && entry.cooldownUntil > now)
84
- return Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) });
81
+ return { limit: Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) }) };
85
82
  if (!entry) {
86
83
  if (this.#entries.size >= this.#capacity) {
87
84
  for (const [oldKey, old] of this.#entries)
88
85
  if (old.active === 0 && old.cooldownUntil <= now)
89
86
  this.#entries.delete(oldKey);
87
+ if (this.#entries.size >= this.#capacity) {
88
+ // Still full of cooling identities: drop the oldest idle one. Forgetting a cooldown is acceptable;
89
+ // refusing every new identity is not. Only entries with running operations are never evicted.
90
+ for (const [oldKey, old] of this.#entries)
91
+ if (old.active === 0) {
92
+ this.#entries.delete(oldKey);
93
+ break;
94
+ }
95
+ }
90
96
  if (this.#entries.size >= this.#capacity)
91
- return limited('capacity');
97
+ return { limit: limited('capacity') };
92
98
  }
93
99
  entry = { active: 0, cooldownUntil: 0 };
94
100
  this.#entries.set(key, entry);
95
101
  }
96
102
  entry.active++;
97
- operation.admitted = true;
98
103
  entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
99
104
  const acceptedEntry = entry;
100
- operation.releases.push(() => {
101
- acceptedEntry.active--;
102
- if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock())
103
- this.#entries.delete(key);
104
- });
105
+ let released = false;
106
+ return {
107
+ release: () => {
108
+ if (released)
109
+ return;
110
+ released = true;
111
+ acceptedEntry.active--;
112
+ if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock())
113
+ this.#entries.delete(key);
114
+ },
115
+ };
116
+ }
117
+ #admit(source, capability, owner, policy, snapshot) {
118
+ const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
119
+ const operation = binding?.operation;
120
+ if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted)
121
+ return limited('operation_unavailable');
122
+ if (!operation.key)
123
+ return limited('identity_unavailable');
124
+ const key = JSON.stringify([capability, ...operation.key]);
125
+ // Re-entering the same capability is a second attempt, rather than a second free execution.
126
+ const acquired = this.#acquire(key, policy);
127
+ if (acquired.limit)
128
+ return acquired.limit;
129
+ operation.admitted = true;
130
+ operation.releases.push(acquired.release);
105
131
  if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
106
132
  const timer = setTimeout(() => {
107
133
  operation.timers.delete(timer);
@@ -132,3 +158,10 @@ export function admitCommandExecution(source, capability, owner, policy, snapsho
132
158
  const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id);
133
159
  return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
134
160
  }
161
+ /** @internal Programmatic (source-less) admission: keyed by command only, never refused for lacking a source. */
162
+ export function admitDirectCommandExecution(capability, policy, snapshot) {
163
+ if (!policy)
164
+ return { release: () => { } };
165
+ const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id);
166
+ return reader ? reader.admitDirect(capability, policy) : { limit: limited('service_unavailable') };
167
+ }
package/lib/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
9
  export * from './permission-explanation.js';
10
- export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
11
- export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
10
+ export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, type ParsedPermit, type PermitKind, } from './permit.js';
11
+ export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, type BoundCommandSource, default as commandFeature, } from './provider.js';
12
12
  export { default } from './provider.js';
13
13
  export type { CommandValidationIssue } from './arguments.js';
package/lib/index.js CHANGED
@@ -7,6 +7,6 @@ export * from './command-index.js';
7
7
  /** @public Command authoring contract and `defineCommand`. */
8
8
  export * from './definition.js';
9
9
  export * from './permission-explanation.js';
10
- export { assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
11
- export { CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
10
+ export { CONVERSATION_MANAGER_PERMIT, canManageConversation, assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, isBuiltinPermit, isPlatformPermit, parsePermitName, } from './permit.js';
11
+ export { bindCommandSource, CommandPathSyntaxError, commandFeatureId, default as commandFeature, } from './provider.js';
12
12
  export { default } from './provider.js';
package/lib/permit.d.ts CHANGED
@@ -2,4 +2,12 @@
2
2
  * Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
3
3
  * 保留 CommandSession 兼容签名供 command-index 过渡使用。
4
4
  */
5
+ import { type PermissionSubject } from '@zhin.js/permission';
6
+ /** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
7
+ export declare const CONVERSATION_MANAGER_PERMIT = "role(owner,admin,master)";
8
+ /**
9
+ * 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
10
+ * 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
11
+ */
12
+ export declare function canManageConversation(subject: PermissionSubject): boolean;
5
13
  export { type PermitKind, type ParsedPermit, parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
package/lib/permit.js CHANGED
@@ -2,4 +2,15 @@
2
2
  * Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
3
3
  * 保留 CommandSession 兼容签名供 command-index 过渡使用。
4
4
  */
5
+ import { checkBuiltinPermit } from '@zhin.js/permission';
6
+ /** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
7
+ export const CONVERSATION_MANAGER_PERMIT = 'role(owner,admin,master)';
8
+ /**
9
+ * 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
10
+ * 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
11
+ */
12
+ export function canManageConversation(subject) {
13
+ return checkBuiltinPermit('private()', subject)
14
+ || checkBuiltinPermit(CONVERSATION_MANAGER_PERMIT, subject);
15
+ }
5
16
  export { parsePermitName, isBuiltinPermit, isPlatformPermit, assertPermitSyntax as assertBuiltinPermits, checkBuiltinPermit, checkBuiltinPermitList, } from '@zhin.js/permission';
package/lib/provider.d.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  import { CommandIndex } from './command-index.js';
2
2
  import { type CommandDefinition } from './definition.js';
3
3
  export declare const commandFeatureId: import("@zhin.js/plugin-runtime").FeatureId;
4
+ export interface BoundCommandSource<TDefinition> {
5
+ /** Plugin-local name the runtime registers, e.g. `term/$word`. */
6
+ readonly localName: string;
7
+ /** The definition with `$parameter` bound from the directory shape, as the runtime loads it. */
8
+ readonly definition: TDefinition;
9
+ }
10
+ /**
11
+ * Apply the runtime's convention-path rules to one command module: directory syntax
12
+ * (`[name]`, `[[name]]`, `[...name]`, `[[...name]]`), the `params` declaration the directory
13
+ * implies, and the route shape (one dynamic segment, last). `source` is the command's `index`
14
+ * file or its directory; everything after the last `commands` directory is the command path.
15
+ * Throws the same errors discovery, loading and the command index throw.
16
+ */
17
+ export declare function bindCommandSource<TDefinition extends CommandDefinition>(value: TDefinition, source: string): BoundCommandSource<TDefinition>;
18
+ export declare function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition>;
4
19
  export declare class CommandPathSyntaxError extends TypeError {
5
20
  constructor(file: string, detail?: string);
6
21
  }
package/lib/provider.js CHANGED
@@ -1,7 +1,7 @@
1
- import { basename, dirname, join, sep } from 'node:path';
1
+ import { basename, dirname, join, relative, sep } from 'node:path';
2
2
  import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
3
3
  import { defineFeatureProvider, } from '@zhin.js/feature-kit';
4
- import { CommandIndex } from './command-index.js';
4
+ import { CommandIndex, assertParameterSegment } from './command-index.js';
5
5
  import { bindCommandParameter, parseCommandDefinition, } from './definition.js';
6
6
  export const commandFeatureId = featureId('zhin.command');
7
7
  const commandFiles = {
@@ -21,6 +21,8 @@ const commandFiles = {
21
21
  async load(source, context) {
22
22
  const module = await context.host.loadModule(source.source);
23
23
  const definition = parseCommandDefinition(module.default);
24
+ const commandDirectories = commandDirectoriesOf(context.packageRoot, source.source);
25
+ assertSingleTrailingParameterDirectory(commandDirectories, source.source);
24
26
  const file = parseCommandDirectory(basename(dirname(source.source)));
25
27
  return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
26
28
  },
@@ -77,6 +79,63 @@ function parseCommandDirectory(value) {
77
79
  }
78
80
  return undefined;
79
81
  }
82
+ /** Directory segments between `commands/` and the command's `index.*` file. */
83
+ function commandDirectoriesOf(packageRoot, source) {
84
+ const commandsRoot = join(packageRoot, 'commands');
85
+ const commandDirectory = dirname(source);
86
+ return relative(commandsRoot, commandDirectory)
87
+ .split(sep)
88
+ .filter((segment) => segment !== '');
89
+ }
90
+ /**
91
+ * A command path may contain at most one parameter directory, and it must be the
92
+ * last directory before `index.*`. Further arguments belong in `defineCommand({ params })`.
93
+ */
94
+ function assertSingleTrailingParameterDirectory(directories, source) {
95
+ const parameterDirectories = [];
96
+ directories.forEach((directory, position) => {
97
+ const hint = parseCommandDirectory(directory)?.parameter;
98
+ if (hint)
99
+ parameterDirectories.push({ position, directory, hint });
100
+ });
101
+ const [keptDirectory, ...movedDirectories] = parameterDirectories;
102
+ if (!keptDirectory)
103
+ return;
104
+ const lastPosition = directories.length - 1;
105
+ if (movedDirectories.length === 0 && keptDirectory.position === lastPosition)
106
+ return;
107
+ throw new CommandPathSyntaxError(source, describeParameterDirectoryRule(directories, keptDirectory, movedDirectories));
108
+ }
109
+ function describeParameterDirectoryRule(directories, keptDirectory, movedDirectories) {
110
+ const parameterDirectories = [keptDirectory, ...movedDirectories];
111
+ const currentPath = `commands/${directories.join('/')}/index.ts`;
112
+ const foundDirectories = parameterDirectories.map((entry) => entry.directory).join(', ');
113
+ const staticDirectories = directories.filter((_directory, position) => !parameterDirectories.some((entry) => entry.position === position));
114
+ const suggestedPath = `commands/${[...staticDirectories, keptDirectory.directory].join('/')}/index.ts`;
115
+ const suggestedParams = parameterDirectories
116
+ .map((entry) => describeParamDeclaration(entry.hint))
117
+ .join(', ');
118
+ const movedNote = movedDirectories.length > 0
119
+ ? `move ${movedDirectories.map((entry) => entry.directory).join(', ')} into defineCommand({ params })`
120
+ : 'move the parameter directory to the end of the path';
121
+ return `only the last directory of a command path may be a parameter directory `
122
+ + `([name], [[name]], [...name] or [[...name]]), and a command may have at most one; `
123
+ + `found ${foundDirectories} in ${currentPath}. `
124
+ + `Fix: ${movedNote}, e.g. ${suggestedPath} with `
125
+ + `defineCommand({ params: { ${suggestedParams} } }) `
126
+ + `(the directory parameter must be listed first). `
127
+ + 'See https://zhin.js.org/authoring/commands#目录参数规则';
128
+ }
129
+ function describeParamDeclaration(hint) {
130
+ const type = hint.rest ? 'text' : 'string';
131
+ const flags = [];
132
+ if (hint.optional)
133
+ flags.push('optional: true');
134
+ if (hint.rest)
135
+ flags.push('rest: true');
136
+ const flagText = flags.map((flag) => `, ${flag}`).join('');
137
+ return `${hint.name}: { type: '${type}'${flagText} }`;
138
+ }
80
139
  /** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
81
140
  function resolveParameter(definition, file, source) {
82
141
  const hint = file?.parameter;
@@ -86,8 +145,13 @@ function resolveParameter(definition, file, source) {
86
145
  if (!schema) {
87
146
  throw new CommandPathSyntaxError(source, `missing params.${hint.name} declaration in defineCommand({ params })`);
88
147
  }
89
- if (Object.keys(definition.params ?? {})[0] !== hint.name) {
90
- throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration`);
148
+ const declaredNames = Object.keys(definition.params ?? {});
149
+ if (declaredNames[0] !== hint.name) {
150
+ const reorderedNames = [hint.name, ...declaredNames.filter((name) => name !== hint.name)];
151
+ throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration: the parameter directory `
152
+ + `binds the first argument after the command name, so it has to come first in `
153
+ + `defineCommand({ params }). Current order: { ${declaredNames.join(', ')} }; `
154
+ + `reorder to { ${reorderedNames.join(', ')} }`);
91
155
  }
92
156
  if (schema.rest !== undefined && schema.rest !== hint.rest
93
157
  || schema.optional !== undefined && schema.optional !== hint.optional) {
@@ -119,6 +183,26 @@ function preferredCommandIndex(entries, preferJavaScript) {
119
183
  .sort((left, right) => commandFilePriority(left.name, preferJavaScript)
120
184
  - commandFilePriority(right.name, preferJavaScript))[0]?.name;
121
185
  }
186
+ export function bindCommandSource(value, source) {
187
+ const directory = /^index\.(?:tsx?|[cm]?js)$/u.test(basename(source)) ? dirname(source) : source;
188
+ const parts = directory.split(/[\\/]/u).filter(Boolean);
189
+ const commandsAt = parts.lastIndexOf('commands');
190
+ if (commandsAt < 0 || commandsAt === parts.length - 1) {
191
+ throw new CommandPathSyntaxError(source, 'expected a path under commands/, e.g. commands/foo/index.ts');
192
+ }
193
+ const segments = parts.slice(commandsAt + 1).map((name) => {
194
+ const segment = parseCommandDirectory(name);
195
+ if (!segment) {
196
+ throw new CommandPathSyntaxError(source, `"${name}" is not a command directory name, so the runtime does not discover it`);
197
+ }
198
+ return segment;
199
+ });
200
+ const definition = parseCommandDefinition(value);
201
+ const bound = bindCommandParameter(definition, resolveParameter(definition, segments.at(-1), source));
202
+ const localName = segments.map((segment) => segment.localSegment).join('/');
203
+ assertParameterSegment(localName.split('/'), bound.$parameter, source);
204
+ return Object.freeze({ localName, definition: bound });
205
+ }
122
206
  export class CommandPathSyntaxError extends TypeError {
123
207
  constructor(file, detail = 'expected commands/foo/index.ts or commands/foo/[name]/index.ts') {
124
208
  super(`Invalid Command path ${file}: ${detail}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/command",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "Convention-based Command Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -21,8 +21,8 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "segment-matcher": "^1.0.5",
24
- "@zhin.js/feature-kit": "1.1.6",
25
24
  "@zhin.js/interaction": "1.1.2",
25
+ "@zhin.js/feature-kit": "1.1.7",
26
26
  "@zhin.js/permission": "1.1.7",
27
27
  "@zhin.js/plugin-runtime": "1.1.15"
28
28
  },
@@ -19,7 +19,7 @@ import {
19
19
  import { commandAdapterMatches, toCommandPermissionSubject } from './permission-explanation.js';
20
20
  import { commandUsage, commandHelp, parseCommandArguments, requestsCommandHelp, type CommandValidationIssue } from './arguments.js';
21
21
  import { MENU_PAGE_SIZE, readMenuAppearance, renderCommandMenu, type CommandMenuSegment } from './command-menu.js';
22
- import { admitCommandExecution } from './execution-runtime.js';
22
+ import { admitCommandExecution, admitDirectCommandExecution } from './execution-runtime.js';
23
23
  import { CommandExecutionLimitedError, normalizeExecutionPolicy, type CommandExecutionLimit } from './execution-policy.js';
24
24
  export { CommandExecutionLimitedError } from './execution-policy.js';
25
25
  import {
@@ -153,7 +153,14 @@ export class CommandIndex {
153
153
  for (const key of keys) occupancy.set(key, source);
154
154
  };
155
155
 
156
+ const disabledByOwner = new Map<PluginId, readonly string[]>();
156
157
  for (const slot of slots) {
158
+ let disabled = disabledByOwner.get(slot.owner);
159
+ if (!disabled) {
160
+ disabled = disabledCommands(this.snapshot, slot.owner);
161
+ disabledByOwner.set(slot.owner, disabled);
162
+ }
163
+ if (isDisabledCommand(slot.localName, disabled)) continue;
157
164
  const namespace = commandNamespace(this.snapshot, slot.owner);
158
165
  const primarySegments = [...namespace, ...runtimeSegments(slot.localName)];
159
166
  const parameter = slot.definition.$parameter;
@@ -268,10 +275,15 @@ export class CommandIndex {
268
275
  const supplied = [...match.remaining, ...(args.length > 0 ? [{ type: 'text', data: { text: args.join(' ') } }] : [])];
269
276
  const parsed = parseCommandArguments(definition, supplied, resolveDynamicParams(match.params, undefined), {});
270
277
  if (parsed.issues.length > 0) throw new CommandArgumentValidationError(parsed.issues, commandHelp(match.command.name, definition));
271
- const limit = admitCommandExecution(undefined, match.command.slot.id, match.command.slot.owner, match.command.execution, operationSnapshot);
272
- if (limit) throw new CommandExecutionLimitedError(limit);
273
- return definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
274
- textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
278
+ // No inbound source: execution policy applies per command, never as a blanket refusal.
279
+ const admission = admitDirectCommandExecution(match.command.slot.id, match.command.execution, operationSnapshot);
280
+ if (admission.limit) throw new CommandExecutionLimitedError(admission.limit);
281
+ try {
282
+ return await definition.execute(createCommandContext(operationSnapshot, match.command.slot.owner,
283
+ textArgs(parsed.remaining), parsed.params, undefined, parsed.remaining, undefined, definition.adapter, parsed.options));
284
+ } finally {
285
+ admission.release();
286
+ }
275
287
  }
276
288
 
277
289
  async dispatch(
@@ -560,6 +572,27 @@ function commandNamespace(snapshot: RuntimeSnapshot, owner: PluginId): readonly
560
572
  return Object.freeze([...segments]);
561
573
  }
562
574
 
575
+ /**
576
+ * 插件实例配置 disabledCommands 列出的指令不进入路由表。条目写插件本地的静态路径
577
+ * (不含 commandNamespace 与参数段,如 `fanyi`、`netease login`);写上级路径会连同
578
+ * 其子命令一起禁用。未匹配任何指令的条目被忽略。
579
+ */
580
+ function disabledCommands(snapshot: RuntimeSnapshot, owner: PluginId): readonly string[] {
581
+ const config = snapshot.config.get(owner) as Readonly<Record<string, unknown>> | undefined;
582
+ const value = config?.disabledCommands;
583
+ if (value === undefined) return Object.freeze([]);
584
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
585
+ throw new TypeError(`Invalid disabledCommands for ${owner}: expected an array of command names`);
586
+ }
587
+ return Object.freeze(value.map((entry: string) => splitCommand(entry).join(' ')).filter(Boolean));
588
+ }
589
+
590
+ function isDisabledCommand(localName: string, disabled: readonly string[]): boolean {
591
+ if (disabled.length === 0) return false;
592
+ const path = runtimeSegments(localName).filter((segment) => !segment.startsWith('$')).join(' ');
593
+ return disabled.some((entry) => path === entry || path.startsWith(`${entry} `));
594
+ }
595
+
563
596
  function isCommandNamespaceSegment(value: string): boolean {
564
597
  return /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(value);
565
598
  }
@@ -699,7 +732,11 @@ function exactMessageText(input: CommandMatchInput): string | undefined {
699
732
  return trimmed || undefined;
700
733
  }
701
734
 
702
- function assertParameterSegment(
735
+ /**
736
+ * Route-shape rule for a convention command: at most one dynamic segment, and it is the final one.
737
+ * Shared by the runtime index and {@link bindCommandSource}.
738
+ */
739
+ export function assertParameterSegment(
703
740
  segments: readonly string[],
704
741
  parameter: CommandParameterDefinition | undefined,
705
742
  source: string,
package/src/definition.ts CHANGED
@@ -360,6 +360,19 @@ type TypedCommandContext<TConfig, TInput extends CommandMessage, TAdapter extend
360
360
  readonly options: InferCommandOptions<O>;
361
361
  };
362
362
 
363
+ /**
364
+ * `defineCommand.withConfig<Config>()` 返回的定义函数:config 类型已固定,params / options / 返回值
365
+ * 仍从定义推断。
366
+ * @public
367
+ */
368
+ export type CommandDefiner<TConfig, TInput extends CommandMessage = CommandMessage> = <
369
+ TResult = unknown,
370
+ const P extends CommandParams = {},
371
+ const O extends CommandOptions = {},
372
+ >(
373
+ definition: CommandAuthoringDefinition<TConfig, TResult, TInput, P, O>,
374
+ ) => Readonly<CommandDefinition<TConfig, TResult, TInput, string | undefined>>;
375
+
363
376
  export function defineCommand<
364
377
  TConfig = unknown,
365
378
  TResult = unknown,
@@ -394,6 +407,28 @@ export function defineCommand<
394
407
  >;
395
408
  }
396
409
 
410
+ /**
411
+ * 先固定插件 config 类型,再定义命令;params / options / 返回值仍从定义推断。
412
+ *
413
+ * ```ts
414
+ * export default defineCommand.withConfig<Config>()({
415
+ * params: { count: { type: 'integer' } },
416
+ * execute({ params, config }) { params.count; config.prefix },
417
+ * })
418
+ * ```
419
+ *
420
+ * `defineCommand<Config>({...})` 仍然可用,但 TypeScript 不支持部分推断,显式写了
421
+ * 类型参数后 params 会退化成 `Record<string, CommandParameterValue>`。
422
+ * 这里是独立的属性而不是 `defineCommand()` 重载:`defineCommand` 常被当作值传给
423
+ * `createEndpointCommands(spec, defineCommand)` 这类工厂,重载会让它推断错签名。
424
+ */
425
+ defineCommand.withConfig = function withConfig<
426
+ TConfig = unknown,
427
+ TInput extends CommandMessage = CommandMessage,
428
+ >(): CommandDefiner<TConfig, TInput> {
429
+ return (definition) => defineCommand(definition);
430
+ };
431
+
397
432
  function validateAdapterName(adapter: string | undefined): void {
398
433
  if (adapter !== undefined && (typeof adapter !== 'string' || adapter.trim() === '')) {
399
434
  throw new TypeError('Command adapter must be a non-empty string');
@@ -12,8 +12,16 @@ interface ConversationIdentity {
12
12
 
13
13
  interface AdmissionReader {
14
14
  admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined;
15
+ /**
16
+ * Programmatic call with no inbound source (e.g. `CommandIndex.execute`). The policy is keyed by command only:
17
+ * there is no sender or conversation to budget, and no reply channel for progress feedback.
18
+ * On success the caller must invoke `release` once the execution settles.
19
+ */
20
+ admitDirect(capability: string, policy: CommandExecutionPolicy): DirectAdmission;
15
21
  }
16
22
 
23
+ type DirectAdmission = { readonly limit: CommandExecutionLimit; readonly release?: undefined } | { readonly limit?: undefined; readonly release: () => void };
24
+
17
25
  /** @internal Admission-only Root resource; no bind or finish authority is published. */
18
26
  export const commandExecutionAdmissionToken = createToken<AdmissionReader>('zhin.command.execution-admission');
19
27
 
@@ -56,7 +64,8 @@ export class CommandExecutionBudget {
56
64
  this.#clock = options.clock ?? (() => performance.now());
57
65
  this.#capacity = options.capacity ?? 10_000;
58
66
  if (!Number.isSafeInteger(this.#capacity) || this.#capacity < 1) throw new TypeError('Invalid command budget capacity');
59
- this.admission = Object.freeze<AdmissionReader>({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot) });
67
+ this.admission = Object.freeze<AdmissionReader>({ admit: (source, capability, owner, policy, snapshot) => this.#admit(source, capability, owner, policy, snapshot),
68
+ admitDirect: (capability, policy) => this.#admitDirect(capability, policy) });
60
69
  }
61
70
 
62
71
  openOperation(input: {
@@ -110,33 +119,54 @@ export class CommandExecutionBudget {
110
119
  });
111
120
  }
112
121
 
113
- #admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined {
114
- const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
115
- const operation = binding?.operation;
116
- if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted) return limited('operation_unavailable');
117
- if (!operation.key) return limited('identity_unavailable');
118
- const key = JSON.stringify([capability, ...operation.key]);
119
- // Re-entering the same capability is a second attempt, rather than a second free execution.
122
+ #admitDirect(capability: string, policy: CommandExecutionPolicy): DirectAdmission {
123
+ return this.#acquire(JSON.stringify([capability, 'direct']), policy);
124
+ }
125
+
126
+ /** Shared entry accounting: concurrency, cooldown and capacity. Returns a one-shot release on success. */
127
+ #acquire(key: string, policy: CommandExecutionPolicy): DirectAdmission {
120
128
  const now = this.#clock();
121
129
  let entry = this.#entries.get(key);
122
- if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent) return limited('busy');
123
- if (entry && entry.cooldownUntil > now) return Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) });
130
+ if (entry && policy.maxConcurrent !== undefined && entry.active >= policy.maxConcurrent) return { limit: limited('busy') };
131
+ if (entry && entry.cooldownUntil > now) return { limit: Object.freeze({ reason: 'cooldown', retryAfterMs: Math.ceil(entry.cooldownUntil - now) }) };
124
132
  if (!entry) {
125
133
  if (this.#entries.size >= this.#capacity) {
126
134
  for (const [oldKey, old] of this.#entries) if (old.active === 0 && old.cooldownUntil <= now) this.#entries.delete(oldKey);
127
- if (this.#entries.size >= this.#capacity) return limited('capacity');
135
+ if (this.#entries.size >= this.#capacity) {
136
+ // Still full of cooling identities: drop the oldest idle one. Forgetting a cooldown is acceptable;
137
+ // refusing every new identity is not. Only entries with running operations are never evicted.
138
+ for (const [oldKey, old] of this.#entries) if (old.active === 0) { this.#entries.delete(oldKey); break; }
139
+ }
140
+ if (this.#entries.size >= this.#capacity) return { limit: limited('capacity') };
128
141
  }
129
142
  entry = { active: 0, cooldownUntil: 0 };
130
143
  this.#entries.set(key, entry);
131
144
  }
132
145
  entry.active++;
133
- operation.admitted = true;
134
146
  entry.cooldownUntil = now + (policy.cooldownMs ?? 0);
135
147
  const acceptedEntry = entry;
136
- operation.releases.push(() => {
137
- acceptedEntry.active--;
138
- if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock()) this.#entries.delete(key);
139
- });
148
+ let released = false;
149
+ return {
150
+ release: () => {
151
+ if (released) return;
152
+ released = true;
153
+ acceptedEntry.active--;
154
+ if (acceptedEntry.active === 0 && acceptedEntry.cooldownUntil <= this.#clock()) this.#entries.delete(key);
155
+ },
156
+ };
157
+ }
158
+
159
+ #admit(source: unknown, capability: string, owner: PluginId, policy: CommandExecutionPolicy, snapshot: RuntimeSnapshot): CommandExecutionLimit | undefined {
160
+ const binding = source !== null && typeof source === 'object' ? this.#views.get(source) : undefined;
161
+ const operation = binding?.operation;
162
+ if (!operation?.active || operation.snapshot !== snapshot || binding?.owner !== owner || operation.signal.aborted) return limited('operation_unavailable');
163
+ if (!operation.key) return limited('identity_unavailable');
164
+ const key = JSON.stringify([capability, ...operation.key]);
165
+ // Re-entering the same capability is a second attempt, rather than a second free execution.
166
+ const acquired = this.#acquire(key, policy);
167
+ if (acquired.limit) return acquired.limit;
168
+ operation.admitted = true;
169
+ operation.releases.push(acquired.release);
140
170
  if (policy.progressAfterMs !== undefined && !operation.progressStopped && !operation.accepted.has(capability)) {
141
171
  const timer = setTimeout(() => {
142
172
  operation.timers.delete(timer);
@@ -163,3 +193,10 @@ export function admitCommandExecution(source: unknown, capability: string, owner
163
193
  const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id) as AdmissionReader | undefined;
164
194
  return reader ? reader.admit(source, capability, owner, policy, snapshot) : limited('service_unavailable');
165
195
  }
196
+
197
+ /** @internal Programmatic (source-less) admission: keyed by command only, never refused for lacking a source. */
198
+ export function admitDirectCommandExecution(capability: string, policy: CommandExecutionPolicy | undefined, snapshot: RuntimeSnapshot): DirectAdmission {
199
+ if (!policy) return { release: () => {} };
200
+ const reader = snapshot.resources.get(snapshot.root)?.get(commandExecutionAdmissionToken.id) as AdmissionReader | undefined;
201
+ return reader ? reader.admitDirect(capability, policy) : { limit: limited('service_unavailable') };
202
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export * from './command-index.js';
8
8
  export * from './definition.js';
9
9
  export * from './permission-explanation.js';
10
10
  export {
11
+ CONVERSATION_MANAGER_PERMIT,
12
+ canManageConversation,
11
13
  assertBuiltinPermits,
12
14
  checkBuiltinPermit,
13
15
  checkBuiltinPermitList,
@@ -18,8 +20,10 @@ export {
18
20
  type PermitKind,
19
21
  } from './permit.js';
20
22
  export {
23
+ bindCommandSource,
21
24
  CommandPathSyntaxError,
22
25
  commandFeatureId,
26
+ type BoundCommandSource,
23
27
  default as commandFeature,
24
28
  } from './provider.js';
25
29
  export { default } from './provider.js';
package/src/permit.ts CHANGED
@@ -2,6 +2,20 @@
2
2
  * Re-export permit 解析/校验 from @zhin.js/permission (SSOT)。
3
3
  * 保留 CommandSession 兼容签名供 command-index 过渡使用。
4
4
  */
5
+ import { checkBuiltinPermit, type PermissionSubject } from '@zhin.js/permission';
6
+
7
+ /** 会话管理者:群主 / 群管理员 / 机器人主人(适配器投影的规范角色名)。 */
8
+ export const CONVERSATION_MANAGER_PERMIT = 'role(owner,admin,master)';
9
+
10
+ /**
11
+ * 「私聊,或 owner / admin / master」。`permit` 列表只支持 AND,
12
+ * 订阅类命令需要这个 OR 规则时用它,并以命令会话的 `scene` / `sender.role` 为输入。
13
+ */
14
+ export function canManageConversation(subject: PermissionSubject): boolean {
15
+ return checkBuiltinPermit('private()', subject)
16
+ || checkBuiltinPermit(CONVERSATION_MANAGER_PERMIT, subject);
17
+ }
18
+
5
19
  export {
6
20
  type PermitKind,
7
21
  type ParsedPermit,
package/src/provider.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { basename, dirname, join, sep } from 'node:path';
1
+ import { basename, dirname, join, relative, sep } from 'node:path';
2
2
  import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
3
3
  import {
4
4
  defineFeatureProvider,
@@ -6,7 +6,7 @@ import {
6
6
  type DiscoveredSource,
7
7
  type SourceConvention,
8
8
  } from '@zhin.js/feature-kit';
9
- import { CommandIndex } from './command-index.js';
9
+ import { CommandIndex, assertParameterSegment } from './command-index.js';
10
10
  import {
11
11
  bindCommandParameter,
12
12
  parseCommandDefinition,
@@ -32,6 +32,8 @@ const commandFiles: SourceConvention = {
32
32
  async load(source, context) {
33
33
  const module = await context.host.loadModule<{ default?: unknown }>(source.source);
34
34
  const definition = parseCommandDefinition(module.default);
35
+ const commandDirectories = commandDirectoriesOf(context.packageRoot, source.source);
36
+ assertSingleTrailingParameterDirectory(commandDirectories, source.source);
35
37
  const file = parseCommandDirectory(basename(dirname(source.source)));
36
38
  return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
37
39
  },
@@ -109,6 +111,76 @@ function parseCommandDirectory(value: string): ParsedCommandFile | undefined {
109
111
  return undefined;
110
112
  }
111
113
 
114
+ /** Directory segments between `commands/` and the command's `index.*` file. */
115
+ function commandDirectoriesOf(packageRoot: string, source: string): string[] {
116
+ const commandsRoot = join(packageRoot, 'commands');
117
+ const commandDirectory = dirname(source);
118
+ return relative(commandsRoot, commandDirectory)
119
+ .split(sep)
120
+ .filter((segment) => segment !== '');
121
+ }
122
+
123
+ interface ParameterDirectory {
124
+ readonly position: number;
125
+ readonly directory: string;
126
+ readonly hint: CommandParameterHint;
127
+ }
128
+
129
+ /**
130
+ * A command path may contain at most one parameter directory, and it must be the
131
+ * last directory before `index.*`. Further arguments belong in `defineCommand({ params })`.
132
+ */
133
+ function assertSingleTrailingParameterDirectory(directories: readonly string[], source: string): void {
134
+ const parameterDirectories: ParameterDirectory[] = [];
135
+ directories.forEach((directory, position) => {
136
+ const hint = parseCommandDirectory(directory)?.parameter;
137
+ if (hint) parameterDirectories.push({ position, directory, hint });
138
+ });
139
+ const [keptDirectory, ...movedDirectories] = parameterDirectories;
140
+ if (!keptDirectory) return;
141
+ const lastPosition = directories.length - 1;
142
+ if (movedDirectories.length === 0 && keptDirectory.position === lastPosition) return;
143
+ throw new CommandPathSyntaxError(
144
+ source,
145
+ describeParameterDirectoryRule(directories, keptDirectory, movedDirectories),
146
+ );
147
+ }
148
+
149
+ function describeParameterDirectoryRule(
150
+ directories: readonly string[],
151
+ keptDirectory: ParameterDirectory,
152
+ movedDirectories: readonly ParameterDirectory[],
153
+ ): string {
154
+ const parameterDirectories = [keptDirectory, ...movedDirectories];
155
+ const currentPath = `commands/${directories.join('/')}/index.ts`;
156
+ const foundDirectories = parameterDirectories.map((entry) => entry.directory).join(', ');
157
+ const staticDirectories = directories.filter((_directory, position) =>
158
+ !parameterDirectories.some((entry) => entry.position === position));
159
+ const suggestedPath = `commands/${[...staticDirectories, keptDirectory.directory].join('/')}/index.ts`;
160
+ const suggestedParams = parameterDirectories
161
+ .map((entry) => describeParamDeclaration(entry.hint))
162
+ .join(', ');
163
+ const movedNote = movedDirectories.length > 0
164
+ ? `move ${movedDirectories.map((entry) => entry.directory).join(', ')} into defineCommand({ params })`
165
+ : 'move the parameter directory to the end of the path';
166
+ return `only the last directory of a command path may be a parameter directory `
167
+ + `([name], [[name]], [...name] or [[...name]]), and a command may have at most one; `
168
+ + `found ${foundDirectories} in ${currentPath}. `
169
+ + `Fix: ${movedNote}, e.g. ${suggestedPath} with `
170
+ + `defineCommand({ params: { ${suggestedParams} } }) `
171
+ + `(the directory parameter must be listed first). `
172
+ + 'See https://zhin.js.org/authoring/commands#目录参数规则';
173
+ }
174
+
175
+ function describeParamDeclaration(hint: CommandParameterHint): string {
176
+ const type = hint.rest ? 'text' : 'string';
177
+ const flags: string[] = [];
178
+ if (hint.optional) flags.push('optional: true');
179
+ if (hint.rest) flags.push('rest: true');
180
+ const flagText = flags.map((flag) => `, ${flag}`).join('');
181
+ return `${hint.name}: { type: '${type}'${flagText} }`;
182
+ }
183
+
112
184
  /** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
113
185
  function resolveParameter(
114
186
  definition: CommandDefinition,
@@ -124,8 +196,16 @@ function resolveParameter(
124
196
  `missing params.${hint.name} declaration in defineCommand({ params })`,
125
197
  );
126
198
  }
127
- if (Object.keys(definition.params ?? {})[0] !== hint.name) {
128
- throw new CommandPathSyntaxError(source, `params.${hint.name} must be the first parameter declaration`);
199
+ const declaredNames = Object.keys(definition.params ?? {});
200
+ if (declaredNames[0] !== hint.name) {
201
+ const reorderedNames = [hint.name, ...declaredNames.filter((name) => name !== hint.name)];
202
+ throw new CommandPathSyntaxError(
203
+ source,
204
+ `params.${hint.name} must be the first parameter declaration: the parameter directory `
205
+ + `binds the first argument after the command name, so it has to come first in `
206
+ + `defineCommand({ params }). Current order: { ${declaredNames.join(', ')} }; `
207
+ + `reorder to { ${reorderedNames.join(', ')} }`,
208
+ );
129
209
  }
130
210
  if (schema.rest !== undefined && schema.rest !== hint.rest
131
211
  || schema.optional !== undefined && schema.optional !== hint.optional) {
@@ -166,6 +246,46 @@ function preferredCommandIndex(
166
246
  - commandFilePriority(right.name, preferJavaScript))[0]?.name;
167
247
  }
168
248
 
249
+ export interface BoundCommandSource<TDefinition> {
250
+ /** Plugin-local name the runtime registers, e.g. `term/$word`. */
251
+ readonly localName: string;
252
+ /** The definition with `$parameter` bound from the directory shape, as the runtime loads it. */
253
+ readonly definition: TDefinition;
254
+ }
255
+
256
+ /**
257
+ * Apply the runtime's convention-path rules to one command module: directory syntax
258
+ * (`[name]`, `[[name]]`, `[...name]`, `[[...name]]`), the `params` declaration the directory
259
+ * implies, and the route shape (one dynamic segment, last). `source` is the command's `index`
260
+ * file or its directory; everything after the last `commands` directory is the command path.
261
+ * Throws the same errors discovery, loading and the command index throw.
262
+ */
263
+ export function bindCommandSource<TDefinition extends CommandDefinition>(
264
+ value: TDefinition,
265
+ source: string,
266
+ ): BoundCommandSource<TDefinition>;
267
+ export function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition>;
268
+ export function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition> {
269
+ const directory = /^index\.(?:tsx?|[cm]?js)$/u.test(basename(source)) ? dirname(source) : source;
270
+ const parts = directory.split(/[\\/]/u).filter(Boolean);
271
+ const commandsAt = parts.lastIndexOf('commands');
272
+ if (commandsAt < 0 || commandsAt === parts.length - 1) {
273
+ throw new CommandPathSyntaxError(source, 'expected a path under commands/, e.g. commands/foo/index.ts');
274
+ }
275
+ const segments = parts.slice(commandsAt + 1).map((name) => {
276
+ const segment = parseCommandDirectory(name);
277
+ if (!segment) {
278
+ throw new CommandPathSyntaxError(source, `"${name}" is not a command directory name, so the runtime does not discover it`);
279
+ }
280
+ return segment;
281
+ });
282
+ const definition = parseCommandDefinition(value);
283
+ const bound = bindCommandParameter(definition, resolveParameter(definition, segments.at(-1), source));
284
+ const localName = segments.map((segment) => segment.localSegment).join('/');
285
+ assertParameterSegment(localName.split('/'), bound.$parameter, source);
286
+ return Object.freeze({ localName, definition: bound });
287
+ }
288
+
169
289
  export class CommandPathSyntaxError extends TypeError {
170
290
  constructor(
171
291
  file: string,