Skip to content

Commit fda61ac

Browse files
committed
Add auth setup command and config-file token support
1 parent c428599 commit fda61ac

12 files changed

Lines changed: 433 additions & 23 deletions

File tree

API.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,45 @@ This keeps automation predictable while allowing full passthrough.
111111

112112
## MVP commands
113113

114+
## `auth.setup`
115+
116+
Store a token in `~/.config/buildkite-cli/auth.json` with strict permissions.
117+
118+
- config directory mode: `0700`
119+
- auth file mode: `0600`
120+
121+
If `--token` is not provided, `bkci` prompts interactively for a token.
122+
123+
### Request
124+
125+
```json
126+
{
127+
"tokenProvided": false
128+
}
129+
```
130+
131+
### Success response
132+
133+
```json
134+
{
135+
"ok": true,
136+
"apiVersion": "v1",
137+
"command": "auth.setup",
138+
"request": {
139+
"tokenProvided": false
140+
},
141+
"summary": {
142+
"configured": true,
143+
"source": "prompt"
144+
},
145+
"pagination": null,
146+
"data": {
147+
"path": "/Users/example/.config/buildkite-cli/auth.json"
148+
},
149+
"error": null
150+
}
151+
```
152+
114153
## `auth.status`
115154

116155
Read the token details and report whether required scopes are present.
@@ -497,6 +536,7 @@ List build annotations.
497536

498537
## Mapping to Buildkite REST endpoints
499538

539+
- `auth.setup` -> local filesystem write (`~/.config/buildkite-cli/auth.json`)
500540
- `auth.status` -> `GET /v2/access-token`
501541
- `builds.list` -> `GET /v2/builds` or `GET /v2/organizations/{org}/builds` or `GET /v2/organizations/{org}/pipelines/{pipeline}/builds`
502542
- `builds.get` -> `GET /v2/organizations/{org}/pipelines/{pipeline}/builds/{number}`

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,18 @@ Set one of these environment variables:
4848
- `BUILDKITE_API_TOKEN`
4949
- `BK_TOKEN`
5050

51+
Or run interactive setup to store a token in:
52+
53+
- `~/.config/buildkite-cli/auth.json`
54+
55+
```bash
56+
bkci auth setup
57+
```
58+
5159
## Commands
5260

