@indigoai-us/hq-cloud 5.19.0 → 5.20.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.
Files changed (145) hide show
  1. package/.github/workflows/ci.yml +8 -4
  2. package/.github/workflows/publish.yml +9 -3
  3. package/dist/bin/sync-runner.d.ts +9 -0
  4. package/dist/bin/sync-runner.d.ts.map +1 -1
  5. package/dist/bin/sync-runner.js +58 -0
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/client-info.d.ts +44 -0
  8. package/dist/client-info.d.ts.map +1 -0
  9. package/dist/client-info.js +112 -0
  10. package/dist/client-info.js.map +1 -0
  11. package/dist/client-info.test.d.ts +11 -0
  12. package/dist/client-info.test.d.ts.map +1 -0
  13. package/dist/client-info.test.js +168 -0
  14. package/dist/client-info.test.js.map +1 -0
  15. package/dist/context.d.ts.map +1 -1
  16. package/dist/context.js +10 -2
  17. package/dist/context.js.map +1 -1
  18. package/dist/entity-resolver.d.ts +48 -0
  19. package/dist/entity-resolver.d.ts.map +1 -0
  20. package/dist/entity-resolver.js +122 -0
  21. package/dist/entity-resolver.js.map +1 -0
  22. package/dist/entity-resolver.test.d.ts +10 -0
  23. package/dist/entity-resolver.test.d.ts.map +1 -0
  24. package/dist/entity-resolver.test.js +236 -0
  25. package/dist/entity-resolver.test.js.map +1 -0
  26. package/dist/index.d.ts +20 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +24 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/schemas/signal-types.d.ts +16 -0
  31. package/dist/schemas/signal-types.d.ts.map +1 -0
  32. package/dist/schemas/signal-types.js +30 -0
  33. package/dist/schemas/signal-types.js.map +1 -0
  34. package/dist/schemas/signal-types.test.d.ts +2 -0
  35. package/dist/schemas/signal-types.test.d.ts.map +1 -0
  36. package/dist/schemas/signal-types.test.js +65 -0
  37. package/dist/schemas/signal-types.test.js.map +1 -0
  38. package/dist/schemas/source-channels.d.ts +15 -0
  39. package/dist/schemas/source-channels.d.ts.map +1 -0
  40. package/dist/schemas/source-channels.js +28 -0
  41. package/dist/schemas/source-channels.js.map +1 -0
  42. package/dist/schemas/source-channels.test.d.ts +2 -0
  43. package/dist/schemas/source-channels.test.d.ts.map +1 -0
  44. package/dist/schemas/source-channels.test.js +65 -0
  45. package/dist/schemas/source-channels.test.js.map +1 -0
  46. package/dist/signals/get.d.ts +13 -0
  47. package/dist/signals/get.d.ts.map +1 -0
  48. package/dist/signals/get.js +74 -0
  49. package/dist/signals/get.js.map +1 -0
  50. package/dist/signals/get.test.d.ts +5 -0
  51. package/dist/signals/get.test.d.ts.map +1 -0
  52. package/dist/signals/get.test.js +170 -0
  53. package/dist/signals/get.test.js.map +1 -0
  54. package/dist/signals/internals.d.ts +16 -0
  55. package/dist/signals/internals.d.ts.map +1 -0
  56. package/dist/signals/internals.js +39 -0
  57. package/dist/signals/internals.js.map +1 -0
  58. package/dist/signals/list.d.ts +10 -0
  59. package/dist/signals/list.d.ts.map +1 -0
  60. package/dist/signals/list.js +76 -0
  61. package/dist/signals/list.js.map +1 -0
  62. package/dist/signals/list.test.d.ts +9 -0
  63. package/dist/signals/list.test.d.ts.map +1 -0
  64. package/dist/signals/list.test.js +227 -0
  65. package/dist/signals/list.test.js.map +1 -0
  66. package/dist/signals/parse.d.ts +8 -0
  67. package/dist/signals/parse.d.ts.map +1 -0
  68. package/dist/signals/parse.js +8 -0
  69. package/dist/signals/parse.js.map +1 -0
  70. package/dist/signals/types.d.ts +69 -0
  71. package/dist/signals/types.d.ts.map +1 -0
  72. package/dist/signals/types.js +10 -0
  73. package/dist/signals/types.js.map +1 -0
  74. package/dist/sources/get.d.ts +11 -0
  75. package/dist/sources/get.d.ts.map +1 -0
  76. package/dist/sources/get.js +67 -0
  77. package/dist/sources/get.js.map +1 -0
  78. package/dist/sources/get.test.d.ts +5 -0
  79. package/dist/sources/get.test.d.ts.map +1 -0
  80. package/dist/sources/get.test.js +132 -0
  81. package/dist/sources/get.test.js.map +1 -0
  82. package/dist/sources/internals.d.ts +16 -0
  83. package/dist/sources/internals.d.ts.map +1 -0
  84. package/dist/sources/internals.js +39 -0
  85. package/dist/sources/internals.js.map +1 -0
  86. package/dist/sources/list.d.ts +10 -0
  87. package/dist/sources/list.d.ts.map +1 -0
  88. package/dist/sources/list.js +76 -0
  89. package/dist/sources/list.js.map +1 -0
  90. package/dist/sources/list.test.d.ts +8 -0
  91. package/dist/sources/list.test.d.ts.map +1 -0
  92. package/dist/sources/list.test.js +198 -0
  93. package/dist/sources/list.test.js.map +1 -0
  94. package/dist/sources/parse.d.ts +18 -0
  95. package/dist/sources/parse.d.ts.map +1 -0
  96. package/dist/sources/parse.js +35 -0
  97. package/dist/sources/parse.js.map +1 -0
  98. package/dist/sources/types.d.ts +62 -0
  99. package/dist/sources/types.d.ts.map +1 -0
  100. package/dist/sources/types.js +8 -0
  101. package/dist/sources/types.js.map +1 -0
  102. package/dist/telemetry.d.ts +87 -0
  103. package/dist/telemetry.d.ts.map +1 -0
  104. package/dist/telemetry.js +349 -0
  105. package/dist/telemetry.js.map +1 -0
  106. package/dist/telemetry.test.d.ts +11 -0
  107. package/dist/telemetry.test.d.ts.map +1 -0
  108. package/dist/telemetry.test.js +309 -0
  109. package/dist/telemetry.test.js.map +1 -0
  110. package/dist/types.d.ts +22 -0
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/vault-client.d.ts +60 -0
  113. package/dist/vault-client.d.ts.map +1 -1
  114. package/dist/vault-client.js +41 -0
  115. package/dist/vault-client.js.map +1 -1
  116. package/package.json +5 -3
  117. package/src/bin/sync-runner.ts +73 -0
  118. package/src/client-info.test.ts +214 -0
  119. package/src/client-info.ts +121 -0
  120. package/src/context.ts +10 -2
  121. package/src/entity-resolver.test.ts +307 -0
  122. package/src/entity-resolver.ts +173 -0
  123. package/src/index.ts +91 -0
  124. package/src/schemas/signal-types.test.ts +82 -0
  125. package/src/schemas/signal-types.ts +38 -0
  126. package/src/schemas/source-channels.test.ts +82 -0
  127. package/src/schemas/source-channels.ts +36 -0
  128. package/src/signals/get.test.ts +204 -0
  129. package/src/signals/get.ts +79 -0
  130. package/src/signals/internals.ts +46 -0
  131. package/src/signals/list.test.ts +283 -0
  132. package/src/signals/list.ts +92 -0
  133. package/src/signals/parse.ts +8 -0
  134. package/src/signals/types.ts +74 -0
  135. package/src/sources/get.test.ts +166 -0
  136. package/src/sources/get.ts +75 -0
  137. package/src/sources/internals.ts +46 -0
  138. package/src/sources/list.test.ts +247 -0
  139. package/src/sources/list.ts +95 -0
  140. package/src/sources/parse.ts +43 -0
  141. package/src/sources/types.ts +67 -0
  142. package/src/telemetry.test.ts +394 -0
  143. package/src/telemetry.ts +436 -0
  144. package/src/types.ts +23 -0
  145. package/src/vault-client.ts +91 -1
