Skip to content
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ Environment resolution, highest precedence first: `--env` > `GUSTO_ENVIRONMENT`

Each environment keeps its **own** credential slot, both in one `credentials.toml` under your config directory. Signing in to sandbox leaves your production session untouched and vice versa, and `gusto auth logout` only clears the environment you name. `gusto auth whoami` reports the active `environment` alongside the credential source, so you can always ask the CLI which one it is talking to.

### Usage metadata

API requests include an anonymous per-install UUID (`X-Gusto-CLI-Install-Id`) and the command that
made the request (`X-Gusto-CLI-Command`, such as `employee-list`). This covers regular API calls and
OAuth registration, token exchange, token refresh, and token inspection requests. To omit both
headers for an invocation, set `GUSTO_TELEMETRY=0` (the values `false` and `no` also opt out):

```sh
GUSTO_TELEMETRY=0 gusto employee list
```

### When auth fails

Auth failures exit `3` and name the environment (`error.environment`). Stored-session failures also name the credential slot they read:
Expand Down
28 changes: 28 additions & 0 deletions src/lib/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,34 @@ describe("ApiClient basics", () => {
}
});

test("sets X-Gusto-CLI-Command when a command slug is configured", async () => {
const captured: { url?: string; init?: RequestInit } = {};
const client = makeClient(mockFetch(captured, { status: 200, body: {} }), { command: "employee-list" });
await client.get("/v1/things");
const headers = captured.init?.headers as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBe("employee-list");
});

test("omits X-Gusto-CLI-Command when no command is configured", async () => {
const captured: { url?: string; init?: RequestInit } = {};
const client = makeClient(mockFetch(captured, { status: 200, body: {} }));
await client.get("/v1/things");
const headers = captured.init?.headers as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBeUndefined();
});

// The header rides on every attempt (a retried GET, a failed request), not just the first read,
// so server-side breakdowns attribute all of a command's traffic - including a 404'd api request.
test("the command header rides even on a 404 response", async () => {
const captured: { url?: string; init?: RequestInit } = {};
const client = makeClient(mockFetch(captured, { status: 404, body: { error: "nope" } }), {
command: "api-request",
});
await expect(client.get("/v1/missing")).rejects.toBeInstanceOf(ApiError);
const headers = captured.init?.headers as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBe("api-request");
});

