a7e810b999
实现 AGC 启动版本检测与 OSS 安装包下载。 - 每次客户端打开时从 OSS latest.json 检查新版本 - 新版本提示支持 releaseNotes,并通过 Tauri 命令下载到系统下载目录 - 下载地址限制受信任 OSS、HTTPS、大小与可选 SHA-256 校验 - 补充 Tauri capability/CSP、单测和发布文档 Closes #224 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/230
300 lines
8.7 KiB
JavaScript
300 lines
8.7 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
export const REQUIRED_PACKAGE_MANAGER = 'npm@10.9.7';
|
|
|
|
export const REQUIRED_WORKSPACES = Object.freeze([
|
|
'apps/admin-web',
|
|
'apps/ai-game-creator-shell',
|
|
'apps/desktop-shell',
|
|
'apps/mobile-shell',
|
|
'apps/preview-deployer-web',
|
|
'packages/image-canvas-core',
|
|
'packages/image-canvas-react',
|
|
'packages/shared',
|
|
'tools/spine-json-export-validator',
|
|
]);
|
|
|
|
const WORKSPACE_NAMES = Object.freeze({
|
|
'apps/admin-web': '@genarrative/admin-web',
|
|
'apps/ai-game-creator-shell': '@genarrative/ai-game-creator-shell',
|
|
'apps/desktop-shell': '@genarrative/desktop-shell',
|
|
'apps/mobile-shell': '@genarrative/mobile-shell',
|
|
'apps/preview-deployer-web': '@genarrative/preview-deployer-web',
|
|
'packages/image-canvas-core': '@genarrative/image-canvas-core',
|
|
'packages/image-canvas-react': '@genarrative/image-canvas-react',
|
|
'packages/shared': '@genarrative/shared',
|
|
'tools/spine-json-export-validator':
|
|
'@genarrative/spine-json-export-validator',
|
|
});
|
|
|
|
const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({
|
|
'package.json': [
|
|
'@genarrative/image-canvas-core',
|
|
'@genarrative/image-canvas-react',
|
|
'@genarrative/shared',
|
|
],
|
|
'apps/admin-web/package.json': ['@genarrative/shared'],
|
|
'apps/ai-game-creator-shell/package.json': [
|
|
'@genarrative/image-canvas-core',
|
|
'@genarrative/image-canvas-react',
|
|
'@genarrative/shared',
|
|
],
|
|
'apps/mobile-shell/package.json': ['@genarrative/shared'],
|
|
'packages/image-canvas-react/package.json': [
|
|
'@genarrative/image-canvas-core',
|
|
],
|
|
});
|
|
|
|
const DEPENDENCY_FIELDS = Object.freeze([
|
|
'dependencies',
|
|
'devDependencies',
|
|
'optionalDependencies',
|
|
'peerDependencies',
|
|
]);
|
|
|
|
const IGNORED_NESTED_DIRECTORIES = new Set([
|
|
'.git',
|
|
'.vite',
|
|
'build',
|
|
'dist',
|
|
'node_modules',
|
|
'target',
|
|
]);
|
|
|
|
const HOIST_SENSITIVE_CONFIGS = Object.freeze([
|
|
'vite.config.ts',
|
|
'vitest.config.ts',
|
|
'apps/ai-game-creator-shell/vite.config.ts',
|
|
]);
|
|
|
|
const FORBIDDEN_WORKSPACE_NODE_MODULES_PATH =
|
|
'apps/ai-game-creator-shell/node_modules/';
|
|
|
|
function readJson(rootDir, relativePath, errors) {
|
|
const absolutePath = path.join(rootDir, relativePath);
|
|
try {
|
|
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
|
|
} catch (error) {
|
|
errors.push(`${relativePath}: cannot read valid JSON (${error.message})`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeWorkspaceLink(value) {
|
|
return typeof value === 'string'
|
|
? value.replace(/^\.\//u, '').replaceAll('\\', '/')
|
|
: value;
|
|
}
|
|
|
|
function findNestedLockfiles(rootDir, workspacePath) {
|
|
const matches = [];
|
|
const pending = [path.join(rootDir, workspacePath)];
|
|
|
|
while (pending.length > 0) {
|
|
const directory = pending.pop();
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!IGNORED_NESTED_DIRECTORIES.has(entry.name)) {
|
|
pending.push(entryPath);
|
|
}
|
|
continue;
|
|
}
|
|
if (
|
|
entry.name === 'package-lock.json' ||
|
|
entry.name === 'npm-shrinkwrap.json'
|
|
) {
|
|
matches.push(path.relative(rootDir, entryPath).replaceAll('\\', '/'));
|
|
}
|
|
}
|
|
}
|
|
|
|
return matches.sort();
|
|
}
|
|
|
|
export function collectNpmWorkspaceErrors(rootDir) {
|
|
const errors = [];
|
|
const rootPackage = readJson(rootDir, 'package.json', errors);
|
|
const lockfile = readJson(rootDir, 'package-lock.json', errors);
|
|
if (!rootPackage || !lockfile) {
|
|
return errors;
|
|
}
|
|
|
|
for (const configPath of HOIST_SENSITIVE_CONFIGS) {
|
|
const absolutePath = path.join(rootDir, configPath);
|
|
if (
|
|
fs.existsSync(absolutePath) &&
|
|
fs
|
|
.readFileSync(absolutePath, 'utf8')
|
|
.includes(FORBIDDEN_WORKSPACE_NODE_MODULES_PATH)
|
|
) {
|
|
errors.push(
|
|
`${configPath}: resolve dependencies from the workspace manifest instead of hard-coding ${FORBIDDEN_WORKSPACE_NODE_MODULES_PATH}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rootPackage.packageManager !== REQUIRED_PACKAGE_MANAGER) {
|
|
errors.push(
|
|
`package.json: packageManager must be ${REQUIRED_PACKAGE_MANAGER}, received ${String(rootPackage.packageManager)}`,
|
|
);
|
|
}
|
|
if (
|
|
JSON.stringify(rootPackage.workspaces) !==
|
|
JSON.stringify(REQUIRED_WORKSPACES)
|
|
) {
|
|
errors.push(
|
|
`package.json: workspaces must be the explicit ordered list ${JSON.stringify(REQUIRED_WORKSPACES)}`,
|
|
);
|
|
}
|
|
|
|
const manifests = new Map([['package.json', rootPackage]]);
|
|
for (const workspacePath of REQUIRED_WORKSPACES) {
|
|
const manifestPath = `${workspacePath}/package.json`;
|
|
const manifest = readJson(rootDir, manifestPath, errors);
|
|
if (!manifest) {
|
|
continue;
|
|
}
|
|
manifests.set(manifestPath, manifest);
|
|
if (manifest.name !== WORKSPACE_NAMES[workspacePath]) {
|
|
errors.push(
|
|
`${manifestPath}: name must be ${WORKSPACE_NAMES[workspacePath]}, received ${String(manifest.name)}`,
|
|
);
|
|
}
|
|
if (workspacePath === 'apps/ai-game-creator-shell') {
|
|
if (!/^\d+\.\d+\.\d+$/u.test(manifest.version ?? '')) {
|
|
errors.push(
|
|
`${manifestPath}: workspace version must be a three-part semver`,
|
|
);
|
|
}
|
|
} else if (manifest.version !== '0.1.0') {
|
|
errors.push(`${manifestPath}: workspace version must be 0.1.0`);
|
|
}
|
|
|
|
for (const nestedLockfile of findNestedLockfiles(rootDir, workspacePath)) {
|
|
errors.push(
|
|
`${nestedLockfile}: nested npm lockfiles are forbidden inside workspaces`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const localPackageNames = new Set(Object.values(WORKSPACE_NAMES));
|
|
for (const [manifestPath, manifest] of manifests) {
|
|
for (const dependencyField of DEPENDENCY_FIELDS) {
|
|
for (const [dependencyName, dependencySpec] of Object.entries(
|
|
manifest[dependencyField] ?? {},
|
|
)) {
|
|
if (
|
|
typeof dependencySpec === 'string' &&
|
|
dependencySpec.startsWith('workspace:')
|
|
) {
|
|
errors.push(
|
|
`${manifestPath}: ${dependencyField}.${dependencyName} must not use the unsupported workspace: protocol`,
|
|
);
|
|
}
|
|
if (
|
|
localPackageNames.has(dependencyName) &&
|
|
dependencySpec !== '0.1.0'
|
|
) {
|
|
errors.push(
|
|
`${manifestPath}: ${dependencyField}.${dependencyName} must use exact version 0.1.0`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [manifestPath, requiredDependencies] of Object.entries(
|
|
REQUIRED_LOCAL_DEPENDENCIES,
|
|
)) {
|
|
const manifest = manifests.get(manifestPath);
|
|
if (!manifest) {
|
|
continue;
|
|
}
|
|
for (const dependencyName of requiredDependencies) {
|
|
if (manifest.dependencies?.[dependencyName] !== '0.1.0') {
|
|
errors.push(
|
|
`${manifestPath}: dependencies.${dependencyName} must be declared as exact version 0.1.0`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const lockPackages = lockfile.packages;
|
|
if (!lockPackages || typeof lockPackages !== 'object') {
|
|
errors.push('package-lock.json: packages map is required');
|
|
return errors;
|
|
}
|
|
if (
|
|
JSON.stringify(lockPackages['']?.workspaces) !==
|
|
JSON.stringify(REQUIRED_WORKSPACES)
|
|
) {
|
|
errors.push(
|
|
'package-lock.json: root package entry must contain the explicit workspace list',
|
|
);
|
|
}
|
|
|
|
for (const workspacePath of REQUIRED_WORKSPACES) {
|
|
const expectedName = WORKSPACE_NAMES[workspacePath];
|
|
const workspaceEntry = lockPackages[workspacePath];
|
|
if (!workspaceEntry || workspaceEntry.name !== expectedName) {
|
|
errors.push(
|
|
`package-lock.json: packages[${JSON.stringify(workspacePath)}] must describe ${expectedName}`,
|
|
);
|
|
}
|
|
|
|
const linkKey = `node_modules/${expectedName}`;
|
|
const linkEntry = lockPackages[linkKey];
|
|
if (
|
|
!linkEntry ||
|
|
linkEntry.link !== true ||
|
|
normalizeWorkspaceLink(linkEntry.resolved) !== workspacePath
|
|
) {
|
|
errors.push(
|
|
`package-lock.json: packages[${JSON.stringify(linkKey)}] must link to ${workspacePath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
export function checkNpmWorkspaces(rootDir) {
|
|
const errors = collectNpmWorkspaceErrors(rootDir);
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
`npm workspace validation failed:\n- ${errors.join('\n- ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const isMainModule =
|
|
process.argv[1] &&
|
|
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
|
|
|
|
if (isMainModule) {
|
|
const rootDir = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
try {
|
|
checkNpmWorkspaces(rootDir);
|
|
console.log('[check:npm-workspaces] OK');
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|