Skip to content

Commit d10c841

Browse files
committed
feat: support configurable API base URL
Add KIT_API_BASE env var and `kit config set-base-url` so the CLI can target non-production environments. OAuth authorize/token endpoints derive from the base URL; default remains https://api.kit.com/v4.
1 parent 696b3e5 commit d10c841

7 files changed

Lines changed: 169 additions & 10 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ kit config set-api-key <key>
3535

3636
When both are present, OAuth takes priority.
3737

38+
### Targeting a different environment
39+
40+
By default the CLI talks to production (`https://api.kit.com/v4`). To point it at a different environment (e.g. a staging or test instance), override the API base URL:
41+
42+
```
43+
kit config set-base-url https://api.example.com/v4
44+
# or, per-invocation without changing stored config:
45+
export KIT_API_BASE=https://api.example.com/v4
46+
```
47+
48+
OAuth authorize/token endpoints derive from this base, so logging in targets the same environment. OAuth apps and credentials are environment-specific — register an app in that environment's developer settings and use its client ID.
49+
3850
## Commands
3951

4052
```

scripts/client.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,15 +355,25 @@ describe('safeJsonParse', () => {
355355

356356
describe('HTTP client', () => {
357357
let _oauthSnap;
358+
let _baseSnap;
358359

359360
before(() => {
360361
process.env.KIT_API_KEY = TEST_API_KEY;
362+
// Pin the base URL so URL assertions are independent of stored config
363+
// (the base URL is configurable via KIT_API_BASE / `kit config set-base-url`).
364+
_baseSnap = process.env.KIT_API_BASE;
365+
process.env.KIT_API_BASE = 'https://api.kit.com/v4';
361366
_oauthSnap = oauthSnapshot();
362367
clearOAuth(); // force API-key auth path; avoids expired-token refresh
363368
});
364369

365370
after(() => {
366371
delete process.env.KIT_API_KEY;
372+
if (_baseSnap !== undefined) {
373+
process.env.KIT_API_BASE = _baseSnap;
374+
} else {
375+
delete process.env.KIT_API_BASE;
376+
}
367377
restoreOAuth(_oauthSnap);
368378
globalThis.fetch = _originalFetch;
369379
});

scripts/config.test.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import { test, describe, before, after } from 'node:test';
99
import assert from 'node:assert/strict';
10-
import { setApiKey, getApiKey, getOAuthClientId, getOAuthRedirectUri } from '../src/config.js';
10+
import { setApiKey, getApiKey, getOAuthClientId, getOAuthRedirectUri, setBaseUrl, getBaseUrl } from '../src/config.js';
1111

1212
// ── setApiKey – validation (throws before writing to disk) ─────────────────
1313

@@ -102,6 +102,89 @@ describe('setApiKey validation', () => {
102102
});
103103
});
104104

105+
// ── setBaseUrl – validation (throws before writing to disk) ────────────────
106+
107+
describe('setBaseUrl validation', () => {
108+
test('throws for empty string', () => {
109+
assert.throws(
110+
() => setBaseUrl(''),
111+
{ message: 'Base URL must be a non-empty string.' }
112+
);
113+
});
114+
115+
test('throws for whitespace-only string', () => {
116+
assert.throws(
117+
() => setBaseUrl(' '),
118+
{ message: 'Base URL must be a non-empty string.' }
119+
);
120+
});
121+
122+
test('throws for null', () => {
123+
assert.throws(
124+
() => setBaseUrl(null),
125+
{ message: 'Base URL must be a non-empty string.' }
126+
);
127+
});
128+
129+
test('throws for non-string value (number)', () => {
130+
assert.throws(
131+
() => setBaseUrl(12345),
132+
{ message: 'Base URL must be a non-empty string.' }
133+
);
134+
});
135+
136+
test('throws for a value that is not a valid URL', () => {
137+
assert.throws(
138+
() => setBaseUrl('not-a-url'),
139+
/Invalid base URL/
140+
);
141+
});
142+
143+
test('throws for a non-http(s) protocol', () => {
144+
assert.throws(
145+
() => setBaseUrl('ftp://api.kit.com/v4'),
146+
{ message: 'Base URL must use http or https.' }
147+
);
148+
});
149+
});
150+
151+
// ── getBaseUrl – environment variable override + normalization ─────────────
152+
153+
describe('getBaseUrl', () => {
154+
let _saved;
155+
156+
before(() => {
157+
_saved = process.env.KIT_API_BASE;
158+
delete process.env.KIT_API_BASE;
159+
});
160+
161+
after(() => {
162+
if (_saved !== undefined) {
163+
process.env.KIT_API_BASE = _saved;
164+
} else {
165+
delete process.env.KIT_API_BASE;
166+
}
167+
});
168+
169+
test('returns KIT_API_BASE env var when set', () => {
170+
process.env.KIT_API_BASE = 'https://api.example.com/v4';
171+
assert.equal(getBaseUrl(), 'https://api.example.com/v4');
172+
delete process.env.KIT_API_BASE;
173+
});
174+
175+
test('strips trailing slashes from the env var value', () => {
176+
process.env.KIT_API_BASE = 'https://api.example.com/v4/';
177+
assert.equal(getBaseUrl(), 'https://api.example.com/v4');
178+
delete process.env.KIT_API_BASE;
179+
});
180+
181+
test('returns an http(s) URL when neither env nor config overrides it', () => {
182+
const val = getBaseUrl();
183+
assert.equal(typeof val, 'string');
184+
assert.match(val, /^https?:\/\//);
185+
});
186+
});
187+
105188
// ── getApiKey – environment variable override ──────────────────────────────
106189

107190
describe('getApiKey', () => {

src/auth.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { createHash, randomBytes } from 'node:crypto';
22
import { createServer } from 'node:http';
33
import { execFile } from 'node:child_process';
4-
import { setTokens, getOAuthClientId, getRefreshToken, getOAuthRedirectUri } from './config.js';
4+
import { setTokens, getOAuthClientId, getRefreshToken, getOAuthRedirectUri, getBaseUrl } from './config.js';
55

66
const REDIRECT_PORT = 9876;
7-
const AUTHORIZE_URL = 'https://api.kit.com/v4/oauth/authorize';
8-
const TOKEN_URL = 'https://api.kit.com/v4/oauth/token';
97
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
108

9+
// OAuth endpoints derive from the configured base URL so they target the same
10+
// environment as API calls. The API host redirects the authorize request to
11+
// its app host automatically.
12+
const authorizeUrl = () => `${getBaseUrl()}/oauth/authorize`;
13+
const tokenUrl = () => `${getBaseUrl()}/oauth/token`;
14+
1115
function base64url(buf) {
1216
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
1317
}
@@ -68,7 +72,7 @@ function waitForCallback() {
6872
}
6973

7074
async function exchangeCode(clientId, code, verifier) {
71-
const res = await fetch(TOKEN_URL, {
75+
const res = await fetch(tokenUrl(), {
7276
method: 'POST',
7377
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
7478
body: JSON.stringify({
@@ -96,7 +100,7 @@ export async function refreshAccessToken() {
96100
throw new Error('No refresh token available. Run `kit login` to re-authenticate.');
97101
}
98102

99-
const res = await fetch(TOKEN_URL, {
103+
const res = await fetch(tokenUrl(), {
100104
method: 'POST',
101105
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
102106
body: JSON.stringify({
@@ -121,7 +125,7 @@ export async function login(clientId) {
121125
const challenge = generateCodeChallenge(verifier);
122126
const state = base64url(randomBytes(16));
123127

124-
const authUrl = new URL(AUTHORIZE_URL);
128+
const authUrl = new URL(authorizeUrl());
125129
authUrl.searchParams.set('client_id', clientId);
126130
authUrl.searchParams.set('response_type', 'code');
127131
authUrl.searchParams.set('redirect_uri', getOAuthRedirectUri());

src/client.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { getApiKey, getAccessToken, isTokenExpired } from './config.js';
1+
import { getApiKey, getAccessToken, isTokenExpired, getBaseUrl } from './config.js';
22
import { refreshAccessToken } from './auth.js';
33

4-
const BASE_URL = 'https://api.kit.com/v4';
54
const MAX_PAGINATE_PAGES = 100;
65

76
class KitApiError extends Error {
@@ -76,7 +75,7 @@ async function getAuthHeader() {
7675
}
7776

7877
async function request(method, path, { body, query } = {}) {
79-
const url = new URL(`${BASE_URL}${path}`);
78+
const url = new URL(`${getBaseUrl()}${path}`);
8079

8180
if (query) {
8281
for (const [k, v] of Object.entries(query)) {

src/commands/account.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { printDetail, printSuccess, addFormatOption, withErrorHandler } from '..
44
import {
55
getAll,
66
setApiKey,
7+
setBaseUrl,
78
setOAuthClientId,
89
setOAuthRedirectUri,
910
setDefaultFormat,
@@ -51,6 +52,19 @@ export function configCommand() {
5152
}
5253
});
5354

55+
cmd
56+
.command('set-base-url <url>')
57+
.description('Set the API base URL (default: https://api.kit.com/v4)')
58+
.action((url) => {
59+
try {
60+
setBaseUrl(url);
61+
console.log(chalk.green('✓ API base URL saved.'));
62+
} catch (err) {
63+
console.error(chalk.red(err.message));
64+
process.exit(1);
65+
}
66+
});
67+
5468
cmd
5569
.command('set-api-key <key>')
5670
.description('Set your Kit API key')

src/config.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const config = new Conf({
55
projectName: 'kit-cli',
66
schema: {
77
apiKey: { type: 'string', default: '' },
8+
baseUrl: { type: 'string', default: 'https://api.kit.com/v4' },
89
defaultFormat: { type: 'string', default: 'table', enum: ['table', 'json'] },
910
perPage: { type: 'number', default: 50, minimum: 1, maximum: 1000 },
1011
oauthClientId: { type: 'string', default: '' },
@@ -22,6 +23,41 @@ try {
2223
// May fail on Windows or if file doesn't exist yet — non-fatal
2324
}
2425

26+
// --- API base URL ---
27+
//
28+
// Defaults to production. Override for other environments (e.g. QA) via the
29+
// KIT_API_BASE env var or `kit config set-base-url`. The env var wins so you
30+
// can target a different host for a single invocation without mutating stored
31+
// config. All API requests and OAuth endpoints derive from this value.
32+
33+
const DEFAULT_BASE_URL = 'https://api.kit.com/v4';
34+
35+
function normalizeBaseUrl(url) {
36+
return url.trim().replace(/\/+$/, ''); // strip trailing slashes so path joins don't double up
37+
}
38+
39+
export function getBaseUrl() {
40+
const fromEnv = process.env.KIT_API_BASE;
41+
const value = (fromEnv && fromEnv.trim()) || config.get('baseUrl') || DEFAULT_BASE_URL;
42+
return normalizeBaseUrl(value);
43+
}
44+
45+
export function setBaseUrl(url) {
46+
if (!url || typeof url !== 'string' || url.trim().length === 0) {
47+
throw new Error('Base URL must be a non-empty string.');
48+
}
49+
let parsed;
50+
try {
51+
parsed = new URL(url.trim());
52+
} catch {
53+
throw new Error(`Invalid base URL: "${url}". Must be a full URL like https://api.kit.com/v4.`);
54+
}
55+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
56+
throw new Error('Base URL must use http or https.');
57+
}
58+
config.set('baseUrl', normalizeBaseUrl(url));
59+
}
60+
2561
// --- API key ---
2662

2763
export function getApiKey() {
@@ -123,6 +159,7 @@ export function getAll() {
123159
}
124160

125161
return {
162+
baseUrl: getBaseUrl(),
126163
apiKey: getApiKey() ? '****' + getApiKey().slice(-4) : '(not set)',
127164
oauthClientId: getOAuthClientId() || '(not set)',
128165
oauthRedirectUri: getOAuthRedirectUri(),

0 commit comments

Comments
 (0)