@hasna/connectors 1.3.46 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -122,7 +122,7 @@ authCmd
122
122
  const userInfo = await getUserInfo(result.tokens!.accessToken);
123
123
  const email = userInfo.email;
124
124
 
125
- // Convert email to profile slug: andrei@hasna.com → andreihasnacom
125
+ // Convert email to profile slug: user@example.com → userexamplecom
126
126
  const profileSlug = email.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
127
127
 
128
128
  // Create profile if it doesn't exist
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import {
3
+ exchangeCodeForTokens,
4
+ refreshAccessToken,
5
+ revokeToken,
6
+ type OAuth2Config,
7
+ } from './oauth';
8
+
9
+ /**
10
+ * Regression tests for https://github.com/hasna/connectors/issues/1
11
+ *
12
+ * X rejects `Authorization: Basic <client_id:client_secret>` on
13
+ * POST /2/oauth2/token when the app is registered as a *public* client:
14
+ * {"error":"unauthorized_client",
15
+ * "error_description":"Missing valid authorization header"}
16
+ *
17
+ * The connector therefore must always authenticate the client with POST body
18
+ * parameters (`client_id`, plus `client_secret` when one is configured) and
19
+ * must never fall back to an Authorization header on the token/revoke
20
+ * endpoints.
21
+ */
22
+
23
+ interface CapturedRequest {
24
+ url: string;
25
+ method?: string;
26
+ headers: Record<string, string>;
27
+ body: URLSearchParams;
28
+ }
29
+
30
+ const realFetch = globalThis.fetch;
31
+
32
+ /**
33
+ * Normalise every shape `RequestInit.headers` can take (plain object, entry
34
+ * array, `Headers` instance) into a lower-cased record. Going through
35
+ * `new Headers(...)` matters: if the implementation ever switched to a
36
+ * `Headers` object, a naive `Object.entries()` would yield `[]` and the
37
+ * "no Authorization header" assertions below would pass vacuously.
38
+ */
39
+ function normaliseHeaders(init?: RequestInit): Record<string, string> {
40
+ const out: Record<string, string> = {};
41
+ if (!init?.headers) return out;
42
+ new Headers(init.headers).forEach((value, key) => {
43
+ out[key.toLowerCase()] = value;
44
+ });
45
+ return out;
46
+ }
47
+
48
+ function captureTokenRequest(): { calls: CapturedRequest[] } {
49
+ const calls: CapturedRequest[] = [];
50
+
51
+ globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
52
+ const headers = normaliseHeaders(init);
53
+
54
+ calls.push({
55
+ url: String(input),
56
+ method: init?.method,
57
+ headers,
58
+ body: new URLSearchParams(String(init?.body ?? '')),
59
+ });
60
+
61
+ return new Response(
62
+ JSON.stringify({
63
+ access_token: 'ACCESS_TOKEN',
64
+ refresh_token: 'NEW_REFRESH_TOKEN',
65
+ expires_in: 7200,
66
+ scope: 'tweet.read tweet.write offline.access',
67
+ token_type: 'bearer',
68
+ }),
69
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
70
+ );
71
+ }) as typeof fetch;
72
+
73
+ return { calls };
74
+ }
75
+
76
+ const PUBLIC_CLIENT: OAuth2Config = {
77
+ clientId: 'PUBLIC_CLIENT_ID',
78
+ redirectUri: 'http://localhost:8888/callback',
79
+ };
80
+
81
+ const CONFIDENTIAL_CLIENT: OAuth2Config = {
82
+ clientId: 'CONFIDENTIAL_CLIENT_ID',
83
+ clientSecret: 'CONFIDENTIAL_CLIENT_SECRET',
84
+ redirectUri: 'http://localhost:8888/callback',
85
+ };
86
+
87
+ afterEach(() => {
88
+ globalThis.fetch = realFetch;
89
+ });
90
+
91
+ describe('OAuth 2.0 token requests - public client (issue #1)', () => {
92
+ test('exchangeCodeForTokens sends client_id in the body and no auth header', async () => {
93
+ const { calls } = captureTokenRequest();
94
+
95
+ const tokens = await exchangeCodeForTokens(
96
+ PUBLIC_CLIENT,
97
+ 'AUTH_CODE',
98
+ 'CODE_VERIFIER'
99
+ );
100
+
101
+ expect(calls).toHaveLength(1);
102
+ const req = calls[0]!;
103
+ expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
104
+ expect(req.method).toBe('POST');
105
+ expect(req.headers['authorization']).toBeUndefined();
106
+ expect(req.headers['content-type']).toBe(
107
+ 'application/x-www-form-urlencoded'
108
+ );
109
+ expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
110
+ expect(req.body.get('client_secret')).toBeNull();
111
+ expect(req.body.get('grant_type')).toBe('authorization_code');
112
+ expect(req.body.get('code')).toBe('AUTH_CODE');
113
+ expect(req.body.get('code_verifier')).toBe('CODE_VERIFIER');
114
+ expect(req.body.get('redirect_uri')).toBe(
115
+ 'http://localhost:8888/callback'
116
+ );
117
+
118
+ expect(tokens.accessToken).toBe('ACCESS_TOKEN');
119
+ expect(tokens.refreshToken).toBe('NEW_REFRESH_TOKEN');
120
+ });
121
+
122
+ test('refreshAccessToken sends client_id in the body and no auth header', async () => {
123
+ const { calls } = captureTokenRequest();
124
+
125
+ await refreshAccessToken(PUBLIC_CLIENT, 'OLD_REFRESH_TOKEN');
126
+
127
+ expect(calls).toHaveLength(1);
128
+ const req = calls[0]!;
129
+ expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
130
+ expect(req.headers['authorization']).toBeUndefined();
131
+ expect(req.body.get('grant_type')).toBe('refresh_token');
132
+ expect(req.body.get('refresh_token')).toBe('OLD_REFRESH_TOKEN');
133
+ expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
134
+ expect(req.body.get('client_secret')).toBeNull();
135
+ });
136
+
137
+ test('revokeToken sends client_id in the body and no auth header', async () => {
138
+ const { calls } = captureTokenRequest();
139
+
140
+ await revokeToken(PUBLIC_CLIENT, 'SOME_ACCESS_TOKEN', 'access_token');
141
+
142
+ expect(calls).toHaveLength(1);
143
+ const req = calls[0]!;
144
+ expect(req.url).toBe('https://api.twitter.com/2/oauth2/revoke');
145
+ expect(req.headers['authorization']).toBeUndefined();
146
+ expect(req.body.get('token')).toBe('SOME_ACCESS_TOKEN');
147
+ expect(req.body.get('token_type_hint')).toBe('access_token');
148
+ expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
149
+ });
150
+ });
151
+
152
+ describe('OAuth 2.0 token requests - configured client secret (issue #1)', () => {
153
+ // This is the exact trigger of issue #1: a client_secret is present in the
154
+ // connector config (env var or ~/.hasna/connectors/connect-x/credentials.json)
155
+ // while the X app itself is registered as a public client. Sending Basic auth
156
+ // in that situation is what produced "Missing valid authorization header".
157
+ test('never falls back to Basic auth when a client secret is configured', async () => {
158
+ const { calls } = captureTokenRequest();
159
+
160
+ await exchangeCodeForTokens(
161
+ CONFIDENTIAL_CLIENT,
162
+ 'AUTH_CODE',
163
+ 'CODE_VERIFIER'
164
+ );
165
+ await refreshAccessToken(CONFIDENTIAL_CLIENT, 'OLD_REFRESH_TOKEN');
166
+ await revokeToken(CONFIDENTIAL_CLIENT, 'SOME_ACCESS_TOKEN');
167
+
168
+ expect(calls).toHaveLength(3);
169
+ for (const req of calls) {
170
+ expect(req.headers['authorization']).toBeUndefined();
171
+ expect(req.body.get('client_id')).toBe('CONFIDENTIAL_CLIENT_ID');
172
+ }
173
+ });
174
+
175
+ test('still authenticates the client via client_secret_post', async () => {
176
+ const { calls } = captureTokenRequest();
177
+
178
+ await exchangeCodeForTokens(
179
+ CONFIDENTIAL_CLIENT,
180
+ 'AUTH_CODE',
181
+ 'CODE_VERIFIER'
182
+ );
183
+
184
+ expect(calls[0]!.body.get('client_secret')).toBe(
185
+ 'CONFIDENTIAL_CLIENT_SECRET'
186
+ );
187
+ });
188
+ });
189
+
190
+ describe('OAuth 2.0 token requests - error surfacing', () => {
191
+ test('exchangeCodeForTokens surfaces the X error payload', async () => {
192
+ globalThis.fetch = (async () =>
193
+ new Response(
194
+ JSON.stringify({
195
+ error: 'unauthorized_client',
196
+ error_description: 'Missing valid authorization header',
197
+ }),
198
+ { status: 400 }
199
+ )) as typeof fetch;
200
+
201
+ await expect(
202
+ exchangeCodeForTokens(PUBLIC_CLIENT, 'AUTH_CODE', 'CODE_VERIFIER')
203
+ ).rejects.toThrow(/Token exchange failed:.*unauthorized_client/s);
204
+ });
205
+ });
@@ -25,7 +25,7 @@ export YOUSEARCH_API_KEY=your-api-key
25
25
  connect-yousearch search "latest AI news" --count 5
