@jsbot-tellus/core 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +13 -17
  2. package/dist/app.js +12 -40
  3. package/dist/app.js.map +1 -1
  4. package/dist/core/config/schema.d.ts +0 -2
  5. package/dist/core/config/schema.d.ts.map +1 -1
  6. package/dist/core/config/schema.js +0 -1
  7. package/dist/core/config/schema.js.map +1 -1
  8. package/dist/core/dispatch/command.d.ts +24 -2
  9. package/dist/core/dispatch/command.d.ts.map +1 -1
  10. package/dist/core/dispatch/command.js +297 -60
  11. package/dist/core/dispatch/command.js.map +1 -1
  12. package/dist/core/dispatch/router.d.ts +17 -3
  13. package/dist/core/dispatch/router.d.ts.map +1 -1
  14. package/dist/core/dispatch/router.js +38 -2
  15. package/dist/core/dispatch/router.js.map +1 -1
  16. package/dist/core/index.d.ts +1 -1
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/plugin/command-params.d.ts +15 -0
  19. package/dist/core/plugin/command-params.d.ts.map +1 -0
  20. package/dist/core/plugin/command-params.js +427 -0
  21. package/dist/core/plugin/command-params.js.map +1 -0
  22. package/dist/core/plugin/context.d.ts.map +1 -1
  23. package/dist/core/plugin/context.js +27 -0
  24. package/dist/core/plugin/context.js.map +1 -1
  25. package/dist/core/plugin/install-dir.d.ts +6 -9
  26. package/dist/core/plugin/install-dir.d.ts.map +1 -1
  27. package/dist/core/plugin/install-dir.js +99 -79
  28. package/dist/core/plugin/install-dir.js.map +1 -1
  29. package/dist/core/plugin/install-validate.d.ts +2 -1
  30. package/dist/core/plugin/install-validate.d.ts.map +1 -1
  31. package/dist/core/plugin/install-validate.js +25 -17
  32. package/dist/core/plugin/install-validate.js.map +1 -1
  33. package/dist/core/plugin/installer.d.ts +3 -3
  34. package/dist/core/plugin/installer.d.ts.map +1 -1
  35. package/dist/core/plugin/installer.js +143 -140
  36. package/dist/core/plugin/installer.js.map +1 -1
  37. package/dist/core/plugin/loader.d.ts +1 -0
  38. package/dist/core/plugin/loader.d.ts.map +1 -1
  39. package/dist/core/plugin/loader.js +24 -2
  40. package/dist/core/plugin/loader.js.map +1 -1
  41. package/dist/core/plugin/types.d.ts +11 -3
  42. package/dist/core/plugin/types.d.ts.map +1 -1
  43. package/dist/core/plugin/types.js.map +1 -1
  44. package/dist/plugins/admin/index.d.ts.map +1 -1
  45. package/dist/plugins/admin/index.js +119 -86
  46. package/dist/plugins/admin/index.js.map +1 -1
  47. package/dist/plugins/core/index.d.ts.map +1 -1
  48. package/dist/plugins/core/index.js +4 -1
  49. package/dist/plugins/core/index.js.map +1 -1
  50. package/docs/architecture.md +22 -24
  51. package/docs/configuration.md +19 -23
  52. package/docs/feature-parity.md +4 -4
  53. package/docs/migration.md +13 -13
  54. package/docs/plugin-api.md +156 -26
  55. package/docs/plugin-development.md +390 -0
  56. package/docs/release.md +2 -2
  57. package/examples/plugins/hello/README.md +10 -5
  58. package/examples/plugins/hello/index.js +3 -6
  59. package/package.json +2 -1
  60. package/src/app.ts +16 -55
  61. package/src/core/config/schema.ts +0 -1
  62. package/src/core/dispatch/command.ts +474 -70
  63. package/src/core/dispatch/router.ts +77 -3
  64. package/src/core/index.ts +2 -0
  65. package/src/core/plugin/command-params.ts +595 -0
  66. package/src/core/plugin/context.ts +47 -2
  67. package/src/core/plugin/install-dir.ts +117 -111
  68. package/src/core/plugin/install-validate.ts +36 -34
  69. package/src/core/plugin/installer.ts +228 -184
  70. package/src/core/plugin/loader.ts +36 -2
  71. package/src/core/plugin/types.ts +23 -3
  72. package/src/plugins/admin/index.ts +302 -233
  73. package/src/plugins/core/index.ts +5 -1
  74. package/dist/core/plugin/installed.d.ts +0 -9
  75. package/dist/core/plugin/installed.d.ts.map +0 -1
  76. package/dist/core/plugin/installed.js +0 -113
  77. package/dist/core/plugin/installed.js.map +0 -1
  78. package/src/core/plugin/installed.ts +0 -165
