import fs from 'node:fs' import path from 'node:path' import { spawnSync } from 'node:child_process' import { PROJECT, inventory, hash } from './upload.mjs' import { loadCosMediaManifest, validateCosMediaManifest } from '../../build/tang-detective-cos-media.mjs' import { validatePruneReceipt, readSourceManifest } from '../../build/tang-detective-source-validation.mjs' // Deliberately task-scoped, recoverable cleanup. Never removes a cloud object or recurses over a deletion target. const mode = process.argv[2] if (!['check', 'apply'].includes(mode)) throw new Error('USE_CHECK_OR_APPLY') const backupDirectory = '/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE' const archive = path.join(backupDirectory, 'original-media.tar.gz') const recoveryDirectory = path.join(backupDirectory, 'pruned-originals') const expectedArchiveSha256 = '656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61' const unused = ['static/background.svg', 'static/calling-logo.png', 'static/check.png', 'static/user/home.png', 'static/user/home_no.png', 'static/wjw.png', 'static/ys.png', 'static/yy.png', 'static/zs.jpg', 'training/static/footprint.svg'] const sourceDirectory = path.join(PROJECT, 'native/tang-detective') const receiptPath = path.join(PROJECT, 'build/tang-detective-media-prune-receipt.json') if (fs.existsSync(receiptPath) || fs.existsSync(recoveryDirectory)) throw new Error('CLEANUP_ALREADY_STARTED_OR_COMPLETED') const source = inventory(PROJECT) const manifest = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-cos-manifest.json'))) const media = loadCosMediaManifest({ sourceDirectory }) const uploaded = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-receipt.json'))) if (!media || uploaded.complete !== true || uploaded.runId !== media.uploadRunId || uploaded.mediaManifestSha256 !== media.manifestSha256 || uploaded.objects.length !== media.objectCount) { throw new Error('REAL_COMPLETE_UPLOAD_RECEIPT_REQUIRED') } for (const entry of media.entries.values()) { const proof = uploaded.objects.find(item => item.url === entry.url) if (!proof || proof.sha256 !== entry.sha256 || proof.bytes !== entry.bytes || proof.remoteVerifiedSha256 !== entry.sha256 || proof.publicReadVerified !== true || proof.uploaded !== true || (entry.kind === 'audio' && proof.rangeVerified !== true)) throw new Error('UPLOAD_PROOF_MISMATCH') } const archiveStat = fs.lstatSync(archive) if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) throw new Error('BACKUP_NOT_REGULAR_FILE') const archiveBytes = fs.readFileSync(archive) if (hash(archiveBytes) !== expectedArchiveSha256) throw new Error('BACKUP_ARCHIVE_CHANGED') const targets = [...source.entries.map(entry => ({ projectPath: `native/tang-detective/${entry.sourcePath}`, bytes: entry.bytes, sha256: entry.sha256, reason: 'verified-cos-replacement' })), ...unused.map(projectPath => { const bytes = fs.readFileSync(path.join(PROJECT, projectPath)) return { projectPath, bytes: bytes.length, sha256: hash(bytes), reason: 'no-runtime-reference' } })] if (targets.length !== 214 || new Set(targets.map(e => e.projectPath)).size !== 214) throw new Error('UNEXPECTED_CLEANUP_SET') function verifyLocal(entry) { let current = PROJECT for (const part of entry.projectPath.split('/')) { current = path.join(current, part) if (fs.lstatSync(current).isSymbolicLink()) throw new Error('CLEANUP_SYMLINK_NOT_ALLOWED') } const stat = fs.lstatSync(current) const bytes = fs.readFileSync(current) if (!stat.isFile() || bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('CLEANUP_SOURCE_CHANGED') } for (const entry of targets) { verifyLocal(entry) const result = spawnSync('tar', ['-xOf', archive, entry.projectPath], { maxBuffer: entry.bytes + 65536 }) if (result.status !== 0 || result.stdout.length !== entry.bytes || hash(result.stdout) !== entry.sha256) { throw new Error(`BACKUP_ENTRY_NOT_IDENTICAL: ${entry.projectPath}`) } } const receipt = { schemaVersion: 1, status: 'completed', uploadRunId: media.uploadRunId, sourceManifestSha256: media.sourceManifestSha256, mediaManifestSha256: media.manifestSha256, backup: { sha256: expectedArchiveSha256, bytes: archiveBytes.length, format: 'tar.gz' }, entries: [...media.entries.values()].map(({ sourcePath, bytes, sha256, objectKey, url }) => ({ sourcePath, bytes, sha256, objectKey, url, backupSha256: expectedArchiveSha256 })), additionalUnusedFiles: targets.filter(entry => entry.reason === 'no-runtime-reference'), sourceMediaBytes: source.entries.reduce((n, e) => n + e.bytes, 0), removedProjectBytes: targets.reduce((n, e) => n + e.bytes, 0), method: 'moved-byte-identical-originals-outside-project-with-verified-archive', contentRegenerated: false, cloudObjectsDeleted: false } validatePruneReceipt(receipt, { source: readSourceManifest(), media }) if (mode === 'check') { console.log(JSON.stringify({ ready: true, files: targets.length, bytes: receipt.removedProjectBytes, backupEntriesByteVerified: targets.length, originalsChanged: false })) } else { // Preflight every target again before the first move. On a failure, roll back only this task's exact paths. targets.forEach(verifyLocal) fs.mkdirSync(recoveryDirectory, { recursive: false }) const moved = [] try { for (const entry of targets) { verifyLocal(entry) const to = path.join(recoveryDirectory, entry.projectPath) fs.mkdirSync(path.dirname(to), { recursive: true }) if (fs.existsSync(to)) throw new Error('RECOVERY_TARGET_EXISTS') fs.renameSync(path.join(PROJECT, entry.projectPath), to) moved.push(entry) } validateCosMediaManifest(manifest, { sourceDirectory, pruneReceipt: receipt }) receipt.completedAt = new Date().toISOString() fs.writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' }) console.log(JSON.stringify({ completed: true, filesRemovedFromProject: moved.length, bytesRemovedFromProject: receipt.removedProjectBytes, recoveryDirectory, archive, receiptPath })) } catch (error) { const recoveryFailures = [] for (const entry of moved.reverse()) { try { const original = path.join(PROJECT, entry.projectPath) if (fs.existsSync(original)) throw new Error('ORIGINAL_PATH_NOW_OCCUPIED') fs.renameSync(path.join(recoveryDirectory, entry.projectPath), original) } catch { recoveryFailures.push(entry.projectPath) } } if (recoveryFailures.length) console.error(JSON.stringify({ recoveryFailures, recoveryDirectory, archive })) throw error } }