@@ -83,6 +83,7 @@ import { share as defaultShare } from "../cli/share.js";
83
83
  import type { ShareOptions, ShareResult } from "../cli/share.js";
84
84
  import type { ConflictStrategy } from "../cli/conflict.js";
85
85
  import type { UploadAuthor } from "../s3.js";
86
+ import { collectAndSendTelemetry } from "../telemetry.js";
86
87
 
87
88
  /**
88
89
  * Sync direction for a run.
@@ -278,6 +279,15 @@ export interface RunnerDeps {
278
279
  sync?: (options: SyncOptions) => Promise<SyncResult>;
279
280
  /** Share function (push phase). Defaults to `cli/share.share`. */
280
281
  share?: (options: ShareOptions) => Promise<ShareResult>;
282
+ /**
283
+ * Telemetry collector — runs just before the `all-complete` emit. Default
284
+ * implementation calls `collectAndSendTelemetry` from `../telemetry.js`
285
+ * using the real VaultClient; tests that inject `createVaultClient` are
286
+ * implicitly opted out (the default skips when the client isn't a real
287
+ * `VaultClient`). Tests that want to assert telemetry behavior should pass
288
+ * an explicit stub here.
289
+ */
290
+ collectTelemetry?: () => Promise<void>;
281
291
  }
