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 { 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 { 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 { 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> { 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 { 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 { const files: PackageFile[] = []; const excludedPaths: string[] = []; let excludedCount = 0; let sourceBytes = 0; async function walk( directory: string, parentChain: DirectoryIdentity[], ): Promise { 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 { 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 { 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 { await assertDirectoryIdentityChain(projectPath, file.directoryChain); let handle: Awaited>; 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 { return typeof value === 'object' && value !== null && !Array.isArray(value); } function formatBytes(value: number): string { return value % (1024 * 1024) === 0 ? `${value / (1024 * 1024)}MB` : `${value} 字节`; }