@openhoo/hoopilot 0.3.0 → 0.4.0

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.
package/dist/cli.js CHANGED
@@ -1,254 +1,324 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ // src/cli.ts
4
+ import { spawn } from "child_process";
5
+
6
+ // src/auth-store.ts
7
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { dirname, join } from "path";
9
+ function authStorePath(env = process.env) {
10
+ if (env.HOOPILOT_AUTH_FILE) {
11
+ return env.HOOPILOT_AUTH_FILE;
12
+ }
13
+ const base = env.XDG_CONFIG_HOME ?? env.APPDATA ?? (env.HOME ? join(env.HOME, ".config") : join(process.cwd(), ".config"));
14
+ return join(base, "hoopilot", "auth.json");
15
+ }
16
+ function readStoredCopilotAuth(path = authStorePath()) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
19
+ if (!parsed || typeof parsed !== "object") {
20
+ return void 0;
21
+ }
22
+ const token = typeof parsed.token === "string" ? parsed.token.trim() : "";
23
+ if (!token) {
24
+ return void 0;
25
+ }
26
+ return {
27
+ apiBaseUrl: typeof parsed.apiBaseUrl === "string" ? parsed.apiBaseUrl : void 0,
28
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : void 0,
29
+ githubDomain: typeof parsed.githubDomain === "string" ? parsed.githubDomain : void 0,
30
+ source: typeof parsed.source === "string" ? parsed.source : void 0,
31
+ token
32
+ };
33
+ } catch {
34
+ return void 0;
35
+ }
36
+ }
37
+ function writeStoredCopilotAuth(auth, path = authStorePath()) {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ writeFileSync(
40
+ path,
41
+ `${JSON.stringify(
42
+ {
43
+ ...auth,
44
+ createdAt: auth.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
45
+ },
46
+ null,
47
+ 2
48
+ )}
49
+ `,
50
+ { mode: 384 }
51
+ );
52
+ try {
53
+ chmodSync(path, 384);
54
+ } catch {
55
+ }
56
+ }
57
+
3
58
  // src/auth.ts
4
- import { execFileSync } from "child_process";
5
- var DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
6
- var DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token";
59
+ var DEFAULT_COPILOT_API_BASE_URL = "https://api.githubcopilot.com";
7
60
  var REFRESH_SKEW_MS = 6e4;
61
+ var STORED_TOKEN_TTL_MS = 10 * 6e4;
8
62
  var CopilotAuthError = class extends Error {
9
63
  constructor(message) {
10
64
  super(message);
11
65
  this.name = "CopilotAuthError";
12
66
  }
13
67
  };
14
- var CopilotTokenExchangeHttpError = class extends CopilotAuthError {
15
- };
16
68
  var CopilotAuth = class {
17
- #authMode;
69
+ #authStorePath;
18
70
  #copilotApiBaseUrl;
19
- #copilotToken;
20
- #env;
21
- #fetch;
22
- #githubToken;
23
- #githubTokenCommand;
24
- #logger;
25
- #tokenExchangeUrl;
26
71
  #cachedAccess;
27
72
  constructor(options = {}) {
28
- this.#authMode = options.authMode ?? "auto";
73
+ this.#authStorePath = options.authStorePath ?? options.env?.HOOPILOT_AUTH_FILE;
29
74
  this.#copilotApiBaseUrl = trimTrailingSlash(
30
75
  options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL
31
76
  );
32
- this.#copilotToken = options.copilotToken;
33
- this.#env = options.env ?? process.env;
34
- this.#fetch = options.fetch ?? fetch;
35
- this.#githubToken = options.githubToken;
36
- this.#githubTokenCommand = options.githubTokenCommand ?? "gh auth token";
37
- this.#logger = options.logger;
38
- this.#tokenExchangeUrl = options.tokenExchangeUrl ?? options.env?.COPILOT_TOKEN_EXCHANGE_URL ?? DEFAULT_TOKEN_EXCHANGE_URL;
39
77
  }
40
78
  async getAccess() {
41
79
  if (this.#cachedAccess && this.#cachedAccess.expiresAtMs - REFRESH_SKEW_MS > Date.now()) {
42
80
  return this.#cachedAccess;
43
81
  }
44
- const directCopilotToken = this.#resolveDirectCopilotToken();
45
- if (directCopilotToken) {
82
+ const stored = readStoredCopilotAuth(this.#authStorePath);
83
+ if (stored) {
46
84
  return this.#cacheAccess({
47
- apiBaseUrl: this.#copilotApiBaseUrl,
48
- expiresAtMs: Date.now() + 10 * 6e4,
49
- source: "copilot-token",
50
- token: directCopilotToken
51
- });
52
- }
53
- if (this.#authMode === "copilot-token") {
54
- throw new CopilotAuthError("COPILOT_API_TOKEN or GITHUB_COPILOT_API_TOKEN is required.");
55
- }
56
- const githubToken = this.#resolveGithubToken();
57
- if (!githubToken) {
58
- throw new CopilotAuthError(
59
- "No Copilot credential found. Set COPILOT_API_TOKEN, set COPILOT_GITHUB_TOKEN from gh auth token, or sign in with gh auth login."
60
- );
61
- }
62
- if (isPersonalAccessToken(githubToken)) {
63
- throw new CopilotAuthError(
64
- "GitHub personal access tokens are not supported for Copilot authentication. Use gh auth login or COPILOT_API_TOKEN."
65
- );
66
- }
67
- try {
68
- return this.#cacheAccess(await this.#exchangeGithubToken(githubToken));
69
- } catch (error) {
70
- if (!(error instanceof CopilotTokenExchangeHttpError)) {
71
- throw error;
72
- }
73
- this.#logger?.warn(
74
- `Copilot token exchange failed; falling back to GitHub CLI token mode: ${errorMessage(
75
- error
76
- )}`
77
- );
78
- return this.#cacheAccess({
79
- apiBaseUrl: this.#copilotApiBaseUrl,
80
- expiresAtMs: Date.now() + 10 * 6e4,
81
- source: "direct-github-token",
82
- token: githubToken
85
+ apiBaseUrl: trimTrailingSlash(stored.apiBaseUrl ?? this.#copilotApiBaseUrl),
86
+ expiresAtMs: Date.now() + STORED_TOKEN_TTL_MS,
87
+ source: "github-copilot-oauth",
88
+ token: stored.token
83
89
  });
84
90
  }
91
+ throw new CopilotAuthError(
92
+ "No GitHub Copilot OAuth credential found. Run `hoopilot login` to sign in through your browser."
93
+ );
85
94
  }
86
95
  #cacheAccess(access) {
87
96
  this.#cachedAccess = access;
88
97
  return access;
89
98
  }