26
26
 
27
27
  # Web search with domain filters (POST)
28
- connect-yousearch search-post "alumia platform" --include-domains "hasna.com,github.com"
28
+ connect-yousearch search-post "agent frameworks" --include-domains "example.com,github.com"
29
29
 
30
30
  # Multi-step research
31
31
  connect-yousearch research "What are the latest developments in AI agents?" --effort deep
@@ -43,7 +43,7 @@ connect-zendesk/
43
43
  - **EC2 Instance**: `hasna-prod-connect-zendesk`
44
44
  - **RDS Database**: `hasna-prod-connect-zendesk`
45
45
  - **S3 Bucket**: `hasna-prod-connect-zendesk`
46
- - **Remote API**: `https://connect.hasna.com/zendesk`
46
+ - **Remote API**: deployment-specific; set `ZENDESK_REMOTE_API_URL` (no shipped default)
47
47
 
48
48
  ## Key Patterns
49
49
 
@@ -17,6 +17,8 @@ EC2_USER ?= ec2-user
17
17
  DEPLOY_PATH ?= /home/ec2-user/connectors/connect-zendesk
18
18
  SERVICE_NAME ?= connect-zendesk
19
19
  PORT ?= 21010
20
+ # Public URL the deployed server is reachable at. Deployment-specific — no default.
21
+ REMOTE_API_URL ?=
20
22
 
