diff --git a/src/commands/auth.ts b/src/commands/auth.ts index b4d62cd..5182828 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -282,7 +282,7 @@ export function authLoginHandler( try { const info = await doLogin(defaultEnv(globals.env), { store: resolveStore(), - http: oauthHttp(globals), + http: await oauthHttp(globals), noBrowser: opts.noBrowser, emitEvent: buildSignInUrlEmitter(globals, sinks), }); diff --git a/src/commands/config.ts b/src/commands/config.ts index 8bed000..8f916b5 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -86,9 +86,10 @@ function configSetHandler(key: string, value: string): CommandHandler { }; } const normalized = normalizeValue(validKey, value); - const cfg = await readConfig(); + const paths = configPaths(); + const cfg = await readConfig(paths); const updated: UserConfig = { ...cfg, [validKey]: normalized }; - await writeConfig(updated); + await writeConfig(updated, paths); return { ok: true, data: { key: validKey, value: normalized } }; }; } diff --git a/src/lib/api-client.test.ts b/src/lib/api-client.test.ts index 405bf3a..85ffe3b 100644 --- a/src/lib/api-client.test.ts +++ b/src/lib/api-client.test.ts @@ -84,6 +84,24 @@ describe("ApiClient basics", () => { expect(result.status).toBe(200); }); + test("stamps X-Gusto-CLI-Install-Id when installId is configured", async () => { + const captured: { url?: string; init?: RequestInit } = {}; + const client = makeClient(mockFetch(captured, { status: 200, body: {} }), { + installId: "11111111-2222-4333-8444-555555555555", + }); + await client.get("/v1/me"); + const headers = captured.init?.headers as Record; + expect(headers["X-Gusto-CLI-Install-Id"]).toBe("11111111-2222-4333-8444-555555555555"); + }); + + test("omits X-Gusto-CLI-Install-Id entirely when installId is undefined (opt-out)", async () => { + const captured: { url?: string; init?: RequestInit } = {}; + const client = makeClient(mockFetch(captured, { status: 200, body: {} })); + await client.get("/v1/me"); + const headers = captured.init?.headers as Record; + expect(headers["X-Gusto-CLI-Install-Id"]).toBeUndefined(); + }); + test("POST sends a JSON body and the right content-type", async () => { const captured: { url?: string; init?: RequestInit } = {}; const client = makeClient(mockFetch(captured, { status: 201, body: { id: "x" } })); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 9a62e4a..8ae4e37 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -104,6 +104,8 @@ export interface ApiClientOptions { baseUrl: string; token: string; apiVersion: string; + /** Anonymous per-install UUID sent as `X-Gusto-CLI-Install-Id`; omit to suppress the header. */ + installId?: string; fetchImpl?: typeof fetch; timeoutMs?: number; maxRetries?: number; @@ -142,6 +144,7 @@ export class ApiClient { private readonly baseUrl: string; private readonly token: string; private readonly apiVersion: string; + private readonly installId?: string; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; private readonly maxRetries: number; @@ -152,6 +155,7 @@ export class ApiClient { this.baseUrl = opts.baseUrl.replace(/\/$/, ""); this.token = opts.token; this.apiVersion = opts.apiVersion; + this.installId = opts.installId; this.fetchImpl = opts.fetchImpl ?? fetch; this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES; @@ -350,6 +354,9 @@ export class ApiClient { Accept: "application/json", "X-Gusto-API-Version": this.apiVersion, }; + if (this.installId !== undefined) { + headers["X-Gusto-CLI-Install-Id"] = this.installId; + } let init: RequestInit = { method, headers, signal: AbortSignal.timeout(timeoutMs) }; if (body !== undefined) { headers["Content-Type"] = "application/json"; diff --git a/src/lib/api-context.ts b/src/lib/api-context.ts index a109175..feae1e3 100644 --- a/src/lib/api-context.ts +++ b/src/lib/api-context.ts @@ -1,4 +1,5 @@ import { ApiClient, stderrRequestObserver } from "./api-client.ts"; +import { resolveInstallIdHeader } from "./config.ts"; import { confirmationGate } from "./confirm.ts"; import { defaultEnv, getAccessToken, getCompanyUuid, resolveApiVersion, resolveBaseUrl } from "./env.ts"; import { ExitCode } from "./exit-codes.ts"; @@ -54,12 +55,13 @@ export interface ApiContextOpts extends AuthOpts { * as a follow-up. */ export function buildApiClient( globals: GlobalFlags, - opts: { baseUrl: string; token: string; stderr?: NodeJS.WritableStream }, + opts: { baseUrl: string; token: string; installId?: string; stderr?: NodeJS.WritableStream }, ): ApiClient { return new ApiClient({ baseUrl: opts.baseUrl, token: opts.token, apiVersion: resolveApiVersion(), + installId: opts.installId, observer: globals.verbose ? stderrRequestObserver(opts.stderr ?? process.stderr) : undefined, }); } @@ -131,7 +133,8 @@ export async function resolveApiContext( const { token, source: tokenSource } = resolved; const baseUrl = resolveBaseUrl(globals.env); - const client = buildApiClient(globals, { baseUrl, token }); + const installId = await resolveInstallIdHeader(); + const client = buildApiClient(globals, { baseUrl, token, installId }); if (opts.requireCompany === false) { return { ok: true, ctx: { client, baseUrl, tokenSource, hasCompany: false } }; @@ -162,7 +165,7 @@ export async function resolveApiContext( /** The token from the stored login session, refreshed on near-expiry; null if none. */ async function sessionToken(globals: GlobalFlags, opts: AuthOpts): Promise { const store = opts.store ?? resolveStore(); - const http = opts.http ?? oauthHttp(globals); + const http = opts.http ?? (await oauthHttp(globals)); try { return await getValidUserToken(store, defaultEnv(globals.env), http, opts.now); } catch (err) { diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 73f3f5c..4e775c1 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -4,9 +4,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { type ConfigPaths, + getOrCreateInstallId, normalizeValue, readConfig, resetConfig, + resolveInstallIdHeader, validateKey, validateValue, writeConfig, @@ -131,3 +133,104 @@ describe("read/write/reset", () => { expect(await readConfig(paths)).toEqual({}); }); }); + +describe("getOrCreateInstallId", () => { + test("generates a v4-shaped UUID on first call and persists it", async () => { + const id = await getOrCreateInstallId(paths); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(await readConfig(paths)).toEqual({ install_id: id }); + }); + + test("second call returns the same value without rewriting", async () => { + const first = await getOrCreateInstallId(paths); + const second = await getOrCreateInstallId(paths); + expect(second).toBe(first); + }); + + test("preserves other config keys when generating", async () => { + await writeConfig({ environment: "sandbox", format: "human" }, paths); + const id = await getOrCreateInstallId(paths); + expect(await readConfig(paths)).toEqual({ environment: "sandbox", format: "human", install_id: id }); + }); + + test("regenerates after resetConfig", async () => { + const first = await getOrCreateInstallId(paths); + await resetConfig(paths); + const second = await getOrCreateInstallId(paths); + expect(second).not.toBe(first); + }); + + test("readConfig drops an empty-string install_id from disk", async () => { + await Bun.write(paths.file, `install_id = ""\n`); + expect(await readConfig(paths)).toEqual({}); + }); + + test("readConfig drops a non-UUID install_id from disk", async () => { + await Bun.write(paths.file, `install_id = "not-a-uuid"\nformat = "agent"\n`); + // Invalid install_id is dropped; sibling valid keys are preserved. + expect(await readConfig(paths)).toEqual({ format: "agent" }); + }); + + test("corrupted install_id is regenerated on next getOrCreateInstallId", async () => { + await Bun.write(paths.file, `install_id = "corrupted-value"\n`); + const fresh = await getOrCreateInstallId(paths); + expect(fresh).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect((await readConfig(paths)).install_id).toBe(fresh); + }); + + test("concurrent first-run calls each produce a valid install_id and the file converges to one", async () => { + // Documented behavior: two racing callers can each generate + write their own UUID. The + // file ends up with whichever wrote last, and any future caller sees that value. Formal + // first-writer-wins would require a lock — accepted trade-off, since divergence is bounded + // to one command per racing caller and self-heals on the next call. + const [a, b] = await Promise.all([getOrCreateInstallId(paths), getOrCreateInstallId(paths)]); + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + expect(a).toMatch(uuidPattern); + expect(b).toMatch(uuidPattern); + const settled = await getOrCreateInstallId(paths); + expect([a, b]).toContain(settled); + }); + + test("writeConfig cleans up its temp file on rename failure", async () => { + // A file at paths.dir makes mkdir fail with ENOTDIR AND rename fail. Verify writeConfig + // throws (propagated to caller) and doesn't leave a .tmp behind under the parent scratch dir. + const badDir = path.join(scratch, "not-a-dir"); + await Bun.write(badDir, ""); + const broken: ConfigPaths = { dir: badDir, file: path.join(badDir, "config.toml") }; + await expect(writeConfig({ install_id: "x" }, broken)).rejects.toThrow(); + const { readdirSync } = await import("node:fs"); + // No `.tmp` sibling under scratch either — the write couldn't proceed past mkdir. + for (const entry of readdirSync(scratch)) { + expect(entry.endsWith(".tmp")).toBe(false); + } + }); +}); + +describe("resolveInstallIdHeader", () => { + test("returns a UUID when telemetry is enabled and the config path is writable", async () => { + const id = await resolveInstallIdHeader(paths); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); + + test("returns undefined without side-effects when GUSTO_TELEMETRY=0", async () => { + const prev = process.env.GUSTO_TELEMETRY; + process.env.GUSTO_TELEMETRY = "0"; + try { + expect(await resolveInstallIdHeader(paths)).toBeUndefined(); + // No config file should have been written under opt-out. + expect(await readConfig(paths)).toEqual({}); + } finally { + if (prev === undefined) delete process.env.GUSTO_TELEMETRY; + else process.env.GUSTO_TELEMETRY = prev; + } + }); + + test("returns undefined (fail-open) when the config path is unwritable", async () => { + // A regular file where the config dir should be: mkdir hits ENOTDIR, getOrCreateInstallId + // throws, resolveInstallIdHeader must catch and silently degrade telemetry. + const badDir = path.join(scratch, "config-blocked"); + await Bun.write(badDir, ""); + const broken: ConfigPaths = { dir: badDir, file: path.join(badDir, "config.toml") }; + expect(await resolveInstallIdHeader(broken)).toBeUndefined(); + }); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts index be9b6ba..28bd8c7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,8 @@ +import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import path from "node:path"; import { parse, stringify } from "smol-toml"; +import { isTelemetryEnabled } from "./env.ts"; import type { Environment } from "./global-flags.ts"; import type { OutputMode } from "./output.ts"; @@ -14,12 +16,18 @@ export interface UserConfig { environment?: Environment; format?: OutputMode; skills_auto_install?: SkillsAutoInstall; + /** Anonymous per-install UUID managed by getOrCreateInstallId; not user-configurable. */ + install_id?: string; } const ENV_VALUES: readonly Environment[] = ["sandbox", "production"] as const; const FORMAT_VALUES: readonly OutputMode[] = ["agent", "human"] as const; const SKILLS_AUTO_INSTALL_VALUES: readonly SkillsAutoInstall[] = ["ask", "always", "never"] as const; +// Permissive UUID shape check — variant intentionally not pinned; we only care that on-disk +// values look like real UUIDs so corruption is rejected. +const INSTALL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + // `json` is the advertised alias for `agent` (see the `--json` / `--agent` global flags). // Accept it as a `format` value and persist it as `agent` so the config mirrors the flags. const FORMAT_ALIASES: Readonly> = { json: "agent" } as const; @@ -55,10 +63,22 @@ export async function readConfig(paths: ConfigPaths = configPaths()): Promise { - const { mkdir, chmod } = await import("node:fs/promises"); + const { mkdir, chmod, rename, rm } = await import("node:fs/promises"); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); - await Bun.write(paths.file, stringify(stripUndefined(cfg))); - await chmod(paths.file, 0o600); + // Write to a uniquely-named temp file and rename into place: POSIX rename on the same + // filesystem is atomic, so a concurrent reader can never observe a half-written file. + // Suffix includes pid + a UUID so two concurrent writes in the same process (or across + // processes with recycled PIDs) don't step on each other's temp file. + const tmp = `${paths.file}.${process.pid}.${randomUUID()}.tmp`; + try { + await Bun.write(tmp, stringify(stripUndefined(cfg))); + await chmod(tmp, 0o600); + await rename(tmp, paths.file); + } catch (err) { + // Best-effort tmp cleanup; don't shadow the real error. + await rm(tmp, { force: true }).catch(() => {}); + throw err; + } } export async function resetConfig(paths: ConfigPaths = configPaths()): Promise { @@ -66,6 +86,44 @@ export async function resetConfig(paths: ConfigPaths = configPaths()): Promise { + const cfg = await readConfig(paths); + if (cfg.install_id) return cfg.install_id; + const install_id = randomUUID(); + await writeConfig({ ...cfg, install_id }, paths); + const settled = await readConfig(paths); + return settled.install_id ?? install_id; +} + +/** The install_id value to stamp on an outbound request, honoring GUSTO_TELEMETRY opt-out. + * Returns undefined (suppresses the header) when telemetry is disabled or when the on-disk + * config can't be read/written — telemetry is best-effort and must never fail the user's command. + * + * Memoized per process when called with the default paths so the file is read at most once + * per invocation regardless of how many outbound requests the command makes. Callers that pass + * an explicit `paths` (tests) bypass the cache and resolve fresh each call. */ +let cachedDefaultInstallId: Promise | undefined; + +export async function resolveInstallIdHeader(paths?: ConfigPaths): Promise { + if (paths !== undefined) return resolveInstallIdOnce(paths); + if (cachedDefaultInstallId === undefined) cachedDefaultInstallId = resolveInstallIdOnce(configPaths()); + return cachedDefaultInstallId; +} + +async function resolveInstallIdOnce(paths: ConfigPaths): Promise { + if (!isTelemetryEnabled()) return undefined; + try { + return await getOrCreateInstallId(paths); + } catch { + return undefined; + } +} + export function validateKey(key: string): ConfigKey | null { return (CONFIG_KEYS as readonly string[]).includes(key) ? (key as ConfigKey) : null; } @@ -113,6 +171,10 @@ function pickValid(raw: Record): UserConfig { ) { out.skills_auto_install = raw.skills_auto_install as SkillsAutoInstall; } + // Drop corrupted values so getOrCreateInstallId regenerates on next call. + if (typeof raw.install_id === "string" && INSTALL_ID_PATTERN.test(raw.install_id)) { + out.install_id = raw.install_id; + } return out; } diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts index 2195f7f..ef8d658 100644 --- a/src/lib/env.test.ts +++ b/src/lib/env.test.ts @@ -4,6 +4,7 @@ import { defaultEnv, getAccessToken, getCompanyUuid, + isTelemetryEnabled, resolveApiVersion, resolveBaseUrl, resolveMcpBaseUrl, @@ -147,3 +148,23 @@ describe("getCompanyUuid", () => { expect(getCompanyUuid(undefined, {})).toBeNull(); }); }); + +describe("isTelemetryEnabled", () => { + test("defaults to true when GUSTO_TELEMETRY is unset", () => { + expect(isTelemetryEnabled({})).toBe(true); + }); + test("stays true on an empty value", () => { + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "" })).toBe(true); + }); + test("returns false for 0 / false / no (case-insensitive)", () => { + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "0" })).toBe(false); + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "false" })).toBe(false); + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "FALSE" })).toBe(false); + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "no" })).toBe(false); + }); + test("stays true for explicitly truthy values", () => { + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "1" })).toBe(true); + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "true" })).toBe(true); + expect(isTelemetryEnabled({ GUSTO_TELEMETRY: "yes" })).toBe(true); + }); +}); diff --git a/src/lib/env.ts b/src/lib/env.ts index fff4c9b..bdc73e0 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -61,6 +61,15 @@ export function isTruthy(value: string | undefined): boolean { return normalized === "1" || normalized === "true" || normalized === "yes"; } +/** Telemetry is opt-out: enabled unless GUSTO_TELEMETRY is set to a recognized falsy value + * (`0`, `false`, or `no`). Anything else — unset, empty, or a truthy value — leaves it on. */ +export function isTelemetryEnabled(source: EnvSource = process.env as EnvSource): boolean { + const value = source.GUSTO_TELEMETRY; + if (!value) return true; + const normalized = value.toLowerCase(); + return !(normalized === "0" || normalized === "false" || normalized === "no"); +} + export function resolveApiVersion(source: EnvSource = process.env as EnvSource): string { return source.GUSTO_API_VERSION ?? DEFAULT_API_VERSION; } diff --git a/src/lib/mcp.ts b/src/lib/mcp.ts index 043c288..b095bd2 100644 --- a/src/lib/mcp.ts +++ b/src/lib/mcp.ts @@ -1,4 +1,5 @@ import { type AuthOpts, buildApiClient, resolveAuthToken } from "./api-context.ts"; +import { resolveInstallIdHeader } from "./config.ts"; import { resolveMcpBaseUrl } from "./env.ts"; import { ExitCode } from "./exit-codes.ts"; import type { GlobalFlags } from "./global-flags.ts"; @@ -40,6 +41,7 @@ export async function callMcpTool( const client = buildApiClient(globals, { baseUrl: resolveMcpBaseUrl(globals.env), token: resolved.token, + installId: await resolveInstallIdHeader(), }); const body = { diff --git a/src/lib/oauth/context.ts b/src/lib/oauth/context.ts index 1732447..2fe2c04 100644 --- a/src/lib/oauth/context.ts +++ b/src/lib/oauth/context.ts @@ -1,10 +1,15 @@ import { ApiClient } from "../api-client.ts"; +import { resolveInstallIdHeader } from "../config.ts"; import { resolveApiVersion, resolveBaseUrl } from "../env.ts"; import type { GlobalFlags } from "../global-flags.ts"; import type { OAuthHttpOptions } from "./endpoints.ts"; -export function oauthHttp(globals: GlobalFlags): OAuthHttpOptions { - return { baseUrl: resolveBaseUrl(globals.env) }; +/** Async because it resolves the anonymous install_id from the on-disk config. */ +export async function oauthHttp(globals: GlobalFlags): Promise { + return { + baseUrl: resolveBaseUrl(globals.env), + installId: await resolveInstallIdHeader(), + }; } /** A single-shot bearer ApiClient for the authed endpoints the oauth flows hit @@ -14,6 +19,7 @@ export function oauthApiClient(http: OAuthHttpOptions, token: string): ApiClient baseUrl: http.baseUrl, token, apiVersion: resolveApiVersion(), + installId: http.installId, fetchImpl: http.fetchImpl, maxRetries: 0, }); diff --git a/src/lib/oauth/endpoints.test.ts b/src/lib/oauth/endpoints.test.ts index b97bd34..acacad0 100644 --- a/src/lib/oauth/endpoints.test.ts +++ b/src/lib/oauth/endpoints.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { OAuthError, expiresAtFrom, postForm, toTokenSet } from "./endpoints.ts"; +import { OAuthError, expiresAtFrom, postForm, postJson, toTokenSet } from "./endpoints.ts"; describe("expiresAtFrom", () => { test("adds expires_in seconds to now", () => { @@ -54,6 +54,47 @@ describe("OAuthError on non-2xx responses", () => { expect(oerr.requestId).toBe("req-token-1"); }); + test("postForm stamps X-Gusto-CLI-Install-Id when configured", async () => { + const captured: { init?: RequestInit } = {}; + const fetchImpl = ((_url: string, init?: RequestInit) => { + captured.init = init; + return Promise.resolve(new Response("{}", { status: 200 })); + }) as unknown as typeof fetch; + await postForm( + { baseUrl: "https://api.test", fetchImpl, installId: "11111111-2222-4333-8444-555555555555" }, + "/v1/mcp/oauth/token", + { grant_type: "authorization_code", code: "c" }, + ); + const headers = captured.init?.headers as Record; + expect(headers["X-Gusto-CLI-Install-Id"]).toBe("11111111-2222-4333-8444-555555555555"); + }); + + test("postJson (DCR) stamps X-Gusto-CLI-Install-Id when configured", async () => { + const captured: { init?: RequestInit } = {}; + const fetchImpl = ((_url: string, init?: RequestInit) => { + captured.init = init; + return Promise.resolve(new Response(JSON.stringify({ client_id: "x", client_secret: "y" }), { status: 200 })); + }) as unknown as typeof fetch; + await postJson( + { baseUrl: "https://api.test", fetchImpl, installId: "11111111-2222-4333-8444-555555555555" }, + "/v1/mcp/oauth/register", + { client_type: "cli" }, + ); + const headers = captured.init?.headers as Record; + expect(headers["X-Gusto-CLI-Install-Id"]).toBe("11111111-2222-4333-8444-555555555555"); + }); + + test("omits X-Gusto-CLI-Install-Id entirely when installId is undefined (opt-out)", async () => { + const captured: { init?: RequestInit } = {}; + const fetchImpl = ((_url: string, init?: RequestInit) => { + captured.init = init; + return Promise.resolve(new Response("{}", { status: 200 })); + }) as unknown as typeof fetch; + await postJson({ baseUrl: "https://api.test", fetchImpl }, "/v1/mcp/oauth/register", {}); + const headers = captured.init?.headers as Record; + expect(headers["X-Gusto-CLI-Install-Id"]).toBeUndefined(); + }); + test("OAuthError.requestId is undefined when the server omits x-request-id", async () => { const fetchImpl = (() => Promise.resolve( diff --git a/src/lib/oauth/endpoints.ts b/src/lib/oauth/endpoints.ts index f449a55..05b5d18 100644 --- a/src/lib/oauth/endpoints.ts +++ b/src/lib/oauth/endpoints.ts @@ -28,6 +28,7 @@ export interface OAuthHttpOptions { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number; + installId?: string; } function joinUrl(baseUrl: string, path: string): string { @@ -58,10 +59,15 @@ async function send(opts: OAuthHttpOptions, path: string, init: RequestInit): Pr return body; } +function withInstallIdHeader(opts: OAuthHttpOptions, headers: Record): Record { + if (opts.installId !== undefined) headers["X-Gusto-CLI-Install-Id"] = opts.installId; + return headers; +} + export function postJson(opts: OAuthHttpOptions, path: string, body: unknown): Promise { return send(opts, path, { method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, + headers: withInstallIdHeader(opts, { "Content-Type": "application/json", Accept: "application/json" }), body: JSON.stringify(body), }); } @@ -72,10 +78,10 @@ export function postForm( form: Record, authHeader?: string, ): Promise { - const headers: Record = { + const headers: Record = withInstallIdHeader(opts, { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", - }; + }); if (authHeader) headers.Authorization = authHeader; return send(opts, path, { method: "POST", headers, body: new URLSearchParams(form).toString() }); }