90
- async #exchangeGithubToken(githubToken) {
91
- const response = await this.#fetch(this.#tokenExchangeUrl, {
92
- headers: {
93
- accept: "application/vnd.github+json",
94
- authorization: `token ${githubToken}`,
95
- "editor-plugin-version": "hoopilot/0.1.0",
96
- "editor-version": "Hoopilot/0.1.0",
97
- "user-agent": "hoopilot/0.1.0"
98
- },
99
- method: "GET"
99
+ };
100
+ function trimTrailingSlash(value) {
101
+ return value.replace(/\/+$/, "");
102
+ }
103
+
104
+ // src/github-device.ts
105
+ import { setTimeout as sleep } from "timers/promises";
106
+ var DEFAULT_GITHUB_COPILOT_CLIENT_ID = "Iv23lijnNxm2e9UX3CF8";
107
+ var DEFAULT_GITHUB_DOMAIN = "github.com";
108
+ var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
109
+ var POLLING_SAFETY_MARGIN_MS = 3e3;
110
+ async function githubCopilotDeviceLogin(options = {}) {
111
+ const env = options.env ?? process.env;
112
+ const fetcher = options.fetch ?? fetch;
113
+ const sleeper = options.sleep ?? sleep;
114
+ const domain = normalizeDomain(
115
+ options.domain ?? env.HOOPILOT_GITHUB_DOMAIN ?? DEFAULT_GITHUB_DOMAIN
116
+ );
117
+ const clientId = options.clientId ?? env.HOOPILOT_GITHUB_CLIENT_ID ?? env.COPILOT_GITHUB_CLIENT_ID ?? DEFAULT_GITHUB_COPILOT_CLIENT_ID;
118
+ const device = await requestDeviceCode(fetcher, domain, clientId);
119
+ const verificationUrl = device.verification_uri;
120
+ const userCode = device.user_code;
121
+ const deviceCode = device.device_code;
122
+ if (!verificationUrl || !userCode || !deviceCode) {
123
+ throw new Error("GitHub device authorization response is missing required fields.");
124
+ }
125
+ options.logger?.info(`First copy your one-time code: ${userCode}`);
126
+ options.logger?.info(`Open ${verificationUrl} in your browser to authorize Hoopilot.`);
127
+ await options.openBrowser?.(verificationUrl);
128
+ return {
129
+ domain,
130
+ token: await pollForAccessToken(fetcher, sleeper, domain, clientId, {
131
+ deviceCode,
132
+ expiresIn: positiveSeconds(device.expires_in, 900),
133
+ interval: positiveSeconds(device.interval, 5)
134
+ })
135
+ };
136
+ }
137
+ async function requestDeviceCode(fetcher, domain, clientId) {
138
+ const response = await fetcher(`https://${domain}/login/device/code`, {
139
+ body: JSON.stringify({
140
+ client_id: clientId,
141
+ scope: "read:user"
142
+ }),
143
+ headers: oauthHeaders(),
144
+ method: "POST"
145
+ });
146
+ if (!response.ok) {
147
+ throw new Error(
148
+ `GitHub device authorization failed with ${response.status}: ${await safeResponseText(
149
+ response
150
+ )}`
151
+ );
152
+ }
153
+ return await response.json();
154
+ }
155
+ async function pollForAccessToken(fetcher, sleeper, domain, clientId, device) {
156
+ let intervalMs = device.interval * 1e3 + POLLING_SAFETY_MARGIN_MS;
157
+ const deadline = Date.now() + device.expiresIn * 1e3;
158
+ while (Date.now() < deadline) {
159
+ await sleeper(intervalMs);
160
+ const response = await fetcher(`https://${domain}/login/oauth/access_token`, {
161
+ body: JSON.stringify({
162
+ client_id: clientId,
163
+ device_code: device.deviceCode,
164
+ grant_type: DEVICE_GRANT_TYPE
165
+ }),
166
+ headers: oauthHeaders(),
167
+ method: "POST"
100
168
  });
101
169
  if (!response.ok) {
102
- throw new CopilotTokenExchangeHttpError(
103
- `GitHub Copilot token exchange failed with ${response.status}: ${await safeResponseText(
170
+ throw new Error(
171
+ `GitHub device token exchange failed with ${response.status}: ${await safeResponseText(
104
172
  response
105
173
  )}`
106
174
  );
107
175
  }
108
- const body = asRecord(await response.json());
109
- const token = getString(body, "token");
110
- if (!token) {
111
- throw new CopilotAuthError("GitHub Copilot token exchange response did not include a token.");
112
- }
113
- return {
114
- apiBaseUrl: endpointFromResponse(body) ?? this.#copilotApiBaseUrl,
115
- expiresAtMs: expiresAtFromResponse(body),
116
- source: "github-token",
117
- token
118
- };
119
- }
120
- #resolveDirectCopilotToken() {
121
- return firstNonEmpty(
122
- this.#copilotToken,
123
- this.#env.COPILOT_API_TOKEN,
124
- this.#env.GITHUB_COPILOT_API_TOKEN,
125
- this.#env.GITHUB_COPILOT_TOKEN
126
- );
127
- }
128
- #resolveGithubToken() {
129
- return firstNonEmpty(
130
- this.#githubToken,
131
- this.#env.COPILOT_GITHUB_TOKEN,
132
- this.#env.GITHUB_COPILOT_GITHUB_TOKEN,
133
- this.#readGithubTokenCommand()
134
- );
135
- }
136
- #readGithubTokenCommand() {
137
- if (this.#githubTokenCommand === false) {
138
- return void 0;
139
- }
140
- const parts = splitCommand(this.#githubTokenCommand);
141
- const [command, ...args] = parts;
142
- if (!command) {
143
- return void 0;
144
- }
145
- try {
146
- const output = execFileSync(command, args, {
147
- encoding: "utf8",
148
- stdio: ["ignore", "pipe", "ignore"],
149
- timeout: 5e3
150
- });
151
- return output.trim() || void 0;
152
- } catch {
153
- return void 0;
176
+ const data = await response.json();
177
+ if (data.access_token) {
178
+ return data.access_token;
154
179
  }
155
- }
156
- };
157
- function splitCommand(command) {
158
- const parts = [];
159
- let current = "";
160
- let quote;
161
- let escaping = false;
162
- for (const character of command.trim()) {
163
- if (escaping) {
164
- current += character;
165
- escaping = false;
180
+ if (data.error === "authorization_pending") {
166
181
  continue;
167
182
  }
168
- if (character === "\\") {
169
- escaping = true;
183
+ if (data.error === "slow_down") {
184
+ intervalMs = positiveSeconds(data.interval, device.interval + 5) * 1e3 + POLLING_SAFETY_MARGIN_MS;
170
185
  continue;
171
186
  }
172
- if (quote) {
173
- if (character === quote) {
174
- quote = void 0;
175
- } else {
176
- current += character;
177
- }
178
- continue;
187
+ if (data.error === "expired_token") {
188
+ throw new Error("GitHub device login expired. Run `hoopilot login` again.");
179
189
  }
180
- if (character === "'" || character === '"') {
181
- quote = character;
182
- continue;
190
+ if (data.error === "access_denied") {
191
+ throw new Error("GitHub device login was cancelled.");
183
192
  }
184
- if (/\s/.test(character)) {
185
- if (current) {
186
- parts.push(current);
187
- current = "";
188
- }
189
- continue;
193
+ if (data.error) {
194
+ throw new Error(data.error_description || `GitHub device login failed: ${data.error}`);
190
195
  }
191
- current += character;
192
196
  }
193
- if (current) {
194
- parts.push(current);
195
- }
196
- return parts;
197
+ throw new Error("GitHub device login timed out. Run `hoopilot login` again.");
197
198
  }
198
- function endpointFromResponse(body) {
199
- const endpoints = asRecord(body.endpoints);
200
- const apiUrl = getString(endpoints, "api") ?? getString(endpoints, "proxy");
201
- return apiUrl ? trimTrailingSlash(apiUrl) : void 0;
199
+ function oauthHeaders() {
200
+ const headers = new Headers();
201
+ headers.set("accept", "application/json");
202
+ headers.set("content-type", "application/json");
203
+ headers.set("user-agent", "hoopilot");
204
+ return headers;
202
205
  }
203
- function expiresAtFromResponse(body) {
204
- const expiresAt = body.expires_at;
205
- if (typeof expiresAt === "number") {
206
- return expiresAt < 1e10 ? expiresAt * 1e3 : expiresAt;
206
+ function normalizeDomain(value) {
207
+ return value.replace(/^https?:\/\//, "").replace(/\/+$/, "");
208
+ }
209
+ function positiveSeconds(value, fallback) {
210
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
211
+ }
212
+ async function safeResponseText(response) {
213
+ const text = await response.text();
214
+ return text.slice(0, 500);
215
+ }
216
+
217
+ // src/logger.ts
218
+ import pino from "pino";
219
+ import pretty from "pino-pretty";
220
+ var DEFAULT_LOG_FORMAT = "pretty";
221
+ var DEFAULT_LOG_LEVEL = "info";
222
+ var LOG_FORMATS = ["json", "pretty"];
223
+ var LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal", "silent"];
224
+ var REDACT_PATHS = [
225
+ "apiKey",
226
+ "authorization",
227
+ "cookie",
228
+ "headers.authorization",
229
+ "headers.Authorization",
230
+ "headers.cookie",
231
+ "headers.Cookie",
232
+ "headers.x-api-key",
233
+ "headers.X-Api-Key",
234
+ "token",
235
+ "*.apiKey",
236
+ "*.authorization",
237
+ "*.cookie",
238
+ "*.token",
239
+ "*.headers.authorization",
240
+ "*.headers.Authorization",
241
+ "*.headers.cookie",
242
+ "*.headers.Cookie",
243
+ "*.headers.x-api-key",
244
+ "*.headers.X-Api-Key"
245
+ ];
246
+ var noopLogger = {
247
+ child: () => noopLogger,
248
+ debug: () => {
249
+ },
250
+ error: () => {
251
+ },
252
+ fatal: () => {
253
+ },
254
+ info: () => {
255
+ },
256
+ trace: () => {
257
+ },
258
+ warn: () => {
207
259
  }
208
- if (typeof expiresAt === "string") {
209
- const asNumber = Number(expiresAt);
210
- if (Number.isFinite(asNumber)) {
211
- return asNumber < 1e10 ? asNumber * 1e3 : asNumber;
212
- }
213
- const parsed = Date.parse(expiresAt);
214
- if (Number.isFinite(parsed)) {
215
- return parsed;
216
- }
260
+ };
261
+ function createHoopilotLogger(options = {}) {
262
+ const env = options.env ?? process.env;
263
+ const level = parseLogLevel(options.level ?? env.HOOPILOT_LOG_LEVEL);
264
+ const format = parseLogFormat(options.format ?? env.HOOPILOT_LOG_FORMAT);
265
+ const pinoOptions = {
266
+ base: {
267
+ service: "hoopilot",
268
+ ...options.base
269
+ },
270
+ level,
271
+ redact: {
272
+ censor: "[Redacted]",
273
+ paths: REDACT_PATHS
274
+ },
275
+ timestamp: pino.stdTimeFunctions.isoTime
276
+ };
277
+ if (format === "pretty") {
278
+ return pino(
279
+ pinoOptions,
280
+ pretty({
281
+ colorize: options.colorize ?? process.stderr.isTTY,
282
+ destination: options.stream ?? 1,
283
+ ignore: "pid,hostname",
284
+ singleLine: true,
285
+ translateTime: "SYS:standard"
286
+ })
287
+ );
217
288
  }
218
- const refreshIn = body.refresh_in;
219
- if (typeof refreshIn === "number" && Number.isFinite(refreshIn)) {
220
- return Date.now() + refreshIn * 1e3;
289
+ if (options.stream) {
290
+ return pino(pinoOptions, options.stream);
221
291
  }
222
- return Date.now() + 10 * 6e4;
292
+ return pino(pinoOptions);
223
293
  }
224
- function firstNonEmpty(...values) {
225
- for (const value of values) {
226
- const trimmed = value?.trim();
227
- if (trimmed) {
228
- return trimmed;
229
- }
294
+ function parseLogFormat(value) {
295
+ if (!value) {
296
+ return DEFAULT_LOG_FORMAT;
230
297
  }
231
- return void 0;
232
- }
233
- function asRecord(value) {
234
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
235
- }
236
- function getString(record, key) {
237
- const value = record[key];
238
- return typeof value === "string" && value ? value : void 0;
298
+ if (isLogFormat(value)) {
299
+ return value;
300
+ }
301
+ throw new Error(`Invalid log format: ${value}. Expected one of: ${LOG_FORMATS.join(", ")}.`);
239
302
  }
240
- function trimTrailingSlash(value) {
241
- return value.replace(/\/+$/, "");
303
+ function parseLogLevel(value) {
304
+ if (!value) {
305
+ return DEFAULT_LOG_LEVEL;
306
+ }
307
+ if (isLogLevel(value)) {
308
+ return value;
309
+ }
310
+ throw new Error(`Invalid log level: ${value}. Expected one of: ${LOG_LEVELS.join(", ")}.`);
242
311
  }
243
- async function safeResponseText(response) {
244
- const text = await response.text();
245
- return text.slice(0, 500);
312
+ function shouldCreateLogger(options) {
313
+ return Boolean(
314
+ options.logger || options.logFormat || options.logLevel || options.env?.HOOPILOT_LOG_FORMAT || options.env?.HOOPILOT_LOG_LEVEL
315
+ );
246
316
  }
247
- function isPersonalAccessToken(token) {
248
- return token.startsWith("github_pat_") || token.startsWith("ghp_");
317
+ function isLogFormat(value) {
318
+ return LOG_FORMATS.includes(value);
249
319
  }
250
- function errorMessage(error) {
251
- return error instanceof Error ? error.message : String(error);
320
+ function isLogLevel(value) {
321
+ return LOG_LEVELS.includes(value);
252
322
  }
253
323
 
254
324
  // src/copilot.ts
@@ -298,6 +368,7 @@ var CopilotClient = class {
298
368
  headers.set("editor-version", "Hoopilot/0.1.0");
299
369
  headers.set("openai-intent", "conversation-panel");
300
370
  headers.set("user-agent", "hoopilot/0.1.0");
371
+ headers.set("x-github-api-version", "2026-06-01");
301
372
  return this.#fetch(`${access.apiBaseUrl}${path}`, {
302
373
  ...init,
303
374
  headers
@@ -323,8 +394,8 @@ function responsesRequestToChatCompletion(request) {
323
394
  metadata: request.metadata,
324
395
  model: contentToText(request.model) || DEFAULT_MODEL,
325
396
  presence_penalty: request.presence_penalty,
326
- reasoning_effort: asRecord2(request.reasoning).effort,
327
- response_format: asRecord2(request.text).format,
397
+ reasoning_effort: asRecord(request.reasoning).effort,
398
+ response_format: asRecord(request.text).format,
328
399
  seed: request.seed,
329
400
  stream: request.stream === true,
330
401
  temperature: request.temperature,
@@ -346,7 +417,7 @@ function completionsRequestToChatCompletion(request) {
346
417
  function chatCompletionToResponse(completion, responseId) {
347
418
  const id = responseId ?? `resp_${randomId()}`;
348
419
  const choice = firstChoice(completion);
349
- const message = asRecord2(choice.message);
420
+ const message = asRecord(choice.message);
350
421
  const model = contentToText(completion.model) || DEFAULT_MODEL;
351
422
  const output = outputItemsFromMessage(message);
352
423
  const usage = responseUsage(completion.usage);
@@ -373,7 +444,7 @@ function chatCompletionToResponse(completion, responseId) {
373
444
  }
374
445
  function chatCompletionToCompletion(completion) {
375
446
  const choice = firstChoice(completion);
376
- const message = asRecord2(choice.message);
447
+ const message = asRecord(choice.message);
377
448
  return removeUndefined({
378
449
  choices: [
379
450
  {
@@ -391,9 +462,9 @@ function chatCompletionToCompletion(completion) {
391
462
  });
392
463
  }
393
464
  function normalizeModelsResponse(upstream) {
394
- const record = asRecord2(upstream);
465
+ const record = asRecord(upstream);
395
466
  const data = Array.isArray(record.data) ? record.data : Array.isArray(upstream) ? upstream : [];
396
- const models = data.map((model) => asRecord2(model)).filter((model) => typeof model.id === "string").map((model) => ({
467
+ const models = data.map((model) => asRecord(model)).filter((model) => typeof model.id === "string").map((model) => ({
397
468
  created: model.created ?? 0,
398
469
  id: model.id,
399
470
  object: "model",
@@ -542,7 +613,7 @@ function inputToMessages(input) {
542
613
  }
543
614
  const messages = [];
544
615
  for (const item of input) {
545
- const record = asRecord2(item);
616
+ const record = asRecord(item);
546
617
  if (record.type === "function_call_output") {
547
618
  messages.push({
548
619
  content: contentToText(record.output),
@@ -584,7 +655,7 @@ function chatMessageContent(content) {
584
655
  }
585
656
  const parts = [];
586
657
  for (const part of content) {
587
- const record = asRecord2(part);
658
+ const record = asRecord(part);
588
659
  const type = contentToText(record.type);
589
660
  if (type === "input_text" || type === "output_text" || type === "text") {
590
661
  parts.push({ text: contentToText(record.text), type: "text" });
@@ -642,7 +713,7 @@ function chatTools(tools) {
642
713
  if (!Array.isArray(tools)) {
643
714
  return void 0;
644
715
  }
645
- const converted = tools.map((tool) => asRecord2(tool)).filter((tool) => tool.type === "function").map((tool) => ({
716
+ const converted = tools.map((tool) => asRecord(tool)).filter((tool) => tool.type === "function").map((tool) => ({
646
717
  function: removeUndefined({
647
718
  description: tool.description,
648
719
  name: tool.name,
@@ -657,7 +728,7 @@ function chatToolChoice(toolChoice) {
657
728
  if (typeof toolChoice === "string" || toolChoice === void 0) {
658
729
  return toolChoice;
659
730
  }
660
- const record = asRecord2(toolChoice);
731
+ const record = asRecord(toolChoice);
661
732
  if (record.type === "function" && typeof record.name === "string") {
662
733
  return { function: { name: record.name }, type: "function" };
663
734
  }
@@ -671,8 +742,8 @@ function outputItemsFromMessage(message) {
671
742
  }
672
743
  const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
673
744
  for (const toolCall of toolCalls) {
674
- const record = asRecord2(toolCall);
675
- const fn = asRecord2(record.function);
745
+ const record = asRecord(toolCall);
746
+ const fn = asRecord(record.function);
676
747
  output.push(
677
748
  functionCallItem({
678
749
  arguments: contentToText(fn.arguments),
@@ -713,10 +784,10 @@ function outputText(output) {
713
784
  return output.flatMap((item) => {
714
785
  const content = item.content;
715
786
  return Array.isArray(content) ? content : [];
716
- }).map((part) => contentToText(asRecord2(part).text)).filter(Boolean).join("");
787
+ }).map((part) => contentToText(asRecord(part).text)).filter(Boolean).join("");
717
788
  }
718
789
  function responseUsage(usage) {
719
- const record = asRecord2(usage);
790
+ const record = asRecord(usage);
720
791
  if (Object.keys(record).length === 0) {
721
792
  return null;
722
793
  }
@@ -730,7 +801,7 @@ function responseUsage(usage) {
730
801
  }
731
802
  function firstChoice(completion) {
732
803
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
733
- return asRecord2(choices[0]);
804
+ return asRecord(choices[0]);
734
805
  }
735
806
  function processChatSseLine(line, enqueue, tools, appendText) {
736
807
  const trimmed = line.trim();
@@ -746,7 +817,7 @@ function processChatSseLine(line, enqueue, tools, appendText) {
746
817
  return;
747
818
  }
748
819
  const choice = firstChoice(parsed);
749
- const delta = asRecord2(choice.delta);
820
+ const delta = asRecord(choice.delta);
750
821
  const content = contentToText(delta.content);
751
822
  if (content) {
752
823
  appendText(content);
@@ -760,8 +831,8 @@ function processChatSseLine(line, enqueue, tools, appendText) {
760
831
  }
761
832
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
762
833
  for (const toolCall of toolCalls) {
763
- const record = asRecord2(toolCall);
764
- const fn = asRecord2(record.function);
834
+ const record = asRecord(toolCall);
835
+ const fn = asRecord(record.function);
765
836
  const index = typeof record.index === "number" ? record.index : tools.size;
766
837
  const existing = tools.get(index) ?? {
767
838
  arguments: "",
@@ -809,7 +880,7 @@ data: ${JSON.stringify(data)}
809
880
  }
810
881
  function parseJson(data) {
811
882
  try {
812
- return asRecord2(JSON.parse(data));
883
+ return asRecord(JSON.parse(data));
813
884
  } catch {
814
885
  return void 0;
815
886
  }
@@ -817,7 +888,7 @@ function parseJson(data) {
817
888
  function removeUndefined(record) {
818
889
  return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
819
890
  }
820
- function asRecord2(value) {
891
+ function asRecord(value) {
821
892
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
822
893
  }
823
894
  function randomId() {
@@ -830,43 +901,111 @@ function epochSeconds() {
830
901
  // src/server.ts
831
902
  var DEFAULT_HOST = "127.0.0.1";
832
903
  var DEFAULT_PORT = 4141;
904
+ var INVALID_JSON_MESSAGE = "Request body must be valid JSON.";
833
905
  function createHoopilotHandler(options = {}) {
834
906
  const client = new CopilotClient(options);
835
907
  const apiKey = options.apiKey ?? options.env?.HOOPILOT_API_KEY;
908
+ const logger = serverLogger(options);
836
909
  return async (request) => {
910
+ const startedAt = performance.now();
837
911
  const url = new URL(request.url);
912
+ const requestId = requestIdFor(request);
913
+ const requestLogger = logger.child({
914
+ method: request.method,
915
+ path: url.pathname,
916
+ requestId,
917
+ route: routeFor(request.method, url.pathname)
918
+ });
838
919
  if (request.method === "OPTIONS") {
839
- return new Response(null, { headers: corsHeaders() });
920
+ return finishResponse(new Response(null, { headers: corsHeaders() }), {
921
+ logger: requestLogger,
922
+ requestId,
923
+ startedAt
924
+ });
840
925
  }
841
926
  if (!isAuthorized(request, apiKey)) {
842
- return jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key.");
927
+ requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
928
+ return finishResponse(
929
+ jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."),
930
+ {
931
+ logger: requestLogger,
932
+ requestId,
933
+ startedAt
934
+ }
935
+ );
843
936
  }
844
937
  try {
845
938
  if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/healthz")) {
846
- return jsonResponse({
847
- name: "hoopilot",
848
- object: "health",
849
- status: "ok"
850
- });
939
+ return finishResponse(
940
+ jsonResponse({
941
+ name: "hoopilot",
942
+ object: "health",
943
+ status: "ok"
944
+ }),
945
+ { logger: requestLogger, requestId, startedAt }
946
+ );
851
947
  }
852
948
  if (request.method === "GET" && url.pathname === "/v1/models") {
853
- return await handleModels(client, request.signal);
949
+ return finishResponse(await handleModels(client, request.signal, requestLogger), {
950
+ logger: requestLogger,
951
+ requestId,
952
+ startedAt
953
+ });
854
954
  }
855
955
  if (request.method === "POST" && url.pathname === "/v1/chat/completions") {
856
- return await handleChatCompletions(client, request);
956
+ return finishResponse(await handleChatCompletions(client, request, requestLogger), {
957
+ logger: requestLogger,
958
+ requestId,
959
+ startedAt
960
+ });
857
961
  }
858
962
  if (request.method === "POST" && url.pathname === "/v1/completions") {
859
- return await handleCompletions(client, request);
963
+ return finishResponse(await handleCompletions(client, request, requestLogger), {
964
+ logger: requestLogger,
965
+ requestId,
966
+ startedAt
967
+ });
860
968
  }
861
969
  if (request.method === "POST" && url.pathname === "/v1/responses") {
862
- return await handleResponses(client, request);
970
+ return finishResponse(await handleResponses(client, request, requestLogger), {
971
+ logger: requestLogger,
972
+ requestId,
973
+ startedAt
974
+ });
863
975
  }
864
- return jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`);
976
+ return finishResponse(
977
+ jsonError(404, "not_found", `No route for ${request.method} ${url.pathname}.`),
978
+ { logger: requestLogger, requestId, startedAt }
979
+ );
865
980
  } catch (error) {
866
981
  if (error instanceof CopilotAuthError) {
867
- return jsonError(401, "copilot_auth_error", error.message);
982
+ requestLogger.warn(
983
+ { err: errorDetails(error), event: "copilot.auth.missing" },
984
+ "copilot auth failed"
985
+ );
986
+ return finishResponse(jsonError(401, "copilot_auth_error", error.message), {
987
+ logger: requestLogger,
988
+ requestId,
989
+ startedAt
990
+ });
991
+ }
992
+ const message = errorMessage(error);
993
+ if (message === INVALID_JSON_MESSAGE) {
994
+ requestLogger.warn(
995
+ { err: errorDetails(error), event: "http.request.failed" },
996
+ "request body was invalid json"
997
+ );
998
+ } else {
999
+ requestLogger.error(
1000
+ { err: errorDetails(error), event: "http.request.failed" },
1001
+ "request failed"
1002
+ );
868
1003
  }
869
- return jsonError(500, "internal_error", errorMessage2(error));
1004
+ return finishResponse(jsonError(500, "internal_error", message), {
1005
+ logger: requestLogger,
1006
+ requestId,
1007
+ startedAt
1008
+ });
870
1009
  }
871
1010
  };
872
1011
  }
