@nimbus-dev/connectors 0.2.4 → 0.4.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/connectors/confluence/src/tools.ts +91 -0
- package/connectors/discord/nimbus.extension.json +2 -2
- package/connectors/discord/src/server.ts +14 -1
- package/connectors/discord/src/tools.ts +64 -2
- package/connectors/github/src/tools.ts +50 -0
- package/connectors/github-actions/src/tools.ts +16 -4
- package/connectors/gitlab/src/tools.ts +151 -0
- package/connectors/jira/src/tools.ts +73 -0
- package/connectors/outlook/src/tool-scope-policy.ts +3 -0
- package/connectors/outlook/src/tools.ts +74 -24
- package/connectors/readwise/nimbus.extension.json +9 -1
- package/connectors/readwise/src/book-mapping.ts +109 -0
- package/connectors/readwise/src/extension.ts +11 -0
- package/connectors/readwise/src/highlight-mapping.ts +88 -0
- package/connectors/readwise/src/readwise-base.ts +6 -0
- package/connectors/readwise/src/record-fields.ts +72 -0
- package/connectors/readwise/src/sync-tool.ts +56 -0
- package/connectors/readwise/src/sync.ts +348 -0
- package/connectors/readwise/src/tools.ts +2 -2
- package/package.json +2 -2
- package/shared/github-rest.ts +13 -2
- package/shared/windows-batch-args.ts +14 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Readwise sync walk, served one page per `__nimbus_sync` call.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the gateway's `readwise-sync.ts` + `runSinglePassPaginatedSync` (Nimbus origin/main
|
|
5
|
+
* 61e034a5), which walked two streams in one run: highlights (`/api/v2/highlights/`), then books
|
|
6
|
+
* (`/api/v2/books/`), each at most {@link READWISE_SYNC_MAX_PAGES} DRF pages of 1000. The gateway
|
|
7
|
+
* now drives the paging, so the position between calls lives in an extension-owned cursor:
|
|
8
|
+
*
|
|
9
|
+
* - `null`, the resting cursor, or ANY value this module did not mint → highlights page 1.
|
|
10
|
+
* - mid-walk: `nimbus-readwise1:` + base64url(`{"stream":"highlights"|"books","page":N}`), the
|
|
11
|
+
* encoding the gateway's `encodeNimbusJsonCursor` used for the resting cursor.
|
|
12
|
+
* - after the last books page: {@link READWISE_RESTING_CURSOR}, byte-identical to the gateway's
|
|
13
|
+
* `pass1Cursor()`, with `hasMore: false`.
|
|
14
|
+
* - a page split for size: the same position plus `"skip":K`, the number of that page's MAPPED
|
|
15
|
+
* items (see {@link mapPage}) already returned. See {@link READWISE_SYNC_RESPONSE_BUDGET_BYTES}.
|
|
16
|
+
*
|
|
17
|
+
* A stream ends when its page is empty, its `next` is null/empty, or its 20th page was read; the
|
|
18
|
+
* highlights stream's end hands off to books page 1 in the same response that carries the page's
|
|
19
|
+
* items. An HTTP error other than 429/401/403, or a body that is not JSON, ends the CURRENT stream
|
|
20
|
+
* with no items — exactly where today's walk degraded a first-page error to an empty result and
|
|
21
|
+
* broke out on a later-page one. A rejected fetch propagates (today's `sync()` threw).
|
|
22
|
+
*
|
|
23
|
+
* The token is only ever an `Authorization` header value: no response, log or error carries it.
|
|
24
|
+
*/
|
|
25
|
+
import type { SyncResponse } from "@nimbus-dev/sdk/sync";
|
|
26
|
+
import { mapReadwiseBookToItem } from "./book-mapping.ts";
|
|
27
|
+
import { mapReadwiseHighlightToItem } from "./highlight-mapping.ts";
|
|
28
|
+
import { READWISE_BASE } from "./readwise-base.ts";
|
|
29
|
+
import { asRecord, type ReadwiseMappingContext, type ReadwiseSyncItem } from "./record-fields.ts";
|
|
30
|
+
|
|
31
|
+
type OkResponse = Extract<SyncResponse, { status: "ok" }>;
|
|
32
|
+
|
|
33
|
+
const CURSOR_PREFIX = "nimbus-readwise1:";
|
|
34
|
+
const PAGE_SIZE = 1000;
|
|
35
|
+
|
|
36
|
+
/** Per-stream page cap, as today. */
|
|
37
|
+
export const READWISE_SYNC_MAX_PAGES = 20;
|
|
38
|
+
|
|
39
|
+
export type ReadwiseStream = "highlights" | "books";
|
|
40
|
+
|
|
41
|
+
export interface ReadwiseCursorPosition {
|
|
42
|
+
readonly stream: ReadwiseStream;
|
|
43
|
+
readonly page: number;
|
|
44
|
+
/**
|
|
45
|
+
* How many of this page's MAPPED items (after null-mapped rows are dropped and repeated ids
|
|
46
|
+
* collapsed) earlier calls already returned. Absent means 0; when present, a positive integer.
|
|
47
|
+
*/
|
|
48
|
+
readonly skip?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The most a response's JSON text may weigh, in UTF-8 bytes. The gateway refuses a `__nimbus_sync`
|
|
53
|
+
* result over 4 MiB (`MCP_TOOL_RESULT_MAX_BYTES`), which fails the run; 3 MiB leaves room for the
|
|
54
|
+
* MCP envelope around the text. A page of 1000 long highlights repeats each text in the title, the
|
|
55
|
+
* preview and the metadata, and can pass 4 MiB, so a page over budget is split across calls.
|
|
56
|
+
*/
|
|
57
|
+
export const READWISE_SYNC_RESPONSE_BUDGET_BYTES = 3 * 1024 * 1024;
|
|
58
|
+
|
|
59
|
+
const STREAMS: Readonly<
|
|
60
|
+
Record<
|
|
61
|
+
ReadwiseStream,
|
|
62
|
+
{
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly map: (raw: unknown, ctx: ReadwiseMappingContext) => ReadwiseSyncItem | null;
|
|
65
|
+
}
|
|
66
|
+
>
|
|
67
|
+
> = {
|
|
68
|
+
highlights: { path: "/api/v2/highlights/", map: mapReadwiseHighlightToItem },
|
|
69
|
+
books: { path: "/api/v2/books/", map: mapReadwiseBookToItem },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function encodePayload(payload: unknown): string {
|
|
73
|
+
return CURSOR_PREFIX + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The cursor a finished pass rests on: the gateway's `pass1Cursor()`, byte for byte. */
|
|
77
|
+
export const READWISE_RESTING_CURSOR = encodePayload({ pass: 1 });
|
|
78
|
+
|
|
79
|
+
export function encodeReadwiseCursor(position: ReadwiseCursorPosition): string {
|
|
80
|
+
const skip = position.skip ?? 0;
|
|
81
|
+
return encodePayload(
|
|
82
|
+
skip === 0
|
|
83
|
+
? { stream: position.stream, page: position.page }
|
|
84
|
+
: { stream: position.stream, page: position.page, skip },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isStream(v: unknown): v is ReadwiseStream {
|
|
89
|
+
return v === "highlights" || v === "books";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The position a cursor this module minted points at, or `null` for anything else — which the
|
|
94
|
+
* walk reads as "start a full pass". Strict: exactly `stream` and `page`, plus `skip` only when it
|
|
95
|
+
* is a positive safe integer; a known stream, a page within the cap, and the canonical encoding
|
|
96
|
+
* (so no two spellings name one position).
|
|
97
|
+
*/
|
|
98
|
+
export function decodeReadwiseCursor(raw: string | null): ReadwiseCursorPosition | null {
|
|
99
|
+
if (!raw?.startsWith(CURSOR_PREFIX)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
let payload: unknown;
|
|
103
|
+
try {
|
|
104
|
+
payload = JSON.parse(
|
|
105
|
+
Buffer.from(raw.slice(CURSOR_PREFIX.length), "base64url").toString("utf8"),
|
|
106
|
+
) as unknown;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const o = asRecord(payload);
|
|
111
|
+
if (o === undefined) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const { stream, page, skip } = o;
|
|
115
|
+
const keyCount = Object.keys(o).length;
|
|
116
|
+
if (keyCount === 3) {
|
|
117
|
+
if (typeof skip !== "number" || !Number.isSafeInteger(skip) || skip < 1) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
} else if (keyCount !== 2) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!isStream(stream) ||
|
|
125
|
+
typeof page !== "number" ||
|
|
126
|
+
!Number.isSafeInteger(page) ||
|
|
127
|
+
page < 1 ||
|
|
128
|
+
page > READWISE_SYNC_MAX_PAGES
|
|
129
|
+
) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const position: ReadwiseCursorPosition =
|
|
133
|
+
typeof skip === "number" ? { stream, page, skip } : { stream, page };
|
|
134
|
+
return encodeReadwiseCursor(position) === raw ? position : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The fetch the walk uses; a seam so tests serve Readwise without a network. */
|
|
138
|
+
export type ReadwiseFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
139
|
+
|
|
140
|
+
export interface ReadwiseSyncDeps {
|
|
141
|
+
/** `READWISE_TOKEN` as the process received it; blank or absent → `unauthenticated`. */
|
|
142
|
+
readonly token: string | undefined;
|
|
143
|
+
/** Defaults to the global `fetch`. */
|
|
144
|
+
readonly fetch?: ReadwiseFetch;
|
|
145
|
+
/** Defaults to `Date.now`. The mappers' `modifiedAt` fallback and the Retry-After clock. */
|
|
146
|
+
readonly now?: () => number;
|
|
147
|
+
/** Defaults to {@link READWISE_SYNC_RESPONSE_BUDGET_BYTES}; tests lower it to split small pages. */
|
|
148
|
+
readonly budgetBytes?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type PageOutcome =
|
|
152
|
+
| { readonly kind: "ok"; readonly parsed: unknown }
|
|
153
|
+
| { readonly kind: "http_error"; readonly status: number; readonly retryAfter: string | null }
|
|
154
|
+
| { readonly kind: "parse_error" };
|
|
155
|
+
|
|
156
|
+
/** A failure's message with every occurrence of the token cut out. */
|
|
157
|
+
function redactedError(e: unknown, token: string): Error {
|
|
158
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
159
|
+
return new Error(`Readwise request failed: ${message.split(token).join("[redacted]")}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function getPage(url: string, token: string, fetchFn: ReadwiseFetch): Promise<PageOutcome> {
|
|
163
|
+
let text: string;
|
|
164
|
+
let res: Response;
|
|
165
|
+
try {
|
|
166
|
+
res = await fetchFn(url, {
|
|
167
|
+
headers: { Authorization: `Token ${token}`, Accept: "application/json" },
|
|
168
|
+
});
|
|
169
|
+
text = await res.text();
|
|
170
|
+
} catch (e) {
|
|
171
|
+
throw redactedError(e, token);
|
|
172
|
+
}
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
return { kind: "http_error", status: res.status, retryAfter: res.headers.get("retry-after") };
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return { kind: "ok", parsed: JSON.parse(text) as unknown };
|
|
178
|
+
} catch {
|
|
179
|
+
return { kind: "parse_error" };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The DRF page envelope both list endpoints use; anything else reads as an empty, final page. */
|
|
184
|
+
function parseReadwisePage(parsed: unknown): { items: unknown[]; hasMore: boolean } {
|
|
185
|
+
const root = asRecord(parsed);
|
|
186
|
+
if (root === undefined) {
|
|
187
|
+
return { items: [], hasMore: false };
|
|
188
|
+
}
|
|
189
|
+
const results = root["results"];
|
|
190
|
+
const items = Array.isArray(results) ? results : [];
|
|
191
|
+
const nextRaw = root["next"];
|
|
192
|
+
const next = typeof nextRaw === "string" && nextRaw !== "" ? nextRaw : null;
|
|
193
|
+
return { items, hasMore: items.length > 0 && next !== null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `Retry-After` as a non-negative integer of ms: delta-seconds, or an HTTP-date (IMF-fixdate or
|
|
198
|
+
* RFC 850, both in GMT) as the time until then, clamped at 0. Anything else — absent, negative,
|
|
199
|
+
* fractional, too large, the obsolete zone-less asctime form — is `undefined`, and the field is
|
|
200
|
+
* left out so the gateway applies its own default.
|
|
201
|
+
*/
|
|
202
|
+
export function parseRetryAfterMs(header: string | null, now: number): number | undefined {
|
|
203
|
+
if (header === null) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const v = header.trim();
|
|
207
|
+
if (/^\d+$/.test(v)) {
|
|
208
|
+
const ms = Number(v) * 1000;
|
|
209
|
+
return Number.isSafeInteger(ms) ? ms : undefined;
|
|
210
|
+
}
|
|
211
|
+
if (!/^[A-Za-z]{3,9}, .+ GMT$/.test(v)) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
const at = Date.parse(v);
|
|
215
|
+
if (!Number.isSafeInteger(at)) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
return Math.max(0, at - now);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pageUrl(position: ReadwiseCursorPosition): string {
|
|
222
|
+
const params = new URLSearchParams({ page_size: String(PAGE_SIZE), page: String(position.page) });
|
|
223
|
+
return `${READWISE_BASE}${STREAMS[position.stream].path}?${params.toString()}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The mapped rows of one page; a repeated externalId keeps its LAST row, as today's upserts did. */
|
|
227
|
+
function mapPage(
|
|
228
|
+
rows: readonly unknown[],
|
|
229
|
+
stream: ReadwiseStream,
|
|
230
|
+
ctx: ReadwiseMappingContext,
|
|
231
|
+
): ReadwiseSyncItem[] {
|
|
232
|
+
const byId = new Map<string, ReadwiseSyncItem>();
|
|
233
|
+
for (const raw of rows) {
|
|
234
|
+
const item = STREAMS[stream].map(raw, ctx);
|
|
235
|
+
if (item !== null) {
|
|
236
|
+
byId.set(item.externalId, item);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return [...byId.values()];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The response that ends `stream`: highlights hand off to books page 1; books end the pass. */
|
|
243
|
+
function endOfStream(stream: ReadwiseStream, items: ReadwiseSyncItem[]): OkResponse {
|
|
244
|
+
return stream === "highlights"
|
|
245
|
+
? {
|
|
246
|
+
status: "ok",
|
|
247
|
+
items,
|
|
248
|
+
cursor: encodeReadwiseCursor({ stream: "books", page: 1 }),
|
|
249
|
+
hasMore: true,
|
|
250
|
+
}
|
|
251
|
+
: { status: "ok", items, cursor: READWISE_RESTING_CURSOR, hasMore: false };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The UTF-8 size of the exact text the tool returns for `response`. */
|
|
255
|
+
export function syncResponseBytes(response: SyncResponse): number {
|
|
256
|
+
return Buffer.byteLength(JSON.stringify(response), "utf8");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* `whole` when it fits the budget; otherwise the largest prefix of `items` that fits, with a
|
|
261
|
+
* cursor that resumes this page right after it. The page's own continuation (next page, handoff,
|
|
262
|
+
* end of pass) lives in `whole`, so only the call that returns the page's LAST item carries it;
|
|
263
|
+
* a remainder still over budget is split again by the next call.
|
|
264
|
+
*
|
|
265
|
+
* At least one item goes out even when that item alone is over budget, so the walk always
|
|
266
|
+
* progresses instead of re-fetching the same page forever. That is safe: one item is bounded — the
|
|
267
|
+
* gateway drops any item whose metadata passes 64 KiB, and its title and preview are one
|
|
268
|
+
* highlight's or book's own text — so a one-item response stays far under the gateway's 4 MiB.
|
|
269
|
+
*/
|
|
270
|
+
function fitToBudget(
|
|
271
|
+
whole: OkResponse,
|
|
272
|
+
position: ReadwiseCursorPosition,
|
|
273
|
+
budget: number,
|
|
274
|
+
): SyncResponse {
|
|
275
|
+
// One item (or none) is never split: that response carries the page's own continuation.
|
|
276
|
+
if (whole.items.length <= 1 || syncResponseBytes(whole) <= budget) {
|
|
277
|
+
return whole;
|
|
278
|
+
}
|
|
279
|
+
const skipped = position.skip ?? 0;
|
|
280
|
+
const prefix = (count: number): OkResponse => ({
|
|
281
|
+
status: "ok",
|
|
282
|
+
items: whole.items.slice(0, count),
|
|
283
|
+
cursor: encodeReadwiseCursor({ ...position, skip: skipped + count }),
|
|
284
|
+
hasMore: true,
|
|
285
|
+
});
|
|
286
|
+
// The largest count in [1, items - 1] that fits; the size only grows with the count, and all
|
|
287
|
+
// of the items (`whole`) are already known not to fit.
|
|
288
|
+
let lo = 1;
|
|
289
|
+
let hi = whole.items.length - 1;
|
|
290
|
+
let best = 1;
|
|
291
|
+
while (lo <= hi) {
|
|
292
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
293
|
+
if (syncResponseBytes(prefix(mid)) <= budget) {
|
|
294
|
+
best = mid;
|
|
295
|
+
lo = mid + 1;
|
|
296
|
+
} else {
|
|
297
|
+
hi = mid - 1;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return prefix(best);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** One `__nimbus_sync` call: fetch the page `cursor` points at and answer with its items. */
|
|
304
|
+
export async function readwiseSyncPage(
|
|
305
|
+
cursor: string | null,
|
|
306
|
+
deps: ReadwiseSyncDeps,
|
|
307
|
+
): Promise<SyncResponse> {
|
|
308
|
+
const token = deps.token?.trim() ?? "";
|
|
309
|
+
if (token === "") {
|
|
310
|
+
return { status: "unauthenticated" };
|
|
311
|
+
}
|
|
312
|
+
const position = decodeReadwiseCursor(cursor) ?? { stream: "highlights", page: 1 };
|
|
313
|
+
const now = (deps.now ?? Date.now)();
|
|
314
|
+
const fetchFn: ReadwiseFetch = deps.fetch ?? ((url, init) => fetch(url, init));
|
|
315
|
+
|
|
316
|
+
const outcome = await getPage(pageUrl(position), token, fetchFn);
|
|
317
|
+
if (outcome.kind === "http_error") {
|
|
318
|
+
if (outcome.status === 429) {
|
|
319
|
+
const retryAfterMs = parseRetryAfterMs(outcome.retryAfter, now);
|
|
320
|
+
return retryAfterMs === undefined
|
|
321
|
+
? { status: "rate_limited" }
|
|
322
|
+
: { status: "rate_limited", retryAfterMs };
|
|
323
|
+
}
|
|
324
|
+
if (outcome.status === 401 || outcome.status === 403) {
|
|
325
|
+
return { status: "unauthenticated" };
|
|
326
|
+
}
|
|
327
|
+
return endOfStream(position.stream, []);
|
|
328
|
+
}
|
|
329
|
+
if (outcome.kind === "parse_error") {
|
|
330
|
+
return endOfStream(position.stream, []);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const page = parseReadwisePage(outcome.parsed);
|
|
334
|
+
// `skip` counts MAPPED items: the same page maps to the same items in the same order, so the
|
|
335
|
+
// count holds across the calls that split it. A field edited in between changes values, not
|
|
336
|
+
// positions; a row added to or removed from the page in between shifts the split by that many.
|
|
337
|
+
const items = mapPage(page.items, position.stream, { syncedAt: now }).slice(position.skip ?? 0);
|
|
338
|
+
const whole: OkResponse =
|
|
339
|
+
page.hasMore && position.page < READWISE_SYNC_MAX_PAGES
|
|
340
|
+
? {
|
|
341
|
+
status: "ok",
|
|
342
|
+
items,
|
|
343
|
+
cursor: encodeReadwiseCursor({ stream: position.stream, page: position.page + 1 }),
|
|
344
|
+
hasMore: true,
|
|
345
|
+
}
|
|
346
|
+
: endOfStream(position.stream, items);
|
|
347
|
+
return fitToBudget(whole, position, deps.budgetBytes ?? READWISE_SYNC_RESPONSE_BUDGET_BYTES);
|
|
348
|
+
}
|
|
@@ -6,15 +6,15 @@ import {
|
|
|
6
6
|
} from "../../../shared/collection-tool-kit.ts";
|
|
7
7
|
import { createJsonGetter, envAuthHeaders } from "../../../shared/env-json-api.ts";
|
|
8
8
|
import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
|
|
9
|
+
import { READWISE_BASE } from "./readwise-base.ts";
|
|
9
10
|
import { filterReadwiseBooks, filterReadwiseHighlights } from "./search-filter.ts";
|
|
10
11
|
|
|
11
|
-
const BASE = "https://readwise.io";
|
|
12
12
|
const HIGHLIGHTS = "/api/v2/highlights/?page_size=1000";
|
|
13
13
|
const BOOKS = "/api/v2/books/?page_size=1000";
|
|
14
14
|
|
|
15
15
|
/** Readwise authenticates with `Authorization: Token <key>`, not Bearer. */
|
|
16
16
|
const readwiseGet = createJsonGetter({
|
|
17
|
-
base:
|
|
17
|
+
base: READWISE_BASE,
|
|
18
18
|
label: "Readwise",
|
|
19
19
|
headers: envAuthHeaders({ env: "READWISE_TOKEN", scheme: "Token" }),
|
|
20
20
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-dev/connectors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "1.32.0",
|
|
29
|
-
"@nimbus-dev/sdk": "^2.
|
|
29
|
+
"@nimbus-dev/sdk": "^2.1.0",
|
|
30
30
|
"@types/nodemailer": "^8.0.2",
|
|
31
31
|
"zod": "^4.6.5"
|
|
32
32
|
},
|
package/shared/github-rest.ts
CHANGED
|
@@ -8,10 +8,19 @@
|
|
|
8
8
|
* places, so a bump had to find all three.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { optionalBaseUrl } from "./env-json-api.ts";
|
|
11
12
|
import { makeRestFetcher, type RestFetchResult } from "./rest-tool-kit.ts";
|
|
12
13
|
|
|
13
14
|
export const GH_API = "https://api.github.com";
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The API base in force: `GITHUB_API_BASE_URL` (a GitHub Enterprise Server `https://host/api/v3`),
|
|
18
|
+
* else {@link GH_API}. Read per call, so a test or an operator can change it without a restart.
|
|
19
|
+
*/
|
|
20
|
+
export function ghApiBase(): string {
|
|
21
|
+
return optionalBaseUrl("GITHUB_API_BASE_URL", GH_API);
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/** The media type GitHub documents for its REST API. */
|
|
16
25
|
export const GH_ACCEPT = "application/vnd.github+json";
|
|
17
26
|
|
|
@@ -24,9 +33,9 @@ export const GH_HEADERS: Record<string, string> = {
|
|
|
24
33
|
"X-GitHub-Api-Version": GH_API_VERSION,
|
|
25
34
|
};
|
|
26
35
|
|
|
27
|
-
/** A Bearer-authenticated JSON request against {@link
|
|
36
|
+
/** A Bearer-authenticated JSON request against {@link ghApiBase} (a relative path or a same-origin URL). */
|
|
28
37
|
export function ghFetch(token: string, path: string, init?: RequestInit): Promise<RestFetchResult> {
|
|
29
|
-
return makeRestFetcher({ apiBase:
|
|
38
|
+
return makeRestFetcher({ apiBase: ghApiBase(), token, defaultHeaders: GH_HEADERS })(path, init);
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/** `/repos/<owner>/<repo>`, both segments URI-encoded. */
|
|
@@ -56,6 +65,8 @@ export function setGhPaging(params: URLSearchParams, paging: GhPaging): void {
|
|
|
56
65
|
* parameters keep the order `setQuery` sets them in.
|
|
57
66
|
*/
|
|
58
67
|
export function ghQueryPath(path: string, setQuery: (params: URLSearchParams) => void): string {
|
|
68
|
+
// Parsed against the FIXED public origin, never the configured base: the result is relative to
|
|
69
|
+
// the base ghFetch prepends, so a GHES `/api/v3` must not be in it.
|
|
59
70
|
const u = new URL(`${GH_API}${path}`);
|
|
60
71
|
setQuery(u.searchParams);
|
|
61
72
|
return `${u.pathname}${u.search}`;
|
|
@@ -77,12 +77,25 @@ export function cmdMetacharacterIn(value: string): string | undefined {
|
|
|
77
77
|
return undefined;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* `name` without the dots and spaces at its end. A loop rather than `/[. ]+$/`, whose engine
|
|
82
|
+
* retries the run from every start position and so takes quadratic time on a long run of dots
|
|
83
|
+
* and spaces that something else follows.
|
|
84
|
+
*/
|
|
85
|
+
function withoutTrailingDotsAndSpaces(name: string): string {
|
|
86
|
+
let end = name.length;
|
|
87
|
+
while (end > 0 && (name[end - 1] === "." || name[end - 1] === " ")) {
|
|
88
|
+
end -= 1;
|
|
89
|
+
}
|
|
90
|
+
return name.slice(0, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
/**
|
|
81
94
|
* Whether Windows runs a file of this name through cmd.exe: a `.cmd` or a `.bat`, in any case,
|
|
82
95
|
* after the trailing dots and spaces Windows drops from a file name — `az.cmd.` opens `az.cmd`.
|
|
83
96
|
*/
|
|
84
97
|
export function isBatchFileName(name: string): boolean {
|
|
85
|
-
const trimmed = name
|
|
98
|
+
const trimmed = withoutTrailingDotsAndSpaces(name).toLowerCase();
|
|
86
99
|
return BATCH_EXTENSIONS.some((ext) => trimmed.endsWith(ext));
|
|
87
100
|
}
|
|
88
101
|
|