21
23
  # Default target
22
24
  help:
@@ -92,7 +94,7 @@ deploy-ec2: deploy-sync
92
94
 
93
95
  deploy: build deploy-ec2
94
96
  @echo "✅ Deployment complete"
95
- @echo "Server running at https://connect.hasna.com/zendesk"
97
+ @echo "Server running at $(if $(REMOTE_API_URL),$(REMOTE_API_URL),<REMOTE_API_URL not set>)"
96
98
 
97
99
  logs:
98
100
  ssh $(EC2_USER)@$(EC2_HOST) "sudo journalctl -u $(SERVICE_NAME) -f"
@@ -11,7 +11,7 @@ This connector provides programmatic access to Zendesk's Support API, including
11
11
  | EC2 Instance | `hasna-prod-connect-zendesk` |
12
12
  | RDS Database | `hasna-prod-connect-zendesk` |
13
13
  | S3 Bucket | `hasna-prod-connect-zendesk` |
14
- | Remote API | `https://connect.hasna.com/zendesk` |
14
+ | Remote API | configured per deployment via `ZENDESK_REMOTE_API_URL` (no default) |
15
15
 
16
16
  ## Installation
17
17
 
@@ -279,7 +279,8 @@ This connector is deployed to:
279
279
  - **Database**: `hasna-prod-connect-zendesk`
280
280
  - **S3**: `hasna-prod-connect-zendesk`
281
281
 
282
- The remote API is accessible at `https://connect.hasna.com/zendesk`
282
+ The remote API host is deployment-specific and has no built-in default. Point the CLI at your
283
+ deployment with `ZENDESK_REMOTE_API_URL` or `connect-zendesk config set-remote-url <url>`.
283
284
 
284
285
  ## License
285
286
 
@@ -43,7 +43,7 @@ Each connector follows this naming pattern:
43
43
  | EC2 Instance | `hasna-prod-connect-{name}` | `hasna-prod-connect-notion` |
44
44
  | RDS Database | `hasna-prod-connect-{name}` | `hasna-prod-connect-notion` |
45
45
  | S3 Bucket | `hasna-prod-connect-{name}` | `hasna-prod-connect-notion` |
46
- | Remote API | `https://connect.hasna.com/{name}` | `https://connect.hasna.com/notion` |
46
+ | Remote API | `https://<connect-host>/{name}` | `https://connect.example.com/notion` |
47
47
 