@@ -895,35 +1034,53 @@ function startHoopilotServer(options = {}) {
895
1034
  url: `http://${host}:${server.port}`
896
1035
  };
897
1036
  }
898
- async function handleModels(client, signal) {
1037
+ async function handleModels(client, signal, logger) {
899
1038
  const upstream = await client.models(signal);
900
1039
  if (!upstream.ok) {
1040
+ if (isUpstreamAuthStatus(upstream.status)) {
1041
+ return proxyError(upstream, logger);
1042
+ }
1043
+ logger.warn(
1044
+ {
1045
+ event: "copilot.models.fallback",
1046
+ upstreamPath: "/models",
1047
+ upstreamStatus: upstream.status
1048
+ },
1049
+ "falling back to built-in model list"
1050
+ );
901
1051
  return jsonResponse({ data: fallbackModels(), object: "list" });
902
1052
  }
1053
+ logUpstreamSuccess(logger, "/models", upstream.status);
903
1054
  return jsonResponse(normalizeModelsResponse(await upstream.json()));
904
1055
  }
905
- async function handleChatCompletions(client, request) {
1056
+ async function handleChatCompletions(client, request, logger) {
906
1057
  const upstream = await client.forwardChatCompletions(await request.text(), request.signal);
1058
+ if (!upstream.ok) {
1059
+ return proxyError(upstream, logger);
1060
+ }
1061
+ logUpstreamSuccess(logger, "/chat/completions", upstream.status);
907
1062
  return proxyResponse(upstream);
908
1063
  }
909
- async function handleCompletions(client, request) {
1064
+ async function handleCompletions(client, request, logger) {
910
1065
  const body = await readJson(request);
911
1066
  const upstream = await client.chatCompletions(
912
1067
  completionsRequestToChatCompletion(body),
913
1068
  request.signal
914
1069
  );
915
1070
  if (!upstream.ok) {
916
- return proxyError(upstream);
1071
+ return proxyError(upstream, logger);
917
1072
  }
1073
+ logUpstreamSuccess(logger, "/chat/completions", upstream.status);
918
1074
  return jsonResponse(chatCompletionToCompletion(await upstream.json()));
919
1075
  }
920
- async function handleResponses(client, request) {
1076
+ async function handleResponses(client, request, logger) {
921
1077
  const body = await readJson(request);
922
1078
  const chatRequest = responsesRequestToChatCompletion(body);
923
1079
  const upstream = await client.chatCompletions(chatRequest, request.signal);
924
1080
  if (!upstream.ok) {
925
- return proxyError(upstream);
1081
+ return proxyError(upstream, logger);
926
1082
  }
1083
+ logUpstreamSuccess(logger, "/chat/completions", upstream.status);
927
1084
  if (body.stream === true && upstream.body) {
928
1085
  return new Response(
929
1086
  responsesStreamFromChatStream(upstream.body, {
@@ -941,8 +1098,19 @@ async function handleResponses(client, request) {
941
1098
  }
942
1099
  return jsonResponse(chatCompletionToResponse(await upstream.json()));
943
1100
  }
944
- async function proxyError(upstream) {
1101
+ async function proxyError(upstream, logger) {
945
1102
  const text = await upstream.text();
1103
+ if (isUpstreamAuthStatus(upstream.status)) {
1104
+ logger.warn(
1105
+ { event: "copilot.auth.rejected", upstreamStatus: upstream.status },
1106
+ "copilot rejected credential or account access"
1107
+ );
1108
+ return jsonError(401, "copilot_auth_error", upstreamAuthMessage(text || upstream.statusText));
1109
+ }
1110
+ logger.warn(
1111
+ { event: "copilot.request.failed", upstreamStatus: upstream.status },
1112
+ "copilot upstream request failed"
1113
+ );
946
1114
  return jsonError(upstream.status, "copilot_error", text || upstream.statusText);
947
1115
  }
948
1116
  function proxyResponse(upstream) {
@@ -964,7 +1132,7 @@ async function readJson(request) {
964
1132
  const value = await request.json();
965
1133
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
966
1134
  } catch {
967
- throw new Error("Request body must be valid JSON.");
1135
+ throw new Error(INVALID_JSON_MESSAGE);
968
1136
  }
969
1137
  }
970
1138
  function jsonResponse(body, status = 200) {
@@ -1003,27 +1171,125 @@ function isAuthorized(request, apiKey) {
1003
1171
  const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
1004
1172
  return bearer === apiKey || request.headers.get("x-api-key") === apiKey;
1005
1173
  }
1174
+ function isUpstreamAuthStatus(status) {
1175
+ return status === 401 || status === 403;
1176
+ }
1177
+ function upstreamAuthMessage(message) {
1178
+ return `GitHub Copilot rejected the credential or account access: ${message}`;
1179
+ }
1006
1180
  function isLoopbackHost(host) {
1007
1181
  return host === "localhost" || host === "127.0.0.1" || host === "::1";
1008
1182
  }
1009
- function errorMessage2(error) {
1183
+ function errorMessage(error) {
1010
1184
  return error instanceof Error ? error.message : String(error);
1011
1185
  }
1186
+ function serverLogger(options) {
1187
+ if (options.logger) {
1188
+ return options.logger.child({ component: "server" });
1189
+ }
1190
+ if (shouldCreateLogger(options)) {
1191
+ return createHoopilotLogger({
1192
+ env: options.env,
1193
+ format: options.logFormat,
1194
+ level: options.logLevel
1195
+ }).child({ component: "server" });
1196
+ }
1197
+ return noopLogger;
1198
+ }
1199
+ function finishResponse(response, options) {
1200
+ const withRequestId = responseWithRequestId(response, options.requestId);
1201
+ logRequestCompleted(options.logger, withRequestId, options.startedAt);
1202
+ return withRequestId;
1203
+ }
1204
+ function responseWithRequestId(response, requestId) {
1205
+ const headers = new Headers(response.headers);
1206
+ headers.set("x-request-id", requestId);
1207
+ return new Response(response.body, {
1208
+ headers,
1209
+ status: response.status,
1210
+ statusText: response.statusText
1211
+ });
1212
+ }
1213
+ function logRequestCompleted(logger, response, startedAt) {
1214
+ const fields = {
1215
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1216
+ event: "http.request.completed",
1217
+ status: response.status,
1218
+ stream: isStreamingResponse(response)
1219
+ };
1220
+ if (response.status >= 500) {
1221
+ logger.error(fields, "request completed with server error");
1222
+ return;
1223
+ }
1224
+ if (response.status >= 400) {
1225
+ logger.warn(fields, "request completed with client error");
1226
+ return;
1227
+ }
1228
+ logger.info(fields, "request completed");
1229
+ }
1230
+ function requestIdFor(request) {
1231
+ const existing = request.headers.get("x-request-id")?.trim();
1232
+ return existing || crypto.randomUUID();
1233
+ }
1234
+ function routeFor(method, path) {
1235
+ if (method === "OPTIONS") {
1236
+ return "cors.preflight";
1237
+ }
1238
+ if (method === "GET" && (path === "/" || path === "/healthz")) {
1239
+ return "health";
1240
+ }
1241
+ if (method === "GET" && path === "/v1/models") {
1242
+ return "models";
1243
+ }
1244
+ if (method === "POST" && path === "/v1/chat/completions") {
1245
+ return "chat_completions";
1246
+ }
1247
+ if (method === "POST" && path === "/v1/completions") {
1248
+ return "completions";
1249
+ }
1250
+ if (method === "POST" && path === "/v1/responses") {
1251
+ return "responses";
1252
+ }
1253
+ return "not_found";
1254
+ }
1255
+ function isStreamingResponse(response) {
1256
+ return response.headers.get("content-type")?.includes("text/event-stream") ?? false;
1257
+ }
1258
+ function logUpstreamSuccess(logger, upstreamPath, status) {
1259
+ logger.debug(
1260
+ {
1261
+ event: "copilot.request.completed",
1262
+ upstreamPath,
1263
+ upstreamStatus: status
1264
+ },
1265
+ "copilot upstream request completed"
1266
+ );
1267
+ }
1268
+ function errorDetails(error) {
1269
+ if (error instanceof Error) {
1270
+ return {
1271
+ message: error.message,
1272
+ name: error.name,
1273
+ stack: error.stack
1274
+ };
1275
+ }
1276
+ return { message: String(error) };
1277
+ }
1012
1278
 
1013
1279
  // src/update.ts
1014
- import { execFileSync as execFileSync2 } from "child_process";
1280
+ import { execFileSync } from "child_process";
1015
1281
  import {
1016
- chmodSync,
1282
+ chmodSync as chmodSync2,
1017
1283
  copyFileSync,
1018
1284
  existsSync,
1019
- mkdirSync,
1285
+ mkdirSync as mkdirSync2,
1020
1286
  realpathSync,
1021
1287
  renameSync,
1022
1288
  rmSync
1023
1289
  } from "fs";
1024
1290
  import { readFile, writeFile } from "fs/promises";
1025
1291
  import { homedir } from "os";
1026
- import { dirname, join } from "path";
1292
+ import { dirname as dirname2, join as join2 } from "path";
1027
1293
 
1028
1294
  // src/update-core.ts
1029
1295
  var REPO_OWNER = "openhoo";
@@ -1192,16 +1458,16 @@ function checksumFor(sumsText, fileName) {
1192
1458
  }
1193
1459
  return void 0;
1194
1460
  }
1195
- function resolveCacheDir(env, platform, homedir2, join2) {
1461
+ function resolveCacheDir(env, platform, homedir2, join3) {
1196
1462
  if (platform === "win32") {
1197
- const base2 = env.LOCALAPPDATA || join2(homedir2, "AppData", "Local");
1198
- return join2(base2, "hoopilot");
1463
+ const base2 = env.LOCALAPPDATA || join3(homedir2, "AppData", "Local");
1464
+ return join3(base2, "hoopilot");
1199
1465
  }
1200
1466
  if (platform === "darwin") {
1201
- return join2(homedir2, "Library", "Caches", "hoopilot");
1467
+ return join3(homedir2, "Library", "Caches", "hoopilot");
1202
1468
  }
1203
- const base = env.XDG_CACHE_HOME || join2(homedir2, ".cache");
1204
- return join2(base, "hoopilot");
1469
+ const base = env.XDG_CACHE_HOME || join3(homedir2, ".cache");
1470
+ return join3(base, "hoopilot");
1205
1471
  }
1206
1472
  function latestReleaseApiUrl() {
1207
1473
  return `https://api.github.com/repos/${REPO}/releases/latest`;
@@ -1238,10 +1504,10 @@ function userAgent(version) {
1238
1504
  return `hoopilot/${version}`;
1239
1505
  }
1240
1506
  function cacheDir() {
1241
- return resolveCacheDir(process.env, process.platform, homedir(), join);
1507
+ return resolveCacheDir(process.env, process.platform, homedir(), join2);
1242
1508
  }
1243
1509
  function stateFilePath() {
1244
- return join(cacheDir(), "update-check.json");
1510
+ return join2(cacheDir(), "update-check.json");
1245
1511
  }
1246
1512
  async function readStateSafe() {
1247
1513
  try {
@@ -1252,7 +1518,7 @@ async function readStateSafe() {
1252
1518
  }
1253
1519
  async function writeStateSafe(state) {
1254
1520
  try {
1255
- mkdirSync(cacheDir(), { recursive: true });
1521
+ mkdirSync2(cacheDir(), { recursive: true });
1256
1522
  await writeFile(stateFilePath(), JSON.stringify(state), "utf8");
1257
1523
  } catch {
1258
1524
  }
@@ -1286,27 +1552,44 @@ async function fetchLatest(version, etag) {
1286
1552
  return null;
1287
1553
  }
1288
1554
  }
1289
- async function maybeNotifyUpdate(currentVersion, kind) {
1555
+ async function maybeNotifyUpdate(currentVersion, kind, logger) {
1290
1556
  if (isUpdateCheckDisabled(process.env, Boolean(process.stderr.isTTY))) {
1557
+ logger?.debug({ event: "update.check.skipped" }, "update check skipped");
1291
1558
  return;
1292
1559
  }
1293
1560
  const state = await readStateSafe();
1294
1561
  if (state.latestVersion && isOutdated(currentVersion, state.latestVersion)) {
1562
+ logger?.debug(
1563
+ {
1564
+ currentVersion,
1565
+ event: "update.notice.cached",
1566
+ installKind: kind,
1567
+ latestVersion: state.latestVersion
1568
+ },
1569
+ "showing cached update notice"
1570
+ );
1295
1571
  process.stderr.write(formatUpdateNotice(currentVersion, state.latestVersion, kind));
1296
1572
  }
1297
1573
  if (shouldRefresh(state.lastCheck, Date.now())) {
1298
- void refreshState(currentVersion, state.etag ?? null).catch(() => {
1574
+ logger?.debug({ event: "update.check.refresh_queued" }, "queued update check refresh");
1575
+ void refreshState(currentVersion, state.etag ?? null, logger).catch((error) => {
1576
+ logger?.debug(
1577
+ { err: errorDetails2(error), event: "update.check.refresh_failed" },
1578
+ "update check refresh failed"
1579
+ );
1299
1580
  });
1300
1581
  }
1301
1582
  }
1302
- async function refreshState(currentVersion, etag) {
1583
+ async function refreshState(currentVersion, etag, logger) {
1303
1584
  const result = await fetchLatest(currentVersion, etag);
1304
1585
  if (!result) {
1586
+ logger?.debug({ event: "update.check.unavailable" }, "update check unavailable");
1305
1587
  return;
1306
1588
  }
1307
1589
  if (result.status === 304) {
1308
1590
  const prev = await readStateSafe();
1309
1591
  await writeStateSafe({ ...prev, lastCheck: Date.now() });
1592
+ logger?.debug({ event: "update.check.not_modified" }, "latest release unchanged");
1310
1593
  return;
1311
1594
  }
1312
1595
  if (result.release) {
@@ -1315,6 +1598,10 @@ async function refreshState(currentVersion, etag) {
1315
1598
  latestVersion: result.release.version,
1316
1599
  etag: result.etag
1317
1600
  });
1601
+ logger?.debug(
1602
+ { event: "update.check.updated", latestVersion: result.release.version },
1603
+ "updated cached latest release state"
1604
+ );
1318
1605
  }
1319
1606
  }
1320
1607
  function detectInstallKind() {
@@ -1335,7 +1622,7 @@ function detectMusl() {
1335
1622
  if (existsSync("/etc/alpine-release")) {
1336
1623
  return true;
1337
1624
  }
1338
- const ldd = execFileSync2("ldd", ["--version"], {
1625
+ const ldd = execFileSync("ldd", ["--version"], {
1339
1626
  encoding: "utf8",
1340
1627
  stdio: ["ignore", "pipe", "pipe"],
1341
1628
  timeout: 2e3
@@ -1422,7 +1709,7 @@ function swapBinary(tmpFile, exePath) {
1422
1709
  const code = error.code;
1423
1710
  if (code === "EXDEV") {
1424
1711
  copyFileSync(tmpFile, exePath);
1425
- chmodSync(exePath, 493);
1712
+ chmodSync2(exePath, 493);
1426
1713
  } else if (code === "EACCES" || code === "EPERM") {
1427
1714
  throw new Error(
1428
1715
  `No permission to update ${exePath}. Re-run with sudo, or reinstall to a writable directory.`
@@ -1441,9 +1728,10 @@ function cleanupOldBinary() {
1441
1728
  } catch {
1442
1729
  }
1443
1730
  }
1444
- async function runUpdate(currentVersion) {
1731
+ async function runUpdate(currentVersion, logger) {
1445
1732
  cleanupOldBinary();
1446
1733
  const kind = detectInstallKind();
1734
+ logger?.debug({ currentVersion, event: "update.started", installKind: kind }, "update started");
1447
1735
  if (kind !== "binary") {
1448
1736
  console.log(`hoopilot ${currentVersion} was installed via npm.`);
1449
1737
  console.log(`Update with: ${upgradeCommandFor("npm")}`);
@@ -1456,6 +1744,10 @@ async function runUpdate(currentVersion) {
1456
1744
  throw new Error("Could not reach GitHub to check for the latest release.");
1457
1745
  }
1458
1746
  if (!isOutdated(currentVersion, release.version)) {
1747
+ logger?.debug(
1748
+ { currentVersion, event: "update.already_current", latestVersion: release.version },
1749
+ "hoopilot is already up to date"
1750
+ );
1459
1751
  console.log(`Already up to date (latest: ${release.version}).`);
1460
1752
  return;
1461
1753
  }
@@ -1467,13 +1759,22 @@ async function runUpdate(currentVersion) {
1467
1759
  throw new Error(`Release ${release.tag} has no asset "${assetName}". Available: ${available}.`);
1468
1760
  }
1469
1761
  console.log(`Updating ${currentVersion} \u2192 ${release.version} (${assetName})...`);
1762
+ logger?.debug(
1763
+ {
1764
+ assetName,
1765
+ currentVersion,
1766
+ event: "update.installing",
1767
+ latestVersion: release.version
1768
+ },
1769
+ "installing update"
1770
+ );
1470
1771
  const exePath = realpathSync(process.execPath);
1471
- const tmpFile = join(dirname(exePath), `.hoopilot-update-${process.pid}.tmp`);
1772
+ const tmpFile = join2(dirname2(exePath), `.hoopilot-update-${process.pid}.tmp`);
1472
1773
  try {
1473
1774
  await downloadToFile(asset.url, tmpFile, currentVersion);
1474
1775
  await verifyChecksum(release, assetName, tmpFile, currentVersion);
1475
1776
  if (process.platform !== "win32") {
1476
- chmodSync(tmpFile, 493);
1777
+ chmodSync2(tmpFile, 493);
1477
1778
  }
1478
1779
  swapBinary(tmpFile, exePath);
1479
1780
  } catch (error) {
@@ -1491,20 +1792,59 @@ async function runUpdate(currentVersion) {
1491
1792
  }
1492
1793
  }
1493
1794
  console.log(`Updated hoopilot to ${release.version}.`);
1795
+ logger?.debug(
1796
+ { currentVersion, event: "update.completed", latestVersion: release.version },
1797
+ "update completed"
1798
+ );
1494
1799
  if (process.platform === "win32") {
1495
1800
  console.log("Restart hoopilot to run the new version.");
1496
1801
  }
1497
1802
  }
1803
+ function errorDetails2(error) {
1804
+ if (error instanceof Error) {
1805
+ return {
1806
+ message: error.message,
1807
+ name: error.name,
1808
+ stack: error.stack
1809
+ };
1810
+ }
1811
+ return { message: String(error) };
1812
+ }
1498
1813
 
1499
1814
  // src/cli.ts
1815
+ var DEFAULT_COPILOT_API_BASE_URL2 = "https://api.githubcopilot.com";
1500
1816
  async function main(argv = Bun.argv.slice(2)) {
1501
1817
  cleanupOldBinary();
1502
1818
  const command = argv[0];
1503
1819
  if (command === "update" || command === "upgrade") {
1504
- await runUpdate(await getVersion());
1820
+ const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
1821
+ if (args2.help) {
1822
+ console.log(helpText(await getVersion()));
1823
+ return;
1824
+ }
1825
+ const logger2 = createHoopilotLogger({
1826
+ env: args2.env,
1827
+ format: args2.logFormat,
1828
+ level: args2.logLevel
1829
+ }).child({ component: "cli", command });
1830
+ await runUpdate(await getVersion(), logger2);
1831
+ return;
1832
+ }
1833
+ if (command === "login") {
1834
+ const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
1835
+ if (args2.help) {
1836
+ console.log(helpText(await getVersion()));
1837
+ return;
1838
+ }
1839
+ args2.logger = createHoopilotLogger({
1840
+ env: args2.env,
1841
+ format: args2.logFormat,
1842
+ level: args2.logLevel
1843
+ }).child({ component: "cli", command: "login" });
1844
+ await runLogin(args2);
1505
1845
  return;
1506
1846
  }
1507
- const args = parseArgs(argv);
1847
+ const args = withRuntimeEnv(parseArgs(argv));
1508
1848
  if (args.help) {
1509
1849
  console.log(helpText(await getVersion()));
1510
1850
  return;
@@ -1513,12 +1853,27 @@ async function main(argv = Bun.argv.slice(2)) {
1513
1853
  console.log(await getVersion());
1514
1854
  return;
1515
1855
  }
1856
+ const logger = createHoopilotLogger({
1857
+ env: args.env,
1858
+ format: args.logFormat,
1859
+ level: args.logLevel
1860
+ }).child({ component: "cli", command: "serve" });
1861
+ args.logger = logger;
1516
1862
  const started = startHoopilotServer(args);
1517
- console.log(`hoopilot listening on ${started.url}`);
1518
- console.log(`OpenAI base URL: ${started.url}/v1`);
1519
- console.log("Use Ctrl+C to stop.");
1520
- if (!args.noUpdateCheck) {
1521
- void maybeNotifyUpdate(await getVersion(), IS_STANDALONE_BINARY ? "binary" : "npm");
1863
+ logger.info(
1864
+ {
1865
+ baseUrl: `${started.url}/v1`,
1866
+ event: "server.started",
1867
+ url: started.url
1868
+ },
1869
+ "hoopilot server started"
1870
+ );
1871
+ if (!args.noUpdateCheck && process.env.HOOPILOT_NO_UPDATE_CHECK !== "1") {
1872
+ void maybeNotifyUpdate(
1873
+ await getVersion(),
1874
+ IS_STANDALONE_BINARY ? "binary" : "npm",
1875
+ logger.child({ component: "update" })
1876
+ );
1522
1877
  }
1523
1878
  }
1524
1879
  function parseArgs(argv) {
@@ -1544,10 +1899,6 @@ function parseArgs(argv) {
1544
1899
  args.allowUnauthenticated = true;
1545
1900
  continue;
1546
1901
  }
1547
- if (arg === "--no-gh") {
1548
- args.githubTokenCommand = false;
1549
- continue;
1550
- }
1551
1902
  if (arg === "--no-update-check") {
1552
1903
  args.noUpdateCheck = true;
1553
1904
  continue;
@@ -1561,20 +1912,17 @@ function parseArgs(argv) {
1561
1912
  case "--api-key":
1562
1913
  args.apiKey = value;
1563
1914
  break;
1564
- case "--auth-mode":
1565
- args.authMode = parseAuthMode(value);
1915
+ case "--auth-file":
1916
+ args.authStorePath = value;
1566
1917
  break;
1567
1918
  case "--copilot-api-base-url":
1568
1919
  args.copilotApiBaseUrl = value;
1569
1920
  break;
1570
- case "--copilot-token":
1571
- args.copilotToken = value;
1572
- break;
1573
- case "--github-token":
1574
- args.githubToken = value;
1921
+ case "--log-format":
1922
+ args.logFormat = parseLogFormat(value);
1575
1923
  break;
1576
- case "--github-token-command":
1577
- args.githubTokenCommand = value;
1924
+ case "--log-level":
1925
+ args.logLevel = parseLogLevel(value);
1578
1926
  break;
1579
1927
  case "--host":
1580
1928
  args.host = value;
@@ -1592,11 +1940,92 @@ function parseArgs(argv) {
1592
1940
  }
1593
1941
  return args;
1594
1942
  }
1595
- function parseAuthMode(value) {
1596
- if (value === "auto" || value === "copilot-token") {
1597
- return value;
1943
+ async function runLogin(options = {}) {
1944
+ const logger = options.logger?.child({ component: "auth" }) ?? noopLogger;
1945
+ logger.debug({ event: "auth.login.started" }, "starting github copilot browser login");
1946
+ console.log("Starting GitHub Copilot browser login...");
1947
+ const login = await githubCopilotDeviceLogin({
1948
+ env: options.env,
1949
+ logger: console,
1950
+ openBrowser: openBrowserBestEffort
1951
+ });
1952
+ console.log("Checking GitHub Copilot access...");
1953
+ const access = await verifyCopilotOAuthToken(login.token, options);
1954
+ logger.debug(
1955
+ { apiBaseUrl: access.apiBaseUrl, event: "auth.login.verified" },
1956
+ "github copilot oauth token verified"
1957
+ );
1958
+ const path = options.authStorePath ?? authStorePath(options.env);
1959
+ writeStoredCopilotAuth(
1960
+ {
1961
+ apiBaseUrl: access.apiBaseUrl,
1962
+ githubDomain: login.domain,
1963
+ source: "github-device-oauth",
1964
+ token: login.token
1965
+ },
1966
+ path
1967
+ );
1968
+ logger.debug({ authStorePath: path, event: "auth.login.stored" }, "copilot credential stored");
1969
+ console.log(`Copilot OAuth credential stored at ${path}`);
1970
+ console.log("Copilot authentication ready.");
1971
+ }
1972
+ async function verifyCopilotOAuthToken(token, options = {}) {
1973
+ const apiBaseUrl = trimTrailingSlash2(
1974
+ options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL2
1975
+ );
1976
+ const fetcher = options.fetch ?? fetch;
1977
+ const response = await fetcher(`${apiBaseUrl}/models`, {
1978
+ headers: copilotHeaders(token),
1979
+ method: "GET"
1980
+ });
1981
+ if (!response.ok) {
1982
+ const message = `GitHub Copilot API verification failed with ${response.status}: ${await safeResponseText2(response)}`;
1983
+ if (response.status === 401 || response.status === 403) {
1984
+ throw new CopilotAuthError(message);
1985
+ }
1986
+ throw new Error(message);
1598
1987
  }
1599
- throw new Error(`Invalid auth mode: ${value}.`);
1988
+ return {
1989
+ apiBaseUrl,
1990
+ expiresAtMs: Date.now() + 10 * 6e4,
1991
+ source: "github-copilot-oauth",
1992
+ token
1993
+ };
1994
+ }
1995
+ function openBrowserBestEffort(url) {
1996
+ const platform = process.platform;
1997
+ const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
1998
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
1999
+ try {
2000
+ const child = spawn(command, args, {
2001
+ detached: true,
2002
+ stdio: "ignore"
2003
+ });
2004
+ child.unref();
2005
+ } catch {
2006
+ }
2007
+ }
2008
+ function copilotHeaders(token) {
2009
+ const headers = new Headers();
2010
+ headers.set("accept", "application/json");
2011
+ headers.set("authorization", `Bearer ${token}`);
2012
+ headers.set("copilot-integration-id", "vscode-chat");
2013
+ headers.set("editor-plugin-version", "hoopilot/0.1.0");
2014
+ headers.set("editor-version", "Hoopilot/0.1.0");
2015
+ headers.set("openai-intent", "conversation-panel");
2016
+ headers.set("user-agent", "hoopilot/0.1.0");
2017
+ headers.set("x-github-api-version", "2026-06-01");
2018
+ return headers;
2019
+ }
2020
+ async function safeResponseText2(response) {
2021
+ const text = await response.text();
2022
+ return text.slice(0, 500);
2023
+ }
2024
+ function trimTrailingSlash2(value) {
2025
+ return value.replace(/\/+$/, "");
2026
+ }
2027
+ function withRuntimeEnv(args) {
2028
+ return { ...args, env: process.env };
1600
2029
  }
1601
2030
  function helpText(version) {
1602
2031
  return `hoopilot ${version}
@@ -1605,23 +2034,23 @@ OpenAI-compatible proxy for GitHub Copilot.
1605
2034
 
1606
2035
  Usage:
1607
2036
  hoopilot [serve] [options]
2037
+ hoopilot login [options]
1608
2038
  hoopilot update
1609
2039
  npx @openhoo/hoopilot [options]
1610
2040
 
1611
2041
  Commands:
1612
2042
  serve Start the proxy server (default)
2043
+ login Sign in through GitHub OAuth in a browser and verify Copilot access
1613
2044
  update, upgrade Update hoopilot to the latest release
1614
2045
 
1615
2046
  Options:
1616
2047
  -p, --port <port> Port to listen on. Default: 4141
1617
2048
  --host <host> Host to listen on. Default: 127.0.0.1
1618
2049
  --api-key <key> Require clients to send Authorization: Bearer <key>
1619
- --auth-mode <mode> auto, copilot-token
1620
- --github-token <token> GitHub CLI OAuth token for a Copilot account. PATs are rejected.
1621
- --github-token-command <cmd> Command used to read a GitHub token. Default: gh auth token
1622
- --copilot-token <token> Short-lived Copilot API bearer token
2050
+ --auth-file <path> OAuth credential store path
1623
2051
  --copilot-api-base-url <url> Copilot API base URL override
1624
- --no-gh Do not try gh auth token
2052
+ --log-level <level> trace, debug, info, warn, error, fatal, or silent
2053
+ --log-format <format> json or pretty. Default: pretty
1625
2054
  --no-update-check Do not check GitHub for a newer release
1626
2055
  --allow-unauthenticated Allow non-loopback bind without --api-key
1627
2056
  -h, --help Show help
@@ -1629,8 +2058,11 @@ Options:
1629
2058
 
1630
2059
  Environment:
1631
2060
  HOOPILOT_API_KEY
1632
- COPILOT_GITHUB_TOKEN
1633
- COPILOT_API_TOKEN, GITHUB_COPILOT_API_TOKEN
2061
+ HOOPILOT_AUTH_FILE
2062
+ HOOPILOT_GITHUB_CLIENT_ID
2063
+ HOOPILOT_GITHUB_DOMAIN
2064
+ HOOPILOT_LOG_FORMAT json or pretty. Default: pretty
2065
+ HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
1634
2066
  COPILOT_API_BASE_URL
1635
2067
  HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
1636
2068
  `;
@@ -1643,6 +2075,7 @@ if (import.meta.main) {
1643
2075
  }
1644
2076
  export {
1645
2077
  main,
1646
- parseArgs
2078
+ parseArgs,
2079
+ verifyCopilotOAuthToken
1647
2080
  };
1648
2081
  //# sourceMappingURL=cli.js.map