import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { basename, extname, join, relative } from 'node:path'; import { artifactPathFor, writeArtifact } from '../artifact-store.js'; import { VclawError } from '../errors.js'; import type { VideoProjectWorkspace } from '../workspace.js'; import { getPublishProfile } from './profiles.js'; import type { PublishMediaInfo, PublishMetadataArtifact, PublishPackageManifest, PublishPlatformId, } from './types.js'; import { validatePublishPackageInput } from './validate.js'; export interface BuildPublishPackageInput { workspace: VideoProjectWorkspace; platform: PublishPlatformId; finalVideoPath: string; metadataPath: string; captionsPath?: string; thumbnailPath?: string; media: PublishMediaInfo; createdAt?: string; } export interface BuildPublishPackageResult { packageDir: string; manifestPath: string; checksumsPath: string; manifest: PublishPackageManifest; reused: boolean; } interface SourceFile { role: 'final-video' | 'metadata' | 'captions' | 'thumbnail'; sourcePath: string; packageName: string; sha256: string; sizeBytes: number; } export async function buildPublishPackage( input: BuildPublishPackageInput, ): Promise { const profile = getPublishProfile(input.platform); const metadata = await readPublishMetadata(input.metadataPath); if (metadata.projectSlug !== input.workspace.slug) { throw new VclawError( 'publish_metadata_missing', `Publish metadata belongs to ${metadata.projectSlug}, not ${input.workspace.slug}.`, { metadataPath: input.metadataPath, expectedProjectSlug: input.workspace.slug, observedProjectSlug: metadata.projectSlug }, ); } const validation = validatePublishPackageInput({ profile, metadata, media: input.media, ...(input.captionsPath ? { captionsPath: input.captionsPath } : {}), ...(input.thumbnailPath ? { thumbnailPath: input.thumbnailPath } : {}), }); if (!validation.ok) { throw new VclawError('invalid_video_format', `Publish package validation failed for ${profile.displayName}.`, { platform: input.platform, blockers: validation.blockers, }); } const sourceFiles = await collectSourceFiles(input); const packageVersion = packageVersionFor({ platform: input.platform, profileVersion: profile.profileVersion, metadata, sourceFiles, validation, }); const packageDir = join(input.workspace.projectDir, 'publish', input.platform, packageVersion); const manifestPath = join(packageDir, 'manifest.json'); const checksumsPath = join(packageDir, 'SHA256SUMS'); const manifest = buildManifest({ workspace: input.workspace, platform: input.platform, packageVersion, profileVersion: profile.profileVersion, createdAt: input.createdAt ?? new Date().toISOString(), sourceFiles, validation, sources: profile.sources, }); if (existsSync(packageDir)) { const existing = existsSync(manifestPath) ? JSON.parse(await readFile(manifestPath, 'utf-8')) as PublishPackageManifest : null; if (!existing || comparableManifest(existing) !== comparableManifest(manifest)) { throw new VclawError('workspace_corrupt', `Publish package directory already exists with different evidence: ${packageDir}`, { packageDir, }); } await assertExistingPackageIntegrity(packageDir, checksumsPath, existing); return { packageDir, manifestPath, checksumsPath, manifest: existing, reused: true }; } const parentDir = join(input.workspace.projectDir, 'publish', input.platform); const stagingDir = join(parentDir, `.staging-${packageVersion}-${process.pid}-${Date.now()}`); await rm(stagingDir, { recursive: true, force: true }); await mkdir(stagingDir, { recursive: true }); try { for (const file of sourceFiles) { await copyFile(file.sourcePath, join(stagingDir, file.packageName)); } const manifestContent = stableJson(manifest); const manifestSha256 = sha256Text(manifestContent); const checksums = [ ...sourceFiles.map((file) => ({ path: file.packageName, sha256: file.sha256 })), { path: 'manifest.json', sha256: manifestSha256 }, ] .map((file) => `${file.sha256.replace(/^sha256:/, '')} ${file.path}`) .sort() .join('\n'); await writeFile(join(stagingDir, 'manifest.json'), manifestContent); await writeFile(join(stagingDir, 'SHA256SUMS'), `${checksums}\n`); await rename(stagingDir, packageDir); } catch (error) { await rm(stagingDir, { recursive: true, force: true }); throw error; } return { packageDir, manifestPath, checksumsPath, manifest, reused: false }; } export async function readPublishMetadata(path: string): Promise { if (!existsSync(path)) { throw new VclawError('publish_metadata_missing', `Publish metadata is missing: ${path}`, { metadataPath: path }); } const raw = await readFile(path, 'utf-8'); let value: unknown; try { value = JSON.parse(raw) as unknown; } catch (error) { throw new VclawError('publish_metadata_missing', 'Publish metadata is malformed JSON.', { metadataPath: path, reason: error instanceof Error ? error.message : String(error), }); } if (!isPublishMetadata(value)) { throw new VclawError( 'publish_metadata_missing', 'Publish metadata must include a project slug, title, supported visibility, created timestamp, and explicit synthetic-media disclosure.', { metadataPath: path }, ); } return value; } export function defaultPublishMetadataPath(workspace: VideoProjectWorkspace): string { return artifactPathFor(workspace, 'publish-metadata'); } export async function writePublishMetadataArtifact(input: { workspace: VideoProjectWorkspace; metadata: Omit & { createdAt?: string }; }): Promise { const workspace = input.workspace; const artifact: PublishMetadataArtifact = { ...input.metadata, createdAt: input.metadata.createdAt ?? new Date().toISOString(), }; if (artifact.projectSlug !== workspace.slug) { throw new VclawError( 'publish_metadata_missing', `Publish metadata belongs to ${artifact.projectSlug}, not ${workspace.slug}.`, { expectedProjectSlug: workspace.slug, observedProjectSlug: artifact.projectSlug }, ); } return writeArtifact(workspace, 'publish-metadata', artifact); } async function collectSourceFiles(input: BuildPublishPackageInput): Promise { return Promise.all([ sourceFile('final-video', input.finalVideoPath, `final${safeExt(input.finalVideoPath, '.mp4')}`), sourceFile('metadata', input.metadataPath, 'metadata.json'), ...(input.captionsPath ? [sourceFile('captions', input.captionsPath, `captions${safeExt(input.captionsPath, '.srt')}`)] : []), ...(input.thumbnailPath ? [sourceFile('thumbnail', input.thumbnailPath, `thumbnail${safeExt(input.thumbnailPath, '.jpg')}`)] : []), ]); } async function assertExistingPackageIntegrity( packageDir: string, checksumsPath: string, manifest: PublishPackageManifest, ): Promise { for (const file of manifest.files) { if (basename(file.path) !== file.path) { throw new VclawError('workspace_corrupt', `Publish package manifest contains an unsafe file path: ${file.path}`, { packageDir, path: file.path, }); } const packagedPath = join(packageDir, file.path); if (!existsSync(packagedPath)) { throw new VclawError('workspace_corrupt', `Publish package file is missing: ${packagedPath}`, { packageDir }); } const packagedSize = (await stat(packagedPath)).size; const packagedHash = await sha256File(packagedPath); if (packagedSize !== file.sizeBytes || packagedHash !== file.sha256) { throw new VclawError('workspace_corrupt', `Publish package file no longer matches its manifest: ${packagedPath}`, { packageDir, expectedSizeBytes: file.sizeBytes, observedSizeBytes: packagedSize, expectedSha256: file.sha256, observedSha256: packagedHash, }); } } if (!existsSync(checksumsPath)) { throw new VclawError('workspace_corrupt', `Publish package checksum file is missing: ${checksumsPath}`, { packageDir }); } const manifestSha256 = sha256Text(stableJson(manifest)); const expectedChecksums = `${[ ...manifest.files.map((file) => ({ path: file.path, sha256: file.sha256 })), { path: 'manifest.json', sha256: manifestSha256 }, ] .map((file) => `${file.sha256.replace(/^sha256:/, '')} ${file.path}`) .sort() .join('\n')}\n`; const observedChecksums = await readFile(checksumsPath, 'utf-8'); if (observedChecksums !== expectedChecksums) { throw new VclawError('workspace_corrupt', `Publish package checksum file does not match its manifest: ${checksumsPath}`, { packageDir, }); } } async function sourceFile( role: SourceFile['role'], sourcePath: string, packageName: string, ): Promise { if (!existsSync(sourcePath)) { throw new VclawError('asset_not_found', `Publish package input is missing: ${sourcePath}`, { role, sourcePath }); } const sizeBytes = (await stat(sourcePath)).size; return { role, sourcePath, packageName, sha256: await sha256File(sourcePath), sizeBytes, }; } function buildManifest(input: { workspace: VideoProjectWorkspace; platform: PublishPlatformId; packageVersion: string; profileVersion: string; createdAt: string; sourceFiles: SourceFile[]; validation: PublishPackageManifest['validation']; sources: PublishPackageManifest['sources']; }): PublishPackageManifest { const byRole = new Map(input.sourceFiles.map((file) => [file.role, file])); const maybeInput = (file: SourceFile | undefined): string | undefined => { if (!file) return undefined; const rel = relative(input.workspace.projectDir, file.sourcePath); return rel.startsWith('..') ? file.sourcePath : rel; }; return { projectSlug: input.workspace.slug, platform: input.platform, packageVersion: input.packageVersion, profileVersion: input.profileVersion, createdAt: input.createdAt, inputs: { finalVideo: maybeInput(byRole.get('final-video')) ?? '', metadata: maybeInput(byRole.get('metadata')) ?? '', ...(byRole.has('captions') ? { captions: maybeInput(byRole.get('captions')) } : {}), ...(byRole.has('thumbnail') ? { thumbnail: maybeInput(byRole.get('thumbnail')) } : {}), }, files: input.sourceFiles.map((file) => ({ role: file.role, path: file.packageName, sha256: file.sha256, sizeBytes: file.sizeBytes, })), validation: input.validation, sources: input.sources, }; } function packageVersionFor(value: unknown): string { const digest = createHash('sha256').update(stableJson(value)).digest('hex').slice(0, 16); return `pkg-${digest}`; } async function sha256File(path: string): Promise { const bytes = await readFile(path); return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; } function sha256Text(value: string): string { return `sha256:${createHash('sha256').update(value).digest('hex')}`; } function stableJson(value: unknown): string { return `${JSON.stringify(sortJson(value), null, 2)}\n`; } function sortJson(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, child]) => [key, sortJson(child)]), ); } return value; } function comparableManifest(manifest: PublishPackageManifest): string { return stableJson({ ...manifest, createdAt: '' }); } function safeExt(path: string, fallback: string): string { const ext = extname(basename(path)).toLowerCase(); return /^[.][a-z0-9]{1,8}$/.test(ext) ? ext : fallback; } function isPublishMetadata(value: unknown): value is PublishMetadataArtifact { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const candidate = value as Partial; if (!isNonEmptyString(candidate.projectSlug) || !isNonEmptyString(candidate.title) || !isNonEmptyString(candidate.createdAt)) { return false; } if (!candidate.visibility || !['private', 'unlisted', 'public'].includes(candidate.visibility)) return false; if (!candidate.syntheticMedia || typeof candidate.syntheticMedia.containsSyntheticMedia !== 'boolean') return false; if (candidate.description !== undefined && typeof candidate.description !== 'string') return false; if (candidate.tags !== undefined && (!Array.isArray(candidate.tags) || candidate.tags.some((tag) => typeof tag !== 'string'))) return false; if (candidate.captionsPath !== undefined && !isNonEmptyString(candidate.captionsPath)) return false; if (candidate.thumbnailPath !== undefined && !isNonEmptyString(candidate.thumbnailPath)) return false; return true; } function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; }