import type { CliConfig } from './config.ts'; /** * Talks to the schulcloud-mcp server's /api surface. * * Deliberately the only thing in the CLI that knows a network exists, and it * never touches Schulcloud directly — the Pi holds that credential. */ export interface ManifestEntry { fileId: string; name: string; path: string; size: number; mimeType: string; courseId: string | null; courseTitle: string; status: 'added' | 'unchanged' | 'removed'; } export interface Manifest { crawlId: number; cursor: string; crawledAt?: string; count: number; entries: ManifestEntry[]; } /** One entry of a file-manager tree or search, as /api/fs returns it. */ export interface FsEntry { type: 'directory' | 'file'; path: string; parentPath: string; depth: number; id: string; name: string; size?: number; mimeType?: string | null; blocked?: boolean; } export interface FsListing { path: string; kind: 'directory' | 'file'; area?: string | null; directories?: { id: string; name: string; path: string }[]; files?: { id: string; name: string; path: string; size: number; mimeType?: string; blocked: boolean }[]; file?: { id: string; name: string; size: number; mimeType?: string; blocked: boolean }; } export interface FsWalk { path: string; kind: 'directory' | 'file'; entries?: FsEntry[]; matches?: FsEntry[]; file?: FsListing['file']; visited?: number; truncated?: boolean; failures?: { path: string; reason: string }[]; } export class ApiError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.status = status; this.name = 'ApiError'; } } export class ApiClient { private readonly config: CliConfig; constructor(config: CliConfig) { this.config = config; } private async request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${this.config.server}${path}`, { ...init, headers: { ...(init.headers ?? {}), Authorization: `Bearer ${this.config.token}` }, }); if (!response.ok) { let detail = ''; try { const body = (await response.json()) as { message?: string; error?: string }; detail = body.message ?? body.error ?? ''; } catch { // Non-JSON error bodies are not worth surfacing verbatim. } throw new ApiError(response.status, describe(response.status, detail, this.config.server)); } return response; } async status(): Promise> { return (await (await this.request('/api/status')).json()) as Record; } async manifest(since?: string): Promise { const query = since ? `?since=${encodeURIComponent(since)}` : ''; return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest; } async refresh(courseId?: string, force = false): Promise> { const response = await this.request('/api/refresh', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ courseId, force }), }); return (await response.json()) as Record; } /** Streams one file's bytes. */ async file(fileId: string): Promise { return this.request(`/api/files/${encodeURIComponent(fileId)}`); } // --- the file manager ------------------------------------------------------ async fsList(path: string): Promise { return (await (await this.request(`/api/fs/list?${new URLSearchParams({ path })}`)).json()) as FsListing; } async fsTree(path: string, depth: number, maxFolders: number): Promise { const query = new URLSearchParams({ path, depth: String(depth), maxFolders: String(maxFolders) }); return (await (await this.request(`/api/fs/tree?${query}`)).json()) as FsWalk; } async fsFind(name: string, path: string, type: string, maxFolders: number): Promise { const query = new URLSearchParams({ name, path, type, maxFolders: String(maxFolders) }); return (await (await this.request(`/api/fs/find?${query}`)).json()) as FsWalk; } /** Streams one file-manager file's bytes, by path or by id. */ async fsFile(target: { path: string } | { id: string; name: string }): Promise { const query = 'path' in target ? new URLSearchParams({ path: target.path }) : new URLSearchParams(target); return this.request(`/api/fs/file?${query}`); } } function describe(status: number, detail: string, server: string): string { if (status === 401) return `Unauthorized — the token is wrong or expired. Re-run: schulcloud login --server ${server} --token `; if (status === 503) return 'The server is running without an index, so this command is unavailable. Set DATABASE_URL on the server.'; if (status === 409) return detail || 'The sync cursor is unknown to the server. Run a full sync with --full.'; if (status === 429) return detail || 'Refreshed too recently — wait a moment, or pass --force.'; // The file manager's own errors already say what was not found and what is there. if ((status === 400 || status === 404 || status === 422) && detail) return detail; return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`; }