@openhoo/hoopilot 0.2.3 → 0.3.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
@@ -5,17 +5,14 @@ import { execFileSync } from "child_process";
5
5
  var DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
6
6
  var DEFAULT_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token";
7
7
  var REFRESH_SKEW_MS = 6e4;
8
- var defaultLogger = {
9
- info: () => void 0,
10
- warn: () => void 0,
11
- error: () => void 0
12
- };
13
8
  var CopilotAuthError = class extends Error {
14
9
  constructor(message) {
15
10
  super(message);
16
11
  this.name = "CopilotAuthError";
17
12
  }
18
13
  };
14
+ var CopilotTokenExchangeHttpError = class extends CopilotAuthError {
15
+ };
19
16
  var CopilotAuth = class {
20
17
  #authMode;
21
18
  #copilotApiBaseUrl;
@@ -37,7 +34,7 @@ var CopilotAuth = class {
37
34
  this.#fetch = options.fetch ?? fetch;
38
35
  this.#githubToken = options.githubToken;
39
36
  this.#githubTokenCommand = options.githubTokenCommand ?? "gh auth token";
40
- this.#logger = options.logger ?? defaultLogger;
37
+ this.#logger = options.logger;
41
38
  this.#tokenExchangeUrl = options.tokenExchangeUrl ?? options.env?.COPILOT_TOKEN_EXCHANGE_URL ?? DEFAULT_TOKEN_EXCHANGE_URL;
42
39
  }
43
40
  async getAccess() {
@@ -45,17 +42,6 @@ var CopilotAuth = class {
45
42
  return this.#cachedAccess;
46
43
  }
47
44
  const directCopilotToken = this.#resolveDirectCopilotToken();
48
- if (this.#authMode === "copilot-token") {
49
- if (!directCopilotToken) {
50
- throw new CopilotAuthError("COPILOT_API_TOKEN or GITHUB_COPILOT_API_TOKEN is required.");
51
- }
52
- return this.#cacheAccess({
53
- apiBaseUrl: this.#copilotApiBaseUrl,
54
- expiresAtMs: Date.now() + 10 * 6e4,
55
- source: "copilot-token",
56
- token: directCopilotToken
57
- });
58
- }
59
45
  if (directCopilotToken) {
60
46
  return this.#cacheAccess({
61
47
  apiBaseUrl: this.#copilotApiBaseUrl,
@@ -64,29 +50,30 @@ var CopilotAuth = class {
64
50
  token: directCopilotToken
65
51
  });
66
52
  }
53
+ if (this.#authMode === "copilot-token") {
54
+ throw new CopilotAuthError("COPILOT_API_TOKEN or GITHUB_COPILOT_API_TOKEN is required.");
55
+ }
67
56
  const githubToken = this.#resolveGithubToken();
68
57
  if (!githubToken) {
69
58
  throw new CopilotAuthError(
70
- "No Copilot credential found. Set COPILOT_GITHUB_TOKEN, GITHUB_TOKEN, or sign in with gh auth login."
59
+ "No Copilot credential found. Set COPILOT_API_TOKEN, set COPILOT_GITHUB_TOKEN from gh auth token, or sign in with gh auth login."
71
60
  );
72
61
  }
73
- if (this.#authMode === "direct-github-token") {
74
- return this.#cacheAccess({
75
- apiBaseUrl: this.#copilotApiBaseUrl,
76
- expiresAtMs: Date.now() + 10 * 6e4,
77
- source: "direct-github-token",
78
- token: githubToken
79
- });
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
+ );
80
66
  }