@@ -1,15 +1,17 @@
1
- import { readFile, rm, writeFile } from 'node:fs/promises'
1
+ import { randomUUID } from 'node:crypto'
2
+ import { cp, mkdir, rename, rm, writeFile } from 'node:fs/promises'
2
3
  import { join } from 'node:path'
3
4
  import type { InstallerConfig } from '../config/schema.js'
4
5
  import type { Logger } from '../logging/logger.js'
5
6
  import {
6
- CORE_PACKAGE_NAME,
7
- INSTALL_STATE_FILE,
8
7
  ensureCoreLink,
9
- ensureInstallDir,
10
- readDependencies,
8
+ ensureInstallRoot,
9
+ packageDirectoryName,
10
+ pathExists,
11
11
  readInstallState,
12
- readInstalledVersion,
12
+ readJsonFile,
13
+ readPackageVersion,
14
+ VALIDATE_SCRIPT_FILE,
13
15
  writeInstallState,
14
16
  type InstalledPackageRecord,
15
17
  type InstallState,
@@ -42,6 +44,7 @@ export class InstallerError extends Error {
42
44
  }
43
45
 
44
46
  export interface InstallerOptions {
47
+ /** Plugins root. Installed packages are placed in subdirectories here. */
45
48
  dir: string
46
49
  config: InstallerConfig
47
50
  logger: Logger
@@ -52,13 +55,14 @@ export interface InstallerOptions {
52
55
  now?: (() => Date) | undefined
53
56
  }
54
57
 
55
- interface BackupFile {
56
- path: string
57
- content: string | null
58
- }
59
-
60
58
  const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i
61
59
  const NPM_FLAGS = ['--no-audit', '--no-fund', '--loglevel=error']
60
+ const STAGING_MANIFEST = {
61
+ name: '@jsbot-tellus/plugin-staging',
62
+ private: true,
63
+ version: '0.0.0',
64
+ type: 'module',
65
+ }
62
66
 
63
67
  function messageOf(error: unknown): string {
64
68
  return error instanceof Error ? error.message : String(error)
@@ -74,6 +78,15 @@ function asInstallerError(error: unknown): InstallerError {
74
78
  return new InstallerError(messageOf(error), { cause: error })
75
79
  }
76
80
 
81
+ function installDirectoryName(
82
+ packageName: string,
83
+ pluginIds: readonly string[]
84
+ ): string {
85
+ const only = pluginIds.length === 1 ? pluginIds[0] : undefined
86
+
87
+ return only ?? packageDirectoryName(packageName)
88
+ }
89
+
77
90
  export class NpmPluginInstaller implements PluginInstaller {
78
91
  readonly enabled: boolean
79
92
 
@@ -101,30 +114,28 @@ export class NpmPluginInstaller implements PluginInstaller {
101
114
  }
102
115
 
103
116
  async list(): Promise<InstalledPackageInfo[]> {
104
- const dependencies = await readDependencies(this.dir)
105
117
  const state = await readInstallState(this.dir)
106
118
  const loadedIds = this.reservedIds()
107
119
  const result: InstalledPackageInfo[] = []
108
120
 
109
- for (const name of Object.keys(dependencies).sort()) {
110
- if (name === CORE_PACKAGE_NAME) continue
111
-
121
+ for (const name of Object.keys(state.packages).sort()) {
112
122
  const record = state.packages[name]
123
+
124
+ if (!record) continue
125
+
113
126
  const version =
114
- (await readInstalledVersion(this.dir, name)) ??
115
- record?.version ??
116
- ''
117
- const pluginIds = record?.pluginIds ?? []
127
+ (await readPackageVersion(join(this.dir, record.dir))) ??
128
+ record.version
118
129
 
119
130
  result.push({
120
131
  packageName: name,
121
132
  version,
122
- spec: record?.spec ?? dependencies[name] ?? name,
123
- pluginIds,
124
- installedAt: record?.installedAt ?? '',
133
+ spec: record.spec,
134
+ pluginIds: [...record.pluginIds],
135
+ installedAt: record.installedAt,
125
136
  loaded:
126
- pluginIds.length > 0 &&
127
- pluginIds.every((id) => loadedIds.has(id)),
137
+ record.pluginIds.length > 0 &&
138
+ record.pluginIds.every((id) => loadedIds.has(id)),
128
139
  })
129
140
  }
130
141
 
@@ -157,13 +168,14 @@ export class NpmPluginInstaller implements PluginInstaller {
157
168
  }
158
169
 
159
170
  return this.serialize(async () => {
160
- await ensureInstallDir(this.dir)
161
-
162
- const dependencies = await readDependencies(this.dir)
163
171
  const state = await readInstallState(this.dir)
164
- this.assertStateCovers(dependencies, state)
165
- const name = this.resolveTarget(target, dependencies, state)
172
+ const name = this.resolveTarget(target, state)
166
173
  const record = state.packages[name]
174
+
175
+ if (!record) {
176
+ throw new InstallerError(`未找到已安装的插件包: ${name}`)
177
+ }
178
+
167
179
  const warnings = this.removalWarnings(name, record, state)
168
180
  const dependents = this.dependentWarnings(name, record, state)
169
181
 
@@ -177,36 +189,52 @@ export class NpmPluginInstaller implements PluginInstaller {
177
189
  )
178
190
  }
179
191
 
180
- const backup = await this.backup()
192
+ const destination = join(this.dir, record.dir)
193
+ const backup = this.temporaryPath('backup')
194
+ const existed = await pathExists(destination)
181
195
 
182
196
  try {
183
- await this.runner.run(
184
- [
185
- 'uninstall',
186
- name,
187
- '--prefix',
188
- this.dir,
189
- ...NPM_FLAGS,
190
- '--ignore-scripts',
191
- ],
192
- { timeoutMs: this.config.timeoutMs, signal }
193
- )
194
- await ensureCoreLink(this.dir)
197
+ if (existed) await rename(destination, backup)
195
198
 
196
- delete state.packages[name]
197
- await writeInstallState(this.dir, state)
198
-
199
- this.logger.info(`removed plugin package ${name}`)
199
+ try {
200
+ delete state.packages[name]
201
+ await writeInstallState(this.dir, state)
202
+ } catch (error) {
203
+ if (existed) {
204
+ await rename(backup, destination).catch(
205
+ (restoreError) => {
206
+ this.logger.error(
207
+ 'failed to restore plugin directory',
208
+ restoreError
209
+ )
210
+ }
211
+ )
212
+ }
200
213
 
201
- return {
202
- packageName: name,
203
- pluginIds: record?.pluginIds ?? [],
204
- warnings,
214
+ throw error
205
215
  }
206
216
  } catch (error) {
207
- await this.rollback(backup, error)
208
217
  throw asInstallerError(error)
209
218
  }
219
+
220
+ if (existed) {
221
+ await rm(backup, { recursive: true, force: true }).catch(
222
+ (error) => {
223
+ this.logger.warn(
224
+ `failed to remove backup directory ${backup}`,
225
+ error
226
+ )
227
+ }
228
+ )
229
+ }
230
+
231
+ this.logger.info(`removed plugin package ${name}`)
232
+
233
+ return {
234
+ packageName: name,
235
+ pluginIds: [...record.pluginIds],
236
+ warnings,
237
+ }
210
238
  })
211
239
  }
212
240
 
@@ -221,25 +249,15 @@ export class NpmPluginInstaller implements PluginInstaller {
221
249
  }
222
250
 
223
251
  return this.serialize(async () => {
224
- await ensureInstallDir(this.dir)
225
-
226
- const dependencies = await readDependencies(this.dir)
227
252
  const state = await readInstallState(this.dir)
228
- this.assertStateCovers(dependencies, state)
229
253
  const names =
230
254
  target === undefined || target.trim().length === 0
231
- ? Object.keys(dependencies)
232
- .filter((name) => name !== CORE_PACKAGE_NAME)
233
- .sort()
234
- : [this.resolveTarget(target, dependencies, state)]
255
+ ? Object.keys(state.packages).sort()
256
+ : [this.resolveTarget(target, state)]
235
257
 
236
258
  const updated: PluginInstallResult[] = []
237
259
  const failed: Array<{ packageName: string; message: string }> = []
238
260
 
239
- if (names.length === 0) {
240
- return { updated, failed }
241
- }
242
-
243
261
  for (const name of names) {
244
262
  if (signal?.aborted) {
245
263
  failed.push({ packageName: name, message: '操作已取消' })
@@ -275,20 +293,30 @@ export class NpmPluginInstaller implements PluginInstaller {
275
293
  spec: PackageSpec,
276
294
  signal: AbortSignal | undefined
277
295
  ): Promise<PluginInstallResult> {
278
- await ensureInstallDir(this.dir)
296
+ await ensureInstallRoot(this.dir)
279
297
 
280
- const previousState = await readInstallState(this.dir)
281
- const dependencies = await readDependencies(this.dir)
282
- this.assertStateCovers(dependencies, previousState)
283
- const backup = await this.backup()
298
+ const state = await readInstallState(this.dir)
299
+ const previous = state.packages[spec.name]
300
+ const staging = this.temporaryPath('staging')
301
+ const backup = this.temporaryPath('backup')
302
+ let target: string | null = null
303
+ let movedTarget = false
304
+ let movedBackup = false
284
305
 
285
306
  try {
307
+ await mkdir(staging, { recursive: true })
308
+ await writeFile(
309
+ join(staging, 'package.json'),
310
+ `${JSON.stringify(STAGING_MANIFEST, null, 4)}\n`,
311
+ 'utf8'
312
+ )
313
+
286
314
  await this.runner.run(
287
315
  [
288
316
  'install',
289
317
  spec.raw,
290
318
  '--prefix',
291
- this.dir,
319
+ staging,
292
320
  '--save-exact',
293
321
  ...NPM_FLAGS,
294
322
  ...(this.config.allowScripts ? [] : ['--ignore-scripts']),
@@ -297,29 +325,77 @@ export class NpmPluginInstaller implements PluginInstaller {
297
325
  { timeoutMs: this.config.timeoutMs, signal }
298
326
  )
299
327
 
300
- await ensureCoreLink(this.dir)
328
+ const packagePath = join(staging, 'node_modules', spec.name)
329
+ const manifest = await readJsonFile(
330
+ join(packagePath, 'package.json')
331
+ )
332
+
333
+ if (
334
+ typeof manifest !== 'object' ||
335
+ manifest === null ||
336
+ Array.isArray(manifest)
337
+ ) {
338
+ throw new InstallerError(`npm 没有安装插件包: ${spec.name}`)
339
+ }
340
+
341
+ const flat = join(staging, 'flat')
342
+
343
+ await cp(packagePath, flat, {
344
+ recursive: true,
345
+ dereference: true,
346
+ })
347
+ await rename(
348
+ join(staging, 'node_modules'),
349
+ join(flat, 'node_modules')
350
+ )
351
+ await rm(join(flat, 'node_modules', spec.name), {
352
+ recursive: true,
353
+ force: true,
354
+ })
355
+ await ensureCoreLink(flat)
301
356
 
302
- const validated = await this.validate(this.dir, spec.name, {
357
+ const validated = await this.validate(flat, spec.name, {
303
358
  timeoutMs: this.config.timeoutMs,
304
359
  signal,
360
+ script: join(this.dir, '.jsbot', VALIDATE_SCRIPT_FILE),
305
361
  })
306
362
 
307
- this.checkPlugins(spec.name, validated, previousState)
363
+ this.checkPlugins(spec.name, validated, state)
308
364
 
309
- const version = await readInstalledVersion(this.dir, spec.name)
365
+ const version = await readPackageVersion(flat)
310
366
 
311
367
  if (version === undefined) {
312
368
  throw new InstallerError(`无法读取已安装包的版本: ${spec.name}`)
313
369
  }
314
370
 
315
371
  const pluginIds = validated.map((plugin) => plugin.id)
316
- const state = previousState
372
+ const directory =
373
+ previous?.dir ?? installDirectoryName(spec.name, pluginIds)
374
+
375
+ target = join(this.dir, directory)
376
+
377
+ await this.assertTargetAvailable(
378
+ state,
379
+ spec.name,
380
+ directory,
381
+ target,
382
+ previous
383
+ )
384
+
317
385
  const warnings = this.configuredWarnings(
318
- (previousState.packages[spec.name]?.pluginIds ?? []).filter(
386
+ (previous?.pluginIds ?? []).filter(
319
387
  (id) => !pluginIds.includes(id)
320
388
  )
321
389
  )
322
390
 
391
+ if (await pathExists(target)) {
392
+ await rename(target, backup)
393
+ movedBackup = true
394
+ }
395
+
396
+ await rename(flat, target)
397
+ movedTarget = true
398
+
323
399
  state.packages[spec.name] = {
324
400
  spec: spec.raw,
325
401
  version,
@@ -328,11 +404,28 @@ export class NpmPluginInstaller implements PluginInstaller {
328
404
  validated.map((plugin) => [plugin.id, plugin.dependencies])
329
405
  ),
330
406
  installedAt: this.now().toISOString(),
407
+ dir: directory,
331
408
  }
332
409
 
333
410
  await writeInstallState(this.dir, state)
334
411
 
335
- this.logger.info(`installed plugin package ${spec.name}@${version}`)
412
+ movedTarget = false
413
+
414
+ if (movedBackup) {
415
+ await rm(backup, { recursive: true, force: true }).catch(
416
+ (error) => {
417
+ this.logger.warn(
418
+ `failed to remove backup directory ${backup}`,
419
+ error
420
+ )
421
+ }
422
+ )
423
+ movedBackup = false
424
+ }
425
+
426
+ this.logger.info(
427
+ `installed plugin package ${spec.name}@${version} into ${target}`
428
+ )
336
429
 
337
430
  return {
338
431
  packageName: spec.name,
@@ -341,8 +434,62 @@ export class NpmPluginInstaller implements PluginInstaller {
341
434
  warnings,
342
435
  }
343
436
  } catch (error) {
344
- await this.rollback(backup, error)
437
+ if (movedTarget && target !== null) {
438
+ await rm(target, { recursive: true, force: true }).catch(
439
+ (cleanupError) => {
440
+ this.logger.error(
441
+ 'failed to remove partially installed plugin',
442
+ cleanupError
443
+ )
444
+ }
445
+ )
446
+ }
447
+
448
+ if (movedBackup && target !== null) {
449
+ await rename(backup, target).catch((restoreError) => {
450
+ this.logger.error(
451
+ 'failed to restore previous plugin directory',
452
+ restoreError
453
+ )
454
+ })
455
+ movedBackup = false
456
+ }
457
+
345
458
  throw asInstallerError(error)
459
+ } finally {
460
+ await rm(staging, { recursive: true, force: true }).catch(
461
+ (error) => {
462
+ this.logger.warn(
463
+ `failed to remove staging directory ${staging}`,
464
+ error
465
+ )
466
+ }
467
+ )
468
+ }
469
+ }
470
+
471
+ private async assertTargetAvailable(
472
+ state: InstallState,
473
+ packageName: string,
474
+ directory: string,
475
+ target: string,
476
+ previous: InstalledPackageRecord | undefined
477
+ ): Promise<void> {
478
+ for (const [name, record] of Object.entries(state.packages)) {
479
+ if (name === packageName) continue
480
+ if (record.dir !== directory) continue
481
+
482
+ throw new InstallerError(
483
+ `插件目录已被其它已安装包使用: ${record.dir}(${name})`
484
+ )
485
+ }
486
+
487
+ if (previous !== undefined) return
488
+
489
+ if (await pathExists(target)) {
490
+ throw new InstallerError(
491
+ `安装目录已存在且不是由安装器管理: ${target}。请先移除该本地插件或为其改名。`
492
+ )
346
493
  }
347
494
  }
348
495
 
@@ -437,18 +584,14 @@ export class NpmPluginInstaller implements PluginInstaller {
437
584
  for (const id of graph.keys()) visit(id)
438
585
  }
439
586
 
440
- private resolveTarget(
441
- target: string,
442
- dependencies: Record<string, string>,
443
- state: InstallState
444
- ): string {
587
+ private resolveTarget(target: string, state: InstallState): string {
445
588
  const value = target.trim()
446
589
 
447
590
  if (value.length === 0) {
448
591
  throw new InstallerError('请提供包名或插件 id')
449
592
  }
450
593
 
451
- if (dependencies[value] !== undefined) return value
594
+ if (state.packages[value] !== undefined) return value
452
595
 
453
596
  for (const [name, record] of Object.entries(state.packages)) {
454
597
  if (record.pluginIds.includes(value)) return name
@@ -516,24 +659,8 @@ export class NpmPluginInstaller implements PluginInstaller {
516
659
  return parsed
517
660
  }
518
661
 
519
- private assertStateCovers(
520
- dependencies: Record<string, string>,
521
- state: InstallState
522
- ): void {
523
- const missing = Object.keys(dependencies)
524
- .filter((name) => name !== CORE_PACKAGE_NAME)
525
- .filter((name) => state.packages[name] === undefined)
526
- .sort()
527
-
528
- if (missing.length === 0) return
529
-
530
- throw new InstallerError(
531
- [
532
- '安装状态与 package.json 不一致,以下依赖缺少安装记录:',
533
- ...missing.map((name) => `- ${name}`),
534
- `请检查安装目录中的 ${INSTALL_STATE_FILE},或手动清理后重新安装。`,
535
- ].join('\n')
536
- )
662
+ private temporaryPath(kind: 'staging' | 'backup'): string {
663
+ return join(this.dir, `.${kind}-${randomUUID()}`)
537
664
  }
538
665
 
539
666
  private assertEnabled(): void {
@@ -544,89 +671,6 @@ export class NpmPluginInstaller implements PluginInstaller {
544
671
  }
545
672
  }
546
673
 
547
- private async backup(): Promise<BackupFile[]> {
548
- const files: BackupFile[] = []
549
-
550
- for (const name of [
551
- 'package.json',
552
- 'package-lock.json',
553
- INSTALL_STATE_FILE,
554
- ]) {
555
- const path = join(this.dir, name)
556
-
557
- try {
558
- files.push({ path, content: await readFile(path, 'utf8') })
559
- } catch (error) {
560
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
561
- throw error
562
- }
563
-
564
- files.push({ path, content: null })
565
- }
566
- }
567
-
568
- return files
569
- }
570
-
571
- private async rollback(files: BackupFile[], cause: unknown): Promise<void> {
572
- try {
573
- for (const file of files) {
574
- if (file.content === null) {
575
- await rm(file.path, { force: true })
576
- } else {
577
- await writeFile(file.path, file.content, 'utf8')
578
- }
579
- }
580
-
581
- const manifest = files.find((file) =>
582
- file.path.endsWith('package.json')
583
- )
584
- const lock = files.find((file) =>
585
- file.path.endsWith('package-lock.json')
586
- )
587
-
588
- if (manifest?.content === null) {
589
- await rm(join(this.dir, 'node_modules'), {
590
- recursive: true,
591
- force: true,
592
- })
593
- return
594
- }
595
-
596
- if (lock?.content !== null && lock !== undefined) {
597
- await this.runner.run(
598
- [
599
- 'ci',
600
- '--prefix',
601
- this.dir,
602
- ...NPM_FLAGS,
603
- '--ignore-scripts',
604
- ],
605
- { timeoutMs: this.config.timeoutMs }
606
- )
607
- } else {
608
- await this.runner.run(
609
- [
610
- 'install',
611
- '--prefix',
612
- this.dir,
613
- ...NPM_FLAGS,
614
- '--ignore-scripts',
615
- ],
616
- { timeoutMs: this.config.timeoutMs }
617
- )
618
- }
619
-
620
- await ensureCoreLink(this.dir)
621
- } catch (error) {
622
- this.logger.error('plugin install rollback failed', error)
623
- throw new InstallerError(
624
- `安装失败且回滚失败:${messageOf(cause)}`,
625
- { cause }
626
- )
627
- }
628
- }
629
-
630
674
  private serialize<T>(task: () => Promise<T>): Promise<T> {
631
675
  const run = this.tail.then(task, task)
632
676
  this.tail = run.then(
@@ -1,5 +1,5 @@
1
- import { stat } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
1
+ import { readdir, stat } from 'node:fs/promises'
2
+ import { join, resolve } from 'node:path'
3
3
  import { pathToFileURL } from 'node:url'
4
4
  import fg from 'fast-glob'
5
5
  import type { Logger } from '../logging/logger.js'
@@ -60,3 +60,37 @@ export async function loadPluginsFromDirectory(
60
60
 
61
61
  return plugins
62
62
  }
63
+
64
+ export async function loadLocalPlugins(
65
+ root: string,
66
+ logger?: LoaderLogger | undefined
67
+ ): Promise<AnyPluginDefinition[]> {
68
+ const absolute = resolve(root)
69
+ const info = await stat(absolute).catch(() => null)
70
+
71
+ if (!info?.isDirectory()) return []
72
+
73
+ const entries = (await readdir(absolute, { withFileTypes: true })).sort(
74
+ (a, b) => a.name.localeCompare(b.name)
75
+ )
76
+ const plugins: AnyPluginDefinition[] = []
77
+
78
+ for (const entry of entries) {
79
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
80
+ if (entry.name === 'node_modules') continue
81
+
82
+ const dist = join(absolute, entry.name, 'dist')
83
+ const distInfo = await stat(dist).catch(() => null)
84
+
85
+ if (!distInfo?.isDirectory()) {
86
+ logger?.warn(
87
+ `skipping local plugin ${entry.name}: dist directory not found`
88
+ )
89
+ continue
90
+ }
91
+
92
+ plugins.push(...(await loadPluginsFromDirectory(dist, logger)))
93
+ }
94
+
95
+ return plugins
96
+ }
@@ -28,6 +28,7 @@ export interface CommandInfo {
28
28
  aliases: string[]
29
29
  permission: Permission
30
30
  description?: string | undefined
31
+ usage?: string | undefined
31
32
  pluginId: string
32
33
  }
33
34
 
@@ -44,10 +45,12 @@ export interface EventContext<TEvent extends SnowLumaEvent = SnowLumaEvent> {
44
45
 
45
46
  export type CommandHandler<
46
47
  TEvent extends OneBotMessageEvent = OneBotMessageEvent,
48
+ TArgs extends unknown[] = [],
47
49
  > = (
48
50
  event: TEvent,
49
51
  context: EventContext<TEvent>,
50
- match: CommandMatch
52
+ match: CommandMatch,
53
+ ...args: TArgs
51
54
  ) => void | Promise<void>
52
55
 
53
56
  export type MessageHandler = (
@@ -77,6 +80,24 @@ export interface CommandOptions {
77
80
  prefixes?: string[] | undefined
78
81
  permission?: Permission | undefined
79
82
  description?: string | undefined
83
+ params?: string | undefined
84
+ }
85
+
86
+ export interface CommandGroupOptions<
87
+ TArgs extends unknown[] = [],
88
+ > extends CommandOptions {
89
+ handler?: CommandHandler<OneBotMessageEvent, TArgs> | undefined
90
+ }
91
+
92
+ export interface CommandGroupRegistrar {
93
+ register<TArgs extends unknown[] = []>(
94
+ options: CommandOptions,
95
+ handler: CommandHandler<OneBotMessageEvent, TArgs>
96
+ ): () => void
97
+ group<TArgs extends unknown[] = []>(
98
+ options: CommandGroupOptions<TArgs>,
99
+ setup: (group: CommandGroupRegistrar) => void
100
+ ): () => void
80
101
  }
81
102
 
82
103
  export interface NoticeOptions {
@@ -94,8 +115,7 @@ export interface MiddlewareOptions {
94
115
  priority?: number | undefined
95
116
  }
96
117
 
97
- export interface CommandRegistrar {
98
- register(options: CommandOptions, handler: CommandHandler): () => void
118
+ export interface CommandRegistrar extends CommandGroupRegistrar {
99
119
  list(): ReadonlyArray<CommandInfo>
100
120
  }
101
121