History

feat(cli): support env-backed server headers (#1539)

brosand committed 3 months ago

Commit 01663af1 parent 8ba64f610 files · +538 −43Browse files

Showing the changes to apps/cli/src (5 of 10 files). Show all

Unified · Split

5 files
  1. apps/cli/src/device-login.test.ts +118 −2
  2. apps/cli/src/device-login.ts +57 −8
  3. apps/cli/src/main.ts +100 −32
  4. apps/cli/src/server-profile.test.ts +15 −0
  5. apps/cli/src/server-profile.ts +6 −0
apps/cli/src/device-login.test.ts+118−2
@@ -1,6 +1,44 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2 2
3import { browserOpenCommand } from "./device-login";3import {
4 browserOpenCommand,
5 discoverCliLogin,
6 refreshDeviceTokens,
7 requestDeviceCode,
8 type CliLoginDiscovery,
9} from "./device-login";
10
11const originalFetch = globalThis.fetch;
12
13interface FetchCall {
14 readonly url: string;
15 readonly headers: Record<string, string>;
16}
17
18const responseJson = (body: Record<string, unknown>, status = 200): Response =>
19 new Response(JSON.stringify(body), {
20 status,
21 headers: { "content-type": "application/json" },
22 });
23
24const installFetch = (handler: (url: string, init: RequestInit | undefined) => Response) => {
25 globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
26 const url =
27 typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
28 return Promise.resolve(handler(url, init));
29 }) as typeof fetch;
30};
31
32const recordCall = (calls: Array<FetchCall>, url: string, init: RequestInit | undefined): void => {
33 calls.push({
34 url,
35 headers: Object.fromEntries(new Headers(init?.headers).entries()),
36 });
37};
38
39afterEach(() => {
40 globalThis.fetch = originalFetch;
41});
4 42
5describe("browserOpenCommand", () => {43describe("browserOpenCommand", () => {
6 it("opens Windows browser URLs without cmd.exe", () => {44 it("opens Windows browser URLs without cmd.exe", () => {
@@ -32,3 +70,81 @@ describe("browserOpenCommand", () => { · 25 unmodified lines
32 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();70 expect(browserOpenCommand("not a url", "win32")).toBeUndefined();
33 });71 });
34});72});
73
74describe("device login headers", () => {
75 it("sends configured headers when discovering CLI login", async () => {
76 const calls: Array<FetchCall> = [];
77 installFetch((url, init) => {
78 recordCall(calls, url, init);
79 return responseJson({
80 provider: "better-auth",
81 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
82 tokenEndpoint: "https://executor.example/api/auth/device/token",
83 clientId: "executor-cli",
84 requestFormat: "json",
85 });
86 });
87
88 const discovery = await discoverCliLogin("https://executor.example", {
89 headers: { "CF-Access-Client-Id": "client-id" },
90 });
91
92 expect(discovery.clientId).toBe("executor-cli");
93 expect(calls).toHaveLength(1);
94 expect(calls[0]?.url).toBe("https://executor.example/api/auth/cli-login");
95 expect(calls[0]?.headers).toMatchObject({
96 accept: "application/json",
97 "cf-access-client-id": "client-id",
98 });
99 });
100
101 it("sends configured headers only to same-origin device endpoints", async () => {
102 const calls: Array<FetchCall> = [];
103 installFetch((url, init) => {
104 recordCall(calls, url, init);
105 if (url.endsWith("/api/auth/device/code")) {
106 return responseJson({
107 device_code: "device-code",
108 user_code: "USER-CODE",
109 verification_uri: "https://executor.example/device",
110 expires_in: 300,
111 interval: 5,
112 });
113 }
114 return responseJson({
115 access_token: "access-token",
116 refresh_token: "refresh-token-2",
117 expires_in: 600,
118 });
119 });
120 const discovery: CliLoginDiscovery = {
121 provider: "better-auth",
122 deviceAuthorizationEndpoint: "https://executor.example/api/auth/device/code",
123 tokenEndpoint: "https://accounts.example/oauth/token",
124 clientId: "executor-cli",
125 requestFormat: "form",
126 };
127 const headers = { "CF-Access-Client-Id": "client-id" };
128
129 await requestDeviceCode(discovery, { serverOrigin: "https://executor.example", headers });
130 await refreshDeviceTokens({
131 tokenEndpoint: discovery.tokenEndpoint,
132 clientId: discovery.clientId,
133 refreshToken: "refresh-token",
134 serverOrigin: "https://executor.example",
135 headers,
136 });
137
138 expect(calls).toHaveLength(2);
139 expect(calls[0]?.headers).toMatchObject({
140 accept: "application/json",
141 "content-type": "application/x-www-form-urlencoded",
142 "cf-access-client-id": "client-id",
143 });
144 expect(calls[1]?.headers).toMatchObject({
145 accept: "application/json",
146 "content-type": "application/x-www-form-urlencoded",
147 });
148 expect(calls[1]?.headers["cf-access-client-id"]).toBeUndefined();
149 });
150});
apps/cli/src/device-login.ts+57−8
@@ -50,6 +50,15 @@ export interface DeviceTokens { · 49 unmodified lines
50 readonly organizationId?: string;50 readonly organizationId?: string;
51}51}
52 52
53export interface DeviceLoginHttpOptions {
54 readonly headers?: Readonly<Record<string, string>>;
55 readonly serverOrigin?: string;
56}
57
58export interface PollForDeviceTokensOptions extends DeviceLoginHttpOptions {
59 readonly now?: () => number;
60}
61
53const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";62const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
54const DEFAULT_INTERVAL_SECONDS = 5;63const DEFAULT_INTERVAL_SECONDS = 5;
55 64
@@ -126,17 +135,43 @@ const definedFields = (fields: Record<string, string | undefined>): Record<strin · 70 unmodified lines
126 string135 string
127 >;136 >;
128 137
138const isSameOrigin = (url: string, origin: string): boolean => {
139 try {
140 return new URL(url).origin === new URL(origin).origin;
141 } catch {
142 return false;
143 }
144};
145
146const headersForUrl = (
147 url: string,
148 baseHeaders: Record<string, string>,
149 options: DeviceLoginHttpOptions = {},
150): Record<string, string> => {
151 const configured =
152 options.headers && (!options.serverOrigin || isSameOrigin(url, options.serverOrigin))
153 ? options.headers
154 : {};
155 return { ...configured, ...baseHeaders };
156};
157
129const post = async (158const post = async (
130 url: string,159 url: string,
131 fields: Record<string, string | undefined>,160 fields: Record<string, string | undefined>,
132 format: "form" | "json",161 format: "form" | "json",
162 options: DeviceLoginHttpOptions = {},
133) =>163) =>
134 fetch(url, {164 fetch(url, {
135 method: "POST",165 method: "POST",
136 headers: {166 headers: headersForUrl(
137 "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded",167 url,
138 accept: "application/json",168 {
139 },169 "content-type":
170 format === "json" ? "application/json" : "application/x-www-form-urlencoded",
171 accept: "application/json",
172 },
173 options,
174 ),
140 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),175 body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields),
141 });176 });
142 177
@@ -150,10 +185,16 @@ const readJson = async (response: Response): Promise<Record<string, unknown>> => · 7 unmodified lines
150 }185 }
151};186};
152 187
153export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => {188export const discoverCliLogin = async (
189 origin: string,
190 options: DeviceLoginHttpOptions = {},
191): Promise<CliLoginDiscovery> => {
154 let response: Response;192 let response: Response;
193 const url = cliLoginUrl(origin);
155 try {194 try {
156 response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } });195 response = await fetch(url, {
196 headers: headersForUrl(url, { accept: "application/json" }, options),
197 });
157 } catch (cause) {198 } catch (cause) {
158 throw new DeviceLoginError(199 throw new DeviceLoginError(
159 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,200 `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -182,11 +223,15 @@ export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscover · 22 unmodified lines
182 };223 };
183};224};
184 225
185export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => {226export const requestDeviceCode = async (
227 discovery: CliLoginDiscovery,
228 options: DeviceLoginHttpOptions = {},
229): Promise<DeviceCodeGrant> => {
186 const response = await post(230 const response = await post(
187 discovery.deviceAuthorizationEndpoint,231 discovery.deviceAuthorizationEndpoint,
188 { client_id: discovery.clientId, scope: discovery.scope },232 { client_id: discovery.clientId, scope: discovery.scope },
189 discovery.requestFormat,233 discovery.requestFormat,
234 options,
190 );235 );
191 const body = await readJson(response);236 const body = await readJson(response);
192 if (!response.ok) {237 if (!response.ok) {
@@ -220,7 +265,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout · 27 unmodified lines
220export const pollForDeviceTokens = async (265export const pollForDeviceTokens = async (
221 discovery: CliLoginDiscovery,266 discovery: CliLoginDiscovery,
222 grant: DeviceCodeGrant,267 grant: DeviceCodeGrant,
223 options: { readonly now?: () => number } = {},268 options: PollForDeviceTokensOptions = {},
224): Promise<DeviceTokens> => {269): Promise<DeviceTokens> => {
225 const now = options.now ?? (() => Date.now());270 const now = options.now ?? (() => Date.now());
226 const deadline = now() + grant.expiresInSeconds * 1000;271 const deadline = now() + grant.expiresInSeconds * 1000;
@@ -240,6 +285,7 @@ export const pollForDeviceTokens = async ( · 13 unmodified lines
240 client_id: discovery.clientId,285 client_id: discovery.clientId,
241 },286 },
242 discovery.requestFormat,287 discovery.requestFormat,
288 options,
243 );289 );
244 const body = await readJson(response);290 const body = await readJson(response);
245 291
@@ -286,6 +332,8 @@ export const refreshDeviceTokens = async (input: { · 40 unmodified lines
286 readonly tokenEndpoint: string;332 readonly tokenEndpoint: string;
287 readonly clientId: string;333 readonly clientId: string;
288 readonly refreshToken: string;334 readonly refreshToken: string;
335 readonly headers?: Readonly<Record<string, string>>;
336 readonly serverOrigin?: string;
289}): Promise<DeviceTokens> => {337}): Promise<DeviceTokens> => {
290 const response = await post(338 const response = await post(
291 input.tokenEndpoint,339 input.tokenEndpoint,
@@ -295,6 +343,7 @@ export const refreshDeviceTokens = async (input: { · 3 unmodified lines
295 client_id: input.clientId,343 client_id: input.clientId,
296 },344 },
297 "form",345 "form",
346 input,
298 );347 );
299 const body = await readJson(response);348 const body = await readJson(response);
300 if (!response.ok) {349 if (!response.ok) {
apps/cli/src/main.ts+100−32
@@ -84,10 +84,13 @@ import { · 83 unmodified lines
84 getExecutorServerAuthorizationHeader,84 getExecutorServerAuthorizationHeader,
85 normalizeExecutorServerConnection,85 normalizeExecutorServerConnection,
86 normalizeExecutorServerOrigin,86 normalizeExecutorServerOrigin,
87 resolveExecutorServerConfiguredHeaders,
88 resolveExecutorServerRequestHeaders,
87 type ExecutorLocalServerKind,89 type ExecutorLocalServerKind,
88 type ExecutorLocalServerManifest,90 type ExecutorLocalServerManifest,
89 type ExecutorServerConnection,91 type ExecutorServerConnection,
90 type ExecutorServerConnectionInput,92 type ExecutorServerConnectionInput,
93 type ExecutorServerHeaders,
91} from "@executor-js/sdk/shared";94} from "@executor-js/sdk/shared";
92import {95import {
93 decodeAccessTokenClaims,96 decodeAccessTokenClaims,
@@ -752,7 +755,7 @@ const OAUTH_REFRESH_SKEW_SECONDS = 60; · 658 unmodified lines
752 755
753const refreshOAuthConnection = (756const refreshOAuthConnection = (
754 connection: ExecutorServerConnection,757 connection: ExecutorServerConnection,
755): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> =>758): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
756 Effect.gen(function* () {759 Effect.gen(function* () {
757 const auth = connection.auth;760 const auth = connection.auth;
758 if (!auth || auth.kind !== "oauth") return connection;761 if (!auth || auth.kind !== "oauth") return connection;
@@ -763,8 +766,19 @@ const refreshOAuthConnection = ( · 4 unmodified lines
763 const { refreshToken, tokenEndpoint, clientId } = auth;766 const { refreshToken, tokenEndpoint, clientId } = auth;
764 if (!refreshToken || !tokenEndpoint || !clientId) return connection;767 if (!refreshToken || !tokenEndpoint || !clientId) return connection;
765 768
769 const headers = yield* resolveExecutorServerConfiguredHeaders(connection, process.env).pipe(
770 Effect.mapError(toError),
771 );
772
766 const refreshed = yield* Effect.tryPromise({773 const refreshed = yield* Effect.tryPromise({
767 try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }),774 try: () =>
775 refreshDeviceTokens({
776 tokenEndpoint,
777 clientId,
778 refreshToken,
779 serverOrigin: connection.origin,
780 headers,
781 }),
768 catch: toError,782 catch: toError,
769 // On a failed refresh, keep the existing token and let the eventual 401783 // On a failed refresh, keep the existing token and let the eventual 401
770 // surface, better than blocking the command on a transient hiccup.784 // surface, better than blocking the command on a transient hiccup.
@@ -1054,31 +1068,36 @@ const printExecutionOutcome = (input: { · 283 unmodified lines
1054// Typed API client1068// Typed API client
1055// ---------------------------------------------------------------------------1069// ---------------------------------------------------------------------------
1056 1070
1057const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) => {1071const makeApiClient = (connection: ExecutorServerConnection, target: ServerTarget = {}) =>
1058 const authorization = getExecutorServerAuthorizationHeader(connection);1072 Effect.gen(function* () {
1059 return HttpApiClient.make(ExecutorApi, {1073 const headers = yield* resolveExecutorServerRequestHeaders(connection, process.env).pipe(
1060 baseUrl: connection.apiBaseUrl,1074 Effect.mapError(toError),
1061 ...(authorization1075 );
1062 ? {1076 return yield* HttpApiClient.make(ExecutorApi, {
1063 transformClient: HttpClient.mapRequest((request) =>1077 baseUrl: connection.apiBaseUrl,
1064 HttpClientRequest.setHeader(request, "authorization", authorization),1078 ...(Object.keys(headers).length > 0
1065 ),1079 ? {
1066 }1080 transformClient: HttpClient.mapRequest((request) =>
1067 : {}),1081 HttpClientRequest.setHeaders(request, headers),
1068 // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite1082 ),
1069 // the transport-level error into the login hint. Without this the client1083 }
1070 // fails decoding the unexpected status and prints the opaque1084 : {}),
1071 // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)1085 // A 401 on an endpoint that doesn't model it is a sign-in problem: rewrite
1072 // decode before this catch and pass through untouched.1086 // the transport-level error into the login hint. Without this the client
1073 transformResponse: (effect) =>1087 // fails decoding the unexpected status and prints the opaque
1074 Effect.catchIf(1088 // `Decode error (401 GET .../api/tools)`. Declared 401s (typed API errors)
1075 effect,1089 // decode before this catch and pass through untouched.
1076 (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,1090 transformResponse: (effect) =>
1077 () =>1091 Effect.catchIf(
1078 Effect.fail(new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target }))),1092 effect,
1079 ),1093 (cause) => HttpClientError.isHttpClientError(cause) && cause.response?.status === 401,
1094 () =>
1095 Effect.fail(
1096 new Error(describeUnauthorizedCliServer({ connection, cliPrefix, target })),
1097 ),
1098 ),
1099 });
1080 }).pipe(Effect.provide(FetchHttpClient.layer));1100 }).pipe(Effect.provide(FetchHttpClient.layer));
1081};
1082 1101
1083// ---------------------------------------------------------------------------1102// ---------------------------------------------------------------------------
1084// Foreground session1103// Foreground session
@@ -2177,17 +2196,47 @@ const toolsCommand = Command.make("tools").pipe( · 1092 unmodified lines
2177 Command.withDescription("Discover available tools and integrations"),2196 Command.withDescription("Discover available tools and integrations"),
2178);2197);
2179 2198
2199const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
2200const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2201
2202const parseHeaderEnvOption = (
2203 headerEnv: Option.Option<Record<string, string>>,
2204): ExecutorServerHeaders | undefined => {
2205 const values = Option.getOrUndefined(headerEnv);
2206 if (!values) return undefined;
2207 const headers: Record<string, { readonly kind: "env"; readonly name: string }> = {};
2208 for (const [rawHeaderName, rawEnvName] of Object.entries(values)) {
2209 const headerName = rawHeaderName.trim();
2210 const envName = rawEnvName.trim();
2211 if (!HEADER_NAME_PATTERN.test(headerName)) {
2212 throw new Error(
2213 `Invalid --header-env header name "${rawHeaderName}". Use an HTTP header token like CF-Access-Client-Id.`,
2214 );
2215 }
2216 if (!ENV_NAME_PATTERN.test(envName)) {
2217 throw new Error(
2218 `Invalid --header-env env name "${rawEnvName}". Use an environment variable name like EXECUTOR_CF_ACCESS_CLIENT_ID.`,
2219 );
2220 }
2221 headers[headerName] = { kind: "env", name: envName };
2222 }
2223 return Object.keys(headers).length > 0 ? headers : undefined;
2224};
2225
2180const profileConnectionInput = (input: {2226const profileConnectionInput = (input: {
2181 readonly origin: string;2227 readonly origin: string;
2182 readonly displayName: Option.Option<string>;2228 readonly displayName: Option.Option<string>;
2183 readonly kind: Option.Option<"http" | "desktop-sidecar">;2229 readonly kind: Option.Option<"http" | "desktop-sidecar">;
2230 readonly headerEnv: Option.Option<Record<string, string>>;
2184}): ExecutorServerConnectionInput => {2231}): ExecutorServerConnectionInput => {
2185 const selectedKind = Option.getOrUndefined(input.kind);2232 const selectedKind = Option.getOrUndefined(input.kind);
2186 const displayName = Option.getOrUndefined(input.displayName);2233 const displayName = Option.getOrUndefined(input.displayName);
2234 const headers = parseHeaderEnvOption(input.headerEnv);
2187 return {2235 return {
2188 kind: selectedKind ?? "http",2236 kind: selectedKind ?? "http",
2189 origin: input.origin,2237 origin: input.origin,
2190 ...(displayName ? { displayName } : {}),2238 ...(displayName ? { displayName } : {}),
2239 ...(headers ? { headers } : {}),
2191 };2240 };
2192};2241};
2193 2242
@@ -2207,13 +2256,16 @@ const printServerProfiles = () => · 13 unmodified lines
2207 origin: profile.connection.origin,2256 origin: profile.connection.origin,
2208 displayName: profile.connection.displayName,2257 displayName: profile.connection.displayName,
2209 auth: profile.connection.auth ? "stored-auth" : "env-auth",2258 auth: profile.connection.auth ? "stored-auth" : "env-auth",
2259 headers: profile.connection.headers
2260 ? `${Object.keys(profile.connection.headers).length} header-env`
2261 : "no-headers",
2210 }));2262 }));
2211 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);2263 const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);
2212 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);2264 const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);
2213 2265
2214 for (const row of rows) {2266 for (const row of rows) {
2215 console.log(2267 console.log(
2216 `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`,2268 `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth} ${row.headers}`,
2217 );2269 );
2218 }2270 }
2219 });2271 });
@@ -2231,17 +2283,23 @@ const serverAddCommand = Command.make( · 11 unmodified lines
2231 Options.optional,2283 Options.optional,
2232 Options.withDescription("Server kind. Defaults to http."),2284 Options.withDescription("Server kind. Defaults to http."),
2233 ),2285 ),
2286 headerEnv: Options.keyValuePair("header-env").pipe(
2287 Options.optional,
2288 Options.withDescription(
2289 "HTTP header mapping header-name=ENV_VAR. Repeat for Cloudflare Access service tokens.",
2290 ),
2291 ),
2234 makeDefault: Options.boolean("default").pipe(2292 makeDefault: Options.boolean("default").pipe(
2235 Options.withDefault(false),2293 Options.withDefault(false),
2236 Options.withDescription("Make this profile the default server."),2294 Options.withDescription("Make this profile the default server."),
2237 ),2295 ),
2238 },2296 },
2239 ({ name, origin, displayName, kind, makeDefault }) =>2297 ({ name, origin, displayName, kind, headerEnv, makeDefault }) =>
2240 Effect.gen(function* () {2298 Effect.gen(function* () {
2241 const profileName = validateCliServerConnectionProfileName(name);2299 const profileName = validateCliServerConnectionProfileName(name);
2242 const store = yield* upsertCliServerConnectionProfile({2300 const store = yield* upsertCliServerConnectionProfile({
2243 name: profileName,2301 name: profileName,
2244 connection: profileConnectionInput({ origin, displayName, kind }),2302 connection: profileConnectionInput({ origin, displayName, kind, headerEnv }),
2245 makeDefault,2303 makeDefault,
2246 });2304 });
2247 const profile = findCliServerConnectionProfile(store, profileName);2305 const profile = findCliServerConnectionProfile(store, profileName);
@@ -2440,12 +2498,18 @@ const loginCommand = Command.make( · 192 unmodified lines
2440 // The target may have been picked implicitly (default profile, or the2498 // The target may have been picked implicitly (default profile, or the
2441 // hosted fallback): say where the login is going before the device flow.2499 // hosted fallback): say where the login is going before the device flow.
2442 console.log(`Signing in to ${target.origin}`);2500 console.log(`Signing in to ${target.origin}`);
2501 const targetProfile = target.profile;
2502 const headers = targetProfile
2503 ? yield* resolveExecutorServerConfiguredHeaders(targetProfile.connection, process.env).pipe(
2504 Effect.mapError(toError),
2505 )
2506 : {};
2443 const discovery = yield* Effect.tryPromise({2507 const discovery = yield* Effect.tryPromise({
2444 try: () => discoverCliLogin(target.origin),2508 try: () => discoverCliLogin(target.origin, { headers }),
2445 catch: toError,2509 catch: toError,
2446 });2510 });
2447 const grant = yield* Effect.tryPromise({2511 const grant = yield* Effect.tryPromise({
2448 try: () => requestDeviceCode(discovery),2512 try: () => requestDeviceCode(discovery, { serverOrigin: target.origin, headers }),
2449 catch: toError,2513 catch: toError,
2450 });2514 });
2451 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;2515 const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri;
@@ -2456,7 +2520,7 @@ const loginCommand = Command.make( · 4 unmodified lines
2456 if (!noBrowser) openBrowser(verifyUrl);2520 if (!noBrowser) openBrowser(verifyUrl);
2457 console.log("Waiting for you to approve in the browser...");2521 console.log("Waiting for you to approve in the browser...");
2458 const tokens = yield* Effect.tryPromise({2522 const tokens = yield* Effect.tryPromise({
2459 try: () => pollForDeviceTokens(discovery, grant),2523 try: () => pollForDeviceTokens(discovery, grant, { serverOrigin: target.origin, headers }),
2460 catch: toError,2524 catch: toError,
2461 });2525 });
2462 2526
@@ -2481,6 +2545,9 @@ const loginCommand = Command.make( · 18 unmodified lines
2481 kind: "http",2545 kind: "http",
2482 origin: target.origin,2546 origin: target.origin,
2483 ...(email ? { displayName: email } : {}),2547 ...(email ? { displayName: email } : {}),
2548 ...(target.profile?.connection.headers
2549 ? { headers: target.profile.connection.headers }
2550 : {}),
2484 auth: {2551 auth: {
2485 kind: "oauth",2552 kind: "oauth",
2486 accessToken: tokens.accessToken,2553 accessToken: tokens.accessToken,
@@ -2543,6 +2610,7 @@ const logoutCommand = Command.make( · 56 unmodified lines
2543 kind: profile.connection.kind,2610 kind: profile.connection.kind,
2544 origin: profile.connection.origin,2611 origin: profile.connection.origin,
2545 displayName: profile.connection.displayName,2612 displayName: profile.connection.displayName,
2613 ...(profile.connection.headers ? { headers: profile.connection.headers } : {}),
2546 },2614 },
2547 makeDefault: store.defaultProfile === profile.name,2615 makeDefault: store.defaultProfile === profile.name,
2548 });2616 });
apps/cli/src/server-profile.test.ts+15−0
@@ -36,6 +36,9 @@ describe("CLI server connection profiles", () => { · 35 unmodified lines
36 connection: {36 connection: {
37 origin: "https://executor.example/api",37 origin: "https://executor.example/api",
38 auth: { kind: "bearer", token: "key_123" },38 auth: { kind: "bearer", token: "key_123" },
39 headers: {
40 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
41 },
39 },42 },
40 makeDefault: true,43 makeDefault: true,
41 });44 });
@@ -50,6 +53,9 @@ describe("CLI server connection profiles", () => { · 8 unmodified lines
50 kind: "bearer",53 kind: "bearer",
51 token: "key_123",54 token: "key_123",
52 });55 });
56 expect(store.profiles[0]?.connection.headers).toEqual({
57 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
58 });
53 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");59 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
54 } finally {60 } finally {
55 rmSync(dataDir, { recursive: true, force: true });61 rmSync(dataDir, { recursive: true, force: true });
@@ -114,6 +120,12 @@ describe("CLI server connection profiles", () => { · 58 unmodified lines
114 key: "desktop-sidecar",120 key: "desktop-sidecar",
115 origin: "http://127.0.0.1:4789",121 origin: "http://127.0.0.1:4789",
116 auth: { kind: "basic", username: "executor", password: "secret" },122 auth: { kind: "basic", username: "executor", password: "secret" },
123 headers: {
124 "CF-Access-Client-Id": {
125 kind: "env",
126 name: "EXECUTOR_CF_ACCESS_CLIENT_ID",
127 },
128 },
117 },129 },
118 },130 },
119 ],131 ],
@@ -127,5 +139,8 @@ describe("CLI server connection profiles", () => { · 7 unmodified lines
127 username: "executor",139 username: "executor",
128 password: "secret",140 password: "secret",
129 });141 });
142 expect(store.profiles[0]?.connection.headers).toEqual({
143 "CF-Access-Client-Id": { kind: "env", name: "EXECUTOR_CF_ACCESS_CLIENT_ID" },
144 });
130 });145 });
131});146});
apps/cli/src/server-profile.ts+6−0
@@ -62,6 +62,11 @@ const PersistedAuth = Schema.Union([ · 61 unmodified lines
62 }),62 }),
63]);63]);
64 64
65const PersistedHeader = Schema.Struct({
66 kind: Schema.Literal("env"),
67 name: Schema.String,
68});
69
65const PersistedConnection = Schema.Struct({70const PersistedConnection = Schema.Struct({
66 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),71 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
67 key: Schema.optional(Schema.String),72 key: Schema.optional(Schema.String),
@@ -69,6 +74,7 @@ const PersistedConnection = Schema.Struct({ · 1 unmodified line
69 apiBaseUrl: Schema.optional(Schema.String),74 apiBaseUrl: Schema.optional(Schema.String),
70 displayName: Schema.optional(Schema.String),75 displayName: Schema.optional(Schema.String),
71 auth: Schema.optional(PersistedAuth),76 auth: Schema.optional(PersistedAuth),
77 headers: Schema.optional(Schema.Record(Schema.String, PersistedHeader)),
72});78});
73 79
74const PersistedProfile = Schema.Struct({80const PersistedProfile = Schema.Struct({

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t