282
292
 
283
293
  // ---------------------------------------------------------------------------
@@ -445,6 +455,55 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
445
455
  return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
446
456
  }
447
457
 
458
+ // ---------------------------------------------------------------------------
459
+ // Telemetry default — closes over the runner's vault client. Skipped when
460
+ // the caller injected a `createVaultClient` stub, because we have no
461
+ // guarantee the stub implements `getTelemetryOptIn` / `postUsage`. The real
462
+ // `VaultClient` from `../vault-client.js` always does. All errors are
463
+ // swallowed — telemetry must never abort or delay sync.
464
+ // ---------------------------------------------------------------------------
465
+
466
+ async function defaultCollectTelemetry(
467
+ client: VaultClientSurface,
468
+ clientIsStub: boolean,
469
+ ): Promise<void> {
470
+ if (clientIsStub) return;
471
+ try {
472
+ // machineId: prefer ~/.hq/menubar.json (set by the menubar app on first
473
+ // launch). When absent — e.g. fresh CLI-only install — fall back to a
474
+ // value that makes the row identifiable as "unattributed" rather than
475
+ // crashing or spoofing another machine's id.
476
+ const menubarPath = path.join(os.homedir(), ".hq", "menubar.json");
477
+ let machineId = "unknown";
478
+ try {
479
+ const raw = await fs.promises.readFile(menubarPath, "utf-8");
480
+ const parsed = JSON.parse(raw) as { machineId?: unknown };
481
+ if (typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
482
+ machineId = parsed.machineId;
483
+ }
484
+ } catch {
485
+ // No menubar.json — proceed with "unknown".
486
+ }
487
+
488
+ // installerVersion: callers (the Tauri menubar) set this when spawning
489
+ // the runner so the historical `installerVersion` dimension on
490
+ // CloudWatch keeps reporting the menubar version, not the runner's
491
+ // package version. CLI callers can leave it unset.
492
+ const installerVersion = process.env.HQ_INSTALLER_VERSION ?? "hq-cloud";
493
+
494
+ await collectAndSendTelemetry({
495
+ // The runtime guarantee here is that `clientIsStub === false` means
496
+ // `client` came from `new VaultClient(vaultConfig)` (see runRunner),
497
+ // which structurally satisfies `TelemetryClientSurface`.
498
+ client: client as unknown as Parameters<typeof collectAndSendTelemetry>[0]["client"],
499
+ machineId,
500
+ installerVersion,
501
+ });
502
+ } catch {
503
+ // Fire-and-forget; nothing escapes the boundary.
504
+ }
505
+ }
506
+
448
507
  // ---------------------------------------------------------------------------
449
508
  // runRunner — testable entrypoint
450
509
  // ---------------------------------------------------------------------------
@@ -922,6 +981,20 @@ export async function runRunner(
922
981
  });
923
982
  }
924
983
 
