@zhin.js/scaffold-wizard 1.1.5 → 1.1.7

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 (44) hide show
  1. package/README.md +1 -1
  2. package/lib/adapter.d.ts +2 -2
  3. package/lib/adapter.d.ts.map +1 -1
  4. package/lib/adapter.js +1 -1
  5. package/lib/apply.d.ts +6 -6
  6. package/lib/apply.d.ts.map +1 -1
  7. package/lib/apply.js +29 -41
  8. package/lib/apply.js.map +1 -1
  9. package/lib/database-definitions.js +1 -1
  10. package/lib/decisions.d.ts.map +1 -1
  11. package/lib/decisions.js +10 -17
  12. package/lib/decisions.js.map +1 -1
  13. package/lib/index.d.ts +3 -1
  14. package/lib/index.d.ts.map +1 -1
  15. package/lib/index.js +2 -1
  16. package/lib/index.js.map +1 -1
  17. package/lib/project-config-plan.d.ts +3 -0
  18. package/lib/project-config-plan.d.ts.map +1 -1
  19. package/lib/project-config-plan.js +38 -12
  20. package/lib/project-config-plan.js.map +1 -1
  21. package/lib/project-deps.d.ts +3 -1
  22. package/lib/project-deps.d.ts.map +1 -1
  23. package/lib/project-deps.js +3 -1
  24. package/lib/project-deps.js.map +1 -1
  25. package/lib/stack-version-ranges.d.ts +35 -0
  26. package/lib/stack-version-ranges.d.ts.map +1 -0
  27. package/lib/stack-version-ranges.js +144 -0
  28. package/lib/stack-version-ranges.js.map +1 -0
  29. package/lib/stack-version-ranges.json +112 -0
  30. package/lib/zhin-stack-deps.d.ts +4 -2
  31. package/lib/zhin-stack-deps.d.ts.map +1 -1
  32. package/lib/zhin-stack-deps.js +4 -2
  33. package/lib/zhin-stack-deps.js.map +1 -1
  34. package/package.json +4 -2
  35. package/src/adapter.ts +2 -2
  36. package/src/apply.ts +34 -46
  37. package/src/database-definitions.ts +1 -1
  38. package/src/decisions.ts +9 -17
  39. package/src/index.ts +7 -1
  40. package/src/project-config-plan.ts +39 -11
  41. package/src/project-deps.ts +3 -1
  42. package/src/stack-version-ranges.ts +160 -0
  43. package/src/zhin-stack-deps.ts +4 -2
  44. package/src/stack-versions.generated.json +0 -45
package/src/apply.ts CHANGED
@@ -2,9 +2,9 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import yaml from 'yaml';
4
4
  import { readPluginConfigurationMap } from '@zhin.js/plugin-runtime';
5
+ import { readApplicationPluginDeclaration } from '@zhin.js/runtime';
5
6
  import type { AdapterSetupResult, AISetupConfig, DatabaseConfig, InitOptions } from './types.js';
