feat(studio): convert an imported course into a studio repo, media kept outside git
Small files are committed unchanged (visibility and source.kind stay); large media are referenced from .stcourses/media.json, ignored, and hard-linked into every worktree from <dataDir>/media/<id>, where the import folder is moved. Studio writes to referenced media are refused and never write through a shared inode. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
60179407f4
commit
df6d235070
@@ -0,0 +1,64 @@
|
||||
import { copyFile, link, mkdir, readdir, rename, rm, cp, stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { gitignoreLine, isLargeMedia } from "./media-refs";
|
||||
import { checkRelPath } from "./relpath";
|
||||
|
||||
export interface ScannedImport {
|
||||
/** Committed into git. */
|
||||
small: string[];
|
||||
/** Kept out of git, referenced by path, size and checksum. */
|
||||
large: { path: string; size: number }[];
|
||||
/** Skipped and never followed: symlinks, sockets, `.git` folders. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** Walks an import folder with lstat semantics: symlinks and special files are skipped, never followed. */
|
||||
export async function scanImport(root: string): Promise<ScannedImport> {
|
||||
const out: ScannedImport = { small: [], large: [], skipped: [] };
|
||||
const walk = async (rel: string) => {
|
||||
const entries = await readdir(join(root, rel), { withFileTypes: true });
|
||||
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
for (const e of entries) {
|
||||
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||
if (e.name.toLowerCase() === ".git") { out.skipped.push(r); continue; }
|
||||
if (e.isDirectory()) await walk(r);
|
||||
else if (e.isFile()) {
|
||||
const size = (await stat(join(root, r))).size;
|
||||
if (isLargeMedia(r, size)) out.large.push({ path: r, size });
|
||||
else out.small.push(r);
|
||||
} else out.skipped.push(r);
|
||||
}
|
||||
};
|
||||
await walk("");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Problems that make a large file impossible to reference: a name git ignore files or the studio cannot express. */
|
||||
export function unreferencable(large: { path: string }[]): string[] {
|
||||
const bad: string[] = [];
|
||||
for (const f of large) {
|
||||
try { gitignoreLine(f.path); if (checkRelPath(f.path) !== f.path) throw new Error("not normal"); } catch { bad.push(f.path); }
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
|
||||
/** A hard link (no extra space), else a reflink or plain copy (another filesystem). The source is only read. */
|
||||
export async function linkOrCopy(src: string, dest: string): Promise<void> {
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
try { await link(src, dest); return; } catch { /* cross-device or unsupported */ }
|
||||
const r = await $`cp --reflink=auto -- ${src} ${dest}`.quiet().nothrow();
|
||||
if (r.exitCode !== 0) await copyFile(src, dest);
|
||||
}
|
||||
|
||||
/** Moves the import folder into the media store; across filesystems it copies (keeping links) and then removes. */
|
||||
export async function moveDir(src: string, dest: string): Promise<void> {
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
try {
|
||||
await rename(src, dest);
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== "EXDEV") throw e;
|
||||
await cp(src, dest, { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false });
|
||||
await rm(src, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
||||
import { mkdir, open, rm, unlink } from "node:fs/promises";
|
||||
import { constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
||||
import { copyFile, mkdir, open, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, sep } from "node:path";
|
||||
import { loadCourse, type Diagnostic } from "@stc/course-format";
|
||||
import { loadCourse, parseManifest, type Diagnostic } from "@stc/course-format";
|
||||
import { linkOrCopy, moveDir, scanImport, unreferencable } from "./convert";
|
||||
import { git, gitOk, splitZ, withLock, type Identity } from "./git";
|
||||
import { MEDIA_REFS, mediaGitignore, parseMediaRefs, sha256File, stringifyMediaRefs, type MediaRef } from "./media-refs";
|
||||
import { checkRelPath } from "./relpath";
|
||||
|
||||
export { checkRelPath } from "./relpath";
|
||||
@@ -68,6 +70,20 @@ export type SyncResult =
|
||||
| { ok: false; reason: "conflict"; conflicts: string[] }
|
||||
| { ok: false; reason: "forbidden"; message: string };
|
||||
|
||||
export interface ConvertResult {
|
||||
commit: string;
|
||||
/** Files kept out of git and referenced from MEDIA_REFS. */
|
||||
media: number;
|
||||
/** Files committed. */
|
||||
files: number;
|
||||
/** Paths skipped in the import folder (symlinks, special files, .git folders). */
|
||||
skipped: string[];
|
||||
/** True when an interrupted conversion was only finished (the repo already existed). */
|
||||
resumed: boolean;
|
||||
}
|
||||
|
||||
export type ConvertProgress = (fraction: number, message: string) => void | Promise<void>;
|
||||
|
||||
export class StudioError extends Error {
|
||||
constructor(message: string, public status = 400) { super(message); }
|
||||
}
|
||||
@@ -121,6 +137,8 @@ export interface StudioStoreOptions {
|
||||
importedDir?: string;
|
||||
/** AppConfig.studioWorktreesDir (default `<dataDir>/worktrees`). */
|
||||
worktreesDir?: string;
|
||||
/** Media stores of converted imports (default `<dataDir>/media`): `<mediaDir>/<id>` is the former import folder. */
|
||||
mediaDir?: string;
|
||||
publishedBranch?: string;
|
||||
/** Tests only: allow local-path remotes (the HTTP feature never sets this). */
|
||||
allowLocalRemotes?: boolean;
|
||||
@@ -133,6 +151,7 @@ export function createStudioStore(opts: StudioStoreOptions) {
|
||||
const reposDir = opts.reposDir ?? join(dataDir, "studio", "repos");
|
||||
const importedDir = opts.importedDir ?? join(dataDir, "courses");
|
||||
const worktreesDir = opts.worktreesDir ?? join(dataDir, "worktrees");
|
||||
const mediaDir = opts.mediaDir ?? join(dataDir, "media");
|
||||
const remoteGuard = opts.allowLocalRemotes ? ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"] : REMOTE_GUARD;
|
||||
|
||||
const paths = (courseId: string) => {
|
||||
@@ -144,6 +163,8 @@ export function createStudioStore(opts: StudioStoreOptions) {
|
||||
published: join(root, "published"),
|
||||
draftsDir: join(root, "drafts"),
|
||||
draft: (authorId: string) => join(root, "drafts", safeAuthor(authorId)),
|
||||
media: join(mediaDir, courseId),
|
||||
imported: join(importedDir, courseId),
|
||||
};
|
||||
};
|
||||
const branchOf = (authorId: string) => `draft/${safeAuthor(authorId)}`;
|
||||
@@ -233,9 +254,53 @@ export function createStudioStore(opts: StudioStoreOptions) {
|
||||
const branch = branchOf(authorId);
|
||||
const exists = (await git(p.bare, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`])).code === 0;
|
||||
await gitOk(p.bare, exists ? ["worktree", "add", dir, branch] : ["worktree", "add", "-b", branch, dir, main]);
|
||||
await materializeMedia(dir, p.media);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** The draft's or worktree's media references (empty for courses that never were imports). */
|
||||
async function mediaRefsIn(dir: string): Promise<MediaRef[]> {
|
||||
const f = await readFileIn(dir, MEDIA_REFS);
|
||||
return f ? parseMediaRefs(f.content) ?? [] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts every referenced file into `dir` as a hard link to `fromRoot/<path>` (a copy across filesystems).
|
||||
* Idempotent; existing destinations are left alone. A reference must name a regular file whose real path is inside
|
||||
* `fromRoot`, and the destination path may not contain a symlink. Returns the references it could not satisfy.
|
||||
*/
|
||||
async function materializeMedia(dir: string, fromRoot: string): Promise<{ linked: number; missing: string[] }> {
|
||||
const refs = await mediaRefsIn(dir);
|
||||
const missing: string[] = [];
|
||||
let linked = 0;
|
||||
let realFrom: string;
|
||||
try { realFrom = realpathSync(fromRoot); } catch { return { linked, missing: refs.map((r) => r.path) }; }
|
||||
for (const r of refs) {
|
||||
try {
|
||||
const src = join(realFrom, r.path);
|
||||
const st = lstatSync(src);
|
||||
const real = realpathSync(src);
|
||||
if (!st.isFile() || !real.startsWith(realFrom + sep)) throw new Error("not a regular file inside the media store");
|
||||
const dest = await safeWritePath(dir, r.path);
|
||||
if (lexists(dest)) continue;
|
||||
await linkOrCopy(real, dest);
|
||||
linked++;
|
||||
} catch {
|
||||
missing.push(r.path);
|
||||
}
|
||||
}
|
||||
return { linked, missing };
|
||||
}
|
||||
|
||||
/** Referenced media and the reference file are not editable in the studio (they are not in git). */
|
||||
async function refuseMediaWrites(dir: string, paths: string[]): Promise<void> {
|
||||
const locked = new Set([MEDIA_REFS, ...(await mediaRefsIn(dir)).map((r) => r.path)]);
|
||||
const hit = paths.map((x) => checkRelPath(x)).filter((x) => locked.has(x));
|
||||
if (hit.length) {
|
||||
throw new StudioError(`${hit.join(", ")}: imported media are kept outside git and cannot be changed in the studio; upload a file under a new name instead`, 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileIn(dir: string, rel: string): Promise<{ content: string; hash: string } | null> {
|
||||
let abs: string;
|
||||
try { abs = inside(dir, rel); } catch { return null; }
|
||||
@@ -260,6 +325,8 @@ export function createStudioStore(opts: StudioStoreOptions) {
|
||||
if (lexists(abs)) await unlink(abs);
|
||||
} else {
|
||||
const abs = await safeWritePath(dir, f.path);
|
||||
// never write into an inode shared with another path (imported media, an owner's original): replace the file
|
||||
if (lexists(abs) && lstatSync(abs).nlink > 1) await unlink(abs);
|
||||
// O_NOFOLLOW: a symlink swapped in after the check makes the open fail instead of writing through it
|
||||
const fh = await open(abs, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o644);
|
||||
try { await fh.writeFile(f.content ?? ""); } finally { await fh.close(); }
|
||||
@@ -315,12 +382,130 @@ export function createStudioStore(opts: StudioStoreOptions) {
|
||||
});
|
||||
},
|
||||
|
||||
/** An importer folder `<importedDir>/<id>` holding an imported (not authored) course the owner may convert. */
|
||||
isConvertibleImport(courseId: string): boolean {
|
||||
if (!ID_RE.test(courseId) || isManaged(courseId)) return false;
|
||||
const p = paths(courseId);
|
||||
try {
|
||||
if (!lstatSync(p.imported).isDirectory()) return false;
|
||||
const m = parseLoose(readFileSync(join(p.imported, MANIFEST), "utf8"));
|
||||
return !!m?.source?.kind && m.source.kind !== "authored";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Re-links referenced media into the published worktree (after an outside checkout removed them, say). */
|
||||
repairMedia: (courseId: string) => lock(courseId, async () => {
|
||||
const p = requireManaged(courseId);
|
||||
return materializeMedia(p.published, p.media);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Turns the imported course `<importedDir>/<id>` into a studio course with the same id (v1.1 S, ruling R9):
|
||||
* small files are committed as they are (manifest unchanged: visibility and source.kind stay), large media are
|
||||
* referenced from MEDIA_REFS and hard-linked into each worktree, and the import folder moves to `<mediaDir>/<id>`.
|
||||
* Owner-only at the HTTP layer. Restart-safe: leftovers of an attempt that never produced a published worktree are
|
||||
* removed and the conversion redone; a converted course whose import folder is still there only finishes the move.
|
||||
*/
|
||||
convertImported(courseId: string, who: Identity, opts: { onProgress?: ConvertProgress } = {}): Promise<ConvertResult> {
|
||||
if (!ID_RE.test(courseId)) return Promise.reject(new StudioError(`invalid course id: ${courseId}`, 404));
|
||||
const progress = opts.onProgress ?? (() => {});
|
||||
return lock(courseId, async () => {
|
||||
const p = paths(courseId);
|
||||
const srcManifest = join(p.imported, MANIFEST);
|
||||
const hasImport = lexists(srcManifest);
|
||||
if (isManaged(courseId)) {
|
||||
const refs = await readFileIn(p.published, MEDIA_REFS);
|
||||
if (!hasImport || !refs) throw new StudioError(`course "${courseId}" is already a studio course`, 409);
|
||||
if (lexists(p.media)) throw new StudioError(`the media store ${p.media} already exists`, 409);
|
||||
await moveDir(p.imported, p.media);
|
||||
await materializeMedia(p.published, p.media);
|
||||
return { commit: await rev(p.published, "HEAD"), media: (parseMediaRefs(refs.content) ?? []).length, files: 0, skipped: [], resumed: true };
|
||||
}
|
||||
if (!hasImport) throw new StudioError(`there is no imported course "${courseId}" in ${importedDir}`, 404);
|
||||
const top = lstatSync(p.imported);
|
||||
if (top.isSymbolicLink() || !top.isDirectory() || lstatSync(srcManifest).isSymbolicLink()) throw new StudioError("the import folder and its manifest must not be symlinks", 400);
|
||||
if (lexists(p.media)) throw new StudioError(`the media store ${p.media} already exists`, 409);
|
||||
const parsed = parseManifest(await Bun.file(srcManifest).text());
|
||||
if (!parsed.ok) throw new StudioError(`the imported manifest is invalid: ${parsed.errors.join("; ")}`, 400);
|
||||
const m = parsed.manifest;
|
||||
if (m.id !== courseId) throw new StudioError(`the imported manifest has id "${m.id}", not "${courseId}"`, 400);
|
||||
if (m.root) throw new StudioError("an imported course with root: cannot become a studio course", 400);
|
||||
if (!m.source?.kind || m.source.kind === "authored") throw new StudioError("only imported courses can be made editable this way", 400);
|
||||
|
||||
await progress(0.02, "Reading the import folder");
|
||||
const scan = await scanImport(p.imported);
|
||||
const bad = unreferencable(scan.large);
|
||||
if (bad.length) throw new StudioError(`these large files have names the studio cannot keep outside git; rename them first: ${bad.map((b) => JSON.stringify(b)).join(", ")}`, 400);
|
||||
|
||||
// A previous attempt that never got as far as the published worktree: start again.
|
||||
rmSync(p.bare, { recursive: true, force: true });
|
||||
rmSync(p.root, { recursive: true, force: true });
|
||||
|
||||
const total = scan.large.reduce((n, f) => n + f.size, 0) || 1;
|
||||
let done = 0;
|
||||
const refs: MediaRef[] = [];
|
||||
for (const f of scan.large) {
|
||||
await progress(0.05 + 0.75 * (done / total), `Checksumming ${f.path}`);
|
||||
refs.push({ path: f.path, size: f.size, sha256: await sha256File(join(p.imported, f.path)) });
|
||||
done += f.size;
|
||||
}
|
||||
|
||||
const work = join(dataDir, "tmp", `convert-${courseId}-${Bun.randomUUIDv7()}`);
|
||||
const stage = join(work, "tree");
|
||||
try {
|
||||
await progress(0.82, "Committing the course files");
|
||||
for (const f of scan.small) {
|
||||
await mkdir(dirname(join(stage, f)), { recursive: true });
|
||||
await copyFile(join(p.imported, f), join(stage, f));
|
||||
}
|
||||
const ownIgnore = scan.small.includes(".gitignore") ? await Bun.file(join(stage, ".gitignore")).text() : null;
|
||||
await mkdir(join(stage, dirname(MEDIA_REFS)), { recursive: true });
|
||||
await writeFile(join(stage, MEDIA_REFS), stringifyMediaRefs(refs));
|
||||
await writeFile(join(stage, ".gitignore"), mediaGitignore(refs.map((r) => r.path), ownIgnore));
|
||||
const list = [...new Set([...scan.small, ".gitignore", MEDIA_REFS])];
|
||||
await writeFile(join(work, "paths"), list.join(" | ||||