Capture current Ansible control plane state
Commit the accumulated infrastructure work that was living only in the working tree: monitoring stack, emergency access/bot, gyro allocator, grimmory, adguard, backup audit and the OpenCode agent definitions. Also ignore Python bytecode, local archives and Nix/direnv artifacts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
|
||||
function unwrap(payload) {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
!Array.isArray(payload) &&
|
||||
Object.hasOwn(payload, "data")
|
||||
) {
|
||||
return payload.data;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export class GrimmoryClient {
|
||||
constructor({ baseUrl, username, passwordFile, fetchImpl = fetch }) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
this.username = username;
|
||||
this.passwordFile = passwordFile;
|
||||
this.fetch = fetchImpl;
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.loginPromise = null;
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
|
||||
async credentials() {
|
||||
let password;
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(
|
||||
this.passwordFile,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) {
|
||||
throw new Error("Grimmory password path must be a regular file");
|
||||
}
|
||||
if (process.getuid && info.uid !== process.getuid()) {
|
||||
throw new Error("Grimmory password file must be owned by the current user");
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new Error("Grimmory password file must not be accessible by group or others");
|
||||
}
|
||||
password = await handle.readFile("utf8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Grimmory password file is missing: ${this.passwordFile}. Run npm run configure in the MCP directory.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
if (!password) throw new Error("Grimmory password file is empty");
|
||||
return { username: this.username, password };
|
||||
}
|
||||
|
||||
async login(attempt = 0) {
|
||||
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(await this.credentials()),
|
||||
});
|
||||
let payload;
|
||||
try {
|
||||
payload = await this.parseResponse(response);
|
||||
} catch (error) {
|
||||
// v3.2.4 can generate the same refresh JWT for concurrent logins in one second.
|
||||
if (error.status === 400 && attempt === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
return this.login(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const tokens = unwrap(payload);
|
||||
if (!tokens?.accessToken || !tokens?.refreshToken) {
|
||||
throw new Error("Grimmory login response does not contain tokens");
|
||||
}
|
||||
this.accessToken = tokens.accessToken;
|
||||
this.refreshToken = tokens.refreshToken;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.refreshToken) return this.login();
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = (async () => {
|
||||
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken: this.refreshToken }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
return this.login();
|
||||
}
|
||||
const tokens = unwrap(await this.parseResponse(response));
|
||||
this.accessToken = tokens.accessToken;
|
||||
this.refreshToken = tokens.refreshToken;
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async ensureLogin() {
|
||||
if (this.accessToken) return;
|
||||
if (!this.loginPromise) {
|
||||
this.loginPromise = this.login().finally(() => {
|
||||
this.loginPromise = null;
|
||||
});
|
||||
}
|
||||
await this.loginPromise;
|
||||
}
|
||||
|
||||
async parseResponse(response) {
|
||||
if (response.status === 204) return null;
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload?.message || payload?.error || text || response.statusText;
|
||||
throw Object.assign(
|
||||
new Error(`Grimmory API ${response.status}: ${message}`),
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async request(path, { optional = false, raw = false, retry = true } = {}) {
|
||||
await this.ensureLogin();
|
||||
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
||||
headers: { authorization: `Bearer ${this.accessToken}` },
|
||||
});
|
||||
if (response.status === 401 && retry) {
|
||||
await this.refresh();
|
||||
return this.request(path, { optional, raw, retry: false });
|
||||
}
|
||||
if (optional && [204, 404].includes(response.status)) return null;
|
||||
if (raw) {
|
||||
if (!response.ok) await this.parseResponse(response);
|
||||
return response;
|
||||
}
|
||||
return unwrap(await this.parseResponse(response));
|
||||
}
|
||||
|
||||
currentUser() {
|
||||
return this.request("/api/v1/users/me");
|
||||
}
|
||||
|
||||
async libraries() {
|
||||
try {
|
||||
return await this.request("/api/v1/app/libraries");
|
||||
} catch (error) {
|
||||
if (!error.message.startsWith("Grimmory API 500:")) throw error;
|
||||
const user = await this.currentUser();
|
||||
if (user.assignedLibraries?.length) return user.assignedLibraries;
|
||||
const books = await this.allBooks();
|
||||
const byLibrary = new Map();
|
||||
for (const book of books) {
|
||||
const entry = byLibrary.get(book.libraryId) || {
|
||||
id: book.libraryId,
|
||||
name: null,
|
||||
bookCount: 0,
|
||||
};
|
||||
entry.bookCount += 1;
|
||||
byLibrary.set(book.libraryId, entry);
|
||||
}
|
||||
for (const entry of byLibrary.values()) {
|
||||
const sample = books.find((book) => book.libraryId === entry.id);
|
||||
if (sample) {
|
||||
const detail = await this.request(
|
||||
`/api/v1/books/${sample.id}?withDescription=false`,
|
||||
);
|
||||
entry.name = detail.libraryName || `Library ${entry.id}`;
|
||||
}
|
||||
}
|
||||
return [...byLibrary.values()];
|
||||
}
|
||||
}
|
||||
|
||||
shelves() {
|
||||
return this.request("/api/v1/app/shelves");
|
||||
}
|
||||
|
||||
authors(params = {}) {
|
||||
return this.request(`/api/v1/app/authors?${query(params)}`);
|
||||
}
|
||||
|
||||
filterOptions(params = {}) {
|
||||
return this.request(`/api/v1/app/filter-options?${query(params)}`);
|
||||
}
|
||||
|
||||
books(params = {}) {
|
||||
return this.request(`/api/v1/app/books?${query(params)}`);
|
||||
}
|
||||
|
||||
searchBooks(search, params = {}) {
|
||||
return this.request(
|
||||
`/api/v1/app/books/search?${query({ q: search, ...params })}`,
|
||||
);
|
||||
}
|
||||
|
||||
async allBooks() {
|
||||
const books = [];
|
||||
const seenPages = new Set();
|
||||
for (let page = 0; page < 10_000; page += 1) {
|
||||
const result = await this.books({ page, size: 50, sort: "title", dir: "asc" });
|
||||
if (!Array.isArray(result?.content)) {
|
||||
throw new Error("Grimmory books response has no content array");
|
||||
}
|
||||
const signature = result.content.map((book) => book.id).join(",");
|
||||
if (seenPages.has(signature) && signature) {
|
||||
throw new Error("Grimmory books pagination repeated a page");
|
||||
}
|
||||
seenPages.add(signature);
|
||||
books.push(...result.content);
|
||||
if (!hasNextPage(result, page, 50)) return books;
|
||||
}
|
||||
throw new Error("Grimmory books pagination exceeded the safety limit");
|
||||
}
|
||||
|
||||
async springPage(path) {
|
||||
const entries = [];
|
||||
const seenPages = new Set();
|
||||
for (let page = 0; page < 10_000; page += 1) {
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
const result = await this.request(`${path}${separator}page=${page}&size=100`);
|
||||
if (!Array.isArray(result?.content)) {
|
||||
throw new Error("Grimmory session response has no content array");
|
||||
}
|
||||
const signature = JSON.stringify(result.content.map((entry) => entry.id));
|
||||
if (seenPages.has(signature) && result.content.length) {
|
||||
throw new Error("Grimmory session pagination repeated a page");
|
||||
}
|
||||
seenPages.add(signature);
|
||||
entries.push(...result.content);
|
||||
if (!hasNextPage(result, page, 100)) return entries;
|
||||
}
|
||||
throw new Error("Grimmory session pagination exceeded the safety limit");
|
||||
}
|
||||
|
||||
async bookBundle(bookId) {
|
||||
const id = Number(bookId);
|
||||
const [book, progress, notebook, annotations, bookmarks, notes, cfiNotes, reviews, sessions] =
|
||||
await Promise.all([
|
||||
this.request(`/api/v1/books/${id}?withDescription=true`),
|
||||
this.request(`/api/v1/app/books/${id}/progress`, { optional: true }),
|
||||
this.request(`/api/v1/notebook/export?bookId=${id}`, { optional: true }),
|
||||
this.request(`/api/v1/annotations/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v1/bookmarks/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v1/book-notes/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v2/book-notes/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v1/reviews/book/${id}`, { optional: true }),
|
||||
this.springPage(`/api/v1/reading-sessions/book/${id}`),
|
||||
]);
|
||||
return {
|
||||
book,
|
||||
progress,
|
||||
notebook: notebook || [],
|
||||
annotations: annotations || [],
|
||||
bookmarks: bookmarks || [],
|
||||
notes: notes || [],
|
||||
cfiNotes: cfiNotes || [],
|
||||
reviews: reviews || [],
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
cover(bookId) {
|
||||
return this.request(`/api/v1/media/book/${Number(bookId)}/cover`, {
|
||||
optional: true,
|
||||
raw: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function query(values) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
params.append(key, String(item));
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function hasNextPage(result, currentPage, pageSize) {
|
||||
if (typeof result.hasNext === "boolean") return result.hasNext;
|
||||
if (typeof result.last === "boolean") return !result.last;
|
||||
const number = result.page?.number ?? result.number ?? currentPage;
|
||||
const totalPages = result.page?.totalPages ?? result.totalPages;
|
||||
if (totalPages !== undefined) return number + 1 < totalPages;
|
||||
if (result.content.length < pageSize) return false;
|
||||
throw new Error("Grimmory books response has no pagination metadata");
|
||||
}
|
||||
|
||||
export { unwrap };
|
||||
Reference in New Issue
Block a user