A token lasts 30 days and only a browser login yields one — the account is federated, so the server cannot mint it. Replacing it meant editing .env and recreating the container, every month. `schulcloud token set` (a hidden prompt, or piped input) and a /token page both send it to PUT /api/token. The server checks it with Schulcloud first — well-formed, unexpired, still logged in, the same account — then swaps it into the config every request reads, restarts the keepalive and saves it in STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A refused paste changes nothing, and the token is never logged. The keepalive's pings carry a generation, so a 401 for the old token that arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami and the log report the expiry and warn a week ahead. Found on the way: a host that is off for more than two hours loses the session however long the token has left — this machine lost it overnight — which is what the always-on Pi is for. 174 tests. Smoke 72/72 on the local instance, and a real swap verified end to end there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
203 lines
7.0 KiB
TypeScript
203 lines
7.0 KiB
TypeScript
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<Response> {
|
|
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<Record<string, unknown>> {
|
|
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
|
|
}
|
|
|
|
async token(): Promise<TokenInfo> {
|
|
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<TokenInfo & { changed: boolean; persisted: boolean }> {
|
|
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<Manifest> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>; 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<Response> {
|
|
return this.request(`/api/files/${encodeURIComponent(fileId)}`);
|
|
}
|
|
|
|
// --- the file manager ------------------------------------------------------
|
|
|
|
async fsList(path: string): Promise<FsListing> {
|
|
return (await (await this.request(`/api/fs/list?${new URLSearchParams({ path })}`)).json()) as FsListing;
|
|
}
|
|
|
|
async fsTree(path: string, depth: number, maxFolders: number): Promise<FsWalk> {
|
|
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<FsWalk> {
|
|
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<Response> {
|
|
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 <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}`;
|
|
}
|