6
7
  import {
7
- collectAdapterPluginConfigs,
8
8
  collectAdapterPluginManifest,
9
9
  generateAdapterEnvVars,
10
10
  getAdapterDependencies,
@@ -14,6 +14,7 @@ import { generateDatabaseEnvVars, mergeEnvText } from './env.js';
14
14
  import { getDatabaseDialectDefinition } from './database-definitions.js';
15
15
  import { ensureDatabaseForAdapters, ensureDatabaseForAI, getAIDependencies } from './project-deps.js';
16
16
  import { DEFAULT_CREATE_BOT_HTTP_PORT, ZHIN_STACK_VERSIONS } from './zhin-stack-deps.js';
17
+ import { withScaffoldVersionRanges } from './stack-version-ranges.js';
17
18
 
18
19
  export function applyDatabaseToConfig(config: Record<string, unknown>, database: DatabaseConfig): void {
19
20
  // 物化为可落盘对象:非 SQLite 连接参数引用 .env(${VAR}),明文密码不进配置文件
@@ -28,15 +29,34 @@ export function clonePluginConfigurationMap(
28
29
  }
29
30
 
30
31
  export function applyAdaptersToConfig(config: Record<string, unknown>, result: AdapterSetupResult): void {
31
- const plugins = clonePluginConfigurationMap(config);
32
32
  for (const instance of result.instances) {
33
- plugins[instance.instanceKey] = mergeAdapterInstanceConfig(
34
- instance.instanceKey,
35
- plugins[instance.instanceKey],
36
- instance.config,
37
- );
33
+ const plugins = clonePluginConfigurationMap(config);
34
+ setPluginInstanceConfig(config, instance, mergeAdapterInstanceConfig(
35
+ instance.instanceKey, plugins[instance.instanceKey], instance.config,
36
+ ));
38
37
  }
39
- config.plugins = plugins;
38
+ if (result.instances.length === 0) clonePluginConfigurationMap(config);
39
+ }
40
+
41
+ /** Writes one instance using the document's explicitly selected protocol. */
42
+ export function setPluginInstanceConfig(
43
+ config: Record<string, unknown>,
44
+ instance: { instanceKey: string; package: string },
45
+ value: Record<string, unknown>,
46
+ ): void {
47
+ const declaration = readApplicationPluginDeclaration(config);
48
+ const existing = declaration.instances.find(item => item.instanceKey === instance.instanceKey);
49
+ if (existing && existing.package !== instance.package) {
50
+ throw new Error(`Plugin instance ${instance.instanceKey} already belongs to ${existing.package}`);
51
+ }
52
+ const next = {
53
+ ...config,
54
+ plugins: { ...(config.plugins as Record<string, string> | undefined), [instance.instanceKey]: instance.package },
55
+ [instance.instanceKey]: value,
56
+ };
57
+ readApplicationPluginDeclaration(next);
58
+ config.plugins = next.plugins;
59
+ config[instance.instanceKey] = value;
40
60
  }
41
61
 
42
62
  function endpointId(endpoint: unknown): string | undefined {
@@ -89,35 +109,6 @@ export function applyAIToConfig(config: Record<string, unknown>, ai: AISetupConf
89
109
  config.ai = materializeAIConfig(ai);
90
110
  }
91
111
 
92
- /** 把 zhin.plugins 清单条目合并进项目 package.json(zhin setup 路径) */
93
- export async function mergePluginManifestIntoPackageJson(
94
- projectDir: string,
95
- entries: Array<{ package: string; instanceKey: string }>,
96
- ): Promise<boolean> {
97
- if (entries.length === 0) return false;
98
- const pkgPath = path.join(projectDir, 'package.json');
99
- if (!(await fs.pathExists(pkgPath))) return false;
100
-
101
- const pkg = await fs.readJson(pkgPath);
102
- const zhin = pkg.zhin && typeof pkg.zhin === 'object' && !Array.isArray(pkg.zhin)
103
- ? pkg.zhin
104
- : { protocol: 1, type: 'plugin', entry: './plugin.ts' };
105
- const manifest: Array<{ package: string; instanceKey: string }> = Array.isArray(zhin.plugins)
106
- ? [...zhin.plugins]
107
- : [];
108
- let changed = false;
109
- for (const entry of entries) {
110
- if (manifest.some((item) => item?.instanceKey === entry.instanceKey)) continue;
111
- manifest.push({ package: entry.package, instanceKey: entry.instanceKey });
112
- changed = true;
113
- }
114
- if (!changed && pkg.zhin) return false;
115
- zhin.plugins = manifest;
116
- pkg.zhin = zhin;
117
- await fs.writeJson(pkgPath, pkg, { spaces: 2 });
118
- return true;
119
- }
120
-
121
112
  export async function appendWizardEnvVars(
122
113
  projectDir: string,
123
114
  adapters?: AdapterSetupResult,
@@ -135,10 +126,7 @@ export async function appendWizardEnvVars(
135
126
  await fs.writeFile(envPath, mergeEnvText(existing, extra));
136
127
  }
137
128
 
138
- // 新 Plugin Runtime 通过 package.json zhin.plugins 清单挂载子插件,与依赖一起落盘
139
- if (adapters) {
140
- await mergePluginManifestIntoPackageJson(projectDir, collectAdapterPluginManifest(adapters));
141
- }
129
+
142
130
  }
143
131
 
144
132
  export function collectWizardDependencies(
@@ -147,7 +135,7 @@ export function collectWizardDependencies(
147
135
  const databaseDriver = options.database
148
136
  ? getDatabaseDialectDefinition(options.database.dialect)?.driver
149
137
  : undefined;
150
- return {
138
+ return withScaffoldVersionRanges({
151
139
  ...(options.adapters ? getAdapterDependencies(options.adapters) : {}),
152
140
  ...(databaseDriver ? { [databaseDriver.package]: databaseDriver.version } : {}),
153
141
  ...getAIDependencies(options.ai),
@@ -156,7 +144,7 @@ export function collectWizardDependencies(
156
144
  '@zhin.js/tool': ZHIN_STACK_VERSIONS['@zhin.js/tool'],
157
145
  '@zhin.js/prompt-section': ZHIN_STACK_VERSIONS['@zhin.js/prompt-section'],
158
146
  } : {}),
159
- };
147
+ });
160
148
  }
161
149
 
162
150
  /** 向导结果需要合并进 package.json zhin.features 的条目(与 create-zhin 路径对齐) */
@@ -295,11 +283,11 @@ function applyLifeAssistantAiExtras(ai: Record<string, unknown>): Record<string,
295
283
  }
296
284
 
297
285
  /**
298
- * 构建新 Plugin Runtime 配置文档(顶层 http/database/ai + plugins.<instanceKey>)。
286
+ * 构建 v2 扁平主配置(plugins 包映射 + 顶层实例业务配置)。
299
287
  * 顶层键与 packages/im/runtime/src/config-composer.ts 的 effectiveSchema 对齐。
300
288
  */
301
289
  export function buildRuntimeConfigDocument(options: InitOptions): Record<string, unknown> {
302
- const doc: Record<string, unknown> = {};
290
+ const doc: Record<string, unknown> = { configVersion: 2, plugins: {} };
303
291
 
304
292
  if (options.database) {
305
293
  doc.database = materializeDatabaseConfig(options.database);
@@ -317,7 +305,7 @@ export function buildRuntimeConfigDocument(options: InitOptions): Record<string,
317
305
  doc.ai = options.template === 'life-assistant' ? applyLifeAssistantAiExtras(ai) : ai;
318
306
  }
319
307
 
320
- doc.plugins = options.adapters ? collectAdapterPluginConfigs(options.adapters) : {};
308
+ if (options.adapters) applyAdaptersToConfig(doc, options.adapters);
321
309
 
322
310
  return doc;
323
311
  }
@@ -101,7 +101,7 @@ export const DATABASE_DIALECT_DEFINITIONS: readonly DatabaseDialectDefinition[]
101
101
  dialect: 'mongodb',
102
102
  name: 'MongoDB',
103
103
  description: '文档数据库',
104
- verifiedServer: 'MongoDB 8',
104
+ verifiedServer: 'MongoDB 7',
105
105
  driver: { package: 'mongodb', version: '^7.5.0' },
106
106
  schema: {
107
107
  type: 'object',
package/src/decisions.ts CHANGED
@@ -3,7 +3,7 @@ import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
4
  import path from 'node:path';
5
5
  import { formatEnvValue, mergeEnvText } from './env.js';
6
- import { clonePluginConfigurationMap, mergeDependenciesIntoPackageJson, mergePluginManifestIntoPackageJson } from './apply.js';
6
+ import { clonePluginConfigurationMap, mergeDependenciesIntoPackageJson, setPluginInstanceConfig } from './apply.js';
7
7
  import { isAiEnabledInConfig } from './project-deps.js';
8
8
 
9
9
  export const TYPESAFE_DECISION_PACKAGE = '@zhin.js/service-typesafe';
@@ -35,7 +35,7 @@ export async function configureTypeSafeDecisions(config: Readonly<Record<string,
35
35
  { type: 'input', name: 'model', message: 'Jev 模型(生产校准后建议固定版本)', default: 'jev-latest', validate: (value: string) => value.trim().length > 0 || '请输入模型名称' },
36
36
  { type: 'select', name: 'mode', message: 'Skill / Tool 推荐运行方式', default: 'shadow', choices: [ { name: 'shadow:观察建议,评估后再启用', value: 'shadow' }, { name: 'active:启用推荐,权限仍由 Zhin 校验', value: 'active' } ] },
37
37
  ]);
38
- const reference = record(record(config.plugins)[answers.instanceKey]).apiKey;
38
+ const reference = record(clonePluginConfigurationMap(config)[answers.instanceKey]).apiKey;
39
39
  const existingVariable = typeof reference === 'string' ? /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(reference)?.[1] : undefined;
40
40
  return { ...answers, envVar: existingVariable ?? decisionKeyVariable(answers.instanceKey) };
41
41
  }
@@ -55,8 +55,7 @@ export function applyTypeSafeDecisionsToConfig(config: Record<string, unknown>,
55
55
  if (!/^[a-z][a-z0-9-]*$/.test(setup.instanceKey) || !setup.model.trim() || !setup.apiKey.trim()) throw new Error('Invalid TypeSafe setup configuration.');
56
56
  const plugins = clonePluginConfigurationMap(config);
57
57
  const existing = record(plugins[setup.instanceKey]);
58
- plugins[setup.instanceKey] = { ...existing, apiKey: '${' + envVariable(setup) + '}', model: setup.model.trim(), timeoutMs: 10000, maxRetries: 2, enabled: true };
59
- config.plugins = plugins;
58
+ setPluginInstanceConfig(config, { instanceKey: setup.instanceKey, package: TYPESAFE_DECISION_PACKAGE }, { ...existing, apiKey: '${' + envVariable(setup) + '}', model: setup.model.trim(), timeoutMs: 10000, maxRetries: 2, enabled: true });
60
59
  const ai = record(config.ai);
61
60
  const previousDecisions = record(ai.decisions);
62
61
  config.ai = { ...ai, decisions: {
@@ -72,13 +71,6 @@ export function applyTypeSafeDecisionsToConfig(config: Record<string, unknown>,
72
71
 
73
72
  export async function saveTypeSafeSetupDependencies(projectDir: string, setup: TypeSafeSetupConfig): Promise<void> {
74
73
  const variable = envVariable(setup);
75
- const pkgPath = path.join(projectDir, 'package.json');
76
- const pkg = await fs.readJson(pkgPath);
77
- const existingManifest = record(pkg.zhin).plugins;
78
- if (Array.isArray(existingManifest) && existingManifest.some(entry => record(entry).instanceKey === setup.instanceKey && record(entry).package !== TYPESAFE_DECISION_PACKAGE)) {
79
- throw new Error('Decision plugin instance already belongs to another package; choose a different instanceKey.');
80
- }
81
- await mergePluginManifestIntoPackageJson(projectDir, [{ package: TYPESAFE_DECISION_PACKAGE, instanceKey: setup.instanceKey }]);
82
74
  await mergeDependenciesIntoPackageJson(projectDir, { [TYPESAFE_DECISION_PACKAGE]: '^1.0.0' });
83
75
  const envPath = path.join(projectDir, '.env');
84
76
  const envExists = await fs.pathExists(envPath);
@@ -116,14 +108,14 @@ export function diagnoseDecisionConfig(config: Readonly<Record<string, unknown>>
116
108
  // Nested Plugin owners are resolved from the actual runtime graph during activation.
117
109
  if (key.includes('/')) return issues;
118
110
  const pkg = record(packageJson);
119
- const manifest = record(pkg.zhin).plugins;
120
- const entries = Array.isArray(manifest) ? manifest.filter(entry => record(entry).instanceKey === key) : [];
121
- if (entries.length !== 1) {
122
- issues.push('决策插件实例未在 package.json#zhin.plugins 唯一声明。');
111
+ const declared = record(config.plugins);
112
+ if (typeof declared[key] !== 'string') {
113
+ issues.push('决策插件实例未在主配置 plugins 中声明。');
123
114
  return issues;
124
115
  }
125
- if (record(entries[0]).package === TYPESAFE_DECISION_PACKAGE) {
126
- const configForProvider = record(record(config.plugins)[key]);
116
+ if (Array.isArray(config.disable_plugins) && config.disable_plugins.includes(key)) issues.push('绑定的决策插件已停用。');
117
+ if (declared[key] === TYPESAFE_DECISION_PACKAGE) {
118
+ const configForProvider = record(config[key]);
127
119
  if (configForProvider.enabled === false) issues.push('绑定的 TypeSafe 插件已禁用。');
128
120
  if (typeof configForProvider.apiKey !== 'string' || !configForProvider.apiKey.trim()) issues.push('TypeSafe 插件缺少 apiKey 环境变量引用。');
129
121
  if (!Object.hasOwn({ ...record(pkg.dependencies), ...record(pkg.devDependencies) }, TYPESAFE_DECISION_PACKAGE)) issues.push('package.json 缺少 @zhin.js/service-typesafe 依赖。');
package/src/index.ts CHANGED
@@ -70,6 +70,13 @@ export {
70
70
  } from './project-deps.js';
71
71
  export type { AiStackIncompatibility } from './project-deps.js';
72
72
 
73
+ export {
74
+ FALLBACK_DEPENDENCY_RANGE,
75
+ scaffoldDependencyRange,
76
+ withScaffoldVersionRanges,
77
+ } from './stack-version-ranges.js';
78
+ export type { StackVersionRanges } from './stack-version-ranges.js';
79
+
73
80
  export {
74
81
  ZHIN_STACK_VERSIONS,
75
82
  DEFAULT_CREATE_BOT_HTTP_PORT,
@@ -128,7 +135,6 @@ export {
128
135
  collectWizardFeatures,
129
136
  mergeDependenciesIntoPackageJson,
130
137
  mergeFeaturesIntoPackageJson,
131
- mergePluginManifestIntoPackageJson,
132
138
  finalizeWizardOptions,
133
139
  applyWizardOptionsToConfig,
134
140
  clonePluginConfigurationMap,
@@ -1,6 +1,9 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
3
  import yaml from 'yaml';
4
+ import { createConfigDocument, readSourcePluginOrder } from '@zhin.js/config-file';
5
+ import { readApplicationPluginDeclaration } from '@zhin.js/runtime';
6
+ import { isDeepStrictEqual } from 'node:util';
4
7
  import {
5
8
  readPluginConfigurationMap,
6
9
  ROOT_CONFIG_FILE_NAMES,
@@ -32,6 +35,8 @@ export interface LoadedProjectConfig {
32
35
  format?: ProjectConfigFormat;
33
36
  config: Record<string, unknown>;
34
37
  writable: boolean;
38
+ /** Exact preview source: comments and declaration order are concurrency inputs. */
39
+ source?: string;
35
40
  message?: string;
36
41
  }
37
42
 
@@ -55,6 +60,7 @@ export interface ProjectConfigPlan {
55
60
  relativePath?: string;
56
61
  format?: ProjectConfigFormat;
57
62
  writable: boolean;
63
+ expectedSource?: string;
58
64
  before: Record<string, unknown>;
59
65
  after: Record<string, unknown>;
60
66
  mutations: string[];
@@ -128,9 +134,10 @@ export function loadProjectConfig(cwd = process.cwd(), configPath?: string): Loa
128
134
  }
129
135
 
130
136
  const content = fs.readFileSync(resolvedPath, 'utf8');
137
+ readSourcePluginOrder(content, format === 'json' ? 'JSON' : 'YAML');
131
138
  const config = parseConfig(content, format);
132
139
  try {
133
- readPluginConfigurationMap(config, relativePath);
140
+ readApplicationPluginDeclaration(config, relativePath);
134
141
  } catch (error) {
135
142
  return {
136
143
  status: 'unsupported',
@@ -150,23 +157,25 @@ export function loadProjectConfig(cwd = process.cwd(), configPath?: string): Loa
150
157
  relativePath,
151
158
  format,
152
159
  config,
160
+ source: content,
153
161
  writable: true,
154
162
  };
155
163
  }
156
164
 
157
165
  function ensurePlugins(config: Record<string, unknown>, pluginsToAdd: readonly string[], mutations: string[]): void {
158
- const plugins = { ...readPluginConfigurationMap(config) };
159
- let changed = false;
166
+ const declaration = readApplicationPluginDeclaration(config);
167
+ const plugins = { ...(config.plugins as Record<string, string> | undefined) };
160
168
  for (const pkg of pluginsToAdd) {
161
169
  const instanceKey = packageToInstanceKey(pkg);
162
- if (instanceKey in plugins) continue;
163
- plugins[instanceKey] = {};
164
- mutations.push(`added ${instanceKey} (${pkg}) to plugins`);
165
- changed = true;
166
- }
167
- if (changed || config.plugins === undefined) {
168
- config.plugins = plugins;
170
+ const existing = declaration.instances.find(item => item.instanceKey === instanceKey);
171
+ if (existing && existing.package !== pkg) throw new Error(`Plugin instance ${instanceKey} already belongs to ${existing.package}`);
172
+ if (!existing) {
173
+ plugins[instanceKey] = pkg;
174
+ mutations.push(`added ${instanceKey} (${pkg}) to plugins`);
175
+ }
169
176
  }
177
+ config.plugins = plugins;
178
+ readApplicationPluginDeclaration(config);
170
179
  }
171
180
 
172
181
  function ensureHttp(config: Record<string, unknown>, mutations: string[]): void {
@@ -332,6 +341,7 @@ export function createProjectConfigPlan(options: ProjectConfigPlanOptions): Proj
332
341
  relativePath: loaded.relativePath,
333
342
  format: loaded.format,
334
343
  writable: loaded.writable,
344
+ expectedSource: 'source' in loaded ? loaded.source : (loaded.configPath && fs.existsSync(loaded.configPath) ? fs.readFileSync(loaded.configPath, 'utf8') : undefined),
335
345
  before,
336
346
  after,
337
347
  mutations,
@@ -354,7 +364,25 @@ export function renderProjectConfigPatch(plan: ProjectConfigPlan): string {
354
364
  export async function applyProjectConfigPlan(plan: ProjectConfigPlan): Promise<boolean> {
355
365
  if (!plan.changed) return false;
356
366
  if (!plan.writable || !plan.configPath || !plan.format) return false;
357
- await fs.writeFile(plan.configPath, serializeConfig(plan.after, plan.format));
367
+ const document = createConfigDocument(plan.configPath);
368
+ const snapshot = await document.readSource();
369
+ if ((plan.expectedSource !== undefined && snapshot.source !== plan.expectedSource) || !isDeepStrictEqual(snapshot.document, plan.before)) throw new Error('配置已在预览后发生变更,请重新生成配置计划。');
370
+ const keys = new Set([...Object.keys(plan.before), ...Object.keys(plan.after)]);
371
+ const patches = [...keys].filter(key => !isDeepStrictEqual(plan.before[key], plan.after[key])).flatMap(key => {
372
+ if (key === 'plugins') {
373
+ const before = plan.before.plugins as Record<string, string> | undefined;
374
+ const after = plan.after.plugins as Record<string, string> | undefined;
375
+ return [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])]
376
+ .filter(alias => before?.[alias] !== after?.[alias])
377
+ .map(alias => Object.hasOwn(after ?? {}, alias)
378
+ ? { op: 'set' as const, path: ['plugins', alias], value: after![alias] }
379
+ : { op: 'remove' as const, path: ['plugins', alias] });
380
+ }
381
+ return [Object.hasOwn(plan.after, key)
382
+ ? { op: 'set' as const, path: [key], value: plan.after[key] }
383
+ : { op: 'remove' as const, path: [key] }];
384
+ });
385
+ await (await document.prepare(snapshot, patches)).commit();
358
386
  return true;
359
387
  }
360
388
 
@@ -5,7 +5,9 @@ import type { AISetupConfig } from './ai.js';
5
5
  import type { InitOptions } from './types.js';
6
6
 
7
7
  /**
8
- * User project dependency policy: scaffolded dependencies intentionally float to latest.
8
+ * Diagnostic baseline for the AI stack (any declared version is accepted).
9
+ * Scaffolds write derived ranges instead of `latest`: `withScaffoldVersionRanges()` uses the
10
+ * `@zhin.js/agent` release version and the third-party ranges Zhin declares as peers.
9
11
  */
10
12
  export const AI_STACK_VERSIONS = {
11
13
  '@zhin.js/agent': 'latest',
@@ -0,0 +1,160 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parse as parseYaml } from 'yaml';
5
+
6
+ /**
7
+ * Version ranges that scaffolds write into new bot projects instead of `latest`.
8
+ *
9
+ * They are derived from the real package versions of the release that ships this package:
10
+ * - published package: `prepack` writes `lib/stack-version-ranges.json` from the monorepo,
11
+ * so the ranges match the versions released together with it;
12
+ * - inside the zhin monorepo (source / tests / local builds): read live from the workspace.
13
+ * Anything unknown keeps the `latest` fallback, so a scaffold never writes an invalid range.
14
+ */
15
+
16
+ /** File written next to the compiled module at pack time. */
17
+ export const STACK_VERSION_RANGES_FILE = 'stack-version-ranges.json';
18
+
19
+ /** Written when no derived range is known for a package. */
20
+ export const FALLBACK_DEPENDENCY_RANGE = 'latest';
21
+
22
+ /** Zhin packages whose peerDependencies define the supported third-party AI stack. */
23
+ const THIRD_PARTY_RANGE_SOURCES = ['@zhin.js/ai', '@zhin.js/agent'] as const;
24
+
25
+ export interface StackVersionRanges {
26
+ readonly [packageName: string]: string;
27
+ }
28
+
29
+ interface WorkspaceManifest {
30
+ readonly name?: string;
31
+ readonly version?: string;
32
+ readonly private?: boolean;
33
+ readonly peerDependencies?: Record<string, string>;
34
+ }
35
+
36
+ function isZhinPackageName(name: string): boolean {
37
+ return name === 'zhin.js' || name.startsWith('@zhin.js/');
38
+ }
39
+
40
+ /** `1.2.3-beta.1` → `^1.2.3-beta.1`; anything that is not a version yields `undefined`. */
41
+ export function caretRangeFromVersion(version: string | undefined): string | undefined {
42
+ if (typeof version !== 'string') return undefined;
43
+ const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/u.exec(version.trim());
44
+ if (!match) return undefined;
45
+ return `^${match[0]}`;
46
+ }
47
+
48
+ function readManifest(directory: string): WorkspaceManifest | undefined {
49
+ const manifestPath = path.join(directory, 'package.json');
50
+ if (!fs.existsSync(manifestPath)) return undefined;
51
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as WorkspaceManifest;
52
+ }
53
+
54
+ function workspacePackageDirectories(workspaceRoot: string): string[] {
55
+ const workspaceFile = path.join(workspaceRoot, 'pnpm-workspace.yaml');
56
+ const workspace = parseYaml(fs.readFileSync(workspaceFile, 'utf8')) as { packages?: unknown };
57
+ const patterns = Array.isArray(workspace.packages)
58
+ ? workspace.packages.filter((pattern): pattern is string => typeof pattern === 'string')
59
+ : [];
60
+ const directories: string[] = [];
61
+ for (const pattern of patterns) {
62
+ if (!pattern.endsWith('/*')) {
63
+ directories.push(path.join(workspaceRoot, pattern));
64
+ continue;
65
+ }
66
+ const parent = path.join(workspaceRoot, pattern.slice(0, -2));
67
+ if (!fs.existsSync(parent)) continue;
68
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
69
+ if (entry.isDirectory()) directories.push(path.join(parent, entry.name));
70
+ }
71
+ }
72
+ return directories;
73
+ }
74
+
75
+ /**
76
+ * Collect ranges from a zhin monorepo checkout: `^<version>` for every published Zhin package,
77
+ * plus the third-party AI ranges Zhin itself declares as peers.
78
+ */
79
+ export function collectStackVersionRanges(workspaceRoot: string): StackVersionRanges {
80
+ const ranges: Record<string, string> = {};
81
+ const manifestsByName = new Map<string, WorkspaceManifest>();
82
+ for (const directory of workspacePackageDirectories(workspaceRoot)) {
83
+ const manifest = readManifest(directory);
84
+ if (!manifest?.name) continue;
85
+ manifestsByName.set(manifest.name, manifest);
86
+ if (manifest.private === true) continue;
87
+ if (!isZhinPackageName(manifest.name)) continue;
88
+ const range = caretRangeFromVersion(manifest.version);
89
+ if (range) ranges[manifest.name] = range;
90
+ }
91
+ for (const sourceName of THIRD_PARTY_RANGE_SOURCES) {
92
+ const peers = manifestsByName.get(sourceName)?.peerDependencies ?? {};
93
+ for (const [peerName, peerRange] of Object.entries(peers)) {
94
+ if (isZhinPackageName(peerName)) continue;
95
+ if (peerRange.startsWith('workspace:')) continue;
96
+ ranges[peerName] ??= peerRange;
97
+ }
98
+ }
99
+ const sortedEntries = Object.entries(ranges).sort(([left], [right]) => left.localeCompare(right));
100
+ return Object.freeze(Object.fromEntries(sortedEntries));
101
+ }
102
+
103
+ /** Nearest ancestor that is the zhin monorepo root, if this module runs from a checkout. */
104
+ export function findZhinWorkspaceRoot(startDirectory: string): string | undefined {
105
+ let directory = startDirectory;
106
+ for (;;) {
107
+ const isWorkspaceRoot = fs.existsSync(path.join(directory, 'pnpm-workspace.yaml'))
108
+ && fs.existsSync(path.join(directory, 'packages/toolkit/scaffold-wizard/package.json'));
109
+ if (isWorkspaceRoot) return directory;
110
+ const parent = path.dirname(directory);
111
+ if (parent === directory) return undefined;
112
+ directory = parent;
113
+ }
114
+ }
115
+
116
+ function readPackedRanges(moduleDirectory: string): StackVersionRanges | undefined {
117
+ const rangesFile = path.join(moduleDirectory, STACK_VERSION_RANGES_FILE);
118
+ if (!fs.existsSync(rangesFile)) return undefined;
119
+ return Object.freeze(JSON.parse(fs.readFileSync(rangesFile, 'utf8')) as StackVersionRanges);
120
+ }
121
+
122
+ let cachedRanges: StackVersionRanges | undefined;
123
+
124
+ /** Ranges for this installation: packed file, then a live monorepo checkout, else none. */
125
+ export function loadStackVersionRanges(): StackVersionRanges {
126
+ if (cachedRanges) return cachedRanges;
127
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
128
+ const packedRanges = readPackedRanges(moduleDirectory);
129
+ if (packedRanges) {
130
+ cachedRanges = packedRanges;
131
+ return cachedRanges;
132
+ }
133
+ const workspaceRoot = findZhinWorkspaceRoot(moduleDirectory);
134
+ cachedRanges = workspaceRoot ? collectStackVersionRanges(workspaceRoot) : Object.freeze({});
135
+ return cachedRanges;
136
+ }
137
+
138
+ /** Range to write for one package; `latest` only when no release version is known. */
139
+ export function scaffoldDependencyRange(
140
+ packageName: string,
141
+ ranges: StackVersionRanges = loadStackVersionRanges(),
142
+ ): string {
143
+ return ranges[packageName] ?? FALLBACK_DEPENDENCY_RANGE;
144
+ }
145
+
146
+ /**
147
+ * Replace `latest` entries with derived ranges before a scaffold writes package.json.
148
+ * Explicit ranges (database drivers, tooling) and packages without a known version stay as they are.
149
+ */
150
+ export function withScaffoldVersionRanges(
151
+ dependencies: Readonly<Record<string, string>>,
152
+ ranges: StackVersionRanges = loadStackVersionRanges(),
153
+ ): Record<string, string> {
154
+ const resolved: Record<string, string> = {};
155
+ for (const [packageName, range] of Object.entries(dependencies)) {
156
+ const isFloating = range === FALLBACK_DEPENDENCY_RANGE;
157
+ resolved[packageName] = isFloating ? scaffoldDependencyRange(packageName, ranges) : range;
158
+ }
159
+ return resolved;
160
+ }
@@ -5,9 +5,11 @@ import { DATABASE_DRIVER_VERSIONS, DATABASE_PACKAGES } from './types.js';
5
5
  import { findMissingPackageDependencies, findUnresolvedPackageInstalls, isAiEnabledInConfig, type PackageJsonLike } from './project-deps.js';
6
6
 
7
7
  /**
8
- * User project dependency policy: scaffolded Zhin packages intentionally float to latest.
8
+ * Diagnostic baseline for doctor / config check: any declared version of these packages is accepted
9
+ * (`latest`), so existing projects are never flagged because of a newer release.
10
+ * Scaffolds do not write these values as-is: `withScaffoldVersionRanges()` replaces `latest` with the
11
+ * caret range of the version released together with this package (see stack-version-ranges.ts).
9
12
  * Database drivers use the live-tested ranges declared by the database definitions.
10
- * WARNING: `latest` is not reproducible; pin Zhin package versions for production.
11
13
  */
12
14
  export const ZHIN_STACK_VERSIONS = {
13
15
  'zhin.js': 'latest',
@@ -1,45 +0,0 @@
1
- {
2
- "$comment": "Auto-generated by scripts/sync-scaffold-deps.mjs — do not edit; run pnpm sync:scaffold-deps",
3
- "zhinStack": {
4
- "zhin.js": "^5.0.0",
5
- "@zhin.js/cli": "^2.0.0",
6
- "@zhin.js/agent": "^1.0.5",
7
- "@zhin.js/database": "^1.0.77",
8
- "@zhin.js/client": "^2.1.0",
9
- "@zhin.js/contract": "^1.0.5",
10
- "@zhin.js/speech": "^2.0.0",
11
- "@zhin.js/html-renderer": "^2.0.0",
12
- "@zhin.js/mcp": "^5.0.0",
13
- "@zhin.js/adapter-sandbox": "^6.0.0",
14
- "@zhin.js/adapter-dingtalk": "^5.0.0",
15
- "@zhin.js/adapter-discord": "^6.0.0",
16
- "@zhin.js/adapter-email": "^4.0.0",
17
- "@zhin.js/adapter-github": "^4.0.0",
18
- "@zhin.js/adapter-icqq": "^7.0.0",
19
- "@zhin.js/adapter-kook": "^6.0.0",
20
- "@zhin.js/adapter-lark": "^5.0.0",
21
- "@zhin.js/adapter-line": "^3.0.0",
22
- "@zhin.js/adapter-milky": "^5.0.0",
23
- "@zhin.js/adapter-napcat": "^5.0.0",
24
- "@zhin.js/adapter-onebot11": "^5.0.0",
25
- "@zhin.js/adapter-onebot12": "^4.0.0",
26
- "@zhin.js/adapter-qq": "^6.0.0",
27
- "@zhin.js/adapter-satori": "^4.0.0",
28
- "@zhin.js/adapter-slack": "^5.0.0",
29
- "@zhin.js/adapter-telegram": "^6.0.0",
30
- "@zhin.js/adapter-wechat-mp": "^4.0.0",
31
- "@zhin.js/adapter-wecom": "^3.0.0",
32
- "@zhin.js/adapter-weixin-ilink": "^4.0.0"
33
- },
34
- "aiStack": {
35
- "@zhin.js/agent": "^1.0.5",
36
- "ai": "^7.0.0",
37
- "zod": "^4.0.0",
38
- "@ai-sdk/openai": "^4.0.8",
39
- "@ai-sdk/anthropic": "^4.0.9",
40
- "@ai-sdk/google": "^4.0.9",
41
- "@ai-sdk/deepseek": "^3.0.5",
42
- "@ai-sdk/openai-compatible": "^3.0.5",
43
- "@modelcontextprotocol/sdk": "^1.29.0"
44
- }
45
- }