Skip to content

Commit 1f0221d

Browse files
riddim-developer-bot[bot]riddim-developer-bot
andauthored
[GRV-32]: Add Linear auth doctor and GraphQL workspace adapter (#49)
## Summary Wires the GRV-31 scaffold's `LinearWorkspace` port to a real Linear GraphQL HTTP adapter and turns `human-handoff-linear doctor` into a read-only auth check that fetches the current viewer/workspace. The adapter exposes the full surface later mutating commands (label install, template sync, project bootstrap) need, so no future issue has to re-implement GraphQL transport or auth handling. - `createLinearGraphqlWorkspace({ apiKey, fetch })` implements `getViewer`, `listTeams`, `listLabels`, `createLabel`, `getTemplate`, `createTemplate`, `updateTemplate`, `createIssue`, `createRelation`. Maps HTTP 401/403/429 and GraphQL `extensions.code` (`AUTHENTICATION_ERROR`, `FORBIDDEN`, `RATELIMITED`) to typed `LinearAuthError` / `LinearPermissionError` / `LinearRateLimitError` / `LinearNetworkError` / `LinearApiError`. - `createInteractiveSecretReader` extends the env reader with an opt-in hidden TTY prompt for the API key. `--no-prompt` disables the fallback for CI. - `doctor` use case resolves the token through the SecretReader port and, when present, calls only `getViewer` through the LinearWorkspace port. It never invokes mutating methods (proven by `tests/use-cases.test.mjs` "doctor: never invokes mutating workspace methods"). - CLI exit codes: 2 missing_token, 3 auth, 4 permission, 5 rate_limit, 6 network, 7 api, 1 unknown. ## Acceptance Criteria Review - [x] CLI reads `LINEAR_API_KEY` from env, supports hidden interactive prompt fallback (documented in README + `--help`), `--no-prompt` disables prompt for CI. - [x] `doctor` validates the token by calling `getViewer` through the LinearWorkspace port; reports success without creating or updating templates, labels, issues, or relations. - [x] `LinearWorkspace` adapter implements all nine port methods (viewer, teams, labels list/create, template get/create/update, issue create, relation create). - [x] GraphQL errors, missing permissions, missing token, and rate-limit failures produce actionable CLI output and distinct non-zero exit codes. - [x] Unit tests cover the adapter through an injected `fetch` double and the use case through a stub workspace — no test requires a real Linear API token. ## Out of Scope (per issue) - Creating/updating the Human Handoff template. (Adapter exposes `createTemplate`/`updateTemplate` but the scaffold's `sync-template` command remains a no-op; `syncHumanHandoffTemplate` raises an actionable not-yet-implemented error.) - Creating labels. (Adapter exposes `createLabel`; no command invokes it yet.) - Creating project HH issues. (Adapter exposes `createIssue`/`createRelation`; `bootstrap-project` command remains a no-op; `bootstrapHumanHandoffProject` raises an actionable not-yet-implemented error.) ## Clean Architecture Shape - **Use case:** `ValidateLinearSetupAuth` (new) — implemented as `src/use-cases/doctor.mjs`. Cataloged in `docs/architecture/use-case-catalog.md`. - **Ports:** `LinearWorkspace`, `SecretReader`, `ConsoleReporter` (extended `ports.mjs` with full `LinearWorkspace` surface). - **Adapters:** `createLinearGraphqlWorkspace` (GraphQL HTTP), `createInteractiveSecretReader` (env + hidden TTY prompt), `createStreamConsoleReporter` (existing). - **Boundary rule:** `tests/boundary.test.mjs` continues to enforce — no `process.env`, `node:child_process`, direct `fetch(`, or `node:process` imports in `src/use-cases/`. ## Verification - `npm test` — all 343 tests pass across `human-handoff-linear` (64), `linear-agent-hooks` (35), `llm-cost-attribution` (203), `llm-cost-estimation` (37), and root (2). `llm-cost-attribution`'s `test:boundary` runs and passes. - Smoke tests via the binary: - `LINEAR_API_KEY=fake_test node packages/human-handoff-linear/bin/human-handoff-linear.mjs doctor --no-prompt` → exits 3 (auth) with `[auth] Linear API rejected the API key (HTTP 401)`. - `env -u LINEAR_API_KEY node packages/human-handoff-linear/bin/human-handoff-linear.mjs doctor --no-prompt` → exits 2 (missing_token) with `LINEAR_API_KEY is not set`. - `human-handoff-linear --help` documents the `--no-prompt` flag and the new auth section. Skipped checks: none. ## Test Plan - [x] `node --test packages/human-handoff-linear/tests/*.test.mjs` (64 tests) - [x] `npm test` from repo root (all workspaces) - [x] CLI smoke tests for help / missing-token / bad-token / unknown command Co-authored-by: riddim-developer-bot <developer-bot@riddimsoftware.com>
1 parent d62a3b4 commit 1f0221d

15 files changed

Lines changed: 1554 additions & 71 deletions

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ Claude Code and Codex hooks that automatically post a provenance comment back to
2222
npx linear-agent-hooks setup
2323
```
2424

25+
### [`human-handoff-linear`](packages/human-handoff-linear)
26+
27+
Linear-backed workflow primitives for the human-handoff pattern — one project-level Human Handoff issue that aggregates every step a human still has to do in an otherwise autonomous flow.
28+
29+
Validate your Linear API key without touching any of your data:
30+
31+
```bash
32+
LINEAR_API_KEY=lin_api_… npx human-handoff-linear doctor
33+
```
34+
2535
### [`llm-cost-attribution`](packages/llm-cost-attribution)
2636

2737
Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions. Reads the CLIs' own session JSONLs — no custom telemetry pipeline required.

docs/architecture/use-case-catalog.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,17 @@ Inputs: setup command selection, optional Linear team selector, Human Handoff te
149149
Outputs: command contract metadata, no-op setup/sync/doctor/bootstrap results, mutation count, token-readiness signal, validated template body.
150150
Entities / values: SetupCommand, LinearTeamSelector, HumanHandoffTemplateBody.
151151
Ports: LinearWorkspace, ConsoleReporter, SecretReader.
152-
Primary adapters: CLI command parser, environment-backed SecretReader, stream ConsoleReporter, no-op LinearWorkspace adapter.
153-
Notes: foundation scaffold only. Core use-case modules import no process environment, child_process, direct fetch, or Linear API details; real GraphQL mutations belong in a future LinearWorkspace adapter.
152+
Primary adapters: CLI command parser, environment-backed SecretReader, stream ConsoleReporter, no-op LinearWorkspace adapter, real GraphQL LinearWorkspace adapter (`createLinearGraphqlWorkspace`).
153+
Notes: foundation scaffold for setup/sync-template/bootstrap-project; `doctor` is no longer no-op (see ValidateLinearSetupAuth). Core use-case modules import no process environment, child_process, direct fetch, or Linear API details — enforced by `tests/boundary.test.mjs`.
154154
Current implementation: `packages/human-handoff-linear/src/use-cases/define-human-handoff-linear-package-contract.mjs`, `packages/human-handoff-linear/src/use-cases/*.mjs`
155+
156+
### ValidateLinearSetupAuth
157+
Actor: Workflow operator
158+
Goal: Validate the Linear API token by fetching the current viewer and workspace through the LinearWorkspace port, so `doctor` can confirm the token works before any mutating command attempts to use it. Read-only: never creates or updates templates, labels, issues, or relations.
159+
Inputs: SecretReader (resolves `LINEAR_API_KEY`), LinearWorkspace (`getViewer`), `tokenRequired` flag (default true).
160+
Outputs: DoctorResult `{ command, tokenPresent, ok, viewer, checks }` where each check carries `{ name, ok, required, error?, details? }`. The CLI maps a failing check's `error.kind` to a stable exit code (missing_token=2, auth=3, permission=4, rate_limit=5, network=6, api=7).
161+
Entities / values: LinearViewer, LinearOrganization, DoctorCheck.
162+
Ports: SecretReader, LinearWorkspace (only `getViewer` is called).
163+
Primary adapters: `createInteractiveSecretReader` (env + hidden TTY prompt), `createLinearGraphqlWorkspace` (HTTPS GraphQL with injected `fetch` for tests), `createStreamConsoleReporter`.
164+
Notes: core use case imports no fetch/env/child_process (enforced by `tests/boundary.test.mjs`). Tests cover GraphQL HTTP errors (401/403/429), GraphQL `errors[]` shapes, network failure, and the missing-token path through injected fetch and stub workspaces — no real Linear API token is required.
165+
Current implementation: `packages/human-handoff-linear/src/use-cases/doctor.mjs`

packages/human-handoff-linear/README.md

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
# human-handoff-linear
22

3-
Linear workflow primitives for installing and maintaining the Human Handoff issue
4-
template used by autonomous project workflows.
3+
Linear workflow primitives for installing and maintaining the Human Handoff
4+
issue template used by autonomous project workflows.
55

6-
This package is intentionally a contract shell. It exposes the CLI and importable
7-
use-case boundaries that future work will wire to real Linear GraphQL mutations.
8-
Current commands are dry-run/no-op unless stated otherwise.
6+
This package is still a contract shell — most commands (`setup`,
7+
`sync-template`, `bootstrap-project`) are dry-run/no-op scaffolds awaiting
8+
later issues. The `doctor` command, however, performs a real Linear auth check
9+
through the GraphQL adapter, and the underlying `LinearWorkspace` adapter
10+
implements the full surface those later commands will use.
911

1012
## Requirements
1113

1214
- Node.js 20+
13-
- A Linear API token for future mutation-backed commands. The current scaffold
14-
does not require a token for help, routing, or no-op checks.
15+
- A Linear personal API key. Create one at
16+
<https://linear.app/settings/api>.
17+
18+
## Auth
19+
20+
The CLI reads the API key from the `LINEAR_API_KEY` environment variable.
21+
22+
```bash
23+
export LINEAR_API_KEY=lin_api_…
24+
```
25+
26+
If the variable is unset and you run from an interactive terminal, the CLI
27+
prompts for the key without echoing it. Pass `--no-prompt` to disable that
28+
fallback (use in CI, where there is no TTY anyway). The key is never logged,
29+
written to disk, or echoed back.
1530

1631
## CLI
1732

@@ -21,16 +36,33 @@ npx human-handoff-linear --help
2136

2237
Subcommands:
2338

24-
- `setup` - validate the package contract and report the future Linear template
25-
setup plan. No Linear mutations are performed in this scaffold.
26-
- `sync-template` - report the checked-in Human Handoff issue body template that
27-
future work will sync into Linear.
28-
- `doctor` - check local CLI readiness without contacting Linear.
29-
- `bootstrap-project` - reserve the project bootstrap command surface for later
30-
implementation.
39+
- `doctor` — validate the Linear API token by fetching the current viewer and
40+
workspace. Read-only: never creates or updates templates, labels, issues, or
41+
relations.
42+
- `setup`, `sync-template`, `bootstrap-project` — scaffold-only today; reserved
43+
command surfaces that later issues will wire to real Linear mutations.
3144

32-
All commands are safe for non-interactive use. Unknown commands fail with a
33-
terse message and do not require a Linear token.
45+
### `doctor`
46+
47+
```text
48+
$ human-handoff-linear doctor
49+
human-handoff-linear doctor - validating Linear auth (read-only).
50+
Linear token: present.
51+
Authenticated as Ada Lovelace in workspace Riddim (riddim).
52+
human-handoff-linear doctor complete - no mutations performed
53+
```
54+
55+
Failure cases map to stable exit codes for scripting:
56+
57+
| Condition | Stderr prefix | Exit |
58+
|---|---|---|
59+
| `LINEAR_API_KEY` unset and `--no-prompt` (or non-TTY) | `LINEAR_API_KEY is not set` | 2 |
60+
| HTTP 401 / GraphQL `AUTHENTICATION_ERROR` | `[auth]` | 3 |
61+
| HTTP 403 / GraphQL `FORBIDDEN` | `[permission]` | 4 |
62+
| HTTP 429 / GraphQL `RATELIMITED` | `[rate_limit]` | 5 |
63+
| Network/transport failure | `[network]` | 6 |
64+
| Other GraphQL or HTTP error | `[api]` | 7 |
65+
| Other / unknown | (no prefix) | 1 |
3466

3567
## Application API
3668

@@ -41,20 +73,36 @@ modules with injected ports:
4173
import {
4274
createBootstrapProjectUseCase,
4375
createDoctorUseCase,
76+
createLinearGraphqlWorkspace,
4477
createSetupUseCase,
4578
createSyncTemplateUseCase,
4679
} from 'human-handoff-linear';
80+
81+
const workspace = createLinearGraphqlWorkspace({ apiKey: process.env.LINEAR_API_KEY });
82+
const doctor = createDoctorUseCase({
83+
reporter: { info: console.log, error: console.error },
84+
secretReader: { read: (name) => process.env[name] },
85+
workspace,
86+
});
87+
const result = await doctor();
88+
if (!result.ok) process.exit(1);
4789
```
4890

4991
Ports are plain objects:
5092

51-
- `ConsoleReporter` - receives `info`, `error`, and optional `verbose` messages.
52-
- `SecretReader` - resolves secrets such as `LINEAR_API_KEY`.
53-
- `LinearWorkspace` - future adapter boundary for Linear workspace/template
54-
operations.
93+
- `ConsoleReporter` — receives `info`, `error`, and optional `verbose` messages.
94+
- `SecretReader` — resolves secrets such as `LINEAR_API_KEY`.
95+
- `LinearWorkspace` — adapter boundary for Linear workspace operations.
96+
97+
The full LinearWorkspace surface (`getViewer`, `listTeams`, `listLabels`,
98+
`createLabel`, `getTemplate`, `createTemplate`, `updateTemplate`,
99+
`createIssue`, `createRelation`) is implemented by
100+
`createLinearGraphqlWorkspace`. Later mutating commands build on these
101+
methods; they do not implement their own GraphQL.
55102

56-
Core use-case modules do not read environment variables, call `fetch`, or exit
57-
the process. Those responsibilities stay in CLI/adapter code.
103+
Core use-case modules do not read environment variables, call `fetch`, or
104+
exit the process. Those responsibilities stay in CLI/adapter code (enforced
105+
by `tests/boundary.test.mjs`).
58106

59107
## Template
60108

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* InteractiveSecretReader — SecretReader adapter that resolves a secret from
3+
* the environment first, then falls back to a hidden interactive TTY prompt.
4+
*
5+
* Conforms to the SecretReader port: `read(name)` returns the secret value or
6+
* `null`. The hidden prompt is opt-in per call via the `interactive` option
7+
* passed to `readLinearApiKey` so non-interactive use (CI / scripts) never
8+
* blocks on stdin.
9+
*
10+
* The API key is never logged, written to disk, or echoed to the terminal.
11+
*/
12+
13+
import { createEnvironmentSecretReader } from './environment-secret-reader.mjs';
14+
15+
/**
16+
* @typedef {object} InteractiveSecretReaderOptions
17+
* @property {Record<string, string|undefined>} [env]
18+
* @property {NodeJS.ReadStream} [stdin]
19+
* @property {NodeJS.WriteStream} [stdout]
20+
* @property {(opts: { stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream, prompt: string }) => Promise<string>} [hiddenPrompt]
21+
*/
22+
23+
/**
24+
* @param {InteractiveSecretReaderOptions} [opts]
25+
* @returns {import('../ports.mjs').SecretReader & {
26+
* readLinearApiKey: (opts?: { interactive?: boolean, prompt?: string }) => Promise<string | null>,
27+
* }}
28+
*/
29+
export function createInteractiveSecretReader({
30+
env = {},
31+
stdin,
32+
stdout,
33+
hiddenPrompt = defaultHiddenPrompt,
34+
} = {}) {
35+
const baseReader = createEnvironmentSecretReader(env);
36+
37+
return Object.freeze({
38+
read(name) {
39+
return baseReader.read(name);
40+
},
41+
42+
async readLinearApiKey({ interactive = false, prompt = 'Linear API key: ' } = {}) {
43+
const fromEnv = baseReader.read('LINEAR_API_KEY');
44+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv.trim();
45+
if (!interactive) return null;
46+
if (!stdin?.isTTY) return null;
47+
const entered = (await hiddenPrompt({ stdin, stdout, prompt }))?.trim() ?? '';
48+
return entered === '' ? null : entered;
49+
},
50+
});
51+
}
52+
53+
/**
54+
* Read one line from `stdin` without echoing to `stdout`. Restores raw mode
55+
* and listeners on exit so the process is left in a clean state.
56+
*/
57+
function defaultHiddenPrompt({ stdin, stdout, prompt }) {
58+
return new Promise((resolve, reject) => {
59+
if (typeof stdin.setRawMode !== 'function') {
60+
reject(new Error('hidden prompt requires a TTY stdin with setRawMode'));
61+
return;
62+
}
63+
64+
stdout.write(prompt);
65+
const previousRaw = stdin.isRaw === true;
66+
try { stdin.setRawMode(true); } catch (e) { reject(e); return; }
67+
stdin.resume();
68+
stdin.setEncoding('utf8');
69+
70+
let buffer = '';
71+
const cleanup = () => {
72+
stdin.removeListener('data', onData);
73+
try { stdin.setRawMode(previousRaw); } catch { /* best-effort */ }
74+
stdin.pause();
75+
stdout.write('\n');
76+
};
77+
78+
const onData = (chunk) => {
79+
for (const ch of chunk) {
80+
if (ch === '') { // Ctrl-C
81+
cleanup();
82+
reject(new Error('Interactive prompt cancelled.'));
83+
return;
84+
}
85+
if (ch === '\r' || ch === '\n') {
86+
cleanup();
87+
resolve(buffer);
88+
return;
89+
}
90+
if (ch === '' || ch === '\b') { // DEL or BS
91+
buffer = buffer.slice(0, -1);
92+
continue;
93+
}
94+
buffer += ch;
95+
}
96+
};
97+
98+
stdin.on('data', onData);
99+
});
100+
}

0 commit comments

Comments
 (0)