diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts index 3b82a55..fcea902 100644 --- a/apps/web/src/features.ts +++ b/apps/web/src/features.ts @@ -1,6 +1,7 @@ import type { WebFeature } from "@stc/contracts"; import { learnerWeb } from "@stc/feature-learner/web"; import { importWeb } from "../../../packages/feature-import/src/web"; +import { studioWeb } from "../../../packages/feature-studio/src/web"; // Phase 1 packages append one import line each and push into this list. -export const webFeatures: WebFeature[] = [learnerWeb, importWeb]; +export const webFeatures: WebFeature[] = [learnerWeb, importWeb, studioWeb]; diff --git a/docs/contract-requests/studio.md b/docs/contract-requests/studio.md new file mode 100644 index 0000000..6fb10c8 --- /dev/null +++ b/docs/contract-requests/studio.md @@ -0,0 +1,89 @@ +# Contract requests from WP-C (studio) + +Each item says what is missing, what the studio does locally for now, and what Phase 2 should change. + +## 1. Features that need the ServerContext (apps/server) + +`startServer(config, features: Feature[])` builds the `ServerContext` internally, so a feature factory such as `studioFeature(ctx)` cannot be put into the feature list. The studio therefore does **not** touch `apps/server/src/main.ts`. Locally it runs through `packages/feature-studio/dev/serve.ts`, which mirrors `startServer`. + +Proposal: +- `startServer(config, features: (Feature | ((ctx: ServerContext) => Feature))[])`, and main.ts adds `studioFeature`. +- An optional `stop?(): void | Promise` on `Feature`, called from `stop()`. The studio uses it to stop its published-worktree watcher (an unref'd interval, so it never blocks exit). + +## 2. Learners must read studio courses from the published worktree (config / main.ts) + +Layout after the review (controller ruling on I4): +- `/studio/repos/.git`: studio bare repos. They contain no `stcourse.yaml`, so discovery never loads them. +- `/worktrees//published`: the published branch, which is what learners read. +- `/worktrees//drafts/`: drafts. They sit at depth 3, so the depth-2 discovery never loads them. +- `/courses/`: the importer's plain folders (WP-B), untouched by the studio. + +`createCourseRepository(config.courseDirs)` scans neither `/worktrees` nor `/courses`. The package exports `studioCourseDirs(config)`, which returns the configured dirs plus `/courses` and `/worktrees`. Tests and the dev entry use it. + +Proposal: main.ts calls `createCourseRepository(studioCourseDirs(config))`, or `loadConfig` appends both dirs. + +Imported courses (`source.kind` other than `authored`, with their root under `/courses`) are read-only in the studio for v1, with the message "Imported course: read-only in the studio for now". There is a `TODO(studio)` in `feature.ts` (`readOnlyMessage`) for converting one into a studio-managed repo. The studio refuses to create a course whose id already exists as `/courses/`. WP-B should likewise refuse an import id that `/studio/repos/.git` already uses. + +## 3. Live preview: swap in the learner `LessonView` + +The studio preview sits behind an adapter in `@stc/feature-studio/web`: + +```ts +export interface LessonPreviewProps { + courseId: string; + markdown: string; + lesson?: { id: string; title: string; type: string; anchor?: string } | null; + resolveUrl: (rel: string) => string; // course-relative path -> URL of the draft file +} +export function setLessonPreview(c: React.ComponentType): void; +``` + +v1 uses a small, safe built-in Markdown renderer. Proposal: `@stc/feature-learner` exports a `LessonView` that can render from a Markdown string plus a URL resolver, without fetching the published course. Phase 2 then calls `setLessonPreview(adapter)` in `packages/feature-studio/src/web.ts`, so drafts are previewed with the real renderer. + +## 4. Web entry points (tsconfig paths) + +Phase 1 packages have server code (`Bun.$`, `node:fs`) that must not enter the browser bundle, so the studio has a separate browser entry, `src/web.ts`. The `@stc/*` → `packages/*/src` path maps `@stc/feature-studio/web` to a directory that does not exist, and the workspace has no `node_modules/@stc` links. For now, `apps/web/src/features.ts` imports it with a relative path. + +Proposal: add `"@stc/*/web": ["packages/*/src/web.ts"]` to the root tsconfig `paths`, and switch the line to `import { studioWeb } from "@stc/feature-studio/web"`. + +## 5. Role-aware navigation (contracts/web.ts) + +`WebRoute.nav` cannot be limited by role, so the header "Studio" link shows for everyone. The page itself tells learners and anonymous users that the studio is for authors, and the API returns 401/403. + +Proposal: add `nav.roles?: Role[]`, and have the shell show the link only to matching users. This needs the learner package's session in the web shell. + +## 6. Draft API for WP-D (AI) + +Exported from `@stc/feature-studio`: + +```ts +export interface DraftWriter { + writeDraftFiles(courseId: string, authorId: string, files: { path: string; content: string }[], message: string): Promise<{ branch: string; commit: string }>; +} +export function createDraftWriter(ctx: { config: Pick }, opts?: { + identity?: (authorId: string) => Promise<{ name: string; email: string } | null> | { name: string; email: string } | null; +}): DraftWriter; +``` + +- It writes and commits on `draft/` and never publishes. +- Without `identity`, commits are authored as ` `. WP-D should pass the signed-in user's name and email. +- It throws for courses not managed by the studio, and for paths that are absolute, contain `..` or touch `.git`. +- `studioFeature(ctx).draftWriter` is the same API, already bound to `ctx`. + +## 7. Authors and roles (decision to confirm) + +Studio access goes to: +- `owner` and `author` roles: all studio courses, and they can create courses; +- users with a `course_authors` row: that course only. + +Creating a course inserts the creator into `course_authors`. No new tables are added, so `migrationsDirs` is `[]`. + +## 8. External pushes straight into the bare repo (no action needed) + +The platform reloads when the published worktree's HEAD changes: an external commit in that worktree, a studio publish, or a Sync. A direct `git push` into `/courses/` is refused by git itself ("branch is currently checked out", checked with git 2.47), so the published files cannot silently fall behind `main`. Outside tools should commit in the published worktree, or push to a configured remote that the author then Syncs. + +## 9. Core media route serves SVG/HTML inline (core, Phase 2) + +The studio's draft `raw` route now sends `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`. The learner-facing `/api/media/:courseId/*` in `packages/core` serves author-supplied SVG inline from the app's origin and sets neither header. + +Proposal: add the same two headers in `serveMedia`. 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. 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/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..6ced9bb --- /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, IMPORTED_MESSAGE, uploadMaxBytes, UPLOAD_EXTENSIONS, type StudioFeature, type StudioFeatureOptions } from "./server/feature"; +export { createDraftWriter, type DraftWriter, type DraftWriterOptions } from "./server/draft-writer"; +export { createStudioStore, contentHash, isAllowedRemote, ownerRequiredFor, type StudioStore, type Identity, type DraftStatus, type PublishResult, type SyncResult } from "./server/store"; +export { createPublishedWatcher, type PublishedWatcher } from "./server/watch"; +export { studioCourseDirs, studioWorktreesDir, studioReposDir } 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/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/feature.ts b/packages/feature-studio/src/server/feature.ts new file mode 100644 index 0000000..e691487 --- /dev/null +++ b/packages/feature-studio/src/server/feature.ts @@ -0,0 +1,359 @@ +import { existsSync, realpathSync } from "node:fs"; +import { basename, extname, 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, ownerRequiredFor, StudioError, type FileWrite, type StudioStore } from "./store"; +import { createPublishedWatcher, type PublishedWatcher } from "./watch"; + +export const OUTSIDE_MESSAGE = "This course is managed outside STCourses"; +export const IMPORTED_MESSAGE = "Imported course: read-only in the studio for now"; + +/** Upload cap in bytes: STC_UPLOAD_MAX_MB, default 100 (review I2). */ +export function uploadMaxBytes(env: Record = process.env): number { + const mb = Number(env.STC_UPLOAD_MAX_MB ?? "100"); + return (Number.isFinite(mb) && mb > 0 ? mb : 100) * 1024 * 1024; +} + +/** Server-side allowlist by extension for uploads: text, documents, images, media, archives and code. */ +export const UPLOAD_EXTENSIONS = new Set([ + "md", "markdown", "txt", "pdf", "png", "jpg", "jpeg", "gif", "svg", "webp", "mp4", "webm", "mp3", "vtt", "srt", "zip", + "js", "mjs", "cjs", "jsx", "ts", "tsx", "json", "yaml", "yml", "py", "sh", "css", "html", "csv", "toml", "sql", + "scala", "sc", "fs", "fsx", "fsproj", "rs", "go", "java", "kt", "c", "h", "cpp", "hpp", "cs", "rb", "php", "swift", "hs", "ex", "exs", "dockerfile", +]); + +/** Reads at most `max` bytes of the body; null when it is larger (checked while streaming, not after buffering). */ +async function readCapped(req: Request, max: number): Promise | null> { + const reader = req.body?.getReader(); + if (!reader) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > max) { await reader.cancel(); return null; } + chunks.push(value); + } + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { out.set(c, off); off += c.byteLength; } + return out; +} + +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`, owner: u.roles.includes("owner") }); + const importedRoot = (() => { const d = resolve(ctx.config.dataDir, "courses"); return existsSync(d) ? realpathSync(d) : d; })(); + /** Why a course that is not studio-managed is read-only here. */ + const readOnlyMessage = (c: LoadedCourse): string => { + const kind = c.manifest?.source?.kind; + const underImports = c.root === importedRoot || c.root.startsWith(importedRoot + sep); + if (kind && kind !== "authored" && underImports) { + // TODO(studio): convert imported course to a studio-managed repo (git init into /studio/repos/.git + // plus a published worktree), so authors can edit it and the owner-only visibility rule applies on publish. + return IMPORTED_MESSAGE; + } + return OUTSIDE_MESSAGE; + }; + + 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)) { + const c = ctx.courses.get(courseId); + return c ? fail(403, readOnlyMessage(c)) : 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.summary(id, user.id).catch(() => null); // read-only: no worktree is created here + 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: readOnlyMessage(c) }); + } + } + 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: readOnlyMessage(c), 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" && !user.roles.includes("owner")) { + // Early feedback only; publish, sync and resolve enforce the same rule on the merged result. + const needOwner = ownerRequiredFor(await store.publishedManifest(courseId), mf.content); + if (needOwner) return fail(403, needOwner); + } + 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) => { + const max = uploadMaxBytes(); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (declared > max) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`); + const bytes = await readCapped(req, max); + if (!bytes) return fail(413, `file too large (limit ${Math.round(max / 1048576)} MB)`); + let form: FormData; + try { form = await new Response(bytes, { headers: { "content-type": req.headers.get("content-type") ?? "" } }).formData(); } catch { return fail(400, "expected multipart form data"); } + const file = form.get("file"); + if (!(file instanceof Blob)) return fail(400, "file is required"); + 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); + if (path === MANIFEST) return fail(400, "edit stcourse.yaml in the manifest editor, not by upload"); + const ext = (extname(path).slice(1) || basename(path)).toLowerCase(); + if (!UPLOAD_EXTENSIONS.has(ext)) return fail(415, `file type .${ext} is not accepted`); + const overwrite = String(form.get("overwrite") ?? "") === "true"; + const message = String(form.get("message") ?? "") || `Upload ${path}`; + // baseHash null = the file must not exist yet; replacing needs an explicit overwrite=true + const r = await store.saveFiles(courseId, who(user), [{ path, content: new Uint8Array(await file.arrayBuffer()), ...(overwrite ? {} : { baseHash: null }) }], message); + if (!r.ok) return json({ ok: false, reason: "exists", error: `${path} already exists; upload again with overwrite to replace it`, 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); + if (!abs) return fail(404, "not found"); + const res = await serveMedia(req, abs, { download: rc.url.searchParams.has("download") }); + // Draft files are author content served from the app's origin: never let them run script (SVG, HTML). + res.headers.set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'; sandbox"); + res.headers.set("X-Content-Type-Options", "nosniff"); + return res; + }), + }, + + "/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 : r.reason === "forbidden" ? 403 : 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 : r.reason === "forbidden" ? 403 : 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/src/server/git.ts b/packages/feature-studio/src/server/git.ts new file mode 100644 index 0000000..68a2895 --- /dev/null +++ b/packages/feature-studio/src/server/git.ts @@ -0,0 +1,69 @@ +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", + "-c", "protocol.ext.allow=never", // author-supplied remote URLs must never run commands +]; + +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"; + // Ignore the machine's global/system git config (url.*.insteadOf, credential helpers, ...). + e.GIT_CONFIG_GLOBAL = "/dev/null"; + e.GIT_CONFIG_NOSYSTEM = "1"; + 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/paths.ts b/packages/feature-studio/src/server/paths.ts new file mode 100644 index 0000000..4f954bc --- /dev/null +++ b/packages/feature-studio/src/server/paths.ts @@ -0,0 +1,23 @@ +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"); +} + +/** Studio bare repos: `/studio/repos/.git` (no manifest, never discovered). */ +export function studioReposDir(dataDir: string): string { + return join(dataDir, "studio", "repos"); +} + +/** + * The course dirs the CourseRepository must scan: the configured ones, the importer's plain folders in + * `/courses`, and studio courses through their published worktree in `/worktrees`. + * Phase 0's server builds the repository from `config.courseDirs` only; see docs/contract-requests/studio.md. + */ +export function studioCourseDirs(config: Pick): string[] { + const dirs = config.courseDirs.map((d) => resolve(d)); + for (const d of [resolve(config.dataDir, "courses"), resolve(studioWorktreesDir(config.dataDir))]) if (!dirs.includes(d)) dirs.push(d); + return dirs; +} diff --git a/packages/feature-studio/src/server/store.ts b/packages/feature-studio/src/server/store.ts new file mode 100644 index 0000000..6d1c3fe --- /dev/null +++ b/packages/feature-studio/src/server/store.ts @@ -0,0 +1,598 @@ +import { constants, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs"; +import { mkdir, open, rm, unlink } 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: "forbidden"; message: string } + | { 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[] } + | { ok: false; reason: "forbidden"; message: string }; + +export class StudioError extends Error { + constructor(message: string, public status = 400) { super(message); } +} + +const HOST = String.raw`[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?`; +const USERINFO = String.raw`(?:[^@/\s:]+(?::[^@/\s]*)?@)?`; +const REMOTE_RES = [ + new RegExp(String.raw`^https://${USERINFO}${HOST}(?::\d+)?/[^\s]*$`), + new RegExp(String.raw`^ssh://${USERINFO}${HOST}(?::\d+)?/[^\s]*$`), + new RegExp(String.raw`^git@${HOST}:[A-Za-z0-9_~.][^\s]*$`), +]; + +/** Only https://, ssh:// and git@host: remotes. Local paths, file://, ext::, fd:: and the rest are refused. */ +export function isAllowedRemote(url: string): boolean { + return REMOTE_RES.some((re) => re.test(url)); +} + +/** Transport hardening passed to every fetch/push, on top of the allowlist (defence in depth). */ +const REMOTE_GUARD = ["-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"]; + +type ManifestLike = { visibility?: string; source?: { kind?: string } } | null; +const kindOf = (m: ManifestLike) => m?.source?.kind ?? "authored"; +function parseLoose(text: string | null): ManifestLike { + if (text === null) return null; + try { return (Bun.YAML.parse(text) as ManifestLike) ?? null; } catch { return null; } +} + +/** + * Spec 12: only the owner may make an imported course public or signed-in, and (to close the obvious bypass) + * only the owner may change `source.kind`. Returns a reason when `resultText` needs the owner, else null. + */ +export function ownerRequiredFor(baseText: string | null, resultText: string | null): string | null { + const base = parseLoose(baseText); + const result = parseLoose(resultText); + if (!result) return null; + if (base && kindOf(base) !== kindOf(result)) return "only the owner can change source.kind"; + const vis = result.visibility ?? "restricted"; + if ((vis === "public" || vis === "signed-in") && (kindOf(result) !== "authored" || (base && kindOf(base) !== "authored"))) { + return "only the owner can make an imported course public or signed-in visible"; + } + return null; +} + +export type Actor = Identity & { id: string; owner?: boolean }; + +export interface StudioStoreOptions { + dataDir: string; + publishedBranch?: string; + /** Tests only: allow local-path remotes (the HTTP feature never sets this). */ + allowLocalRemotes?: boolean; +} + +export function createStudioStore(opts: StudioStoreOptions) { + const dataDir = opts.dataDir; + const main = opts.publishedBranch ?? "main"; + // Studio repos live apart from the importer's plain folders in /courses (controller ruling, review I4). + const reposDir = join(dataDir, "studio", "repos"); + const importedDir = join(dataDir, "courses"); + const worktreesDir = join(dataDir, "worktrees"); + const remoteGuard = opts.allowLocalRemotes ? ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never"] : REMOTE_GUARD; + + 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(reposDir, `${courseId}.git`), + 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; + }; + const remoteOk = (url: string) => opts.allowLocalRemotes ? !/^\s*-/.test(url) && !/^(ext|fd)::/.test(url) : isAllowedRemote(url); + /** The configured remote, re-checked on every use (it could have been set outside the studio). */ + const usableRemote = async (bare: string): Promise<{ ok: true } | { ok: false; message: string }> => { + const url = await remoteUrl(bare); + if (!url) return { ok: false, message: "no remote is configured for this course" }; + if (!remoteOk(url)) return { ok: false, message: "the configured remote is not an https://, ssh:// or git@host: URL" }; + return { ok: true }; + }; + const showText = async (cwd: string, spec: string): Promise => { + const r = await git(cwd, ["show", spec]); + return r.code === 0 ? r.stdout : null; + }; + + const lexists = (p: string) => { try { lstatSync(p); return true; } catch { return false; } }; + + /** + * For writes (review C1): refuses when any existing component of `rel`, the final one included, is a symlink + * (lstat, so dangling links count), creates missing parents, then re-checks the parent's real path is inside. + */ + const safeWritePath = async (root: string, rel: string, opts: { allowFinalSymlink?: boolean } = {}): Promise => { + const n = checkRelPath(rel); + const realRoot = realpathSync(root); + const parts = n.split(sep); + let cur = realRoot; + for (let i = 0; i < parts.length; i++) { + cur = join(cur, parts[i]!); + if (!lexists(cur)) break; + const st = lstatSync(cur); + const last = i === parts.length - 1; + if (st.isSymbolicLink() && !(last && opts.allowFinalSymlink)) throw new Error(`invalid path (symlink): ${rel}`); + if (!last && !st.isDirectory()) throw new Error(`invalid path (not a directory): ${rel}`); + } + const abs = join(realRoot, n); + await mkdir(dirname(abs), { recursive: true }); + const realParent = realpathSync(dirname(abs)); + if (realParent !== realRoot && !realParent.startsWith(realRoot + sep)) throw new Error(`invalid path (escapes the course): ${rel}`); + return abs; + }; + + /** For reads: absolute path for `rel` whose real path (symlinks followed) stays inside `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) { + if (f.delete) { + // unlink removes a symlink itself, never its target, so deleting a planted link is allowed + const abs = await safeWritePath(dir, f.path, { allowFinalSymlink: true }); + if (lexists(abs)) await unlink(abs); + } else { + const abs = await safeWritePath(dir, f.path); + // O_NOFOLLOW: a symlink swapped in after the check makes the open fail instead of writing through it + const fh = await open(abs, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o644); + try { await fh.writeFile(f.content ?? ""); } finally { await fh.close(); } + } + touched.push(checkRelPath(f.path)); + } + return touched; + } + + const api = { + dataDir, + publishedBranch: main, + paths, + branchOf, + isManaged, + + async listManaged(): Promise { + if (!existsSync(reposDir)) return []; + return readdirSync(reposDir).filter((n) => n.endsWith(".git")).map((n) => n.slice(0, -4)).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); + await lock(id, async () => { + if (existsSync(p.bare) || existsSync(p.root) || existsSync(join(importedDir, id))) throw new StudioError(`course "${id}" already exists`, 409); + mkdirSync(reposDir, { 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: Actor, 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), + }; + }, + + /** List view status, read from the bare repo only: never creates a worktree or branch (review I5). */ + async summary(courseId: string, authorId: string): Promise<{ hasDraft: boolean; ahead: number; behind: number; merging: boolean; remote: string | null }> { + const p = requireManaged(courseId); + const branch = branchOf(authorId); + const hasDraft = (await git(p.bare, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`])).code === 0; + const dir = p.draft(authorId); + return { + hasDraft, + ahead: hasDraft ? await count(p.bare, `${main}..${branch}`) : 0, + behind: hasDraft ? await count(p.bare, `${branch}..${main}`) : 0, + merging: existsSync(join(dir, ".git")) ? await isMerging(dir) : false, + remote: await remoteUrl(p.bare), + }; + }, + + /** The published manifest text (base for the owner-only rule). */ + async publishedManifest(courseId: string): Promise { + return showText(requireManaged(courseId).bare, `${main}:${MANIFEST}`); + }, + + 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: Actor): 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 baseManifest = await showText(p.published, `HEAD:${MANIFEST}`); + // Merge without committing, so the merged result is checked before it becomes the published version. + const r = await git(p.published, ["merge", "--no-ff", "--no-commit", 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 }; + } + const merged = (await readFileIn(p.published, MANIFEST))?.content ?? null; + const needOwner = ownerRequiredFor(baseManifest, merged); + if (needOwner && !who.owner) { + await git(p.published, ["merge", "--abort"]); + return { ok: false, reason: "forbidden", message: needOwner }; + } + const mergedErrors = (await api.validateDraftUnlocked(courseId, p.published)).filter((d) => d.level === "error"); + if (mergedErrors.length) { + await git(p.published, ["merge", "--abort"]); + return { ok: false, reason: "invalid", diagnostics: mergedErrors }; + } + await gitOk(p.published, ["commit", "-q", "-m", `Publish ${branch}`], who); + // 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: Actor, 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") { + const ru = await usableRemote(p.bare); + if (!ru.ok) return { ok: false, reason: "no-remote", message: ru.message }; + const f = await git(p.bare, [...remoteGuard, "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: Actor, 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 m = files.find((f) => checkRelPath(f.path) === MANIFEST); + if (m && !who.owner) { + const needOwner = ownerRequiredFor(await showText(requireManaged(courseId).bare, `${main}:${MANIFEST}`), m.content); + if (needOwner) throw new StudioError(needOwner, 403); + } + 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 (!remoteOk(url.trim())) throw new StudioError("remote must be an https://, ssh:// or git@host: 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: Actor): Promise { + return lock(courseId, async () => { + const p = requireManaged(courseId); + const ru = await usableRemote(p.bare); + if (!ru.ok) return { ok: false, reason: "no-remote", message: ru.message }; + 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, [...remoteGuard, "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; + const baseManifest = await showText(p.bare, `${main}:${MANIFEST}`); + if (localBehind) { + const needOwner = ownerRequiredFor(baseManifest, await showText(p.bare, `${remoteRef}:${MANIFEST}`)); + if (needOwner && !who.owner) return { ok: false, reason: "forbidden", message: needOwner }; + await gitOk(p.published, ["merge", "-q", "--ff-only", remoteRef]); + pulled = true; + } else if (!remoteBehind) { + const r = await git(p.published, ["merge", "--no-ff", "--no-commit", 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() }; + } + const needOwner = ownerRequiredFor(baseManifest, (await readFileIn(p.published, MANIFEST))?.content ?? null); + if (needOwner && !who.owner) { + await git(p.published, ["merge", "--abort"]); + return { ok: false, reason: "forbidden", message: needOwner }; + } + await gitOk(p.published, ["commit", "-q", "-m", `Sync with origin/${main}`], who); + pulled = true; + } + } + } + const ahead = hasRemote ? await count(p.bare, `${remoteRef}..${main}`) : 1; + if (ahead > 0) { + const r = await git(p.bare, [...remoteGuard, "push", "-q", "origin", `${main}:${main}`]); + if (r.code !== 0) return { ok: false, reason: "network", message: r.stderr.trim() || "push failed" }; + await git(p.bare, [...remoteGuard, "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/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/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)} /> + +