Skip to content

Commit 87a474c

Browse files
feat: add createUsageTracking for shared CLI request headers (#176)
* feat: add createUsageTracking for shared CLI request headers Every Doist CLI should identify itself to the backends the same way, so API logs can tell CLI traffic apart and attribute it to a version, an OS, one CLI run and the command that made the request. todoist-cli has its own copy of this; comms-cli and automations-cli send only part of it. createUsageTracking({ cliName, version }) returns a per-process tracker: - headers() gives User-Agent, doist-platform, doist-version, doist-os, a fresh request-id per call, a session-id fixed for the process and cli-command. - setCommand() takes the Commander command from a preAction hook and labels it by its path below the program (td task add -> task:add), or takes a string label for work outside Commander. - mergeHeaders(init) merges the usage headers into a request's own, winning over a caller header of the same name in any case. request-id is separate from the X-Request-Id the Doist SDKs send. That one is an idempotency key the caller controls and is absent on most requests, so it can't double as a trace ID. * fix: keep commander out of the root entry's types setCommand took a Commander Command, so the emitted usage-tracking.d.ts imported the optional commander peer and a consumer of the root entry without it would fail to type-check. It now takes CommandLike, the name() and parent that labelling reads, which a real Command satisfies. A new root-entry test walks every module src/index.ts reaches and fails if any imports an optional peer, type-only imports included. The session ID is per tracker, not per process, so the README and docs now say so and tell CLIs to create one tracker per process.
1 parent 640b305 commit 87a474c

6 files changed

Lines changed: 403 additions & 8 deletions

File tree

‎CODEBASE.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,14 @@ that needs them (`commander`, `marked`, `marked-terminal-renderer`,
5656
Each subpath is an independent entry point so JSON-only consumers don't pay for
5757
markdown/OAuth transitive installs.
5858

59-
| Subpath | Provides | Optional peers needed |
60-
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
61-
| `.` (root) | `CliError`, config I/O, data/state dirs, JSON/NDJSON + `emitView`, `printEmpty`, spinner, stdin reader, terminal detection, global-args parser, command-token scan | — (chalk/yocto bundled) |
62-
| `@doist/cli-core/auth` | OAuth runtime, the `attach*Command` registrars, providers, keyring `TokenStore`, refresh, the `TokenStore`/`AuthProvider` contracts | `commander`, `oauth4webapi`, `open`, `@napi-rs/keyring` |
63-
| `@doist/cli-core/commands` | `registerChangelogCommand`, `registerUpdateCommand` + semver helpers | `commander` |
64-
| `@doist/cli-core/extensions` | `createExtensionManager`, `registerExtension*` registrars, `createExtension` scaffolding, doctor helpers | `commander`, `zod` |
65-
| `@doist/cli-core/markdown` | `preloadMarkdown`, `renderMarkdown` | `marked`, `marked-terminal-renderer` |
66-
| `@doist/cli-core/testing` | `describeEmptyMachineOutput`, `createTestProgram`, console captures, `mockStdin`, account fixtures, extension fixtures | `vitest`, `commander` |
59+
| Subpath | Provides | Optional peers needed |
60+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
61+
| `.` (root) | `CliError`, config I/O, data/state dirs, JSON/NDJSON + `emitView`, `printEmpty`, spinner, stdin reader, terminal detection, global-args parser, command-token scan, usage-tracking headers | — (chalk/yocto bundled) |
62+
| `@doist/cli-core/auth` | OAuth runtime, the `attach*Command` registrars, providers, keyring `TokenStore`, refresh, the `TokenStore`/`AuthProvider` contracts | `commander`, `oauth4webapi`, `open`, `@napi-rs/keyring` |
63+
| `@doist/cli-core/commands` | `registerChangelogCommand`, `registerUpdateCommand` + semver helpers | `commander` |
64+
| `@doist/cli-core/extensions` | `createExtensionManager`, `registerExtension*` registrars, `createExtension` scaffolding, doctor helpers | `commander`, `zod` |
65+
| `@doist/cli-core/markdown` | `preloadMarkdown`, `renderMarkdown` | `marked`, `marked-terminal-renderer` |
66+
| `@doist/cli-core/testing` | `describeEmptyMachineOutput`, `createTestProgram`, console captures, `mockStdin`, account fixtures, extension fixtures | `vitest`, `commander` |
6767

6868
The public surface = every re-export through these entry barrels. `tsc --noEmit`
6969
validates the re-exports; there is no separate runtime pinning test (per AGENTS.md).
@@ -84,6 +84,7 @@ src/
8484
├─ spinner.ts # createSpinner factory (yocto-spinner wrapper)
8585
├─ stdin.ts # readStdin (piped input for --stdin; STDIN_IS_TTY on a terminal)
8686
├─ terminal.ts # isStdoutTTY / isStdinTTY / isStderrTTY / isCI
87+
├─ usage-tracking.ts # createUsageTracking (CLI attribution headers for API requests)
8788
├─ markdown.ts # ./markdown subpath (lazy marked + terminal renderer)
8889
├─ testing/ # ./testing subpath — shipped helpers for consumers' tests
8990
│ ├─ accounts.ts # TestAccount fixtures (Ingen identities) + buildTokenStore / buildSingleEntryStore

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ npm install @doist/cli-core
3131
| `stdin` | `readStdin`, `StdinErrorCode` | Reads piped stdin to the end for a `--stdin` flag; throws `STDIN_IS_TTY` on an interactive terminal instead of waiting for EOF. |
3232
| `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. |
3333
| `testing` (subpath) | `describeEmptyMachineOutput`, `createTestProgram`, `captureConsole`, `captureStream`, `mockStdin`, `writeFixtureExtension`, `writeFakeGitRepo`, `buildTokenStore`, `buildSingleEntryStore`, `ingenEntries`, `alanGrant` / `ellieSattler` / `ianMalcolm`, `TestAccount` / `StoreEntry` / `TokenStoreHarness` / `MatchAccount` / `MockStdinOptions` types | Vitest helpers + fixtures reusable by consuming CLIs: a parametrised empty-state suite (`--json` / `--ndjson` / human modes); a Commander test-program builder (`createTestProgram`; the whole subpath **requires** `commander` since the barrel re-exports it); console / stdout-stderr spies that silence + auto-restore (`captureConsole` / `captureStream`, call inside a test or `beforeEach`); `mockStdin`, which swaps `process.stdin` for a pipe (or a terminal with `isTTY: true`) and restores it after the test; and a canonical stateful in-memory `TokenStore` mock plus shared account fixtures (`buildTokenStore` / `buildSingleEntryStore`) modelling `createKeyringTokenStore`'s default-selection contract — pass `matchAccount` to mirror a consumer's own ref-matching (numeric-id / case-insensitive label); and `writeFixtureExtension` / `writeFakeGitRepo`, which put a runnable extension on disk so the `extensions` subpath can be exercised through a real process. |
34+
| `usage-tracking` | `createUsageTracking`, `UsageTracking`, `UsageTrackingOptions`, `UsageHeadersOptions`, `CommandLike` | The request headers every Doist CLI sends so backends can attribute API traffic: `User-Agent: <cli>/<version>`, `doist-platform: cli`, `doist-version`, `doist-os` (`macos`/`linux`/`windows`/`unknown`), a per-request `request-id` UUID, a `session-id` UUID per tracker (create one tracker per process so it groups one CLI run), and `cli-command` (the active command as a low-cardinality label such as `task:add`). Call `setCommand(actionCommand)` from a `preAction` hook (it takes any `CommandLike`, so the root entry needs no `commander`), then pass `headers()` to the SDK client or `mergeHeaders(init)` into a raw `fetch`. `request-id` is separate from the SDKs' `X-Request-Id`, which is an idempotency key sent only on some writes. |
3435

3536
## Usage
3637

‎src/index.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { readFileSync } from 'node:fs'
2+
import { dirname, join, relative, resolve } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
5+
import { describe, expect, it } from 'vitest'
6+
7+
const SRC = dirname(fileURLToPath(import.meta.url))
8+
9+
const packageJson = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8')) as {
10+
peerDependenciesMeta?: Record<string, { optional?: boolean }>
11+
}
12+
const optionalPeers = Object.entries(packageJson.peerDependenciesMeta ?? {})
13+
.filter(([, meta]) => meta.optional)
14+
.map(([name]) => name)
15+
16+
/** Every source file the root entry reaches through relative imports, with its text. */
17+
function rootEntrySources(): Map<string, string> {
18+
const sources = new Map<string, string>()
19+
const queue = [join(SRC, 'index.ts')]
20+
for (let file = queue.pop(); file !== undefined; file = queue.pop()) {
21+
if (sources.has(file)) continue
22+
const source = readFileSync(file, 'utf8')
23+
sources.set(file, source)
24+
for (const [, specifier] of source.matchAll(/from\s+'(\.[^']+)'/g)) {
25+
queue.push(resolve(dirname(file), specifier.replace(/\.js$/, '.ts')))
26+
}
27+
}
28+
return sources
29+
}
30+
31+
describe('root entry', () => {
32+
// The root entry promises to work without any optional peer installed. A
33+
// type-only import still lands in the emitted `.d.ts`, so a consumer
34+
// without the peer gets `Cannot find module` from the type checker.
35+
it('imports no optional peer, not even for types', () => {
36+
const offenders: string[] = []
37+
for (const [file, source] of rootEntrySources()) {
38+
for (const [, specifier] of source.matchAll(/from\s+'([^.'][^']*)'/g)) {
39+
const peer = optionalPeers.find(
40+
(name) => specifier === name || specifier.startsWith(`${name}/`),
41+
)
42+
if (peer) offenders.push(`${relative(SRC, file)} imports ${specifier}`)
43+
}
44+
}
45+
46+
expect(optionalPeers).toContain('commander')
47+
expect(offenders).toEqual([])
48+
})
49+
})

‎src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,10 @@ export type {
5151
export { readStdin } from './stdin.js'
5252
export type { StdinErrorCode } from './stdin.js'
5353
export { isCI, isStderrTTY, isStdinTTY, isStdoutTTY } from './terminal.js'
54+
export { createUsageTracking } from './usage-tracking.js'
55+
export type {
56+
CommandLike,
57+
UsageHeadersOptions,
58+
UsageTracking,
59+
UsageTrackingOptions,
60+
} from './usage-tracking.js'

0 commit comments

Comments
 (0)