@indigoai-us/hq-cli 5.123.1 → 5.123.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.123.2] — 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - Company slug lookup no longer prints `Failed to resolve company slug '…':`
10
+ with nothing after the colon. Transient failures (timeouts, 429, 5xx) are
11
+ retried once, and the error names the HTTP status and says it is a temporary
12
+ lookup failure rather than a missing company.
13
+
5
14
  ## [5.123.1] — 2026-09-19
6
15
 
7
16
  ### Fixed
@@ -320,16 +320,37 @@ async function resolveCompanyByUid(token, uid) {
320
320
  if (!res.ok) {
321
321
  raiseIfUnauthorized(res);
322
322
  const body = (await res.json().catch(() => ({})));
323
- throw new Error(`Failed to resolve company '${uid}': ${body.error ?? res.statusText}`);
323
+ throw new Error(`Failed to resolve company '${uid}': ${formatSlugLookupReason(res, body)}`);
324
324
  }
325
325
  const data = (await res.json());
326
326
  return data.entity.uid;
327
327
  }
328
+ /**
329
+ * Node's fetch (undici) often leaves `statusText` empty, and some vault
330
+ * error bodies send `error: ""` or omit it. `??` does not treat "" as missing,
331
+ * so `body.error ?? res.statusText` produced
332
+ * `Failed to resolve company slug '…':` with nothing after the colon
333
+ * (Shepherd #2306). Never return an empty reason: prefer a non-blank body
334
+ * error, then statusText, then `HTTP <status>`.
335
+ */
336
+ function formatSlugLookupReason(res, body) {
337
+ const fromBody = typeof body.error === "string" ? body.error.trim() : "";
338
+ if (fromBody)
339
+ return fromBody;
340
+ const fromStatus = (res.statusText ?? "").trim();
341
+ if (fromStatus)
342
+ return fromStatus;
343
+ return `HTTP ${res.status}`;
344
+ }
345
+ function isTransientSlugLookupStatus(status) {
346
+ return status === 408 || status === 425 || status === 429 || status >= 500;
347
+ }
328
348
  /**
329
349
  * Resolve a company slug WITHIN THE CALLER'S NAMESPACE via the server's
330
350
  * caller-scoped `/entity/check-slug/me` endpoint. Returns the caller's own
331
- * company uid for that slug, or `null` when the caller owns / actively belongs
332
- * to no company with that slug.
351
+ * company uid for that slug, `miss` when the caller owns / actively belongs
352
+ * to no company with that slug, or `transient` when the lookup itself failed
353
+ * (timeout, 429, 5xx) after one retry.
333
354
  *
334
355
  * This is the load-bearing fix for DEV-1742. Under hq-pro's per-user-namespace
335
356
  * model two unrelated tenants may each hold "acme", so the legacy GLOBAL
@@ -344,25 +365,63 @@ async function resolveCompanyByUid(token, uid) {
344
365
  * the same primitive hq-cloud's `context.ts` already resolves slugs through.
345
366
  */
