feat(infra): add Dockerfile dependency validator + fix 16 missing COPYs

New script validates that all workspace deps in package.json have
matching COPY statements in Dockerfiles. Fixed missing shared-pwa,
shared-vite-config, patches/, and project-specific package COPYs
across 7 web app Dockerfiles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 20:25:54 +01:00
parent 10df359fdb
commit a2605e8816
12 changed files with 286 additions and 7 deletions

View file

@ -43,6 +43,10 @@ COPY packages/shared-vite-config ./packages/shared-vite-config
COPY packages/shared-api-client ./packages/shared-api-client
COPY packages/shared-stores ./packages/shared-stores
COPY packages/shared-app-onboarding ./packages/shared-app-onboarding
COPY packages/shared-pwa ./packages/shared-pwa
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ ./patches/
# Copy chat packages
COPY apps/chat/packages ./apps/chat/packages

View file

@ -43,6 +43,10 @@ COPY packages/shared-vite-config ./packages/shared-vite-config
COPY packages/shared-api-client ./packages/shared-api-client
COPY packages/shared-stores ./packages/shared-stores
COPY packages/shared-app-onboarding ./packages/shared-app-onboarding
COPY packages/shared-pwa ./packages/shared-pwa
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ ./patches/
# Copy clock packages and web
COPY apps/clock/packages ./apps/clock/packages

View file

@ -42,6 +42,10 @@ COPY packages/shared-utils ./packages/shared-utils
COPY packages/shared-error-tracking ./packages/shared-error-tracking
COPY packages/shared-vite-config ./packages/shared-vite-config
COPY packages/shared-tsconfig ./packages/shared-tsconfig
COPY packages/shared-pwa ./packages/shared-pwa
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ ./patches/
# Copy nutriphi packages and web
COPY apps/nutriphi/packages ./apps/nutriphi/packages

View file

@ -50,6 +50,10 @@ COPY packages/shared-vite-config ./packages/shared-vite-config
COPY packages/shared-api-client ./packages/shared-api-client
COPY packages/shared-stores ./packages/shared-stores
COPY packages/shared-app-onboarding ./packages/shared-app-onboarding
COPY packages/shared-pwa ./packages/shared-pwa
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ ./patches/
# Copy photos shared package
COPY apps/photos/packages/shared ./apps/photos/packages/shared

View file

@ -27,6 +27,9 @@ COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ ./patches/
# Copy llm-playground service
COPY apps/playground/apps/web ./apps/playground/apps/web

View file

@ -43,6 +43,14 @@ COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-error-tracking ./packages/shared-error-tracking
COPY packages/shared-utils ./packages/shared-utils
COPY packages/shared-app-onboarding ./packages/shared-app-onboarding
COPY packages/shared-pwa ./packages/shared-pwa
COPY packages/shared-vite-config ./packages/shared-vite-config
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ ./patches/
# Copy presi shared package
COPY apps/presi/packages/shared ./apps/presi/packages/shared
# Copy presi web
COPY apps/presi/apps/web ./apps/presi/apps/web

View file

@ -34,6 +34,11 @@ COPY packages/shared-error-tracking ./packages/shared-error-tracking
COPY packages/shared-vite-config ./packages/shared-vite-config
COPY packages/shared-api-client ./packages/shared-api-client
COPY packages/shared-app-onboarding ./packages/shared-app-onboarding
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-pwa ./packages/shared-pwa
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ ./patches/
# Copy skilltree web
COPY apps/skilltree/apps/web ./apps/skilltree/apps/web

View file

@ -1,3 +1,4 @@
export * from './create-task.dto';
export * from './update-task.dto';
export * from './query-tasks.dto';
export * from './reorder-tasks.dto';

View file

@ -0,0 +1,11 @@
import { IsArray, IsString, IsOptional } from 'class-validator';
export class ReorderTasksDto {
@IsArray()
@IsString({ each: true })
taskIds: string[];
@IsOptional()
@IsString()
projectId?: string | null;
}

View file

