@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.
- package/.github/workflows/ci.yml +8 -4
- package/.github/workflows/publish.yml +9 -3
- package/dist/bin/sync-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +58 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/client-info.d.ts +44 -0
- package/dist/client-info.d.ts.map +1 -0
- package/dist/client-info.js +112 -0
- package/dist/client-info.js.map +1 -0
- package/dist/client-info.test.d.ts +11 -0
- package/dist/client-info.test.d.ts.map +1 -0
- package/dist/client-info.test.js +168 -0
- package/dist/client-info.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +10 -2
- package/dist/context.js.map +1 -1
- package/dist/entity-resolver.d.ts +48 -0
- package/dist/entity-resolver.d.ts.map +1 -0
- package/dist/entity-resolver.js +122 -0
- package/dist/entity-resolver.js.map +1 -0
- package/dist/entity-resolver.test.d.ts +10 -0
- package/dist/entity-resolver.test.d.ts.map +1 -0
- package/dist/entity-resolver.test.js +236 -0
- package/dist/entity-resolver.test.js.map +1 -0
- package/dist/index.d.ts +20 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/signal-types.d.ts +16 -0
- package/dist/schemas/signal-types.d.ts.map +1 -0
- package/dist/schemas/signal-types.js +30 -0
- package/dist/schemas/signal-types.js.map +1 -0
- package/dist/schemas/signal-types.test.d.ts +2 -0
- package/dist/schemas/signal-types.test.d.ts.map +1 -0
- package/dist/schemas/signal-types.test.js +65 -0
- package/dist/schemas/signal-types.test.js.map +1 -0
- package/dist/schemas/source-channels.d.ts +15 -0
- package/dist/schemas/source-channels.d.ts.map +1 -0
- package/dist/schemas/source-channels.js +28 -0
- package/dist/schemas/source-channels.js.map +1 -0
- package/dist/schemas/source-channels.test.d.ts +2 -0
- package/dist/schemas/source-channels.test.d.ts.map +1 -0
- package/dist/schemas/source-channels.test.js +65 -0
- package/dist/schemas/source-channels.test.js.map +1 -0
- package/dist/signals/get.d.ts +13 -0
- package/dist/signals/get.d.ts.map +1 -0
- package/dist/signals/get.js +74 -0
- package/dist/signals/get.js.map +1 -0
- package/dist/signals/get.test.d.ts +5 -0
- package/dist/signals/get.test.d.ts.map +1 -0
- package/dist/signals/get.test.js +170 -0
- package/dist/signals/get.test.js.map +1 -0
- package/dist/signals/internals.d.ts +16 -0
- package/dist/signals/internals.d.ts.map +1 -0
- package/dist/signals/internals.js +39 -0
- package/dist/signals/internals.js.map +1 -0
- package/dist/signals/list.d.ts +10 -0
- package/dist/signals/list.d.ts.map +1 -0
- package/dist/signals/list.js +76 -0
- package/dist/signals/list.js.map +1 -0
- package/dist/signals/list.test.d.ts +9 -0
- package/dist/signals/list.test.d.ts.map +1 -0
- package/dist/signals/list.test.js +227 -0
- package/dist/signals/list.test.js.map +1 -0
- package/dist/signals/parse.d.ts +8 -0
- package/dist/signals/parse.d.ts.map +1 -0
- package/dist/signals/parse.js +8 -0
- package/dist/signals/parse.js.map +1 -0
- package/dist/signals/types.d.ts +69 -0
- package/dist/signals/types.d.ts.map +1 -0
- package/dist/signals/types.js +10 -0
- package/dist/signals/types.js.map +1 -0
- package/dist/sources/get.d.ts +11 -0
- package/dist/sources/get.d.ts.map +1 -0
- package/dist/sources/get.js +67 -0
- package/dist/sources/get.js.map +1 -0
- package/dist/sources/get.test.d.ts +5 -0
- package/dist/sources/get.test.d.ts.map +1 -0
- package/dist/sources/get.test.js +132 -0
- package/dist/sources/get.test.js.map +1 -0
- package/dist/sources/internals.d.ts +16 -0
- package/dist/sources/internals.d.ts.map +1 -0
- package/dist/sources/internals.js +39 -0
- package/dist/sources/internals.js.map +1 -0
- package/dist/sources/list.d.ts +10 -0
- package/dist/sources/list.d.ts.map +1 -0
- package/dist/sources/list.js +76 -0
- package/dist/sources/list.js.map +1 -0
- package/dist/sources/list.test.d.ts +8 -0
- package/dist/sources/list.test.d.ts.map +1 -0
- package/dist/sources/list.test.js +198 -0
- package/dist/sources/list.test.js.map +1 -0
- package/dist/sources/parse.d.ts +18 -0
- package/dist/sources/parse.d.ts.map +1 -0
- package/dist/sources/parse.js +35 -0
- package/dist/sources/parse.js.map +1 -0
- package/dist/sources/types.d.ts +62 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +8 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/telemetry.d.ts +87 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +349 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/telemetry.test.d.ts +11 -0
- package/dist/telemetry.test.d.ts.map +1 -0
- package/dist/telemetry.test.js +309 -0
- package/dist/telemetry.test.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +60 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +41 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +5 -3
- package/src/bin/sync-runner.ts +73 -0
- package/src/client-info.test.ts +214 -0
- package/src/client-info.ts +121 -0
- package/src/context.ts +10 -2
- package/src/entity-resolver.test.ts +307 -0
- package/src/entity-resolver.ts +173 -0
- package/src/index.ts +91 -0
- package/src/schemas/signal-types.test.ts +82 -0
- package/src/schemas/signal-types.ts +38 -0
- package/src/schemas/source-channels.test.ts +82 -0
- package/src/schemas/source-channels.ts +36 -0
- package/src/signals/get.test.ts +204 -0
- package/src/signals/get.ts +79 -0
- package/src/signals/internals.ts +46 -0
- package/src/signals/list.test.ts +283 -0
- package/src/signals/list.ts +92 -0
- package/src/signals/parse.ts +8 -0
- package/src/signals/types.ts +74 -0
- package/src/sources/get.test.ts +166 -0
- package/src/sources/get.ts +75 -0
- package/src/sources/internals.ts +46 -0
- package/src/sources/list.test.ts +247 -0
- package/src/sources/list.ts +95 -0
- package/src/sources/parse.ts +43 -0
- package/src/sources/types.ts +67 -0
- package/src/telemetry.test.ts +394 -0
- package/src/telemetry.ts +436 -0
- package/src/types.ts +23 -0
- package/src/vault-client.ts +91 -1
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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
|
});
|