From fc37da9789e1ef459b45a933f1905eca5b5ad6ac Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:11:41 +0100 Subject: [PATCH 1/9] docs(studio): WP-C implementation plan Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-30-studio.md | 77 +++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-30-studio.md diff --git a/docs/superpowers/plans/2026-10-30-studio.md b/docs/superpowers/plans/2026-10-30-studio.md new file mode 100644 index 0000000..97362bd --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-studio.md @@ -0,0 +1,77 @@ +# STCourses WP-C Studio Implementation Plan + +> **For agentic workers:** executed inline by the WP-C agent (superpowers:executing-plans). Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Browser studio for studio-managed courses: create a course, edit the manifest, lessons (Markdown with live preview), deck links, attachments and exercises in a per-author draft worktree; every save is a commit by the author; publish merges the draft into the published branch that learners read; Sync with a remote only on request; conflicts are shown and resolved by the author, never automatically. + +**Architecture:** +- `packages/feature-studio/src/server/git.ts`: a thin `git` CLI wrapper over `Bun.$` (explicit identity, no hooks, no signing) plus a per-course async mutex. +- `packages/feature-studio/src/server/store.ts`: `StudioStore`, the git model of spec section 12: + - `/courses/`: a **bare** repo (it holds no `stcourse.yaml`, so course discovery never loads it); + - `/worktrees//published`: worktree on the published branch (`main`); the only tree learners read; + - `/worktrees//drafts/`: worktree on `draft/` (depth 3 under `worktrees/`, so discovery, which stops at depth 2, never loads drafts). +- Publish = `git merge --no-ff draft/` inside the published worktree. A conflicting merge is aborted at once, so the published tree is never left half-merged, and the conflicting paths are returned. The author then runs "Update draft" (merge of `main`, or `origin/main` for Sync, into the draft worktree), resolves each file in the studio, and publishes again. +- Save uses optimistic concurrency: each file carries the hash of the content the editor loaded; a mismatch with the file on disk (an external edit) gives 409 with both versions. Nothing is overwritten. +- `watch.ts`: polls the published HEAD of each managed course (default every 3 s, unref'd timer) and calls `ctx.courses.reload(id)` when it changed (external commit, pull, publish). +- `feature.ts`: `studioFeature(ctx)` with `/api/studio/*` routes. Access: owner, `author` role, or a `course_authors` row for that course. Courses not managed by the studio (overlays, fixture/imported folders) are listed read-only: "This course is managed outside STCourses". +- `draft-writer.ts`: `createDraftWriter(ctx)` for WP-D. +- Web (`src/web/*`): `/studio` (course list, New course) and `/studio/:courseId` (outline sidebar, editors, status bar with Publish / Update draft / Sync and the conflict view). Work look from the mockup. The lesson preview sits behind a `LessonPreview` adapter; v1 is a small safe Markdown renderer; Phase 2 swaps in the learner `LessonView` (contract request). + +**Tech Stack:** Bun 1.3, TypeScript strict, git CLI 2.4x via `Bun.$`, React 19 + React Router 6, `@stc/course-format` for validation, `@stc/ui` primitives. + +**Spec:** sections 5, 9.1, 12 (git model), 13. Brief: `.superpowers/sdd/2026-10-30-stcourses/phase-1-brief.md` (WP-C). + +## Global Constraints + +Inherits every item of the Global Constraints in `docs/superpowers/plans/2026-10-30-stcourses.md`. Additionally: +- Never write into a course whose root is outside `` (overlays) — the studio refuses writes for non-managed courses. +- Paths written through the studio must be relative, stay inside the worktree, and never touch `.git`. +- No new DB tables (authors use the existing `course_authors`); `migrationsDirs` is exported empty. + +## Review Focus + +1. A conflicting external commit to `main` is detected at publish and shown; the published tree keeps the external content (test: acceptance). +2. Drafts never reach learners: before publish the `CourseRepository` still serves the published content (test: acceptance). +3. An external edit to a draft file between load and save gives 409, not an overwrite (test: store). +4. Paths with spaces and non-ASCII characters save, commit and publish (test: store). +5. Path traversal (`..`, absolute, `.git/…`) is rejected (test: store). + +## Tasks + +### Task 1: git wrapper and StudioStore (create, drafts, save) + +**Files:** Create `packages/feature-studio/src/server/{git.ts,store.ts,paths.ts}`; Test `packages/feature-studio/test/store.test.ts`. + +**Interfaces (Produces):** `createStudioStore(dataDir): StudioStore` with `createCourse`, `isManaged`, `listManaged`, `ensureDraft`, `readFile`, `listFiles`, `saveFiles`, `status`. + +- [ ] Step 1: failing tests: create gives bare repo + published worktree with a valid manifest; draft worktree on `draft/`; save commits with the author's name/email; stale base hash gives a conflict; traversal rejected; non-ASCII path works. +- [ ] Step 2: run, FAIL. Step 3: implement. Step 4: PASS. Step 5: commit. + +### Task 2: publish, update draft, conflict resolution, sync + +**Files:** extend `store.ts`; Test `packages/feature-studio/test/publish.test.ts`. + +- [ ] Step 1: failing tests: publish merges; external conflicting commit gives `{ok:false, conflicts}` and leaves `main` untouched; dirty published tree refuses publish; update draft shows ours/theirs; resolve + publish succeeds; abort; Sync with a local bare remote (pull fast-forward, push, conflict reported); no remote gives an error. +- [ ] Steps 2–5 as above. + +### Task 3: watcher and draft writer + +**Files:** `src/server/{watch.ts,draft-writer.ts}`; Test `test/watch.test.ts`, `test/draft-writer.test.ts`. + +- [ ] Failing tests: external commit triggers `courses.reload`; `writeDraftFiles` commits on `draft/` and the published tree is unchanged. Implement, pass, commit. + +### Task 4: HTTP feature and acceptance + +**Files:** `src/server/feature.ts`, `src/index.ts`; Test `test/feature.test.ts`, `test/acceptance.test.ts`. + +- [ ] Failing tests: 401/403 for anonymous and learners; overlay course read-only; create → edit lesson → publish → learner sees it via `CourseRepository` and `/api/media`; conflicting external commit shown, not overwritten; manifest validation errors returned. Implement, pass, commit. + +### Task 5: Studio web UI + +**Files:** `src/web/{api.ts,markdown.ts,preview.tsx,StudioHome.tsx,CourseStudio.tsx,editors/*.tsx,studio.css}`, `src/web.ts`; Test `test/markdown.test.ts`. + +- [ ] Failing test for the Markdown renderer (escaping, headings, fences, lists, links without `javascript:`). Implement UI. Commit. + +### Task 6: wiring, dev server, contract requests, verification + +- [ ] Append to `apps/web/src/features.ts`; dev entry `packages/feature-studio/dev/serve.ts`; `docs/contract-requests/studio.md`; run `bun test`, typecheck, Postgres run; headless Chrome screenshots of `/studio` and `/studio/:id`; report. From a6f887f31c9da02ff3d5755f9034a8f4df9adcf2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:14:47 +0100 Subject: [PATCH 2/9] feat(studio): git wrapper and StudioStore (create course, drafts, save as commit) Co-Authored-By: Claude Opus 5.5 --- packages/feature-studio/src/server/git.ts | 65 +++ packages/feature-studio/src/server/store.ts | 456 ++++++++++++++++++++ packages/feature-studio/test/helpers.ts | 25 ++ packages/feature-studio/test/store.test.ts | 118 +++++ 4 files changed, 664 insertions(+) create mode 100644 packages/feature-studio/src/server/git.ts create mode 100644 packages/feature-studio/src/server/store.ts create mode 100644 packages/feature-studio/test/helpers.ts create mode 100644 packages/feature-studio/test/store.test.ts diff --git a/packages/feature-studio/src/server/git.ts b/packages/feature-studio/src/server/git.ts new file mode 100644 index 0000000..9ce4dc4 --- /dev/null +++ b/packages/feature-studio/src/server/git.ts @@ -0,0 +1,65 @@ +import { $ } from "bun"; + +export interface Identity { name: string; email: string } +export interface GitResult { code: number; stdout: string; stderr: string } + +/** Settings that make studio git runs deterministic, whatever the machine's global config says. */ +const BASE = [ + "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false", + "-c", "core.quotepath=false", + "-c", "merge.conflictstyle=merge", + "-c", "pull.rebase=false", + "-c", "init.defaultBranch=main", +]; + +function env(who?: Identity): Record { + const e: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && !k.startsWith("GIT_")) e[k] = v; // never inherit GIT_DIR, GIT_INDEX_FILE, ... + } + e.GIT_TERMINAL_PROMPT = "0"; + e.GIT_SSH_COMMAND = "ssh -o BatchMode=yes"; + // A fallback identity so git never refuses a merge/commit; real saves always pass `who`. + const id = who ?? { name: "STCourses", email: "studio@stcourses.invalid" }; + e.GIT_AUTHOR_NAME = id.name; + e.GIT_AUTHOR_EMAIL = id.email; + e.GIT_COMMITTER_NAME = id.name; + e.GIT_COMMITTER_EMAIL = id.email; + return e; +} + +/** Runs `git ` in `cwd`. Never throws on a non-zero exit; use `gitOk` for that. */ +export async function git(cwd: string, args: string[], who?: Identity): Promise { + const r = await $`git ${BASE} ${args}`.cwd(cwd).env(env(who)).quiet().nothrow(); + return { code: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() }; +} + +export class GitError extends Error { + constructor(public args: string[], public result: GitResult) { + super(`git ${args.join(" ")} failed (${result.code}): ${result.stderr.trim() || result.stdout.trim()}`); + } +} + +export async function gitOk(cwd: string, args: string[], who?: Identity): Promise { + const r = await git(cwd, args, who); + if (r.code !== 0) throw new GitError(args, r); + return r.stdout; +} + +/** NUL-separated output (`-z`) to a list, so paths with spaces or newlines survive. */ +export function splitZ(out: string): string[] { + return out.split("\0").filter(Boolean); +} + +const locks = new Map>(); + +/** Serialises git work per key (the course's bare repo path) across every store instance in the process. */ +export function withLock(key: string, fn: () => Promise): Promise { + const prev = locks.get(key) ?? Promise.resolve(); + const next = prev.then(fn, fn); + const tail = next.catch(() => {}); + locks.set(key, tail); + tail.then(() => { if (locks.get(key) === tail) locks.delete(key); }); + return next; +} diff --git a/packages/feature-studio/src/server/store.ts b/packages/feature-studio/src/server/store.ts new file mode 100644 index 0000000..cb63def --- /dev/null +++ b/packages/feature-studio/src/server/store.ts @@ -0,0 +1,456 @@ +import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs"; +import { mkdir, rm, unlink, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, normalize, sep } from "node:path"; +import { loadCourse, type Diagnostic } from "@stc/course-format"; +import { git, gitOk, splitZ, withLock, type Identity } from "./git"; + +export type { Identity } from "./git"; + +export const MANIFEST = "stcourse.yaml"; +const ID_RE = /^[a-z0-9][a-z0-9-]*$/; + +export function contentHash(content: string | Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(content).digest("hex"); +} + +/** Branch- and path-safe form of a user id. Distinct ids may collide only if they differ in unsafe characters. */ +export function safeAuthor(authorId: string): string { + const s = authorId.replace(/[^A-Za-z0-9_-]/g, "_"); + return s === "" ? "_" : s; +} + +/** Normalised relative path, or throws. Rejects absolute paths, `..`, `.git` and NUL. */ +export function checkRelPath(p: string): string { + if (typeof p !== "string" || p === "" || p.includes("\0") || isAbsolute(p) || p.includes("\\")) throw new Error(`invalid path: ${JSON.stringify(p)}`); + const n = normalize(p); + const parts = n.split(sep); + if (n === "." || parts[0] === ".." || parts.some((x) => x === ".." || x.toLowerCase() === ".git") || n.endsWith(sep)) { + throw new Error(`invalid path: ${JSON.stringify(p)}`); + } + return n; +} + +export interface FileWrite { + path: string; + content?: string | Uint8Array; + /** Hash of the content the editor started from: `null` = the file must not exist; omitted = no check. */ + baseHash?: string | null; + delete?: boolean; +} + +export interface FileConflict { path: string; current: string | null; currentHash: string | null } +export type SaveResult = { ok: true; commit: string | null } | { ok: false; conflicts: FileConflict[] }; + +export interface DraftStatus { + courseId: string; + branch: string; + publishedBranch: string; + draftHead: string; + publishedHead: string; + /** Draft commits not yet published. */ + ahead: number; + /** Published commits (external edits, pulls, other authors' publishes) not yet in the draft. */ + behind: number; + merging: boolean; + conflicts: string[]; + remote: string | null; + /** Uncommitted changes in the published worktree (someone editing it outside the studio). */ + publishedDirty: string[]; +} + +export type PublishResult = + | { ok: true; commit: string; published: boolean } + | { ok: false; reason: "conflict"; conflicts: string[] } + | { ok: false; reason: "dirty"; files: string[] } + | { ok: false; reason: "invalid"; diagnostics: Diagnostic[] } + | { ok: false; reason: "merging" }; + +export type MergeResult = { ok: true; commit: string } | { ok: false; reason: "conflict"; conflicts: string[] } | { ok: false; reason: "merging" | "no-remote" | "dirty"; message: string }; + +export interface ConflictFile { path: string; base: string | null; ours: string | null; theirs: string | null; working: string | null } + +export type SyncResult = + | { ok: true; pulled: boolean; pushed: boolean; head: string } + | { ok: false; reason: "no-remote" | "dirty" | "network"; message: string } + | { ok: false; reason: "conflict"; conflicts: string[] }; + +export class StudioError extends Error { + constructor(message: string, public status = 400) { super(message); } +} + +export interface StudioStoreOptions { dataDir: string; publishedBranch?: string } + +export function createStudioStore(opts: StudioStoreOptions) { + const dataDir = opts.dataDir; + const main = opts.publishedBranch ?? "main"; + const coursesDir = join(dataDir, "courses"); + const worktreesDir = join(dataDir, "worktrees"); + + const paths = (courseId: string) => { + if (!ID_RE.test(courseId)) throw new StudioError(`invalid course id: ${courseId}`, 404); + const root = join(worktreesDir, courseId); + return { + bare: join(coursesDir, courseId), + root, + published: join(root, "published"), + draftsDir: join(root, "drafts"), + draft: (authorId: string) => join(root, "drafts", safeAuthor(authorId)), + }; + }; + const branchOf = (authorId: string) => `draft/${safeAuthor(authorId)}`; + + const isManaged = (courseId: string): boolean => { + if (!ID_RE.test(courseId)) return false; + const p = paths(courseId); + return existsSync(join(p.bare, "HEAD")) && existsSync(join(p.published, ".git")); + }; + const requireManaged = (courseId: string) => { + if (!isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404); + return paths(courseId); + }; + const lock = (courseId: string, fn: () => Promise) => withLock(paths(courseId).bare, fn); + + const rev = async (cwd: string, ref: string) => (await gitOk(cwd, ["rev-parse", ref])).trim(); + const count = async (cwd: string, range: string) => Number((await gitOk(cwd, ["rev-list", "--count", range])).trim()); + const unmerged = async (cwd: string) => [...new Set(splitZ(await gitOk(cwd, ["diff", "--name-only", "-z", "--diff-filter=U"])))]; + const isMerging = async (cwd: string) => (await git(cwd, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])).code === 0; + const dirtyFiles = async (cwd: string) => + splitZ(await gitOk(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"])).map((l) => l.slice(3)).filter(Boolean); + const remoteUrl = async (bare: string) => { + const r = await git(bare, ["remote", "get-url", "origin"]); + return r.code === 0 ? r.stdout.trim() : null; + }; + + /** Absolute path for `rel` inside `root`; also refuses when an existing parent is a symlink out of the root. */ + const inside = (root: string, rel: string): string => { + const n = checkRelPath(rel); + const abs = join(root, n); + const realRoot = realpathSync(root); + let probe = dirname(abs); + while (!existsSync(probe)) probe = dirname(probe); + const realProbe = realpathSync(probe); + if (realProbe !== realRoot && !realProbe.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`); + if (existsSync(abs)) { + const real = realpathSync(abs); + if (real !== realRoot && !real.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`); + } + return abs; + }; + + async function ensureDraftUnlocked(courseId: string, authorId: string): Promise { + const p = requireManaged(courseId); + const dir = p.draft(authorId); + if (existsSync(join(dir, ".git"))) return dir; + mkdirSync(p.draftsDir, { recursive: true }); + await git(p.bare, ["worktree", "prune"]); + 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]); + return dir; + } + + async function readFileIn(dir: string, rel: string): Promise<{ content: string; hash: string } | null> { + let abs: string; + try { abs = inside(dir, rel); } catch { return null; } + if (!existsSync(abs) || !statSync(abs).isFile()) return null; + const bytes = new Uint8Array(await Bun.file(abs).arrayBuffer()); + return { content: new TextDecoder().decode(bytes), hash: contentHash(bytes) }; + } + + async function commitAll(dir: string, who: Identity, message: string, pathspecs?: string[]): Promise { + await gitOk(dir, ["add", "-A", "--", ...(pathspecs ?? ["."])]); + if ((await git(dir, ["diff", "--cached", "--quiet"])).code === 0) return null; + await gitOk(dir, ["commit", "-q", "-m", message], who); + return rev(dir, "HEAD"); + } + + async function writeFiles(dir: string, files: FileWrite[]): Promise { + const touched: string[] = []; + for (const f of files) { + const abs = inside(dir, f.path); + if (f.delete) { + if (existsSync(abs)) await unlink(abs); + } else { + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, f.content ?? ""); + } + touched.push(checkRelPath(f.path)); + } + return touched; + } + + const api = { + dataDir, + publishedBranch: main, + paths, + branchOf, + isManaged, + + async listManaged(): Promise { + if (!existsSync(coursesDir)) return []; + return readdirSync(coursesDir).filter((id) => ID_RE.test(id) && isManaged(id)).sort(); + }, + + async createCourse(input: { id: string; title: string; pitch?: string }, who: Identity): Promise { + const { id, title } = input; + if (!ID_RE.test(id)) throw new StudioError(`invalid course id "${id}": use lowercase letters, digits and dashes`); + if (!title.trim()) throw new StudioError("title is required"); + const p = paths(id); + if (existsSync(p.bare) || existsSync(p.root)) throw new StudioError(`course "${id}" already exists`, 409); + await lock(id, async () => { + mkdirSync(coursesDir, { recursive: true }); + const tmp = join(dataDir, "tmp", `new-${id}-${Date.now()}`); + mkdirSync(join(tmp, "lessons"), { recursive: true }); + try { + await gitOk(dataDir, ["init", "-q", "--bare", "-b", main, p.bare]); + const manifest = { + schema: 1, id, title: title.trim(), ...(input.pitch ? { pitch: input.pitch } : {}), + authors: [who.name], visibility: "restricted", source: { kind: "authored" }, + modules: [{ id: "m1", title: "Getting started", lessons: [{ id: "welcome", title: "Welcome", type: "text", file: "lessons/welcome.md" }] }], + }; + await Bun.write(join(tmp, MANIFEST), Bun.YAML.stringify(manifest, null, 2) + "\n"); + await Bun.write(join(tmp, "lessons/welcome.md"), `# Welcome\n\nThis is the first lesson of ${title.trim()}.\n`); + const gd = ["--git-dir", p.bare, "--work-tree", tmp]; + await gitOk(tmp, [...gd, "add", "-A"]); + await gitOk(tmp, [...gd, "commit", "-q", "-m", `Create course ${id}`], who); + mkdirSync(p.root, { recursive: true }); + await gitOk(p.bare, ["worktree", "add", p.published, main]); + } catch (e) { + rmSync(p.bare, { recursive: true, force: true }); + rmSync(p.root, { recursive: true, force: true }); + throw e; + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + }, + + ensureDraft: (courseId: string, authorId: string) => lock(courseId, () => ensureDraftUnlocked(courseId, authorId)), + + async listFiles(courseId: string, authorId: string): Promise { + const dir = await api.ensureDraft(courseId, authorId); + const tracked = splitZ(await gitOk(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"])); + return [...new Set(tracked)].filter((f) => existsSync(join(dir, f))).sort(); + }, + + async readFile(courseId: string, authorId: string, rel: string) { + const dir = await api.ensureDraft(courseId, authorId); + return readFileIn(dir, rel); + }, + + async readPublished(courseId: string, rel: string) { + return readFileIn(requireManaged(courseId).published, rel); + }, + + draftDir: (courseId: string, authorId: string) => api.ensureDraft(courseId, authorId), + + async saveFiles(courseId: string, who: Identity & { id: string }, files: FileWrite[], message: string): Promise { + for (const f of files) checkRelPath(f.path); + if (files.length === 0) throw new StudioError("nothing to save"); + return lock(courseId, async () => { + const dir = await ensureDraftUnlocked(courseId, who.id); + if (await isMerging(dir)) throw new StudioError("the draft has a merge in progress: resolve or abort it first", 409); + const conflicts: FileConflict[] = []; + for (const f of files) { + if (f.baseHash === undefined) continue; + const cur = await readFileIn(dir, f.path); + if ((cur?.hash ?? null) !== f.baseHash) conflicts.push({ path: checkRelPath(f.path), current: cur?.content ?? null, currentHash: cur?.hash ?? null }); + } + if (conflicts.length) return { ok: false, conflicts }; + const touched = await writeFiles(dir, files); + const commit = await commitAll(dir, who, message.trim() || "Edit in studio", touched); + return { ok: true, commit }; + }); + }, + + async status(courseId: string, authorId: string): Promise { + const p = requireManaged(courseId); + const dir = await api.ensureDraft(courseId, authorId); + const branch = branchOf(authorId); + const merging = await isMerging(dir); + return { + courseId, branch, publishedBranch: main, + draftHead: await rev(dir, "HEAD"), + publishedHead: await rev(p.bare, main), + ahead: await count(p.bare, `${main}..${branch}`), + behind: await count(p.bare, `${branch}..${main}`), + merging, + conflicts: merging ? await unmerged(dir) : [], + remote: await remoteUrl(p.bare), + publishedDirty: await dirtyFiles(p.published), + }; + }, + + async publishedHead(courseId: string): Promise { + return rev(requireManaged(courseId).published, "HEAD"); + }, + + /** Validates the draft as the learner loader would; error diagnostics block publishing. */ + async validateDraft(courseId: string, authorId: string): Promise { + return api.validateDraftUnlocked(courseId, await api.ensureDraft(courseId, authorId)); + }, + + publish(courseId: string, who: Identity & { id: string }): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + const dir = await ensureDraftUnlocked(courseId, who.id); + const branch = branchOf(who.id); + if (await isMerging(dir)) return { ok: false, reason: "merging" }; + const dirty = await dirtyFiles(p.published); + if (dirty.length) return { ok: false, reason: "dirty", files: dirty }; + const diags = (await api.validateDraftUnlocked(courseId, dir)).filter((d) => d.level === "error"); + if (diags.length) return { ok: false, reason: "invalid", diagnostics: diags }; + if ((await count(p.bare, `${main}..${branch}`)) === 0) return { ok: true, commit: await rev(p.published, "HEAD"), published: false }; + const r = await git(p.published, ["merge", "--no-ff", "-m", `Publish ${branch}`, branch], who); + if (r.code !== 0) { + const conflicts = await unmerged(p.published); + await git(p.published, ["merge", "--abort"]); + if (!conflicts.length) throw new StudioError(`publish failed: ${r.stderr.trim() || r.stdout.trim()}`, 500); + return { ok: false, reason: "conflict", conflicts }; + } + // Bring the draft level with what was just published, so "behind" stays meaningful. + await git(dir, ["merge", "-q", "--ff-only", main]); + return { ok: true, commit: await rev(p.published, "HEAD"), published: true }; + }); + }, + + async validateDraftUnlocked(courseId: string, dir: string): Promise { + const c = await loadCourse(join(dir, MANIFEST)); + const diags = [...c.diagnostics]; + if (c.manifest && c.manifest.id !== courseId) diags.push({ level: "error", courseId, path: MANIFEST, message: `manifest id "${c.manifest.id}" must stay "${courseId}"` }); + if (c.manifest?.root) diags.push({ level: "error", courseId, path: MANIFEST, message: "studio courses cannot set root" }); + return diags; + }, + + /** Merges the published branch (or the remote's) into the author's draft. Conflicts stay in the draft for the author to resolve. */ + updateDraft(courseId: string, who: Identity & { id: string }, from: "published" | "remote" = "published"): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + const dir = await ensureDraftUnlocked(courseId, who.id); + if (await isMerging(dir)) return { ok: false, reason: "merging", message: "a merge is already in progress" }; + let target = main; + if (from === "remote") { + if (!(await remoteUrl(p.bare))) return { ok: false, reason: "no-remote", message: "no remote is configured" }; + const f = await git(p.bare, ["fetch", "-q", "origin"]); + if (f.code !== 0) return { ok: false, reason: "no-remote", message: f.stderr.trim() }; + target = `origin/${main}`; + if ((await git(p.bare, ["rev-parse", "-q", "--verify", `refs/remotes/${target}`])).code !== 0) return { ok: true, commit: await rev(dir, "HEAD") }; + } + const r = await git(dir, ["merge", "--no-ff", "-m", `Update draft from ${target}`, target], who); + if (r.code !== 0) { + const conflicts = await unmerged(dir); + if (!conflicts.length) { + await git(dir, ["merge", "--abort"]); + throw new StudioError(`update failed: ${r.stderr.trim() || r.stdout.trim()}`, 500); + } + return { ok: false, reason: "conflict", conflicts }; + } + return { ok: true, commit: await rev(dir, "HEAD") }; + }); + }, + + async conflicts(courseId: string, authorId: string): Promise { + const dir = await api.ensureDraft(courseId, authorId); + if (!(await isMerging(dir))) return []; + const stage = async (n: number, path: string) => { + const r = await git(dir, ["show", `:${n}:${path}`]); + return r.code === 0 ? r.stdout : null; + }; + const out: ConflictFile[] = []; + for (const path of await unmerged(dir)) { + out.push({ path, base: await stage(1, path), ours: await stage(2, path), theirs: await stage(3, path), working: (await readFileIn(dir, path))?.content ?? null }); + } + return out; + }, + + /** Writes the author's resolution (`content: null` deletes) and commits the merge once nothing is unmerged. */ + resolve(courseId: string, who: Identity & { id: string }, files: { path: string; content: string | null }[]): Promise<{ ok: true; remaining: string[]; commit: string | null }> { + return lock(courseId, async () => { + const dir = await ensureDraftUnlocked(courseId, who.id); + if (!(await isMerging(dir))) throw new StudioError("no merge in progress", 409); + const touched = await writeFiles(dir, files.map((f) => (f.content === null ? { path: f.path, delete: true } : { path: f.path, content: f.content }))); + await gitOk(dir, ["add", "-A", "--", ...touched]); + const remaining = await unmerged(dir); + if (remaining.length) return { ok: true, remaining, commit: null }; + await gitOk(dir, ["commit", "-q", "--no-edit"], who); + return { ok: true, remaining: [], commit: await rev(dir, "HEAD") }; + }); + }, + + abortMerge(courseId: string, authorId: string): Promise { + return lock(courseId, async () => { + const dir = await ensureDraftUnlocked(courseId, authorId); + if (await isMerging(dir)) await gitOk(dir, ["merge", "--abort"]); + }); + }, + + async remote(courseId: string): Promise { + return remoteUrl(requireManaged(courseId).bare); + }, + + setRemote(courseId: string, url: string | null): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + const cur = await remoteUrl(p.bare); + if (!url) { + if (cur) await gitOk(p.bare, ["remote", "remove", "origin"]); + return; + } + if (/^\s*-/.test(url) || /\s/.test(url.trim())) throw new StudioError("invalid remote url"); + await gitOk(p.bare, cur ? ["remote", "set-url", "origin", url.trim()] : ["remote", "add", "origin", url.trim()]); + // Bare repos created with `init --bare` have no fetch refspec for remote-tracking branches by default. + await gitOk(p.bare, ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]); + }); + }, + + /** + * Explicit Sync with the remote: pull (fast-forward, or a merge without conflicts) and push the published branch. + * A conflicting divergence is aborted and reported; the author merges the remote into the draft to resolve it. + */ + sync(courseId: string, who: Identity): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + if (!(await remoteUrl(p.bare))) return { ok: false, reason: "no-remote", message: "no remote is configured for this course" }; + const dirty = await dirtyFiles(p.published); + if (dirty.length) return { ok: false, reason: "dirty", message: `uncommitted changes in the published worktree: ${dirty.join(", ")}` }; + const f = await git(p.bare, ["fetch", "-q", "origin"]); + if (f.code !== 0) return { ok: false, reason: "network", message: f.stderr.trim() || "fetch failed" }; + const remoteRef = `refs/remotes/origin/${main}`; + const hasRemote = (await git(p.bare, ["rev-parse", "-q", "--verify", remoteRef])).code === 0; + let pulled = false; + let pushed = false; + if (hasRemote) { + const local = await rev(p.bare, main); + const remote = await rev(p.bare, remoteRef); + if (local !== remote) { + const localBehind = (await git(p.bare, ["merge-base", "--is-ancestor", local, remote])).code === 0; + const remoteBehind = (await git(p.bare, ["merge-base", "--is-ancestor", remote, local])).code === 0; + if (localBehind) { + await gitOk(p.published, ["merge", "-q", "--ff-only", remoteRef]); + pulled = true; + } else if (!remoteBehind) { + const r = await git(p.published, ["merge", "--no-ff", "-m", `Sync with origin/${main}`, remoteRef], who); + if (r.code !== 0) { + const conflicts = await unmerged(p.published); + await git(p.published, ["merge", "--abort"]); + return conflicts.length ? { ok: false, reason: "conflict", conflicts } : { ok: false, reason: "network", message: r.stderr.trim() }; + } + pulled = true; + } + } + } + const ahead = hasRemote ? await count(p.bare, `${remoteRef}..${main}`) : 1; + if (ahead > 0) { + const r = await git(p.bare, ["push", "-q", "origin", `${main}:${main}`]); + if (r.code !== 0) return { ok: false, reason: "network", message: r.stderr.trim() || "push failed" }; + await git(p.bare, ["fetch", "-q", "origin"]); + pushed = true; + } + return { ok: true, pulled, pushed, head: await rev(p.published, "HEAD") }; + }); + }, + }; + return api; +} + +export type StudioStore = ReturnType; diff --git a/packages/feature-studio/test/helpers.ts b/packages/feature-studio/test/helpers.ts new file mode 100644 index 0000000..c4516e8 --- /dev/null +++ b/packages/feature-studio/test/helpers.ts @@ -0,0 +1,25 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { createStudioStore, type Identity } from "../src/server/store"; + +export const ada: Identity & { id: string } = { id: "u-ada", name: "Ada Lovelace", email: "ada@example.org" }; +export const bob: Identity & { id: string } = { id: "u-bob", name: "Bob Ext", email: "bob@example.org" }; + +export function tempDir(prefix = "stc-studio-"): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +export async function newStore() { + const dataDir = tempDir(); + const store = createStudioStore({ dataDir }); + return { dataDir, store }; +} + +/** Runs git as an outside tool (VS Code, a terminal) would: plain identity, in the given dir. */ +export async function extGit(cwd: string, ...args: string[]): Promise { + const r = await $`git -c user.name=${bob.name} -c user.email=${bob.email} -c commit.gpgsign=false -c core.hooksPath=/dev/null ${args}`.cwd(cwd).quiet().nothrow(); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr.toString()}`); + return r.stdout.toString().trim(); +} diff --git a/packages/feature-studio/test/store.test.ts b/packages/feature-studio/test/store.test.ts new file mode 100644 index 0000000..6377f42 --- /dev/null +++ b/packages/feature-studio/test/store.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { loadCourse } from "@stc/course-format"; +import { contentHash } from "../src/server/store"; +import { ada, bob, extGit, newStore } from "./helpers"; + +describe("createCourse", () => { + test("makes a bare repo, a published worktree on main and a valid manifest", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "intro-git", title: "Intro: Git & you" }, ada); + expect(existsSync(join(dataDir, "courses/intro-git/HEAD"))).toBe(true); + expect(existsSync(join(dataDir, "courses/intro-git/stcourse.yaml"))).toBe(false); + const pub = store.paths("intro-git").published; + expect(pub).toBe(join(dataDir, "worktrees/intro-git/published")); + expect(await extGit(pub, "rev-parse", "--abbrev-ref", "HEAD")).toBe("main"); + const loaded = await loadCourse(join(pub, "stcourse.yaml")); + expect(loaded.status).toBe("ok"); + expect(loaded.manifest?.title).toBe("Intro: Git & you"); + expect(await extGit(pub, "log", "-1", "--format=%an <%ae>")).toBe("Ada Lovelace "); + expect(store.isManaged("intro-git")).toBe(true); + expect(await store.listManaged()).toEqual(["intro-git"]); + }); + + test("rejects bad and duplicate ids", async () => { + const { store } = await newStore(); + await expect(store.createCourse({ id: "Bad Id", title: "x" }, ada)).rejects.toThrow(/id/); + await store.createCourse({ id: "c1", title: "x" }, ada); + await expect(store.createCourse({ id: "c1", title: "y" }, ada)).rejects.toThrow(/exists/); + }); +}); + +describe("drafts and saving", () => { + test("a draft worktree per author on draft/; save commits as the author; published untouched", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const dir = await store.ensureDraft("c1", ada.id); + expect(dir).toBe(store.paths("c1").draft(ada.id)); + expect(await extGit(dir, "rev-parse", "--abbrev-ref", "HEAD")).toBe("draft/u-ada"); + const cur = await store.readFile("c1", ada.id, "lessons/welcome.md"); + expect(cur).not.toBeNull(); + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Hello draft\n", baseHash: cur!.hash }], "Edit welcome"); + expect(r.ok).toBe(true); + expect(await extGit(dir, "log", "-1", "--format=%an <%ae>|%s")).toBe("Ada Lovelace |Edit welcome"); + expect(readFileSync(join(store.paths("c1").published, "lessons/welcome.md"), "utf8")).not.toContain("Hello draft"); + const st = await store.status("c1", ada.id); + expect(st.ahead).toBe(1); + expect(st.behind).toBe(0); + expect(st.merging).toBe(false); + }); + + test("saving unchanged content makes no commit", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: cur.content, baseHash: cur.hash }], "noop"); + expect(r).toEqual({ ok: true, commit: null }); + }); + + test("an external edit between load and save is a conflict, not an overwrite", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; + const file = join(store.paths("c1").draft(ada.id), "lessons/welcome.md"); + writeFileSync(file, "edited in VS Code\n"); + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "studio text\n", baseHash: cur.hash }], "x"); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.conflicts).toEqual([{ path: "lessons/welcome.md", current: "edited in VS Code\n", currentHash: contentHash("edited in VS Code\n") }]); + expect(readFileSync(file, "utf8")).toBe("edited in VS Code\n"); + }); + + test("baseHash null means the file must not exist yet", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const r = await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "x", baseHash: null }], "x"); + expect(r.ok).toBe(false); + }); + + test("paths with spaces and non-ASCII characters", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const p = "S08 - Containerizing/Æble notes ü.md"; + const r = await store.saveFiles("c1", ada, [{ path: p, content: "æøå\n", baseHash: null }], "Add notes"); + expect(r.ok).toBe(true); + expect((await store.readFile("c1", ada.id, p))?.content).toBe("æøå\n"); + expect(await store.listFiles("c1", ada.id)).toContain(p); + }); + + test("binary content and deletion", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const bytes = new Uint8Array([0, 1, 2, 255]); + expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", content: bytes }], "bin")).ok).toBe(true); + expect(new Uint8Array(readFileSync(join(store.paths("c1").draft(ada.id), "media/a.bin")))).toEqual(bytes); + expect((await store.saveFiles("c1", ada, [{ path: "media/a.bin", delete: true }], "rm")).ok).toBe(true); + expect(await store.listFiles("c1", ada.id)).not.toContain("media/a.bin"); + }); + + test("path traversal and .git are rejected", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + for (const bad of ["../x.md", "/etc/passwd", ".git/config", "a/../../x", "", "a\0b"]) { + await expect(store.saveFiles("c1", ada, [{ path: bad, content: "x" }], "x")).rejects.toThrow(/path/i); + } + expect(await store.readFile("c1", ada.id, "../../../etc/passwd")).toBeNull(); + }); + + test("authors get separate drafts; odd author ids are made safe", async () => { + const { store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const a = await store.ensureDraft("c1", ada.id); + const b = await store.ensureDraft("c1", "bob/../x y"); + expect(a).not.toBe(b); + expect(await extGit(b, "rev-parse", "--abbrev-ref", "HEAD")).toMatch(/^draft\/[A-Za-z0-9_-]+$/); + void bob; + }); +}); From c2385b472ce476f5d2db76a97cda5b35c7cad7b4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:15:20 +0100 Subject: [PATCH 3/9] feat(studio): publish as merge, update draft, conflict resolution, explicit sync Co-Authored-By: Claude Opus 5.5 --- packages/feature-studio/test/publish.test.ts | 146 +++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 packages/feature-studio/test/publish.test.ts diff --git a/packages/feature-studio/test/publish.test.ts b/packages/feature-studio/test/publish.test.ts new file mode 100644 index 0000000..cc5c33e --- /dev/null +++ b/packages/feature-studio/test/publish.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { ada, bob, extGit, newStore, tempDir } from "./helpers"; + +async function courseWithEdit(text = "# Draft text\n") { + const s = await newStore(); + await s.store.createCourse({ id: "c1", title: "C" }, ada); + const cur = (await s.store.readFile("c1", ada.id, "lessons/welcome.md"))!; + await s.store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: text, baseHash: cur.hash }], "Draft edit"); + return { ...s, pub: s.store.paths("c1").published }; +} + +describe("publish", () => { + test("merges the draft into main; the draft is level afterwards", async () => { + const { store, pub } = await courseWithEdit(); + const r = await store.publish("c1", ada); + expect(r).toMatchObject({ ok: true, published: true }); + expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Draft text\n"); + expect(await extGit(pub, "log", "-1", "--format=%an|%s")).toBe("Ada Lovelace|Publish draft/u-ada"); + const st = await store.status("c1", ada.id); + expect([st.ahead, st.behind]).toEqual([0, 0]); + expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: false }); + }); + + test("a conflicting external commit on main is reported and main keeps the external content", async () => { + const { store, pub } = await courseWithEdit("# Studio version\n"); + writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n"); + await extGit(pub, "commit", "-qam", "External edit"); + const head = await extGit(pub, "rev-parse", "HEAD"); + const r = await store.publish("c1", ada); + expect(r).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); + expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# External version\n"); + expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head); + expect(await extGit(pub, "status", "--porcelain")).toBe(""); + expect((await store.status("c1", ada.id)).behind).toBe(1); + }); + + test("uncommitted edits in the published worktree block publishing", async () => { + const { store, pub } = await courseWithEdit(); + writeFileSync(join(pub, "lessons/welcome.md"), "half-typed in an editor"); + const r = await store.publish("c1", ada); + expect(r).toEqual({ ok: false, reason: "dirty", files: ["lessons/welcome.md"] }); + expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("half-typed in an editor"); + }); + + test("an invalid manifest blocks publishing with diagnostics", async () => { + const { store } = await courseWithEdit(); + const m = (await store.readFile("c1", ada.id, "stcourse.yaml"))!; + await store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: m.content.replace("lessons/welcome.md", "lessons/missing.md"), baseHash: m.hash }], "break"); + const r = await store.publish("c1", ada); + expect(r.ok).toBe(false); + if (r.ok || r.reason !== "invalid") throw new Error("expected invalid"); + expect(r.diagnostics[0]!.message).toContain("lessons/missing.md"); + }); +}); + +describe("update draft and conflict resolution", () => { + test("conflicts are shown with both sides, resolved by the author, then publish succeeds", async () => { + const { store, pub } = await courseWithEdit("# Studio version\n"); + writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n"); + await extGit(pub, "commit", "-qam", "External edit"); + const u = await store.updateDraft("c1", ada); + expect(u).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); + const st = await store.status("c1", ada.id); + expect(st.merging).toBe(true); + expect(st.conflicts).toEqual(["lessons/welcome.md"]); + await expect(store.saveFiles("c1", ada, [{ path: "x.md", content: "x" }], "x")).rejects.toThrow(/merge/); + const [c] = await store.conflicts("c1", ada.id); + expect(c).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio version\n", theirs: "# External version\n" }); + expect(c!.working).toContain("<<<<<<<"); + const res = await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Both versions\n" }]); + expect(res.remaining).toEqual([]); + expect(res.commit).toBeTruthy(); + expect(await store.publish("c1", ada)).toMatchObject({ ok: true, published: true }); + expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Both versions\n"); + }); + + test("abort returns the draft to its pre-merge state", async () => { + const { store, pub } = await courseWithEdit("# Studio version\n"); + writeFileSync(join(pub, "lessons/welcome.md"), "# External version\n"); + await extGit(pub, "commit", "-qam", "External edit"); + await store.updateDraft("c1", ada); + await store.abortMerge("c1", ada.id); + const st = await store.status("c1", ada.id); + expect(st.merging).toBe(false); + expect((await store.readFile("c1", ada.id, "lessons/welcome.md"))!.content).toBe("# Studio version\n"); + }); + + test("a non-conflicting external commit merges cleanly into the draft", async () => { + const { store, pub } = await courseWithEdit(); + writeFileSync(join(pub, "extra.md"), "external\n"); + await extGit(pub, "add", "extra.md"); + await extGit(pub, "commit", "-qm", "External add"); + expect((await store.updateDraft("c1", ada)).ok).toBe(true); + expect((await store.readFile("c1", ada.id, "extra.md"))!.content).toBe("external\n"); + }); +}); + +describe("sync", () => { + test("no remote: an error, nothing happens", async () => { + const { store } = await courseWithEdit(); + expect(await store.sync("c1", ada)).toMatchObject({ ok: false, reason: "no-remote" }); + }); + + test("push to an empty remote, pull a remote commit, report a conflicting divergence", async () => { + const { store, pub } = await courseWithEdit(); + await store.publish("c1", ada); + const remote = join(tempDir(), "remote.git"); + await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote); + await store.setRemote("c1", remote); + expect(await store.remote("c1")).toBe(remote); + expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true, pulled: false }); + expect(await extGit(remote, "rev-parse", "main")).toBe(await extGit(pub, "rev-parse", "HEAD")); + + // someone else pushes to the remote + const other = join(tempDir(), "other"); + await extGit(tempDir(), "clone", "-q", remote, other); + writeFileSync(join(other, "lessons/welcome.md"), "# Remote version\n"); + await extGit(other, "commit", "-qam", "Remote edit"); + await extGit(other, "push", "-q", "origin", "main"); + expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pulled: true, pushed: false }); + expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Remote version\n"); + + // diverge with a conflict + writeFileSync(join(other, "lessons/welcome.md"), "# Remote again\n"); + await extGit(other, "commit", "-qam", "Remote edit 2"); + await extGit(other, "push", "-q", "origin", "main"); + await store.updateDraft("c1", ada); + const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!; + await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# Local again\n", baseHash: cur.hash }], "local"); + expect((await store.publish("c1", ada)).ok).toBe(true); + const head = await extGit(pub, "rev-parse", "HEAD"); + expect(await store.sync("c1", ada)).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); + expect(await extGit(pub, "rev-parse", "HEAD")).toBe(head); + expect(readFileSync(join(pub, "lessons/welcome.md"), "utf8")).toBe("# Local again\n"); + + // the author resolves via the draft, then Sync goes through + expect(await store.updateDraft("c1", ada, "remote")).toMatchObject({ ok: false, reason: "conflict" }); + await store.resolve("c1", ada, [{ path: "lessons/welcome.md", content: "# Merged\n" }]); + expect((await store.publish("c1", ada)).ok).toBe(true); + expect(await store.sync("c1", ada)).toMatchObject({ ok: true, pushed: true }); + expect(await extGit(remote, "show", "main:lessons/welcome.md")).toBe("# Merged"); + void bob; + }); +}); From 06b6ea61681a2641d32f059b54fce485ccaed688 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:16:13 +0100 Subject: [PATCH 4/9] feat(studio): published-worktree watcher and DraftWriter API for the AI package Co-Authored-By: Claude Opus 5.5 --- .../feature-studio/src/server/draft-writer.ts | 31 +++++++++++ packages/feature-studio/src/server/paths.ts | 17 ++++++ packages/feature-studio/src/server/watch.ts | 52 +++++++++++++++++++ .../feature-studio/test/draft-writer.test.ts | 29 +++++++++++ packages/feature-studio/test/watch.test.ts | 44 ++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 packages/feature-studio/src/server/draft-writer.ts create mode 100644 packages/feature-studio/src/server/paths.ts create mode 100644 packages/feature-studio/src/server/watch.ts create mode 100644 packages/feature-studio/test/draft-writer.test.ts create mode 100644 packages/feature-studio/test/watch.test.ts diff --git a/packages/feature-studio/src/server/draft-writer.ts b/packages/feature-studio/src/server/draft-writer.ts new file mode 100644 index 0000000..a4100d3 --- /dev/null +++ b/packages/feature-studio/src/server/draft-writer.ts @@ -0,0 +1,31 @@ +import type { AppConfig } from "@stc/contracts"; +import { createStudioStore, StudioError, safeAuthor, type Identity } from "./store"; + +/** Programmatic draft API for other packages (WP-D's studio generator). Writes only to `draft/`, never publishes. */ +export interface DraftWriter { + writeDraftFiles( + courseId: string, + authorId: string, + files: { path: string; content: string }[], + message: string, + ): Promise<{ branch: string; commit: string }>; +} + +export interface DraftWriterOptions { + /** Commit author for `authorId`. Without it (or when it returns null) commits use ` `. */ + identity?: (authorId: string) => Promise | Identity | null; +} + +export function createDraftWriter(ctx: { config: Pick }, opts: DraftWriterOptions = {}): DraftWriter { + const store = createStudioStore({ dataDir: ctx.config.dataDir }); + return { + async writeDraftFiles(courseId, authorId, files, message) { + if (!store.isManaged(courseId)) throw new StudioError(`course ${courseId} is not managed by the studio`, 404); + const id = (await opts.identity?.(authorId)) ?? { name: authorId, email: `${safeAuthor(authorId)}@users.stcourses.invalid` }; + const r = await store.saveFiles(courseId, { ...id, id: authorId }, files.map((f) => ({ path: f.path, content: f.content })), message); + if (!r.ok) throw new StudioError("draft write conflict", 409); // unreachable: no base hashes are given + const st = await store.status(courseId, authorId); + return { branch: st.branch, commit: r.commit ?? st.draftHead }; + }, + }; +} diff --git a/packages/feature-studio/src/server/paths.ts b/packages/feature-studio/src/server/paths.ts new file mode 100644 index 0000000..f95b9f0 --- /dev/null +++ b/packages/feature-studio/src/server/paths.ts @@ -0,0 +1,17 @@ +import { join, resolve } from "node:path"; +import type { AppConfig } from "@stc/contracts"; + +/** Where learners read studio courses from: `/worktrees//published` (depth 2; drafts sit at depth 3 and are never discovered). */ +export function studioWorktreesDir(dataDir: string): string { + return join(dataDir, "worktrees"); +} + +/** + * The course dirs the CourseRepository must scan so studio courses load from their published worktree. + * Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md. + */ +export function studioCourseDirs(config: Pick): string[] { + const wt = resolve(studioWorktreesDir(config.dataDir)); + const dirs = config.courseDirs.map((d) => resolve(d)); + return dirs.includes(wt) ? dirs : [...dirs, wt]; +} diff --git a/packages/feature-studio/src/server/watch.ts b/packages/feature-studio/src/server/watch.ts new file mode 100644 index 0000000..29d484d --- /dev/null +++ b/packages/feature-studio/src/server/watch.ts @@ -0,0 +1,52 @@ +import type { CourseRepository } from "@stc/course-format"; +import type { StudioStore } from "./store"; + +export interface PublishedWatcher { + /** Compares every managed course's published HEAD with the last one seen; reloads the repository for changed ones. */ + check(): Promise; + /** Records a head the studio itself just produced (publish, sync) after reloading. */ + note(courseId: string, head: string): void; + start(): void; + stop(): void; +} + +/** + * Spec 12: "The platform watches the published worktree and reloads when it changes, after an external commit or a pull." + * Polling `git rev-parse HEAD` is portable (fs.watch on .git internals is not) and costs one git spawn per course per tick. + */ +export function createPublishedWatcher(store: StudioStore, courses: CourseRepository, opts: { intervalMs?: number } = {}): PublishedWatcher { + const seen = new Map(); + let timer: ReturnType | null = null; + let running: Promise | null = null; + + const run = async (): Promise => { + const changed: string[] = []; + for (const id of await store.listManaged()) { + let head: string; + try { head = await store.publishedHead(id); } catch { continue; } + const prev = seen.get(id); + seen.set(id, head); + if (prev === undefined ? courses.get(id) === null : prev !== head) changed.push(id); + } + for (const id of changed) await courses.reload(id); + return changed; + }; + + const w: PublishedWatcher = { + check() { + running ??= run().finally(() => { running = null; }); + return running; + }, + note(courseId, head) { seen.set(courseId, head); }, + start() { + if (timer) return; + timer = setInterval(() => { w.check().catch((e) => console.error("[studio] watcher", e)); }, opts.intervalMs ?? 3000); + (timer as { unref?: () => void }).unref?.(); + }, + stop() { + if (timer) clearInterval(timer); + timer = null; + }, + }; + return w; +} diff --git a/packages/feature-studio/test/draft-writer.test.ts b/packages/feature-studio/test/draft-writer.test.ts new file mode 100644 index 0000000..3833051 --- /dev/null +++ b/packages/feature-studio/test/draft-writer.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createDraftWriter } from "../src/server/draft-writer"; +import { ada, extGit, newStore } from "./helpers"; + +test("writeDraftFiles commits on draft/, never on the published branch", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const writer = createDraftWriter({ config: { dataDir } }, { identity: async (id) => (id === ada.id ? ada : null) }); + const r = await writer.writeDraftFiles("c1", ada.id, [{ path: "lessons/generated.md", content: "# Generated\n" }], "AI draft: module 2"); + expect(r.branch).toBe("draft/u-ada"); + const draft = store.paths("c1").draft(ada.id); + expect(await extGit(draft, "rev-parse", "HEAD")).toBe(r.commit); + expect(await extGit(draft, "log", "-1", "--format=%an|%s")).toBe("Ada Lovelace|AI draft: module 2"); + expect(readFileSync(join(draft, "lessons/generated.md"), "utf8")).toBe("# Generated\n"); + expect(existsSync(join(store.paths("c1").published, "lessons/generated.md"))).toBe(false); +}); + +test("unknown identity falls back to the author id; unmanaged course and bad paths throw", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + const writer = createDraftWriter({ config: { dataDir } }); + const r = await writer.writeDraftFiles("c1", "u-x", [{ path: "a.md", content: "a" }], "m"); + expect(await extGit(store.paths("c1").draft("u-x"), "log", "-1", "--format=%an <%ae>")).toBe("u-x "); + expect(r.branch).toBe("draft/u-x"); + await expect(writer.writeDraftFiles("pure-snake", "u-x", [{ path: "a.md", content: "a" }], "m")).rejects.toThrow(/not managed/); + await expect(writer.writeDraftFiles("c1", "u-x", [{ path: "../a.md", content: "a" }], "m")).rejects.toThrow(/path/); +}); diff --git a/packages/feature-studio/test/watch.test.ts b/packages/feature-studio/test/watch.test.ts new file mode 100644 index 0000000..05de072 --- /dev/null +++ b/packages/feature-studio/test/watch.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createCourseRepository } from "@stc/course-format"; +import { createPublishedWatcher } from "../src/server/watch"; +import { studioCourseDirs } from "../src/server/paths"; +import { ada, extGit, newStore } from "./helpers"; + +test("an external commit in the published worktree reloads the CourseRepository", async () => { + const { dataDir, store } = await newStore(); + const courses = await createCourseRepository(studioCourseDirs({ dataDir, courseDirs: [] })); + let reloads = 0; + const realReload = courses.reload.bind(courses); + courses.reload = async (id?: string) => { reloads++; await realReload(id); }; + const w = createPublishedWatcher(store, courses, { intervalMs: 50 }); + + await store.createCourse({ id: "c1", title: "C" }, ada); + expect(await w.check()).toEqual(["c1"]); // new course, not yet loaded + expect(courses.get("c1")?.status).toBe("ok"); + expect(await w.check()).toEqual([]); + + const pub = store.paths("c1").published; + writeFileSync(join(pub, "lessons/welcome.md"), "# External\n"); + await extGit(pub, "commit", "-qam", "external"); + expect(await w.check()).toEqual(["c1"]); + expect(await courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); + expect(reloads).toBe(2); + + // the timer path + writeFileSync(join(pub, "lessons/welcome.md"), "# External 2\n"); + await extGit(pub, "commit", "-qam", "external 2"); + w.start(); + for (let i = 0; i < 40 && (await courses.readLessonMarkdown("c1", "welcome")) !== "# External 2\n"; i++) await Bun.sleep(25); + w.stop(); + expect(await courses.readLessonMarkdown("c1", "welcome")).toBe("# External 2\n"); +}); + +test("drafts are not discovered as courses", async () => { + const { dataDir, store } = await newStore(); + await store.createCourse({ id: "c1", title: "C" }, ada); + await store.ensureDraft("c1", ada.id); + const courses = await createCourseRepository(studioCourseDirs({ dataDir, courseDirs: [] })); + expect(courses.list().map((c) => [c.id, c.root])).toEqual([["c1", store.paths("c1").published]]); +}); From 497ce91216df18ee6351bacc8b485d36b612538c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:18:25 +0100 Subject: [PATCH 5/9] feat(studio): /api/studio routes, author access, acceptance tests Co-Authored-By: Claude Opus 5.5 --- packages/feature-studio/package.json | 6 +- packages/feature-studio/src/index.ts | 10 + packages/feature-studio/src/server/feature.ts | 300 ++++++++++++++++++ .../feature-studio/test/acceptance.test.ts | 92 ++++++ packages/feature-studio/test/app-helpers.ts | 43 +++ packages/feature-studio/test/feature.test.ts | 78 +++++ 6 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 packages/feature-studio/src/index.ts create mode 100644 packages/feature-studio/src/server/feature.ts create mode 100644 packages/feature-studio/test/acceptance.test.ts create mode 100644 packages/feature-studio/test/app-helpers.ts create mode 100644 packages/feature-studio/test/feature.test.ts diff --git a/packages/feature-studio/package.json b/packages/feature-studio/package.json index 0e7914e..fa1d8e4 100644 --- a/packages/feature-studio/package.json +++ b/packages/feature-studio/package.json @@ -3,5 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", - "license": "AGPL-3.0-only" + "license": "AGPL-3.0-only", + "exports": { + ".": "./src/index.ts", + "./web": "./src/web.ts" + } } diff --git a/packages/feature-studio/src/index.ts b/packages/feature-studio/src/index.ts new file mode 100644 index 0000000..1cf5e9b --- /dev/null +++ b/packages/feature-studio/src/index.ts @@ -0,0 +1,10 @@ +// Server entry of @stc/feature-studio. The browser imports "@stc/feature-studio/web" instead (no server code). +export { studioFeature, OUTSIDE_MESSAGE, type StudioFeature, type StudioFeatureOptions } from "./server/feature"; +export { createDraftWriter, type DraftWriter, type DraftWriterOptions } from "./server/draft-writer"; +export { createStudioStore, contentHash, type StudioStore, type Identity, type DraftStatus, type PublishResult, type SyncResult } from "./server/store"; +export { createPublishedWatcher, type PublishedWatcher } from "./server/watch"; +export { studioCourseDirs, studioWorktreesDir } from "./server/paths"; +export { studioWeb } from "./web"; + +/** The studio adds no tables: authors use Phase 0's `course_authors`. */ +export const migrationsDirs: string[] = []; diff --git a/packages/feature-studio/src/server/feature.ts b/packages/feature-studio/src/server/feature.ts new file mode 100644 index 0000000..0460772 --- /dev/null +++ b/packages/feature-studio/src/server/feature.ts @@ -0,0 +1,300 @@ +import { basename, resolve, sep } from "node:path"; +import type { AuthUser, Feature, RequestContext, RouteHandler, ServerContext } from "@stc/contracts"; +import { serveMedia } from "@stc/core"; +import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from "@stc/course-format"; +import { aclRepo } from "@stc/db"; +import { createDraftWriter, type DraftWriter } from "./draft-writer"; +import { studioWorktreesDir } from "./paths"; +import { checkRelPath, createStudioStore, MANIFEST, StudioError, type FileWrite, type StudioStore } from "./store"; +import { createPublishedWatcher, type PublishedWatcher } from "./watch"; + +export const OUTSIDE_MESSAGE = "This course is managed outside STCourses"; +const MAX_UPLOAD = 2 * 1024 * 1024 * 1024; // 2 GB; large videos belong in the media dir, not git, but we do not forbid them + +export interface StudioFeature extends Feature { + store: StudioStore; + watcher: PublishedWatcher; + draftWriter: DraftWriter; + stop(): void; +} + +export interface StudioFeatureOptions { + /** Poll the published worktrees for external commits (default true). */ + watch?: boolean; + watchIntervalMs?: number; +} + +const json = (body: unknown, status = 200) => Response.json(body, { status }); +const fail = (status: number, error: string) => json({ error }, status); + +async function body(req: Request): Promise { + try { + return (await req.json()) as T; + } catch { + throw new StudioError("request body must be JSON"); + } +} + +function manifestToYaml(m: unknown): string { + return Bun.YAML.stringify(m, null, 2) + "\n"; +} + +export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature { + const store = createStudioStore({ dataDir: ctx.config.dataDir }); + const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs }); + const acl = aclRepo(ctx.db); + const wtDir = resolve(studioWorktreesDir(ctx.config.dataDir)); + const draftWriter = createDraftWriter(ctx); + + const hasRole = (u: AuthUser) => u.roles.includes("owner") || u.roles.includes("author"); + const authoredCourses = async (u: AuthUser): Promise => + (await ctx.db.sql`SELECT course_id FROM course_authors WHERE user_id = ${u.id}`).map((r: { course_id: string }) => r.course_id); + const canEdit = async (u: AuthUser, courseId: string) => hasRole(u) || (await acl.isAuthor(courseId, u.id)); + const isStudioCourse = (c: LoadedCourse) => resolve(c.manifestPath).startsWith(wtDir + sep); + const who = (u: AuthUser) => ({ id: u.id, name: u.name || u.email || u.id, email: u.email || `${u.id}@users.stcourses.invalid` }); + + const afterPublishedChange = async (courseId: string) => { + await ctx.courses.reload(courseId); + watcher.note(courseId, await store.publishedHead(courseId)); + }; + + /** Wraps a handler: signed-in author/owner (or per-course author), error mapping. */ + const guard = (h: (req: Request, rc: RequestContext, user: AuthUser) => Promise): RouteHandler => async (req, rc) => { + const user = rc.user; + if (!user) return fail(401, "sign in to use the studio"); + try { + if (!hasRole(user) && (await authoredCourses(user)).length === 0) return fail(403, "the studio is for authors"); + return await h(req, rc, user); + } catch (e) { + if (e instanceof StudioError) return fail(e.status, e.message); + const msg = e instanceof Error ? e.message : String(e); + if (/^invalid path/.test(msg)) return fail(400, msg); + console.error("[studio]", e); + return fail(500, msg); + } + }; + + /** For per-course routes: the course must be studio-managed and editable by the user. */ + const course = (h: (req: Request, rc: RequestContext, user: AuthUser, courseId: string) => Promise) => + guard(async (req, rc, user) => { + const courseId = rc.params.courseId ?? ""; + if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course"); + if (!store.isManaged(courseId)) { + return ctx.courses.get(courseId) ? fail(403, OUTSIDE_MESSAGE) : fail(404, "no such course"); + } + return h(req, rc, user, courseId); + }); + + async function courseState(courseId: string, user: AuthUser) { + const status = await store.status(courseId, user.id); + const m = await store.readFile(courseId, user.id, MANIFEST); + let manifestRaw: unknown = null; + let manifestErrors: string[] = []; + if (m) { + try { manifestRaw = Bun.YAML.parse(m.content); } catch { /* reported by parseManifest */ } + const parsed = parseManifest(m.content); + manifestErrors = parsed.ok ? [] : parsed.errors; + } else { + manifestErrors = [`${MANIFEST} is missing`]; + } + return { + readOnly: false, + course: { id: courseId, title: (manifestRaw as { title?: string } | null)?.title ?? courseId, managed: true }, + status, + manifestText: m?.content ?? "", + manifestHash: m?.hash ?? null, + manifestRaw, + manifestErrors, + diagnostics: status.merging ? [] : await store.validateDraft(courseId, user.id), + files: await store.listFiles(courseId, user.id), + }; + } + + const routes: Feature["routes"] = { + "/api/studio/courses": { + GET: guard(async (_req, _rc, user) => { + const mine = new Set(await authoredCourses(user)); + const all = hasRole(user); + const managed = await store.listManaged(); + const courses: unknown[] = []; + for (const id of managed) { + if (!all && !mine.has(id)) continue; + const loaded = ctx.courses.get(id); + const st = await store.status(id, user.id).catch(() => null); + courses.push({ + id, managed: true, + title: loaded?.manifest?.title ?? id, + pitch: loaded?.manifest?.pitch ?? null, + accent: loaded?.manifest?.accent ?? null, + publishedStatus: loaded?.status ?? "error", + ahead: st?.ahead ?? 0, behind: st?.behind ?? 0, merging: st?.merging ?? false, remote: st?.remote ?? null, + }); + } + if (all) { + for (const c of ctx.courses.list()) { + if (isStudioCourse(c) || managed.includes(c.id)) continue; + courses.push({ id: c.id, managed: false, title: c.manifest?.title ?? c.id, pitch: c.manifest?.pitch ?? null, accent: c.manifest?.accent ?? null, publishedStatus: c.status, message: OUTSIDE_MESSAGE }); + } + } + return json({ courses, canCreate: all }); + }), + POST: guard(async (req, _rc, user) => { + if (!hasRole(user)) return fail(403, "only authors can create courses"); + const b = await body<{ id?: unknown; title?: unknown; pitch?: unknown }>(req); + if (typeof b.id !== "string" || typeof b.title !== "string" || !b.title.trim()) return fail(400, "id and title are required"); + if (ctx.courses.get(b.id) || store.isManaged(b.id)) return fail(409, `course "${b.id}" already exists`); + await store.createCourse({ id: b.id, title: b.title, pitch: typeof b.pitch === "string" ? b.pitch : undefined }, who(user)); + await ctx.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${b.id}, ${user.id})`; + await afterPublishedChange(b.id); + return json({ id: b.id }, 201); + }), + }, + + "/api/studio/courses/:courseId": { + GET: guard(async (_req, rc, user) => { + const courseId = rc.params.courseId ?? ""; + if (!(await canEdit(user, courseId))) return fail(403, "you are not an author of this course"); + if (!store.isManaged(courseId)) { + const c = ctx.courses.get(courseId); + if (!c) return fail(404, "no such course"); + return json({ readOnly: true, message: OUTSIDE_MESSAGE, course: { id: c.id, title: c.manifest?.title ?? c.id, managed: false, root: c.root }, diagnostics: c.diagnostics }); + } + return json(await courseState(courseId, user)); + }), + }, + + "/api/studio/courses/:courseId/file": { + GET: course(async (_req, rc, user, courseId) => { + const path = rc.url.searchParams.get("path") ?? ""; + const f = await store.readFile(courseId, user.id, path); + return f ? json({ path, ...f }) : fail(404, "no such file"); + }), + }, + + "/api/studio/courses/:courseId/files": { + PUT: course(async (req, _rc, user, courseId) => { + const b = await body<{ message?: string; files?: FileWrite[]; manifest?: unknown; manifestBaseHash?: string | null }>(req); + const files: FileWrite[] = Array.isArray(b.files) ? b.files.map((f) => ({ path: String(f.path), content: f.delete ? undefined : String(f.content ?? ""), baseHash: f.baseHash, delete: !!f.delete })) : []; + if (b.manifest !== undefined) { + files.unshift({ path: MANIFEST, content: manifestToYaml(b.manifest), baseHash: b.manifestBaseHash }); + } + const mf = files.find((f) => checkRelPath(f.path) === MANIFEST); + if (mf && typeof mf.content === "string") { + const parsed = parseManifest(mf.content); + if (parsed.ok && parsed.manifest.source && parsed.manifest.source.kind !== "authored" && parsed.manifest.visibility !== "restricted" && !user.roles.includes("owner")) { + return fail(403, "only the owner can make an imported course public or signed-in visible"); + } + } + const r = await store.saveFiles(courseId, who(user), files, b.message ?? ""); + if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409); + return json({ ok: true, commit: r.commit }); + }), + }, + + "/api/studio/courses/:courseId/validate": { + POST: course(async (req) => { + const b = await body<{ manifest?: unknown; manifestText?: string }>(req); + if (typeof b.manifestText === "string") { + const r = parseManifest(b.manifestText); + return json({ errors: r.ok ? [] : r.errors }); + } + const r = ManifestSchema.safeParse(b.manifest); + return json({ errors: r.success ? [] : r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) }); + }), + }, + + "/api/studio/courses/:courseId/upload": { + POST: course(async (req, _rc, user, courseId) => { + let form: FormData; + try { form = await req.formData(); } catch { return fail(400, "expected multipart form data"); } + const file = form.get("file"); + if (!(file instanceof Blob)) return fail(400, "file is required"); + if (file.size > MAX_UPLOAD) return fail(413, "file too large"); + const explicit = form.get("path"); + const dir = String(form.get("dir") ?? "").replace(/^\/+|\/+$/g, ""); + const name = basename((file as File).name || "upload.bin"); + const path = checkRelPath(typeof explicit === "string" && explicit ? explicit : dir ? `${dir}/${name}` : name); + const message = String(form.get("message") ?? "") || `Upload ${path}`; + const r = await store.saveFiles(courseId, who(user), [{ path, content: new Uint8Array(await file.arrayBuffer()) }], message); + if (!r.ok) return json({ ok: false, reason: "conflict", conflicts: r.conflicts }, 409); + return json({ ok: true, path, commit: r.commit }); + }), + }, + + "/api/studio/courses/:courseId/raw/*": { + GET: course(async (req, rc, user, courseId) => { + const m = /^\/api\/studio\/courses\/[^/]+\/raw\/(.*)$/s.exec(rc.url.pathname); + let rel: string; + try { rel = decodeURIComponent(m?.[1] ?? ""); } catch { return fail(404, "not found"); } + const dir = await store.draftDir(courseId, user.id); + try { checkRelPath(rel); } catch { return fail(404, "not found"); } + const abs = resolveInside(dir, rel); + return abs ? serveMedia(req, abs, { download: rc.url.searchParams.has("download") }) : fail(404, "not found"); + }), + }, + + "/api/studio/courses/:courseId/publish": { + POST: course(async (_req, _rc, user, courseId) => { + const r = await store.publish(courseId, who(user)); + if (r.ok) { + await afterPublishedChange(courseId); + return json(r); + } + return json(r, r.reason === "invalid" ? 422 : 409); + }), + }, + + "/api/studio/courses/:courseId/update-draft": { + POST: course(async (req, _rc, user, courseId) => { + const b = req.headers.get("content-type")?.includes("json") ? await body<{ from?: string }>(req) : {}; + const r = await store.updateDraft(courseId, who(user), b.from === "remote" ? "remote" : "published"); + return json(r, r.ok ? 200 : r.reason === "no-remote" ? 400 : 409); + }), + }, + + "/api/studio/courses/:courseId/conflicts": { + GET: course(async (_req, _rc, user, courseId) => json({ files: await store.conflicts(courseId, user.id) })), + }, + + "/api/studio/courses/:courseId/resolve": { + POST: course(async (req, _rc, user, courseId) => { + const b = await body<{ files?: { path: string; content: string | null }[] }>(req); + if (!Array.isArray(b.files) || !b.files.length) return fail(400, "files are required"); + return json(await store.resolve(courseId, who(user), b.files.map((f) => ({ path: String(f.path), content: f.content === null ? null : String(f.content) })))); + }), + }, + + "/api/studio/courses/:courseId/abort": { + POST: course(async (_req, _rc, user, courseId) => { + await store.abortMerge(courseId, user.id); + return json({ ok: true }); + }), + }, + + "/api/studio/courses/:courseId/remote": { + PUT: course(async (req, _rc, _user, courseId) => { + const b = await body<{ url?: string | null }>(req); + await store.setRemote(courseId, b.url ? String(b.url) : null); + return json({ ok: true, remote: await store.remote(courseId) }); + }), + }, + + "/api/studio/courses/:courseId/sync": { + POST: course(async (_req, _rc, user, courseId) => { + const r = await store.sync(courseId, who(user)); + if (r.ok) { + await afterPublishedChange(courseId); + return json(r); + } + return json(r, r.reason === "no-remote" ? 400 : r.reason === "network" ? 502 : 409); + }), + }, + }; + + if (opts.watch !== false) { + watcher.check().catch((e) => console.error("[studio] watcher", e)); + watcher.start(); + } + + return { name: "studio", routes, store, watcher, draftWriter, stop: () => watcher.stop() }; +} diff --git a/packages/feature-studio/test/acceptance.test.ts b/packages/feature-studio/test/acceptance.test.ts new file mode 100644 index 0000000..80d9505 --- /dev/null +++ b/packages/feature-studio/test/acceptance.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { extGit } from "./helpers"; +import { REPO, setupApp } from "./app-helpers"; + +let env: Awaited>; +afterEach(() => env?.stop()); + +const lesson = (path: string) => `/api/studio/courses/c1/file?path=${encodeURIComponent(path)}`; + +test("acceptance: create a course, add and edit a lesson, publish, a learner sees it", async () => { + env = await setupApp(); + const { call, ctx } = env; + expect((await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" })).status).toBe(201); + expect(ctx.courses.get("c1")?.status).toBe("ok"); + expect(ctx.courses.get("c1")?.root).toBe(join(env.dataDir, "worktrees/c1/published")); + + // add a lesson (new file + manifest entry, one commit) and make the course public + const st = (await call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st.readOnly).toBe(false); + const m = structuredClone(st.manifestRaw); + m.visibility = "public"; + m.modules[0].lessons.push({ id: "second", title: "Second lesson", type: "text", file: "lessons/second.md" }); + let r = await call("ada", "PUT", "/api/studio/courses/c1/files", { + message: "Add second lesson", + manifest: m, manifestBaseHash: st.manifestHash, + files: [{ path: "lessons/second.md", content: "# Second\n\nFirst draft.\n", baseHash: null }], + }); + expect(r.status).toBe(200); + expect(r.json.commit).toBeTruthy(); + + // edit it + const f = (await call("ada", "GET", lesson("lessons/second.md"))).json; + expect(f.content).toContain("First draft."); + r = await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Edit second", files: [{ path: "lessons/second.md", content: "# Second\n\nEdited text.\n", baseHash: f.hash }] }); + expect(r.status).toBe(200); + + // drafts never reach learners + expect(ctx.courses.get("c1")?.lessonIndex.has("second")).toBe(false); + expect((await call(null, "GET", "/api/media/c1/lessons/second.md")).status).toBe(404); + + // publish + r = await call("ada", "POST", "/api/studio/courses/c1/publish"); + expect(r.status).toBe(200); + expect(r.json).toMatchObject({ ok: true, published: true }); + expect(await ctx.courses.readLessonMarkdown("c1", "second")).toBe("# Second\n\nEdited text.\n"); + const media = await call(null, "GET", "/api/media/c1/lessons/second.md"); + expect(media.status).toBe(200); + expect(media.json).toContain("Edited text."); +}); + +test("acceptance: a conflicting external commit is shown, not overwritten", async () => { + env = await setupApp(); + const { call, ctx, feature } = env; + await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "Course One" }); + const f = (await call("ada", "GET", lesson("lessons/welcome.md"))).json; + await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "Studio edit", files: [{ path: "lessons/welcome.md", content: "# Studio\n", baseHash: f.hash }] }); + + const pub = join(env.dataDir, "worktrees/c1/published"); + writeFileSync(join(pub, "lessons/welcome.md"), "# External\n"); + await extGit(pub, "commit", "-qam", "Edited in VS Code"); + expect(await feature.watcher.check()).toEqual(["c1"]); + expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); + + const st = (await call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st.status.behind).toBe(1); + + const r = await call("ada", "POST", "/api/studio/courses/c1/publish"); + expect(r.status).toBe(409); + expect(r.json).toEqual({ ok: false, reason: "conflict", conflicts: ["lessons/welcome.md"] }); + expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# External\n"); + + const u = await call("ada", "POST", "/api/studio/courses/c1/update-draft", { from: "published" }); + expect(u.status).toBe(409); + const c = (await call("ada", "GET", "/api/studio/courses/c1/conflicts")).json; + expect(c.files[0]).toMatchObject({ path: "lessons/welcome.md", ours: "# Studio\n", theirs: "# External\n" }); + expect((await call("ada", "POST", "/api/studio/courses/c1/resolve", { files: [{ path: "lessons/welcome.md", content: "# Studio + External\n" }] })).json.remaining).toEqual([]); + expect((await call("ada", "POST", "/api/studio/courses/c1/publish")).status).toBe(200); + expect(await ctx.courses.readLessonMarkdown("c1", "welcome")).toBe("# Studio + External\n"); +}); + +test("overlay and other outside courses are read-only", async () => { + env = await setupApp([resolve(REPO, "fixtures/courses")]); + const list = (await env.call("ada", "GET", "/api/studio/courses")).json; + expect(list.courses).toContainEqual(expect.objectContaining({ id: "sample", managed: false, message: "This course is managed outside STCourses" })); + const one = await env.call("ada", "GET", "/api/studio/courses/sample"); + expect(one.json).toMatchObject({ readOnly: true, message: "This course is managed outside STCourses" }); + const w = await env.call("ada", "PUT", "/api/studio/courses/sample/files", { message: "x", files: [{ path: "a.md", content: "x" }] }); + expect(w.status).toBe(403); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "sample", title: "Dup" })).status).toBe(409); +}); diff --git a/packages/feature-studio/test/app-helpers.ts b/packages/feature-studio/test/app-helpers.ts new file mode 100644 index 0000000..0c46da1 --- /dev/null +++ b/packages/feature-studio/test/app-helpers.ts @@ -0,0 +1,43 @@ +import { resolve } from "node:path"; +import type { AuthUser, ServerContext } from "@stc/contracts"; +import { createApp } from "@stc/core"; +import { createCourseRepository } from "@stc/course-format"; +import { connect, migrate } from "@stc/db"; +import { studioFeature } from "../src/server/feature"; +import { studioCourseDirs } from "../src/server/paths"; +import { tempDir } from "./helpers"; + +export const REPO = resolve(import.meta.dir, "../../.."); +export const users: Record = { + ada: { id: "u-ada", email: "ada@example.org", name: "Ada Lovelace", roles: ["author"], groups: [] }, + owner: { id: "u-own", email: "own@example.org", name: "Olive Owner", roles: ["owner"], groups: [] }, + lea: { id: "u-lea", email: "lea@example.org", name: "Lea Learner", roles: ["learner"], groups: [] }, +}; + +/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */ +export async function setupApp(extraCourseDirs: string[] = []) { + const dataDir = tempDir(); + const db = await connect("sqlite://:memory:"); + await migrate(db); + const config = { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x" }; + const courses = await createCourseRepository(studioCourseDirs(config)); + const ctx = { + config, db, courses, jobs: null as never, + auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null }, + } satisfies ServerContext; + const feature = studioFeature(ctx, { watch: false }); + const app = createApp(ctx, [feature]); + const call = async (as: string | null, method: string, path: string, body?: unknown) => { + const headers: Record = {}; + if (as) headers["x-user"] = as; + let payload: BodyInit | undefined; + if (body instanceof FormData) payload = body; + else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); } + const r = await app.fetch(new Request(`http://x${path}`, { method, headers, body: payload })); + const text = await r.text(); + let json: any = null; + try { json = JSON.parse(text); } catch { json = text; } + return { status: r.status, json }; + }; + return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); } }; +} diff --git a/packages/feature-studio/test/feature.test.ts b/packages/feature-studio/test/feature.test.ts new file mode 100644 index 0000000..2ed53a0 --- /dev/null +++ b/packages/feature-studio/test/feature.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from "bun:test"; +import { setupApp } from "./app-helpers"; + +let env: Awaited>; +afterEach(() => env?.stop()); + +test("anonymous gets 401, learners 403", async () => { + env = await setupApp(); + expect((await env.call(null, "GET", "/api/studio/courses")).status).toBe(401); + expect((await env.call("lea", "GET", "/api/studio/courses")).status).toBe(403); + expect((await env.call("lea", "POST", "/api/studio/courses", { id: "x", title: "X" })).status).toBe(403); +}); + +test("creating a course records the creator in course_authors; a per-course author without the role can edit it", async () => { + env = await setupApp(); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201); + const rows = await env.db.sql`SELECT course_id, user_id FROM course_authors`; + expect(rows.map((r: any) => [r.course_id, r.user_id])).toEqual([["c1", "u-ada"]]); + await env.db.sql`INSERT INTO course_authors (course_id, user_id) VALUES (${"c1"}, ${"u-lea"})`; + const list = (await env.call("lea", "GET", "/api/studio/courses")).json; + expect(list.courses.map((c: any) => c.id)).toEqual(["c1"]); + expect((await env.call("lea", "GET", "/api/studio/courses/c1")).status).toBe(200); + expect((await env.call("lea", "POST", "/api/studio/courses", { id: "c2", title: "C2" })).status).toBe(403); +}); + +test("bad input: invalid id, missing title, bad JSON, bad paths, stale base hash", async () => { + env = await setupApp(); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "Bad Id", title: "x" })).status).toBe(400); + expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1" })).status).toBe(400); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + expect((await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "../x", content: "x" }] })).status).toBe(400); + const r = await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", files: [{ path: "lessons/welcome.md", content: "x", baseHash: "stale" }] }); + expect(r.status).toBe(409); + expect(r.json.conflicts[0].path).toBe("lessons/welcome.md"); + expect((await env.call("ada", "GET", "/api/studio/courses/nope")).status).toBe(404); + expect((await env.call("ada", "GET", "/api/studio/courses/c1/file?path=../../etc/passwd")).status).toBe(404); +}); + +test("manifest validation errors come from @stc/course-format", async () => { + env = await setupApp(); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + const st = (await env.call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st.manifestErrors).toEqual([]); + const bad = { ...st.manifestRaw, accent: "orange", modules: [{ id: "m", title: "M", lessons: [{ id: "x", title: "X", type: "text" }] }] }; + const v = (await env.call("ada", "POST", "/api/studio/courses/c1/validate", { manifest: bad })).json; + expect(v.errors).toEqual(expect.arrayContaining([expect.stringContaining("accent"), expect.stringContaining("file is required")])); + // saving an invalid draft is allowed (it is a draft) but publishing is refused with diagnostics + await env.call("ada", "PUT", "/api/studio/courses/c1/files", { message: "wip", manifest: bad, manifestBaseHash: st.manifestHash, files: [] }); + const st2 = (await env.call("ada", "GET", "/api/studio/courses/c1")).json; + expect(st2.manifestErrors.length).toBeGreaterThan(0); + const p = await env.call("ada", "POST", "/api/studio/courses/c1/publish"); + expect(p.status).toBe(422); + expect(p.json.reason).toBe("invalid"); +}); + +test("upload an attachment and serve it back from the draft", async () => { + env = await setupApp(); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + const fd = new FormData(); + fd.set("file", new File([new Uint8Array([37, 80, 68, 70])], "Æble slides.pdf", { type: "application/pdf" })); + fd.set("dir", "media"); + const r = await env.call("ada", "POST", "/api/studio/courses/c1/upload", fd); + expect(r.status).toBe(200); + expect(r.json.path).toBe("media/Æble slides.pdf"); + const raw = await env.app.fetch(new Request(`http://x/api/studio/courses/c1/raw/${encodeURIComponent("media/Æble slides.pdf")}`, { headers: { "x-user": "ada" } })); + expect(raw.status).toBe(200); + expect(raw.headers.get("content-type")).toBe("application/pdf"); + expect((await env.call("lea", "GET", `/api/studio/courses/c1/raw/media/x`)).status).toBe(403); +}); + +test("remote config and sync without a remote", async () => { + env = await setupApp(); + await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" }); + expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(400); + expect((await env.call("ada", "PUT", "/api/studio/courses/c1/remote", { url: "/tmp/nowhere.git" })).status).toBe(200); + expect((await env.call("ada", "GET", "/api/studio/courses/c1")).json.status.remote).toBe("/tmp/nowhere.git"); + expect((await env.call("ada", "POST", "/api/studio/courses/c1/sync")).status).toBe(502); +}); From 0992df7257d7c2466635a0e8fffce9a9da5cf2bd Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:26:18 +0100 Subject: [PATCH 6/9] feat(studio): studio web UI (course list, manifest, lessons with live preview, decks, attachments, exercises, conflicts, sync) and dev entry Co-Authored-By: Claude Opus 5.5 --- apps/web/src/features.ts | 1 + packages/feature-studio/dev/serve.ts | 35 ++++ .../feature-studio/src/ui/CourseStudio.tsx | 163 +++++++++++++++++ packages/feature-studio/src/ui/StudioHome.tsx | 119 +++++++++++++ packages/feature-studio/src/ui/api.ts | 58 ++++++ .../src/ui/editors/CourseSettings.tsx | 85 +++++++++ .../src/ui/editors/GitPanels.tsx | 123 +++++++++++++ .../src/ui/editors/LessonEditor.tsx | 168 ++++++++++++++++++ .../feature-studio/src/ui/editors/Media.tsx | 87 +++++++++ .../feature-studio/src/ui/editors/common.tsx | 131 ++++++++++++++ packages/feature-studio/src/ui/markdown.ts | 129 ++++++++++++++ packages/feature-studio/src/ui/preview.tsx | 28 +++ packages/feature-studio/src/ui/studio.css | 98 ++++++++++ packages/feature-studio/src/ui/workspace.ts | 110 ++++++++++++ packages/feature-studio/src/web.ts | 12 ++ packages/feature-studio/test/app-helpers.ts | 17 +- packages/feature-studio/test/feature.test.ts | 6 +- packages/feature-studio/test/markdown.test.ts | 38 ++++ 18 files changed, 1403 insertions(+), 5 deletions(-) create mode 100644 packages/feature-studio/dev/serve.ts create mode 100644 packages/feature-studio/src/ui/CourseStudio.tsx create mode 100644 packages/feature-studio/src/ui/StudioHome.tsx create mode 100644 packages/feature-studio/src/ui/api.ts create mode 100644 packages/feature-studio/src/ui/editors/CourseSettings.tsx create mode 100644 packages/feature-studio/src/ui/editors/GitPanels.tsx create mode 100644 packages/feature-studio/src/ui/editors/LessonEditor.tsx create mode 100644 packages/feature-studio/src/ui/editors/Media.tsx create mode 100644 packages/feature-studio/src/ui/editors/common.tsx create mode 100644 packages/feature-studio/src/ui/markdown.ts create mode 100644 packages/feature-studio/src/ui/preview.tsx create mode 100644 packages/feature-studio/src/ui/studio.css create mode 100644 packages/feature-studio/src/ui/workspace.ts create mode 100644 packages/feature-studio/src/web.ts create mode 100644 packages/feature-studio/test/markdown.test.ts diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts index 8b077c9..cc95268 100644 --- a/apps/web/src/features.ts +++ b/apps/web/src/features.ts @@ -2,3 +2,4 @@ import type { WebFeature } from "@stc/contracts"; // Phase 1 packages append one import line each and push into this list. export const webFeatures: WebFeature[] = []; +import { studioWeb } from "../../../packages/feature-studio/src/web"; webFeatures.push(studioWeb); diff --git a/packages/feature-studio/dev/serve.ts b/packages/feature-studio/dev/serve.ts new file mode 100644 index 0000000..ef20f0e --- /dev/null +++ b/packages/feature-studio/dev/serve.ts @@ -0,0 +1,35 @@ +/** + * Dev server for the studio until Phase 2 lets apps/server compose ctx-dependent features + * (docs/contract-requests/studio.md). Mirrors startServer, adds the studio feature and a dev-only user. + * + * STC_PORT=3463 STC_DATA_DIR=/tmp/stc-studio-data STC_DEV_USER=author bun packages/feature-studio/dev/serve.ts + * + * STC_DEV_USER: "author" | "owner" | "learner" | unset (anonymous). Never use this entry in production. + */ +import type { AuthUser, ServerContext } from "@stc/contracts"; +import { createApp, createJobQueue, loadConfig } from "@stc/core"; +import { createCourseRepository } from "@stc/course-format"; +import { connect, migrate } from "@stc/db"; +import indexHtml from "../../../apps/web/index.html"; +import { studioCourseDirs, studioFeature } from "../src"; + +const config = loadConfig(); +if (process.env.NODE_ENV === "production") throw new Error("dev/serve.ts is for development only"); +const role = process.env.STC_DEV_USER as AuthUser["roles"][number] | undefined; +const devUser: AuthUser | null = role ? { id: `dev-${role}`, name: `Dev ${role[0]!.toUpperCase()}${role.slice(1)}`, email: `${role}@dev.invalid`, roles: [role], groups: [] } : null; + +const db = await connect(config.databaseUrl); +await migrate(db); +const courses = await createCourseRepository(studioCourseDirs(config)); +const jobs = createJobQueue(db, {}); +const ctx: ServerContext = { config, db, courses, jobs, auth: { userFromRequest: async () => devUser } }; +const studio = studioFeature(ctx); +const app = createApp(ctx, [studio]); +jobs.start(); +const server = Bun.serve({ + port: config.port, + routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml }, + fetch: (req) => app.fetch(req), + development: { hmr: false, console: true }, +}); +console.log(`STCourses studio dev server on ${server.url} as ${devUser ? devUser.name : "anonymous"}`); diff --git a/packages/feature-studio/src/ui/CourseStudio.tsx b/packages/feature-studio/src/ui/CourseStudio.tsx new file mode 100644 index 0000000..c5b1cbb --- /dev/null +++ b/packages/feature-studio/src/ui/CourseStudio.tsx @@ -0,0 +1,163 @@ +import { useState, type FormEvent } from "react"; +import { Link, useParams, useSearchParams } from "react-router-dom"; +import { CourseCover, Pill } from "@stc/ui"; +import { api, coursePath, uniqueId, type Diagnostic, type ManifestLesson } from "./api"; +import { AccessMessage } from "./StudioHome"; +import { clone, useWorkspace, type Workspace } from "./workspace"; +import { CourseSettings } from "./editors/CourseSettings"; +import { LessonEditor } from "./editors/LessonEditor"; +import { AttachmentsEditor, DecksEditor } from "./editors/Media"; +import { ConflictView, RawManifest, SyncPanel } from "./editors/GitPanels"; +import { Field } from "./editors/common"; +import "./studio.css"; + +type Notice = { kind: "ok" | "warn" | "bad"; text: string; files?: string[]; diagnostics?: Diagnostic[]; offerUpdate?: boolean }; + +export default function CourseStudio() { + const { courseId = "" } = useParams(); + const { ws, readOnly, error } = useWorkspace(courseId); + + if (error) return