48
48
  ## Project Structure
49
49
 
@@ -175,4 +175,4 @@ The connector is deployed to AWS infrastructure:
175
175
 
176
176
  1. Build the project: `make build`
177
177
  2. Deploy to EC2: `make deploy-ec2`
178
- 3. Configure the remote API at `https://connect.hasna.com/{name}`
178
+ 3. Point the CLI at the deployment via `<NAME>_REMOTE_API_URL` (no default is shipped)
@@ -54,7 +54,7 @@ http {
54
54
 
55
55
  server {
56
56
  listen 80;
57
- server_name connect.hasna.com;
57
+ server_name connect.example.com;
58
58
 
59
59
  location / {
60
60
  root /usr/share/nginx/html;
@@ -127,7 +127,7 @@ http {
127
127
 
128
128
  server {
129
129
  listen 80;
130
- server_name gmail.connect.hasna.com;
130
+ server_name gmail.connect.example.com;
131
131
 
132
132
  location / {
133
133
  proxy_pass http://127.0.0.1:3001;
@@ -140,7 +140,7 @@ http {
140
140
 
141
141
  server {
142
142
  listen 80;
143
- server_name googlecontacts.connect.hasna.com;
143
+ server_name googlecontacts.connect.example.com;
144
144
 
145
145
  location / {
146
146
  proxy_pass http://127.0.0.1:3002;
@@ -153,7 +153,7 @@ http {
153
153
 
154
154
  server {
155
155
  listen 80;
156
- server_name googledrive.connect.hasna.com;
156
+ server_name googledrive.connect.example.com;
157
157
 
158
158
  location / {
159
159
  proxy_pass http://127.0.0.1:3003;
@@ -166,7 +166,7 @@ http {
166
166
 
167
167
  server {
168
168
  listen 80;
169
- server_name linear.connect.hasna.com;
169
+ server_name linear.connect.example.com;
170
170
 
171
171
  location / {
172
172
  proxy_pass http://127.0.0.1:3004;
@@ -179,7 +179,7 @@ http {
179
179
 
180
180
  server {
181
181
  listen 80;
182
- server_name notion.connect.hasna.com;
182
+ server_name notion.connect.example.com;
183
183
 
184
184
  location / {
185
185
  proxy_pass http://127.0.0.1:3005;
@@ -192,7 +192,7 @@ http {
192
192
 
193
193
  server {
194
194
  listen 80;
195
- server_name clickbank.connect.hasna.com;
195
+ server_name clickbank.connect.example.com;
196
196
 
197
197
  location / {
198
198
  proxy_pass http://127.0.0.1:3013;
@@ -205,7 +205,7 @@ http {
205
205
 
206
206
  server {
207
207
  listen 80;
208
- server_name zendesk.connect.hasna.com;
208
+ server_name zendesk.connect.example.com;
209
209
 
210
210
  location / {
211
211
  proxy_pass http://127.0.0.1:21010;
@@ -12,10 +12,10 @@ upstream connect_zendesk {
12
12
  keepalive 32;
13
13
  }
14
14
 
15
- # Main server block for connect.hasna.com/zendesk
16
- # Add this location block to the existing connect.hasna.com server
15
+ # Main server block for connect.example.com/zendesk
16
+ # Add this location block to the existing connect.example.com server
17
17
  #
18
- # If you have a separate server block for connect.hasna.com, add:
18
+ # If you have a separate server block for connect.example.com, add:
19
19
  #
20
20
  # location /zendesk {
21
21
  # proxy_pass http://connect_zendesk;
@@ -30,21 +30,21 @@ upstream connect_zendesk {
30
30
  # proxy_read_timeout 86400;
31
31
  # }
32
32
 
33
- # Standalone server configuration (use if connect.hasna.com doesn't exist)
33
+ # Standalone server configuration (use if connect.example.com doesn't exist)
34
34
  # Uncomment and modify as needed:
35
35
  #
36
36
  # server {
37
37
  # listen 80;
38
- # server_name connect.hasna.com;
38
+ # server_name connect.example.com;
39
39
  # return 301 https://$host$request_uri;
40
40
  # }
41
41
  #
42
42
  # server {
43
43
  # listen 443 ssl http2;
44
- # server_name connect.hasna.com;
44
+ # server_name connect.example.com;
45
45
  #
46
- # ssl_certificate /etc/letsencrypt/live/connect.hasna.com/fullchain.pem;
47
- # ssl_certificate_key /etc/letsencrypt/live/connect.hasna.com/privkey.pem;
46
+ # ssl_certificate /etc/letsencrypt/live/connect.example.com/fullchain.pem;
47
+ # ssl_certificate_key /etc/letsencrypt/live/connect.example.com/privkey.pem;
48
48
  #
49
49
  # location /zendesk {
50
50
  # proxy_pass http://connect_zendesk;
@@ -16,7 +16,7 @@ import {
16
16
  getConfigDir,
17
17
  getBaseConfigDir,
18
18
  getExportsDir,
19
- getRemoteApiUrl,
19
+ findRemoteApiUrl,
20
20
  setRemoteApiUrl,
21
21
  setProfileOverride,
22
22
  getCurrentProfile,
@@ -238,7 +238,7 @@ configCmd
238
238
 
239
239
  configCmd
240
240
  .command('set-remote-url <url>')
241
- .description('Set remote API URL (default: https://connect.hasna.com/zendesk)')
241
+ .description('Set remote API URL (no default; also settable via ZENDESK_REMOTE_API_URL)')
242
242
  .action((url: string) => {
243
243
  setRemoteApiUrl(url);
244
244
  success(`Remote API URL set to: ${url}`);
@@ -253,13 +253,13 @@ configCmd
253
253
  const apiToken = getApiToken();
254
254
  const baseUrl = getBaseUrl();
255
255
  const account = getDefaultAccount();
256
- const remoteUrl = getRemoteApiUrl();
256
+ const remoteUrl = findRemoteApiUrl();
257
257
  info(`Profile: ${chalk.cyan(profile)}`);
258
258
  info(`Email: ${email || chalk.gray('not set')}`);
259
259
  info(`API Token: ${apiToken ? `${apiToken.substring(0, 6)}...${apiToken.substring(apiToken.length - 4)}` : chalk.gray('not set')}`);
260
260
  info(`Base URL: ${baseUrl || chalk.gray('not set')}`);
261
261
  info(`Default Account: ${account || chalk.gray('not set')}`);
262
- info(`Remote API URL: ${remoteUrl}`);
262
+ info(`Remote API URL: ${remoteUrl || chalk.gray('not set')}`);
263
263
  info(`Config Directory: ${getBaseConfigDir()}`);
264
264
  info(`Profile Config: ${getConfigDir()}`);
265
265
  info(`Exports Directory: ${getExportsDir()}`);
@@ -274,17 +274,28 @@ configCmd
274
274
  });
275
275
 
276
276
  // ============================================
277
- // Remote API Commands (connect.hasna.com)
277
+ // Remote API Commands (host comes from ZENDESK_REMOTE_API_URL / config)
278
278
  // ============================================
279
279
  const remoteCmd = program
280
280
  .command('remote')
281
281
  .description('Interact with the remote Zendesk connector API');
282
282
 
283
+ // The remote host has no shipped default. Commands that need it exit with the
284
+ // connector's usual error convention rather than an uncaught throw.
285
+ function requireRemoteApiUrl(): string {
286
+ const remoteUrl = findRemoteApiUrl();
287
+ if (!remoteUrl) {
288
+ error('Remote API URL is not configured. Set ZENDESK_REMOTE_API_URL or run: connect-zendesk config set-remote-url <url>');
289
+ process.exit(1);
290
+ }
291
+ return remoteUrl;
292
+ }
293
+
283
294
  remoteCmd
284
295
  .command('status')
285
296
  .description('Check remote API status')
286
297
  .action(async () => {
287
- const remoteUrl = getRemoteApiUrl();
298
+ const remoteUrl = requireRemoteApiUrl();
288
299
  logger.command('remote status', { remoteUrl });
289
300
  try {
290
301
  const response = await fetch(`${remoteUrl}/status`);
@@ -301,7 +312,7 @@ remoteCmd
301
312
  .command('health')
302
313
  .description('Check remote API health')
303
314
  .action(async () => {
304
- const remoteUrl = getRemoteApiUrl();
315
+ const remoteUrl = requireRemoteApiUrl();
305
316
  logger.command('remote health', { remoteUrl });
306
317
  try {
307
318
  const response = await fetch(`${remoteUrl}/health`);
@@ -321,7 +332,7 @@ remoteCmd
321
332
  .command('url')
322
333
  .description('Show current remote API URL')
323
334
  .action(() => {
324
- info(`Remote API URL: ${getRemoteApiUrl()}`);
335
+ info(`Remote API URL: ${findRemoteApiUrl() || chalk.gray('not set')}`);
325
336
  });
326
337
 
327
338
  // ============================================
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * connect-zendesk server
4
4
  * Remote API server for Zendesk connector
5
- * Deployed at https://connect.hasna.com/zendesk
5
+ * Deployment host is environment-specific; see nginx.conf for the reverse-proxy template.
6
6
  */
7
7
 
8
8
  const PORT = parseInt(process.env.PORT || '3000');
@@ -2,6 +2,14 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
2
  import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
3
3
  import { homedir } from 'os';
4
4
  import { join } from 'path';
5
+ import {
6
+ findRemoteApiUrl,
7
+ getRemoteApiUrl,
8
+ setRemoteApiUrl,
9
+ setProfileOverride,
10
+ getBaseConfigDir,
11
+ clearConfig,
12
+ } from './config';
5
13
 
6
14
  // We need to test the config module with a custom config dir
7
15
  // to avoid messing with actual user config
@@ -190,4 +198,66 @@ describe('Config utilities', () => {
190
198
  else delete process.env.ZENDESK_EMAIL;
191
199
  });
192
200
  });
201
+
202
+ // The remote API URL no longer falls back to a hardcoded deployment host, so
203
+ // these exercise the real module instead of re-implementing the priority
204
+ // logic: an unconfigured URL has to fail loudly rather than silently resolve
205
+ // to a baked-in default, and each configured source has to round-trip.
206
+ describe('remote API URL resolution', () => {
207
+ const REMOTE_URL_TEST_PROFILE = 'remote-url-test';
208
+ const ENV_URL = 'https://env.example.com/zendesk';
209
+ const STORED_URL = 'https://stored.example.com/zendesk';
210
+
211
+ let originalEnv: string | undefined;
212
+
213
+ beforeEach(() => {
214
+ originalEnv = process.env.ZENDESK_REMOTE_API_URL;
215
+ delete process.env.ZENDESK_REMOTE_API_URL;
216
+ // Profiles are the module's own isolation seam: point config reads and
217
+ // writes at a throwaway profile so the real one is never touched.
218
+ setProfileOverride(REMOTE_URL_TEST_PROFILE);
219
+ clearConfig();
220
+ });
221
+
222
+ afterEach(() => {
223
+ setProfileOverride(undefined);
224
+ // Derive the throwaway path by name rather than from getConfigDir(), so
225
+ // this stays a delete of the test profile no matter how the override is
226
+ // sequenced above it.
227
+ rmSync(join(getBaseConfigDir(), 'profiles', REMOTE_URL_TEST_PROFILE), {
228
+ recursive: true,
229
+ force: true,
230
+ });
231
+
232
+ if (originalEnv === undefined) delete process.env.ZENDESK_REMOTE_API_URL;
233
+ else process.env.ZENDESK_REMOTE_API_URL = originalEnv;
234
+ });
235
+
236
+ test('is unset and throws when neither env nor config provides a URL', () => {
237
+ expect(findRemoteApiUrl()).toBeUndefined();
238
+ expect(() => getRemoteApiUrl()).toThrow(/ZENDESK_REMOTE_API_URL/);
239
+ });
240
+
241
+ test('resolves from the environment variable', () => {
242
+ process.env.ZENDESK_REMOTE_API_URL = ENV_URL;
243
+
244
+ expect(findRemoteApiUrl()).toBe(ENV_URL);
245
+ expect(getRemoteApiUrl()).toBe(ENV_URL);
246
+ });
247
+
248
+ test('resolves from the stored config value', () => {
249
+ setRemoteApiUrl(STORED_URL);
250
+
251
+ expect(findRemoteApiUrl()).toBe(STORED_URL);
252
+ expect(getRemoteApiUrl()).toBe(STORED_URL);
253
+ });
254
+
255
+ test('environment variable takes precedence over the stored config value', () => {
256
+ setRemoteApiUrl(STORED_URL);
257
+ process.env.ZENDESK_REMOTE_API_URL = ENV_URL;
258
+
259
+ expect(findRemoteApiUrl()).toBe(ENV_URL);
260
+ expect(getRemoteApiUrl()).toBe(ENV_URL);
261
+ });
262
+ });
193
263
  });
@@ -499,10 +499,20 @@ export function clearConfig(): void {
499
499
  saveConfig({});
500
500
  }
501
501
 
502
- const DEFAULT_REMOTE_API_URL = 'https://connect.hasna.com/zendesk';
502
+ // The remote API host is deployment-specific and has no shippable default.
503
+ // Configure it with ZENDESK_REMOTE_API_URL or `connect-zendesk config set-remote-url <url>`.
504
+ export function findRemoteApiUrl(): string | undefined {
505
+ return process.env.ZENDESK_REMOTE_API_URL || loadConfig().remoteApiUrl || undefined;
506
+ }
503
507
 
504
508
  export function getRemoteApiUrl(): string {
505
- return process.env.ZENDESK_REMOTE_API_URL || loadConfig().remoteApiUrl || DEFAULT_REMOTE_API_URL;
509
+ const url = findRemoteApiUrl();
510
+ if (!url) {
511
+ throw new Error(
512
+ 'Remote API URL is not configured. Set ZENDESK_REMOTE_API_URL or run: connect-zendesk config set-remote-url <url>',
513
+ );
514
+ }
515
+ return url;
506
516
  }
507
517
 
508
518
  export function setRemoteApiUrl(url: string): void {
@@ -0,0 +1,72 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ // The remote API URL used to fall back to a hardcoded deployment host. It no
7
+ // longer has a shipped default, so these lock in how the CLI behaves when it is
8
+ // unset: display commands must still print, and commands that genuinely need
9
+ // the URL must fail with actionable guidance rather than an uncaught throw.
10
+ //
11
+ // Resolution order itself is covered in config.test.ts. This file covers the
12
+ // CLI surface: os.homedir() does not observe runtime process.env.HOME mutation,
13
+ // so each case runs the real CLI in a subprocess with its own HOME.
14
+
15
+ const CLI = join(import.meta.dir, '..', 'cli', 'index.ts');
16
+ const ENV_KEY = 'ZENDESK_REMOTE_API_URL';
17
+
18
+ let home: string;
19
+
20
+ function runCli(args: string[], env: Record<string, string> = {}) {
21
+ const result = Bun.spawnSync({
22
+ cmd: ['bun', 'run', CLI, ...args],
23
+ env: { ...process.env, HOME: home, [ENV_KEY]: '', ...env },
24
+ stdout: 'pipe',
25
+ stderr: 'pipe',
26
+ });
27
+ return {
28
+ code: result.exitCode,
29
+ out: new TextDecoder().decode(result.stdout) + new TextDecoder().decode(result.stderr),
30
+ };
31
+ }
32
+
33
+ describe('CLI behaviour when the remote API URL is unset', () => {
34
+ beforeEach(() => {
35
+ home = mkdtempSync(join(tmpdir(), 'connect-zendesk-remote-url-'));
36
+ });
37
+
38
+ afterEach(() => {
39
+ rmSync(home, { recursive: true, force: true });
40
+ });
41
+
42
+ test('config show reports it as unset instead of throwing', () => {
43
+ const { code, out } = runCli(['config', 'show']);
44
+ expect(code).toBe(0);
45
+ expect(out).toContain('Remote API URL:');
46
+ expect(out).toContain('not set');
47
+ });
48
+
49
+ test('remote url reports it as unset instead of throwing', () => {
50
+ const { code, out } = runCli(['remote', 'url']);
51
+ expect(code).toBe(0);
52
+ expect(out).toContain('not set');
53
+ expect(out).not.toContain('at getRemoteApiUrl');
54
+ });
55
+
56
+ test.each(['status', 'health'])(
57
+ 'remote %s fails with actionable guidance, not a stack trace',
58
+ (sub) => {
59
+ const { code, out } = runCli(['remote', sub]);
60
+ expect(code).toBe(1);
61
+ expect(out).toContain(ENV_KEY);
62
+ expect(out).toContain('config set-remote-url');
63
+ expect(out).not.toContain('at getRemoteApiUrl');
64
+ },
65
+ );
66
+
67
+ test('no deployment host is baked in as a fallback', () => {
68
+ // A regression here means someone reintroduced a literal default.
69
+ const { out } = runCli(['remote', 'url']);
70
+ expect(out).not.toMatch(/https?:\/\/\S+/);
71
+ });
72
+ });