@openhoo/hoopilot 0.3.1 → 0.5.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,126 @@ 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";
1281
+ import { createHash } from "crypto";
1015
1282
  import {
1016
- chmodSync,
1283
+ chmodSync as chmodSync2,
1017
1284
  copyFileSync,
1018
1285
  existsSync,
1019
- mkdirSync,
1286
+ mkdirSync as mkdirSync2,
1020
1287
  realpathSync,
1021
1288
  renameSync,
1022
1289
  rmSync
1023
1290
  } from "fs";
1024
1291
  import { readFile, writeFile } from "fs/promises";
1025
1292
  import { homedir } from "os";
1026
- import { dirname, join } from "path";
1293
+ import { dirname as dirname2, join as join2 } from "path";
1027
1294
 
1028
1295
  // src/update-core.ts
1029
1296
  var REPO_OWNER = "openhoo";
@@ -1192,16 +1459,16 @@ function checksumFor(sumsText, fileName) {
1192
1459
  }
1193
1460
  return void 0;
1194
1461
  }
1195
- function resolveCacheDir(env, platform, homedir2, join2) {
1462
+ function resolveCacheDir(env, platform, homedir2, join3) {
1196
1463
  if (platform === "win32") {
1197
- const base2 = env.LOCALAPPDATA || join2(homedir2, "AppData", "Local");
1198
- return join2(base2, "hoopilot");
1464
+ const base2 = env.LOCALAPPDATA || join3(homedir2, "AppData", "Local");
1465
+ return join3(base2, "hoopilot");
1199
1466
  }
1200
1467
  if (platform === "darwin") {
1201
- return join2(homedir2, "Library", "Caches", "hoopilot");
1468
+ return join3(homedir2, "Library", "Caches", "hoopilot");
1202
1469
  }
1203
- const base = env.XDG_CACHE_HOME || join2(homedir2, ".cache");
1204
- return join2(base, "hoopilot");
1470
+ const base = env.XDG_CACHE_HOME || join3(homedir2, ".cache");
1471
+ return join3(base, "hoopilot");
1205
1472
  }
1206
1473
  function latestReleaseApiUrl() {
1207
1474
  return `https://api.github.com/repos/${REPO}/releases/latest`;
@@ -1238,10 +1505,10 @@ function userAgent(version) {
1238
1505
  return `hoopilot/${version}`;
1239
1506
  }
1240
1507
  function cacheDir() {
1241
- return resolveCacheDir(process.env, process.platform, homedir(), join);
1508
+ return resolveCacheDir(process.env, process.platform, homedir(), join2);
1242
1509
  }
1243
1510
  function stateFilePath() {
1244
- return join(cacheDir(), "update-check.json");
1511
+ return join2(cacheDir(), "update-check.json");
1245
1512
  }
1246
1513
  async function readStateSafe() {
1247
1514
  try {
@@ -1252,7 +1519,7 @@ async function readStateSafe() {
1252
1519
  }
1253
1520
  async function writeStateSafe(state) {
1254
1521
  try {
1255
- mkdirSync(cacheDir(), { recursive: true });
1522
+ mkdirSync2(cacheDir(), { recursive: true });
1256
1523
  await writeFile(stateFilePath(), JSON.stringify(state), "utf8");
1257
1524
  } catch {
1258
1525
  }
@@ -1286,27 +1553,44 @@ async function fetchLatest(version, etag) {
1286
1553
  return null;
1287
1554
  }
1288
1555
  }
1289
- async function maybeNotifyUpdate(currentVersion, kind) {
1556
+ async function maybeNotifyUpdate(currentVersion, kind, logger) {
1290
1557
  if (isUpdateCheckDisabled(process.env, Boolean(process.stderr.isTTY))) {
1558
+ logger?.debug({ event: "update.check.skipped" }, "update check skipped");
1291
1559
  return;
1292
1560
  }
1293
1561
  const state = await readStateSafe();
1294
1562
  if (state.latestVersion && isOutdated(currentVersion, state.latestVersion)) {
1563
+ logger?.debug(
1564
+ {
1565
+ currentVersion,
1566
+ event: "update.notice.cached",
1567
+ installKind: kind,
1568
+ latestVersion: state.latestVersion
1569
+ },
1570
+ "showing cached update notice"
1571
+ );
1295
1572
  process.stderr.write(formatUpdateNotice(currentVersion, state.latestVersion, kind));
1296
1573
  }
1297
1574
  if (shouldRefresh(state.lastCheck, Date.now())) {
1298
- void refreshState(currentVersion, state.etag ?? null).catch(() => {
1575
+ logger?.debug({ event: "update.check.refresh_queued" }, "queued update check refresh");
1576
+ void refreshState(currentVersion, state.etag ?? null, logger).catch((error) => {
1577
+ logger?.debug(
1578
+ { err: errorDetails2(error), event: "update.check.refresh_failed" },
1579
+ "update check refresh failed"
1580
+ );
1299
1581
  });
1300
1582
  }
1301
1583
  }
1302
- async function refreshState(currentVersion, etag) {
1584
+ async function refreshState(currentVersion, etag, logger) {
1303
1585
  const result = await fetchLatest(currentVersion, etag);
1304
1586
  if (!result) {
1587
+ logger?.debug({ event: "update.check.unavailable" }, "update check unavailable");
1305
1588
  return;
1306
1589
  }
1307
1590
  if (result.status === 304) {
1308
1591
  const prev = await readStateSafe();
1309
1592
  await writeStateSafe({ ...prev, lastCheck: Date.now() });
1593
+ logger?.debug({ event: "update.check.not_modified" }, "latest release unchanged");
1310
1594
  return;
1311
1595
  }
1312
1596
  if (result.release) {
@@ -1315,6 +1599,10 @@ async function refreshState(currentVersion, etag) {
1315
1599
  latestVersion: result.release.version,
1316
1600
  etag: result.etag
1317
1601
  });
1602
+ logger?.debug(
1603
+ { event: "update.check.updated", latestVersion: result.release.version },
1604
+ "updated cached latest release state"
1605
+ );
1318
1606
  }
1319
1607
  }
1320
1608
  function detectInstallKind() {
@@ -1335,7 +1623,7 @@ function detectMusl() {
1335
1623
  if (existsSync("/etc/alpine-release")) {
1336
1624
  return true;
1337
1625
  }
1338
- const ldd = execFileSync2("ldd", ["--version"], {
1626
+ const ldd = execFileSync("ldd", ["--version"], {
1339
1627
  encoding: "utf8",
1340
1628
  stdio: ["ignore", "pipe", "pipe"],
1341
1629
  timeout: 2e3
@@ -1354,12 +1642,10 @@ async function downloadToFile(url, dest, version) {
1354
1642
  if (!response.ok || !response.body) {
1355
1643
  throw new Error(`Download failed (${response.status}) for ${url}`);
1356
1644
  }
1357
- await Bun.write(dest, response);
1645
+ await writeFile(dest, new Uint8Array(await response.arrayBuffer()));
1358
1646
  }
1359
1647
  async function sha256File(path) {
1360
- const hasher = new Bun.CryptoHasher("sha256");
1361
- hasher.update(await Bun.file(path).arrayBuffer());
1362
- return hasher.digest("hex");
1648
+ return createHash("sha256").update(await readFile(path)).digest("hex");
1363
1649
  }
1364
1650
  async function verifyChecksum(release, assetName, file, version) {
1365
1651
  const sums = release.assets.find((asset) => asset.name === SHA256SUMS);
@@ -1422,7 +1708,7 @@ function swapBinary(tmpFile, exePath) {
1422
1708
  const code = error.code;
1423
1709
  if (code === "EXDEV") {
1424
1710
  copyFileSync(tmpFile, exePath);
1425
- chmodSync(exePath, 493);
1711
+ chmodSync2(exePath, 493);
1426
1712
  } else if (code === "EACCES" || code === "EPERM") {
1427
1713
  throw new Error(
1428
1714
  `No permission to update ${exePath}. Re-run with sudo, or reinstall to a writable directory.`
@@ -1441,9 +1727,10 @@ function cleanupOldBinary() {
1441
1727
  } catch {
1442
1728
  }
1443
1729
  }
1444
- async function runUpdate(currentVersion) {
1730
+ async function runUpdate(currentVersion, logger) {
1445
1731
  cleanupOldBinary();
1446
1732
  const kind = detectInstallKind();
1733
+ logger?.debug({ currentVersion, event: "update.started", installKind: kind }, "update started");
1447
1734
  if (kind !== "binary") {
1448
1735
  console.log(`hoopilot ${currentVersion} was installed via npm.`);
1449
1736
  console.log(`Update with: ${upgradeCommandFor("npm")}`);
@@ -1456,6 +1743,10 @@ async function runUpdate(currentVersion) {
1456
1743
  throw new Error("Could not reach GitHub to check for the latest release.");
1457
1744
  }
1458
1745
  if (!isOutdated(currentVersion, release.version)) {
1746
+ logger?.debug(
1747
+ { currentVersion, event: "update.already_current", latestVersion: release.version },
1748
+ "hoopilot is already up to date"
1749
+ );
1459
1750
  console.log(`Already up to date (latest: ${release.version}).`);
1460
1751
  return;
1461
1752
  }
@@ -1467,13 +1758,22 @@ async function runUpdate(currentVersion) {
1467
1758
  throw new Error(`Release ${release.tag} has no asset "${assetName}". Available: ${available}.`);
1468
1759
  }
1469
1760
  console.log(`Updating ${currentVersion} \u2192 ${release.version} (${assetName})...`);
1761
+ logger?.debug(
1762
+ {
1763
+ assetName,
1764
+ currentVersion,
1765
+ event: "update.installing",
1766
+ latestVersion: release.version
1767
+ },
1768
+ "installing update"
1769
+ );
1470
1770
  const exePath = realpathSync(process.execPath);
1471
- const tmpFile = join(dirname(exePath), `.hoopilot-update-${process.pid}.tmp`);
1771
+ const tmpFile = join2(dirname2(exePath), `.hoopilot-update-${process.pid}.tmp`);
1472
1772
  try {
1473
1773
  await downloadToFile(asset.url, tmpFile, currentVersion);
1474
1774
  await verifyChecksum(release, assetName, tmpFile, currentVersion);
1475
1775
  if (process.platform !== "win32") {
1476
- chmodSync(tmpFile, 493);
1776
+ chmodSync2(tmpFile, 493);
1477
1777
  }
1478
1778
  swapBinary(tmpFile, exePath);
1479
1779
  } catch (error) {
@@ -1491,20 +1791,59 @@ async function runUpdate(currentVersion) {
1491
1791
  }
1492
1792
  }
1493
1793
  console.log(`Updated hoopilot to ${release.version}.`);
1794
+ logger?.debug(
1795
+ { currentVersion, event: "update.completed", latestVersion: release.version },
1796
+ "update completed"
1797
+ );
1494
1798
  if (process.platform === "win32") {
1495
1799
  console.log("Restart hoopilot to run the new version.");
1496
1800
  }
1497
1801
  }
1802
+ function errorDetails2(error) {
1803
+ if (error instanceof Error) {
1804
+ return {
1805
+ message: error.message,
1806
+ name: error.name,
1807
+ stack: error.stack
1808
+ };
1809
+ }
1810
+ return { message: String(error) };
1811
+ }
1498
1812
 
1499
1813
  // src/cli.ts
1814
+ var DEFAULT_COPILOT_API_BASE_URL2 = "https://api.githubcopilot.com";
1500
1815
  async function main(argv = Bun.argv.slice(2)) {
1501
1816
  cleanupOldBinary();
1502
1817
  const command = argv[0];
1503
1818
  if (command === "update" || command === "upgrade") {
1504
- await runUpdate(await getVersion());
1819
+ const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
1820
+ if (args2.help) {
1821
+ console.log(helpText(await getVersion()));
1822
+ return;
1823
+ }
1824
+ const logger2 = createHoopilotLogger({
1825
+ env: args2.env,
1826
+ format: args2.logFormat,
1827
+ level: args2.logLevel
1828
+ }).child({ component: "cli", command });
1829
+ await runUpdate(await getVersion(), logger2);
1830
+ return;
1831
+ }
1832
+ if (command === "login") {
1833
+ const args2 = withRuntimeEnv(parseArgs(argv.slice(1)));
1834
+ if (args2.help) {
1835
+ console.log(helpText(await getVersion()));
1836
+ return;
1837
+ }
1838
+ args2.logger = createHoopilotLogger({
1839
+ env: args2.env,
1840
+ format: args2.logFormat,
1841
+ level: args2.logLevel
1842
+ }).child({ component: "cli", command: "login" });
1843
+ await runLogin(args2);
1505
1844
  return;
1506
1845
  }
1507
- const args = parseArgs(argv);
1846
+ const args = withRuntimeEnv(parseArgs(argv));
1508
1847
  if (args.help) {
1509
1848
  console.log(helpText(await getVersion()));
1510
1849
  return;
@@ -1513,12 +1852,27 @@ async function main(argv = Bun.argv.slice(2)) {
1513
1852
  console.log(await getVersion());
1514
1853
  return;
1515
1854
  }
1855
+ const logger = createHoopilotLogger({
1856
+ env: args.env,
1857
+ format: args.logFormat,
1858
+ level: args.logLevel
1859
+ }).child({ component: "cli", command: "serve" });
1860
+ args.logger = logger;
1516
1861
  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");
1862
+ logger.info(
1863
+ {
1864
+ baseUrl: `${started.url}/v1`,
1865
+ event: "server.started",
1866
+ url: started.url
1867
+ },
1868
+ "hoopilot server started"
1869
+ );
1870
+ if (!args.noUpdateCheck && process.env.HOOPILOT_NO_UPDATE_CHECK !== "1") {
1871
+ void maybeNotifyUpdate(
1872
+ await getVersion(),
1873
+ IS_STANDALONE_BINARY ? "binary" : "npm",
1874
+ logger.child({ component: "update" })
1875
+ );
1522
1876
  }
1523
1877
  }
1524
1878
  function parseArgs(argv) {
@@ -1544,10 +1898,6 @@ function parseArgs(argv) {
1544
1898
  args.allowUnauthenticated = true;
1545
1899
  continue;
1546
1900
  }
1547
- if (arg === "--no-gh") {
1548
- args.githubTokenCommand = false;
1549
- continue;
1550
- }
1551
1901
  if (arg === "--no-update-check") {
1552
1902
  args.noUpdateCheck = true;
1553
1903
  continue;
@@ -1561,20 +1911,17 @@ function parseArgs(argv) {
1561
1911
  case "--api-key":
1562
1912
  args.apiKey = value;
1563
1913
  break;
1564
- case "--auth-mode":
1565
- args.authMode = parseAuthMode(value);
1914
+ case "--auth-file":
1915
+ args.authStorePath = value;
1566
1916
  break;
1567
1917
  case "--copilot-api-base-url":
1568
1918
  args.copilotApiBaseUrl = value;
1569
1919
  break;
1570
- case "--copilot-token":
1571
- args.copilotToken = value;
1572
- break;
1573
- case "--github-token":
1574
- args.githubToken = value;
1920
+ case "--log-format":
1921
+ args.logFormat = parseLogFormat(value);
1575
1922
  break;
1576
- case "--github-token-command":
1577
- args.githubTokenCommand = value;
1923
+ case "--log-level":
1924
+ args.logLevel = parseLogLevel(value);
1578
1925
  break;
1579
1926
  case "--host":
1580
1927
  args.host = value;
@@ -1592,11 +1939,92 @@ function parseArgs(argv) {
1592
1939
  }
1593
1940
  return args;
1594
1941
  }
1595
- function parseAuthMode(value) {
1596
- if (value === "auto" || value === "copilot-token") {
1597
- return value;
1942
+ async function runLogin(options = {}) {
1943
+ const logger = options.logger?.child({ component: "auth" }) ?? noopLogger;
1944
+ logger.debug({ event: "auth.login.started" }, "starting github copilot browser login");
1945
+ console.log("Starting GitHub Copilot browser login...");
1946
+ const login = await githubCopilotDeviceLogin({
1947
+ env: options.env,
1948
+ logger: console,
1949
+ openBrowser: openBrowserBestEffort
1950
+ });
1951
+ console.log("Checking GitHub Copilot access...");
1952
+ const access = await verifyCopilotOAuthToken(login.token, options);
1953
+ logger.debug(
1954
+ { apiBaseUrl: access.apiBaseUrl, event: "auth.login.verified" },
1955
+ "github copilot oauth token verified"
1956
+ );
1957
+ const path = options.authStorePath ?? authStorePath(options.env);
1958
+ writeStoredCopilotAuth(
1959
+ {
1960
+ apiBaseUrl: access.apiBaseUrl,
1961
+ githubDomain: login.domain,
1962
+ source: "github-device-oauth",
1963
+ token: login.token
1964
+ },
1965
+ path
1966
+ );
1967
+ logger.debug({ authStorePath: path, event: "auth.login.stored" }, "copilot credential stored");
1968
+ console.log(`Copilot OAuth credential stored at ${path}`);
1969
+ console.log("Copilot authentication ready.");
1970
+ }
1971
+ async function verifyCopilotOAuthToken(token, options = {}) {
1972
+ const apiBaseUrl = trimTrailingSlash2(
1973
+ options.copilotApiBaseUrl ?? options.env?.COPILOT_API_BASE_URL ?? DEFAULT_COPILOT_API_BASE_URL2
1974
+ );
1975
+ const fetcher = options.fetch ?? fetch;
1976
+ const response = await fetcher(`${apiBaseUrl}/models`, {
1977
+ headers: copilotHeaders(token),
1978
+ method: "GET"
1979
+ });
1980
+ if (!response.ok) {
1981
+ const message = `GitHub Copilot API verification failed with ${response.status}: ${await safeResponseText2(response)}`;
1982
+ if (response.status === 401 || response.status === 403) {
1983
+ throw new CopilotAuthError(message);
1984
+ }
1985
+ throw new Error(message);
1598
1986
  }
1599
- throw new Error(`Invalid auth mode: ${value}.`);
1987
+ return {
1988
+ apiBaseUrl,
1989
+ expiresAtMs: Date.now() + 10 * 6e4,
1990
+ source: "github-copilot-oauth",
1991
+ token
1992
+ };
1993
+ }
1994
+ function openBrowserBestEffort(url) {
1995
+ const platform = process.platform;
1996
+ const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
1997
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
1998
+ try {
1999
+ const child = spawn(command, args, {
2000
+ detached: true,
2001
+ stdio: "ignore"
2002
+ });
2003
+ child.unref();
2004
+ } catch {
2005
+ }
2006
+ }
2007
+ function copilotHeaders(token) {
2008
+ const headers = new Headers();
2009
+ headers.set("accept", "application/json");
2010
+ headers.set("authorization", `Bearer ${token}`);
2011
+ headers.set("copilot-integration-id", "vscode-chat");
2012
+ headers.set("editor-plugin-version", "hoopilot/0.1.0");
2013
+ headers.set("editor-version", "Hoopilot/0.1.0");
2014
+ headers.set("openai-intent", "conversation-panel");
2015
+ headers.set("user-agent", "hoopilot/0.1.0");
2016
+ headers.set("x-github-api-version", "2026-06-01");
2017
+ return headers;
2018
+ }
2019
+ async function safeResponseText2(response) {
2020
+ const text = await response.text();
2021
+ return text.slice(0, 500);
2022
+ }
2023
+ function trimTrailingSlash2(value) {
2024
+ return value.replace(/\/+$/, "");
2025
+ }
2026
+ function withRuntimeEnv(args) {
2027
+ return { ...args, env: process.env };
1600
2028
  }
1601
2029
  function helpText(version) {
1602
2030
  return `hoopilot ${version}
@@ -1605,23 +2033,23 @@ OpenAI-compatible proxy for GitHub Copilot.
1605
2033
 
1606
2034
  Usage:
1607
2035
  hoopilot [serve] [options]
2036
+ hoopilot login [options]
1608
2037
  hoopilot update
1609
2038
  npx @openhoo/hoopilot [options]
1610
2039
 
1611
2040
  Commands:
1612
2041
  serve Start the proxy server (default)
2042
+ login Sign in through GitHub OAuth in a browser and verify Copilot access
1613
2043
  update, upgrade Update hoopilot to the latest release
1614
2044
 
1615
2045
  Options:
1616
2046
  -p, --port <port> Port to listen on. Default: 4141
1617
2047
  --host <host> Host to listen on. Default: 127.0.0.1
1618
2048
  --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
2049
+ --auth-file <path> OAuth credential store path
1623
2050
  --copilot-api-base-url <url> Copilot API base URL override
1624
- --no-gh Do not try gh auth token
2051
+ --log-level <level> trace, debug, info, warn, error, fatal, or silent
2052
+ --log-format <format> json or pretty. Default: pretty
1625
2053
  --no-update-check Do not check GitHub for a newer release
1626
2054
  --allow-unauthenticated Allow non-loopback bind without --api-key
1627
2055
  -h, --help Show help
@@ -1629,8 +2057,11 @@ Options:
1629
2057
 
1630
2058
  Environment:
1631
2059
  HOOPILOT_API_KEY
1632
- COPILOT_GITHUB_TOKEN
1633
- COPILOT_API_TOKEN, GITHUB_COPILOT_API_TOKEN
2060
+ HOOPILOT_AUTH_FILE
2061
+ HOOPILOT_GITHUB_CLIENT_ID
2062
+ HOOPILOT_GITHUB_DOMAIN
2063
+ HOOPILOT_LOG_FORMAT json or pretty. Default: pretty
2064
+ HOOPILOT_LOG_LEVEL trace, debug, info, warn, error, fatal, or silent
1634
2065
  COPILOT_API_BASE_URL
1635
2066
  HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
1636
2067
  `;
@@ -1643,6 +2074,7 @@ if (import.meta.main) {
1643
2074
  }
1644
2075
  export {
1645
2076
  main,
1646
- parseArgs
2077
+ parseArgs,
2078
+ verifyCopilotOAuthToken
1647
2079
  };
1648
2080
  //# sourceMappingURL=cli.js.map