@ -3,7 +3,7 @@ import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nes
import { UseCredits } from '@manacore/nestjs-integration';
import { CreditOperationType } from '@manacore/credit-operations';
import { TaskService } from './task.service';
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto, ReorderTasksDto } from './dto';
@Controller('tasks')
@UseGuards(JwtAuthGuard)
@ -121,12 +121,8 @@ export class TaskController {
}
@Put('reorder')
async reorder(
@CurrentUser() user: CurrentUserData,
@Body('taskIds') taskIds: string[],
@Body('projectId') projectId?: string | null
) {
const tasks = await this.taskService.reorder(user.userId, taskIds, projectId);
async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderTasksDto) {
const tasks = await this.taskService.reorder(user.userId, dto.taskIds, dto.projectId);
return { tasks };
}
}

View file

@ -16,6 +16,7 @@
"clean": "turbo run clean",
"format": "prettier --config .prettierrc.json --write \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
"format:check": "prettier --config .prettierrc.json --check \"**/*.{ts,tsx,js,jsx,json,md,svelte,astro}\"",
"validate:dockerfiles": "node scripts/validate-dockerfiles.mjs",
"setup:env": "node scripts/generate-env.mjs",
"setup:db": "./scripts/setup-databases.sh",
"setup:db:chat": "./scripts/setup-databases.sh chat",

238
scripts/validate-dockerfiles.mjs Executable file
View file

