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; } }