346
367
  async function resolveSlugInCallerNamespace(token, slug) {
347
- const res = await vaultApiFetch({
368
+ const attempt = async () => {
369
+ const res = await vaultApiFetch({
370
+ token,
371
+ path: "/entity/check-slug/me",
372
+ query: { type: "company", slug },
373
+ signal: AbortSignal.timeout(identityLookupTimeoutMs()),
374
+ });
375
+ if (!res.ok) {
376
+ raiseIfUnauthorized(res);
377
+ // Namespace lookup unavailable (e.g. membership table not configured →
378
+ // 503, or the caller has no person entity). A 401 short-circuits above
379
+ // because the token is bad and the global fallback would only 401 again.
380
+ // Transient statuses are retried once below; other non-2xx still mean
381
+ // "couldn't resolve here" and fall back to the global lookup.
382
+ // vaultApiFetch already recorded the non-2xx as a Sentry breadcrumb.
383
+ if (isTransientSlugLookupStatus(res.status)) {
384
+ return { kind: "transient" };
385
+ }
386
+ return { kind: "miss" };
387
+ }
388
+ const data = (await res.json());
389
+ // `available: false` carries the caller's own company holding the slug.
390
+ const uid = data.conflictingCompanyUid;
391
+ return uid ? { kind: "hit", uid } : { kind: "miss" };
392
+ };
393
+ const run = async () => {
394
+ try {
395
+ return await attempt();
396
+ }
397
+ catch (err) {
398
+ if (networkTransportErrorCode(err) === null)
399
+ throw err;
400
+ return { kind: "transient" };
401
+ }
402
+ };
403
+ const first = await run();
404
+ if (first.kind !== "transient")
405
+ return first;
406
+ return run();
407
+ }
408
+ async function fetchGlobalCompanyBySlug(token, ref) {
409
+ const attempt = () => vaultApiFetch({
348
410
  token,
349
- path: '/entity/check-slug/me',
350
- query: { type: 'company', slug },
411
+ path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
351
412
  signal: AbortSignal.timeout(identityLookupTimeoutMs()),
352
413
  });
353
- if (!res.ok) {
354
- raiseIfUnauthorized(res);
355
- // Namespace lookup unavailable (e.g. membership table not configured →
356
- // 503, or the caller has no person entity). A 401 short-circuits above
357
- // because the token is bad and the global fallback would only 401 again;
358
- // other non-2xx statuses signal "couldn't resolve here" and let the caller
359
- // fall back to the global lookup. vaultApiFetch already recorded the
360
- // non-2xx as a Sentry breadcrumb, so this is not a silent swallow.
361
- return null;
414
+ try {
415
+ const res = await attempt();
416
+ if (res.ok || !isTransientSlugLookupStatus(res.status))
417
+ return res;
362
418
  }
363
- const data = (await res.json());
364
- // `available: false` carries the caller's own company holding the slug.
365
- return data.conflictingCompanyUid ?? null;
419
+ catch (err) {
420
+ if (networkTransportErrorCode(err) === null)
421
+ throw err;
422
+ return attempt();
423
+ }
424
+ return attempt();
366
425
  }
367
426
  async function resolveCompanyUid(token, ref) {
368
427
  // Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
@@ -379,39 +438,25 @@ async function resolveCompanyUid(token, ref) {
379
438
  // caller's OWN company (unique within their namespace by the invariant
380
439
  // above), making a stranger's same-slug company invisible.
381
440
  //
382
- // A non-2xx already degrades to the global fallback below (via `return null`).
383
- // A TRANSPORT failure must degrade the SAME way, not abort the command:
384
- // `resolveSlugInCallerNamespace`'s fetch is bounded by AbortSignal.timeout, so
385
- // a vault gateway that accepts the connection but never answers rejects with a
386
- // DOMException named `TimeoutError`. Before this catch, that rejection threw
387
- // straight out of `resolveCompanyUid`, skipped the global by-slug lookup, and
388
- // — being unclassified by main.ts — filed a Sentry crash report instead of an
389
- // actionable connectivity message (Sentry HQ-CLI-1P / issue 7716090679).
390
- // Degrade ONLY on a recognized transport failure; re-throw everything else
391
- // unchanged, so a genuine hq-cli defect stays fatal and the 401 AuthError that
392
- // `raiseIfUnauthorized` throws still short-circuits before any fallback.
393
- let mine;
394
- try {
395
- mine = await resolveSlugInCallerNamespace(token, ref);
441
+ // A non-2xx already degrades to the global fallback below (`miss` /
442
+ // `transient`). A TRANSPORT failure must degrade the SAME way, not abort
443
+ // the command: `resolveSlugInCallerNamespace`'s fetch is bounded by
444
+ // AbortSignal.timeout, so a vault gateway that accepts the connection but
445
+ // never answers rejects with a DOMException named `TimeoutError`. That is
446
+ // classified as `transient` (and retried once) inside the helper; a genuine
447
+ // hq-cli defect still throws, and the 401 AuthError that
448
+ // `raiseIfUnauthorized` throws still short-circuits before any fallback
449
+ // (Sentry HQ-CLI-1P / issue 7716090679).
450
+ const mine = await resolveSlugInCallerNamespace(token, ref);
451
+ if (mine.kind === "hit") {
452
+ return mine.uid;
396
453
  }
397
- catch (err) {
398
- if (networkTransportErrorCode(err) === null) {
399
- throw err;
400
- }
401
- mine = null;
402
- }
403
- if (mine) {
404
- return mine;
405
- }
406
- // The caller owns / belongs to no company with this slug. Fall back to the
407
- // GLOBAL by-slug lookup for the cross-company / admin resolutions that
408
- // intentionally target a company the caller isn't a member of (e.g. a
409
- // group-grant target). A single global match resolves cleanly.
410
- const res = await vaultApiFetch({
411
- token,
412
- path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
413
- signal: AbortSignal.timeout(identityLookupTimeoutMs()),
414
- });
454
+ // The caller owns / belongs to no company with this slug, or the namespace
455
+ // lookup itself failed. Fall back to the GLOBAL by-slug lookup for the
456
+ // cross-company / admin resolutions that intentionally target a company the
457
+ // caller isn't a member of (e.g. a group-grant target). A single global
458
+ // match resolves cleanly.
459
+ const res = await fetchGlobalCompanyBySlug(token, ref);
415
460
  if (!res.ok) {
416
461
  raiseIfUnauthorized(res);
417
462
  const body = (await res.json().catch(() => ({})));
@@ -425,10 +470,19 @@ async function resolveCompanyUid(token, ref) {
425
470
  `is in your namespace. Re-run with --company <uid> to pick one:\n` +
426
471
  body.uids.map((u) => ` --company ${u}`).join('\n'));
427
472
  }
428
- if (res.status === 404 && body.error === "Entity not found") {
473
+ // Only a clean namespace miss plus a global 404 is "this slug does not
474
+ // exist". A transient namespace failure followed by 404 is still a lookup
475
+ // failure — treating it as not-found sent owners to check a slug that
476
+ // was fine (Shepherd #2306).
477
+ if (res.status === 404 && mine.kind === "miss") {
429
478
  throw Object.assign(new Error(`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`), { expected: true });
430
479
  }
431
- throw new Error(`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`);
480
+ const reason = formatSlugLookupReason(res, body);
481
+ const retryable = mine.kind === "transient" || isTransientSlugLookupStatus(res.status);
482
+ const hint = retryable
483
+ ? " This is a temporary lookup failure, not a missing company. Retry the command."
484
+ : "";
485
+ throw new Error(`Failed to resolve company slug '${ref}': ${reason}.${hint}`);
432
486
  }
433
487
  const data = (await res.json());
434
488
  return data.entity.uid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.123.1",
3
+ "version": "5.123.2",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {