@zhin.js/gateway 0.0.0-stage → 1.1.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.
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/lib/config/ai-config-loader.d.ts +22 -0
- package/lib/config/ai-config-loader.js +96 -0
- package/lib/config/configuration-chat-policy.d.ts +57 -0
- package/lib/config/configuration-chat-policy.js +423 -0
- package/lib/config/configuration-patch.d.ts +16 -0
- package/lib/config/configuration-patch.js +105 -0
- package/lib/config/configuration-path.d.ts +6 -0
- package/lib/config/configuration-path.js +40 -0
- package/lib/config/configuration-projection.d.ts +6 -0
- package/lib/config/configuration-projection.js +28 -0
- package/lib/config/configuration-redaction.d.ts +24 -0
- package/lib/config/configuration-redaction.js +128 -0
- package/lib/config/configuration.d.ts +81 -0
- package/lib/config/configuration.js +686 -0
- package/lib/config/console-schema.d.ts +11 -0
- package/lib/config/console-schema.js +222 -0
- package/lib/config/environment-files.d.ts +2 -0
- package/lib/config/environment-files.js +21 -0
- package/lib/config/plugin-package-map.d.ts +2 -0
- package/lib/config/plugin-package-map.js +14 -0
- package/lib/config/plugin-schema-catalog.d.ts +22 -0
- package/lib/config/plugin-schema-catalog.js +262 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.js +12 -0
- package/lib/database/config.d.ts +22 -0
- package/lib/database/config.js +159 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +31 -0
- package/lib/database/project-database.js +130 -0
- package/lib/database.d.ts +2 -0
- package/lib/database.js +2 -0
- package/lib/http/console-rpc.d.ts +19 -0
- package/lib/http/console-rpc.js +51 -0
- package/lib/http/gateway-console.d.ts +63 -0
- package/lib/http/gateway-console.js +309 -0
- package/lib/http/gateway-events.d.ts +35 -0
- package/lib/http/gateway-events.js +172 -0
- package/lib/http/gateway-http-server.d.ts +95 -0
- package/lib/http/gateway-http-server.js +518 -0
- package/lib/http/local-principal.d.ts +2 -0
- package/lib/http/local-principal.js +16 -0
- package/lib/http/run-gateway.d.ts +43 -0
- package/lib/http/run-gateway.js +268 -0
- package/lib/http/start-gateway.d.ts +114 -0
- package/lib/http/start-gateway.js +220 -0
- package/lib/http-response.d.ts +6 -0
- package/lib/http-response.js +25 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +6 -0
- package/lib/iam/crypto.d.ts +12 -0
- package/lib/iam/crypto.js +58 -0
- package/lib/iam/index.d.ts +8 -0
- package/lib/iam/index.js +8 -0
- package/lib/iam/install.d.ts +36 -0
- package/lib/iam/install.js +74 -0
- package/lib/iam/local-admin.d.ts +27 -0
- package/lib/iam/local-admin.js +102 -0
- package/lib/iam/rate-limit.d.ts +15 -0
- package/lib/iam/rate-limit.js +52 -0
- package/lib/iam/routes.d.ts +23 -0
- package/lib/iam/routes.js +157 -0
- package/lib/iam/rpc.d.ts +14 -0
- package/lib/iam/rpc.js +100 -0
- package/lib/iam/service.d.ts +203 -0
- package/lib/iam/service.js +784 -0
- package/lib/iam/setup-service.d.ts +83 -0
- package/lib/iam/setup-service.js +206 -0
- package/lib/iam/tables.d.ts +20 -0
- package/lib/iam/tables.js +82 -0
- package/lib/iam.d.ts +2 -0
- package/lib/iam.js +2 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/local/client.d.ts +71 -0
- package/lib/local/client.js +296 -0
- package/lib/local/handler.d.ts +30 -0
- package/lib/local/handler.js +86 -0
- package/lib/local/local-socket.d.ts +55 -0
- package/lib/local/local-socket.js +232 -0
- package/lib/local/paths.d.ts +34 -0
- package/lib/local/paths.js +58 -0
- package/lib/local/protocol.d.ts +140 -0
- package/lib/local/protocol.js +90 -0
- package/lib/local/state-file.d.ts +8 -0
- package/lib/local/state-file.js +45 -0
- package/lib/local.d.ts +10 -0
- package/lib/local.js +10 -0
- package/lib/onboarding/allowlist.d.ts +54 -0
- package/lib/onboarding/allowlist.js +106 -0
- package/lib/onboarding/errors.d.ts +7 -0
- package/lib/onboarding/errors.js +17 -0
- package/lib/onboarding/index.d.ts +6 -0
- package/lib/onboarding/index.js +6 -0
- package/lib/onboarding/install.d.ts +99 -0
- package/lib/onboarding/install.js +132 -0
- package/lib/onboarding/package-manager.d.ts +47 -0
- package/lib/onboarding/package-manager.js +179 -0
- package/lib/onboarding/service.d.ts +53 -0
- package/lib/onboarding/service.js +144 -0
- package/lib/onboarding/state-store.d.ts +26 -0
- package/lib/onboarding/state-store.js +97 -0
- package/lib/onboarding.d.ts +2 -0
- package/lib/onboarding.js +2 -0
- package/lib/packages/authorization.d.ts +23 -0
- package/lib/packages/authorization.js +109 -0
- package/lib/packages/errors.d.ts +11 -0
- package/lib/packages/errors.js +26 -0
- package/lib/packages/index.d.ts +5 -0
- package/lib/packages/index.js +5 -0
- package/lib/packages/rpc.d.ts +30 -0
- package/lib/packages/rpc.js +155 -0
- package/lib/packages/service.d.ts +121 -0
- package/lib/packages/service.js +441 -0
- package/lib/packages/snapshot.d.ts +14 -0
- package/lib/packages/snapshot.js +73 -0
- package/lib/packages.d.ts +2 -0
- package/lib/packages.js +2 -0
- package/lib/plugin-management/activity.d.ts +6 -0
- package/lib/plugin-management/activity.js +14 -0
- package/lib/plugin-management/config-explanation.d.ts +6 -0
- package/lib/plugin-management/config-explanation.js +113 -0
- package/lib/plugin-management/use-cases.d.ts +15 -0
- package/lib/plugin-management/use-cases.js +120 -0
- package/lib/plugin-management/workflow.d.ts +92 -0
- package/lib/plugin-management/workflow.js +772 -0
- package/lib/plugin-management.d.ts +5 -0
- package/lib/plugin-management.js +5 -0
- package/lib/project/config-file.d.ts +6 -0
- package/lib/project/config-file.js +58 -0
- package/lib/project/environment.d.ts +13 -0
- package/lib/project/environment.js +52 -0
- package/lib/project/instance-declarations.d.ts +55 -0
- package/lib/project/instance-declarations.js +542 -0
- package/lib/project/legacy-config.d.ts +25 -0
- package/lib/project/legacy-config.js +70 -0
- package/lib/project/node-requirements.d.ts +7 -0
- package/lib/project/node-requirements.js +20 -0
- package/lib/project/tsx-module-loader.d.ts +7 -0
- package/lib/project/tsx-module-loader.js +29 -0
- package/lib/project.d.ts +7 -0
- package/lib/project.js +7 -0
- package/lib/server.d.ts +11 -0
- package/lib/server.js +11 -0
- package/lib/supervisor/instance-logs.d.ts +39 -0
- package/lib/supervisor/instance-logs.js +152 -0
- package/lib/supervisor/instance-supervisor.d.ts +140 -0
- package/lib/supervisor/instance-supervisor.js +408 -0
- package/lib/supervisor/run-instance.d.ts +35 -0
- package/lib/supervisor/run-instance.js +161 -0
- package/lib/supervisor/terminal-state.d.ts +30 -0
- package/lib/supervisor/terminal-state.js +57 -0
- package/lib/supervisor.d.ts +4 -0
- package/lib/supervisor.js +4 -0
- package/lib/terminal/backends.d.ts +38 -0
- package/lib/terminal/backends.js +193 -0
- package/lib/terminal/config.d.ts +63 -0
- package/lib/terminal/config.js +237 -0
- package/lib/terminal/host.d.ts +26 -0
- package/lib/terminal/host.js +76 -0
- package/lib/terminal/index.d.ts +60 -0
- package/lib/terminal/index.js +135 -0
- package/lib/terminal/modules.d.ts +87 -0
- package/lib/terminal/modules.js +44 -0
- package/lib/terminal/service.d.ts +101 -0
- package/lib/terminal/service.js +455 -0
- package/lib/terminal/tickets.d.ts +46 -0
- package/lib/terminal/tickets.js +76 -0
- package/lib/terminal.d.ts +3 -0
- package/lib/terminal.js +3 -0
- package/lib/usage/aggregate.d.ts +87 -0
- package/lib/usage/aggregate.js +212 -0
- package/lib/usage.d.ts +2 -0
- package/lib/usage.js +2 -0
- package/package.json +116 -3
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { isAlias, isMap, isScalar, isSeq, Pair, parseDocument, visit } from 'yaml';
|
|
5
|
+
import { ROOT_CONFIG_FILE_NAMES, selectRootConfigFile } from '@zhin.js/plugin-runtime';
|
|
6
|
+
import { createConfigDocument, HOST_CONFIG_KEYS, NodePackageResolver, PackageResolutionError, ProjectGraphService, mergeChildPluginReferences, readSourcePluginOrder } from '@zhin.js/runtime';
|
|
7
|
+
import { readPluginLifecycleState, resolvePluginLifecycleFile } from '@zhin.js/runtime';
|
|
8
|
+
const JOURNAL_DIRECTORY = '.zhin/instance-declarations-migration';
|
|
9
|
+
const JOURNAL_NAME = 'journal.json';
|
|
10
|
+
const BACKUP_DIRECTORY = '.zhin/backups';
|
|
11
|
+
const RESERVED = new Set([...HOST_CONFIG_KEYS, 'plugins', 'configVersion', 'disable_plugins', '__proto__', 'constructor', 'prototype']);
|
|
12
|
+
const hash = (source) => createHash('sha256').update(source).digest('hex');
|
|
13
|
+
/** Static, offline and side-effect-free. Does not load schemas from executable entries. */
|
|
14
|
+
export async function planInstanceDeclarationMigration(projectRoot, options = {}) {
|
|
15
|
+
const root = await realpath(resolve(projectRoot));
|
|
16
|
+
await assertNoPendingInstanceMigration(root);
|
|
17
|
+
const selected = selectRootConfigFile((await readdir(root)).filter(file => ROOT_CONFIG_FILE_NAMES.includes(file)));
|
|
18
|
+
if (!selected)
|
|
19
|
+
throw new Error('No Root YAML/JSON configuration found; create configVersion: 2 manually.');
|
|
20
|
+
const configPath = join(root, selected), packagePath = join(root, 'package.json');
|
|
21
|
+
const [configBefore, packageBefore] = await Promise.all([sourceState(configPath), sourceState(packagePath)]);
|
|
22
|
+
const configAst = parseStrict(configBefore.source, configPath);
|
|
23
|
+
const packageAst = parseStrict(packageBefore.source, packagePath);
|
|
24
|
+
const config = configAst.toJS({ maxAliasCount: 100 });
|
|
25
|
+
if (config.configVersion !== undefined)
|
|
26
|
+
throw new Error('Only an unversioned legacy configuration can be migrated; configVersion: 2 needs no conversion.');
|
|
27
|
+
const legacyConfigs = configAst.get('plugins', true);
|
|
28
|
+
if (legacyConfigs !== undefined && !isMap(legacyConfigs))
|
|
29
|
+
throw new Error('Legacy plugins must be an instance configuration map; arrays need a manual migration.');
|
|
30
|
+
const lifecyclePath = options.lifecycleFile ?? resolvePluginLifecycleFile(root);
|
|
31
|
+
if (!isAbsolute(lifecyclePath))
|
|
32
|
+
throw new Error('Lifecycle file must be an absolute path');
|
|
33
|
+
if ([configPath, packagePath].includes(resolve(lifecyclePath)))
|
|
34
|
+
throw new Error('Lifecycle file must be distinct from package.json and the Root configuration');
|
|
35
|
+
const lifecycleBefore = await sourceState(lifecyclePath);
|
|
36
|
+
const lifecycleAst = lifecycleBefore.exists ? parseStrict(lifecycleBefore.source, `${lifecyclePath}.json`) : undefined;
|
|
37
|
+
const lifecycle = await readPluginLifecycleState(lifecyclePath);
|
|
38
|
+
const resolver = await NodePackageResolver.create(root);
|
|
39
|
+
const rootPackage = await resolver.root(root);
|
|
40
|
+
if (rootPackage.packageJson.zhin.type !== 'plugin')
|
|
41
|
+
throw new Error('Root package must be a Plugin');
|
|
42
|
+
const guards = new Map([[packagePath, packageBefore.digest]]);
|
|
43
|
+
async function guard(pkg) {
|
|
44
|
+
const file = join(pkg.root, 'package.json');
|
|
45
|
+
guards.set(file, (await sourceState(file)).digest);
|
|
46
|
+
}
|
|
47
|
+
let facade;
|
|
48
|
+
let carrier;
|
|
49
|
+
if (rootPackage.packageJson.zhin.platformFeatures !== false
|
|
50
|
+
&& (rootPackage.packageJson.dependencies?.['@zhin.js/core'] || rootPackage.packageJson.optionalDependencies?.['@zhin.js/core'])) {
|
|
51
|
+
try {
|
|
52
|
+
carrier = await resolver.resolve('@zhin.js/core', rootPackage);
|
|
53
|
+
await guard(carrier);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!(error instanceof PackageResolutionError))
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let inherited = [];
|
|
61
|
+
if (rootPackage.packageJson.zhin.platformFeatures !== false
|
|
62
|
+
&& (rootPackage.packageJson.dependencies?.['zhin.js'] || rootPackage.packageJson.optionalDependencies?.['zhin.js'])) {
|
|
63
|
+
try {
|
|
64
|
+
facade = await resolver.resolve('zhin.js', rootPackage);
|
|
65
|
+
// Existing platform inheritance is admitted only with its Feature carrier.
|
|
66
|
+
carrier ??= await resolver.resolve('@zhin.js/core', facade);
|
|
67
|
+
await guard(facade);
|
|
68
|
+
await guard(carrier);
|
|
69
|
+
if (facade.packageJson.zhin.type === 'plugin')
|
|
70
|
+
inherited = facade.packageJson.zhin.plugins;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (!(error instanceof PackageResolutionError))
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const own = rootPackage.packageJson.zhin.plugins;
|
|
78
|
+
const references = mergeChildPluginReferences(own, inherited);
|
|
79
|
+
const ownKeys = new Set(own.map(reference => reference.instanceKey));
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const instances = [];
|
|
82
|
+
for (const reference of references) {
|
|
83
|
+
const key = reference.instanceKey;
|
|
84
|
+
if (seen.has(key) || RESERVED.has(key))
|
|
85
|
+
throw new Error(`Cannot migrate alias ${key}: duplicate or Host/framework field collision; keep the original identity and migrate manually.`);
|
|
86
|
+
seen.add(key);
|
|
87
|
+
if (reference.optional || reference.api !== undefined)
|
|
88
|
+
throw new Error(`Cannot migrate ${key}: per-reference optional/api is not representable in v2.`);
|
|
89
|
+
const owner = ownKeys.has(key) ? rootPackage : facade;
|
|
90
|
+
const pkg = await resolver.resolve(reference.package, owner);
|
|
91
|
+
await guard(pkg);
|
|
92
|
+
if (pkg.packageJson.zhin.type !== 'plugin')
|
|
93
|
+
throw new Error(`${key} is not a Plugin`);
|
|
94
|
+
if (pkg.packageJson.zhin.plugins.length)
|
|
95
|
+
throw new Error(`Cannot migrate ${key}: nested plugin instances cannot be flattened without changing PluginId.`);
|
|
96
|
+
// Moving a facade-relative reference must not silently change its physical implementation.
|
|
97
|
+
if (owner !== rootPackage) {
|
|
98
|
+
const fromRoot = await resolver.resolve(reference.package, rootPackage).catch(() => undefined);
|
|
99
|
+
if (!fromRoot || fromRoot.root !== pkg.root)
|
|
100
|
+
throw new Error(`Cannot migrate inherited ${key}: declare the original package specification in the Root dependencies first (resolution owner would change).`);
|
|
101
|
+
}
|
|
102
|
+
instances.push({ instanceKey: key, package: reference.package, pluginId: `root/${key}`, enabled: !lifecycle.disabled.includes(key) });
|
|
103
|
+
}
|
|
104
|
+
for (const key of lifecycle.disabled)
|
|
105
|
+
if (!seen.has(key))
|
|
106
|
+
throw new Error(`Orphan disabled alias ${key}; resolve it before migration.`);
|
|
107
|
+
if (isMap(legacyConfigs))
|
|
108
|
+
for (const pair of legacyConfigs.items) {
|
|
109
|
+
const key = String(isScalar(pair.key) ? pair.key.value : pair.key);
|
|
110
|
+
if (!seen.has(key))
|
|
111
|
+
throw new Error(`Orphan plugin configuration ${key}; migration will not discard user data.`);
|
|
112
|
+
}
|
|
113
|
+
for (const key of Object.keys(config))
|
|
114
|
+
if (key !== 'plugins' && !HOST_CONFIG_KEYS.includes(key) && !seen.has(key)) {
|
|
115
|
+
throw new Error(`Unknown top-level legacy field ${key}; resolve its ownership manually before migration.`);
|
|
116
|
+
}
|
|
117
|
+
for (const instance of instances)
|
|
118
|
+
if (configAst.has(instance.instanceKey))
|
|
119
|
+
throw new Error(`Top-level field ${instance.instanceKey} already exists; cannot overwrite it.`);
|
|
120
|
+
// Move the existing Pair nodes, preserving their quoting, comments and scalar values.
|
|
121
|
+
const emptyDeclarations = {};
|
|
122
|
+
const declaration = configAst.createNode(emptyDeclarations);
|
|
123
|
+
if (!isMap(declaration) || !isMap(configAst.contents))
|
|
124
|
+
throw new Error('Configuration root must be an object');
|
|
125
|
+
if (isMap(legacyConfigs)) {
|
|
126
|
+
declaration.comment = legacyConfigs.comment;
|
|
127
|
+
declaration.commentBefore = legacyConfigs.commentBefore;
|
|
128
|
+
declaration.spaceBefore = legacyConfigs.spaceBefore;
|
|
129
|
+
}
|
|
130
|
+
const originalPairs = isMap(legacyConfigs) ? [...legacyConfigs.items] : [];
|
|
131
|
+
for (const instance of instances)
|
|
132
|
+
declaration.items.push(new Pair(configAst.createNode(instance.instanceKey), configAst.createNode(instance.package)));
|
|
133
|
+
configAst.set('plugins', declaration);
|
|
134
|
+
configAst.set('configVersion', 2);
|
|
135
|
+
configAst.set('disable_plugins', configAst.createNode(instances.filter(instance => !instance.enabled).map(instance => instance.instanceKey)));
|
|
136
|
+
for (const instance of instances) {
|
|
137
|
+
const pair = originalPairs.find(item => String(isScalar(item.key) ? item.key.value : item.key) === instance.instanceKey);
|
|
138
|
+
if (pair)
|
|
139
|
+
configAst.contents.items.push(pair);
|
|
140
|
+
else
|
|
141
|
+
configAst.contents.items.push(new Pair(configAst.createNode(instance.instanceKey), configAst.createNode({})));
|
|
142
|
+
}
|
|
143
|
+
const configAfter = printAst(configAst, configBefore.source, configPath);
|
|
144
|
+
const output = parseStrict(configAfter, configPath).toJS();
|
|
145
|
+
if (output.configVersion !== 2 || readSourcePluginOrder(configAfter, isJson(configPath) ? 'JSON' : 'YAML')?.join('\0') !== instances.map(instance => instance.instanceKey).join('\0'))
|
|
146
|
+
throw new Error('Migration produced a different declaration order');
|
|
147
|
+
const migratedRoot = {
|
|
148
|
+
...rootPackage,
|
|
149
|
+
packageJson: { ...rootPackage.packageJson, zhin: { ...rootPackage.packageJson.zhin, plugins: [] } },
|
|
150
|
+
};
|
|
151
|
+
const graph = await new ProjectGraphService({
|
|
152
|
+
root: async () => migratedRoot,
|
|
153
|
+
resolve: (request, owner) => resolver.resolve(request, owner),
|
|
154
|
+
workspacePackages: () => resolver.workspacePackages(),
|
|
155
|
+
}).inspect(root, output, instances.map(instance => instance.instanceKey));
|
|
156
|
+
for (const pkg of graph.packages.values())
|
|
157
|
+
await guard(pkg);
|
|
158
|
+
// Verify via the actual adapter without changing any source files.
|
|
159
|
+
await createConfigDocument(configPath).prepareReplacement((await createConfigDocument(configPath).read()).revision, configAfter);
|
|
160
|
+
packageAst.setIn(['zhin', 'plugins'], []);
|
|
161
|
+
const packageAfter = printAst(packageAst, packageBefore.source, packagePath);
|
|
162
|
+
const files = [
|
|
163
|
+
{ path: configPath, before: configBefore, after: present(configAfter, configBefore.mode) },
|
|
164
|
+
{ path: packagePath, before: packageBefore, after: present(packageAfter, packageBefore.mode) },
|
|
165
|
+
];
|
|
166
|
+
if (lifecycleAst) {
|
|
167
|
+
lifecycleAst.set('disabled', []);
|
|
168
|
+
files.push({ path: lifecyclePath, before: lifecycleBefore, after: present(printAst(lifecycleAst, lifecycleBefore.source, `${lifecyclePath}.json`), lifecycleBefore.mode) });
|
|
169
|
+
}
|
|
170
|
+
return Object.freeze({ root, files: Object.freeze(files), guards: Object.freeze([...guards].map(([path, digest]) => ({ path, digest }))), instances: Object.freeze(instances), pendingChecks: Object.freeze(['Executable inline Schema, Token requirements and Runtime setup remain unverified until controlled startup.']) });
|
|
171
|
+
}
|
|
172
|
+
export async function assertNoPendingInstanceMigration(root) {
|
|
173
|
+
const journal = await readJournal(root);
|
|
174
|
+
if (journal && !['completed', 'rolled-back'].includes(journal.status))
|
|
175
|
+
throw new Error('Incomplete instance-declaration migration. Keep the Bot stopped; run zhin migrate instances --recover continue or --recover rollback.');
|
|
176
|
+
}
|
|
177
|
+
export async function applyInstanceDeclarationMigration(plan, options = {}) {
|
|
178
|
+
return withProjectLock(plan.root, async () => {
|
|
179
|
+
await assertStopped(plan.root);
|
|
180
|
+
await assertNoPendingInstanceMigration(plan.root);
|
|
181
|
+
for (const file of plan.files)
|
|
182
|
+
await assertState(file.path, file.before);
|
|
183
|
+
for (const guard of plan.guards)
|
|
184
|
+
if ((await sourceState(guard.path)).digest !== guard.digest)
|
|
185
|
+
throw new Error(`Migration source changed: ${guard.path}`);
|
|
186
|
+
const directory = join(plan.root, JOURNAL_DIRECTORY);
|
|
187
|
+
const previous = await readJournal(plan.root);
|
|
188
|
+
// A completed rollback may be retried; archive every original backup intact.
|
|
189
|
+
if (previous)
|
|
190
|
+
await rename(directory, `${directory}.${randomUUID()}.backup`);
|
|
191
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
192
|
+
await chmod(directory, 0o700);
|
|
193
|
+
const files = [];
|
|
194
|
+
const batch = randomUUID();
|
|
195
|
+
for (const [index, file] of plan.files.entries()) {
|
|
196
|
+
const backup = `${batch}-${index}.before`, candidate = `${batch}-${index}.after`;
|
|
197
|
+
await writeFile(join(directory, backup), file.before.source, { mode: 0o600, flag: 'wx' });
|
|
198
|
+
await writeFile(join(directory, candidate), file.after.source, { mode: 0o600, flag: 'wx' });
|
|
199
|
+
files.push({ path: file.path, before: summary(file.before), after: summary(file.after), backup, candidate });
|
|
200
|
+
}
|
|
201
|
+
const journal = { version: 1, root: plan.root, status: 'prepared', files, guards: [...plan.guards] };
|
|
202
|
+
await writeJournal(plan.root, journal);
|
|
203
|
+
// A plain, timestamped copy users can inspect or restore by hand, written before any cutover.
|
|
204
|
+
const backupDirectory = await writeHumanBackup(plan, (options.now ?? (() => new Date()))());
|
|
205
|
+
await perform(plan.root, journal, 'continue', options);
|
|
206
|
+
return Object.freeze({ backupDirectory });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function backupStamp(date) {
|
|
210
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
211
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
212
|
+
}
|
|
213
|
+
async function writeHumanBackup(plan, date) {
|
|
214
|
+
const parent = join(plan.root, BACKUP_DIRECTORY);
|
|
215
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
216
|
+
const stamp = `config-v2-${backupStamp(date)}`;
|
|
217
|
+
let directory = join(parent, stamp);
|
|
218
|
+
for (let attempt = 2;; attempt++) {
|
|
219
|
+
try {
|
|
220
|
+
await mkdir(directory, { mode: 0o700 });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST') || attempt > 100)
|
|
225
|
+
throw error;
|
|
226
|
+
directory = join(parent, `${stamp}-${attempt}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
await chmod(directory, 0o700);
|
|
230
|
+
const entries = [];
|
|
231
|
+
for (const [index, file] of plan.files.entries()) {
|
|
232
|
+
if (!file.before.exists)
|
|
233
|
+
continue;
|
|
234
|
+
const local = relative(plan.root, file.path);
|
|
235
|
+
const name = !local || local.startsWith('..') || isAbsolute(local) ? join('external', `${index}-${basename(file.path)}`) : local;
|
|
236
|
+
const target = join(directory, name);
|
|
237
|
+
await mkdir(join(target, '..'), { recursive: true, mode: 0o700 });
|
|
238
|
+
// Originals may contain inline credentials; keep the copy private.
|
|
239
|
+
await writeFile(target, file.before.source, { mode: 0o600, flag: 'wx' });
|
|
240
|
+
await chmod(target, 0o600);
|
|
241
|
+
entries.push({ original: file.path, backup: name.split(sep).join('/'), sha256: file.before.digest });
|
|
242
|
+
}
|
|
243
|
+
const manifest = {
|
|
244
|
+
kind: 'zhin-config-v2-migration-backup',
|
|
245
|
+
createdAt: date.toISOString(),
|
|
246
|
+
root: plan.root,
|
|
247
|
+
files: entries,
|
|
248
|
+
restore: [
|
|
249
|
+
'Preferred: keep the Bot stopped and run `zhin migrate instances --recover rollback`.',
|
|
250
|
+
'Manual: copy each backup file back to its original path, then remove .zhin/instance-declarations-migration.',
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
await writeFile(join(directory, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
254
|
+
return directory;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Human-readable, value-free preview of a migration plan. Only key names, package
|
|
258
|
+
* specifiers and instance state are shown; configuration values (including
|
|
259
|
+
* environment references) never appear, so the output is safe to paste in issues.
|
|
260
|
+
*/
|
|
261
|
+
export function formatInstanceMigrationPreview(plan, options = {}) {
|
|
262
|
+
const mode = options.mode ?? 'dry-run';
|
|
263
|
+
const display = (file) => {
|
|
264
|
+
const local = relative(plan.root, file);
|
|
265
|
+
return !local || local.startsWith('..') || isAbsolute(local) ? file : local.split(sep).join('/');
|
|
266
|
+
};
|
|
267
|
+
const [configFile, packageFile, lifecycleFile] = plan.files;
|
|
268
|
+
const lines = [
|
|
269
|
+
mode === 'dry-run'
|
|
270
|
+
? 'zhin migrate instances — dry-run(仅预览,不写文件 / preview only, nothing is written)'
|
|
271
|
+
: 'zhin migrate instances — apply(写入前自动备份 / backing up before writing)',
|
|
272
|
+
'',
|
|
273
|
+
];
|
|
274
|
+
const legacyKeys = new Map();
|
|
275
|
+
if (configFile?.before.exists) {
|
|
276
|
+
try {
|
|
277
|
+
const legacy = parseDocument(configFile.before.source).get('plugins', true);
|
|
278
|
+
if (isMap(legacy))
|
|
279
|
+
for (const pair of legacy.items) {
|
|
280
|
+
const key = String(isScalar(pair.key) ? pair.key.value : pair.key);
|
|
281
|
+
legacyKeys.set(key, isMap(pair.value) ? pair.value.items.map(item => String(isScalar(item.key) ? item.key.value : item.key)) : []);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch { /* planning already validated the source */ }
|
|
285
|
+
}
|
|
286
|
+
const quote = (value) => JSON.stringify(value);
|
|
287
|
+
const fields = (keys) => keys.length ? `{${keys.slice(0, 8).join(', ')}${keys.length > 8 ? `, … +${keys.length - 8}` : ''}}` : '{}';
|
|
288
|
+
if (configFile) {
|
|
289
|
+
lines.push(`${display(configFile.path)}`);
|
|
290
|
+
lines.push(' + configVersion: 2');
|
|
291
|
+
for (const instance of plan.instances)
|
|
292
|
+
lines.push(` + plugins.${instance.instanceKey}: ${quote(instance.package)}`);
|
|
293
|
+
for (const instance of plan.instances) {
|
|
294
|
+
const keys = legacyKeys.get(instance.instanceKey);
|
|
295
|
+
if (keys === undefined)
|
|
296
|
+
lines.push(` + ${instance.instanceKey}: {}`);
|
|
297
|
+
else
|
|
298
|
+
lines.push(` ~ plugins.${instance.instanceKey}.${fields(keys)} → ${instance.instanceKey}.${fields(keys)} (moved, values unchanged / 原值搬移)`);
|
|
299
|
+
}
|
|
300
|
+
const disabled = plan.instances.filter(instance => !instance.enabled).map(instance => instance.instanceKey);
|
|
301
|
+
lines.push(` + disable_plugins: [${disabled.join(', ')}]`);
|
|
302
|
+
lines.push('');
|
|
303
|
+
}
|
|
304
|
+
if (packageFile) {
|
|
305
|
+
lines.push(display(packageFile.path));
|
|
306
|
+
lines.push(` - zhin.plugins (${plan.instances.length} legacy mount(s) / 旧挂载) → []`);
|
|
307
|
+
lines.push(' dependencies, scripts, zhin.features/entry/engine are kept / 其余字段保持不变');
|
|
308
|
+
lines.push('');
|
|
309
|
+
}
|
|
310
|
+
if (lifecycleFile) {
|
|
311
|
+
lines.push(display(lifecycleFile.path));
|
|
312
|
+
lines.push(' ~ disabled → [] (moved to disable_plugins / 已并入 disable_plugins)');
|
|
313
|
+
lines.push('');
|
|
314
|
+
}
|
|
315
|
+
lines.push('Instances / 实例:');
|
|
316
|
+
for (const instance of plan.instances) {
|
|
317
|
+
lines.push(` ${instance.instanceKey} ${instance.package} ${instance.enabled ? 'enabled' : 'disabled'} ${instance.pluginId}`);
|
|
318
|
+
}
|
|
319
|
+
if (!plan.instances.length)
|
|
320
|
+
lines.push(' (none)');
|
|
321
|
+
if (plan.pendingChecks.length) {
|
|
322
|
+
lines.push('', 'Pending checks / 待启动后验证:');
|
|
323
|
+
for (const check of plan.pendingChecks)
|
|
324
|
+
lines.push(` - ${check}`);
|
|
325
|
+
}
|
|
326
|
+
if (mode === 'dry-run') {
|
|
327
|
+
lines.push('', `Backup on --apply / 应用时自动备份到: ${BACKUP_DIRECTORY}/config-v2-<timestamp>/`);
|
|
328
|
+
}
|
|
329
|
+
return lines.join('\n');
|
|
330
|
+
}
|
|
331
|
+
export async function recoverInstanceDeclarationMigration(rootPath, action, options = {}) {
|
|
332
|
+
const root = await realpath(resolve(rootPath));
|
|
333
|
+
await withProjectLock(root, async () => {
|
|
334
|
+
await assertStopped(root);
|
|
335
|
+
const journal = await readJournal(root);
|
|
336
|
+
if (!journal)
|
|
337
|
+
throw new Error('No instance-declaration migration journal exists');
|
|
338
|
+
await perform(root, journal, action, options);
|
|
339
|
+
}, true);
|
|
340
|
+
}
|
|
341
|
+
async function perform(root, journal, action, options) {
|
|
342
|
+
if (journal.root !== root || journal.version !== 1 || !Array.isArray(journal.files))
|
|
343
|
+
throw new Error('Invalid migration journal');
|
|
344
|
+
const directory = join(root, JOURNAL_DIRECTORY);
|
|
345
|
+
const states = await Promise.all(journal.files.map(async (file) => {
|
|
346
|
+
if (!isAbsolute(file.path) || !/^[a-f0-9-]+-\d+\.before$/.test(file.backup) || !/^[a-f0-9-]+-\d+\.after$/.test(file.candidate))
|
|
347
|
+
throw new Error('Invalid migration journal path');
|
|
348
|
+
const current = await sourceState(file.path);
|
|
349
|
+
if (!matches(current, file.before) && !matches(current, file.after))
|
|
350
|
+
throw new Error(`File changed after migration: ${file.path}; recovery will not overwrite user edits.`);
|
|
351
|
+
const target = action === 'continue' ? file.after : file.before;
|
|
352
|
+
const source = await readFile(join(directory, action === 'continue' ? file.candidate : file.backup), 'utf8');
|
|
353
|
+
if (hash(source) !== target.digest)
|
|
354
|
+
throw new Error('Migration backup/candidate digest mismatch');
|
|
355
|
+
return { file, current, target, source };
|
|
356
|
+
}));
|
|
357
|
+
// Consulted dependency manifests must still match unless they are one of our writes.
|
|
358
|
+
for (const guard of journal.guards)
|
|
359
|
+
if (!journal.files.some(file => file.path === guard.path) && (await sourceState(guard.path)).digest !== guard.digest)
|
|
360
|
+
throw new Error(`Migration package changed: ${guard.path}`);
|
|
361
|
+
journal.status = action === 'continue' ? 'applying' : 'rolling-back';
|
|
362
|
+
await writeJournal(root, journal);
|
|
363
|
+
for (const [index, state] of states.entries()) {
|
|
364
|
+
if (!matches(state.current, state.target)) {
|
|
365
|
+
await assertState(state.file.path, state.current);
|
|
366
|
+
if (state.target.exists)
|
|
367
|
+
await atomicWrite(state.file.path, state.source, state.target.mode);
|
|
368
|
+
else
|
|
369
|
+
await rm(state.file.path);
|
|
370
|
+
await options.afterWrite?.(index);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
journal.status = action === 'continue' ? 'completed' : 'rolled-back';
|
|
374
|
+
await writeJournal(root, journal);
|
|
375
|
+
}
|
|
376
|
+
function parseStrict(source, file) {
|
|
377
|
+
if (isJson(file)) {
|
|
378
|
+
try {
|
|
379
|
+
JSON.parse(source);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
throw new Error(`Cannot migrate ${file}: Invalid JSON syntax`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const document = parseDocument(source, { strict: true, uniqueKeys: true, prettyErrors: false });
|
|
386
|
+
if (document.errors.length) {
|
|
387
|
+
// Parser messages may contain source excerpts or offending scalar values.
|
|
388
|
+
// Emit only stable diagnostic codes, even when pretty formatting is off.
|
|
389
|
+
const reason = document.errors.some(error => error.code === 'DUPLICATE_KEY') ? 'Mapping keys must be unique' : 'Invalid YAML syntax';
|
|
390
|
+
throw new Error(`Cannot migrate ${file}: ${reason} (${document.errors.map(error => error.code).join(', ')})`);
|
|
391
|
+
}
|
|
392
|
+
if (!isMap(document.contents))
|
|
393
|
+
throw new Error(`Configuration must be an object: ${file}`);
|
|
394
|
+
visit(document, (_, node) => {
|
|
395
|
+
if (isAlias(node) || isMap(node) && node.items.some(pair => isScalar(pair.key) && String(pair.key.value) === '<<'))
|
|
396
|
+
throw new Error(`YAML aliases/merge keys require a manual migration: ${file}`);
|
|
397
|
+
if (isMap(node)) {
|
|
398
|
+
const keys = new Set();
|
|
399
|
+
for (const pair of node.items) {
|
|
400
|
+
if (!isScalar(pair.key))
|
|
401
|
+
throw new Error(`Complex mapping keys need manual migration: ${file}`);
|
|
402
|
+
const key = String(pair.key.value);
|
|
403
|
+
if (keys.has(key))
|
|
404
|
+
throw new Error(`Duplicate object key ${key}: ${file}`);
|
|
405
|
+
keys.add(key);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
return document;
|
|
410
|
+
}
|
|
411
|
+
function isJson(file) { return extname(file).toLowerCase() === '.json'; }
|
|
412
|
+
function printAst(document, original, file) {
|
|
413
|
+
if (!isJson(file)) {
|
|
414
|
+
const source = document.toString({ indent: original.match(/^( +)\S/mu)?.[1].length ?? 2, lineWidth: 0 });
|
|
415
|
+
return original.includes('\r\n') ? source.replaceAll('\n', '\r\n') : source;
|
|
416
|
+
}
|
|
417
|
+
// YAML AST preserves ordered JSON Pair nodes; serialize without JS Object.keys.
|
|
418
|
+
const indent = original.match(/\n([ \t]+)"/u)?.[1] ?? ' ';
|
|
419
|
+
function render(node, level) {
|
|
420
|
+
const prefix = indent.repeat(level + 1), suffix = indent.repeat(level);
|
|
421
|
+
if (isMap(node))
|
|
422
|
+
return node.items.length ? `{\n${node.items.map(pair => `${prefix}${JSON.stringify(String(isScalar(pair.key) ? pair.key.value : pair.key))}: ${render(pair.value, level + 1)}`).join(',\n')}\n${suffix}}` : '{}';
|
|
423
|
+
if (isSeq(node))
|
|
424
|
+
return node.items.length ? `[\n${node.items.map(item => `${prefix}${render(item, level + 1)}`).join(',\n')}\n${suffix}]` : '[]';
|
|
425
|
+
return JSON.stringify(isScalar(node) ? node.value : node) ?? 'null';
|
|
426
|
+
}
|
|
427
|
+
const source = `${render(document.contents, 0)}\n`;
|
|
428
|
+
return original.includes('\r\n') ? source.replaceAll('\n', '\r\n') : source;
|
|
429
|
+
}
|
|
430
|
+
function summary(state) { return { exists: state.exists, digest: state.digest, mode: state.mode }; }
|
|
431
|
+
function present(source, mode = 0o600) { return { exists: true, source, digest: hash(source), mode }; }
|
|
432
|
+
function matches(current, expected) { return current.exists === expected.exists && current.digest === expected.digest && current.mode === expected.mode; }
|
|
433
|
+
async function sourceState(file) {
|
|
434
|
+
try {
|
|
435
|
+
if ((await lstat(file)).isSymbolicLink())
|
|
436
|
+
throw new Error(`Migration does not replace symbolic links: ${file}`);
|
|
437
|
+
const source = await readFile(file, 'utf8');
|
|
438
|
+
return present(source, (await stat(file)).mode);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
442
|
+
return { exists: false, source: '', digest: hash(''), mode: 0o600 };
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async function assertState(file, expected) {
|
|
447
|
+
if (!matches(await sourceState(file), expected))
|
|
448
|
+
throw new Error(`Migration source changed: ${file}`);
|
|
449
|
+
}
|
|
450
|
+
async function atomicWrite(file, source, mode) {
|
|
451
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
452
|
+
try {
|
|
453
|
+
await writeFile(temporary, source, { mode, flag: 'wx' });
|
|
454
|
+
// Creation mode is masked by process.umask; restore the captured bits before cutover.
|
|
455
|
+
await chmod(temporary, mode & 0o7777);
|
|
456
|
+
await rename(temporary, file);
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
await rm(temporary, { force: true });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async function writeJournal(root, journal) { await atomicWrite(join(root, JOURNAL_DIRECTORY, JOURNAL_NAME), `${JSON.stringify(journal, null, 2)}\n`, 0o600); }
|
|
463
|
+
async function readJournal(root) {
|
|
464
|
+
try {
|
|
465
|
+
return JSON.parse(await readFile(join(root, JOURNAL_DIRECTORY, JOURNAL_NAME), 'utf8'));
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
469
|
+
return undefined;
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
export async function assertStopped(root) {
|
|
474
|
+
let source;
|
|
475
|
+
try {
|
|
476
|
+
source = await readFile(join(root, '.zhin.pid'), 'utf8');
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
480
|
+
return;
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
const pid = Number(source.trim());
|
|
484
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
485
|
+
throw new Error('Invalid .zhin.pid; confirm the Bot is stopped before migration.');
|
|
486
|
+
try {
|
|
487
|
+
process.kill(pid, 0);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
if (error instanceof Error && 'code' in error && error.code === 'ESRCH')
|
|
491
|
+
return;
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
throw new Error('Stop the running Bot before migrating instance declarations.');
|
|
495
|
+
}
|
|
496
|
+
export async function withProjectLock(root, operation, recovery = false) {
|
|
497
|
+
await mkdir(join(root, '.zhin'), { recursive: true });
|
|
498
|
+
const file = join(root, '.zhin/plugin-management.lock');
|
|
499
|
+
const nonce = randomUUID();
|
|
500
|
+
let handle;
|
|
501
|
+
try {
|
|
502
|
+
handle = await open(file, 'wx', 0o600);
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
if (!recovery || !(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
|
|
506
|
+
throw new Error('Project plugin management is locked; stop its writer before retrying.', { cause: error });
|
|
507
|
+
const identity = await lstat(file);
|
|
508
|
+
if (!identity.isFile())
|
|
509
|
+
throw new Error('Invalid migration project lock', { cause: error });
|
|
510
|
+
const previous = await readFile(file, 'utf8');
|
|
511
|
+
const owner = JSON.parse(previous);
|
|
512
|
+
if (typeof owner.pid !== 'number' || !Number.isSafeInteger(owner.pid) || owner.pid <= 0)
|
|
513
|
+
throw new Error('Invalid migration lock owner; inspect it manually.', { cause: error });
|
|
514
|
+
let exited = false;
|
|
515
|
+
try {
|
|
516
|
+
process.kill(owner.pid, 0);
|
|
517
|
+
}
|
|
518
|
+
catch (probe) {
|
|
519
|
+
if (probe instanceof Error && 'code' in probe && probe.code === 'ESRCH')
|
|
520
|
+
exited = true;
|
|
521
|
+
else
|
|
522
|
+
throw probe;
|
|
523
|
+
}
|
|
524
|
+
if (!exited)
|
|
525
|
+
throw new Error('Migration project lock belongs to a running process.', { cause: error });
|
|
526
|
+
const current = await lstat(file);
|
|
527
|
+
if (current.dev !== identity.dev || current.ino !== identity.ino || await readFile(file, 'utf8') !== previous)
|
|
528
|
+
throw new Error('Migration lock changed during recovery', { cause: error });
|
|
529
|
+
await rm(file);
|
|
530
|
+
handle = await open(file, 'wx', 0o600);
|
|
531
|
+
}
|
|
532
|
+
const marker = JSON.stringify({ pid: process.pid, nonce });
|
|
533
|
+
try {
|
|
534
|
+
await handle.writeFile(marker);
|
|
535
|
+
return await operation();
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
await handle.close();
|
|
539
|
+
if (await readFile(file, 'utf8') === marker)
|
|
540
|
+
await rm(file);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Public upgrade guide for the configVersion 2 (flat plugin instance) migration. */
|
|
2
|
+
export declare const CONFIG_V2_MIGRATION_DOCS_URL = "https://zhin.js.org/operations/upgrades#configversion-2";
|
|
3
|
+
export declare const CONFIG_V2_MIGRATION_COMMANDS: readonly ["zhin stop", "zhin migrate instances", "zhin migrate instances --apply"];
|
|
4
|
+
export interface LegacyConfigDetection {
|
|
5
|
+
/** True when the project still uses the unversioned (v1) configuration layout. */
|
|
6
|
+
readonly legacy: boolean;
|
|
7
|
+
/** Root configuration file relative to the project, when one exists. */
|
|
8
|
+
readonly configFile?: string;
|
|
9
|
+
/** Human-readable evidence, never containing configuration values. */
|
|
10
|
+
readonly reasons: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Detects a v1 (pre-configVersion 2) project without validating or executing anything.
|
|
14
|
+
* Only key presence is inspected, so the result never carries configuration values.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectLegacyConfig(root: string): Promise<LegacyConfigDetection>;
|
|
17
|
+
/** Multi-line, bilingual migration hint shared by start, config check and doctor. */
|
|
18
|
+
export declare function formatLegacyConfigHint(detection: Pick<LegacyConfigDetection, 'reasons'>): string;
|
|
19
|
+
export declare class LegacyConfigError extends Error {
|
|
20
|
+
readonly detection: LegacyConfigDetection;
|
|
21
|
+
readonly code = "ZHIN_LEGACY_CONFIG_V1";
|
|
22
|
+
constructor(detection: LegacyConfigDetection);
|
|
23
|
+
}
|
|
24
|
+
/** Throws a {@link LegacyConfigError} carrying the migration steps when the project is still v1. */
|
|
25
|
+
export declare function assertNotLegacyConfig(root: string): Promise<void>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createConfigDocument } from '@zhin.js/runtime';
|
|
4
|
+
import { findConfigFile } from './config-file.js';
|
|
5
|
+
/** Public upgrade guide for the configVersion 2 (flat plugin instance) migration. */
|
|
6
|
+
export const CONFIG_V2_MIGRATION_DOCS_URL = 'https://zhin.js.org/operations/upgrades#configversion-2';
|
|
7
|
+
export const CONFIG_V2_MIGRATION_COMMANDS = Object.freeze([
|
|
8
|
+
'zhin stop',
|
|
9
|
+
'zhin migrate instances',
|
|
10
|
+
'zhin migrate instances --apply',
|
|
11
|
+
]);
|
|
12
|
+
/**
|
|
13
|
+
* Detects a v1 (pre-configVersion 2) project without validating or executing anything.
|
|
14
|
+
* Only key presence is inspected, so the result never carries configuration values.
|
|
15
|
+
*/
|
|
16
|
+
export async function detectLegacyConfig(root) {
|
|
17
|
+
const reasons = [];
|
|
18
|
+
const configFile = findConfigFile(root) ?? undefined;
|
|
19
|
+
if (configFile) {
|
|
20
|
+
try {
|
|
21
|
+
const { document } = await createConfigDocument(path.join(root, configFile)).read();
|
|
22
|
+
if (document && typeof document === 'object' && !Array.isArray(document)
|
|
23
|
+
&& document.configVersion === undefined) {
|
|
24
|
+
reasons.push(`${configFile} 缺少 configVersion: 2 / ${configFile} has no configVersion: 2`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Syntax errors are reported by the regular config diagnostics.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const packageFile = path.join(root, 'package.json');
|
|
32
|
+
try {
|
|
33
|
+
const manifest = await fs.readJson(packageFile);
|
|
34
|
+
if (Array.isArray(manifest.zhin?.plugins) && manifest.zhin.plugins.length) {
|
|
35
|
+
reasons.push('package.json 仍有 zhin.plugins 旧挂载 / package.json still declares zhin.plugins');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Missing or malformed package.json is reported elsewhere.
|
|
40
|
+
}
|
|
41
|
+
return Object.freeze({ legacy: reasons.length > 0, configFile, reasons: Object.freeze(reasons) });
|
|
42
|
+
}
|
|
43
|
+
/** Multi-line, bilingual migration hint shared by start, config check and doctor. */
|
|
44
|
+
export function formatLegacyConfigHint(detection) {
|
|
45
|
+
return [
|
|
46
|
+
'检测到 v1 配置:当前版本只接受 configVersion: 2,旧配置不会再被加载。',
|
|
47
|
+
'Detected a v1 configuration: this Zhin release only accepts configVersion: 2.',
|
|
48
|
+
...detection.reasons.map(reason => ` - ${reason}`),
|
|
49
|
+
'迁移步骤 / How to migrate:',
|
|
50
|
+
` 1. 先停止 Bot / stop the Bot first: ${CONFIG_V2_MIGRATION_COMMANDS[0]}`,
|
|
51
|
+
` 2. 预览改动 / preview (writes nothing): ${CONFIG_V2_MIGRATION_COMMANDS[1]}`,
|
|
52
|
+
` 3. 应用迁移 / apply (auto backup): ${CONFIG_V2_MIGRATION_COMMANDS[2]}`,
|
|
53
|
+
`文档 / Docs: ${CONFIG_V2_MIGRATION_DOCS_URL}`,
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
export class LegacyConfigError extends Error {
|
|
57
|
+
detection;
|
|
58
|
+
code = 'ZHIN_LEGACY_CONFIG_V1';
|
|
59
|
+
constructor(detection) {
|
|
60
|
+
super(formatLegacyConfigHint(detection));
|
|
61
|
+
this.detection = detection;
|
|
62
|
+
this.name = 'LegacyConfigError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Throws a {@link LegacyConfigError} carrying the migration steps when the project is still v1. */
|
|
66
|
+
export async function assertNotLegacyConfig(root) {
|
|
67
|
+
const detection = await detectLegacyConfig(root);
|
|
68
|
+
if (detection.legacy)
|
|
69
|
+
throw new LegacyConfigError(detection);
|
|
70
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与 create-zhin-app / 仓库 engines 一致。下限来自 @zhin.js/ai 依赖的 undici 8(engines >=22.19.0,
|
|
3
|
+
* Node 20 上会因缺少 webidl.util.markAsUncloneable 崩溃)。
|
|
4
|
+
*/
|
|
5
|
+
export declare const NODE_ENGINES_HINT = ">=22.19.0";
|
|
6
|
+
export declare function isNodeVersionSupported(version?: string): boolean;
|
|
7
|
+
export declare function formatNodeRequirementMessage(version?: string): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与 create-zhin-app / 仓库 engines 一致。下限来自 @zhin.js/ai 依赖的 undici 8(engines >=22.19.0,
|
|
3
|
+
* Node 20 上会因缺少 webidl.util.markAsUncloneable 崩溃)。
|
|
4
|
+
*/
|
|
5
|
+
export const NODE_ENGINES_HINT = '>=22.19.0';
|
|
6
|
+
export function isNodeVersionSupported(version = process.version) {
|
|
7
|
+
const match = /^v(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
8
|
+
if (!match)
|
|
9
|
+
return false;
|
|
10
|
+
const major = parseInt(match[1], 10);
|
|
11
|
+
const minor = parseInt(match[2], 10);
|
|
12
|
+
if (major > 22)
|
|
13
|
+
return true;
|
|
14
|
+
return major === 22 && minor >= 19;
|
|
15
|
+
}
|
|
16
|
+
export function formatNodeRequirementMessage(version = process.version) {
|
|
17
|
+
return isNodeVersionSupported(version)
|
|
18
|
+
? `${version} (${NODE_ENGINES_HINT})`
|
|
19
|
+
: `${version}(需要 ${NODE_ENGINES_HINT})`;
|
|
20
|
+
}
|