需求:让非专业用户在项目操作区一次提交,运营审核通过后直接发布。 实现:由 Electron Main 完成安全打包、自动版本、幂等重试和状态脱敏;补齐友好失败反馈、唯一提交入口及隔离 Electron E2E fixture。
586 lines
17 KiB
TypeScript
586 lines
17 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import type { Stats } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import {
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
readdir,
|
|
realpath,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
|
|
|
const GENERATED_MANIFEST = (
|
|
'schema_version: 1\n'
|
|
+ 'kind: web\n'
|
|
+ 'runtime: static\n'
|
|
+ 'build:\n'
|
|
+ ' preset: vite\n'
|
|
+ ' package_manager: npm\n'
|
|
+ ' entry: index.html\n'
|
|
);
|
|
const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0);
|
|
const FILE_MODE = 0o100644;
|
|
const DEFAULT_LIMITS: ProjectPackageLimits = {
|
|
maxFiles: 2_000,
|
|
maxSourceBytes: 200 * 1024 * 1024,
|
|
maxArchiveBytes: 50 * 1024 * 1024,
|
|
};
|
|
const REQUIRED_FILE_LIMITS = {
|
|
'package.json': 2 * 1024 * 1024,
|
|
'package-lock.json': 50 * 1024 * 1024,
|
|
'index.html': 10 * 1024 * 1024,
|
|
} as const;
|
|
const EXCLUDED_DIRECTORY_NAMES = new Set([
|
|
'.cache',
|
|
'.git',
|
|
'.gnupg',
|
|
'.idea',
|
|
'.kube',
|
|
'.next',
|
|
'.niancode',
|
|
'.opencode',
|
|
'.project-docs',
|
|
'.turbo',
|
|
'.vite',
|
|
'.vscode',
|
|
'.aws',
|
|
'.docker',
|
|
'.ssh',
|
|
'build',
|
|
'coverage',
|
|
'deploy',
|
|
'dist',
|
|
'knowledge',
|
|
'node_modules',
|
|
'secrets',
|
|
]);
|
|
const EXCLUDED_FILE_NAMES = new Set([
|
|
'.dockerignore',
|
|
'.ds_store',
|
|
'.git-credentials',
|
|
'.netrc',
|
|
'.npmrc',
|
|
'art_guide.md',
|
|
'asset_plan.md',
|
|
'credentials.json',
|
|
'debug.log',
|
|
'dockerfile',
|
|
'docker-compose.yaml',
|
|
'docker-compose.yml',
|
|
'findings.md',
|
|
'gdd.md',
|
|
'nginx.conf',
|
|
'niancode.yml',
|
|
'_netrc',
|
|
'id_dsa',
|
|
'id_ecdsa',
|
|
'id_ed25519',
|
|
'id_rsa',
|
|
'product_overview.md',
|
|
'progress.md',
|
|
'release.md',
|
|
'task_plan.md',
|
|
'tasks.md',
|
|
'thumbs.db',
|
|
'version.md',
|
|
'works-cloud-deploy.json',
|
|
'works-deploy-check.json',
|
|
'works-publish.json',
|
|
'部署报告.md',
|
|
]);
|
|
const EXCLUDED_FILE_SUFFIXES = [
|
|
'.crt',
|
|
'.jks',
|
|
'.key',
|
|
'.keystore',
|
|
'.log',
|
|
'.p12',
|
|
'.pem',
|
|
'.pfx',
|
|
'.p8',
|
|
'.ppk',
|
|
'.tfstate',
|
|
'.tfvars',
|
|
] as const;
|
|
|
|
export type ProjectPackageLimits = {
|
|
maxFiles: number;
|
|
maxSourceBytes: number;
|
|
maxArchiveBytes: number;
|
|
};
|
|
|
|
export type StaticProjectPackageSummary = {
|
|
archivePath: string;
|
|
archiveName: string;
|
|
sha256: string;
|
|
fileCount: number;
|
|
sourceBytes: number;
|
|
archiveBytes: number;
|
|
excludedCount: number;
|
|
excludedPaths: string[];
|
|
manifest: {
|
|
schema_version: 1;
|
|
kind: 'web';
|
|
runtime: 'static';
|
|
build: {
|
|
preset: 'vite';
|
|
package_manager: 'npm';
|
|
entry: 'index.html';
|
|
};
|
|
};
|
|
};
|
|
|
|
export class ProjectPackageError extends Error {
|
|
readonly code: string;
|
|
|
|
constructor(code: string, message: string) {
|
|
super(message);
|
|
this.name = 'ProjectPackageError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
type PackageFile = {
|
|
absolutePath: string;
|
|
archivePath: string;
|
|
size: number;
|
|
device: number;
|
|
inode: number;
|
|
modifiedAtMs: number;
|
|
changedAtMs: number;
|
|
directoryChain: DirectoryIdentity[];
|
|
};
|
|
|
|
type DirectoryIdentity = {
|
|
path: string;
|
|
realPath: string;
|
|
device: number;
|
|
inode: number;
|
|
};
|
|
|
|
type PackageScan = {
|
|
files: PackageFile[];
|
|
sourceBytes: number;
|
|
excludedCount: number;
|
|
excludedPaths: string[];
|
|
};
|
|
|
|
export async function createStaticProjectPackage(input: {
|
|
projectPath: string;
|
|
archivePath: string;
|
|
limits?: ProjectPackageLimits;
|
|
}): Promise<StaticProjectPackageSummary> {
|
|
const limits = input.limits ?? DEFAULT_LIMITS;
|
|
const projectPath = await resolveProjectDirectory(input.projectPath);
|
|
await validateViteProject(projectPath, limits);
|
|
const scan = await scanProject(projectPath, limits);
|
|
const manifestBytes = Buffer.from(GENERATED_MANIFEST, 'utf8');
|
|
const sourceBytes = scan.sourceBytes + manifestBytes.length;
|
|
if (sourceBytes > limits.maxSourceBytes) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_TOO_LARGE',
|
|
`项目文件总大小超过 ${formatBytes(limits.maxSourceBytes)} 限制`,
|
|
);
|
|
}
|
|
if (scan.files.length + 1 > limits.maxFiles) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_TOO_MANY_FILES',
|
|
`项目文件数超过 ${limits.maxFiles} 个限制`,
|
|
);
|
|
}
|
|
|
|
const zip = new AdmZip();
|
|
const filesByArchivePath = new Map(scan.files.map((file) => [file.archivePath, file]));
|
|
const archivePaths = ['niancode.yml', ...filesByArchivePath.keys()]
|
|
.sort((left, right) => left.localeCompare(right, 'en'));
|
|
for (const archivePath of archivePaths) {
|
|
const data = archivePath === 'niancode.yml'
|
|
? manifestBytes
|
|
: await readStableFile(projectPath, filesByArchivePath.get(archivePath)!);
|
|
zip.addFile(archivePath, data, '', FILE_MODE);
|
|
const entry = zip.getEntry(archivePath);
|
|
if (entry) entry.header.time = FIXED_ZIP_TIME;
|
|
}
|
|
|
|
const archiveBytes = zip.toBuffer();
|
|
if (archiveBytes.length > limits.maxArchiveBytes) {
|
|
throw new ProjectPackageError(
|
|
'ARCHIVE_TOO_LARGE',
|
|
`压缩包超过 ${formatBytes(limits.maxArchiveBytes)} 限制`,
|
|
);
|
|
}
|
|
await mkdir(dirname(input.archivePath), { recursive: true });
|
|
await writeFile(input.archivePath, archiveBytes);
|
|
|
|
return {
|
|
archivePath: input.archivePath,
|
|
archiveName: 'project.zip',
|
|
sha256: createHash('sha256').update(archiveBytes).digest('hex'),
|
|
fileCount: archivePaths.length,
|
|
sourceBytes,
|
|
archiveBytes: archiveBytes.length,
|
|
excludedCount: scan.excludedCount,
|
|
excludedPaths: scan.excludedPaths,
|
|
manifest: {
|
|
schema_version: 1,
|
|
kind: 'web',
|
|
runtime: 'static',
|
|
build: {
|
|
preset: 'vite',
|
|
package_manager: 'npm',
|
|
entry: 'index.html',
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function resolveProjectDirectory(projectPath: string): Promise<string> {
|
|
try {
|
|
const resolved = await realpath(projectPath);
|
|
const stats = await lstat(resolved);
|
|
if (!stats.isDirectory()) throw new Error('not a directory');
|
|
return resolved;
|
|
} catch {
|
|
throw new ProjectPackageError('PROJECT_NOT_FOUND', '本地项目目录不存在或无法读取');
|
|
}
|
|
}
|
|
|
|
async function validateViteProject(
|
|
projectPath: string,
|
|
limits: ProjectPackageLimits,
|
|
): Promise<void> {
|
|
const packageJson = await readRequiredJson(projectPath, 'package.json', limits);
|
|
const packageLock = await readRequiredJson(projectPath, 'package-lock.json', limits);
|
|
const indexHtml = await readRequiredFile(projectPath, 'index.html', limits);
|
|
const dependencies = isRecord(packageJson.dependencies) ? packageJson.dependencies : {};
|
|
const devDependencies = isRecord(packageJson.devDependencies) ? packageJson.devDependencies : {};
|
|
const viteVersion = devDependencies.vite ?? dependencies.vite;
|
|
if (typeof viteVersion !== 'string' || !viteVersion.trim()) {
|
|
throw new ProjectPackageError('VITE_NOT_DECLARED', 'package.json 没有声明 Vite 依赖');
|
|
}
|
|
if (
|
|
packageJson.packageManager !== undefined
|
|
&& (typeof packageJson.packageManager !== 'string' || !packageJson.packageManager.startsWith('npm@'))
|
|
) {
|
|
throw new ProjectPackageError(
|
|
'PACKAGE_MANAGER_UNSUPPORTED',
|
|
'当前仅支持带 package-lock.json 的 npm 项目',
|
|
);
|
|
}
|
|
if (
|
|
!Number.isInteger(packageLock.lockfileVersion)
|
|
|| ![2, 3].includes(packageLock.lockfileVersion as number)
|
|
) {
|
|
throw new ProjectPackageError(
|
|
'LOCKFILE_UNSUPPORTED',
|
|
'package-lock.json 必须使用 lockfileVersion 2 或 3',
|
|
);
|
|
}
|
|
if (!indexHtml.toString('utf8').trim()) {
|
|
throw new ProjectPackageError('INDEX_HTML_EMPTY', 'index.html 不能为空');
|
|
}
|
|
}
|
|
|
|
async function readRequiredJson(
|
|
projectPath: string,
|
|
filename: 'package.json' | 'package-lock.json',
|
|
limits: ProjectPackageLimits,
|
|
): Promise<Record<string, unknown>> {
|
|
const content = await readRequiredFile(projectPath, filename, limits);
|
|
try {
|
|
const value = JSON.parse(content.toString('utf8')) as unknown;
|
|
if (!isRecord(value)) throw new Error('not an object');
|
|
return value;
|
|
} catch {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_FILE_INVALID',
|
|
`${filename} 不是有效的 JSON 对象`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function readRequiredFile(
|
|
projectPath: string,
|
|
filename: keyof typeof REQUIRED_FILE_LIMITS,
|
|
limits: ProjectPackageLimits,
|
|
): Promise<Buffer> {
|
|
try {
|
|
const filePath = join(projectPath, filename);
|
|
const stats = await lstat(filePath);
|
|
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('not a regular file');
|
|
const maxBytes = Math.min(limits.maxSourceBytes, REQUIRED_FILE_LIMITS[filename]);
|
|
if (stats.size > maxBytes) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_TOO_LARGE',
|
|
`${filename} 超过 ${formatBytes(maxBytes)} 限制`,
|
|
);
|
|
}
|
|
const rootIdentity = await readSafeDirectoryIdentity(projectPath, projectPath);
|
|
return await readStableFile(projectPath, {
|
|
absolutePath: filePath,
|
|
archivePath: filename,
|
|
size: stats.size,
|
|
device: stats.dev,
|
|
inode: stats.ino,
|
|
modifiedAtMs: stats.mtimeMs,
|
|
changedAtMs: stats.ctimeMs,
|
|
directoryChain: [rootIdentity],
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof ProjectPackageError) throw error;
|
|
throw new ProjectPackageError('PROJECT_FILE_MISSING', `项目根目录缺少 ${filename}`);
|
|
}
|
|
}
|
|
|
|
async function scanProject(
|
|
projectPath: string,
|
|
limits: ProjectPackageLimits,
|
|
): Promise<PackageScan> {
|
|
const files: PackageFile[] = [];
|
|
const excludedPaths: string[] = [];
|
|
let excludedCount = 0;
|
|
let sourceBytes = 0;
|
|
|
|
async function walk(
|
|
directory: string,
|
|
parentChain: DirectoryIdentity[],
|
|
): Promise<void> {
|
|
await assertDirectoryIdentityChain(projectPath, parentChain);
|
|
const before = await readSafeDirectoryIdentity(projectPath, directory);
|
|
const directoryChain = [...parentChain, before];
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
|
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
for (const entry of entries) {
|
|
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
|
const absolutePath = join(directory, entry.name);
|
|
const archivePath = toArchivePath(relative(projectPath, absolutePath));
|
|
const isDirectory = entry.isDirectory();
|
|
if (isExcludedPath(archivePath, entry.name, isDirectory)) {
|
|
excludedCount += 1;
|
|
if (excludedPaths.length < 100) {
|
|
excludedPaths.push(isDirectory ? `${archivePath}/` : archivePath);
|
|
}
|
|
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
|
continue;
|
|
}
|
|
if (entry.isSymbolicLink()) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_SYMLINK_UNSUPPORTED',
|
|
`项目包含不支持的符号链接:${archivePath}`,
|
|
);
|
|
}
|
|
validateArchivePath(archivePath);
|
|
if (isDirectory) {
|
|
await walk(absolutePath, directoryChain);
|
|
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_FILE_UNSUPPORTED',
|
|
`项目包含不支持的文件类型:${archivePath}`,
|
|
);
|
|
}
|
|
const stats = await lstat(absolutePath);
|
|
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_CHANGED_DURING_PACKAGING',
|
|
`打包过程中项目文件发生变化:${archivePath}`,
|
|
);
|
|
}
|
|
sourceBytes += stats.size;
|
|
if (sourceBytes > limits.maxSourceBytes) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_TOO_LARGE',
|
|
`项目文件总大小超过 ${formatBytes(limits.maxSourceBytes)} 限制`,
|
|
);
|
|
}
|
|
files.push({
|
|
absolutePath,
|
|
archivePath,
|
|
size: stats.size,
|
|
device: stats.dev,
|
|
inode: stats.ino,
|
|
modifiedAtMs: stats.mtimeMs,
|
|
changedAtMs: stats.ctimeMs,
|
|
directoryChain,
|
|
});
|
|
if (files.length + 1 > limits.maxFiles) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_TOO_MANY_FILES',
|
|
`项目文件数超过 ${limits.maxFiles} 个限制`,
|
|
);
|
|
}
|
|
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
|
}
|
|
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
|
}
|
|
|
|
await walk(projectPath, []);
|
|
return {
|
|
files,
|
|
sourceBytes,
|
|
excludedCount,
|
|
excludedPaths: excludedPaths.sort((left, right) => left.localeCompare(right, 'en')),
|
|
};
|
|
}
|
|
|
|
async function readSafeDirectoryIdentity(
|
|
projectPath: string,
|
|
directory: string,
|
|
): Promise<DirectoryIdentity> {
|
|
try {
|
|
const stats = await lstat(directory);
|
|
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_SYMLINK_UNSUPPORTED',
|
|
`项目包含不支持的目录链接:${toArchivePath(relative(projectPath, directory)) || '.'}`,
|
|
);
|
|
}
|
|
const resolvedDirectory = await realpath(directory);
|
|
const relativePath = relative(projectPath, resolvedDirectory);
|
|
if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_PATH_UNSUPPORTED',
|
|
'项目目录指向了项目范围之外的位置',
|
|
);
|
|
}
|
|
return {
|
|
path: directory,
|
|
device: stats.dev,
|
|
inode: stats.ino,
|
|
realPath: resolvedDirectory,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof ProjectPackageError) throw error;
|
|
throw new ProjectPackageError(
|
|
'PROJECT_CHANGED_DURING_PACKAGING',
|
|
`打包过程中项目目录发生变化:${toArchivePath(relative(projectPath, directory)) || '.'}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function assertDirectoryIdentityChain(
|
|
projectPath: string,
|
|
directoryChain: DirectoryIdentity[],
|
|
): Promise<void> {
|
|
for (const expected of directoryChain) {
|
|
const current = await readSafeDirectoryIdentity(projectPath, expected.path);
|
|
if (
|
|
current.device !== expected.device
|
|
|| current.inode !== expected.inode
|
|
|| current.realPath !== expected.realPath
|
|
) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_CHANGED_DURING_PACKAGING',
|
|
`打包过程中项目目录发生变化:${toArchivePath(relative(projectPath, expected.path)) || '.'}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isExcludedPath(archivePath: string, basename: string, isDirectory: boolean): boolean {
|
|
const lowerName = basename.toLowerCase();
|
|
if (isDirectory) return EXCLUDED_DIRECTORY_NAMES.has(lowerName);
|
|
if (lowerName.startsWith('.env')) return true;
|
|
if (EXCLUDED_FILE_NAMES.has(lowerName)) return true;
|
|
const compactName = lowerName.replace(/[^a-z0-9]/g, '');
|
|
if (
|
|
lowerName.endsWith('.json')
|
|
&& (
|
|
compactName.includes('serviceaccount')
|
|
|| compactName.includes('firebaseadmin')
|
|
|| compactName.includes('applicationdefaultcredentials')
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
if (lowerName.includes('.tfstate') || lowerName.includes('.tfvars')) return true;
|
|
return EXCLUDED_FILE_SUFFIXES.some((suffix) => lowerName.endsWith(suffix))
|
|
|| archivePath.toLowerCase().startsWith('works-');
|
|
}
|
|
|
|
function validateArchivePath(archivePath: string): void {
|
|
const parts = archivePath.split('/');
|
|
if (
|
|
!archivePath
|
|
|| archivePath.includes('\\')
|
|
|| parts.some((part) => !part
|
|
|| part === '.'
|
|
|| part === '..'
|
|
|| part.includes(':')
|
|
|| part.endsWith(' ')
|
|
|| part.endsWith('.'))
|
|
) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_PATH_UNSUPPORTED',
|
|
`项目包含不支持的文件路径:${archivePath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function readStableFile(projectPath: string, file: PackageFile): Promise<Buffer> {
|
|
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
|
let handle: Awaited<ReturnType<typeof open>>;
|
|
try {
|
|
handle = await open(file.absolutePath, 'r');
|
|
} catch {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_CHANGED_DURING_PACKAGING',
|
|
`打包过程中项目文件发生变化:${file.archivePath}`,
|
|
);
|
|
}
|
|
try {
|
|
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
|
assertStableFileStats(file, await handle.stat());
|
|
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
|
const data = await handle.readFile();
|
|
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
|
assertStableFileStats(file, await handle.stat());
|
|
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
|
if (data.length !== file.size) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_CHANGED_DURING_PACKAGING',
|
|
`打包过程中项目文件发生变化:${file.archivePath}`,
|
|
);
|
|
}
|
|
return data;
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
function assertStableFileStats(file: PackageFile, stats: Stats): void {
|
|
if (
|
|
!stats.isFile()
|
|
|| stats.size !== file.size
|
|
|| stats.dev !== file.device
|
|
|| stats.ino !== file.inode
|
|
|| stats.mtimeMs !== file.modifiedAtMs
|
|
|| stats.ctimeMs !== file.changedAtMs
|
|
) {
|
|
throw new ProjectPackageError(
|
|
'PROJECT_CHANGED_DURING_PACKAGING',
|
|
`打包过程中项目文件发生变化:${file.archivePath}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function toArchivePath(value: string): string {
|
|
return sep === '/' ? value : value.split(sep).join('/');
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function formatBytes(value: number): string {
|
|
return value % (1024 * 1024) === 0 ? `${value / (1024 * 1024)}MB` : `${value} 字节`;
|
|
}
|