chore: prepare v1.0.0 release

This commit is contained in:
Tom Boullay
2026-04-27 23:43:16 +02:00
parent 31c05a35fc
commit dddecbb11c
18 changed files with 123 additions and 239 deletions
+1 -13
View File
@@ -1,18 +1,10 @@
// ---------------------------------------------------------------------------
// File diff classification — compares local files against a remote file map
// ---------------------------------------------------------------------------
import { MODEL_EXTENSIONS } from './constants'
import type { FileChange, PushFile } from './types'
export interface DiffResult {
/** Map of lowercase filename → change status (for commit message) */
interface DiffResult {
fileChanges: Map<string, FileChange>
/** Files that actually need to be pushed (new or changed) */
changedFilesToPush: PushFile[]
/** Filenames that were on remote but not in the new upload */
deletedFileNames: string[]
/** Full paths for deletion on remote */
deletePaths: string[]
}
@@ -41,13 +33,10 @@ export function classifyFileChanges(
const isModel = MODEL_EXTENSIONS.has(ext)
if (isModel) {
// Model: always re-push since compression makes size comparison unreliable.
// Mark as 'unchanged' for the commit message when the folder already exists.
const remoteSize = remoteFileMap.get(filename.toLowerCase())
fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged')
changedFilesToPush.push(f)
} else {
// Texture: compare by size
const localSize = Buffer.from(f.contentBase64, 'base64').length
const remoteSize = remoteFileMap.get(filename.toLowerCase())
@@ -63,7 +52,6 @@ export function classifyFileChanges(
}
}
// Files on remote not in the new upload → deleted (orphans)
const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase()))
const deletedFileNames: string[] = []
const deletePaths: string[] = []
+59 -57
View File
@@ -1,14 +1,11 @@
import { createHash } from 'crypto'
import { Octokit } from '@octokit/rest'
import { LFS_EXTENSIONS } from './constants'
import { isRecord } from './guards'
import type { PushFile, RemoteFile } from './types'
const LFS_BATCH_SIZE = 100
// ---------------------------------------------------------------------------
// Octokit helpers
// ---------------------------------------------------------------------------
function isHttpError(err: unknown): err is { status: number } {
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number'
}
@@ -33,22 +30,15 @@ function parseRepoUrl(): { owner: string; repo: string } {
return { owner: match[1], repo: match[2] }
}
// ---------------------------------------------------------------------------
// Git LFS helpers
// ---------------------------------------------------------------------------
/** Check if a file path should be tracked by LFS based on its extension. */
function isLfsFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
return LFS_EXTENSIONS.has(ext)
}
/** Build an LFS pointer file (text content stored in the Git blob). */
function buildLfsPointer(sha256: string, size: number): string {
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
}
/** Parse an LFS pointer to extract the real file size. Returns null if not a pointer. */
function parseLfsPointer(content: string): { oid: string; size: number } | null {
if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null
const sizeMatch = content.match(/^size (\d+)$/m)
@@ -81,14 +71,62 @@ interface LfsObject {
contentBase64: string
}
/**
* Upload binary objects to the Git LFS server via the Batch API.
*
* Flow:
* 1. POST to the LFS batch endpoint with operation "upload"
* 2. For each object that has an "upload" action, PUT the binary content
* 3. If the server omits "actions", the object already exists — skip upload
*/
interface LfsBatchObject {
oid: string
size: number
actions?: {
upload?: { href: string; header?: Record<string, string> }
verify?: { href: string; header?: Record<string, string> }
}
error?: { code: number; message: string }
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
}
function parseLfsAction(value: unknown) {
if (!isRecord(value) || typeof value.href !== 'string') return undefined
return {
href: value.href,
header: isStringRecord(value.header) ? value.header : undefined,
}
}
function parseLfsBatchObject(value: unknown): LfsBatchObject | null {
if (!isRecord(value) || typeof value.oid !== 'string' || typeof value.size !== 'number') return null
const actions = isRecord(value.actions)
? {
upload: parseLfsAction(value.actions.upload),
verify: parseLfsAction(value.actions.verify),
}
: undefined
const error = isRecord(value.error) && typeof value.error.code === 'number' && typeof value.error.message === 'string'
? { code: value.error.code, message: value.error.message }
: undefined
return { oid: value.oid, size: value.size, actions, error }
}
function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
if (!isRecord(value) || !Array.isArray(value.objects)) {
throw new Error('LFS batch response invalide')
}
const objects: LfsBatchObject[] = []
for (const object of value.objects) {
const parsed = parseLfsBatchObject(object)
if (!parsed) {
throw new Error('LFS batch object invalide')
}
objects.push(parsed)
}
return objects
}
async function uploadToLfs(
owner: string,
repo: string,
@@ -118,7 +156,6 @@ async function uploadToLfsBatch(
const token = process.env.GITHUB_TOKEN!
const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch`
// 1. Batch request — ask for upload URLs
const batchRes = await fetch(lfsUrl, {
method: 'POST',
headers: {
@@ -142,27 +179,15 @@ async function uploadToLfsBatch(
throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`)
}
const batchData = (await batchRes.json()) as {
objects: Array<{
oid: string
size: number
actions?: {
upload?: { href: string; header?: Record<string, string> }
verify?: { href: string; header?: Record<string, string> }
}
error?: { code: number; message: string }
}>
}
const batchObjects = parseLfsBatchResponse(await batchRes.json())
// 2. Upload each object that has an upload action
const objectMap = new Map(objects.map((o) => [o.oid, o]))
for (const obj of batchData.objects) {
for (const obj of batchObjects) {
if (obj.error) {
throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`)
}
// No actions = server already has this object, skip
if (!obj.actions?.upload) continue
const local = objectMap.get(obj.oid)
@@ -187,7 +212,6 @@ async function uploadToLfsBatch(
throw new Error(`LFS upload failed for ${obj.oid} (${uploadRes.status}): ${text}`)
}
// 3. Verify if required
if (obj.actions.verify) {
const verifyAction = obj.actions.verify
const verifyHeaders: Record<string, string> = {
@@ -214,10 +238,6 @@ async function uploadToLfsBatch(
})
}
// ---------------------------------------------------------------------------
// Read remote folder contents (with real file sizes for LFS files)
// ---------------------------------------------------------------------------
export async function getRemoteFolder(
folderPath: string,
): Promise<{ exists: boolean; files: RemoteFile[] }> {
@@ -237,16 +257,12 @@ export async function getRemoteFolder(
return { exists: false, files: [] }
}
// For LFS-tracked files, the "size" from getContent is the pointer size (~130 bytes),
// not the real file size. We need to fetch each LFS pointer to get the real size.
const files: RemoteFile[] = await Promise.all(
data.map(async (f): Promise<RemoteFile> => {
if (!isLfsFile(f.name) || f.size > 1024) {
// Not LFS or too large to be a pointer — use size as-is
return { name: f.name, size: f.size }
}
// Fetch the blob content to check if it's an LFS pointer
try {
const { data: fileData } = await octokit.repos.getContent({
owner,
@@ -279,10 +295,6 @@ export async function getRemoteFolder(
}
}
// ---------------------------------------------------------------------------
// Push all files in a single commit (with optional deletions + LFS support)
// ---------------------------------------------------------------------------
export async function pushAllToGitHub(
files: PushFile[],
deletePaths: string[],
@@ -292,7 +304,6 @@ export async function pushAllToGitHub(
const { owner, repo } = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main'
// --- Separate LFS files from regular files ---
const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = []
const regularFiles: PushFile[] = []
@@ -306,7 +317,6 @@ export async function pushAllToGitHub(
}
}
// --- Upload LFS objects to the LFS server ---
if (lfsFiles.length > 0) {
await uploadToLfs(
owner,
@@ -315,7 +325,6 @@ export async function pushAllToGitHub(
)
}
// 1. Get latest commit on branch
const { data: ref } = await octokit.git.getRef({
owner,
repo,
@@ -323,21 +332,18 @@ export async function pushAllToGitHub(
})
const latestCommitSha = ref.object.sha
// 2. Get that commit's tree
const { data: commit } = await octokit.git.getCommit({
owner,
repo,
commit_sha: latestCommitSha,
})
// 3. Create blobs — LFS files get pointer blobs, regular files get raw blobs
const allFiles = [...regularFiles, ...lfsFiles]
const blobResults = await Promise.all(
allFiles.map((f) => {
const lfs = lfsFiles.find((lf) => lf.path === f.path)
if (lfs) {
// Create a blob with the LFS pointer text (NOT the binary content)
const pointer = buildLfsPointer(lfs.oid, lfs.size)
return octokit.git.createBlob({
owner,
@@ -346,7 +352,6 @@ export async function pushAllToGitHub(
encoding: 'base64',
})
}
// Regular file — push content as-is
return octokit.git.createBlob({
owner,
repo,
@@ -356,7 +361,6 @@ export async function pushAllToGitHub(
}),
)
// 4. Build tree entries: new/changed files + deletions
const newFilePaths = new Set(files.map((f) => f.path))
const deleteEntries = deletePaths
.filter((p) => !newFilePaths.has(p))
@@ -382,7 +386,6 @@ export async function pushAllToGitHub(
],
})
// 5. Create a single commit
const { data: newCommit } = await octokit.git.createCommit({
owner,
repo,
@@ -391,7 +394,6 @@ export async function pushAllToGitHub(
parents: [latestCommitSha],
})
// 6. Update branch ref
await octokit.git.updateRef({
owner,
repo,
+7
View File
@@ -0,0 +1,7 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function getErrorMessage(error: unknown, fallback = 'Erreur inconnue') {
return error instanceof Error ? error.message : fallback
}
+1 -32
View File
@@ -1,11 +1,5 @@
// ---------------------------------------------------------------------------
// Nextcloud WebDAV client
// Uses native fetch — no npm package needed.
// ---------------------------------------------------------------------------
const MAX_VERSIONS = 1000
// Lazy-cached config to avoid recomputing on every request
let cachedConfig: { davBase: string; auth: string } | null = null
function getConfig() {
@@ -19,7 +13,6 @@ function getConfig() {
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)')
}
// Public share WebDAV: https://cloud.example.com/public.php/webdav/
const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav`
const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64')
@@ -32,10 +25,6 @@ function davUrl(davBase: string, path: string): string {
return `${davBase}/${clean}`
}
// ---------------------------------------------------------------------------
// Low-level WebDAV helpers
// ---------------------------------------------------------------------------
async function davRequest(
method: string,
path: string,
@@ -57,12 +46,7 @@ async function davRequest(
return res
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Check if a folder exists on the Nextcloud instance. */
export async function folderExists(path: string): Promise<boolean> {
async function folderExists(path: string): Promise<boolean> {
const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' })
if (res.status === 404) return false
@@ -72,10 +56,6 @@ export async function folderExists(path: string): Promise<boolean> {
throw new Error(`PROPFIND ${path} failed (${res.status}): ${text.slice(0, 200)}`)
}
/**
* Create a folder and all parent segments if they don't exist.
* Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists).
*/
export async function mkdirRecursive(path: string): Promise<void> {
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
let current = ''
@@ -84,14 +64,12 @@ export async function mkdirRecursive(path: string): Promise<void> {
current += '/' + seg
const res = await davRequest('MKCOL', current + '/')
if (res.status !== 201 && res.status !== 405) {
// 201 = created, 405 = already exists — both are fine
const text = await res.text().catch(() => '')
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
}
}
}
/** Upload a file (overwrite if exists). */
export async function uploadFile(path: string, content: Buffer): Promise<void> {
const res = await davRequest('PUT', path, content, {
'Content-Type': 'application/octet-stream',
@@ -104,7 +82,6 @@ export async function uploadFile(path: string, content: Buffer): Promise<void> {
}
}
/** Move (rename) a folder or file. */
export async function moveFolder(from: string, to: string): Promise<void> {
const { davBase } = getConfig()
const destination = davUrl(davBase, to) + '/'
@@ -120,14 +97,6 @@ export async function moveFolder(from: string, to: string): Promise<void> {
}
}
// ---------------------------------------------------------------------------
// High-level: find next available version folder
// ---------------------------------------------------------------------------
/**
* Find the next available Vx folder for archiving.
* E.g. if V1/coffeetest exists but V2/coffeetest doesn't, returns "V2".
*/
export async function findNextVersion(
basePath: string,
folderName: string,
+2 -21
View File
@@ -4,19 +4,11 @@ import { sanitizeFilename } from './sanitize'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants'
import type { ParsedFile } from './types'
export interface ParsedUpload {
interface ParsedUpload {
folderName: string
files: ParsedFile[]
/** Any extra string fields from the FormData (e.g. "action") */
extra: Record<string, string>
}
/**
* Parse a multi-file FormData upload request.
* Validates file extensions, file sizes, and returns parsed files.
* Extra string fields (beyond folderName, files, fileTypes, textureNames)
* are returned in `extra`.
*/
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
const formData = await req.formData()
const folderValue = formData.get('folderName')
@@ -27,16 +19,6 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string')
const textureNames = formData.getAll('textureNames').filter((value): value is string => typeof value === 'string')
// Collect extra string fields
const knownKeys = new Set(['folderName', 'files', 'fileTypes', 'textureNames'])
const extra: Record<string, string> = {}
for (const [key, value] of formData.entries()) {
if (!knownKeys.has(key) && typeof value === 'string') {
extra[key] = value
}
}
// Runtime validation: ensure entries are actual File objects
const fileEntries: File[] = []
for (const entry of rawFiles) {
if (!(entry instanceof File)) {
@@ -56,7 +38,6 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const file = fileEntries[i]
if (!file || file.size === 0) continue
// File size limit
if (file.size > MAX_FILE_SIZE) {
throw new Error(
`Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
@@ -100,5 +81,5 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
throw new Error('Un seul fichier model.gltf est autorise')
}
return { folderName: safeFolderName, files: parsed, extra }
return { folderName: safeFolderName, files: parsed }
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { extname } from 'path'
import sharp from 'sharp'
import { getErrorMessage } from './guards'
interface TextureCompressionResult {
buffer: Buffer
@@ -35,7 +36,7 @@ export async function compressTextureBuffer(
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = getErrorMessage(err, String(err))
return {
buffer,
compressed: false,
+3 -42
View File
@@ -1,7 +1,4 @@
// ---------------------------------------------------------------------------
// Client-side API helpers for upload operations
// ---------------------------------------------------------------------------
import { isRecord } from './guards'
import type { FolderEntry } from './client-types'
import type { FileDiff } from './types'
@@ -10,16 +7,12 @@ export interface CheckResult {
diffs: FileDiff[]
}
export interface StageResult {
interface StageResult {
stagingId: string
folderName: string
filesCount: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getApiError(data: unknown, fallback: string) {
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
}
@@ -30,23 +23,10 @@ function isFileDiff(value: unknown): value is FileDiff {
&& (value.status === 'new' || value.status === 'changed' || value.status === 'deleted')
}
// ---------------------------------------------------------------------------
// Shared FormData builder
// ---------------------------------------------------------------------------
function buildUploadFormData(
folder: FolderEntry,
extra?: Record<string, string>,
): FormData {
function buildUploadFormData(folder: FolderEntry): FormData {
const formData = new FormData()
formData.append('folderName', folder.folderName)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
formData.append(key, value)
}
}
formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model')
formData.append('textureNames', '')
@@ -60,14 +40,6 @@ function buildUploadFormData(
return formData
}
// ---------------------------------------------------------------------------
// Check folder diffs against remote (GitHub)
// ---------------------------------------------------------------------------
/**
* Check whether a folder already exists on the remote repo and compute diffs.
* Throws on auth/network errors so callers can surface them to the user.
*/
export async function checkFolderDiffs(
stagingId: string,
secret: string,
@@ -85,7 +57,6 @@ export async function checkFolderDiffs(
const data: unknown = await res.json()
// Surface auth/server errors to the caller
if (!res.ok) {
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
}
@@ -129,11 +100,6 @@ export async function stageUpload(
}
}
// ---------------------------------------------------------------------------
// Upload original files to Nextcloud Drive
// ---------------------------------------------------------------------------
/** Upload original files to Nextcloud Drive. */
export async function uploadDrive(
stagingId: string,
secret: string,
@@ -163,11 +129,6 @@ export async function uploadDrive(
}
}
// ---------------------------------------------------------------------------
// Upload files to GitHub
// ---------------------------------------------------------------------------
/** Upload files to GitHub. */
export async function uploadGit(
stagingId: string,
secret: string,
+3 -5
View File
@@ -1,4 +1,6 @@
export type DriveAction = 'new' | 'replace'
import { isRecord } from './guards'
type DriveAction = 'new' | 'replace'
interface StagingRequestBody {
stagingId: string
@@ -8,10 +10,6 @@ interface DriveRequestBody extends StagingRequestBody {
action: DriveAction
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function parseStagingRequestBody(value: unknown): StagingRequestBody {
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
throw new Error('stagingId manquant')
+1 -1
View File
@@ -65,7 +65,7 @@ async function writeManifest(manifest: StagingManifest) {
await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8')
}
export async function cleanupExpiredStagingUploads() {
async function cleanupExpiredStagingUploads() {
if (!existsSync(STAGING_ROOT)) return
const entries = await readdir(STAGING_ROOT, { withFileTypes: true })
+18 -8
View File
@@ -1,9 +1,6 @@
// ---------------------------------------------------------------------------
// Client-side folder validation
// ---------------------------------------------------------------------------
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming'
import { getErrorMessage, isRecord } from '@/lib/guards'
import type { TextureFile } from '@/lib/client-types'
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
@@ -22,7 +19,9 @@ export type ValidationResult =
| { ok: false; errors: string[] }
function isGltfJson(value: unknown): value is GltfJson {
return typeof value === 'object' && value !== null
if (!isRecord(value)) return false
if (value.buffers === undefined) return true
return Array.isArray(value.buffers) && value.buffers.every(isRecord)
}
function getReferencedBufferNames(gltf: GltfJson) {
@@ -83,10 +82,12 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
try {
parsed = JSON.parse(await model.text())
} catch {
return warnings
throw new Error('model.gltf contient un JSON invalide')
}
if (!isGltfJson(parsed)) return warnings
if (!isGltfJson(parsed)) {
throw new Error('model.gltf a une structure invalide')
}
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
@@ -144,7 +145,16 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
return { ok: false, errors }
}
const warnings = await getGltfWarnings(modelFiles[0], supportFiles)
let warnings: string[] = []
try {
warnings = await getGltfWarnings(modelFiles[0], supportFiles)
} catch (err) {
errors.push(getErrorMessage(err, 'model.gltf invalide'))
}
if (errors.length > 0) {
return { ok: false, errors }
}
return { ok: true, model: modelFiles[0], textures, warnings }
}