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 }[]; } /** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */ export interface TokenInfo { expiresAt?: string; daysLeft?: number; source: string; persistent: boolean; keepalive: { running: boolean; budgetSeconds?: number; lastExtendedAt?: string; rejectedAt?: string } | null; } 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 token(): Promise { return (await (await this.request('/api/token')).json()) as TokenInfo; } /** Hands the server a fresh Schulcloud token; it checks the token before using it. */ async replaceToken(jwt: string): Promise { const response = await this.request('/api/token', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jwt }), }); return (await response.json()) as TokenInfo & { changed: boolean; persisted: boolean }; } async manifest(since?: string): Promise { const query = since ? `?since=${encodeURIComponent(since)}` : ''; return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest; } /** * Starts a re-crawl and waits for it by polling the server's status. * * Not one long request: a crawl that downloads every course file can run for * many minutes, and fetch gives up after five without response headers — * which reported "fetch failed" for a crawl that was succeeding. */ async refresh( courseId?: string, force = false, onWaiting?: (seconds: number) => void, ): Promise> { const response = await this.request('/api/refresh', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ courseId, force, wait: false }), }); const started = (await response.json()) as { joined?: boolean; startedAt?: string | null }; const began = Date.now(); for (;;) { await new Promise((resolve) => setTimeout(resolve, 3000)); const status = (await this.status()) as { indexer?: { running?: boolean; lastResult?: Record; lastError?: string } | null; }; const indexer = status.indexer; if (!indexer) throw new ApiError(503, 'The server has no indexer.'); if (indexer.running) { onWaiting?.(Math.round((Date.now() - began) / 1000)); continue; } if (indexer.lastError) throw new ApiError(502, `The re-crawl failed on the server: ${indexer.lastError}`); if (!indexer.lastResult) throw new ApiError(502, 'The re-crawl finished without a result.'); return { ...indexer.lastResult, joined: started.joined === true }; } } /** 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}`; }