5361
```bash
62+
bkci auth setup [--token TOKEN]
5463
bkci auth status
5564
bkci builds list --org ORG [--pipeline PIPELINE] [--branch BRANCH] [--state STATE]
5665
bkci builds get --org ORG --pipeline PIPELINE --build BUILD_NUMBER

src/cli/execute-command.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -551,26 +551,30 @@ export async function executeCommand(options: {
551551
};
552552
}
553553

554-
const response = await options.client.requestJson({
555-
path: `/v2/organizations/${options.command.args.org}/pipelines/${options.command.args.pipeline}/builds/${options.command.args.buildNumber}/annotations`,
556-
});
554+
if (options.command.name === "annotations.list") {
555+
const response = await options.client.requestJson({
556+
path: `/v2/organizations/${options.command.args.org}/pipelines/${options.command.args.pipeline}/builds/${options.command.args.buildNumber}/annotations`,
557+
});
557558

558-
if (options.command.global.raw) {
559+
if (options.command.global.raw) {
560+
return {
561+
request,
562+
summary: {},
563+
pagination: null,
564+
data: response.data,
565+
};
566+
}
567+
568+
const annotations = asArray(response.data).map((entry) => mapAnnotation(entry));
559569
return {
560570
request,
561-
summary: {},
571+
summary: {
572+
count: annotations.length,
573+
},
562574
pagination: null,
563-
data: response.data,
575+
data: annotations,
564576
};
565577
}
566578

567-
const annotations = asArray(response.data).map((entry) => mapAnnotation(entry));
568-
return {
569-
request,
570-
summary: {
571-
count: annotations.length,
572-
},
573-
pagination: null,
574-
data: annotations,
575-
};
579+
throw new Error(`unsupported command for executeCommand: ${options.command.name}`);
576580
}

src/cli/parse-args.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import test from "node:test";
33

44
import { parseCliArgs } from "./parse-args.js";
55

6+
test("parseCliArgs parses auth setup with token", () => {
7+
const parsed = parseCliArgs(["auth", "setup", "--token", "abc123"]);
8+
9+
assert.equal(parsed.name, "auth.setup");
10+
assert.equal(parsed.global.raw, false);
11+
assert.deepEqual(parsed.args, {
12+
token: "abc123",
13+
});
14+
});
15+
616
test("parseCliArgs parses auth status", () => {
717
const parsed = parseCliArgs(["auth", "status"]);
818

src/cli/parse-args.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
AnnotationsListArgs,
33
ArtifactsDownloadArgs,
44
ArtifactsListArgs,
5+
AuthSetupArgs,
56
AuthStatusArgs,
67
BuildsGetArgs,
78
BuildsListArgs,
@@ -60,6 +61,25 @@ function parseGlobalOptions(tokens: Array<string>): { readonly global: ParsedGlo
6061
};
6162
}
6263

64+
function parseAuthSetup(tokens: Array<string>): AuthSetupArgs {
65+
let token: string | null = null;
66+
let index = 0;
67+
68+
while (index < tokens.length) {
69+
const current = tokens[index] ?? "";
70+
if (current === "--token") {
71+
const next = readOptionValue(tokens, index, current);
72+
token = normalizeValue(next.value);
73+
index = next.nextIndex;
74+
continue;
75+
}
76+
77+
throw new Error(`unknown argument for auth setup: ${current}`);
78+
}
79+
80+
return { token };
81+
}
82+
6383
function parseAuthStatus(tokens: Array<string>): AuthStatusArgs {
6484
if (tokens.length > 0) {
6585
throw new Error(`unknown argument for auth status: ${tokens[0] ?? ""}`);
@@ -413,6 +433,14 @@ export function parseCliArgs(argv: Array<string>): ParsedCommand {
413433

414434
const [group, action, ...rest] = tokens;
415435

436+
if (group === "auth" && action === "setup") {
437+
return {
438+
name: "auth.setup",
439+
global: globalParsed.global,
440+
args: parseAuthSetup(rest),
441+
};
442+
}
443+
416444
if (group === "auth" && action === "status") {
417445
return {
418446
name: "auth.status",
@@ -476,6 +504,7 @@ export function parseCliArgs(argv: Array<string>): ParsedCommand {
476504
}
477505

478506
export const USAGE_TEXT = `Usage:
507+
bkci auth setup [--token TOKEN]
479508
bkci auth status [--raw]
480509
bkci builds list --org ORG [--pipeline PIPELINE] [--branch BRANCH] [--state STATE] [--page N] [--per-page N] [--raw]
481510
bkci builds get --org ORG --pipeline PIPELINE --build BUILD_NUMBER [--raw]

src/cli/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ export type ParsedGlobalOptions = {
22
readonly raw: boolean;
33
};
44

5+
export type AuthSetupArgs = {
6+
readonly token: string | null;
7+
};
8+
59
export type AuthStatusArgs = Record<string, never>;
610

