nixamp 0.68.2 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +15 -5
- package/dist/ai-label.js +84 -34
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +12 -2
- package/dist/school-cli.d.ts +2 -0
- package/dist/school-cli.js +151 -0
- package/dist/school-client.d.ts +61 -0
- package/dist/school-client.js +148 -0
- package/dist/school-discovery.d.ts +32 -0
- package/dist/school-discovery.js +204 -0
- package/dist/school-mcp.d.ts +180 -0
- package/dist/school-mcp.js +143 -0
- package/dist/server.js +10 -0
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +6 -6
- package/dist/veo-studio.d.ts +1 -1
- package/dist/veo-studio.js +2 -2
- package/package.json +1 -1
- package/src/ai-label.ts +86 -35
- package/src/main.ts +8 -0
- package/src/mcp.ts +14 -2
- package/src/school-cli.ts +131 -0
- package/src/school-client.ts +180 -0
- package/src/school-discovery.ts +212 -0
- package/src/school-mcp.ts +153 -0
- package/src/server.ts +9 -0
- package/src/station-cli.ts +1 -0
- package/src/veo-studio.ts +2 -2
- package/web/dist/assets/{ar-BUn23r3N.js → ar-CoJztWFs.js} +1 -1
- package/web/dist/assets/{cs-FwQ-DhBJ.js → cs-1Avz2N93.js} +1 -1
- package/web/dist/assets/{da-C8OtQ3BT.js → da-CiNddZp3.js} +1 -1
- package/web/dist/assets/{de-CldRtYgJ.js → de-D8Rljw7l.js} +1 -1
- package/web/dist/assets/{es-DSPjVfW5.js → es-CIxJsfiv.js} +1 -1
- package/web/dist/assets/{fi-XgwcnMwU.js → fi-C5MieJtE.js} +1 -1
- package/web/dist/assets/{fr-BH9o9Stx.js → fr-DBMzgQul.js} +1 -1
- package/web/dist/assets/{hi-DuMMcoiX.js → hi-CUNsIGCW.js} +1 -1
- package/web/dist/assets/{hu-B2Hiutdz.js → hu-CRkS43qM.js} +1 -1
- package/web/dist/assets/{id-BNctMtkh.js → id-DtXzZzr5.js} +1 -1
- package/web/dist/assets/{it-C_ADjBqX.js → it-CleEG3yJ.js} +1 -1
- package/web/dist/assets/{main-BVDC-f0Q.js → main-DZtT4u1U.js} +2 -2
- package/web/dist/assets/{nl-BPTAZ6gI.js → nl-HDKYTlnz.js} +1 -1
- package/web/dist/assets/{ru-CMSzyodc.js → ru-CX6u3qpl.js} +1 -1
- package/web/dist/assets/{sv-BqdBoD8p.js → sv-BRCqB1fE.js} +1 -1
- package/web/dist/assets/{uk-C3lM_pHS.js → uk-BMLA9Xl6.js} +1 -1
- package/web/dist/assets/{vi-CkVfVKSH.js → vi-DzPvzeIq.js} +1 -1
- package/web/dist/assets/{zh-BDiROZo0.js → zh-Cbb7TICl.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp school`: classes on backtoschool.help from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* A class is a live event on nixamp's accounts, so `nixamp login` is the
|
|
5
|
+
* sign-in here too. `teach` is the short path: a station link in, a class on
|
|
6
|
+
* the air out. `check` and `prune` exist because a class stays live after
|
|
7
|
+
* its broadcast is gone, and the page then shows a player that never plays.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass,
|
|
11
|
+
type SchoolClass,
|
|
12
|
+
} from "./school-client.ts";
|
|
13
|
+
|
|
14
|
+
export const SCHOOL_USAGE = `nixamp school: classes on backtoschool.help (sign in with \`nixamp login\`)
|
|
15
|
+
|
|
16
|
+
nixamp school [list] [--status live|scheduled|draft|ended] [--mine]
|
|
17
|
+
nixamp school show SLUG one class, and whether its broadcast plays
|
|
18
|
+
nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]
|
|
19
|
+
make a class of a broadcast and start it now
|
|
20
|
+
nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]
|
|
21
|
+
nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]
|
|
22
|
+
nixamp school start SLUG
|
|
23
|
+
nixamp school end SLUG
|
|
24
|
+
nixamp school delete SLUG
|
|
25
|
+
nixamp school check your live classes, and which ones no longer play
|
|
26
|
+
nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)
|
|
27
|
+
|
|
28
|
+
LINK is what plays: a station link (\`nixamp station show ID\`), a nixamp.com share link, a
|
|
29
|
+
Pairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.
|
|
30
|
+
--json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
34
|
+
const at = argv.indexOf(name);
|
|
35
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The first argument after the command that is not a flag or a flag's value. */
|
|
39
|
+
function positional(argv: string[]): string | undefined {
|
|
40
|
+
const valued = new Set(["--title", "--description", "--topic", "--starts", "--link", "--status"]);
|
|
41
|
+
for (let at = 1; at < argv.length; at += 1) {
|
|
42
|
+
const one = argv[at]!;
|
|
43
|
+
if (valued.has(one)) { at += 1; continue; }
|
|
44
|
+
if (!one.startsWith("--")) return one;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fields(argv: string[]): Record<string, unknown> {
|
|
50
|
+
const out: Record<string, unknown> = {};
|
|
51
|
+
for (const [name, key] of [["--title", "title"], ["--description", "description"], ["--topic", "topic"], ["--starts", "startsAt"], ["--link", "broadcastUrl"]] as const) {
|
|
52
|
+
const value = flag(argv, name);
|
|
53
|
+
if (value !== undefined) out[key] = value;
|
|
54
|
+
}
|
|
55
|
+
for (const visibility of ["public", "unlisted", "private"]) if (argv.includes(`--${visibility}`)) out["visibility"] = visibility;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function print(item: SchoolClass): void {
|
|
60
|
+
console.log(`${item.status.padEnd(9)} ${item.title}\n ${classUrl(item)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function school(argv: string[]): Promise<number> {
|
|
64
|
+
const [sub = "list"] = argv;
|
|
65
|
+
const json = argv.includes("--json");
|
|
66
|
+
const slug = positional(argv);
|
|
67
|
+
const out = (value: unknown, human: () => void): void => { if (json) console.log(JSON.stringify(value, null, 2)); else human(); };
|
|
68
|
+
try {
|
|
69
|
+
if (sub === "help" || argv.includes("--help")) {
|
|
70
|
+
console.log(SCHOOL_USAGE);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (sub === "list") {
|
|
74
|
+
const status = flag(argv, "--status");
|
|
75
|
+
const items = await listClasses({ ...(status ? { status } : {}), mine: argv.includes("--mine") });
|
|
76
|
+
out(items, () => { if (items.length === 0) console.log("No classes match."); for (const one of items) print(one); });
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (sub === "check" || sub === "prune") {
|
|
80
|
+
const checked = await checkClasses();
|
|
81
|
+
const broken = checked.filter((one) => one.health.ok === false);
|
|
82
|
+
const act = sub === "prune" ? (argv.includes("--delete") ? "delete" : argv.includes("--end") ? "end" : "") : "";
|
|
83
|
+
for (const { item } of act ? broken : []) {
|
|
84
|
+
if (act === "delete") await deleteClass(item.id);
|
|
85
|
+
else await moveClass(item.id, "end");
|
|
86
|
+
}
|
|
87
|
+
out(checked.map(({ item, health }) => ({ slug: item.slug, ok: health.ok, reason: health.reason, ...(act && health.ok === false ? { done: act } : {}) })), () => {
|
|
88
|
+
if (checked.length === 0) console.log("You have no live classes.");
|
|
89
|
+
for (const { item, health } of checked) console.log(`${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : " ? "} ${item.slug} (${health.reason})`);
|
|
90
|
+
if (sub === "prune" && broken.length > 0) console.log(act ? `\n${act === "delete" ? "Deleted" : "Ended"} ${broken.length}.` : `\n${broken.length} no longer play. --end or --delete them.`);
|
|
91
|
+
});
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
if (sub === "teach" || sub === "create") {
|
|
95
|
+
const input = fields(argv);
|
|
96
|
+
if (slug) input["broadcastUrl"] = slug;
|
|
97
|
+
if (!input["title"]) { console.error(`nixamp school ${sub} needs --title`); return 2; }
|
|
98
|
+
if (sub === "teach" && !input["broadcastUrl"]) { console.error("nixamp school teach needs the LINK that plays"); return 2; }
|
|
99
|
+
const item = sub === "teach" ? await teach(input) : await createClass(input);
|
|
100
|
+
out(item, () => { print(item); if (sub === "create") console.log(`\nnixamp school start ${item.slug} puts it on the air.`); });
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
if (!slug) { console.error(`nixamp school ${sub} needs a class (its slug or /live/ link)\n\n${SCHOOL_USAGE}`); return 2; }
|
|
104
|
+
if (sub === "show") {
|
|
105
|
+
const item = await getClass(slug);
|
|
106
|
+
const health = await broadcastHealth(item.broadcastUrl);
|
|
107
|
+
out({ ...item, broadcast: health }, () => { print(item); console.log(` broadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`); });
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (sub === "update") {
|
|
111
|
+
const item = await updateClass(slug, fields(argv));
|
|
112
|
+
out(item, () => print(item));
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (sub === "start" || sub === "end") {
|
|
116
|
+
const item = await moveClass(slug, sub);
|
|
117
|
+
out(item, () => print(item));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
if (sub === "delete" || sub === "rm") {
|
|
121
|
+
await deleteClass(slug);
|
|
122
|
+
out({ ok: true, slug }, () => console.log(`Deleted ${slug}.`));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
console.error(`nixamp school: no command "${sub}".\n\n${SCHOOL_USAGE}`);
|
|
126
|
+
return 2;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(`nixamp school: ${(error as Error).message}`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help as a client: its classes are nixamp's live events, served
|
|
3
|
+
* by the same process on another host, so one nixamp token signs in to both.
|
|
4
|
+
*
|
|
5
|
+
* The CLI (school-cli.ts) and the MCP tools (school-mcp.ts) are both this
|
|
6
|
+
* file; neither talks HTTP itself.
|
|
7
|
+
*
|
|
8
|
+
* `broadcastHealth` is the reason this exists as much as the CRUD: a class
|
|
9
|
+
* stays "live" until somebody ends it, long after the channel it points at
|
|
10
|
+
* was stopped, and the page then shows a player that never plays. It asks the
|
|
11
|
+
* broadcast itself, with only what the public link already carries.
|
|
12
|
+
*/
|
|
13
|
+
import { readSession } from "./session.ts";
|
|
14
|
+
|
|
15
|
+
export const SCHOOL_SITE = "https://backtoschool.help";
|
|
16
|
+
|
|
17
|
+
type Fetcher = typeof fetch;
|
|
18
|
+
|
|
19
|
+
export interface SchoolOptions {
|
|
20
|
+
/** backtoschool.help unless NIXAMP_SCHOOL_URL says otherwise; the tests hand in a local server. */
|
|
21
|
+
site?: string;
|
|
22
|
+
/** The session on disk (or NIXAMP_TOKEN) unless given; null means signed out. */
|
|
23
|
+
token?: string | null;
|
|
24
|
+
fetcher?: Fetcher;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SchoolClass {
|
|
28
|
+
id: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
title: string;
|
|
31
|
+
status: string;
|
|
32
|
+
visibility: string;
|
|
33
|
+
kind: string;
|
|
34
|
+
version: number;
|
|
35
|
+
ownerId: string;
|
|
36
|
+
broadcastUrl?: string | null;
|
|
37
|
+
description?: string;
|
|
38
|
+
topic?: string;
|
|
39
|
+
hostName?: string;
|
|
40
|
+
startsAt?: string | null;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SchoolError extends Error {
|
|
45
|
+
constructor(message: string, readonly status = 0) {
|
|
46
|
+
super(message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function schoolSite(options: SchoolOptions = {}): string {
|
|
51
|
+
return (options.site ?? process.env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE).replace(/\/+$/, "");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function classUrl(item: Pick<SchoolClass, "slug">, options: SchoolOptions = {}): string {
|
|
55
|
+
return `${schoolSite(options)}/live/${item.slug}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tokenOf(options: SchoolOptions): string | null {
|
|
59
|
+
if (options.token !== undefined) return options.token;
|
|
60
|
+
return readSession()?.token ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function call(path: string, init: { method?: string; body?: unknown; auth?: boolean } = {}, options: SchoolOptions = {}): Promise<Record<string, unknown>> {
|
|
64
|
+
const token = tokenOf(options);
|
|
65
|
+
if (init.auth && !token) throw new SchoolError("Not signed in. Run `nixamp login`, or set NIXAMP_TOKEN.", 401);
|
|
66
|
+
const headers: Record<string, string> = { accept: "application/json" };
|
|
67
|
+
if (token) headers["authorization"] = `Bearer ${token}`;
|
|
68
|
+
if (init.body !== undefined) headers["content-type"] = "application/json";
|
|
69
|
+
const response = await (options.fetcher ?? fetch)(`${schoolSite(options)}${path}`, {
|
|
70
|
+
method: init.method ?? "GET",
|
|
71
|
+
headers,
|
|
72
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
73
|
+
});
|
|
74
|
+
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
75
|
+
if (!response.ok) throw new SchoolError(String(body["error"] ?? `backtoschool.help answered ${response.status}`), response.status);
|
|
76
|
+
return body;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const eventOf = (body: Record<string, unknown>): SchoolClass => (body["event"] ?? body) as SchoolClass;
|
|
80
|
+
const ref = (value: string): string => encodeURIComponent(value.replace(/^.*\/live\//, "").replace(/[?#].*$/, ""));
|
|
81
|
+
|
|
82
|
+
export async function listClasses(query: { status?: string; kind?: string; mine?: boolean; limit?: number } = {}, options: SchoolOptions = {}): Promise<SchoolClass[]> {
|
|
83
|
+
const params = new URLSearchParams();
|
|
84
|
+
if (query.status) params.set("status", query.status);
|
|
85
|
+
if (query.kind) params.set("kind", query.kind);
|
|
86
|
+
if (query.mine) params.set("mine", "true");
|
|
87
|
+
params.set("limit", String(query.limit ?? 60));
|
|
88
|
+
const body = await call(`/api/v1/events?${params}`, { auth: query.mine === true }, options);
|
|
89
|
+
return (body["events"] as SchoolClass[]) ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A class by its slug, id, or the /live/<slug> link. */
|
|
93
|
+
export async function getClass(slug: string, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
94
|
+
return eventOf(await call(`/api/v1/events/${ref(slug)}`, {}, options));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function createClass(input: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
98
|
+
return eventOf(await call("/api/v1/events", { method: "POST", body: { kind: "class", visibility: "public", ...input }, auth: true }, options));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function updateClass(slug: string, patch: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
102
|
+
const current = await getClass(slug, options);
|
|
103
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}`, { method: "PATCH", body: { ...patch, version: current.version }, auth: true }, options));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
|
|
107
|
+
export async function moveClass(slug: string, action: "start" | "end" | "doors" | "encore" | "cancel" | "archive", options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
108
|
+
const current = await getClass(slug, options);
|
|
109
|
+
return eventOf(await call(`/api/v1/events/${ref(current.id)}/${action}`, { method: "POST", body: { version: current.version }, auth: true }, options));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function deleteClass(slug: string, options: SchoolOptions = {}): Promise<void> {
|
|
113
|
+
await call(`/api/v1/events/${ref(slug)}`, { method: "DELETE", auth: true }, options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Make a class of a broadcast and put it on the air in one go. */
|
|
117
|
+
export async function teach(input: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
|
|
118
|
+
const made = await createClass(input, options);
|
|
119
|
+
return moveClass(made.slug, "start", options);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface Health {
|
|
123
|
+
ok: boolean | null;
|
|
124
|
+
reason: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Does this broadcast play? true and false are answers; null is "cannot tell
|
|
129
|
+
* from here" (a Pairux room), which a prune must leave alone.
|
|
130
|
+
*/
|
|
131
|
+
export async function broadcastHealth(link: string | null | undefined, fetcher: Fetcher = fetch, timeoutMs = 8000): Promise<Health> {
|
|
132
|
+
if (!link) return { ok: false, reason: "no broadcast link" };
|
|
133
|
+
let url: URL;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(link);
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, reason: "not a link" };
|
|
138
|
+
}
|
|
139
|
+
const get = (target: string, headers: Record<string, string> = {}): Promise<Response> =>
|
|
140
|
+
fetcher(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
141
|
+
try {
|
|
142
|
+
// A station page: its own token opens its state.
|
|
143
|
+
const station = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1];
|
|
144
|
+
if (station && url.hostname.endsWith(".nixamp.com")) {
|
|
145
|
+
const response = await get(`${url.origin}/api/v1/stations/${station}/state?t=${encodeURIComponent(url.searchParams.get("t") ?? "")}`);
|
|
146
|
+
if (!response.ok) return { ok: false, reason: `station ${station} answered ${response.status}` };
|
|
147
|
+
const state = (await response.json()) as { state?: string };
|
|
148
|
+
return state.state === "live" ? { ok: true, reason: `station ${station} is live` } : { ok: false, reason: `station ${station} is ${state.state ?? "not live"}` };
|
|
149
|
+
}
|
|
150
|
+
// nixamp.com/?url=<server>/view/<listen key>&play=channel:<id>: the listen key reads the server's channels.
|
|
151
|
+
if ((url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com") && url.searchParams.get("url")) {
|
|
152
|
+
const server = new URL(url.searchParams.get("url")!);
|
|
153
|
+
const key = /\/view\/([^/?#]+)/.exec(server.pathname)?.[1] ?? "";
|
|
154
|
+
const play = url.searchParams.get("play") ?? "live";
|
|
155
|
+
const response = await get(`${server.origin}/api/streams?k=${encodeURIComponent(key)}`);
|
|
156
|
+
if (!response.ok) return { ok: false, reason: `${server.host} answered ${response.status}` };
|
|
157
|
+
const streams = (await response.json()) as { server?: { live?: boolean }; channels?: Array<{ id: string }> };
|
|
158
|
+
if (play.startsWith("channel:")) {
|
|
159
|
+
const id = play.slice("channel:".length);
|
|
160
|
+
return (streams.channels ?? []).some((one) => one.id === id)
|
|
161
|
+
? { ok: true, reason: `channel ${id} is on ${server.hostname}` }
|
|
162
|
+
: { ok: false, reason: `channel ${id} is no longer on ${server.hostname}` };
|
|
163
|
+
}
|
|
164
|
+
if (play === "live") return streams.server?.live ? { ok: true, reason: `${server.hostname} is live` } : { ok: false, reason: `${server.hostname} is not live` };
|
|
165
|
+
return { ok: true, reason: `${server.hostname} is up` };
|
|
166
|
+
}
|
|
167
|
+
if (url.hostname === "pairux.com" || url.hostname === "www.pairux.com") return { ok: null, reason: "a Pairux room; cannot tell from here" };
|
|
168
|
+
// Direct media: one byte is enough to know it is there.
|
|
169
|
+
const response = await get(url.href, { range: "bytes=0-0" });
|
|
170
|
+
return response.ok ? { ok: true, reason: `${url.hostname} serves it` } : { ok: false, reason: `${url.hostname} answered ${response.status}` };
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { ok: false, reason: `unreachable: ${(error as Error).message}` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Your live classes, each with whether its broadcast plays. */
|
|
177
|
+
export async function checkClasses(options: SchoolOptions = {}, status = "live"): Promise<Array<{ item: SchoolClass; health: Health }>> {
|
|
178
|
+
const items = await listClasses({ mine: true, status }, options);
|
|
179
|
+
return Promise.all(items.map(async (item) => ({ item, health: await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch) })));
|
|
180
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help's own front door for agents: llms.txt, an OpenAPI
|
|
3
|
+
* document of the class API, and MCP at /mcp/public (reading) and /mcp
|
|
4
|
+
* (teaching, as the bearer).
|
|
5
|
+
*
|
|
6
|
+
* The school is served by the nixamp process under another Host, so before
|
|
7
|
+
* this its /llms.txt and /mcp answered as nixamp.com: an agent landing on
|
|
8
|
+
* the school was told about radio stations and handed no class tools. The
|
|
9
|
+
* routes here run only for the school's host, ahead of nixamp's.
|
|
10
|
+
*
|
|
11
|
+
* The tools are school-mcp.ts, called over HTTP against the school's own
|
|
12
|
+
* origin with the caller's token, exactly as from a CLI. Sign-in is nixamp's:
|
|
13
|
+
* one account, one token, and nixamp.com is the authorization server.
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
+
import { SCHOOL_PUBLIC_TOOLS, SCHOOL_TOOLS, callSchoolTool, isSchoolPublicTool, isSchoolTool } from "./school-mcp.ts";
|
|
17
|
+
import { SCHOOL_SITE } from "./school-client.ts";
|
|
18
|
+
|
|
19
|
+
export interface SchoolDiscoveryContext {
|
|
20
|
+
/** This school's origin, e.g. https://backtoschool.help. */
|
|
21
|
+
site: string;
|
|
22
|
+
/** nixamp.com: where tokens come from and where OAuth is. */
|
|
23
|
+
accountSite: string;
|
|
24
|
+
version: string;
|
|
25
|
+
whoIs: (token: string) => Promise<unknown>;
|
|
26
|
+
token: string;
|
|
27
|
+
readBody: (request: IncomingMessage, limit: number) => Promise<string>;
|
|
28
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
29
|
+
fetcher?: typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CORS = {
|
|
33
|
+
"access-control-allow-origin": "*",
|
|
34
|
+
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
|
35
|
+
"access-control-allow-headers": "authorization, content-type, mcp-session-id, mcp-protocol-version",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const PATHS = new Set(["/llms.txt", "/openapi.json", "/mcp", "/mcp/public", "/.well-known/oauth-protected-resource"]);
|
|
39
|
+
|
|
40
|
+
/** Is this host the school? backtoschool.help (or NIXAMP_SCHOOL_URL), with or without www. */
|
|
41
|
+
export function isSchoolSite(site: string | undefined, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
42
|
+
if (!site) return false;
|
|
43
|
+
const bare = (value: string): string => new URL(value).hostname.replace(/^www\./, "");
|
|
44
|
+
try {
|
|
45
|
+
return bare(site) === bare(env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function schoolLlmsTxt(site: string, accountSite: string): string {
|
|
52
|
+
return `# BackToSchool.help
|
|
53
|
+
|
|
54
|
+
> Live classes anybody can teach and anybody can join: a broadcast (a nixamp station, a screen share, a video link) with chat and raised hands around it. Free to watch. Accounts are nixamp accounts.
|
|
55
|
+
|
|
56
|
+
## Web
|
|
57
|
+
- ${site}/ : classes live now and coming up
|
|
58
|
+
- ${site}/live/<slug> : one class
|
|
59
|
+
|
|
60
|
+
## MCP
|
|
61
|
+
- Public, no account (what is on, one class): ${site}/mcp/public
|
|
62
|
+
- Signed in (teach, start, end, delete, check your classes): ${site}/mcp , a nixamp token as the bearer (\`nixamp token create\`) or OAuth via ${accountSite}
|
|
63
|
+
- Local over stdio: npx -y nixamp mcp (the class tools are among nixamp's)
|
|
64
|
+
|
|
65
|
+
Tools: classes_list, class_get, class_teach (a broadcast link in, a live class out), class_create, class_update, class_start, class_end, class_delete, classes_check (which of your live classes no longer play).
|
|
66
|
+
|
|
67
|
+
## CLI
|
|
68
|
+
- Install: curl -fsSL ${accountSite}/install.sh | sh (or npx -y nixamp)
|
|
69
|
+
- Sign in: nixamp login
|
|
70
|
+
- nixamp school list --status live
|
|
71
|
+
- nixamp school teach <station link> --title "..." make a class and start it
|
|
72
|
+
- nixamp school check | prune --end find and end classes whose broadcast stopped
|
|
73
|
+
- nixamp school help
|
|
74
|
+
|
|
75
|
+
## API
|
|
76
|
+
- OpenAPI: ${site}/openapi.json
|
|
77
|
+
- GET ${site}/api/v1/events?status=live : classes (public ones; ?mine=true with a token)
|
|
78
|
+
- GET ${site}/api/v1/events/<slug> : one class
|
|
79
|
+
- POST ${site}/api/v1/events : make one {title, broadcastUrl, description, topic, visibility, startsAt}
|
|
80
|
+
- POST ${site}/api/v1/events/<slug>/start|end {version} : the version you last read
|
|
81
|
+
- PATCH ${site}/api/v1/events/<slug> {..., version}, DELETE ${site}/api/v1/events/<slug>
|
|
82
|
+
- Auth: Authorization: Bearer <nixamp token>
|
|
83
|
+
|
|
84
|
+
## What a broadcast can be
|
|
85
|
+
A nixamp station link (https://<server>.nixamp.com:4321/station/<id>?t=<token>), a nixamp.com share link, a Pairux room, or a direct https .m3u8/.mp4/.mp3. Only broadcast what you have the right to: your own teaching, or openly licensed courses (MIT OpenCourseWare is CC BY-NC-SA: free, credited, no ads).
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function schoolOpenapi(site: string, accountSite: string, version: string): Record<string, unknown> {
|
|
90
|
+
const ok = { description: "OK", content: { "application/json": { schema: { type: "object" } } } };
|
|
91
|
+
const auth = { security: [{ bearer: [] }] };
|
|
92
|
+
const slug = [{ name: "slug", in: "path", required: true, schema: { type: "string" }, description: "The class's slug or id." }];
|
|
93
|
+
const versioned = { content: { "application/json": { schema: { type: "object", properties: { version: { type: "integer" } }, required: ["version"] } } } };
|
|
94
|
+
const fields = {
|
|
95
|
+
title: { type: "string", maxLength: 160 }, description: { type: "string", maxLength: 5000 }, topic: { type: "string", maxLength: 100 },
|
|
96
|
+
broadcastUrl: { type: "string", format: "uri" }, visibility: { type: "string", enum: ["public", "unlisted", "private"] },
|
|
97
|
+
startsAt: { type: "string", format: "date-time" }, chatEnabled: { type: "boolean" }, handRaiseEnabled: { type: "boolean" },
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
openapi: "3.1.0",
|
|
101
|
+
info: { title: "BackToSchool.help", version, description: "Live classes. Reading is open; teaching wants a nixamp token as the bearer.", license: { name: "MIT", identifier: "MIT" } },
|
|
102
|
+
servers: [{ url: site }],
|
|
103
|
+
externalDocs: { description: "llms.txt: MCP, CLI and API in one place", url: `${site}/llms.txt` },
|
|
104
|
+
components: { securitySchemes: { bearer: { type: "http", scheme: "bearer", description: `A nixamp token (\`nixamp token create\`) or an OAuth access token from ${accountSite}.` } } },
|
|
105
|
+
paths: {
|
|
106
|
+
"/api/v1/events": {
|
|
107
|
+
get: {
|
|
108
|
+
summary: "Classes", tags: ["classes"], responses: { 200: ok },
|
|
109
|
+
parameters: [
|
|
110
|
+
{ name: "status", in: "query", schema: { type: "string", enum: ["draft", "scheduled", "starting", "live", "encore", "ended", "cancelled", "archived"] } },
|
|
111
|
+
{ name: "mine", in: "query", schema: { type: "boolean" }, description: "Only yours (needs the bearer)." },
|
|
112
|
+
{ name: "limit", in: "query", schema: { type: "integer", maximum: 100 } },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
post: { summary: "Make a class (a draft; start it to go live)", tags: ["classes"], ...auth, requestBody: { content: { "application/json": { schema: { type: "object", properties: { kind: { type: "string", enum: ["class", "talk", "concert"] }, ...fields }, required: ["title"] } } } }, responses: { 200: ok } },
|
|
116
|
+
},
|
|
117
|
+
"/api/v1/events/{slug}": {
|
|
118
|
+
parameters: slug,
|
|
119
|
+
get: { summary: "One class", tags: ["classes"], responses: { 200: ok } },
|
|
120
|
+
patch: { summary: "Change a class you host", tags: ["classes"], ...auth, requestBody: { content: { "application/json": { schema: { type: "object", properties: { ...fields, version: { type: "integer" } }, required: ["version"] } } } }, responses: { 200: ok } },
|
|
121
|
+
delete: { summary: "Delete a class you host", tags: ["classes"], ...auth, responses: { 200: ok } },
|
|
122
|
+
},
|
|
123
|
+
"/api/v1/events/{slug}/start": { parameters: slug, post: { summary: "Go live", tags: ["classes"], ...auth, requestBody: versioned, responses: { 200: ok } } },
|
|
124
|
+
"/api/v1/events/{slug}/end": { parameters: slug, post: { summary: "End it", tags: ["classes"], ...auth, requestBody: versioned, responses: { 200: ok } } },
|
|
125
|
+
"/api/v1/events/{slug}/chat": { parameters: slug, get: { summary: "The class chat", tags: ["chat"], responses: { 200: ok } }, post: { summary: "Say something", tags: ["chat"], ...auth, responses: { 200: ok } } },
|
|
126
|
+
"/api/v1/auth/me": { get: { summary: "Who the bearer is", tags: ["auth"], ...auth, responses: { 200: ok } } },
|
|
127
|
+
"/mcp/public": { post: { summary: "MCP (streamable HTTP, JSON-RPC): classes_list, class_get", tags: ["mcp"], responses: { 200: ok } } },
|
|
128
|
+
"/mcp": { post: { summary: "MCP (streamable HTTP, JSON-RPC): every class tool, as the bearer", tags: ["mcp"], ...auth, responses: { 200: ok } } },
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type Message = { id?: unknown; method?: unknown; params?: Record<string, unknown> };
|
|
134
|
+
|
|
135
|
+
export async function handleSchoolDiscovery(request: IncomingMessage, response: ServerResponse, path: string, ctx: SchoolDiscoveryContext): Promise<boolean> {
|
|
136
|
+
if (!PATHS.has(path)) return false;
|
|
137
|
+
const method = request.method ?? "GET";
|
|
138
|
+
const site = ctx.site.replace(/\/+$/, "");
|
|
139
|
+
if (method === "OPTIONS") {
|
|
140
|
+
response.writeHead(204, CORS);
|
|
141
|
+
response.end();
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (path === "/llms.txt" || path === "/openapi.json" || path === "/.well-known/oauth-protected-resource") {
|
|
145
|
+
if (method !== "GET" && method !== "HEAD") { ctx.json(response, 405, { error: "GET only" }); return true; }
|
|
146
|
+
const [type, body] = path === "/llms.txt"
|
|
147
|
+
? ["text/plain; charset=utf-8", schoolLlmsTxt(site, ctx.accountSite)]
|
|
148
|
+
: ["application/json; charset=utf-8", JSON.stringify(path === "/openapi.json"
|
|
149
|
+
? schoolOpenapi(site, ctx.accountSite, ctx.version)
|
|
150
|
+
: { resource: site, authorization_servers: [ctx.accountSite], bearer_methods_supported: ["header"], resource_name: "BackToSchool.help" }, null, 2)];
|
|
151
|
+
response.writeHead(200, { ...CORS, "content-type": type, "cache-control": "public, max-age=300" });
|
|
152
|
+
response.end(method === "HEAD" ? undefined : body);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
// MCP. GET has nothing to push; DELETE ends a session nobody keeps.
|
|
156
|
+
if (method === "GET") { response.writeHead(405, { ...CORS, allow: "POST, DELETE" }); response.end(); return true; }
|
|
157
|
+
if (method === "DELETE") { response.writeHead(204, CORS); response.end(); return true; }
|
|
158
|
+
if (method !== "POST") { ctx.json(response, 405, { error: "POST a JSON-RPC message" }); return true; }
|
|
159
|
+
const signed = path === "/mcp";
|
|
160
|
+
if (signed && (await ctx.whoIs(ctx.token)) === null) {
|
|
161
|
+
const error = JSON.stringify({ error: "sign in: a nixamp token as the bearer (`nixamp token create`), or OAuth" });
|
|
162
|
+
response.writeHead(401, {
|
|
163
|
+
...CORS,
|
|
164
|
+
"content-type": "application/json; charset=utf-8",
|
|
165
|
+
"www-authenticate": `Bearer resource_metadata="${site}/.well-known/oauth-protected-resource"`,
|
|
166
|
+
});
|
|
167
|
+
response.end(error);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
let parsed: unknown;
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(await ctx.readBody(request, 256 * 1024));
|
|
173
|
+
} catch {
|
|
174
|
+
ctx.json(response, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
const tools = signed ? SCHOOL_TOOLS : SCHOOL_PUBLIC_TOOLS;
|
|
178
|
+
const allowed = signed ? isSchoolTool : isSchoolPublicTool;
|
|
179
|
+
const answers: unknown[] = [];
|
|
180
|
+
for (const message of (Array.isArray(parsed) ? parsed : [parsed]) as Message[]) {
|
|
181
|
+
const id = message?.id ?? null;
|
|
182
|
+
const name = typeof message?.method === "string" ? message.method : "";
|
|
183
|
+
if (name.startsWith("notifications/")) continue;
|
|
184
|
+
if (name === "initialize") {
|
|
185
|
+
answers.push({ jsonrpc: "2.0", id, result: {
|
|
186
|
+
protocolVersion: (message.params?.["protocolVersion"] as string | undefined) ?? "2025-06-18",
|
|
187
|
+
capabilities: { tools: { listChanged: false } },
|
|
188
|
+
serverInfo: { name: "backtoschool", title: "BackToSchool.help: live classes", version: "1" },
|
|
189
|
+
instructions: signed
|
|
190
|
+
? "Live classes on BackToSchool.help. classes_list / class_get to see what is on; class_teach puts a broadcast (a nixamp station link, a Pairux room, an https video) on the air as a class in one call; classes_check finds your live classes whose broadcast stopped, and class_end / class_delete clear them. Only broadcast what you have the right to."
|
|
191
|
+
: "Live classes on BackToSchool.help: classes_list and class_get. To teach, use /mcp with a nixamp token as the bearer.",
|
|
192
|
+
} });
|
|
193
|
+
} else if (name === "ping") {
|
|
194
|
+
answers.push({ jsonrpc: "2.0", id, result: {} });
|
|
195
|
+
} else if (name === "tools/list") {
|
|
196
|
+
answers.push({ jsonrpc: "2.0", id, result: { tools } });
|
|
197
|
+
} else if (name === "tools/call" && allowed(String(message.params?.["name"] ?? ""))) {
|
|
198
|
+
const result = await callSchoolTool(String(message.params!["name"]), (message.params?.["arguments"] ?? {}) as Record<string, unknown>, {
|
|
199
|
+
site, token: ctx.token || null, ...(ctx.fetcher ? { fetcher: ctx.fetcher } : {}),
|
|
200
|
+
});
|
|
201
|
+
answers.push({ jsonrpc: "2.0", id, result });
|
|
202
|
+
} else if (name === "tools/call") {
|
|
203
|
+
answers.push({ jsonrpc: "2.0", id, error: { code: -32001, message: "only classes_list and class_get are public; for the rest use /mcp with a nixamp token as the bearer (`nixamp token create`), or OAuth" } });
|
|
204
|
+
} else {
|
|
205
|
+
answers.push({ jsonrpc: "2.0", id, error: { code: -32601, message: `no method ${name}` } });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (answers.length === 0) { response.writeHead(202, CORS); response.end(); return true; }
|
|
209
|
+
response.writeHead(200, { ...CORS, "content-type": "application/json; charset=utf-8" });
|
|
210
|
+
response.end(JSON.stringify(Array.isArray(parsed) ? answers : answers[0]));
|
|
211
|
+
return true;
|
|
212
|
+
}
|