@monadeo.com/grimoire-cli 0.3.7 → 0.4.1

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/index.js CHANGED
@@ -10,7 +10,7 @@ import { join as join6 } from "node:path";
10
10
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
- var DEFAULT_API_BASE = "https://grimoire-api.monadeo.com";
13
+ var DEFAULT_API_BASE = "https://cli.grimoire.monadeo.com";
14
14
  function globalConfigPath() {
15
15
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "grimoire", "config.json");
16
16
  }
@@ -99,28 +99,38 @@ async function fetchWithTimeout(url, init = {}) {
99
99
  }
100
100
 
101
101
  // ../core/dist/auth.js
102
+ function configDir() {
103
+ return join2(process.env.XDG_CONFIG_HOME ?? join2(homedir2(), ".config"), "grimoire");
104
+ }
102
105
  function credentialsPath() {
103
- return join2(process.env.XDG_CONFIG_HOME ?? join2(homedir2(), ".config"), "grimoire", "credentials.json");
106
+ return join2(configDir(), "credentials.json");
104
107
  }
105
- function storeRefreshToken(token) {
108
+ function storeSession(session) {
106
109
  const path = credentialsPath();
107
110
  mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
108
- writeFileSync2(path, JSON.stringify({ refresh_token: token }) + "\n", { mode: 384 });
111
+ writeFileSync2(path, JSON.stringify(session) + "\n", { mode: 384 });
109
112
  }
110
- function readRefreshToken() {
113
+ function readSession() {
111
114
  try {
112
115
  const parsed = JSON.parse(readFileSync2(credentialsPath(), "utf8"));
113
- return typeof parsed.refresh_token === "string" && parsed.refresh_token !== "" ? parsed.refresh_token : void 0;
116
+ if (typeof parsed.refresh_token === "string" && parsed.refresh_token !== "" && typeof parsed.supabase_url === "string" && typeof parsed.supabase_anon_key === "string") {
117
+ return {
118
+ refresh_token: parsed.refresh_token,
119
+ supabase_url: parsed.supabase_url,
120
+ supabase_anon_key: parsed.supabase_anon_key
121
+ };
122
+ }
123
+ return void 0;
114
124
  } catch {
115
125
  return void 0;
116
126
  }
117
127
  }
118
- function clearRefreshToken() {
128
+ function clearSession() {
119
129
  if (existsSync2(credentialsPath()))
120
130
  rmSync(credentialsPath());
121
131
  }
122
132
  function machineTokenPath() {
123
- return join2(process.env.XDG_CONFIG_HOME ?? join2(homedir2(), ".config"), "grimoire", "machine-token");
133
+ return join2(configDir(), "machine-token");
124
134
  }
125
135
  function storeMachineToken(token) {
126
136
  const path = machineTokenPath();
@@ -139,56 +149,136 @@ function clearMachineToken() {
139
149
  if (existsSync2(machineTokenPath()))
140
150
  rmSync(machineTokenPath());
141
151
  }
152
+ async function fetchAuthConfig(apiBase) {
153
+ const res = await fetchWithTimeout(`${apiBase.replace(/\/+$/, "")}/v1/auth/config`);
154
+ if (!res.ok)
155
+ throw new ApiError(res.status, "auth_config_unavailable", await res.text());
156
+ const body = await res.json();
157
+ if (typeof body.supabase_url !== "string" || typeof body.supabase_anon_key !== "string" || typeof body.oauth_provider !== "string" || !Array.isArray(body.redirect_urls) || body.redirect_urls.length === 0) {
158
+ throw new ApiError(res.status, "auth_config_malformed", body);
159
+ }
160
+ return {
161
+ supabase_url: body.supabase_url,
162
+ supabase_anon_key: body.supabase_anon_key,
163
+ oauth_provider: body.oauth_provider,
164
+ redirect_urls: body.redirect_urls.map(String)
165
+ };
166
+ }
167
+ function parseDetail(text) {
168
+ try {
169
+ return JSON.parse(text);
170
+ } catch {
171
+ return text;
172
+ }
173
+ }
174
+ async function gotrueToken(supabaseUrl, anonKey, grantType, body) {
175
+ const res = await fetchWithTimeout(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/token?grant_type=${grantType}`, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json", apikey: anonKey },
178
+ body: JSON.stringify(body)
179
+ });
180
+ const text = await res.text();
181
+ if (!res.ok) {
182
+ throw new ApiError(res.status, grantType === "pkce" ? "login_failed" : "refresh_failed", parseDetail(text));
183
+ }
184
+ const parsed = JSON.parse(text);
185
+ if (typeof parsed.access_token !== "string" || typeof parsed.refresh_token !== "string" || typeof parsed.expires_in !== "number") {
186
+ throw new ApiError(res.status, "token_malformed", parsed);
187
+ }
188
+ return {
189
+ access_token: parsed.access_token,
190
+ refresh_token: parsed.refresh_token,
191
+ expires_in: parsed.expires_in
192
+ };
193
+ }
142
194
  function base64url(buf) {
143
195
  return buf.toString("base64url");
144
196
  }
145
- async function browserLogin(apiBase, openBrowser2) {
146
- const broker = apiBase.replace(/\/+$/, "");
197
+ async function bindCallback(server, redirectUrls) {
198
+ for (const candidate of redirectUrls) {
199
+ const port = Number(new URL(candidate).port);
200
+ const bound = await new Promise((resolve) => {
201
+ const onError = () => {
202
+ server.off("error", onError);
203
+ resolve(false);
204
+ };
205
+ server.once("error", onError);
206
+ server.listen(port, "127.0.0.1", () => {
207
+ server.off("error", onError);
208
+ resolve(true);
209
+ });
210
+ });
211
+ if (bound)
212
+ return candidate;
213
+ }
214
+ throw new Error(`No free login callback port among: ${redirectUrls.join(", ")}`);
215
+ }
216
+ async function browserLogin(apiBase, openBrowser, timeoutMs = 3e5) {
217
+ const config = await fetchAuthConfig(apiBase);
147
218
  const verifier = base64url(randomBytes(32));
148
219
  const challenge = base64url(createHash("sha256").update(verifier).digest());
149
220
  const state = base64url(randomBytes(16));
150
221
  const code = await new Promise((resolve, reject) => {
151
- const server = createServer((req, res2) => {
222
+ let timer;
223
+ const finish2 = (outcome) => {
224
+ if (timer)
225
+ clearTimeout(timer);
226
+ server.closeAllConnections();
227
+ server.close();
228
+ if ("code" in outcome)
229
+ resolve(outcome.code);
230
+ else
231
+ reject(outcome.error);
232
+ };
233
+ const server = createServer((req, res) => {
152
234
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
235
+ if (req.method !== "GET" || url.pathname !== `/callback/${state}`) {
236
+ res.writeHead(404).end();
237
+ return;
238
+ }
239
+ const failure = url.searchParams.get("error_description") ?? url.searchParams.get("error");
153
240
  const received = url.searchParams.get("code");
154
- if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state || !received) {
155
- res2.writeHead(404).end();
241
+ if (failure || !received) {
242
+ res.writeHead(400, { "Content-Type": "text/plain" }).end(`Login failed: ${failure ?? "no code"}`);
243
+ finish2({ error: new Error(`Login failed: ${failure ?? "no code returned"}`) });
156
244
  return;
157
245
  }
158
- res2.writeHead(302, { Location: `${broker}/auth/cli/success` }).end();
159
- clearTimeout(timer);
160
- server.close();
161
- resolve(received);
162
- });
163
- server.listen(0, "127.0.0.1", () => {
164
- const port = server.address().port;
165
- const redirect = `http://127.0.0.1:${port}/callback`;
166
- openBrowser2(`${broker}/auth/cli/start?code_challenge=${challenge}&code_challenge_method=S256&state=${state}&redirect_uri=${encodeURIComponent(redirect)}`);
246
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("Logged in to Grimoire. You can close this tab.");
247
+ finish2({ code: received });
167
248
  });
168
- const timer = setTimeout(() => {
169
- server.close();
170
- reject(new Error("Login timed out"));
171
- }, 3e5);
249
+ bindCallback(server, config.redirect_urls).then((redirectBase) => {
250
+ timer = setTimeout(() => finish2({ error: new Error("Login timed out") }), timeoutMs);
251
+ const authorize = new URL(`${config.supabase_url.replace(/\/+$/, "")}/auth/v1/authorize`);
252
+ authorize.searchParams.set("provider", config.oauth_provider);
253
+ authorize.searchParams.set("redirect_to", `${redirectBase.replace(/\/+$/, "")}/${state}`);
254
+ authorize.searchParams.set("code_challenge", challenge);
255
+ authorize.searchParams.set("code_challenge_method", "s256");
256
+ openBrowser(authorize.toString());
257
+ }).catch((err) => finish2({ error: err }));
172
258
  });
173
- const res = await fetchWithTimeout(`${broker}/auth/cli/exchange`, {
174
- method: "POST",
175
- headers: { "Content-Type": "application/json" },
176
- body: JSON.stringify({ code, code_verifier: verifier })
259
+ const tokens = await gotrueToken(config.supabase_url, config.supabase_anon_key, "pkce", {
260
+ auth_code: code,
261
+ code_verifier: verifier
262
+ });
263
+ storeSession({
264
+ refresh_token: tokens.refresh_token,
265
+ supabase_url: config.supabase_url,
266
+ supabase_anon_key: config.supabase_anon_key
177
267
  });
178
- if (!res.ok)
179
- throw new Error(`Token exchange failed: ${res.status}`);
180
- const { refresh_token } = await res.json();
181
- if (typeof refresh_token !== "string" || refresh_token.length === 0) {
182
- throw new Error("Token exchange succeeded but returned no refresh token");
183
- }
184
- storeRefreshToken(refresh_token);
185
268
  }
186
269
 
187
270
  // ../core/dist/client.js
271
+ var CODES = {
272
+ 401: "unauthorized",
273
+ 403: "forbidden",
274
+ 404: "not_found",
275
+ 422: "invalid_request",
276
+ 429: "quota_exceeded"
277
+ };
188
278
  var GrimoireClient = class {
189
279
  baseUrl;
190
280
  machineToken;
191
- cachedIdToken;
281
+ cachedAccessToken;
192
282
  refreshInFlight;
193
283
  constructor(opts = {}) {
194
284
  this.baseUrl = (opts.baseUrl ?? loadGlobalConfig().apiBaseUrl).replace(/\/+$/, "");
@@ -197,38 +287,36 @@ var GrimoireClient = class {
197
287
  async bearer() {
198
288
  if (this.machineToken)
199
289
  return this.machineToken;
200
- if (this.cachedIdToken && this.cachedIdToken.expiresAt > Date.now() + 6e4) {
201
- return this.cachedIdToken.token;
290
+ if (this.cachedAccessToken && this.cachedAccessToken.expiresAt > Date.now() + 6e4) {
291
+ return this.cachedAccessToken.token;
202
292
  }
203
- this.refreshInFlight ??= this.refreshIdToken().finally(() => {
293
+ this.refreshInFlight ??= this.refreshAccessToken().finally(() => {
204
294
  this.refreshInFlight = void 0;
205
295
  });
206
296
  return this.refreshInFlight;
207
297
  }
208
- // Exchange the Firebase refresh token for a fresh ID token (silent refresh).
209
- async refreshIdToken() {
210
- const refresh = readRefreshToken();
211
- if (!refresh)
298
+ // Exchange the stored refresh token for a fresh access token. GoTrue rotates
299
+ // refresh tokens, so the new one replaces the stored one every time.
300
+ async refreshAccessToken() {
301
+ const session = readSession();
302
+ if (!session)
212
303
  throw new ApiError(401, "not_logged_in", "Run `grimoire login`");
213
- const res = await fetchWithTimeout(`${this.baseUrl}/auth/cli/refresh`, {
214
- method: "POST",
215
- headers: { "Content-Type": "application/json" },
216
- body: JSON.stringify({ refresh_token: refresh })
217
- });
218
- if (!res.ok)
219
- throw new ApiError(res.status, "refresh_failed", "Run `grimoire login`");
220
- let payload;
304
+ let tokens;
221
305
  try {
222
- payload = await res.json();
223
- } catch {
224
- throw new ApiError(res.status, "refresh_failed", "Malformed refresh response");
225
- }
226
- const { id_token, expires_in } = payload;
227
- if (typeof id_token !== "string" || typeof expires_in !== "number") {
228
- throw new ApiError(res.status, "refresh_failed", "Malformed refresh response");
306
+ tokens = await gotrueToken(session.supabase_url, session.supabase_anon_key, "refresh_token", {
307
+ refresh_token: session.refresh_token
308
+ });
309
+ } catch (err) {
310
+ if (err instanceof ApiError)
311
+ throw new ApiError(err.status, "refresh_failed", "Run `grimoire login`");
312
+ throw err;
229
313
  }
230
- this.cachedIdToken = { token: id_token, expiresAt: Date.now() + expires_in * 1e3 };
231
- return id_token;
314
+ storeSession({ ...session, refresh_token: tokens.refresh_token });
315
+ this.cachedAccessToken = {
316
+ token: tokens.access_token,
317
+ expiresAt: Date.now() + tokens.expires_in * 1e3
318
+ };
319
+ return tokens.access_token;
232
320
  }
233
321
  async refreshSession() {
234
322
  await this.bearer();
@@ -245,11 +333,12 @@ var GrimoireClient = class {
245
333
  async request(path, init = {}, auth = true) {
246
334
  let res = await this.send(path, init, auth);
247
335
  if (res.status === 401 && auth && !this.machineToken) {
248
- this.cachedIdToken = void 0;
336
+ this.cachedAccessToken = void 0;
249
337
  res = await this.send(path, init, auth);
250
338
  }
251
339
  return this.parseResponse(res);
252
340
  }
341
+ // FastAPI errors carry {"detail": "..."} (string) or {"detail": [...]} (422).
253
342
  async parseResponse(res) {
254
343
  const isJson = res.headers.get("content-type")?.includes("json") ?? false;
255
344
  const text = await res.text();
@@ -264,33 +353,28 @@ var GrimoireClient = class {
264
353
  }
265
354
  }
266
355
  if (!res.ok) {
267
- const code = parsed && typeof body === "object" && body !== null ? body.error ?? "error" : "error";
268
- throw new ApiError(res.status, code, parsed ? body : text);
356
+ const detail = parsed && typeof body === "object" && body !== null ? body.detail : void 0;
357
+ throw new ApiError(res.status, CODES[res.status] ?? "error", detail ?? (parsed ? body : text));
269
358
  }
270
359
  if (isJson)
271
360
  return parsed ? body : {};
272
361
  return text;
273
362
  }
274
363
  search(input) {
275
- return this.request("/v1/search", {
276
- method: "POST",
277
- body: JSON.stringify(input)
278
- });
364
+ return this.request("/v1/search", { method: "POST", body: JSON.stringify(input) });
279
365
  }
280
- listSources(q) {
281
- return this.request(`/v1/sources${q ? `?q=${encodeURIComponent(q)}` : ""}`);
366
+ listSources() {
367
+ return this.request("/v1/sources");
282
368
  }
283
- listVersions(sourceId) {
284
- return this.request(`/v1/sources/${encodeURIComponent(sourceId)}/versions`);
369
+ listVersions(product) {
370
+ return this.request(`/v1/sources/${encodeURIComponent(product)}/versions`);
285
371
  }
286
- getContext(chunkId, window = 2) {
287
- return this.request(`/v1/documents/${encodeURIComponent(chunkId)}/context?window=${window}`);
372
+ getDoc(pointId, window = 2) {
373
+ return this.request(`/v1/doc/${encodeURIComponent(pointId)}?window=${window}`);
288
374
  }
289
- reportResult(chunkId, verdict, note) {
290
- return this.request("/v1/feedback", {
291
- method: "POST",
292
- body: JSON.stringify({ chunk_id: chunkId, verdict, note })
293
- });
375
+ reportResult(pointId, verdict, note) {
376
+ const body = { point_id: pointId, verdict, ...note ? { note } : {} };
377
+ return this.request("/v1/report", { method: "POST", body: JSON.stringify(body) });
294
378
  }
295
379
  submitSource(body) {
296
380
  return this.request("/v1/sources", { method: "POST", body: JSON.stringify(body) });
@@ -298,12 +382,58 @@ var GrimoireClient = class {
298
382
  getJob(jobId) {
299
383
  return this.request(`/v1/jobs/${encodeURIComponent(jobId)}`);
300
384
  }
385
+ me() {
386
+ return this.request("/v1/me");
387
+ }
388
+ reviewQueue() {
389
+ return this.request("/v1/staff/review-queue");
390
+ }
391
+ approveJob(jobId) {
392
+ return this.request(`/v1/staff/jobs/${encodeURIComponent(jobId)}/approve`, { method: "POST" });
393
+ }
394
+ listUsers() {
395
+ return this.request("/v1/staff/users");
396
+ }
397
+ grantUser(subject, name) {
398
+ return this.request("/v1/staff/users", {
399
+ method: "POST",
400
+ body: JSON.stringify({ subject, name })
401
+ });
402
+ }
403
+ revokeUser(subject) {
404
+ return this.request(`/v1/staff/users/${encodeURIComponent(subject)}`, { method: "DELETE" });
405
+ }
406
+ mintToken(name, quotaPerDay) {
407
+ return this.request("/v1/staff/tokens", {
408
+ method: "POST",
409
+ body: JSON.stringify({ name, quota_per_day: quotaPerDay })
410
+ });
411
+ }
412
+ recrawlSource(sourceId) {
413
+ return this.request(`/v1/staff/sources/${encodeURIComponent(sourceId)}/recrawl`, { method: "POST" });
414
+ }
415
+ rejectJob(jobId, reason) {
416
+ return this.request(`/v1/staff/jobs/${encodeURIComponent(jobId)}/reject`, {
417
+ method: "POST",
418
+ body: JSON.stringify({ reason })
419
+ });
420
+ }
301
421
  };
302
422
 
303
423
  // src/args.ts
304
424
  var UsageError = class extends Error {
305
425
  };
306
- var BOOL_FLAGS = /* @__PURE__ */ new Set(["json", "compact", "watch", "private", "names", "http", "unset"]);
426
+ var BOOL_FLAGS = /* @__PURE__ */ new Set([
427
+ "json",
428
+ "compact",
429
+ "watch",
430
+ "names",
431
+ "http",
432
+ "unset",
433
+ "rolling",
434
+ "debug",
435
+ "no-launch-browser"
436
+ ]);
307
437
  function parseArgs(argv, aliases = {}, allowed) {
308
438
  const positionals = [];
309
439
  const flags = {};
@@ -354,39 +484,39 @@ function requireFlagOneOf(parsed, name, allowed, usage) {
354
484
 
355
485
  // src/output.ts
356
486
  var EXIT = { ok: 0, apiError: 1, authRequired: 2, quota: 3, notFound: 4 };
357
- function noteRerank(status) {
358
- if (status === void 0) return;
359
- const label = status === "used" ? "reranker: used" : status === "degraded" ? "reranker: degraded (fused order)" : "reranker: disabled";
360
- process.stderr.write(`${label}
487
+ function preamble(res) {
488
+ const versions = Object.entries(res.resolved_versions).map(([product, version]) => `${product}@${version}`).join(", ");
489
+ process.stderr.write(`sources: ${versions} \xB7 retrievals remaining: ${res.retrievals_remaining}
361
490
  `);
362
- }
363
- function warnWeak(confidence) {
364
- if (confidence === "weak") {
365
- process.stderr.write(
366
- "note: low-confidence results \u2014 the docs may not cover this; tell the user rather than guessing.\n"
367
- );
491
+ process.stderr.write(`note: ${res.untrusted_content_notice}
492
+ `);
493
+ if (res.results.length === 0) {
494
+ process.stderr.write("note: no result passed the relevance threshold \u2014 the docs may not cover this.\n");
368
495
  }
369
496
  }
370
- function printResults(results, confidence, rerankStatus) {
371
- noteRerank(rerankStatus);
372
- warnWeak(confidence);
373
- for (const r of results) {
374
- const path = (r.heading_path ?? []).join(" \u203A ");
375
- process.stdout.write(`
376
- ${r.score.toFixed(3)} ${r.source}@${r.version} ${path}
377
- ${r.origin_url}
378
- `);
497
+ function heading(r) {
498
+ return r.heading_path.join(" \u203A ");
499
+ }
500
+ function printResults(res) {
501
+ preamble(res);
502
+ for (const r of res.results) {
503
+ process.stdout.write(
504
+ `
505
+ ${r.score.toFixed(3)} ${r.product}@${r.version} ${heading(r)}
506
+ ${r.source_url}
507
+ point_id: ${r.point_id}
508
+ `
509
+ );
379
510
  const preview = r.text.length > 500 ? `${r.text.slice(0, 500)}\u2026` : r.text;
380
511
  process.stdout.write(`${preview}
381
512
  `);
382
513
  }
383
514
  }
384
- function printCompact(results, confidence, rerankStatus) {
385
- noteRerank(rerankStatus);
386
- warnWeak(confidence);
387
- for (const r of results) {
515
+ function printCompact(res) {
516
+ preamble(res);
517
+ for (const r of res.results) {
388
518
  process.stdout.write(
389
- `${r.score.toFixed(3)} | ${r.source}@${r.version} | ${(r.heading_path ?? []).join(" \u203A ")} | ${r.origin_url}
519
+ `${r.score.toFixed(3)} | ${r.product}@${r.version} | ${heading(r)} | ${r.source_url} | ${r.point_id}
390
520
  `
391
521
  );
392
522
  }
@@ -438,7 +568,7 @@ function setupCodex() {
438
568
  function hasSession() {
439
569
  if (process.env.GRIMOIRE_AUTH_TOKEN) return true;
440
570
  try {
441
- return readMachineToken() !== void 0 || readRefreshToken() !== void 0;
571
+ return readMachineToken() !== void 0 || readSession() !== void 0;
442
572
  } catch {
443
573
  return false;
444
574
  }
@@ -538,7 +668,7 @@ var CONFIG_KEYS = {
538
668
  describe: `API origin without a path (default ${DEFAULT_API_BASE}; env GRIMOIRE_API_URL overrides)`,
539
669
  parse: (raw) => {
540
670
  if (!/^https?:\/\/[^/]+$/.test(raw)) {
541
- throw new UsageError("api-url must be a bare origin, e.g. https://grimoire-api.monadeo.com");
671
+ throw new UsageError("api-url must be a bare origin, e.g. https://api.example.com");
542
672
  }
543
673
  return raw;
544
674
  }
@@ -547,23 +677,10 @@ var CONFIG_KEYS = {
547
677
  prop: "updateCheckHours",
548
678
  describe: "hours between CLI update checks (default 24, 0 disables)",
549
679
  parse: (raw) => intInRange("update-check-hours", raw, 0)
550
- },
551
- language: {
552
- prop: "defaultLanguage",
553
- describe: "default search language filter",
554
- parse: (raw) => {
555
- if (!raw) throw new UsageError("language must be non-empty");
556
- return raw;
557
- }
558
- },
559
- "max-response-tokens": {
560
- prop: "maxResponseTokens",
561
- describe: "search response token budget",
562
- parse: (raw) => intInRange("max-response-tokens", raw, 1)
563
680
  }
564
681
  };
565
682
  var AUTH_TOKEN_KEY = "auth-token";
566
- var MACHINE_TOKEN_RE = /^mt_[0-9a-f]{64}$/;
683
+ var MACHINE_TOKEN_RE = /^mt_[A-Za-z0-9_-]{32,}$/;
567
684
  var USAGE = "Usage: grimoire config [<key>] [<value>] [--unset] (keys: " + [...Object.keys(CONFIG_KEYS), AUTH_TOKEN_KEY].join(", ") + ")";
568
685
  function runConfig(args) {
569
686
  const [key, value] = args.positionals;
@@ -595,7 +712,7 @@ function runConfig(args) {
595
712
  return EXIT.ok;
596
713
  }
597
714
  if (!MACHINE_TOKEN_RE.test(value)) {
598
- throw new UsageError("auth-token must be a machine token, e.g. mt_<64 hex chars>");
715
+ throw new UsageError("auth-token must be a machine token starting with mt_");
599
716
  }
600
717
  storeMachineToken(value);
601
718
  process.stdout.write(`${AUTH_TOKEN_KEY} set
@@ -668,6 +785,33 @@ function runUpdate() {
668
785
  return result.status === 0 ? EXIT.ok : EXIT.apiError;
669
786
  }
670
787
 
788
+ // src/commands/ingest.ts
789
+ function submissionFromArgs(url, args) {
790
+ const usage = "Usage: grimoire ingest <url> --product <name> (--rolling | --fixed <version> | --npm <pkg> | --pypi <pkg> | --github <owner/repo>)";
791
+ const product = args.flags.product?.[0];
792
+ if (!product) throw new UsageError(usage);
793
+ const fixed = args.flags.fixed?.[0];
794
+ const npm = args.flags.npm?.[0];
795
+ const pypi = args.flags.pypi?.[0];
796
+ const github = args.flags.github?.[0];
797
+ const probes = [npm, pypi, github].filter(Boolean).length;
798
+ if (probes > 1) throw new UsageError("Pass only one of --npm, --pypi, --github");
799
+ const body = {
800
+ url,
801
+ product,
802
+ version_rule: { kind: "rolling" },
803
+ include_patterns: args.flags.include ?? [],
804
+ exclude_patterns: args.flags.exclude ?? []
805
+ };
806
+ if (args.bools.has("rolling")) return body;
807
+ if (fixed) return { ...body, version_rule: { kind: "fixed", value: fixed } };
808
+ if (npm) return { ...body, version_rule: { kind: "fixed" }, probe: { kind: "npm", package: npm } };
809
+ if (pypi) return { ...body, version_rule: { kind: "fixed" }, probe: { kind: "pypi", package: pypi } };
810
+ if (github) return { ...body, version_rule: { kind: "fixed" }, probe: { kind: "github", repo: github } };
811
+ throw new UsageError(`${usage}
812
+ A version rule is mandatory: --rolling for unversioned docs, --fixed, or a release probe.`);
813
+ }
814
+
671
815
  // src/updatecheck.ts
672
816
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
673
817
  import { homedir as homedir4 } from "node:os";
@@ -724,15 +868,21 @@ async function refreshUpdateState(current, configuredHours) {
724
868
  }
725
869
 
726
870
  // src/index.ts
727
- function openBrowser(url) {
728
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
729
- try {
730
- execFileSync(cmd, [url], { stdio: "ignore" });
731
- } catch {
732
- process.stdout.write(`Open this URL to log in:
733
- ${url}
871
+ function loginOpener(launchBrowser) {
872
+ return (url) => {
873
+ process.stdout.write(`${url}
734
874
  `);
735
- }
875
+ if (!launchBrowser) {
876
+ process.stderr.write("Open the URL above in a browser to log in; waiting for the callback\u2026\n");
877
+ return;
878
+ }
879
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
880
+ try {
881
+ execFileSync(cmd, [url], { stdio: "ignore" });
882
+ } catch {
883
+ process.stderr.write("Could not open a browser; open the URL above manually.\n");
884
+ }
885
+ };
736
886
  }
737
887
  function parseSourceFlags(values) {
738
888
  return (values ?? []).map((v) => {
@@ -740,33 +890,40 @@ function parseSourceFlags(values) {
740
890
  return version ? { source, version } : { source };
741
891
  });
742
892
  }
743
- var VERSION = true ? "0.3.7" : "dev";
893
+ function toSelectors(pins) {
894
+ return pins.map((p) => ({ product: p.source, ...p.version ? { version: p.version } : {} }));
895
+ }
896
+ var VERSION = true ? "0.4.1" : "dev";
744
897
  var HELP = `grimoire ${VERSION} \u2014 documentation retrieval for AI agents
745
898
 
746
- grimoire login | logout | whoami
899
+ grimoire login [--no-launch-browser] | logout | whoami
747
900
  grimoire setup <claude-code|cursor|windsurf|codex>
748
901
  grimoire init
749
- grimoire search "<query>" [-s nextjs@15 -s react] [--lang en] [--top-k 8] [--json|--compact]
902
+ grimoire search "<query>" [-s nextjs@15 -s react] [--json|--compact] [--debug]
750
903
  grimoire sources [--q <kw>] [--names|--json]
751
- grimoire versions <source> [--json]
904
+ grimoire versions <product> [--json]
752
905
  grimoire config [<key>] [<value>] [--unset]
753
906
  grimoire update
754
- grimoire doc <chunk_id> [--window 2]
755
- grimoire report <chunk_id> --verdict helpful|incorrect|outdated [--note "..."]
756
- grimoire ingest <url> [--version 15.2] [--private] [--webhook URL] [--watch]
907
+ grimoire doc <point_id> [--window 2] [--json]
908
+ grimoire report <point_id> --verdict helpful|incorrect|outdated [--note "..."]
909
+ grimoire ingest <url> --product <name> (--rolling | --fixed <version> | --npm <pkg> | --pypi <pkg> | --github <owner/repo>)
910
+ [--include <pattern>]... [--exclude <pattern>]... [--watch]
757
911
  grimoire jobs <job_id> [--watch]
912
+ grimoire staff queue [--json] | approve <job_id> | reject <job_id> --reason "..."
913
+ grimoire staff users [--json] | grant <subject> --name "..." | revoke <subject>
914
+ grimoire staff token <name> --quota <per-day> | recrawl <source_id>
758
915
  grimoire mcp [--http]
759
916
  grimoire help | --help | -h
760
917
  grimoire version | --version | -v
761
918
 
762
919
  env: GRIMOIRE_AUTH_TOKEN \u2014 machine token (CI, instead of login)
763
- GRIMOIRE_API_URL \u2014 API origin without a path, e.g. https://grimoire-api-qa.monadeo.com
920
+ GRIMOIRE_API_URL \u2014 API origin without a path
764
921
  `;
765
922
  var COMMAND_FLAGS = {
766
923
  version: [],
767
924
  "--version": [],
768
925
  "-v": [],
769
- login: [],
926
+ login: ["no-launch-browser"],
770
927
  logout: [],
771
928
  setup: [],
772
929
  init: [],
@@ -777,14 +934,23 @@ var COMMAND_FLAGS = {
777
934
  mcp: ["http"],
778
935
  config: ["unset"],
779
936
  update: [],
780
- search: ["source", "lang", "top-k", "json", "compact"],
937
+ search: ["source", "json", "compact", "debug"],
781
938
  sources: ["q", "names", "json"],
782
939
  versions: ["json"],
783
- doc: ["window"],
940
+ doc: ["window", "json"],
784
941
  report: ["verdict", "note"],
785
- ingest: ["version", "private", "webhook", "watch"],
786
- jobs: ["watch"]
942
+ ingest: ["product", "rolling", "fixed", "npm", "pypi", "github", "include", "exclude", "watch"],
943
+ jobs: ["watch"],
944
+ staff: ["reason", "name", "quota", "json"]
787
945
  };
946
+ function describeError(err) {
947
+ const detail = typeof err.body === "string" ? err.body : err.body !== void 0 && err.body !== null ? JSON.stringify(err.body) : "";
948
+ return `error: ${err.code}${detail ? ` \u2014 ${detail}` : ""}`;
949
+ }
950
+ function describeJob(job) {
951
+ const extra = [job.reason ? `reason: ${job.reason}` : "", job.source_id ? `source: ${job.source_id}` : ""].filter(Boolean).join(" ");
952
+ return `${job.state}${extra ? ` ${extra}` : ""}`;
953
+ }
788
954
  async function main(argv) {
789
955
  const [command, ...rest] = argv;
790
956
  const args = parseArgs(rest, { "-s": "source", "-q": "q" }, command !== void 0 ? COMMAND_FLAGS[command] : []);
@@ -797,12 +963,14 @@ async function main(argv) {
797
963
  `);
798
964
  return EXIT.ok;
799
965
  case "login": {
800
- await browserLogin(loadGlobalConfig().apiBaseUrl, openBrowser);
966
+ const launch = !args.bools.has("no-launch-browser");
967
+ process.stderr.write(launch ? "Opening your browser to log in\u2026\n" : "Login URL:\n");
968
+ await browserLogin(loadGlobalConfig().apiBaseUrl, loginOpener(launch));
801
969
  process.stdout.write("Logged in.\n");
802
970
  return EXIT.ok;
803
971
  }
804
972
  case "logout":
805
- clearRefreshToken();
973
+ clearSession();
806
974
  process.stdout.write("Logged out.\n");
807
975
  return EXIT.ok;
808
976
  case "setup":
@@ -827,33 +995,22 @@ async function main(argv) {
827
995
  try {
828
996
  switch (command) {
829
997
  case "whoami": {
830
- if (process.env.GRIMOIRE_AUTH_TOKEN) {
831
- process.stdout.write("machine token configured (GRIMOIRE_AUTH_TOKEN)\n");
832
- return EXIT.ok;
833
- }
834
- if (readMachineToken()) {
835
- process.stdout.write("machine token configured (grimoire config auth-token)\n");
836
- return EXIT.ok;
837
- }
838
- if (!readRefreshToken()) {
998
+ const via = process.env.GRIMOIRE_AUTH_TOKEN ? "GRIMOIRE_AUTH_TOKEN" : readMachineToken() ? "grimoire config auth-token" : readSession() ? "browser login" : void 0;
999
+ if (!via) {
839
1000
  process.stderr.write("not logged in \u2014 run `grimoire login`\n");
840
1001
  return EXIT.authRequired;
841
1002
  }
842
- try {
843
- await client.refreshSession();
844
- process.stdout.write("logged in (browser session)\n");
845
- return EXIT.ok;
846
- } catch (err) {
847
- const reason = err instanceof ApiError ? err.code : err.message;
848
- process.stderr.write(`not logged in (${reason}) \u2014 run \`grimoire login\`
849
- `);
850
- return EXIT.authRequired;
851
- }
1003
+ const me = await client.me();
1004
+ process.stdout.write(
1005
+ `${me.kind} ${me.subject}${me.is_staff ? " staff" : ""} quota ${me.quota_per_day}/day via ${via}
1006
+ `
1007
+ );
1008
+ return EXIT.ok;
852
1009
  }
853
1010
  case "search": {
854
1011
  const query = args.positionals[0];
855
1012
  if (!query) {
856
- process.stderr.write('Usage: grimoire search "<query>" -s <source>\n');
1013
+ process.stderr.write('Usage: grimoire search "<query>" -s <product>[@version]\n');
857
1014
  return EXIT.apiError;
858
1015
  }
859
1016
  const explicit = parseSourceFlags(args.flags.source);
@@ -861,99 +1018,73 @@ async function main(argv) {
861
1018
  if (sources.length === 0) {
862
1019
  const initHelps = existsSync6(join6(process.cwd(), "package.json")) || existsSync6(join6(process.cwd(), "requirements.txt"));
863
1020
  process.stderr.write(
864
- 'No sources selected. List what is indexed:\n grimoire sources\nthen scope the search:\n grimoire search "<query>" -s <source>[@version]\n' + (initHelps ? "or pin this project's sources from its dependencies:\n grimoire init\n" : "")
1021
+ 'No sources selected. List what is indexed:\n grimoire sources\nthen scope the search:\n grimoire search "<query>" -s <product>[@version]\n' + (initHelps ? "or pin this project's sources from its dependencies:\n grimoire init\n" : "")
865
1022
  );
866
1023
  return EXIT.apiError;
867
1024
  }
868
- const res = await client.search({
869
- query,
870
- sources,
871
- language: args.flags.lang?.[0],
872
- top_k: intFlag(args, "top-k")
873
- });
1025
+ const res = await client.search({ query, sources: toSelectors(sources), debug: args.bools.has("debug") });
874
1026
  if (json) process.stdout.write(JSON.stringify(res, null, 2) + "\n");
875
- else if (args.bools.has("compact")) printCompact(res.results, res.confidence, res.rerank_status);
876
- else printResults(res.results, res.confidence, res.rerank_status);
1027
+ else if (args.bools.has("compact")) printCompact(res);
1028
+ else printResults(res);
877
1029
  return EXIT.ok;
878
1030
  }
879
1031
  case "sources": {
880
- const res = await client.listSources(args.flags.q?.[0]);
881
- const sources = res.sources ?? [];
1032
+ const needle = args.flags.q?.[0]?.toLowerCase();
1033
+ const sources = (await client.listSources()).filter(
1034
+ (s) => !needle || s.product.toLowerCase().includes(needle) || s.base_url.toLowerCase().includes(needle)
1035
+ );
882
1036
  if (json) {
883
1037
  process.stdout.write(JSON.stringify(sources, null, 2) + "\n");
884
1038
  } else if (args.bools.has("names")) {
885
- for (const s of sources) process.stdout.write(`${s.source_id}
1039
+ for (const s of sources) process.stdout.write(`${s.product}
886
1040
  `);
887
1041
  } else {
888
1042
  for (const s of sources) {
889
- const latest = s.latest_semver ?? s.latest_version ?? "-";
890
- const vis = s.visibility === "private" ? " (private)" : "";
891
- const meta = [
892
- s.latest_chunks != null ? `${s.latest_chunks} chunks` : "",
893
- s.latest_pages != null ? `${s.latest_pages} pages` : "",
894
- s.latest_crawled_at ? `crawled ${s.latest_crawled_at.slice(0, 10)}` : ""
895
- ].filter(Boolean).join(" \xB7 ");
896
- process.stdout.write(
897
- [`${s.source_id}@${latest}${vis}`, meta, s.origin_url ?? ""].filter(Boolean).join(" ") + "\n"
898
- );
1043
+ const versions = s.versions.length > 0 ? s.versions.join(", ") : "(not indexed yet)";
1044
+ process.stdout.write(`${s.product} ${versions} ${s.base_url}
1045
+ `);
899
1046
  }
900
1047
  }
901
1048
  return EXIT.ok;
902
1049
  }
903
1050
  case "versions": {
904
- const source = requirePositional(args, 0, "Usage: grimoire versions <source> [--json]");
905
- const res = await client.listVersions(source);
906
- const versions = res.versions ?? [];
1051
+ const product = requirePositional(args, 0, "Usage: grimoire versions <product> [--json]");
1052
+ const res = await client.listVersions(product);
907
1053
  if (json) {
908
- process.stdout.write(JSON.stringify(versions, null, 2) + "\n");
1054
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
909
1055
  return EXIT.ok;
910
1056
  }
911
- const available = versions.filter((v) => v.status === "active");
912
- if (available.length === 0) {
913
- process.stdout.write(`no active versions for ${source}
1057
+ for (const v of res.versions) {
1058
+ process.stdout.write(`${v.version}${v.version === res.latest ? " latest" : ""} ${v.chunk_count} chunks
914
1059
  `);
915
- return EXIT.ok;
916
- }
917
- for (const v of available) {
918
- const label = v.semver ?? v.version_id;
919
- const parts = [
920
- label,
921
- v.is_latest ? "latest" : "",
922
- `${v.chunk_count ?? 0} chunks`,
923
- `crawled ${(v.ingested_at ?? "").slice(0, 10)}`,
924
- v.semver ? `(${v.version_id})` : ""
925
- ].filter(Boolean);
926
- process.stdout.write(parts.join(" ") + "\n");
927
1060
  }
928
1061
  return EXIT.ok;
929
1062
  }
930
1063
  case "doc": {
931
- const chunkId = requirePositional(args, 0, "Usage: grimoire doc <chunk_id> [--window 2]");
1064
+ const pointId = requirePositional(args, 0, "Usage: grimoire doc <point_id> [--window 2] [--json]");
932
1065
  const window = intFlag(args, "window", { min: 0, max: 5 }) ?? 2;
933
- const res = await client.getContext(chunkId, window);
934
- process.stdout.write(JSON.stringify(res.chunks ?? [], null, 2) + "\n");
1066
+ const res = await client.getDoc(pointId, window);
1067
+ if (json) {
1068
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
1069
+ return EXIT.ok;
1070
+ }
1071
+ process.stderr.write(`${res.product}@${res.version} ${res.heading_path.join(" \u203A ")}
1072
+ ${res.source_url}
1073
+ `);
1074
+ process.stdout.write(res.text + "\n");
935
1075
  return EXIT.ok;
936
1076
  }
937
1077
  case "report": {
938
- const usage = 'Usage: grimoire report <chunk_id> --verdict helpful|incorrect|outdated [--note "..."]';
939
- const chunkId = requirePositional(args, 0, usage);
1078
+ const usage = 'Usage: grimoire report <point_id> --verdict helpful|incorrect|outdated [--note "..."]';
1079
+ const pointId = requirePositional(args, 0, usage);
940
1080
  const verdict = requireFlagOneOf(args, "verdict", ["helpful", "incorrect", "outdated"], usage);
941
- await client.reportResult(chunkId, verdict, args.flags.note?.[0]);
1081
+ await client.reportResult(pointId, verdict, args.flags.note?.[0]);
942
1082
  process.stdout.write("Reported.\n");
943
1083
  return EXIT.ok;
944
1084
  }
945
1085
  case "ingest": {
946
- const url = requirePositional(
947
- args,
948
- 0,
949
- "Usage: grimoire ingest <url> [--version 15.2] [--private] [--webhook URL] [--watch]"
950
- );
951
- const res = await client.submitSource({
952
- url,
953
- version: args.flags.version?.[0],
954
- visibility: args.bools.has("private") ? "private" : "public",
955
- webhook_url: args.flags.webhook?.[0]
956
- });
1086
+ const url = requirePositional(args, 0, "Usage: grimoire ingest <url> --product <name> --rolling|--fixed|--npm|--pypi|--github");
1087
+ const res = await client.submitSource(submissionFromArgs(url, args));
957
1088
  process.stdout.write(`Job: ${res.job_id}
958
1089
  `);
959
1090
  if (args.bools.has("watch")) return watchJob(client, res.job_id);
@@ -963,6 +1094,70 @@ async function main(argv) {
963
1094
  const jobId = requirePositional(args, 0, "Usage: grimoire jobs <job_id> [--watch]");
964
1095
  return args.bools.has("watch") ? watchJob(client, jobId) : printJob(client, jobId);
965
1096
  }
1097
+ case "staff": {
1098
+ const usage = 'Usage: grimoire staff queue [--json] | approve <job_id> | reject <job_id> --reason "..." | users [--json] | grant <subject> --name "..." | revoke <subject> | token <name> --quota <per-day> | recrawl <source_id>';
1099
+ const [action, jobId] = args.positionals;
1100
+ if (action === "token") {
1101
+ const quota = intFlag(args, "quota", { min: 1, max: 1e6 });
1102
+ if (!jobId || quota === void 0) throw new UsageError(usage);
1103
+ const minted = await client.mintToken(jobId, quota);
1104
+ process.stderr.write(`token ${minted.id} ${minted.name} quota ${minted.quota_per_day}/day \u2014 shown once, store it now
1105
+ `);
1106
+ process.stdout.write(`${minted.token}
1107
+ `);
1108
+ return EXIT.ok;
1109
+ }
1110
+ if (action === "recrawl") {
1111
+ if (!jobId) throw new UsageError(usage);
1112
+ process.stdout.write(`${describeJob(await client.recrawlSource(jobId))}
1113
+ `);
1114
+ return EXIT.ok;
1115
+ }
1116
+ if (action === "users") {
1117
+ const users = await client.listUsers();
1118
+ if (json) process.stdout.write(JSON.stringify(users, null, 2) + "\n");
1119
+ else for (const u of users) process.stdout.write(`${u.subject} ${u.status} ${u.name} by ${u.granted_by}
1120
+ `);
1121
+ return EXIT.ok;
1122
+ }
1123
+ if (action === "grant") {
1124
+ const name = args.flags.name?.[0];
1125
+ if (!jobId || !name) throw new UsageError(usage);
1126
+ const granted = await client.grantUser(jobId, name);
1127
+ process.stdout.write(`${granted.subject} ${granted.status} ${granted.name}
1128
+ `);
1129
+ return EXIT.ok;
1130
+ }
1131
+ if (action === "revoke") {
1132
+ if (!jobId) throw new UsageError(usage);
1133
+ const revoked = await client.revokeUser(jobId);
1134
+ process.stdout.write(`${revoked.subject} ${revoked.status}
1135
+ `);
1136
+ return EXIT.ok;
1137
+ }
1138
+ if (action === "queue") {
1139
+ const queue = await client.reviewQueue();
1140
+ if (json) process.stdout.write(JSON.stringify(queue, null, 2) + "\n");
1141
+ else if (queue.length === 0) process.stdout.write("review queue is empty\n");
1142
+ else for (const job of queue) process.stdout.write(`${job.id} ${describeJob(job)}
1143
+ `);
1144
+ return EXIT.ok;
1145
+ }
1146
+ if (!jobId) throw new UsageError(usage);
1147
+ if (action === "approve") {
1148
+ process.stdout.write(`${describeJob(await client.approveJob(jobId))}
1149
+ `);
1150
+ return EXIT.ok;
1151
+ }
1152
+ if (action === "reject") {
1153
+ const reason = args.flags.reason?.[0];
1154
+ if (!reason) throw new UsageError(usage);
1155
+ process.stdout.write(`${describeJob(await client.rejectJob(jobId, reason))}
1156
+ `);
1157
+ return EXIT.ok;
1158
+ }
1159
+ throw new UsageError(usage);
1160
+ }
966
1161
  default:
967
1162
  process.stderr.write(`Unknown command: ${command}
968
1163
  ${HELP}`);
@@ -970,14 +1165,10 @@ ${HELP}`);
970
1165
  }
971
1166
  } catch (err) {
972
1167
  if (err instanceof ApiError) {
973
- const body = typeof err.body === "object" && err.body !== null ? err.body : {};
974
- process.stderr.write(`error: ${err.code}${body.message ? ` \u2014 ${body.message}` : ""}
975
- `);
976
- if (body.available?.length) process.stderr.write(`available: ${body.available.join(", ")}
977
- `);
978
- if (body.did_you_mean?.length) process.stderr.write(`did you mean: ${body.did_you_mean.join(", ")}
1168
+ process.stderr.write(`${describeError(err)}
979
1169
  `);
980
1170
  if (err.status === 401) return EXIT.authRequired;
1171
+ if (err.status === 403) return EXIT.authRequired;
981
1172
  if (err.status === 429) return EXIT.quota;
982
1173
  if (err.status === 404) return EXIT.notFound;
983
1174
  return EXIT.apiError;
@@ -985,14 +1176,13 @@ ${HELP}`);
985
1176
  throw err;
986
1177
  }
987
1178
  }
988
- var TERMINAL_STATUSES = ["complete", "failed", "rejected"];
989
- var FAILED_STATUSES = ["failed", "rejected"];
1179
+ var TERMINAL_STATES = ["accepted", "done", "rejected", "failed", "pending_review"];
1180
+ var FAILED_STATES = ["rejected", "failed"];
990
1181
  async function printJob(client, jobId) {
991
1182
  const job = await client.getJob(jobId);
992
- const status = job.status ?? "unknown";
993
- process.stdout.write(`${status} ${JSON.stringify(job.counters ?? {})}
1183
+ process.stdout.write(`${describeJob(job)}
994
1184
  `);
995
- return FAILED_STATUSES.includes(status) ? EXIT.apiError : EXIT.ok;
1185
+ return FAILED_STATES.includes(job.state) ? EXIT.apiError : EXIT.ok;
996
1186
  }
997
1187
  var WATCH_POLL_MS = 5e3;
998
1188
  var WATCH_BACKOFF_CAP_MS = 3e4;
@@ -1006,11 +1196,13 @@ async function watchJob(client, jobId) {
1006
1196
  while (Date.now() < deadline) {
1007
1197
  try {
1008
1198
  const job = await client.getJob(jobId);
1009
- const status = job.status ?? "unknown";
1010
- process.stdout.write(`${status} ${JSON.stringify(job.counters ?? {})}
1199
+ process.stdout.write(`${describeJob(job)}
1011
1200
  `);
1012
- if (TERMINAL_STATUSES.includes(status)) {
1013
- return FAILED_STATUSES.includes(status) ? EXIT.apiError : EXIT.ok;
1201
+ if (TERMINAL_STATES.includes(job.state)) {
1202
+ if (job.state === "pending_review") {
1203
+ process.stderr.write("Parked for staff review: grimoire staff queue\n");
1204
+ }
1205
+ return FAILED_STATES.includes(job.state) ? EXIT.apiError : EXIT.ok;
1014
1206
  }
1015
1207
  backoff = WATCH_POLL_MS;
1016
1208
  await sleep(WATCH_POLL_MS);