@zhin.js/scaffold-wizard 0.1.9 → 0.2.1

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 (57) hide show
  1. package/README.md +2 -1
  2. package/lib/adapter-configurers.d.ts +10 -0
  3. package/lib/adapter-configurers.d.ts.map +1 -1
  4. package/lib/adapter-configurers.js +428 -24
  5. package/lib/adapter-configurers.js.map +1 -1
  6. package/lib/adapter.d.ts +43 -0
  7. package/lib/adapter.d.ts.map +1 -1
  8. package/lib/adapter.js +163 -33
  9. package/lib/adapter.js.map +1 -1
  10. package/lib/ai.d.ts +6 -0
  11. package/lib/ai.d.ts.map +1 -1
  12. package/lib/ai.js +20 -4
  13. package/lib/ai.js.map +1 -1
  14. package/lib/apply.d.ts +11 -6
  15. package/lib/apply.d.ts.map +1 -1
  16. package/lib/apply.js +84 -15
  17. package/lib/apply.js.map +1 -1
  18. package/lib/env.d.ts +14 -0
  19. package/lib/env.d.ts.map +1 -0
  20. package/lib/env.js +89 -0
  21. package/lib/env.js.map +1 -0
  22. package/lib/index.d.ts +4 -3
  23. package/lib/index.d.ts.map +1 -1
  24. package/lib/index.js +3 -2
  25. package/lib/index.js.map +1 -1
  26. package/lib/optional-peers.d.ts.map +1 -1
  27. package/lib/optional-peers.js +11 -0
  28. package/lib/optional-peers.js.map +1 -1
  29. package/lib/project-config-plan.d.ts +0 -1
  30. package/lib/project-config-plan.d.ts.map +1 -1
  31. package/lib/project-config-plan.js +0 -8
  32. package/lib/project-config-plan.js.map +1 -1
  33. package/lib/project-deps.d.ts +2 -1
  34. package/lib/project-deps.d.ts.map +1 -1
  35. package/lib/project-deps.js +13 -4
  36. package/lib/project-deps.js.map +1 -1
  37. package/lib/weixin-ilink-login.d.ts +33 -0
  38. package/lib/weixin-ilink-login.d.ts.map +1 -0
  39. package/lib/weixin-ilink-login.js +90 -0
  40. package/lib/weixin-ilink-login.js.map +1 -0
  41. package/lib/zhin-stack-deps.d.ts +4 -3
  42. package/lib/zhin-stack-deps.d.ts.map +1 -1
  43. package/lib/zhin-stack-deps.js +4 -20
  44. package/lib/zhin-stack-deps.js.map +1 -1
  45. package/package.json +3 -1
  46. package/src/adapter-configurers.ts +456 -24
  47. package/src/adapter.ts +171 -34
  48. package/src/ai.ts +21 -4
  49. package/src/apply.ts +98 -14
  50. package/src/env.ts +113 -0
  51. package/src/index.ts +12 -2
  52. package/src/optional-peers.ts +9 -0
  53. package/src/project-config-plan.ts +0 -9
  54. package/src/project-deps.ts +13 -4
  55. package/src/stack-versions.generated.json +36 -38
  56. package/src/weixin-ilink-login.ts +151 -0
  57. package/src/zhin-stack-deps.ts +4 -22
package/src/adapter.ts CHANGED
@@ -5,12 +5,18 @@ import {
5
5
  adapterDocsUrl,
6
6
  configureDiscordEndpoint,
7
7
  configureGitHubEndpoint,
8
+ configureMilkyBot,
9
+ configureNapcatBot,
8
10
  configureOneBot11Bot,
11
+ configureOneBot12Bot,
9
12
  configureQQBot,
13
+ configureSatoriBot,
10
14
  configureSlackEndpoint,
11
15
  configureTelegramEndpoint,
16
+ configureWeixinIlinkBot,
12
17
  } from './adapter-configurers.js';
13
18
  import { ZHIN_STACK_VERSIONS } from './zhin-stack-deps.js';
19
+ import { formatEnvValue } from './env.js';
14
20
 
15
21
  /** 一个待挂载的子插件实例(package.json zhin.plugins 清单 + zhin.config plugins.<instanceKey> 配置) */