984
+ // Fire telemetry collector before the all-complete emit so the cursor at
985
+ // `~/.hq/telemetry-cursor.json` is consistent with what the menubar sees.
986
+ // Awaited fully — fire-and-forget would lose in-flight POSTs at process
987
+ // exit, and the previous 10s race was wrong: a first-run user with
988
+ // backlog (e.g. 60K Claude session events) takes well over 10s legitimately,
989
+ // and the race silently dropped the entire batch when it fired. Errors
990
+ // are swallowed inside `defaultCollectTelemetry`; per-request timeouts
991
+ // come from `VaultClient`'s retry loop (3 attempts × exponential backoff),
992
+ // which naturally bounds the outer wait.
993
+ const telemetryFn =
994
+ deps.collectTelemetry ??
995
+ (() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined));
996
+ await telemetryFn().catch(() => undefined);
997
+
925
998
  emit({
926
999
  type: "all-complete",
927
1000
  companiesAttempted: plan.length,
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Tests for the client-info helpers and header injection.
3
+ *
4
+ * Two layers are exercised here:
5
+ * 1. The pure functions in `client-info.ts` — buildClientHeaders,
6
+ * clientInfoFromPackage, detectHqCoreVersion.
7
+ * 2. End-to-end injection into VaultClient.request, since "the headers
8
+ * actually land on outbound fetch" is the property consumers care about.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import {
16
+ buildClientHeaders,
17
+ clientInfoFromPackage,
18
+ detectHqCoreVersion,
19
+ HEADER_CLIENT_NAME,
20
+ HEADER_CLIENT_VERSION,
21
+ HEADER_HQ_CORE_VERSION,
22
+ } from "./client-info.js";
23
+ import { VaultClient } from "./vault-client.js";
24
+
25
+ describe("buildClientHeaders", () => {
26
+ it("returns empty object when info is undefined", () => {
27
+ expect(buildClientHeaders(undefined)).toEqual({});
28
+ });
29
+
30
+ it("emits User-Agent + x-hq-client-{name,version} from name/version", () => {
31
+ const headers = buildClientHeaders({
32
+ name: "@indigoai-us/hq-cli",
33
+ version: "5.15.0",
34
+ });
35
+ expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
36
+ expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
37
+ expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
38
+ expect(headers[HEADER_HQ_CORE_VERSION]).toBeUndefined();
39
+ });
40
+
41
+ it("includes x-hq-core-version only when hqCoreVersion is set", () => {
42
+ const headers = buildClientHeaders({
43
+ name: "@indigoai-us/hq-cli",
44
+ version: "5.15.0",
45
+ hqCoreVersion: "14.1.0",
46
+ });
47
+ expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
48
+ });
49
+
50
+ it("emits arbitrary extra fields as x-hq-client-<key> headers", () => {
51
+ const headers = buildClientHeaders({
52
+ name: "x",
53
+ version: "1.0.0",
54
+ extra: { machine: "ec2-bot-7", channel: "stable" },
55
+ });
56
+ expect(headers["x-hq-client-machine"]).toBe("ec2-bot-7");
57
+ expect(headers["x-hq-client-channel"]).toBe("stable");
58
+ });
59
+ });
60
+
61
+ describe("clientInfoFromPackage", () => {
62
+ it("extracts name and version from a parsed package.json", () => {
63
+ expect(clientInfoFromPackage({ name: "foo", version: "1.2.3" })).toEqual({
64
+ name: "foo",
65
+ version: "1.2.3",
66
+ });
67
+ });
68
+
69
+ it("throws when name is missing", () => {
70
+ expect(() => clientInfoFromPackage({ version: "1.0.0" })).toThrow(/name/);
71
+ });
72
+
73
+ it("throws when version is missing", () => {
74
+ expect(() => clientInfoFromPackage({ name: "foo" })).toThrow(/version/);
75
+ });
76
+
77
+ it("throws on non-string fields", () => {
78
+ expect(() =>
79
+ clientInfoFromPackage({ name: 42 as unknown as string, version: "1.0.0" }),
80
+ ).toThrow();
81
+ });
82
+ });
83
+
84
+ describe("detectHqCoreVersion", () => {
85
+ let tmpRoot: string;
86
+ const origHqHome = process.env.HQ_HOME;
87
+ const origHqRoot = process.env.HQ_ROOT;
88
+
89
+ beforeEach(() => {
90
+ tmpRoot = mkdtempSync(join(tmpdir(), "hq-core-detect-"));
91
+ delete process.env.HQ_HOME;
92
+ delete process.env.HQ_ROOT;
93
+ });
94
+
95
+ afterEach(() => {
96
+ rmSync(tmpRoot, { recursive: true, force: true });
97
+ if (origHqHome !== undefined) process.env.HQ_HOME = origHqHome;
98
+ if (origHqRoot !== undefined) process.env.HQ_ROOT = origHqRoot;
99
+ });
100
+
101
+ function seedHqCore(root: string, version: string): void {
102
+ mkdirSync(join(root, "core"), { recursive: true });
103
+ mkdirSync(join(root, "companies"), { recursive: true });
104
+ writeFileSync(
105
+ join(root, "core", "core.yaml"),
106
+ `version: 1\nhqVersion: "${version}"\nupdatedAt: "2026-05-13T00:00:00Z"\n`,
107
+ );
108
+ }
109
+
110
+ it("returns undefined when nothing on the walk-up has core.yaml", () => {
111
+ expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
112
+ });
113
+
114
+ it("returns hqVersion when startDir is the hq-core root", () => {
115
+ seedHqCore(tmpRoot, "14.1.0");
116
+ expect(detectHqCoreVersion(tmpRoot)).toBe("14.1.0");
117
+ });
118
+
119
+ it("walks upward to find core.yaml from a nested cwd", () => {
120
+ seedHqCore(tmpRoot, "14.2.0");
121
+ const nested = join(tmpRoot, "companies", "acme", "projects", "p1");
122
+ mkdirSync(nested, { recursive: true });
123
+ expect(detectHqCoreVersion(nested)).toBe("14.2.0");
124
+ });
125
+
126
+ it("ignores directories that have core.yaml but no companies/ — disambiguates fixtures", () => {
127
+ mkdirSync(join(tmpRoot, "core"), { recursive: true });
128
+ writeFileSync(
129
+ join(tmpRoot, "core", "core.yaml"),
130
+ `version: 1\nhqVersion: "99.0.0"\n`,
131
+ );
132
+ // No companies/ at this level → not an hq-core root.
133
+ expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
134
+ });
135
+
136
+ it("honors HQ_HOME env override before walking cwd", () => {
137
+ seedHqCore(tmpRoot, "14.3.0");
138
+ process.env.HQ_HOME = tmpRoot;
139
+ // startDir intentionally points elsewhere — env should win.
140
+ const elsewhere = mkdtempSync(join(tmpdir(), "elsewhere-"));
141
+ try {
142
+ expect(detectHqCoreVersion(elsewhere)).toBe("14.3.0");
143
+ } finally {
144
+ rmSync(elsewhere, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ it("parses unquoted hqVersion values too", () => {
149
+ mkdirSync(join(tmpRoot, "core"), { recursive: true });
150
+ mkdirSync(join(tmpRoot, "companies"), { recursive: true });
151
+ writeFileSync(
152
+ join(tmpRoot, "core", "core.yaml"),
153
+ `version: 1\nhqVersion: 14.4.0\n`,
154
+ );
155
+ expect(detectHqCoreVersion(tmpRoot)).toBe("14.4.0");
156
+ });
157
+ });
158
+
159
+ describe("VaultClient stamps client headers on outbound requests", () => {
160
+ afterEach(() => {
161
+ vi.restoreAllMocks();
162
+ });
163
+
164
+ it("includes x-hq-client-name + x-hq-client-version when clientInfo is set", async () => {
165
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
166
+ new Response(JSON.stringify({ memberships: [] }), {
167
+ status: 200,
168
+ headers: { "Content-Type": "application/json" },
169
+ }),
170
+ );
171
+
172
+ const client = new VaultClient({
173
+ apiUrl: "https://vault.test.example.com",
174
+ authToken: "tok",
175
+ clientInfo: {
176
+ name: "@indigoai-us/hq-cli",
177
+ version: "5.15.0",
178
+ hqCoreVersion: "14.1.0",
179
+ },
180
+ });
181
+
182
+ await client.listMyMemberships();
183
+
184
+ expect(fetchSpy).toHaveBeenCalledOnce();
185
+ const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
186
+ const headers = init.headers as Record<string, string>;
187
+ expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
188
+ expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
189
+ expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
190
+ expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
191
+ });
192
+
193
+ it("omits client headers when clientInfo is not set — back-compat", async () => {
194
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
195
+ new Response(JSON.stringify({ memberships: [] }), {
196
+ status: 200,
197
+ headers: { "Content-Type": "application/json" },
198
+ }),
199
+ );
200
+
201
+ const client = new VaultClient({
202
+ apiUrl: "https://vault.test.example.com",
203
+ authToken: "tok",
204
+ });
205
+
206
+ await client.listMyMemberships();
207
+
208
+ const headers = (fetchSpy.mock.calls[0]?.[1] as RequestInit)
209
+ .headers as Record<string, string>;
210
+ expect(headers[HEADER_CLIENT_NAME]).toBeUndefined();
211
+ expect(headers[HEADER_CLIENT_VERSION]).toBeUndefined();
212
+ expect(headers["User-Agent"]).toBeUndefined();
213
+ });
214
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Client identification — every HQ client that talks to hq-cloud-api should
3
+ * stamp its name and version on outbound requests so the server can attribute
4
+ * traffic, gate on minimum versions, and surface deprecation warnings.
5
+ *
6
+ * The CLI in particular sends a third header (`x-hq-core-version`) when it's
7
+ * invoked from inside an hq-core checkout, so the server sees which scaffold
8
+ * generation the user is running against.
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import type { ClientInfo } from "./types.js";
14
+
15
+ export const HEADER_CLIENT_NAME = "x-hq-client-name";
16
+ export const HEADER_CLIENT_VERSION = "x-hq-client-version";
17
+ export const HEADER_HQ_CORE_VERSION = "x-hq-core-version";
18
+
19
+ /**
20
+ * Build the set of `x-hq-*` headers (plus a derived `User-Agent`) for a
21
+ * ClientInfo. Returns an empty object when info is undefined so callers can
22
+ * spread the result unconditionally.
23
+ */
24
+ export function buildClientHeaders(
25
+ info: ClientInfo | undefined,
26
+ ): Record<string, string> {
27
+ if (!info) return {};
28
+
29
+ const headers: Record<string, string> = {
30
+ "User-Agent": `${info.name}/${info.version}`,
31
+ [HEADER_CLIENT_NAME]: info.name,
32
+ [HEADER_CLIENT_VERSION]: info.version,
33
+ };
34
+
35
+ if (info.hqCoreVersion) {
36
+ headers[HEADER_HQ_CORE_VERSION] = info.hqCoreVersion;
37
+ }
38
+
39
+ if (info.extra) {
40
+ for (const [k, v] of Object.entries(info.extra)) {
41
+ headers[`x-hq-client-${k}`] = v;
42
+ }
43
+ }
44
+
45
+ return headers;
46
+ }
47
+
48
+ /**
49
+ * Build a ClientInfo from a parsed package.json. Most consumers will call this
50
+ * once at startup and pass the result into every VaultServiceConfig.
51
+ */
52
+ export function clientInfoFromPackage(pkg: {
53
+ name?: unknown;
54
+ version?: unknown;
55
+ }): ClientInfo {
56
+ if (typeof pkg.name !== "string" || pkg.name.length === 0) {
57
+ throw new Error("clientInfoFromPackage: package.json is missing a name");
58
+ }
59
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
60
+ throw new Error("clientInfoFromPackage: package.json is missing a version");
61
+ }
62
+ return { name: pkg.name, version: pkg.version };
63
+ }
64
+
65
+ /**
66
+ * Walk upward from `startDir` looking for `core/core.yaml`. When found, parse
67
+ * out the `hqVersion` field and return it. Returns undefined if we never find
68
+ * an hq-core checkout — i.e. the caller is running from a plain user dir.
69
+ *
70
+ * Honors `HQ_HOME` env var as an explicit override so multi-checkout setups
71
+ * (e.g. CI bots, the menubar app pointing at a non-cwd HQ root) can pin the
72
+ * detection without relying on cwd.
73
+ *
74
+ * Why a regex instead of a YAML parser: `core.yaml` lives at the very top of
75
+ * the file and the field has a stable shape — adding a YAML dep just to read
76
+ * one string would balloon every consumer's bundle. The current shape is
77
+ * `hqVersion: "X.Y.Z"` (quoted) per the canonical seed; we tolerate unquoted
78
+ * too.
79
+ */
80
+ export function detectHqCoreVersion(startDir?: string): string | undefined {
81
+ const fromEnv = process.env.HQ_HOME ?? process.env.HQ_ROOT;
82
+ if (fromEnv) {
83
+ const v = readCoreVersionAt(fromEnv);
84
+ if (v) return v;
85
+ }
86
+
87
+ let dir = resolve(startDir ?? process.cwd());
88
+ while (true) {
89
+ const v = readCoreVersionAt(dir);
90
+ if (v) return v;
91
+ const parent = dirname(dir);
92
+ if (parent === dir) return undefined;
93
+ dir = parent;
94
+ }
95
+ }
96
+
97
+ function readCoreVersionAt(hqRoot: string): string | undefined {
98
+ // hq-core identity requires BOTH core/core.yaml AND companies/ — matches the
99
+ // CLI's own detection (commit bc827d0). Without this guard, any directory
100
+ // containing a stray `core/core.yaml` (e.g. a test fixture) would be
101
+ // misidentified as an hq-core root.
102
+ const yamlPath = join(hqRoot, "core", "core.yaml");
103
+ const companiesPath = join(hqRoot, "companies");
104
+ let content: string;
105
+ try {
106
+ content = readFileSync(yamlPath, "utf8");
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ try {
111
+ // statSync would be marginally cleaner, but readdirSync of a nonexistent
112
+ // path throws synchronously which is what we want.
113
+ readFileSync(companiesPath, { flag: "r" });
114
+ } catch (err) {
115
+ const e = err as NodeJS.ErrnoException;
116
+ // EISDIR is the success case — companies/ exists and is a directory.
117
+ if (e.code !== "EISDIR") return undefined;
118
+ }
119
+ const match = /^hqVersion:\s*["']?([^"'\s]+)/m.exec(content);
120
+ return match ? match[1] : undefined;
121
+ }
package/src/context.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { EntityContext, VaultServiceConfig } from "./types.js";
10
+ import { buildClientHeaders } from "./client-info.js";
10
11
 
11
12
  /** Minimum remaining TTL before auto-refresh triggers (2 minutes). */
12
13
  const REFRESH_THRESHOLD_MS = 2 * 60 * 1000;
@@ -231,7 +232,10 @@ async function fetchEntity(
231
232
  config: VaultServiceConfig,
232
233
  ): Promise<EntityResponse> {
233
234
  const res = await fetch(`${config.apiUrl}/entity/${uid}`, {
234
- headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
235
+ headers: {
236
+ Authorization: `Bearer ${await resolveAuthToken(config)}`,
237
+ ...buildClientHeaders(config.clientInfo),
238
+ },
235
239
  });
236
240
  if (!res.ok) {
237
241
  const body = await res.text();
@@ -255,7 +259,10 @@ async function fetchEntityBySlug(
255
259
  // sync runner's "I want MY company" intent.
256
260
  const checkUrl = `${config.apiUrl}/entity/check-slug/me?type=${encodeURIComponent(type)}&slug=${encodeURIComponent(slug)}`;
257
261
  const checkRes = await fetch(checkUrl, {
258
- headers: { Authorization: `Bearer ${await resolveAuthToken(config)}` },
262
+ headers: {
263
+ Authorization: `Bearer ${await resolveAuthToken(config)}`,
264
+ ...buildClientHeaders(config.clientInfo),
265
+ },
259
266
  });
260
267
  if (!checkRes.ok) {
261
268
  const body = await checkRes.text();
@@ -290,6 +297,7 @@ async function postVend(
290
297
  headers: {
291
298
  "Content-Type": "application/json",
292
299
  Authorization: `Bearer ${await resolveAuthToken(config)}`,
300
+ ...buildClientHeaders(config.clientInfo),
293
301
  },
294
302
  body: JSON.stringify({ ...body, durationSeconds: DEFAULT_SESSION_DURATION_SECONDS }),
295
303
  });