diff --git a/README.md b/README.md index edc54b0..95896b7 100644 --- a/README.md +++ b/README.md @@ -576,6 +576,17 @@ Reply.io login (`reply auth login`) to actually do anything. | `REPLY_TEAM_ID` | Team/workspace id sent as `X-TEAM-ID` (same as `--team-id`) | | `REPLY_CONFIG_DIR` | Config directory (default `~/.config/reply`; `%APPDATA%\reply` on Windows) | | `REPLY_NO_UPDATE_CHECK` | Set to `1` to never check whether a newer release exists | +| `REPLY_CLIENT` | The program running the CLI, as `/`, put before `reply-cli/` in the User-Agent | + +`REPLY_CLIENT` lets a program that runs the CLI for you say so on every request +to Reply: with `REPLY_CLIENT=jason-harness/0.22.0` the User-Agent is +`jason-harness/0.22.0 reply-cli/0.17.2` instead of `reply-cli/0.17.2`, so calls +made through that program can be told apart from your own. The Jason harness +sets it for each command it runs. The value must be one lower-case name of up to +32 letters, digits or dashes, a `/`, and a version of up to 32 letters, digits, +dots, `+` or `-`; anything else is left out and the User-Agent stays as it is. +It is a label, not an input: it never refuses a command and never prints +anything, and it is read only from the environment of the process. ## Contributing diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 49fba2a..ddf2beb 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -59,8 +59,43 @@ describe('config', ()=>{ }); it('user_agent is -cli/ and identifies the CLI', ()=>{ - expect(user_agent()).toBe(`${APP_NAME}-cli/${cli_version()}`); - expect(user_agent().startsWith('reply-cli/')).toBe(true); + expect(user_agent({})).toBe(`${APP_NAME}-cli/${cli_version()}`); + expect(user_agent({}).startsWith('reply-cli/')).toBe(true); + }); + + it('a well-formed REPLY_CLIENT names the program running the CLI before it, the CLI\'s own token kept last', ()=>{ + const own = `reply-cli/${cli_version()}`; + for (const client of ['jason-harness/0.22.0', 'a/1', 'x9/1.0.0-rc.1+build.7', `${'a'.repeat(32)}/${'9'.repeat(32)}`, 'my-tool/V2']) + { + expect(user_agent({REPLY_CLIENT: client}), client).toBe(`${client} ${own}`); + } + }); + + it('leaves the User-Agent exactly as it is when REPLY_CLIENT is unset or not one product token and its version', ()=>{ + const own = `reply-cli/${cli_version()}`; + const malformed = [ + '', ' ', 'jason harness/0.22.0', 'jason-harness/0.22 0', 'jason-harness', 'jason-harness/', '/0.22.0', + `${'a'.repeat(33)}/1`, `a/${'9'.repeat(33)}`, 'Jason-harness/0.22.0', 'JASON/1', '9jason/1', '-jason/1', + 'jason_harness/1', 'jason/harness/1', 'jason/1 (Windows)', ' jason/1', 'jason/1 ', 'jason/1\n', 'jason/1\r\nX-Evil: 1', + 'jason/1;', 'jasön/1', 'jason/1é', + ]; + expect(user_agent({}), 'unset').toBe(own); + for (const client of malformed) + { + expect(user_agent({REPLY_CLIENT: client}), JSON.stringify(client)).toBe(own); + } + }); + + it('reads REPLY_CLIENT from the process environment by default', ()=>{ + const before = process.env.REPLY_CLIENT; + try { + process.env.REPLY_CLIENT = 'jason-harness/0.22.0'; + expect(user_agent()).toBe(`jason-harness/0.22.0 reply-cli/${cli_version()}`); + delete process.env.REPLY_CLIENT; + expect(user_agent()).toBe(`reply-cli/${cli_version()}`); + } finally { + if (before === undefined) { delete process.env.REPLY_CLIENT; } else { process.env.REPLY_CLIENT = before; } + } }); }); }); diff --git a/src/__tests__/utils/client.test.ts b/src/__tests__/utils/client.test.ts index 7eec4be..f419299 100644 --- a/src/__tests__/utils/client.test.ts +++ b/src/__tests__/utils/client.test.ts @@ -185,6 +185,21 @@ describe('utils/client', ()=>{ expect(init.headers['User-Agent']).toMatch(/^reply-cli\/\d+\.\d+\.\d+/); }); + it('puts the program named by REPLY_CLIENT before the CLI in the User-Agent, and leaves a malformed one out', async()=>{ + mock_fetch.mockImplementation(async()=>json_res({ok: true})); + try { + vi.stubEnv('REPLY_CLIENT', 'jason-harness/0.22.0'); + await get(BASE, 'tok', '/x'); + vi.stubEnv('REPLY_CLIENT', 'jason harness'); + await get(BASE, 'tok', '/x'); + } finally { + vi.unstubAllEnvs(); + } + const agents = mock_fetch.mock.calls.map(([, init])=>init.headers['User-Agent']); + expect(agents[0]).toMatch(/^jason-harness\/0\.22\.0 reply-cli\/\d+\.\d+\.\d+/); + expect(agents[1]).toMatch(/^reply-cli\/\d+\.\d+\.\d+/); + }); + describe('retry policy: reads vs writes', ()=>{ const net_error = (code?: string)=>Object.assign(new TypeError('fetch failed'), code ? {cause: Object.assign(new Error(code), {code})} : {}); diff --git a/src/config.ts b/src/config.ts index cfabc2a..bab1a0d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,9 +44,19 @@ const cli_version = (): string=>{ return version; }; +// One product token and its version, as a program running the CLI names itself. +const CLIENT = /^[a-z][a-z0-9-]{0,31}\/[0-9A-Za-z.+-]{1,32}$/; + // Identifies CLI requests for telemetry (not security). Product token derives // from APP_NAME to keep the single build identity, e.g. 'reply-cli/0.1.0'. -const user_agent = (): string=>`${APP_NAME}-cli/${cli_version()}`; +// _CLIENT puts the program running the CLI before it, e.g. +// 'jason-harness/0.22.0 reply-cli/0.17.2'. It is a label, not an input: a value +// of any other shape is left out, and nothing is refused or printed. +const user_agent = (env: Env = process.env): string=>{ + const own = `${APP_NAME}-cli/${cli_version()}`; + const client = get_env('CLIENT', env); + return client !== undefined && CLIENT.test(client) ? `${client} ${own}` : own; +}; // Default per-user config dir, mirroring gh/aws/az conventions. // linux/mac: $XDG_CONFIG_HOME/ (fallback ~/.config/)