import { accessSync, chmodSync, constants, lstatSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import type { Artifact } from "../shared/artifacts.ts"; import { isValidArtifactName, MAX_ARTIFACT_SIZE } from "../shared/artifacts.ts"; import { avatarContentTypeForFile, AVATAR_TYPE_ERROR, MAX_AVATAR_UPLOAD_BYTES, } from "../shared/project-avatar.ts"; import { isProjectOwnership, projectOwnershipLabel, type ProjectOwnership, } from "../shared/project-ownership.ts"; import { isProjectType, projectTypeLabel, type ProjectType } from "../shared/project-type.ts"; import { artifactDeleteConfirmed, parseArtifactArgs } from "./artifact-args.ts"; import { confirmed } from "./confirm.ts"; import { parseProjectAvatarArgs, PROJECT_AVATAR_USAGE } from "./project-avatar-args.ts"; import { parseProjectCreateArgs, PROJECT_CREATE_USAGE } from "./project-create-args.ts"; import { parseProjectFavoriteArgs } from "./project-favorite-args.ts"; import { parseProjectFieldArgs } from "./project-field-args.ts"; // Agent-facing CLI: an HTTP client of the hub API (src/app/api). Parity rule: // every operation here must exist in the webapp and vice versa (see repo-root // AGENTS.md). Keep this file dependency-free so a bare checkout can run it. const DEFAULT_URL = "https://hub.raidiant.io"; const hubRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); const configDir = join( process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "raidiant-hub", ); const configPath = join(configDir, "config.json"); interface Config { url?: string; token?: string; } const readConfig = (): Config => { try { return JSON.parse(readFileSync(configPath, "utf8")) as Config; } catch { return {}; } }; const config = readConfig(); const hubUrl = (process.env.RAIDIANT_HUB_URL || config.url || DEFAULT_URL).replace(/\/+$/, ""); const HELP = `raidiant-hub — operate the Raidiant hub Usage: raidiant-hub setup Put the raidiant-hub command on your PATH raidiant-hub login [--url ] Log in via GitHub (approve in browser) raidiant-hub logout Sign out and remove the saved token raidiant-hub projects [--json] List projects, repos, and favorite state raidiant-hub project [--json] Show one project raidiant-hub project create Create a project (type: product|tool; ownership: company|personal) raidiant-hub project name Rename a project raidiant-hub project type Change a project type raidiant-hub project lead Change a project lead raidiant-hub project avatar set Upload a project avatar image raidiant-hub project avatar clear [--yes] Remove a project avatar raidiant-hub project repo add Link a GitHub repo raidiant-hub project repo remove [--yes] Unlink a GitHub repo raidiant-hub project favorite Favorite a project for the signed-in member raidiant-hub project unfavorite Remove a project favorite raidiant-hub project member add Add a project member raidiant-hub project member remove [--yes] Remove a project member raidiant-hub members [--json] List members raidiant-hub member [--json] Show one member raidiant-hub artifacts [--all|--global|--project ] [--json] List artifacts raidiant-hub artifact upload --title [--project <id>] [--description <text>] raidiant-hub artifact get <id> Print an artifact's HTML raidiant-hub artifact update <id> [--title <title>] [--description <text|->] [--project <id|->] [--file <path>] raidiant-hub artifact delete <id> [--yes] Move an artifact to recoverable trash raidiant-hub artifact restore <id> Restore a deleted artifact raidiant-hub artifact url <id> Print the hub URL for an artifact raidiant-hub help Show this help Hub: ${hubUrl} (${config.token ? "logged in" : "not logged in"}) RAIDIANT_HUB_URL overrides the hub URL for one invocation.`; class CliError extends Error {} const fail = (message: string): never => { throw new CliError(message); }; const request = async (path: string, init?: RequestInit): Promise<Response> => { const headers = new Headers(init?.headers); if (config.token) headers.set("Authorization", `Bearer ${config.token}`); let response: Response; try { response = await fetch(hubUrl + path, { ...init, headers }); } catch { return fail( `Cannot reach the hub at ${hubUrl}.` + (hubUrl.includes("localhost") ? " Start it with: pnpm dev" : ""), ); } if (response.status === 401) { fail( config.token ? "The saved login expired or was revoked. Run: raidiant-hub login" : "Not logged in. Run: raidiant-hub login", ); } if (response.status === 403) { fail(`The hub at ${hubUrl} refused the request (origin lock). Use the gate URL.`); } return response; }; const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => { const response = await request(path, init); if (!response.ok) { const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined; fail(body?.error ?? `Hub error ${response.status} on ${path}.`); } return (await response.json()) as T; }; interface Project { id: string; name: string; type: ProjectType; ownership: ProjectOwnership; avatarUrl?: string; leadId?: string; memberIds: string[]; repos: string[]; isFavorite: boolean; } interface Member { id: string; name: string; imageUrl?: string; githubLogin?: string; } interface MemberSummary extends Member { projectCount: number; leadCount: number; } interface MemberDetail extends Member { projects: Project[]; favorites: Project[]; } const printArtifact = (artifact: Artifact) => { console.log(`${artifact.id} ${artifact.title}`); console.log(` project: ${artifact.projectId ?? "global"}`); console.log(` file: ${artifact.fileName} ${artifact.size}B`); if (artifact.description) console.log(` description: ${artifact.description}`); }; const printProject = (project: Project) => { console.log(`${project.id} ${project.name}`); console.log(` type: ${isProjectType(project.type) ? projectTypeLabel(project.type) : project.type}`); console.log( ` ownership: ${isProjectOwnership(project.ownership) ? projectOwnershipLabel(project.ownership) : project.ownership}`, ); console.log(` avatar: ${project.avatarUrl ? hubUrl + project.avatarUrl : "generated"}`); console.log(` favorite: ${project.isFavorite ? "yes" : "no"}`); console.log(` lead: ${project.leadId ?? "unassigned"}`); for (const memberId of project.memberIds) console.log(` member: ${memberId}`); for (const repo of project.repos) console.log(` ${repo}`); }; const printMember = (member: MemberDetail) => { console.log(`${member.id} ${member.name}`); if (member.githubLogin) console.log(` github: https://github.com/${member.githubLogin}`); for (const project of member.favorites) { console.log(` favorite: ${project.id} ${project.name}`); } for (const project of member.projects) { const lead = project.leadId === member.id ? " lead" : ""; const personal = project.ownership === "personal" ? " personal" : ""; console.log(` project: ${project.id} ${project.name}${lead}${personal}`); } }; // --- setup: symlink the bin script into a directory on PATH --- const isWritableDir = (dir: string): boolean => { try { accessSync(dir, constants.W_OK); return statSync(dir).isDirectory(); } catch { return false; } }; const setup = () => { if (process.platform === "win32") fail("setup supports macOS and Linux only."); const binSource = join(hubRoot, "bin", "raidiant-hub.mjs"); chmodSync(binSource, 0o755); const home = homedir(); const pathDirs = (process.env.PATH ?? "").split(":").filter(Boolean); const preferred = [join(home, ".local", "bin"), "/opt/homebrew/bin", "/usr/local/bin"]; let dir = preferred.find((d) => pathDirs.includes(d) && isWritableDir(d)) ?? pathDirs.find((d) => d.startsWith(`${home}/`) && isWritableDir(d)); let pathHint = false; if (!dir) { dir = preferred[0]; mkdirSync(dir, { recursive: true }); pathHint = !pathDirs.includes(dir); } const link = join(dir, "raidiant-hub"); try { if (!lstatSync(link).isSymbolicLink()) { fail(`${link} exists and is not a symlink. Remove it, then run setup again.`); } rmSync(link); } catch (error) { if (error instanceof CliError) throw error; } symlinkSync(binSource, link); console.log(`Linked ${link} -> ${binSource}`); if (pathHint) { console.log(`Add it to your PATH:\n export PATH="${dir}:$PATH"`); } console.log("Next step: raidiant-hub login"); }; // --- login: GitHub device flow — approve in a signed-in browser --- const CLIENT_ID = "raidiant-hub-cli"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const postJson = async (url: string, body: unknown): Promise<Response> => { try { return await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } catch { return fail(`Cannot reach the hub at ${new URL(url).origin}.`); } }; const login = async (urlFlag: string | undefined) => { const url = (urlFlag || process.env.RAIDIANT_HUB_URL || DEFAULT_URL).replace(/\/+$/, ""); const codeResponse = await postJson(`${url}/api/auth/device/code`, { client_id: CLIENT_ID, }); if (codeResponse.status === 404) { fail(`The hub at ${url} has no sign-in configured (local dev needs none).`); } if (!codeResponse.ok) fail(`Hub error ${codeResponse.status}. Login not started.`); const code = (await codeResponse.json()) as { device_code: string; user_code: string; expires_in?: number; interval?: number; }; console.log(`Open ${url}/device?user_code=${encodeURIComponent(code.user_code)}`); console.log(`Code ${code.user_code}`); console.log("Waiting for approval…"); let interval = (code.interval ?? 5) * 1000; const deadline = Date.now() + (code.expires_in ?? 1800) * 1000; while (Date.now() < deadline) { await sleep(interval); const tokenResponse = await postJson(`${url}/api/auth/device/token`, { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: code.device_code, client_id: CLIENT_ID, }); const body = (await tokenResponse.json().catch(() => ({}))) as { access_token?: string; error?: string; error_description?: string; }; if (tokenResponse.ok && body.access_token) { mkdirSync(configDir, { recursive: true }); writeFileSync( configPath, `${JSON.stringify({ url, token: body.access_token }, null, 2)}\n`, { mode: 0o600 }, ); console.log(`Logged in to ${url}. Token saved to ${configPath}`); return; } if (body.error === "authorization_pending") continue; if (body.error === "slow_down") { interval += 5000; continue; } fail(body.error_description ?? body.error ?? `Hub error ${tokenResponse.status}.`); } fail("Login timed out. Run: raidiant-hub login"); }; const logout = async () => { if (config.token && config.url) { // Revoke the session server-side; best effort. await fetch(`${config.url}/api/auth/sign-out`, { method: "POST", headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", }, body: "{}", }).catch(() => undefined); } rmSync(configPath, { force: true }); console.log("Logged out."); }; // --- command dispatch --- const args = process.argv.slice(2); const json = args.includes("--json"); const urlFlagIndex = args.indexOf("--url"); const urlFlag = urlFlagIndex >= 0 ? args[urlFlagIndex + 1] : undefined; const positional = args.filter( (a, i) => a !== "--json" && a !== "--url" && (urlFlagIndex < 0 || i !== urlFlagIndex + 1), ); const showProject = (project: Project) => { if (json) console.log(JSON.stringify(project, null, 2)); else printProject(project); }; const patchProject = (projectId: string, change: Record<string, string>) => requestJson<Project>(`/api/projects/${encodeURIComponent(projectId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(change), }); const uploadProjectAvatar = async (projectId: string, path: string): Promise<Project> => { const contentType = avatarContentTypeForFile(path); if (!contentType) return fail(AVATAR_TYPE_ERROR); let image: Buffer; try { image = readFileSync(path); } catch { return fail(`Cannot read the image file: ${path}`); } if (image.byteLength === 0) return fail("The avatar image is empty."); if (image.byteLength > MAX_AVATAR_UPLOAD_BYTES) { return fail("Avatar images must be smaller than 5 MB."); } return requestJson<Project>(`/api/projects/${encodeURIComponent(projectId)}/avatar`, { method: "PUT", headers: { "Content-Type": contentType }, body: new Uint8Array(image), }); }; const confirmProjectChange = async ( question: string, yes: boolean, nonInteractiveError: string, ): Promise<boolean> => { try { return await confirmed(question, yes, nonInteractiveError); } catch (error) { return fail(error instanceof Error ? error.message : String(error)); } }; const run = async (): Promise<number> => { switch (positional[0]) { case "setup": { setup(); return 0; } case "login": { await login(urlFlag); return 0; } case "logout": { await logout(); return 0; } case "projects": { const projects = await requestJson<Project[]>("/api/projects"); if (json) console.log(JSON.stringify(projects, null, 2)); else for (const project of projects) printProject(project); return 0; } case "project": { if (!positional[1]) break; if (positional[1] === "create") { const parsed = parseProjectCreateArgs(positional) ?? { error: PROJECT_CREATE_USAGE, }; if ("error" in parsed) { fail(parsed.error); } else { const project = await requestJson<Project>("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: parsed.id, type: parsed.type, ownership: parsed.ownership, name: parsed.name, }), }); if (json) console.log(JSON.stringify(project, null, 2)); else printProject(project); return 0; } } if (positional[1] === "favorite" || positional[1] === "unfavorite") { const parsed = parseProjectFavoriteArgs(positional) ?? { error: "Usage: raidiant-hub project favorite <id> | project unfavorite <id>", }; if ("error" in parsed) { fail(parsed.error); } else { const project = await requestJson<Project>( `/api/projects/${encodeURIComponent(parsed.projectId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ favorite: parsed.action === "favorite" }), }, ); if (json) console.log(JSON.stringify(project, null, 2)); else printProject(project); return 0; } } if (positional[1] === "avatar") { const parsed = parseProjectAvatarArgs(positional) ?? { error: PROJECT_AVATAR_USAGE }; if ("error" in parsed) { fail(parsed.error); } else if (parsed.action === "set") { showProject(await uploadProjectAvatar(parsed.projectId, parsed.path)); return 0; } else { const ok = await confirmProjectChange( `Remove the avatar of project ${parsed.projectId}?`, parsed.yes, "Project avatar clear needs an interactive terminal or --yes.", ); if (!ok) { console.log("Avatar clear cancelled."); return 0; } showProject( await requestJson<Project>( `/api/projects/${encodeURIComponent(parsed.projectId)}/avatar`, { method: "DELETE" }, ), ); return 0; } } const field = parseProjectFieldArgs(positional); if (field) { if ("error" in field) { fail(field.error); } else { switch (field.kind) { case "name": showProject(await patchProject(field.projectId, { name: field.name })); return 0; case "type": showProject(await patchProject(field.projectId, { type: field.type })); return 0; case "repoAdd": showProject(await patchProject(field.projectId, { addRepo: field.url })); return 0; case "repoRemove": { const ok = await confirmProjectChange( `Unlink ${field.url} from project ${field.projectId}?`, field.yes, "Project repo remove needs an interactive terminal or --yes.", ); if (!ok) { console.log("Repo remove cancelled."); return 0; } showProject(await patchProject(field.projectId, { removeRepo: field.url })); return 0; } case "memberAdd": showProject(await patchProject(field.projectId, { addMemberId: field.memberId })); return 0; case "memberRemove": { const ok = await confirmProjectChange( `Remove member ${field.memberId} from project ${field.projectId}?`, field.yes, "Project member remove needs an interactive terminal or --yes.", ); if (!ok) { console.log("Member remove cancelled."); return 0; } showProject( await patchProject(field.projectId, { removeMemberId: field.memberId }), ); return 0; } } } } if (positional[1] === "lead" && positional[2] && positional[3]) { showProject(await patchProject(positional[2], { leadId: positional[3] })); return 0; } const response = await request(`/api/projects/${encodeURIComponent(positional[1])}`); if (response.status === 404) fail(`No project with id "${positional[1]}".`); if (!response.ok) fail(`Hub error ${response.status}.`); const project = (await response.json()) as Project; if (json) console.log(JSON.stringify(project, null, 2)); else printProject(project); return 0; } case "members": { const members = await requestJson<MemberSummary[]>("/api/members"); if (json) console.log(JSON.stringify(members, null, 2)); else { for (const member of members) { const github = member.githubLogin ? ` @${member.githubLogin}` : ""; const leads = member.leadCount > 0 ? ` leads:${member.leadCount}` : ""; console.log( `${member.id} ${member.name}${github} projects:${member.projectCount}${leads}`, ); } } return 0; } case "member": { if (!positional[1]) break; const response = await request(`/api/members/${encodeURIComponent(positional[1])}`); if (response.status === 404) fail(`No member with id "${positional[1]}".`); if (!response.ok) fail(`Hub error ${response.status}.`); const member = (await response.json()) as MemberDetail; if (json) console.log(JSON.stringify(member, null, 2)); else printMember(member); return 0; } case "artifacts": case "artifact": { const parsed = parseArtifactArgs(positional); if (!parsed) break; if ("error" in parsed) return fail(parsed.error); switch (parsed.kind) { case "list": { const query = parsed.scope === "project" ? `?scope=project&projectId=${encodeURIComponent(parsed.projectId)}` : `?scope=${parsed.scope}`; const artifacts = await requestJson<Artifact[]>(`/api/artifacts${query}`); if (json) console.log(JSON.stringify(artifacts, null, 2)); else for (const artifact of artifacts) printArtifact(artifact); return 0; } case "upload": { const fileName = basename(parsed.path).replaceAll(" ", "-"); if (!isValidArtifactName(fileName)) { fail(`Invalid artifact name: ${fileName} (must be a plain .html filename)`); } const content = readFileSync(parsed.path, "utf8"); if (Buffer.byteLength(content, "utf8") > MAX_ARTIFACT_SIZE) { fail("File is larger than 10 MB."); } const artifact = await requestJson<Artifact>("/api/artifacts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: parsed.title, description: parsed.description, projectId: parsed.projectId, fileName, content, }), }); if (json) console.log(JSON.stringify(artifact, null, 2)); else printArtifact(artifact); return 0; } case "get": { const response = await request( `/api/artifacts/${encodeURIComponent(parsed.id)}/content`, ); if (response.status === 404) fail(`No artifact with id "${parsed.id}".`); if (!response.ok) fail(`Hub error ${response.status}.`); process.stdout.write(await response.text()); return 0; } case "update": { const body: Record<string, string | null> = {}; if (parsed.title !== undefined) body.title = parsed.title; if (parsed.description !== undefined) body.description = parsed.description; if (parsed.projectId !== undefined) body.projectId = parsed.projectId; if (parsed.path !== undefined) { const fileName = basename(parsed.path).replaceAll(" ", "-"); if (!isValidArtifactName(fileName)) { fail(`Invalid artifact name: ${fileName} (must be a plain .html filename)`); } const content = readFileSync(parsed.path, "utf8"); if (Buffer.byteLength(content, "utf8") > MAX_ARTIFACT_SIZE) { fail("File is larger than 10 MB."); } body.fileName = fileName; body.content = content; } const artifact = await requestJson<Artifact>( `/api/artifacts/${encodeURIComponent(parsed.id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); if (json) console.log(JSON.stringify(artifact, null, 2)); else printArtifact(artifact); return 0; } case "delete": { let confirmed: boolean; try { confirmed = await artifactDeleteConfirmed(parsed.id, parsed.yes); } catch (error) { return fail(error instanceof Error ? error.message : String(error)); } if (!confirmed) { console.log("Delete cancelled."); return 0; } const response = await request(`/api/artifacts/${encodeURIComponent(parsed.id)}`, { method: "DELETE", }); if (response.status === 404) fail(`No artifact with id "${parsed.id}".`); if (!response.ok) fail(`Hub error ${response.status}.`); console.log(`Deleted ${parsed.id}`); return 0; } case "restore": { const artifact = await requestJson<Artifact>( `/api/artifacts/${encodeURIComponent(parsed.id)}/restore`, { method: "POST" }, ); if (json) console.log(JSON.stringify(artifact, null, 2)); else printArtifact(artifact); return 0; } case "url": console.log(`${hubUrl}/artifacts/${parsed.id}`); return 0; } break; } case "help": case undefined: { console.log(HELP); return positional[0] ? 0 : 2; } default: { console.error(`Unknown command: ${positional[0]}\n\n${HELP}`); return 2; } } console.error(HELP); return 2; }; run().then( (code) => process.exit(code), (error) => { console.error(error instanceof CliError ? error.message : String(error)); process.exit(1); }, );