81
67
  try {
82
- const exchanged = await this.#exchangeGithubToken(githubToken);
83
- return this.#cacheAccess(exchanged);
68
+ return this.#cacheAccess(await this.#exchangeGithubToken(githubToken));
84
69
  } catch (error) {
85
- if (this.#authMode === "github-token") {
70
+ if (!(error instanceof CopilotTokenExchangeHttpError)) {
86
71
  throw error;
87
72
  }
88
- this.#logger.warn(
89
- `Copilot token exchange failed; falling back to direct GitHub token mode: ${errorMessage(error)}`
73
+ this.#logger?.warn(
74
+ `Copilot token exchange failed; falling back to GitHub CLI token mode: ${errorMessage(
75
+ error
76
+ )}`
90
77
  );
91
78
  return this.#cacheAccess({
92
79
  apiBaseUrl: this.#copilotApiBaseUrl,
@@ -112,7 +99,7 @@ var CopilotAuth = class {
112
99
  method: "GET"
113
100
  });
114
101
  if (!response.ok) {
115
- throw new CopilotAuthError(
102
+ throw new CopilotTokenExchangeHttpError(
116
103
  `GitHub Copilot token exchange failed with ${response.status}: ${await safeResponseText(
117
104
  response
118
105
  )}`
@@ -143,8 +130,6 @@ var CopilotAuth = class {
143
130
  this.#githubToken,
144
131
  this.#env.COPILOT_GITHUB_TOKEN,
145
132
  this.#env.GITHUB_COPILOT_GITHUB_TOKEN,
146
- this.#env.GH_TOKEN,
147
- this.#env.GITHUB_TOKEN,
148
133
  this.#readGithubTokenCommand()
149
134
  );
150
135
  }
@@ -259,6 +244,9 @@ async function safeResponseText(response) {
259
244
  const text = await response.text();
260
245
  return text.slice(0, 500);
261
246
  }
247
+ function isPersonalAccessToken(token) {
248
+ return token.startsWith("github_pat_") || token.startsWith("ghp_");
249
+ }
262
250
  function errorMessage(error) {
263
251
  return error instanceof Error ? error.message : String(error);
264
252
  }
@@ -1022,21 +1010,516 @@ function errorMessage2(error) {
1022
1010
  return error instanceof Error ? error.message : String(error);
1023
1011
  }
1024
1012
 
1013
+ // src/update.ts
1014
+ import { execFileSync as execFileSync2 } from "child_process";
1015
+ import {
1016
+ chmodSync,
1017
+ copyFileSync,
1018
+ existsSync,
1019
+ mkdirSync,
1020
+ realpathSync,
1021
+ renameSync,
1022
+ rmSync
1023
+ } from "fs";
1024
+ import { readFile, writeFile } from "fs/promises";
1025
+ import { homedir } from "os";
1026
+ import { dirname, join } from "path";
1027
+
1028
+ // src/update-core.ts
1029
+ var REPO_OWNER = "openhoo";
1030
+ var REPO_NAME = "hoopilot";
1031
+ var REPO = `${REPO_OWNER}/${REPO_NAME}`;
1032
+ var NPM_PACKAGE = "@openhoo/hoopilot";
1033
+ var UPDATE_CHECK_INTERVAL_MS = 1e3 * 60 * 60 * 24;
1034
+ function parseSemver(input) {
1035
+ const value = String(input).trim().replace(/^[v=]+/, "");
1036
+ const match = value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
1037
+ if (!match) {
1038
+ return null;
1039
+ }
1040
+ return {
1041
+ major: Number(match[1]),
1042
+ minor: Number(match[2]),
1043
+ patch: Number(match[3]),
1044
+ // Build metadata (everything after "+") is intentionally dropped.
1045
+ prerelease: match[4] ? match[4].split(".") : []
1046
+ };
1047
+ }
1048
+ function comparePrerelease(a, b) {
1049
+ if (a.length === 0 && b.length === 0) {
1050
+ return 0;
1051
+ }
1052
+ if (a.length === 0) {
1053
+ return 1;
1054
+ }
1055
+ if (b.length === 0) {
1056
+ return -1;
1057
+ }
1058
+ const len = Math.max(a.length, b.length);
1059
+ for (let i = 0; i < len; i++) {
1060
+ const x = a[i];
1061
+ const y = b[i];
1062
+ if (x === void 0) {
1063
+ return -1;
1064
+ }
1065
+ if (y === void 0) {
1066
+ return 1;
1067
+ }
1068
+ const xNumeric = /^\d+$/.test(x);
1069
+ const yNumeric = /^\d+$/.test(y);
1070
+ if (xNumeric && yNumeric) {
1071
+ const diff = Number(x) - Number(y);
1072
+ if (diff !== 0) {
1073
+ return diff < 0 ? -1 : 1;
1074
+ }
1075
+ } else if (xNumeric) {
1076
+ return -1;
1077
+ } else if (yNumeric) {
1078
+ return 1;
1079
+ } else if (x !== y) {
1080
+ return x < y ? -1 : 1;
1081
+ }
1082
+ }
1083
+ return 0;
1084
+ }
1085
+ function compareSemver(a, b) {
1086
+ const pa = parseSemver(a);
1087
+ const pb = parseSemver(b);
1088
+ if (!pa || !pb) {
1089
+ if (!pa && !pb) {
1090
+ return 0;
1091
+ }
1092
+ return pa ? 1 : -1;
1093
+ }
1094
+ if (pa.major !== pb.major) {
1095
+ return pa.major < pb.major ? -1 : 1;
1096
+ }
1097
+ if (pa.minor !== pb.minor) {
1098
+ return pa.minor < pb.minor ? -1 : 1;
1099
+ }
1100
+ if (pa.patch !== pb.patch) {
1101
+ return pa.patch < pb.patch ? -1 : 1;
1102
+ }
1103
+ return comparePrerelease(pa.prerelease, pb.prerelease);
1104
+ }
1105
+ function isOutdated(current, latest) {
1106
+ return compareSemver(current, latest) < 0;
1107
+ }
1108
+ function versionFromTag(tag) {
1109
+ return tag.trim().replace(/^v/, "");
1110
+ }
1111
+ function assetSuffixFor(platform, arch, isMusl) {
1112
+ const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : "linux";
1113
+ const cpu = arch === "arm64" || arch === "aarch64" ? "arm64" : "x64";
1114
+ const libc = os === "linux" && isMusl ? "-musl" : "";
1115
+ return `${os}-${cpu}${libc}`;
1116
+ }
1117
+ function assetNameFor(suffix) {
1118
+ const name = `hoopilot-${suffix}`;
1119
+ return suffix.startsWith("windows-") ? `${name}.exe` : name;
1120
+ }
1121
+ function isUpdateCheckDisabled(env, isTty) {
1122
+ if (env.HOOPILOT_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER) {
1123
+ return true;
1124
+ }
1125
+ if (env.NODE_ENV === "test") {
1126
+ return true;
1127
+ }
1128
+ if (!isTty) {
1129
+ return true;
1130
+ }
1131
+ if (env.CI && env.CI !== "false" || env.CONTINUOUS_INTEGRATION || env.GITHUB_ACTIONS || env.BUILD_NUMBER || env.RUN_ID) {
1132
+ return true;
1133
+ }
1134
+ return false;
1135
+ }
1136
+ function shouldRefresh(lastCheck, now, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
1137
+ return now - lastCheck >= intervalMs;
1138
+ }
1139
+ function upgradeCommandFor(kind) {
1140
+ return kind === "binary" ? "hoopilot update" : `npm install -g ${NPM_PACKAGE}@latest (or: bun add -g ${NPM_PACKAGE})`;
1141
+ }
1142
+ function shouldCleanupOldBinary(platform, isStandaloneBinary) {
1143
+ return platform === "win32" && isStandaloneBinary;
1144
+ }
1145
+ function formatUpdateNotice(current, latest, kind) {
1146
+ return `
1147
+ Update available for hoopilot: ${current} \u2192 ${latest}
1148
+ Run: ${upgradeCommandFor(kind)}
1149
+
1150
+ `;
1151
+ }
1152
+ function parseState(text) {
1153
+ try {
1154
+ const data = JSON.parse(text);
1155
+ return {
1156
+ lastCheck: typeof data.lastCheck === "number" ? data.lastCheck : 0,
1157
+ latestVersion: typeof data.latestVersion === "string" ? data.latestVersion : null,
1158
+ etag: typeof data.etag === "string" ? data.etag : null
1159
+ };
1160
+ } catch {
1161
+ return { lastCheck: 0, latestVersion: null, etag: null };
1162
+ }
1163
+ }
1164
+ function parseLatestRelease(json) {
1165
+ if (!json || typeof json !== "object") {
1166
+ return null;
1167
+ }
1168
+ const record = json;
1169
+ const tag = typeof record.tag_name === "string" ? record.tag_name : void 0;
1170
+ if (!tag) {
1171
+ return null;
1172
+ }
1173
+ const assets = [];
1174
+ if (Array.isArray(record.assets)) {
1175
+ for (const item of record.assets) {
1176
+ if (item && typeof item === "object") {
1177
+ const asset = item;
1178
+ if (typeof asset.name === "string" && typeof asset.browser_download_url === "string") {
1179
+ assets.push({ name: asset.name, url: asset.browser_download_url });
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+ return { version: versionFromTag(tag), tag, assets };
1185
+ }
1186
+ function checksumFor(sumsText, fileName) {
1187
+ for (const line of sumsText.split(/\r?\n/)) {
1188
+ const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
1189
+ if (match?.[1] && match[2]?.trim() === fileName) {
1190
+ return match[1].toLowerCase();
1191
+ }
1192
+ }
1193
+ return void 0;
1194
+ }
1195
+ function resolveCacheDir(env, platform, homedir2, join2) {
1196
+ if (platform === "win32") {
1197
+ const base2 = env.LOCALAPPDATA || join2(homedir2, "AppData", "Local");
1198
+ return join2(base2, "hoopilot");
1199
+ }
1200
+ if (platform === "darwin") {
1201
+ return join2(homedir2, "Library", "Caches", "hoopilot");
1202
+ }
1203
+ const base = env.XDG_CACHE_HOME || join2(homedir2, ".cache");
1204
+ return join2(base, "hoopilot");
1205
+ }
1206
+ function latestReleaseApiUrl() {
1207
+ return `https://api.github.com/repos/${REPO}/releases/latest`;
1208
+ }
1209
+
1210
+ // src/version.ts
1211
+ var BAKED_VERSION = typeof HOOPILOT_VERSION !== "undefined" ? HOOPILOT_VERSION : void 0;
1212
+ var BAKED_TARGET = typeof HOOPILOT_TARGET !== "undefined" ? HOOPILOT_TARGET : void 0;
1213
+ var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
1214
+ var cachedVersion;
1215
+ async function getVersion() {
1216
+ if (cachedVersion !== void 0) {
1217
+ return cachedVersion;
1218
+ }
1219
+ let resolved;
1220
+ if (BAKED_VERSION) {
1221
+ resolved = BAKED_VERSION;
1222
+ } else {
1223
+ try {
1224
+ const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
1225
+ resolved = typeof manifest.version === "string" ? manifest.version : "0.0.0";
1226
+ } catch {
1227
+ resolved = "0.0.0";
1228
+ }
1229
+ }
1230
+ cachedVersion = resolved;
1231
+ return resolved;
1232
+ }
1233
+
1234
+ // src/update.ts
1235
+ var REQUEST_TIMEOUT_MS = 8e3;
1236
+ var SHA256SUMS = "SHA256SUMS";
1237
+ function userAgent(version) {
1238
+ return `hoopilot/${version}`;
1239
+ }
1240
+ function cacheDir() {
1241
+ return resolveCacheDir(process.env, process.platform, homedir(), join);
1242
+ }
1243
+ function stateFilePath() {
1244
+ return join(cacheDir(), "update-check.json");
1245
+ }
1246
+ async function readStateSafe() {
1247
+ try {
1248
+ return parseState(await readFile(stateFilePath(), "utf8"));
1249
+ } catch {
1250
+ return { lastCheck: 0, latestVersion: null, etag: null };
1251
+ }
1252
+ }
1253
+ async function writeStateSafe(state) {
1254
+ try {
1255
+ mkdirSync(cacheDir(), { recursive: true });
1256
+ await writeFile(stateFilePath(), JSON.stringify(state), "utf8");
1257
+ } catch {
1258
+ }
1259
+ }
1260
+ async function fetchLatest(version, etag) {
1261
+ try {
1262
+ const headers = {
1263
+ Accept: "application/vnd.github+json",
1264
+ "User-Agent": userAgent(version),
1265
+ "X-GitHub-Api-Version": "2022-11-28"
1266
+ };
1267
+ if (etag) {
1268
+ headers["If-None-Match"] = etag;
1269
+ }
1270
+ const response = await fetch(latestReleaseApiUrl(), {
1271
+ headers,
1272
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1273
+ });
1274
+ if (response.status === 304) {
1275
+ return { status: 304, etag: etag ?? null, release: null };
1276
+ }
1277
+ if (!response.ok) {
1278
+ return { status: response.status, etag: null, release: null };
1279
+ }
1280
+ return {
1281
+ status: response.status,
1282
+ etag: response.headers.get("etag"),
1283
+ release: parseLatestRelease(await response.json())
1284
+ };
1285
+ } catch {
1286
+ return null;
1287
+ }
1288
+ }
1289
+ async function maybeNotifyUpdate(currentVersion, kind) {
1290
+ if (isUpdateCheckDisabled(process.env, Boolean(process.stderr.isTTY))) {
1291
+ return;
1292
+ }
1293
+ const state = await readStateSafe();
1294
+ if (state.latestVersion && isOutdated(currentVersion, state.latestVersion)) {
1295
+ process.stderr.write(formatUpdateNotice(currentVersion, state.latestVersion, kind));
1296
+ }
1297
+ if (shouldRefresh(state.lastCheck, Date.now())) {
1298
+ void refreshState(currentVersion, state.etag ?? null).catch(() => {
1299
+ });
1300
+ }
1301
+ }
1302
+ async function refreshState(currentVersion, etag) {
1303
+ const result = await fetchLatest(currentVersion, etag);
1304
+ if (!result) {
1305
+ return;
1306
+ }
1307
+ if (result.status === 304) {
1308
+ const prev = await readStateSafe();
1309
+ await writeStateSafe({ ...prev, lastCheck: Date.now() });
1310
+ return;
1311
+ }
1312
+ if (result.release) {
1313
+ await writeStateSafe({
1314
+ lastCheck: Date.now(),
1315
+ latestVersion: result.release.version,
1316
+ etag: result.etag
1317
+ });
1318
+ }
1319
+ }
1320
+ function detectInstallKind() {
1321
+ return IS_STANDALONE_BINARY ? "binary" : "npm";
1322
+ }
1323
+ function detectMusl() {
1324
+ if (process.platform !== "linux") {
1325
+ return false;
1326
+ }
1327
+ try {
1328
+ const report = process.report?.getReport?.();
1329
+ if (report?.header && "glibcVersionRuntime" in report.header) {
1330
+ return !report.header.glibcVersionRuntime;
1331
+ }
1332
+ } catch {
1333
+ }
1334
+ try {
1335
+ if (existsSync("/etc/alpine-release")) {
1336
+ return true;
1337
+ }
1338
+ const ldd = execFileSync2("ldd", ["--version"], {
1339
+ encoding: "utf8",
1340
+ stdio: ["ignore", "pipe", "pipe"],
1341
+ timeout: 2e3
1342
+ });
1343
+ return /musl/i.test(ldd);
1344
+ } catch {
1345
+ return false;
1346
+ }
1347
+ }
1348
+ async function downloadToFile(url, dest, version) {
1349
+ const response = await fetch(url, {
1350
+ headers: { "User-Agent": userAgent(version) },
1351
+ redirect: "follow",
1352
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS * 10)
1353
+ });
1354
+ if (!response.ok || !response.body) {
1355
+ throw new Error(`Download failed (${response.status}) for ${url}`);
1356
+ }
1357
+ await Bun.write(dest, response);
1358
+ }
1359
+ async function sha256File(path) {
1360
+ const hasher = new Bun.CryptoHasher("sha256");
1361
+ hasher.update(await Bun.file(path).arrayBuffer());
1362
+ return hasher.digest("hex");
1363
+ }
1364
+ async function verifyChecksum(release, assetName, file, version) {
1365
+ const sums = release.assets.find((asset) => asset.name === SHA256SUMS);
1366
+ if (!sums) {
1367
+ throw new Error(
1368
+ `Release ${release.tag} has no ${SHA256SUMS}; refusing to install an unverified binary.`
1369
+ );
1370
+ }
1371
+ const response = await fetch(sums.url, {
1372
+ headers: { "User-Agent": userAgent(version) },
1373
+ redirect: "follow",
1374
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
1375
+ });
1376
+ if (!response.ok) {
1377
+ throw new Error(`Could not download ${SHA256SUMS} (${response.status}).`);
1378
+ }
1379
+ const expected = checksumFor(await response.text(), assetName);
1380
+ if (!expected) {
1381
+ throw new Error(`No checksum for ${assetName} in ${SHA256SUMS}.`);
1382
+ }
1383
+ const actual = await sha256File(file);
1384
+ if (actual.toLowerCase() !== expected) {
1385
+ throw new Error(`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}.`);
1386
+ }
1387
+ }
1388
+ function swapBinary(tmpFile, exePath) {
1389
+ if (process.platform === "win32") {
1390
+ const oldExe = `${exePath}.old`;
1391
+ try {
1392
+ rmSync(oldExe, { force: true });
1393
+ } catch {
1394
+ }
1395
+ renameSync(exePath, oldExe);
1396
+ const restore = () => {
1397
+ try {
1398
+ renameSync(oldExe, exePath);
1399
+ } catch {
1400
+ }
1401
+ };
1402
+ try {
1403
+ renameSync(tmpFile, exePath);
1404
+ } catch (error) {
1405
+ if (error.code === "EXDEV") {
1406
+ try {
1407
+ copyFileSync(tmpFile, exePath);
1408
+ } catch (copyError) {
1409
+ restore();
1410
+ throw copyError;
1411
+ }
1412
+ } else {
1413
+ restore();
1414
+ throw error;
1415
+ }
1416
+ }
1417
+ return;
1418
+ }
1419
+ try {
1420
+ renameSync(tmpFile, exePath);
1421
+ } catch (error) {
1422
+ const code = error.code;
1423
+ if (code === "EXDEV") {
1424
+ copyFileSync(tmpFile, exePath);
1425
+ chmodSync(exePath, 493);
1426
+ } else if (code === "EACCES" || code === "EPERM") {
1427
+ throw new Error(
1428
+ `No permission to update ${exePath}. Re-run with sudo, or reinstall to a writable directory.`
1429
+ );
1430
+ } else {
1431
+ throw error;
1432
+ }
1433
+ }
1434
+ }
1435
+ function cleanupOldBinary() {
1436
+ if (!shouldCleanupOldBinary(process.platform, IS_STANDALONE_BINARY)) {
1437
+ return;
1438
+ }
1439
+ try {
1440
+ rmSync(`${realpathSync(process.execPath)}.old`, { force: true });
1441
+ } catch {
1442
+ }
1443
+ }
1444
+ async function runUpdate(currentVersion) {
1445
+ cleanupOldBinary();
1446
+ const kind = detectInstallKind();
1447
+ if (kind !== "binary") {
1448
+ console.log(`hoopilot ${currentVersion} was installed via npm.`);
1449
+ console.log(`Update with: ${upgradeCommandFor("npm")}`);
1450
+ return;
1451
+ }
1452
+ console.log(`hoopilot ${currentVersion} \u2014 checking for updates...`);
1453
+ const result = await fetchLatest(currentVersion);
1454
+ const release = result?.release ?? null;
1455
+ if (!release) {
1456
+ throw new Error("Could not reach GitHub to check for the latest release.");
1457
+ }
1458
+ if (!isOutdated(currentVersion, release.version)) {
1459
+ console.log(`Already up to date (latest: ${release.version}).`);
1460
+ return;
1461
+ }
1462
+ const suffix = BAKED_TARGET ?? assetSuffixFor(process.platform, process.arch, detectMusl());
1463
+ const assetName = assetNameFor(suffix);
1464
+ const asset = release.assets.find((entry) => entry.name === assetName);
1465
+ if (!asset) {
1466
+ const available = release.assets.map((entry) => entry.name).join(", ") || "none";
1467
+ throw new Error(`Release ${release.tag} has no asset "${assetName}". Available: ${available}.`);
1468
+ }
1469
+ console.log(`Updating ${currentVersion} \u2192 ${release.version} (${assetName})...`);
1470
+ const exePath = realpathSync(process.execPath);
1471
+ const tmpFile = join(dirname(exePath), `.hoopilot-update-${process.pid}.tmp`);
1472
+ try {
1473
+ await downloadToFile(asset.url, tmpFile, currentVersion);
1474
+ await verifyChecksum(release, assetName, tmpFile, currentVersion);
1475
+ if (process.platform !== "win32") {
1476
+ chmodSync(tmpFile, 493);
1477
+ }
1478
+ swapBinary(tmpFile, exePath);
1479
+ } catch (error) {
1480
+ const code = error.code;
1481
+ if (code === "EACCES" || code === "EPERM") {
1482
+ throw new Error(
1483
+ `No permission to update ${exePath}. Re-run with sudo, or reinstall to a writable directory (e.g. set HOOPILOT_INSTALL_DIR).`
1484
+ );
1485
+ }
1486
+ throw error;
1487
+ } finally {
1488
+ try {
1489
+ rmSync(tmpFile, { force: true });
1490
+ } catch {
1491
+ }
1492
+ }
1493
+ console.log(`Updated hoopilot to ${release.version}.`);
1494
+ if (process.platform === "win32") {
1495
+ console.log("Restart hoopilot to run the new version.");
1496
+ }
1497
+ }
1498
+
1025
1499
  // src/cli.ts
1026
1500
  async function main(argv = Bun.argv.slice(2)) {
1501
+ cleanupOldBinary();
1502
+ const command = argv[0];
1503
+ if (command === "update" || command === "upgrade") {
1504
+ await runUpdate(await getVersion());
1505
+ return;
1506
+ }
1027
1507
  const args = parseArgs(argv);
1028
1508
  if (args.help) {
1029
- console.log(helpText(await packageVersion()));
1509
+ console.log(helpText(await getVersion()));
1030
1510
  return;
1031
1511
  }
1032
1512
  if (args.version) {
1033
- console.log(await packageVersion());
1513
+ console.log(await getVersion());
1034
1514
  return;
1035
1515
  }
1036
1516
  const started = startHoopilotServer(args);
1037
1517
  console.log(`hoopilot listening on ${started.url}`);
1038
1518
  console.log(`OpenAI base URL: ${started.url}/v1`);
1039
1519
  console.log("Use Ctrl+C to stop.");
1520
+ if (!args.noUpdateCheck) {
1521
+ void maybeNotifyUpdate(await getVersion(), IS_STANDALONE_BINARY ? "binary" : "npm");
1522
+ }
1040
1523
  }
1041
1524
  function parseArgs(argv) {
1042
1525
  const args = {};
@@ -1065,6 +1548,10 @@ function parseArgs(argv) {
1065
1548
  args.githubTokenCommand = false;
1066
1549
  continue;
1067
1550
  }
1551
+ if (arg === "--no-update-check") {
1552
+ args.noUpdateCheck = true;
1553
+ continue;
1554
+ }
1068
1555
  const [name, inlineValue] = arg.split("=", 2);
1069
1556
  const value = inlineValue ?? rest.shift();
1070
1557
  if (!value) {
@@ -1106,19 +1593,11 @@ function parseArgs(argv) {
1106
1593
  return args;
1107
1594
  }
1108
1595
  function parseAuthMode(value) {
1109
- if (value === "auto" || value === "copilot-token" || value === "github-token" || value === "direct-github-token") {
1596
+ if (value === "auto" || value === "copilot-token") {
1110
1597
  return value;
1111
1598
  }
1112
1599
  throw new Error(`Invalid auth mode: ${value}.`);
1113
1600
  }
1114
- async function packageVersion() {
1115
- try {
1116
- const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
1117
- return typeof manifest.version === "string" ? manifest.version : "0.0.0";
1118
- } catch {
1119
- return "0.0.0";
1120
- }
1121
- }
1122
1601
  function helpText(version) {
1123
1602
  return `hoopilot ${version}
1124
1603
 
@@ -1126,27 +1605,34 @@ OpenAI-compatible proxy for GitHub Copilot.
1126
1605
 
1127
1606
  Usage:
1128
1607
  hoopilot [serve] [options]
1608
+ hoopilot update
1129
1609
  npx @openhoo/hoopilot [options]
1130
1610
 
1611
+ Commands:
1612
+ serve Start the proxy server (default)
1613
+ update, upgrade Update hoopilot to the latest release
1614
+
1131
1615
  Options:
1132
1616
  -p, --port <port> Port to listen on. Default: 4141
1133
1617
  --host <host> Host to listen on. Default: 127.0.0.1
1134
1618
  --api-key <key> Require clients to send Authorization: Bearer <key>
1135
- --auth-mode <mode> auto, github-token, direct-github-token, copilot-token
1136
- --github-token <token> GitHub OAuth token for a Copilot account
1619
+ --auth-mode <mode> auto, copilot-token
1620
+ --github-token <token> GitHub CLI OAuth token for a Copilot account. PATs are rejected.
1137
1621
  --github-token-command <cmd> Command used to read a GitHub token. Default: gh auth token
1138
1622
  --copilot-token <token> Short-lived Copilot API bearer token
1139
1623
  --copilot-api-base-url <url> Copilot API base URL override
1140
1624
  --no-gh Do not try gh auth token
1625
+ --no-update-check Do not check GitHub for a newer release
1141
1626
  --allow-unauthenticated Allow non-loopback bind without --api-key
1142
1627
  -h, --help Show help
1143
1628
  -v, --version Show version
1144
1629
 
1145
1630
  Environment:
1146
1631
  HOOPILOT_API_KEY
1147
- COPILOT_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN
1632
+ COPILOT_GITHUB_TOKEN
1148
1633
  COPILOT_API_TOKEN, GITHUB_COPILOT_API_TOKEN
1149
1634
  COPILOT_API_BASE_URL
1635
+ HOOPILOT_NO_UPDATE_CHECK Set to disable update checks (also NO_UPDATE_NOTIFIER)
1150
1636
  `;
1151
1637
  }
1152
1638
  if (import.meta.main) {