711
export type BuildsListArgs = {
@@ -52,6 +56,7 @@ export type AnnotationsListArgs = {
5256
};
5357

5458
export type ParsedCommand =
59+
| { readonly name: "auth.setup"; readonly global: ParsedGlobalOptions; readonly args: AuthSetupArgs }
5560
| { readonly name: "auth.status"; readonly global: ParsedGlobalOptions; readonly args: AuthStatusArgs }
5661
| { readonly name: "builds.list"; readonly global: ParsedGlobalOptions; readonly args: BuildsListArgs }
5762
| { readonly name: "builds.get"; readonly global: ParsedGlobalOptions; readonly args: BuildsGetArgs }

src/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type CommandName =
2+
| "auth.setup"
23
| "auth.status"
34
| "builds.list"
45
| "builds.get"

src/index.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import { executeCommand } from "./cli/execute-command.js";
44
import { parseCliArgs, USAGE_TEXT } from "./cli/parse-args.js";
5+
import type { ParsedCommand } from "./cli/types.js";
56
import { failureEnvelope, successEnvelope } from "./core/envelope.js";
67
import { toApiError } from "./core/errors.js";
78
import { createBuildkiteClient } from "./shell/buildkite-client.js";
89
import { getBuildkiteTokenFromEnv } from "./shell/env.js";
10+
import { setupAuthConfig } from "./shell/auth-config.js";
911

1012
function printJson(value: unknown): void {
1113
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
@@ -15,13 +17,23 @@ function shouldShowHelp(argv: Array<string>): boolean {
1517
return argv.includes("--help") || argv.includes("-h");
1618
}
1719

20+
function getSafeRequest(command: ParsedCommand): Record<string, unknown> {
21+
if (command.name === "auth.setup") {
22+
return {
23+
tokenProvided: command.args.token !== null,
24+
};
25+
}
26+
27+
return { ...command.args };
28+
}
29+
1830
async function main(argv: Array<string>): Promise<number> {
1931
if (argv.length === 0 || shouldShowHelp(argv)) {
2032
process.stdout.write(`${USAGE_TEXT}\n`);
2133
return 0;
2234
}
2335

24-
let parsed;
36+
let parsed: ParsedCommand;
2537
try {
2638
parsed = parseCliArgs(argv);
2739
} catch (error) {
@@ -30,6 +42,37 @@ async function main(argv: Array<string>): Promise<number> {
3042
return 1;
3143
}
3244

45+
if (parsed.name === "auth.setup") {
46+
try {
47+
const result = await setupAuthConfig({
48+
token: parsed.args.token,
49+
});
50+
51+
const output = successEnvelope({
52+
command: parsed.name,
53+
request: getSafeRequest(parsed),
54+
summary: {
55+
configured: true,
56+
source: result.source,
57+
},
58+
pagination: null,
59+
data: {
60+
path: result.path,
61+
},
62+
});
63+
printJson(output);
64+
return 0;
65+
} catch (error) {
66+
const output = failureEnvelope({
67+
command: parsed.name,
68+
request: getSafeRequest(parsed),
69+
error: toApiError(error),
70+
});
71+
printJson(output);
72+
return 1;
73+
}
74+
}
75+
3376
try {
3477
const token = getBuildkiteTokenFromEnv();
3578
const client = createBuildkiteClient(token);
@@ -51,7 +94,7 @@ async function main(argv: Array<string>): Promise<number> {
5194
} catch (error) {
5295
const output = failureEnvelope({
5396
command: parsed.name,
54-
request: { ...parsed.args },
97+
request: getSafeRequest(parsed),
5598
error: toApiError(error),
5699
});
57100
printJson(output);

src/shell/auth-config.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import test from "node:test";
6+
7+
import { getAuthConfigPath, readTokenFromConfig, setupAuthConfig } from "./auth-config.js";
8+
9+
test("setupAuthConfig writes auth.json with strict permissions", async () => {
10+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "bkci-auth-"));
11+
const authPath = path.join(tempDir, "config", "buildkite-cli", "auth.json");
12+
const previousPath = process.env.BKCI_AUTH_PATH;
13+
14+
try {
15+
process.env.BKCI_AUTH_PATH = authPath;
16+
17+
const setupResult = await setupAuthConfig({ token: "token-123" });
18+
assert.equal(setupResult.path, authPath);
19+
assert.equal(setupResult.source, "argument");
20+
21+
const content = await readFile(authPath, "utf8");
22+
const parsed = JSON.parse(content) as { token?: string };
23+
assert.equal(parsed.token, "token-123");
24+
assert.equal(readTokenFromConfig(), "token-123");
25+
assert.equal(getAuthConfigPath(), authPath);
26+
27+
const fileInfo = await stat(authPath);
28+
const fileMode = fileInfo.mode & 0o777;
29+
assert.equal(fileMode, 0o600);
30+
31+
const directoryInfo = await stat(path.dirname(authPath));
32+
const directoryMode = directoryInfo.mode & 0o777;
33+
assert.equal(directoryMode, 0o700);
34+
} finally {
35+
if (previousPath === undefined) {
36+
delete process.env.BKCI_AUTH_PATH;
37+
} else {
38+
process.env.BKCI_AUTH_PATH = previousPath;
39+
}
40+
await rm(tempDir, { recursive: true, force: true });
41+
}
42+
});

0 commit comments

Comments
 (0)