16
22
  export interface AdapterPluginInstance {
@@ -32,7 +38,7 @@ export interface AdapterSetupResult {
32
38
  }
33
39
 
34
40
  // 适配器定义
35
- interface AdapterDefinition {
41
+ export interface AdapterDefinition {
36
42
  name: string;
37
43
  value: string;
38
44
  package: string;
@@ -48,7 +54,7 @@ interface AdapterDefinition {
48
54
  fields: AdapterField[];
49
55
  }
50
56
 
51
- interface AdapterField {
57
+ export interface AdapterField {
52
58
  key: string;
53
59
  message: string;
54
60
  type?: 'input' | 'password' | 'select';
@@ -56,9 +62,11 @@ interface AdapterField {
56
62
  required?: boolean;
57
63
  envKey?: string; // 如果是敏感信息,对应的环境变量名
58
64
  choices?: { name: string; value: string }[];
65
+ /** 字段归属:endpoint 级(默认,进 endpoints[i])或 shared(顶层共享字段) */
66
+ scope?: 'endpoint' | 'shared';
59
67
  }
60
68
 
61
- const ADAPTERS: AdapterDefinition[] = [
69
+ export const ADAPTERS: AdapterDefinition[] = [
62
70
  {
63
71
  name: 'Sandbox (调试沙盒,默认)',
64
72
  value: 'sandbox',
@@ -80,6 +88,8 @@ const ADAPTERS: AdapterDefinition[] = [
80
88
  docUrl: adapterDocsUrl('icqq'),
81
89
  fields: [
82
90
  { key: 'name', message: 'QQ 号(与 icqq login 时一致):', required: true, envKey: 'ICQQ_ACCOUNT' },
91
+ // schema 顶层 required: master(所有 endpoint 共享)
92
+ { key: 'master', message: '主人 QQ 号(master,/approve 等管理命令):', required: true, scope: 'shared' },
83
93
  ],
84
94
  },
85
95
  {
@@ -153,6 +163,8 @@ const ADAPTERS: AdapterDefinition[] = [
153
163
  fields: [
154
164
  { key: 'appKey', message: 'App Key:', required: true, envKey: 'DINGTALK_APP_KEY' },
155
165
  { key: 'appSecret', message: 'App Secret:', required: true, type: 'password', envKey: 'DINGTALK_APP_SECRET' },
166
+ // schema endpoints[i] required 含 robotCode(主动发送 /robot/send 需要)
167
+ { key: 'robotCode', message: 'Robot Code:', required: true, envKey: 'DINGTALK_ROBOT_CODE' },
156
168
  { key: 'webhookPath', message: 'Webhook 路径:', default: '/dingtalk/webhook' },
157
169
  ],
158
170
  },
@@ -167,7 +179,24 @@ const ADAPTERS: AdapterDefinition[] = [
167
179
  fields: [
168
180
  { key: 'appId', message: 'App ID:', required: true, envKey: 'LARK_APP_ID' },
169
181
  { key: 'appSecret', message: 'App Secret:', required: true, type: 'password', envKey: 'LARK_APP_SECRET' },
170
- { key: 'webhookPath', message: 'Webhook 路径:', default: '/lark/webhook' },
182
+ // schema 顶层共享字段(endpoints[i] 不含 webhookPath)
183
+ { key: 'webhookPath', message: 'Webhook 路径:', default: '/lark/webhook', scope: 'shared' },
184
+ ],
185
+ },
186
+ {
187
+ name: 'LINE',
188
+ value: 'line',
189
+ package: '@zhin.js/adapter-line',
190
+ plugin: '@zhin.js/adapter-line',
191
+ needsHttp: true,
192
+ description: 'LINE Messaging API',
193
+ setupHint: '在 LINE Developers Console 创建 Messaging API Channel;Webhook URL 需 HTTPS 公网可达,并关闭 Auto-reply。',
194
+ docUrl: adapterDocsUrl('line'),
195
+ fields: [
196
+ { key: 'channelSecret', message: 'Channel Secret:', required: true, type: 'password', envKey: 'LINE_CHANNEL_SECRET' },
197
+ { key: 'channelAccessToken', message: 'Channel Access Token:', required: true, type: 'password', envKey: 'LINE_CHANNEL_ACCESS_TOKEN' },
198
+ // schema 顶层共享字段(endpoints[i] 不含 webhookPath)
199
+ { key: 'webhookPath', message: 'Webhook 路径:', default: '/line/webhook', scope: 'shared' },
171
200
  ],
172
201
  },
173
202
  {
@@ -182,6 +211,54 @@ const ADAPTERS: AdapterDefinition[] = [
182
211
  configure: configureOneBot11Bot,
183
212
  fields: [],
184
213
  },
214
+ {
215
+ name: 'OneBot v12',
216
+ value: 'onebot12',
217
+ package: '@zhin.js/adapter-onebot12',
218
+ plugin: '@zhin.js/adapter-onebot12',
219
+ needsHttp: false,
220
+ description: 'OneBot v12(ws / webhook / wss)',
221
+ setupHint: 'connection: ws 连 OneBot 实现;webhook / wss 由内置 HTTP Host 承接。',
222
+ docUrl: adapterDocsUrl('onebot12'),
223
+ configure: configureOneBot12Bot,
224
+ fields: [],
225
+ },
226
+ {
227
+ name: 'NapCat',
228
+ value: 'napcat',
229
+ package: '@zhin.js/adapter-napcat',
230
+ plugin: '@zhin.js/adapter-napcat',
231
+ needsHttp: false,
232
+ description: 'NapCat(OneBot 11 + NapCat 扩展)',
233
+ setupHint: 'connection: ws 连 NapCat(默认 ws://127.0.0.1:3001);wss / http 由内置 HTTP Host 承接。',
234
+ docUrl: adapterDocsUrl('napcat'),
235
+ configure: configureNapcatBot,
236
+ fields: [],
237
+ },
238
+ {
239
+ name: 'Milky',
240
+ value: 'milky',
241
+ package: '@zhin.js/adapter-milky',
242
+ plugin: '@zhin.js/adapter-milky',
243
+ needsHttp: false,
244
+ description: 'Milky 协议(ws / sse / webhook / wss)',
245
+ setupHint: 'connection: ws 连协议端 ws(s)://baseUrl/event;webhook / wss 由内置 HTTP Host 承接。',
246
+ docUrl: adapterDocsUrl('milky'),
247
+ configure: configureMilkyBot,
248
+ fields: [],
249
+ },
250
+ {
251
+ name: 'Satori',
252
+ value: 'satori',
253
+ package: '@zhin.js/adapter-satori',
254
+ plugin: '@zhin.js/adapter-satori',
255
+ needsHttp: false,
256
+ description: 'Satori 聊天协议(ws / webhook)',
257
+ setupHint: 'connection: ws 连 Satori SDK(默认 http://127.0.0.1:5140);webhook 由内置 HTTP Host 承接。',
258
+ docUrl: adapterDocsUrl('satori'),
259
+ configure: configureSatoriBot,
260
+ fields: [],
261
+ },
185
262
  {
186
263
  name: '微信公众号',
187
264
  value: 'wechat-mp',
@@ -194,7 +271,26 @@ const ADAPTERS: AdapterDefinition[] = [
194
271
  { key: 'appId', message: 'App ID:', required: true, envKey: 'WECHAT_APP_ID' },
195
272
  { key: 'appSecret', message: 'App Secret:', required: true, type: 'password', envKey: 'WECHAT_APP_SECRET' },
196
273
  { key: 'token', message: '验证 Token:', required: true, envKey: 'WECHAT_TOKEN' },
197
- { key: 'webhookPath', message: 'Webhook 路径:', default: '/wechat/webhook' },
274
+ // schema 顶层共享字段名为 path(endpoints[i] 不含此项)
275
+ { key: 'webhookPath', message: 'Webhook 路径:', default: '/wechat/webhook', scope: 'shared' },
276
+ ],
277
+ },
278
+ {
279
+ name: '企业微信',
280
+ value: 'wecom',
281
+ package: '@zhin.js/adapter-wecom',
282
+ plugin: '@zhin.js/adapter-wecom',
283
+ needsHttp: true,
284
+ description: '企业微信自建应用',
285
+ setupHint: '管理后台创建自建应用并配置「接收消息」URL(需公网可达);Token / EncodingAESKey 与后台一致。',
286
+ docUrl: adapterDocsUrl('wecom'),
287
+ fields: [
288
+ { key: 'corpId', message: 'Corp ID:', required: true, envKey: 'WECOM_CORP_ID' },
289
+ { key: 'agentSecret', message: '应用 Secret:', required: true, type: 'password', envKey: 'WECOM_AGENT_SECRET' },
290
+ { key: 'token', message: '回调 Token:', required: true, type: 'password', envKey: 'WECOM_TOKEN' },
291
+ { key: 'encodingAESKey', message: 'EncodingAESKey(43 字符):', required: true, type: 'password', envKey: 'WECOM_AES_KEY' },
292
+ // schema 顶层共享字段(endpoints[i] 不含 webhookPath)
293
+ { key: 'webhookPath', message: '回调路径:', default: '/wecom/callback', scope: 'shared' },
198
294
  ],
199
295
  },
200
296
  {
@@ -227,6 +323,18 @@ const ADAPTERS: AdapterDefinition[] = [
227
323
  configure: configureGitHubEndpoint,
228
324
  fields: [],
229
325
  },
326
+ {
327
+ name: '微信 iLink(个人微信)',
328
+ value: 'weixin-ilink',
329
+ package: '@zhin.js/adapter-weixin-ilink',
330
+ plugin: '@zhin.js/adapter-weixin-ilink',
331
+ needsHttp: false,
332
+ description: '个人微信(iLink / ClawBot 灰度入口,长轮询,仅私聊)',
333
+ setupHint: '需最新版微信 + ClawBot 灰度资格;默认扫码绑定(bot_token 写入 .env),也可手动输入或放 data/weixin-ilink/<name>.json 侧车凭证文件。',
334
+ docUrl: adapterDocsUrl('weixin-ilink'),
335
+ configure: configureWeixinIlinkBot,
336
+ fields: [],
337
+ },
230
338
  ];
231
339
 
232
340
  /**
@@ -253,7 +361,7 @@ export async function configureAdapters(): Promise<AdapterSetupResult> {
253
361
  if (input.length === 0) return '至少选择一个适配器';
254
362
  return true;
255
363
  },
256
- pageSize: 14,
364
+ pageSize: 22,
257
365
  }
258
366
  ]);
259
367
 
@@ -374,7 +482,7 @@ export async function configureAdapters(): Promise<AdapterSetupResult> {
374
482
  result.instances.push({
375
483
  package: adapterDef.package,
376
484
  instanceKey: adapterDef.value,
377
- config: buildFieldBasedInstanceConfig(adapterDef.value, fieldValues),
485
+ config: buildFieldBasedInstanceConfig(adapterDef, fieldValues),
378
486
  });
379
487
  }
380
488
 
@@ -382,36 +490,56 @@ export async function configureAdapters(): Promise<AdapterSetupResult> {
382
490
  }
383
491
 
384
492
  /**
385
- * 字段式适配器(无自定义 configure)的实例配置整形,对齐各插件 schema.json:
386
- * - wechat-mp:webhookPath → path
387
- * - email:smtp/imap 平铺字段 → smtp/imap 嵌套对象
388
- * - 其余:字段直传
493
+ * 字段式适配器(无自定义 configure)的实例配置整形,对齐各插件 schema.json 新格式:
494
+ * - endpoint 级字段(name + 凭据等,字段默认 scope: 'endpoint')进 `endpoints: [{ name, ... }]`
495
+ * - scope: 'shared' 的字段(如 lark/wechat-mp 的 webhookPath)留顶层
496
+ * - wechat-mp:顶层字段名为 path(向导沿用 webhookPath 提问)
497
+ * - email:smtp/imap 平铺字段 → endpoint 内 smtp/imap 嵌套对象
498
+ * - 未单独询问 name 的适配器用 `<adapter>-bot` 兜底
389
499
  */
390
- function buildFieldBasedInstanceConfig(adapterValue: string, values: Record<string, any>): Record<string, unknown> {
391
- if (adapterValue === 'wechat-mp') {
392
- const { webhookPath, ...rest } = values;
393
- return { ...rest, path: webhookPath ?? '/wechat/webhook' };
394
- }
395
- if (adapterValue === 'email') {
500
+ export function buildFieldBasedInstanceConfig(adapter: AdapterDefinition, values: Record<string, any>): Record<string, unknown> {
501
+ if (adapter.value === 'email') {
396
502
  const user = values.user ?? '';
397
503
  const password = values.password ?? '';
398
504
  return {
399
- smtp: {
400
- host: values.smtpHost,
401
- port: Number(values.smtpPort) || 465,
402
- secure: true,
403
- auth: { user, pass: password },
404
- },
405
- imap: {
406
- host: values.imapHost,
407
- port: Number(values.imapPort) || 993,
408
- tls: true,
409
- user,
410
- password,
411
- },
505
+ endpoints: [{
506
+ name: 'email-bot',
507
+ smtp: {
508
+ host: values.smtpHost,
509
+ port: Number(values.smtpPort) || 465,
510
+ secure: true,
511
+ auth: { user, pass: password },
512
+ },
513
+ imap: {
514
+ host: values.imapHost,
515
+ port: Number(values.imapPort) || 993,
516
+ tls: true,
517
+ user,
518
+ password,
519
+ },
520
+ }],
412
521
  };
413
522
  }
414
- return values;
523
+
524
+ const endpoint: Record<string, unknown> = {};
525
+ const shared: Record<string, unknown> = {};
526
+ for (const field of adapter.fields) {
527
+ const value = values[field.key];
528
+ if (value === undefined) continue;
529
+ if (field.scope === 'shared') {
530
+ shared[field.key] = value;
531
+ } else {
532
+ endpoint[field.key] = value;
533
+ }
534
+ }
535
+ if (adapter.value === 'wechat-mp' && shared.webhookPath !== undefined) {
536
+ shared.path = shared.webhookPath;
537
+ delete shared.webhookPath;
538
+ }
539
+ if (!endpoint.name) {
540
+ endpoint.name = `${adapter.value}-bot`;
541
+ }
542
+ return { ...shared, endpoints: [endpoint] };
415
543
  }
416
544
 
417
545
  /**
@@ -423,7 +551,7 @@ export function generateAdapterEnvVars(result: AdapterSetupResult): string {
423
551
 
424
552
  const lines: string[] = ['', '# 适配器配置'];
425
553
  for (const [key, value] of entries) {
426
- lines.push(`${key}=${value}`);
554
+ lines.push(`${key}=${formatEnvValue(value)}`);
427
555
  }
428
556
  return lines.join('\n');
429
557
  }
@@ -464,12 +592,14 @@ export function getAdapterSetupNotes(result: AdapterSetupResult): string[] {
464
592
  notes.push('Telegram polling: 本地开发可直接 pnpm dev,无需公网');
465
593
  }
466
594
  if (context === 'github') {
467
- if (entry.webhook_secret) {
595
+ const endpoints = Array.isArray(entry.endpoints) ? entry.endpoints as Array<Record<string, unknown>> : [];
596
+ const firstEndpoint = endpoints[0] ?? {};
597
+ if (firstEndpoint.webhook_secret) {
468
598
  notes.push('GitHub Webhook: App 设置 URL 为 https://<域名>/github/webhook');
469
599
  } else {
470
600
  notes.push('GitHub polling: 无 Webhook 时将轮询 Events API(默认 60s)');
471
601
  }
472
- if (entry.app_id) {
602
+ if (firstEndpoint.app_id) {
473
603
  notes.push('GitHub App: 私钥路径需存在,且 App 已安装到目标仓库');
474
604
  } else {
475
605
  notes.push('GitHub gh CLI: 运行 gh auth login 完成认证');
@@ -521,3 +651,10 @@ export function getAdapterDependencies(result: AdapterSetupResult): Record<strin
521
651
 
522
652
  return deps;
523
653
  }
654
+
655
+ /**
656
+ * 按 npm 包名查找已知适配器定义(如 @zhin.js/adapter-telegram)
657
+ */
658
+ export function findAdapterByPackage(packageName: string): AdapterDefinition | undefined {
659
+ return ADAPTERS.find(a => a.package === packageName || a.plugin === packageName);
660
+ }
package/src/ai.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import inquirer from 'inquirer';
2
2
  import chalk from 'chalk';
3
+ import { formatEnvValue } from './env.js';
3
4
 
4
5
  export interface AISetupConfig {
5
6
  enabled: boolean;
@@ -103,6 +104,16 @@ export function providerSdkFor(name: string): string {
103
104
  }
104
105
  }
105
106
 
107
+ /**
108
+ * openai-compatible sdk 在 runtime 无 baseUrl 无法建 provider
109
+ * (packages/im/ai sdk-provider-adapter 直接拒绝),这些 provider 的 baseUrl 必填,
110
+ * 向导预填官方地址。
111
+ */
112
+ export const PROVIDER_DEFAULT_BASE_URLS: Readonly<Record<string, string>> = Object.freeze({
113
+ zhipu: 'https://open.bigmodel.cn/api/paas/v4',
114
+ moonshot: 'https://api.moonshot.cn/v1',
115
+ });
116
+
106
117
  function defaultModelForProvider(driver: string): string {
107
118
  const entry = PROVIDERS.find(p => p.value === driver);
108
119
  return entry?.defaultModel ?? '';
@@ -192,7 +203,7 @@ export const RECOMMENDED_AI_DEFAULTS = {
192
203
  export async function configureAI(): Promise<AISetupConfig> {
193
204
  console.log('');
194
205
  console.log(chalk.blue('🤖 配置 AI 智能体'));
195
- console.log(chalk.gray(' 启用后将自动写入 package.json:@zhin.js/agent、zod、ai、所选 @ai-sdk/*、@modelcontextprotocol/sdk。'));
206
+ console.log(chalk.gray(' 启用后将自动写入 package.json:@zhin.js/agent、zod、ai、所选 @ai-sdk/*(启用 memory MCP 时另加 @modelcontextprotocol/sdk)。'));
196
207
  console.log(chalk.gray(' 会话持久化默认开启,将自动配置 SQLite 数据库(若尚未选择)。'));
197
208
 
198
209
  const { enableAI } = await inquirer.prompt([
@@ -253,6 +264,7 @@ export async function configureAI(): Promise<AISetupConfig> {
253
264
  models: [ollamaConfig.model.trim()],
254
265
  };
255
266
  } else {
267
+ const defaultBaseUrl = PROVIDER_DEFAULT_BASE_URLS[provider];
256
268
  const cloudConfig = await inquirer.prompt([
257
269
  {
258
270
  type: 'input',
@@ -272,8 +284,13 @@ export async function configureAI(): Promise<AISetupConfig> {
272
284
  {
273
285
  type: 'input',
274
286
  name: 'baseUrl',
275
- message: '自定义 API 地址(留空使用官方默认):',
276
- default: '',
287
+ message: defaultBaseUrl
288
+ ? 'API 地址(该提供商 sdk 为 openai-compatible,baseUrl 必填):'
289
+ : '自定义 API 地址(留空使用官方默认):',
290
+ default: defaultBaseUrl ?? '',
291
+ validate: defaultBaseUrl
292
+ ? (input: string) => (input.trim() ? true : 'baseUrl 不能为空(openai-compatible 无默认地址)')
293
+ : undefined,
277
294
  }
278
295
  ]);
279
296
 
@@ -389,7 +406,7 @@ export function generateAIEnvVars(config: AISetupConfig): string {
389
406
  for (const [, providerConfig] of Object.entries(config.providers)) {
390
407
  const realKey = (providerConfig as any).__envApiKey;
391
408
  if (realKey) {
392
- envVars.push(`AI_API_KEY=${realKey}`);
409
+ envVars.push(`AI_API_KEY=${formatEnvValue(realKey)}`);
393
410
  }
394
411
  }
395
412
 
package/src/apply.ts CHANGED
@@ -10,21 +10,14 @@ import {
10
10
  getAdapterDependencies,
11
11
  } from './adapter.js';
12
12
  import { generateAIEnvVars, materializeAIConfig } from './ai.js';
13
+ import { generateDatabaseEnvVars, mergeEnvText } from './env.js';
13
14
  import { ensureDatabaseForAdapters, ensureDatabaseForAI, getAIDependencies } from './project-deps.js';
14
- import { DEFAULT_CREATE_BOT_HTTP_PORT } from './zhin-stack-deps.js';
15
+ import { DEFAULT_CREATE_BOT_HTTP_PORT, ZHIN_STACK_VERSIONS } from './zhin-stack-deps.js';
15
16
  import { packageToInstanceKey } from './project-config-plan.js';
16
17
 
17
- /**
18
- * @deprecated Plugin Runtime 由 CLI composition root 装配 Console/HTTP Host,
19
- * 不再需要在 plugins 清单中声明 host 插件;保留导出仅为兼容。
20
- */
21
- export const CONSOLE_HOST_PLUGINS = [
22
- '@zhin.js/host-router',
23
- '@zhin.js/host-api',
24
- ] as const;
25
-
26
18
  export function applyDatabaseToConfig(config: Record<string, unknown>, database: DatabaseConfig): void {
27
- config.database = database;
19
+ // 物化为可落盘对象:非 SQLite 连接参数引用 .env(${VAR}),明文密码不进配置文件
20
+ config.database = materializeDatabaseConfig(database);
28
21
  }
29
22
 
30
23
  /** 将 legacy 数组形式 plugins 归一化为新 runtime 的 instanceKey 映射 */
@@ -46,13 +39,63 @@ export function normalizePluginsMap(value: unknown): Record<string, unknown> {
46
39
  export function applyAdaptersToConfig(config: Record<string, unknown>, result: AdapterSetupResult): void {
47
40
  const plugins = normalizePluginsMap(config.plugins);
48
41
  for (const instance of result.instances) {
49
- plugins[instance.instanceKey] = instance.config;
42
+ plugins[instance.instanceKey] = mergeAdapterInstanceConfig(
43
+ instance.instanceKey,
44
+ plugins[instance.instanceKey],
45
+ instance.config,
46
+ );
50
47
  }
51
48
  config.plugins = plugins;
52
49
  // legacy endpoints 列表不被 runtime 配置 schema 接受(additionalProperties: false)
53
50
  delete config.endpoints;
54
51
  }
55
52
 
53
+ function endpointName(endpoint: unknown): string | undefined {
54
+ if (!endpoint || typeof endpoint !== 'object' || Array.isArray(endpoint)) return undefined;
55
+ const name = (endpoint as Record<string, unknown>).name;
56
+ return typeof name === 'string' ? name : undefined;
57
+ }
58
+
59
+ /**
60
+ * 重跑向导时按实例合并配置而非整体覆盖:保留现有 endpoints 中 name 不冲突的项
61
+ * 与其他实例级键;被向导结果覆盖的 endpoints / 键在覆盖前打印提示。
62
+ */
63
+ function mergeAdapterInstanceConfig(
64
+ instanceKey: string,
65
+ existing: unknown,
66
+ next: Record<string, unknown>,
67
+ ): Record<string, unknown> {
68
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return next;
69
+ const current = existing as Record<string, unknown>;
70
+ const dropped: string[] = [];
71
+
72
+ const merged: Record<string, unknown> = { ...current, ...next };
73
+
74
+ const currentEndpoints = Array.isArray(current.endpoints) ? current.endpoints : [];
75
+ const nextEndpoints = Array.isArray(next.endpoints) ? next.endpoints : [];
76
+ if (currentEndpoints.length > 0 && nextEndpoints.length > 0) {
77
+ const nextNames = new Set(nextEndpoints.map(endpointName));
78
+ const kept = currentEndpoints.filter((endpoint) => {
79
+ const name = endpointName(endpoint);
80
+ if (name != null && nextNames.has(name)) {
81
+ dropped.push(`endpoint "${name}"`);
82
+ return false;
83
+ }
84
+ return true;
85
+ });
86
+ merged.endpoints = [...nextEndpoints, ...kept];
87
+ }
88
+
89
+ for (const key of Object.keys(next)) {
90
+ if (key !== 'endpoints' && key in current) dropped.push(`键 "${key}"`);
91
+ }
92
+
93
+ if (dropped.length > 0) {
94
+ console.warn(`[zhin setup] plugins.${instanceKey} 的 ${dropped.join('、')} 已被向导结果覆盖`);
95
+ }
96
+ return merged;
97
+ }
98
+
56
99
  export function applyAIToConfig(config: Record<string, unknown>, ai: AISetupConfig): void {
57
100
  config.ai = materializeAIConfig(ai);
58
101
  }
@@ -90,14 +133,17 @@ export async function appendWizardEnvVars(
90
133
  projectDir: string,
91
134
  adapters?: AdapterSetupResult,
92
135
  ai?: AISetupConfig,
136
+ database?: DatabaseConfig,
93
137
  ): Promise<void> {
94
138
  const extra =
139
+ (database ? generateDatabaseEnvVars(database) : '') +
95
140
  (adapters ? generateAdapterEnvVars(adapters) : '') +
96
141
  (ai ? generateAIEnvVars(ai) : '');
97
- if (extra) {
142
+ if (extra.trim()) {
98
143
  const envPath = path.join(projectDir, '.env');
99
144
  const existing = (await fs.pathExists(envPath)) ? await fs.readFile(envPath, 'utf-8') : '';
100
- await fs.writeFile(envPath, existing.replace(/\s*$/, '') + extra + '\n');
145
+ // 按 KEY 覆盖而非追加:重复运行向导幂等,不产生重复行
146
+ await fs.writeFile(envPath, mergeEnvText(existing, extra));
101
147
  }
102
148
 
103
149
  // 新 Plugin Runtime 通过 package.json zhin.plugins 清单挂载子插件,与依赖一起落盘
@@ -112,9 +158,47 @@ export function collectWizardDependencies(
112
158
  return {
113
159
  ...(options.adapters ? getAdapterDependencies(options.adapters) : {}),
114
160
  ...getAIDependencies(options.ai),
161
+ // tools/ 约定需要 @zhin.js/tool(与 create-zhin 路径对齐)
162
+ ...(options.ai?.enabled ? { '@zhin.js/tool': ZHIN_STACK_VERSIONS['@zhin.js/tool'] } : {}),
115
163
  };
116
164
  }
117
165
 
166
+ /** 向导结果需要合并进 package.json zhin.features 的条目(与 create-zhin 路径对齐) */
167
+ export function collectWizardFeatures(
168
+ options: Pick<InitOptions, 'ai'>,
169
+ ): Array<{ package: string; api: string }> {
170
+ return options.ai?.enabled ? [{ package: '@zhin.js/tool', api: '^1.0.0' }] : [];
171
+ }
172
+
173
+ /** 把 features 清单条目合并进项目 package.json 的 zhin.features(zhin setup 路径) */
174
+ export async function mergeFeaturesIntoPackageJson(
175
+ projectDir: string,
176
+ entries: Array<{ package: string; api: string }>,
177
+ ): Promise<boolean> {
178
+ if (entries.length === 0) return false;
179
+ const pkgPath = path.join(projectDir, 'package.json');
180
+ if (!(await fs.pathExists(pkgPath))) return false;
181
+
182
+ const pkg = await fs.readJson(pkgPath);
183
+ const zhin = pkg.zhin && typeof pkg.zhin === 'object' && !Array.isArray(pkg.zhin)
184
+ ? pkg.zhin
185
+ : { protocol: 1, type: 'plugin', entry: './plugin.ts' };
186
+ const features: Array<{ package: string; api?: string }> = Array.isArray(zhin.features)
187
+ ? [...zhin.features]
188
+ : [];
189
+ let changed = false;
190
+ for (const entry of entries) {
191
+ if (features.some((item) => item?.package === entry.package)) continue;
192
+ features.push(entry);
193
+ changed = true;
194
+ }
195
+ if (!changed && pkg.zhin) return false;
196
+ zhin.features = features;
197
+ pkg.zhin = zhin;
198
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
199
+ return true;
200
+ }
201
+
118
202
  export async function mergeDependenciesIntoPackageJson(
119
203
  projectDir: string,
120
204
  deps: Record<string, string>,
package/src/env.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { DatabaseConfig } from './types.js';
2
+
3
+ /**
4
+ * dotenv 值格式化:含 `#` / 空白 / 引号 / 反斜杠 / 换行 时用双引号包裹并转义,
5
+ * 避免未转义值截断注释或破坏行解析(所有 .env 生成点统一走这里)。
6
+ */
7
+ export function formatEnvValue(value: unknown): string {
8
+ const raw = value == null ? '' : String(value);
9
+ if (raw !== '' && !/[#"'\\\s]/.test(raw)) return raw;
10
+ return `"${raw
11
+ .replace(/\\/g, '\\\\')
12
+ .replace(/"/g, '\\"')
13
+ .replace(/\r/g, '\\r')
14
+ .replace(/\n/g, '\\n')}"`;
15
+ }
16
+
17
+ const ENV_KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=/;
18
+
19
+ /**
20
+ * 幂等合并 .env 内容:extra 中出现的 KEY 覆盖 existing 同名行(原位替换),
21
+ * 新 KEY 连同其段落注释追加到文末。重复执行结果不变。
22
+ */
23
+ export function mergeEnvText(existing: string, extra: string): string {
24
+ const entries: Array<{ comment?: string; key: string; line: string }> = [];
25
+ let pendingComment: string | undefined;
26
+ for (const rawLine of extra.split('\n')) {
27
+ const line = rawLine.trim();
28
+ if (!line) continue;
29
+ if (line.startsWith('#')) {
30
+ pendingComment = line;
31
+ continue;
32
+ }
33
+ const match = ENV_KEY_LINE.exec(line);
34
+ if (!match) continue;
35
+ entries.push({ comment: pendingComment, key: match[1], line });
36
+ pendingComment = undefined;
37
+ }
38
+ const out = existing.split('\n');
39
+ if (entries.length === 0) {
40
+ return existing.replace(/\s*$/, '') + (existing.trim() ? '\n' : '');
41
+ }
42
+ const byKey = new Map(entries.map((entry) => [entry.key, entry]));
43
+ const consumed = new Set<string>();
44
+ for (let index = 0; index < out.length; index += 1) {
45
+ const match = ENV_KEY_LINE.exec(out[index].trim());
46
+ if (match && byKey.has(match[1])) {
47
+ out[index] = byKey.get(match[1])!.line;
48
+ consumed.add(match[1]);
49
+ }
50
+ }
51
+ const fresh = entries.filter((entry) => !consumed.has(entry.key));
52
+ if (fresh.length > 0) {
53
+ while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
54
+ if (out.length > 0) out.push('');
55
+ let lastComment: string | undefined;
56
+ for (const entry of fresh) {
57
+ if (entry.comment && entry.comment !== lastComment) out.push(entry.comment);
58
+ lastComment = entry.comment;
59
+ out.push(entry.line);
60
+ }
61
+ }
62
+ return out.join('\n').replace(/\s*$/, '') + '\n';
63
+ }
64
+
65
+ /** 生成数据库连接环境变量(连接参数落 .env,配置文件只引用 ${VAR})。 */
66
+ export function generateDatabaseEnvVars(config: DatabaseConfig): string {
67
+ const envVars: string[] = [];
68
+
69
+ switch (config.dialect) {
70
+ case 'mysql':
71
+ envVars.push(
72
+ '# MySQL 数据库配置',
73
+ `DB_HOST=${formatEnvValue(config.host || 'localhost')}`,
74
+ `DB_PORT=${formatEnvValue(config.port || 3306)}`,
75
+ `DB_USER=${formatEnvValue(config.user || 'root')}`,
76
+ `DB_PASSWORD=${formatEnvValue(config.password || '')}`,
77
+ `DB_DATABASE=${formatEnvValue(config.database || 'zhin_bot')}`
78
+ );
79
+ break;
80
+ case 'pg':
81
+ envVars.push(
82
+ '# PostgreSQL 数据库配置',
83
+ `DB_HOST=${formatEnvValue(config.host || 'localhost')}`,
84
+ `DB_PORT=${formatEnvValue(config.port || 5432)}`,
85
+ `DB_USER=${formatEnvValue(config.user || 'postgres')}`,
86
+ `DB_PASSWORD=${formatEnvValue(config.password || '')}`,
87
+ `DB_DATABASE=${formatEnvValue(config.database || 'zhin_bot')}`
88
+ );
89
+ break;
90
+ case 'mongodb':
91
+ envVars.push(
92
+ '# MongoDB 数据库配置',
93
+ `DB_URL=${formatEnvValue(config.url || 'mongodb://localhost:27017')}`,
94
+ `DB_NAME=${formatEnvValue(config.dbName || 'zhin_bot')}`
95
+ );
96
+ break;
97
+ case 'redis':
98
+ envVars.push(
99
+ '# Redis 数据库配置',
100
+ `REDIS_HOST=${formatEnvValue(config.socket?.host || 'localhost')}`,
101
+ `REDIS_PORT=${formatEnvValue(config.socket?.port || 6379)}`,
102
+ `REDIS_PASSWORD=${formatEnvValue(config.password || '')}`,
103
+ `REDIS_DB=${formatEnvValue(config.database ?? 0)}`
104
+ );
105
+ break;
106
+ case 'sqlite':
107
+ default:
108
+ // SQLite 不需要额外的环境变量
109
+ break;
110
+ }
111
+
112
+ return envVars.length > 0 ? `\n\n${envVars.join('\n')}` : '';
113
+ }