@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* Validate that all web app Dockerfiles include COPY statements
* for every workspace dependency listed in package.json.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
// Build a map of package name -> directory path (relative to repo root)
// for both packages/* and apps/*/packages/*
function buildPackageMap() {
const map = new Map();
// packages/*
const packagesDir = join(ROOT, 'packages');
if (existsSync(packagesDir)) {
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const pkgJsonPath = join(packagesDir, entry.name, 'package.json');
if (!existsSync(pkgJsonPath)) continue;
try {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
map.set(pkg.name, `packages/${entry.name}`);
} catch {}
}
}
// apps/*/packages/*
const appsDir = join(ROOT, 'apps');
if (existsSync(appsDir)) {
for (const appEntry of readdirSync(appsDir, { withFileTypes: true })) {
if (!appEntry.isDirectory()) continue;
const appPkgsDir = join(appsDir, appEntry.name, 'packages');
if (!existsSync(appPkgsDir)) continue;
for (const pkgEntry of readdirSync(appPkgsDir, { withFileTypes: true })) {
if (!pkgEntry.isDirectory()) continue;
const pkgJsonPath = join(appPkgsDir, pkgEntry.name, 'package.json');
if (!existsSync(pkgJsonPath)) continue;
try {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
map.set(pkg.name, `apps/${appEntry.name}/packages/${pkgEntry.name}`);
} catch {}
}
}
}
return map;
}
// Extract workspace deps from package.json
function getWorkspaceDeps(pkgJsonPath) {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const deps = [];
for (const section of ['dependencies', 'devDependencies']) {
if (!pkg[section]) continue;
for (const [name, version] of Object.entries(pkg[section])) {
if (typeof version === 'string' && version.startsWith('workspace:')) {
deps.push(name);
}
}
}
return deps;
}
// Extract COPY paths from Dockerfile (only from builder stage, before RUN install)
function getDockerfileCopyPaths(dockerfilePath) {
const content = readFileSync(dockerfilePath, 'utf8');
const lines = content.split('\n');
const copyPaths = new Set();
let hasPatchesCopy = false;
for (const line of lines) {
const trimmed = line.trim();
// Match COPY statements like: COPY packages/shared-utils ./packages/shared-utils
// or COPY apps/zitare/packages/content ./apps/zitare/packages/content
const copyMatch = trimmed.match(/^COPY\s+((?:packages|apps)\/\S+)/);
if (copyMatch) {
copyPaths.add(copyMatch[1]);
}
// Check for patches copy
if (trimmed.match(/^COPY\s+patches[\s/]/)) {
hasPatchesCopy = true;
}
}
return { copyPaths, hasPatchesCopy };
}
// Extract @scope/package imports from a source file
function extractImports(filePath) {
if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, 'utf8');
const imports = new Set();
// Match import ... from '@scope/package' and import '@scope/package'
// Also match dynamic imports and require
const patterns = [
/from\s+['"](@[^'"/]+\/[^'"/]+)/g,
/import\s*\(\s*['"](@[^'"/]+\/[^'"/]+)/g,
/import\s+['"](@[^'"/]+\/[^'"/]+)/g,
/require\s*\(\s*['"](@[^'"/]+\/[^'"/]+)/g,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
imports.add(match[1]);
}
}
return [...imports];
}
function main() {
const packageMap = buildPackageMap();
const appsDir = join(ROOT, 'apps');
let hasErrors = false;
const results = [];
// Find all web app Dockerfiles
const appDirs = readdirSync(appsDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
for (const appName of appDirs) {
const dockerfilePath = join(appsDir, appName, 'apps', 'web', 'Dockerfile');
if (!existsSync(dockerfilePath)) continue;
const pkgJsonPath = join(appsDir, appName, 'apps', 'web', 'package.json');
if (!existsSync(pkgJsonPath)) {
results.push({
path: `apps/${appName}/apps/web/Dockerfile`,
errors: ['package.json not found in same directory'],
warnings: [],
depCount: 0,
});
hasErrors = true;
continue;
}
const workspaceDeps = getWorkspaceDeps(pkgJsonPath);
const { copyPaths, hasPatchesCopy } = getDockerfileCopyPaths(dockerfilePath);
const errors = [];
const warnings = [];
// Check each workspace dep has a corresponding COPY
for (const dep of workspaceDeps) {
const dirPath = packageMap.get(dep);
if (!dirPath) {
warnings.push(`UNKNOWN PACKAGE: ${dep} (not found in workspace)`);
continue;
}
// Check if any COPY path matches or is a parent directory of this package
// e.g. "apps/calendar/packages" covers "apps/calendar/packages/shared"
const found = [...copyPaths].some(
(cp) => cp === dirPath || dirPath.startsWith(cp + '/') || cp.startsWith(dirPath)
);
if (!found) {
errors.push(`MISSING: ${dep} → add: COPY ${dirPath} ./${dirPath}`);
}
}
// Check patches
if (!hasPatchesCopy) {
errors.push('MISSING: patches/ directory → add: COPY patches/ ./patches/');
}
// Check for imports in hooks.server.ts and vite.config.ts not in package.json
const hooksPath = join(appsDir, appName, 'apps', 'web', 'src', 'hooks.server.ts');
const vitePath = join(appsDir, appName, 'apps', 'web', 'vite.config.ts');
for (const filePath of [hooksPath, vitePath]) {
const imports = extractImports(filePath);
const fileName = filePath.includes('hooks.server') ? 'hooks.server.ts' : 'vite.config.ts';
for (const imp of imports) {
// Only check workspace packages (ones we know about)
if (packageMap.has(imp) && !workspaceDeps.includes(imp)) {
warnings.push(`NOT IN package.json: ${imp} (imported in ${fileName})`);
}
}
}
if (errors.length > 0) hasErrors = true;
results.push({
path: `apps/${appName}/apps/web/Dockerfile`,
errors,
warnings,
depCount: workspaceDeps.length,
});
}
// Print results
for (const result of results) {
const totalIssues = result.errors.length + result.warnings.length;
if (totalIssues === 0) {
console.log(`\u2713 ${result.path} - all ${result.depCount} deps covered`);
} else {
if (result.errors.length > 0) {
console.log(
`\u2717 ${result.path} - ${result.errors.length} issue${result.errors.length !== 1 ? 's' : ''}:`
);
for (const err of result.errors) {
console.log(` ${err}`);
}
}
if (result.warnings.length > 0) {
if (result.errors.length === 0) {
console.log(
`\u26A0 ${result.path} - ${result.warnings.length} warning${result.warnings.length !== 1 ? 's' : ''}:`
);
}
for (const warn of result.warnings) {
console.log(` \u26A0 ${warn}`);
}
}
if (result.errors.length === 0 && result.warnings.length > 0) {
// Already printed the warning header above
}
}
}
// Summary
const errorCount = results.reduce((sum, r) => sum + r.errors.length, 0);
const warnCount = results.reduce((sum, r) => sum + r.warnings.length, 0);
console.log('');
console.log(
`Checked ${results.length} Dockerfiles: ${errorCount} error${errorCount !== 1 ? 's' : ''}, ${warnCount} warning${warnCount !== 1 ? 's' : ''}`
);
if (hasErrors) {
process.exit(1);
}
}
main();