test("same-origin absolute URLs are passed through", async () => {
const captured: { url?: string; init?: RequestInit } = {};
const client = makeClient(mockFetch(captured, { status: 200, body: {} }));
Expand Down
7 changes: 7 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export interface ApiClientOptions {
/** Optional per-request observer; called once per attempt on success and failure. When set,
* powers `--verbose` stderr logging. */
observer?: RequestObserver;
/** Slug of the CLI command driving these requests (e.g. `employee-list`). When set, sent as the
* `X-Gusto-CLI-Command` header on every request so server-side observability can break CLI
* traffic down by command. Omitted when command context is unavailable or telemetry is disabled. */
command?: string;
/** The credential this client authenticates with, stamped onto any `ApiError` it throws so a 401
* can name what was refused. Omitted by clients built without a resolved context. */
auth?: AuthContext;
Expand Down Expand Up @@ -181,6 +185,7 @@ export class ApiClient {
private readonly maxRetries: number;
private readonly retrySleepMs: (attempt: number) => number;
private readonly observer?: RequestObserver;
private readonly command?: string;
private readonly auth?: AuthContext;

constructor(opts: ApiClientOptions) {
Expand All @@ -194,6 +199,7 @@ export class ApiClient {
// Exponential backoff: 1s, 2s, 4s, 8s. Tests override to skip waits.
this.retrySleepMs = opts.retrySleepMs ?? ((attempt) => 2 ** attempt * 1000);
this.observer = opts.observer;
this.command = opts.command;
this.auth = opts.auth;
}

Expand Down Expand Up @@ -388,6 +394,7 @@ export class ApiClient {
"X-Gusto-API-Version": this.apiVersion,
"User-Agent": USER_AGENT,
};
if (this.command) headers["X-Gusto-CLI-Command"] = this.command;
if (this.installId !== undefined) {
headers["X-Gusto-CLI-Install-Id"] = this.installId;
}
Expand Down
105 changes: 103 additions & 2 deletions src/lib/api-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
writeResource,
} from "./api-context.ts";
import { ExitCode } from "./exit-codes.ts";
import type { GlobalFlags } from "./global-flags.ts";
import { commandSlug, type GlobalFlags } from "./global-flags.ts";
import { oauthHttp } from "./oauth/context.ts";
import { OAuthError } from "./oauth/endpoints.ts";
import { memoryStore, mockHttp } from "./oauth/test-support.ts";
import type { TokenStore } from "./oauth/token-store.ts";
Expand Down Expand Up @@ -61,7 +62,13 @@ const throwingStore = (err: unknown): TokenStore => ({

// resolveApiContext reads token/company/base-url from process.env when no override is passed.
// Snapshot and clear the relevant vars so tests don't depend on the dev's shell.
const ENV_KEYS = ["GUSTO_ACCESS_TOKEN", "GUSTO_COMPANY_UUID", "GUSTO_API_BASE_URL", "GUSTO_API_VERSION"];
const ENV_KEYS = [
"GUSTO_ACCESS_TOKEN",
"GUSTO_COMPANY_UUID",
"GUSTO_API_BASE_URL",
"GUSTO_API_VERSION",
"GUSTO_TELEMETRY",
];
let saved: Record<string, string | undefined>;

beforeEach(() => {
Expand Down Expand Up @@ -276,6 +283,32 @@ describe("resolveApiContext - stored session fallback", () => {
expect(result.ok).toBe(true);
});

test("a REST command stamps its command slug on the session refresh request", async () => {
const fetchStub = stubGlobalFetch([
{
status: 200,
body: { access_token: "fresh-tok", refresh_token: "fresh-refresh", expires_in: 3600 },
},
]);
try {
const result = await resolveApiContext(
{ ...flags, command: "gusto employee list" },
{
requireCompany: false,
store: memoryStore({ production: { ...expiredSlot(), expiresAt: 20_000 } }),
now: () => 10_000,
},
);

expect(result.ok).toBe(true);
const refresh = fetchStub.calls.find((call) => call.url.endsWith("/v1/mcp/oauth/token"));
expect(new URLSearchParams(String(refresh?.body)).get("grant_type")).toBe("refresh_token");
expect((refresh?.headers as Record<string, string> | undefined)?.["X-Gusto-CLI-Command"]).toBe("employee-list");
} finally {
fetchStub.restore();
}
});

test("a rejected token refresh is token_refresh_failed, not no_access_token", async () => {
// A refresh token is on file and only the *refresh* failed, so this must not report absence:
// "no access token, run auth login" reads as "nothing here", when what is here decides the
Expand Down Expand Up @@ -1208,3 +1241,71 @@ describe("buildApiClient --verbose wiring", () => {
expect(chunks).toEqual([]);
});
});

describe("commandSlug", () => {
test("strips the leading `gusto ` and hyphenates the rest", () => {
expect(commandSlug("gusto api request")).toBe("api-request");
expect(commandSlug("gusto employee list")).toBe("employee-list");
});

test("lowercases and collapses runs of whitespace to a single hyphen", () => {
expect(commandSlug("gusto Employee List")).toBe("employee-list");
});

test("a single-word command with no `gusto ` prefix passes through", () => {
expect(commandSlug("upgrade")).toBe("upgrade");
});
});

describe("buildApiClient command header wiring", () => {
async function callAndCaptureHeaders(globals: GlobalFlags): Promise<RequestInit["headers"]> {
const stub = stubGlobalFetch([{ status: 200, body: { ok: true } }]);
try {
const client = buildApiClient(globals, { baseUrl: "https://api.example.test", token: "t" });
await client.get("/v1/me");
return stub.calls[0]?.headers;
} finally {
stub.restore();
}
}

test("a structured command carries its slug as X-Gusto-CLI-Command", async () => {
const headers = (await callAndCaptureHeaders({ ...flags, command: "gusto employee list" })) as Record<
string,
string
>;
expect(headers["X-Gusto-CLI-Command"]).toBe("employee-list");
});

test("the `gusto api request` escape hatch carries its own slug", async () => {
const headers = (await callAndCaptureHeaders({ ...flags, command: "gusto api request" })) as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBe("api-request");
});

test("no command on globals sends no X-Gusto-CLI-Command header", async () => {
const headers = (await callAndCaptureHeaders(flags)) as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBeUndefined();
});

test("GUSTO_TELEMETRY=0 suppresses X-Gusto-CLI-Command", async () => {
process.env.GUSTO_TELEMETRY = "0";
const headers = (await callAndCaptureHeaders({ ...flags, command: "gusto employee list" })) as Record<
string,
string
>;
expect(headers["X-Gusto-CLI-Command"]).toBeUndefined();
});
});

describe("oauthHttp command header wiring", () => {
test("carries the auth login command slug into the OAuth token_info client", async () => {
const http = await oauthHttp({ ...flags, command: "gusto auth login" });
expect(http.command).toBe("auth-login");
});

test("GUSTO_TELEMETRY=0 suppresses the OAuth token_info command header", async () => {
process.env.GUSTO_TELEMETRY = "0";
const http = await oauthHttp({ ...flags, command: "gusto auth login" });
expect(http.command).toBeUndefined();
});
});
3 changes: 2 additions & 1 deletion src/lib/api-context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiClient, type AuthContext, type ResolvedTokenSource, stderrRequestObserver } from "./api-client.ts";
import { resolveInstallIdHeader } from "./config.ts";
import { resolveCommandHeader, resolveInstallIdHeader } from "./config.ts";
import { confirmationGate } from "./confirm.ts";
import {
type CompanySource,
Expand Down Expand Up @@ -90,6 +90,7 @@ export function buildApiClient(
apiVersion: resolveApiVersion(),
installId: opts.installId,
observer: globals.verbose ? stderrRequestObserver(opts.stderr ?? process.stderr) : undefined,
command: resolveCommandHeader(globals),
auth: opts.auth,
});
}
Expand Down
15 changes: 15 additions & 0 deletions src/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
normalizeValue,
readConfig,
resetConfig,
resolveCommandHeader,
resolveInstallIdHeader,
validateKey,
validateValue,
Expand Down Expand Up @@ -268,3 +269,17 @@ describe("resolveInstallIdHeader", () => {
expect(await resolveInstallIdHeader(broken)).toBeUndefined();
});
});

describe("resolveCommandHeader", () => {
test("returns the command slug when telemetry is enabled", () => {
expect(resolveCommandHeader({ command: "gusto employee list" }, {})).toBe("employee-list");
});

test("returns undefined when GUSTO_TELEMETRY opts out", () => {
expect(resolveCommandHeader({ command: "gusto employee list" }, { GUSTO_TELEMETRY: "0" })).toBeUndefined();
});

test("returns undefined when no command context is available", () => {
expect(resolveCommandHeader({}, {})).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from "node:path";
import { parse, stringify } from "smol-toml";
import { isTelemetryEnabled } from "./env.ts";
import type { EnvSource } from "./env.ts";
import type { Environment } from "./global-flags.ts";
import { commandSlug, type Environment, type GlobalFlags } from "./global-flags.ts";
import type { OutputMode } from "./output.ts";

export type ConfigKey = "environment" | "format" | "skills_auto_install" | "auto_update";
Expand Down Expand Up @@ -155,6 +155,15 @@ async function resolveInstallIdOnce(paths: ConfigPaths): Promise<string | undefi
}
}

/** The command slug to stamp on outbound requests, honoring the same GUSTO_TELEMETRY opt-out as
* the anonymous install ID. Centralized here so every API client construction path shares the gate. */
export function resolveCommandHeader(
globals: Pick<GlobalFlags, "command">,
env: EnvSource = process.env as EnvSource,
): string | undefined {
return isTelemetryEnabled(env) && globals.command ? commandSlug(globals.command) : undefined;
}

export function validateKey(key: string): ConfigKey | null {
return (CONFIG_KEYS as readonly string[]).includes(key) ? (key as ConfigKey) : null;
}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/global-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ export interface GlobalFlags {
verbose: boolean;
env?: Environment;
fields?: FieldSelection;
/** The full command path being run (e.g. `"gusto employee list"`). Set by the runner from the
* dispatched command, not parsed from CLI options, so it's absent until the runner injects it.
* Threaded through to the API client as the per-command `X-Gusto-CLI-Command` request header. */
command?: string;
}

/** Turn a full command path into the compact slug sent as `X-Gusto-CLI-Command`: strip a leading
* `"gusto "`, trim, lowercase, and collapse internal whitespace runs to a single `-`. */
export function commandSlug(command: string): string {
return command
.replace(/^gusto /, "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-");
}

/** Resolve commander's `--fields [list]` value into a FieldSelection.
Expand Down
23 changes: 23 additions & 0 deletions src/lib/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,29 @@ describe("callMcpTool — env routing + envelope", () => {
});
});

describe("callMcpTool — command header", () => {
// `feedback` (and any callMcpTool command) rides the same buildApiClient path as the REST
// surfaces, so its request carries X-Gusto-CLI-Command too. That's intended: the header
// attributes each API request to the command that made it, and `feedback` is a real command.
async function captureHeaders(globals: GlobalFlags): Promise<RequestInit["headers"]> {
const stub = stubGlobalFetch(() => ({ status: 200, body: successEnvelope({ source: "none" }) }));
try {
const result = await callMcpTool({ ...globals, command: "gusto feedback" }, stdinAuth(), "submit_feedback", {
message: "hello",
});
expect(result.ok).toBe(true);
return stub.calls[0]?.headers;
} finally {
stub.restore();
}
}

test("the feedback (MCP) path carries X-Gusto-CLI-Command with the command slug", async () => {
const headers = (await captureHeaders(sandbox)) as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBe("feedback");
});
});

describe("callMcpTool — success unwrap", () => {
test("unwraps result.content[0].text and returns the parsed JSON as data", async () => {
const payload = { source: "third_party", timesheets: [{ id: "ts-1" }] };
Expand Down
4 changes: 3 additions & 1 deletion src/lib/oauth/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiClient } from "../api-client.ts";
import { resolveInstallIdHeader } from "../config.ts";
import { resolveCommandHeader, resolveInstallIdHeader } from "../config.ts";
import { resolveApiVersion, resolveBaseUrl } from "../env.ts";
import type { Environment, GlobalFlags } from "../global-flags.ts";
import type { OAuthHttpOptions } from "./endpoints.ts";
Expand All @@ -9,6 +9,7 @@ export async function oauthHttp(globals: GlobalFlags): Promise<OAuthHttpOptions>
return {
baseUrl: resolveBaseUrl(globals.env),
installId: await resolveInstallIdHeader(),
command: resolveCommandHeader(globals),
};
}

Expand All @@ -26,6 +27,7 @@ export function oauthApiClient(http: OAuthHttpOptions, token: string, environmen
token,
apiVersion: resolveApiVersion(),
installId: http.installId,
command: http.command,
fetchImpl: http.fetchImpl,
maxRetries: 0,
auth: { tokenSource: "login", environment },
Expand Down
28 changes: 28 additions & 0 deletions src/lib/oauth/endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,34 @@ describe("install ID on OAuth requests", () => {
});
});

describe("command on OAuth requests", () => {
test("postForm stamps X-Gusto-CLI-Command when configured", async () => {
Comment thread
jeff-gusto marked this conversation as resolved.
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, command: "employee-list" }, OAUTH_PATHS.token, {
grant_type: "refresh_token",
});
const headers = captured.init?.headers as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBe("employee-list");
});

test("postJson stamps X-Gusto-CLI-Command 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 postJson({ baseUrl: "https://api.test", fetchImpl, command: "auth-login" }, OAUTH_PATHS.register, {
client_type: "cli",
});
const headers = captured.init?.headers as Record<string, string>;
expect(headers["X-Gusto-CLI-Command"]).toBe("auth-login");
});
});

describe("User-Agent on OAuth requests", () => {
function capturingFetch(captured: { init?: RequestInit }): typeof fetch {
return ((_url: string | URL | Request, init?: RequestInit) => {
Expand Down
Loading
Loading