Studio

{error.status === 404 ?
No such course.
: }
; + if (readOnly) { + return ( +
+

Studio

+

{readOnly.title}

+
{readOnly.message}. It is read-only here; edit it in its own repository at {readOnly.root}.
+
+ ); + } + if (!ws) return

Loading…

; + return ; +} + +function Workbench({ ws }: { ws: Workspace }) { + const [params, setParams] = useSearchParams(); + const view = ws.state.status.merging ? "conflicts" : params.get("view") ?? "settings"; + const lessonId = params.get("lesson"); + const go = (v: string, lesson?: string) => { + if (ws.docDirty && !window.confirm("You have unsaved changes to the course structure. Leave them unsaved for now?")) return; + setParams(lesson ? { view: v, lesson } : { view: v }); + }; + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(false); + const st = ws.state.status; + const hasErrors = ws.state.manifestErrors.length > 0 || ws.state.diagnostics.some((d) => d.level === "error"); + + const publish = async () => { + setBusy(true); + const { status, data } = await api("POST", coursePath(ws.courseId, "/publish")); + setBusy(false); + if (status === 200) setNotice(data.published ? { kind: "ok", text: "Published. Learners now see this version." } : { kind: "ok", text: "Nothing new to publish." }); + else if (data?.reason === "conflict") setNotice({ kind: "warn", text: "Not published: the published version was changed outside your draft, in the same places you edited. Nothing was overwritten. Update your draft, resolve the conflicts, then publish.", files: data.conflicts, offerUpdate: true }); + else if (data?.reason === "dirty") setNotice({ kind: "warn", text: "Not published: someone has unsaved edits in the published folder on the server. Ask them to commit or discard them first.", files: data.files }); + else if (data?.reason === "invalid") setNotice({ kind: "bad", text: "Not published: the draft has errors.", diagnostics: data.diagnostics }); + else setNotice({ kind: "bad", text: data?.error ?? `Publish failed (${status})` }); + await ws.reload(); + }; + const updateDraft = async () => { + setBusy(true); + const { status, data } = await api("POST", coursePath(ws.courseId, "/update-draft"), { from: "published" }); + setBusy(false); + setNotice(status === 200 ? { kind: "ok", text: "Your draft now includes the latest published changes." } : status === 409 && data?.reason === "conflict" ? null : { kind: "bad", text: data?.message ?? data?.error }); + await ws.reload(); + }; + + return ( +
+ + +
+
+
+ Editing your draft {st.branch} + {" · "}{st.ahead ? {st.ahead} unpublished {st.ahead === 1 ? "change" : "changes"} : "nothing unpublished"} + {st.behind ? <> · {st.behind} new on published since your draft : null} + {ws.docDirty ? <> · unsaved structure changes : null} +
+ {st.behind && !st.merging ? Update draft : null} + Publish +
+ + {notice ? ( +
+ {notice.text} + {notice.files?.length ?
    {notice.files.map((f) =>
  • {f}
  • )}
: null} + {notice.diagnostics?.length ?
    {notice.diagnostics.map((d, i) =>
  • {d.lessonId ? `${d.lessonId}: ` : ""}{d.message}
  • )}
: null} + {notice.offerUpdate ?
Update draft and resolve
: null} +
+ ) : null} + + {view === "conflicts" ? + : view === "lesson" && lessonId ? setParams({ view: "settings" })} /> + : view === "decks" ? + : view === "attachments" ? + : view === "manifest" ? + : view === "sync" ? + : } +
+
+ ); +} + +/** Adds a lesson: manifest entry plus its Markdown file, saved in one commit. */ +function AddLesson({ ws, moduleId, onAdded }: { ws: Workspace; moduleId: string; onAdded: (id: string) => void }) { + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [type, setType] = useState("text"); + const [err, setErr] = useState(null); + if (!open) return ; + const submit = async (e: FormEvent) => { + e.preventDefault(); + const taken = new Set(ws.doc.modules.flatMap((m) => m.lessons.map((l) => l.id))); + const id = uniqueId(title, taken); + const doc = clone(ws.doc); + const lesson: ManifestLesson = type === "video" + ? { id, title: title.trim(), type, file: `media/${id}.mp4`, transcript: `lessons/${id}.md` } + : { id, title: title.trim(), type, file: `lessons/${id}.md` }; + doc.modules.find((m) => m.id === moduleId)!.lessons.push(lesson); + const r = await ws.save({ manifest: doc, files: [{ path: `lessons/${id}.md`, content: `# ${title.trim()}\n\n`, baseHash: null }], message: `Add lesson ${id}` }); + if (!r.ok) return setErr(r.error ?? "A file with that name already exists"); + setOpen(false); setTitle(""); + onAdded(id); + }; + return ( +
+ setTitle(e.target.value)} /> + + + + {err ? {err} : null} +
Add setOpen(false)}>Cancel
+
+ ); +} diff --git a/packages/feature-studio/src/ui/StudioHome.tsx b/packages/feature-studio/src/ui/StudioHome.tsx new file mode 100644 index 0000000..0a68b8c --- /dev/null +++ b/packages/feature-studio/src/ui/StudioHome.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { CourseCover, Pill } from "@stc/ui"; +import { api, slug } from "./api"; +import "./studio.css"; + +interface CourseRow { + id: string; title: string; pitch: string | null; accent: string | null; managed: boolean; publishedStatus: "ok" | "error"; + ahead?: number; behind?: number; merging?: boolean; remote?: string | null; message?: string; +} + +export function AccessMessage({ status, error }: { status: number; error?: string }) { + if (status === 401) return
Sign in to use the studio. The studio is for course authors and the owner.
; + if (status === 403) return
The studio is for authors. Ask the owner for author access.
; + return
Something went wrong. {error}
; +} + +export default function StudioHome() { + const [rows, setRows] = useState(null); + const [canCreate, setCanCreate] = useState(false); + const [err, setErr] = useState<{ status: number; error?: string } | null>(null); + const [title, setTitle] = useState(""); + const [id, setId] = useState(""); + const [idTouched, setIdTouched] = useState(false); + const [createErr, setCreateErr] = useState(null); + const [busy, setBusy] = useState(false); + const nav = useNavigate(); + + useEffect(() => { + api<{ courses: CourseRow[]; canCreate: boolean; error?: string }>("GET", "/api/studio/courses").then(({ status, data }) => { + if (status !== 200) return setErr({ status, error: data?.error }); + setRows(data.courses); + setCanCreate(data.canCreate); + }); + }, []); + + const create = async (e: FormEvent) => { + e.preventDefault(); + setBusy(true); + setCreateErr(null); + const { status, data } = await api("POST", "/api/studio/courses", { id, title }); + setBusy(false); + if (status === 201) nav(`/studio/${encodeURIComponent(data.id)}`); + else setCreateErr(data?.error ?? `Could not create the course (${status})`); + }; + + const managed = rows?.filter((r) => r.managed) ?? []; + const outside = rows?.filter((r) => !r.managed) ?? []; + + return ( +
+

Studio

+

Your courses

+

Edit on your own draft, save as you go, and publish when it is ready. Learners only see what you publish.

+ {err ? : null} + + {canCreate ? ( +
+

New course

+
+ + + Create course +
+ {createErr ?

{createErr}

: null} +
+ ) : null} + + {rows ? ( +
+

Studio courses

+ {managed.length === 0 ?

No studio courses yet.

: ( +
    + {managed.map((c) => ( +
  • + +
    + {c.title} + {c.id}{c.pitch ? ` · ${c.pitch}` : ""} +
    + {c.merging ? Resolving conflicts : null} + {c.ahead ? {c.ahead} unpublished : null} + {c.behind ? {c.behind} new on published : null} + {c.remote ? Remote : null} + {c.publishedStatus === "error" ? Published version has errors : null} +
  • + ))} +
+ )} +
+ ) : null} + + {outside.length ? ( +
+

Managed outside STCourses

+

These courses come from repositories the platform does not own. Edit them in their own repository.

+
    + {outside.map((c) => ( +
  • + +
    + {c.title} + {c.id} · {c.message} +
    + Read-only +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/packages/feature-studio/src/ui/api.ts b/packages/feature-studio/src/ui/api.ts new file mode 100644 index 0000000..8efb89f --- /dev/null +++ b/packages/feature-studio/src/ui/api.ts @@ -0,0 +1,58 @@ +// Browser client for /api/studio. Types mirror the server responses (kept here so no server module enters the bundle). + +export interface ApiError { status: number; body: any } + +export async function api(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> { + const init: RequestInit = { method, headers: {} }; + if (body instanceof FormData) init.body = body; + else if (body !== undefined) { + (init.headers as Record)["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + const r = await fetch(path, init); + const text = await r.text(); + let data: any = null; + try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; } + return { status: r.status, data }; +} + +export const coursePath = (id: string, rest = "") => `/api/studio/courses/${encodeURIComponent(id)}${rest}`; +export const rawUrl = (id: string, rel: string) => coursePath(id, `/raw/${rel.split("/").map(encodeURIComponent).join("/")}`); + +export interface ManifestLesson { + id: string; title: string; type: "text" | "video" | "exercise"; file?: string; anchor?: string; duration?: number; transcript?: string; + hints?: { file: string; match: string }; + browser?: { language: "js" | "ts"; starter: string; tests: string; solution?: string }; + localCommand?: string | Record; + [k: string]: unknown; +} +export interface ManifestModule { id: string; title: string; lessons: ManifestLesson[]; [k: string]: unknown } +export interface ManifestDeck { id: string; file: string; title?: string; attachTo: string } +export interface ManifestAttachment { file: string; title?: string; module?: string } +export interface ManifestDoc { + schema: 1; id: string; title: string; pitch?: string; authors?: string[]; cover?: string; accent?: string; collections?: string[]; + visibility?: "public" | "signed-in" | "restricted"; gating?: "none" | "sequential"; mentor?: string; + decks?: ManifestDeck[]; attachments?: ManifestAttachment[]; modules: ManifestModule[]; [k: string]: unknown; +} + +export interface DraftStatus { + branch: string; publishedBranch: string; draftHead: string; publishedHead: string; ahead: number; behind: number; + merging: boolean; conflicts: string[]; remote: string | null; publishedDirty: string[]; +} +export interface Diagnostic { level: "error" | "warning"; lessonId?: string; path?: string; message: string } + +export interface CourseState { + readOnly: false; course: { id: string; title: string }; status: DraftStatus; manifestText: string; manifestHash: string | null; + manifestRaw: ManifestDoc | null; manifestErrors: string[]; diagnostics: Diagnostic[]; files: string[]; +} +export interface ReadOnlyState { readOnly: true; message: string; course: { id: string; title: string; root: string }; diagnostics: Diagnostic[] } + +export interface FileWrite { path: string; content?: string; baseHash?: string | null; delete?: boolean } +export interface FileConflict { path: string; current: string | null; currentHash: string | null } + +export const slug = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"; +export const uniqueId = (base: string, taken: Set) => { + let id = slug(base); + for (let n = 2; taken.has(id); n++) id = `${slug(base)}-${n}`; + return id; +}; diff --git a/packages/feature-studio/src/ui/editors/CourseSettings.tsx b/packages/feature-studio/src/ui/editors/CourseSettings.tsx new file mode 100644 index 0000000..6aae94b --- /dev/null +++ b/packages/feature-studio/src/ui/editors/CourseSettings.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { Pill } from "@stc/ui"; +import { uniqueId } from "../api"; +import type { Workspace } from "../workspace"; +import { DiagnosticsList, Errors, Field, FilePicker, SaveBar, isImage, isMarkdown, useManifestValidation } from "./common"; + +const list = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean); + +/** Manifest fields editor (structured), with live validation from @stc/course-format. */ +export function CourseSettings({ ws }: { ws: Workspace }) { + const { doc, setDoc, state } = ws; + const errors = useManifestValidation(ws, { manifest: doc }); + const [newModule, setNewModule] = useState(""); + const set = (k: K, v: (typeof doc)[K] | undefined) => setDoc((d) => { + if (v === undefined || v === "") delete d[k]; else d[k] = v; + }); + + return ( +
+

Course settings

+

{doc.title || "Untitled course"}

+

These fields live in stcourse.yaml. Changes go to your draft when you save.

+ +
+ set("title", e.target.value)} /> + +