@indigoai-us/hq-cli 5.77.0 → 5.77.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.77.1]
6
+
7
+ ### Fixed
8
+
9
+ - Missing company-slug responses are classified as expected user errors, so the
10
+ CLI no longer sends this expected absence to Sentry.
11
+
5
12
  ## [5.71.0]
6
13
 
7
14
  ### Added
@@ -22,6 +22,13 @@ import { type BannerLevel } from "../lib/narrow-hint-banner.js";
22
22
  * succeed when files were dropped" guarantee unit-testable.
23
23
  */
24
24
  export declare function scopeExcludedWarning(count: number): string | null;
25
+ /**
26
+ * Bound foreground waits for a watcher/manual sync that currently owns the
27
+ * per-root operation lock. The cloud engine reads this environment value on
28
+ * every lock acquisition. A command-line value wins; otherwise preserve a
29
+ * valid explicit caller environment value and supply a finite CLI default.
30
+ */
31
+ export declare function configureSyncLockTimeout(raw: string | undefined): void;
25
32
  export interface PullAllVaultClient {
26
33
  listMyMemberships(): Promise<Array<{
27
34
  companyUid: string;
@@ -59,6 +59,21 @@ function resolveDeletePolicy() {
59
59
  }
60
60
  return "currency-gated";
61
61
  }
62
+ const DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 300;
63
+ /**
64
+ * Bound foreground waits for a watcher/manual sync that currently owns the
65
+ * per-root operation lock. The cloud engine reads this environment value on
66
+ * every lock acquisition. A command-line value wins; otherwise preserve a
67
+ * valid explicit caller environment value and supply a finite CLI default.
68
+ */
69
+ export function configureSyncLockTimeout(raw) {
70
+ const value = raw ?? process.env.HQ_OP_LOCK_TIMEOUT ?? String(DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS);
71
+ const seconds = Number(value);
72
+ if (!Number.isInteger(seconds) || seconds < 0) {
73
+ throw new Error("--lock-timeout must be a non-negative integer number of seconds");
74
+ }
75
+ process.env.HQ_OP_LOCK_TIMEOUT = String(seconds);
76
+ }
62
77
  // Oldest-first by createdAt, ties broken by uid lexicographic — matches
63
78
  // `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
64
79
  // the same person bucket that `hq-sync-runner` picks.
@@ -478,6 +493,7 @@ export function registerCloudCommands(program) {
478
493
  .argument("[paths...]", "Paths to push (defaults to current directory)")
479
494
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
480
495
  .option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
496
+ .option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
481
497
  .option("--message <msg>", "Optional message attached to journal entries for these uploads")
482
498
  .option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
483
499
  .option("--creds-from-stdin", "Read a pre-vended EntityContext as JSON from stdin instead of vending " +
@@ -509,6 +525,7 @@ export function registerCloudCommands(program) {
509
525
  "`--all`.")
510
526
  .action(async (paths, options) => {
511
527
  try {
528
+ configureSyncLockTimeout(options.lockTimeout);
512
529
  assertSingleSelector(options, "push");
513
530
  }
514
531
  catch (err) {
@@ -688,6 +705,7 @@ export function registerCloudCommands(program) {
688
705
  .description("Pull permitted files from the company vault to local HQ")
689
706
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
690
707
  .option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
708
+ .option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
691
709
  .option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
692
710
  .option("--all", "Pull every company you are a member of plus your personal vault " +
693
711
  "into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
@@ -711,6 +729,7 @@ export function registerCloudCommands(program) {
711
729
  "quarantined under .hq/scope-quarantine/ (recoverable).")
712
730
  .action(async (options) => {
713
731
  try {
732
+ configureSyncLockTimeout(options.lockTimeout);
714
733
  assertSingleSelector(options, "pull");
715
734
  }
716
735
  catch (err) {
@@ -882,6 +901,7 @@ export function registerCloudCommands(program) {
882
901
  "(mirrors AppBar HQ Sync's \"Sync Now\" button)")
883
902
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
884
903
  .option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
904
+ .option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
885
905
  .option("--message <msg>", "Optional message attached to journal entries for the push leg")
886
906
  .option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
887
907
  .option("--all", "Sync every company you are a member of plus your personal vault " +
@@ -901,6 +921,7 @@ export function registerCloudCommands(program) {
901
921
  "out-of-scope files are quarantined under .hq/scope-quarantine/.")
902
922
  .action(async (options) => {
903
923
  try {
924
+ configureSyncLockTimeout(options.lockTimeout);
904
925
  assertSingleSelector(options, "now");
905
926
  if (options.all) {
906
927
  // `options.personal === false` is Commander's auto-negation
package/dist/main.js CHANGED
@@ -67,6 +67,9 @@ import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check
67
67
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
68
68
  import { CLI_VERSION } from "./cli-version.js";
69
69
  import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
70
+ import { settleWithin } from "./utils/settle-with-timeout.js";
71
+ /** Hard upper bound for non-user-visible release-health finalization. */
72
+ const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
70
73
  // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
71
74
  // the pipe early. This covers the ASYNC path — an 'error' event emitted on the
72
75
  // stream. The SYNCHRONOUS path (a `write EPIPE` thrown straight out of
@@ -306,7 +309,11 @@ export async function runCli() {
306
309
  finally {
307
310
  // Release health: finalize the per-run session before the flush.
308
311
  Sentry.endSession();
309
- await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
312
+ // Neither task may turn a successful command into Node's
313
+ // `unsettled top-level await` exit. They are observability-only after the
314
+ // command has completed, so a bounded best-effort wait is the terminal
315
+ // lifecycle boundary for this invocation.
316
+ await settleWithin([refreshVersionCache(), Sentry.flush(2000)], RELEASE_HEALTH_SETTLE_TIMEOUT_MS);
310
317
  }
311
318
  }
312
319
  //# sourceMappingURL=main.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Await finalization work without allowing a best-effort promise to leave a
3
+ * CLI's top-level await pending forever. The timer intentionally stays
4
+ * referenced: Node must remain alive long enough to settle this race.
5
+ */
6
+ export declare function settleWithin(promises: readonly Promise<unknown>[], timeoutMs: number): Promise<"settled" | "timed_out">;
7
+ //# sourceMappingURL=settle-with-timeout.d.ts.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Await finalization work without allowing a best-effort promise to leave a
3
+ * CLI's top-level await pending forever. The timer intentionally stays
4
+ * referenced: Node must remain alive long enough to settle this race.
5
+ */
6
+ export async function settleWithin(promises, timeoutMs) {
7
+ let timer;
8
+ const timedOut = new Promise((resolve) => {
9
+ timer = setTimeout(() => resolve("timed_out"), timeoutMs);
10
+ });
11
+ try {
12
+ return await Promise.race([
13
+ Promise.allSettled(promises).then(() => "settled"),
14
+ timedOut,
15
+ ]);
16
+ }
17
+ finally {
18
+ if (timer !== undefined)
19
+ clearTimeout(timer);
20
+ }
21
+ }
22
+ //# sourceMappingURL=settle-with-timeout.js.map
@@ -176,6 +176,9 @@ async function resolveCompanyUid(token, ref) {
176
176
  `is in your namespace. Re-run with --company <uid> to pick one:\n` +
177
177
  body.uids.map((u) => ` --company ${u}`).join('\n'));
178
178
  }
179
+ if (res.status === 404 && body.error === "Entity not found") {
180
+ throw Object.assign(new Error(`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`), { expected: true });
181
+ }
179
182
  throw new Error(`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`);
180
183
  }
181
184
  const data = (await res.json());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.77.0",
3
+ "version": "5.77.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -0,0 +1,41 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+
3
+ import { configureSyncLockTimeout } from "./cloud.js";
4
+
5
+ const originalLockTimeout = process.env.HQ_OP_LOCK_TIMEOUT;
6
+
7
+ afterEach(() => {
8
+ if (originalLockTimeout === undefined) {
9
+ delete process.env.HQ_OP_LOCK_TIMEOUT;
10
+ } else {
11
+ process.env.HQ_OP_LOCK_TIMEOUT = originalLockTimeout;
12
+ }
13
+ });
14
+
15
+ describe("configureSyncLockTimeout", () => {
16
+ it("supplies a finite foreground wait when the caller has not configured one", () => {
17
+ delete process.env.HQ_OP_LOCK_TIMEOUT;
18
+ configureSyncLockTimeout(undefined);
19
+ expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("300");
20
+ });
21
+
22
+ it("honors an explicit zero-second refusal", () => {
23
+ configureSyncLockTimeout("0");
24
+ expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("0");
25
+ });
26
+
27
+ it("honors a valid inherited lock timeout", () => {
28
+ process.env.HQ_OP_LOCK_TIMEOUT = "17";
29
+ configureSyncLockTimeout(undefined);
30
+ expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("17");
31
+ });
32
+
33
+ it("rejects an invalid timeout instead of silently waiting forever", () => {
34
+ expect(() => configureSyncLockTimeout("forever")).toThrow("--lock-timeout");
35
+ });
36
+
37
+ it("rejects an invalid inherited timeout instead of restoring an infinite wait", () => {
38
+ process.env.HQ_OP_LOCK_TIMEOUT = "forever";
39
+ expect(() => configureSyncLockTimeout(undefined)).toThrow("--lock-timeout");
40
+ });
41
+ });
@@ -97,6 +97,24 @@ function resolveDeletePolicy(): "owned-only" | "currency-gated" | "all" {
97
97
  interface CommonSyncOptions {
98
98
  hqRoot: string;
99
99
  company?: string;
100
+ lockTimeout?: string;
101
+ }
102
+
103
+ const DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 300;
104
+
105
+ /**
106
+ * Bound foreground waits for a watcher/manual sync that currently owns the
107
+ * per-root operation lock. The cloud engine reads this environment value on
108
+ * every lock acquisition. A command-line value wins; otherwise preserve a
109
+ * valid explicit caller environment value and supply a finite CLI default.
110
+ */
111
+ export function configureSyncLockTimeout(raw: string | undefined): void {
112
+ const value = raw ?? process.env.HQ_OP_LOCK_TIMEOUT ?? String(DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS);
113
+ const seconds = Number(value);
114
+ if (!Number.isInteger(seconds) || seconds < 0) {
115
+ throw new Error("--lock-timeout must be a non-negative integer number of seconds");
116
+ }
117
+ process.env.HQ_OP_LOCK_TIMEOUT = String(seconds);
100
118
  }
101
119
 
102
120
  // ─────────────────────────────────────────────────────────────────────────────
@@ -821,6 +839,10 @@ export function registerCloudCommands(program: Command): void {
821
839
  "--company <slug>",
822
840
  "Company slug or UID (defaults to active company in .hq/config.json)",
823
841
  )
842
+ .option(
843
+ "--lock-timeout <seconds>",
844
+ "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
845
+ )
824
846
  .option(
825
847
  "--message <msg>",
826
848
  "Optional message attached to journal entries for these uploads",
@@ -884,6 +906,7 @@ export function registerCloudCommands(program: Command): void {
884
906
  },
885
907
  ) => {
886
908
  try {
909
+ configureSyncLockTimeout(options.lockTimeout);
887
910
  assertSingleSelector(options, "push");
888
911
  } catch (err) {
889
912
  console.error(
@@ -1118,6 +1141,10 @@ export function registerCloudCommands(program: Command): void {
1118
1141
  "--company <slug>",
1119
1142
  "Company slug or UID (defaults to active company in .hq/config.json)",
1120
1143
  )
1144
+ .option(
1145
+ "--lock-timeout <seconds>",
1146
+ "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
1147
+ )
1121
1148
  .option(
1122
1149
  "--on-conflict <strategy>",
1123
1150
  "Conflict strategy: overwrite | keep | abort (omit for interactive)",
@@ -1168,6 +1195,7 @@ export function registerCloudCommands(program: Command): void {
1168
1195
  },
1169
1196
  ) => {
1170
1197
  try {
1198
+ configureSyncLockTimeout(options.lockTimeout);
1171
1199
  assertSingleSelector(options, "pull");
1172
1200
  } catch (err) {
1173
1201
  console.error(
@@ -1410,6 +1438,10 @@ export function registerCloudCommands(program: Command): void {
1410
1438
  "--company <slug>",
1411
1439
  "Company slug or UID (defaults to active company in .hq/config.json)",
1412
1440
  )
1441
+ .option(
1442
+ "--lock-timeout <seconds>",
1443
+ "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
1444
+ )
1413
1445
  .option(
1414
1446
  "--message <msg>",
1415
1447
  "Optional message attached to journal entries for the push leg",
@@ -1460,6 +1492,7 @@ export function registerCloudCommands(program: Command): void {
1460
1492
  },
1461
1493
  ) => {
1462
1494
  try {
1495
+ configureSyncLockTimeout(options.lockTimeout);
1463
1496
  assertSingleSelector(options, "now");
1464
1497
  if (options.all) {
1465
1498
  // `options.personal === false` is Commander's auto-negation
package/src/main.ts CHANGED
@@ -75,6 +75,10 @@ import {
75
75
  } from "./utils/version-gate.js";
76
76
  import { CLI_VERSION } from "./cli-version.js";
77
77
  import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
78
+ import { settleWithin } from "./utils/settle-with-timeout.js";
79
+
80
+ /** Hard upper bound for non-user-visible release-health finalization. */
81
+ const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
78
82
 
79
83
  // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
80
84
  // the pipe early. This covers the ASYNC path — an 'error' event emitted on the
@@ -351,6 +355,13 @@ export async function runCli(): Promise<void> {
351
355
  } finally {
352
356
  // Release health: finalize the per-run session before the flush.
353
357
  Sentry.endSession();
354
- await Promise.allSettled([refreshVersionCache(), Sentry.flush(2000)]);
358
+ // Neither task may turn a successful command into Node's
359
+ // `unsettled top-level await` exit. They are observability-only after the
360
+ // command has completed, so a bounded best-effort wait is the terminal
361
+ // lifecycle boundary for this invocation.
362
+ await settleWithin(
363
+ [refreshVersionCache(), Sentry.flush(2000)],
364
+ RELEASE_HEALTH_SETTLE_TIMEOUT_MS,
365
+ );
355
366
  }
356
367
  }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { settleWithin } from "./settle-with-timeout.js";
4
+
5
+ describe("settleWithin", () => {
6
+ it("settles normally when all finalizers finish", async () => {
7
+ await expect(settleWithin([Promise.resolve(), Promise.reject(new Error("ignored"))], 100))
8
+ .resolves.toBe("settled");
9
+ });
10
+
11
+ it("settles the caller when a best-effort finalizer never resolves", async () => {
12
+ vi.useFakeTimers();
13
+ try {
14
+ const result = settleWithin([new Promise<void>(() => {})], 1_000);
15
+ await vi.advanceTimersByTimeAsync(1_000);
16
+ await expect(result).resolves.toBe("timed_out");
17
+ } finally {
18
+ vi.useRealTimers();
19
+ }
20
+ });
21
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Await finalization work without allowing a best-effort promise to leave a
3
+ * CLI's top-level await pending forever. The timer intentionally stays
4
+ * referenced: Node must remain alive long enough to settle this race.
5
+ */
6
+ export async function settleWithin(
7
+ promises: readonly Promise<unknown>[],
8
+ timeoutMs: number,
9
+ ): Promise<"settled" | "timed_out"> {
10
+ let timer: ReturnType<typeof setTimeout> | undefined;
11
+ const timedOut = new Promise<"timed_out">((resolve) => {
12
+ timer = setTimeout(() => resolve("timed_out"), timeoutMs);
13
+ });
14
+ try {
15
+ return await Promise.race([
16
+ Promise.allSettled(promises).then(() => "settled" as const),
17
+ timedOut,
18
+ ]);
19
+ } finally {
20
+ if (timer !== undefined) clearTimeout(timer);
21
+ }
22
+ }
@@ -8,6 +8,7 @@ import { Sentry } from '../sentry.js';
8
8
  import { getCompanyUid, getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
9
9
  import { isAuthError } from './auth-error.js';
10
10
  import { isCompanySelectionError } from './company-selection-error.js';
11
+ import { isExpectedUserError } from './expected-cli-error.js';
11
12
 
12
13
  const fetchMock = vi.fn();
13
14
  const originalFetch = globalThis.fetch;
@@ -142,6 +143,19 @@ describe('getEntityUid', () => {
142
143
  expect(fetchMock.mock.calls[1][0]).toMatch(/\/entity\/by-slug\/company\/acme/);
143
144
  });
144
145
 
146
+ it('classifies a global by-slug entity miss as an expected user error (HQ-CLI-8)', async () => {
147
+ fetchMock
148
+ .mockResolvedValueOnce(mockResponse(200, { available: true }))
149
+ .mockResolvedValueOnce(mockResponse(404, { error: 'Entity not found' }));
150
+
151
+ const err = await getEntityUid('tok', { companySlug: 'missing-co' }).catch(
152
+ (e: unknown) => e,
153
+ );
154
+
155
+ expect(isExpectedUserError(err)).toBe(true);
156
+ expect((err as Error).message).toMatch(/Company slug 'missing-co' was not found/);
157
+ });
158
+
145
159
  it('on residual global ambiguity (not in namespace) gives actionable --company <uid> guidance', async () => {
146
160
  // Caller belongs to no "acme"; the slug matches multiple strangers'
147
161
  // companies. The server 409s with the colliding uids and the CLI must tell
@@ -217,6 +217,14 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
217
217
  body.uids.map((u) => ` --company ${u}`).join('\n'),
218
218
  );
219
219
  }
220
+ if (res.status === 404 && body.error === "Entity not found") {
221
+ throw Object.assign(
222
+ new Error(
223
+ `Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`,
224
+ ),
225
+ { expected: true as const },
226
+ );
227
+ }
220
228
  throw new Error(
221
229
  `Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`,
222
230
  );