From 0c6f03fca1b1557c7a43d5486ae725e793c6e03d Mon Sep 17 00:00:00 2001
From: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com>
Date: Sat, 24 Oct 2026 13:34:27 +0800
Subject: [PATCH 1/2] feat: add authorized bulk Star for homepage repositories
List the homepage GitHub repositories and add a user-authorized device flow with a server-side token session, fixed repository allowlist, and partial-failure retry. Document GitHub App and same-origin hosting prerequisites.
Agent-assisted-by: Codex (implementation)
Signed-off-by: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com>
---
LOCAL_PREVIEW.md | 2 ++
SHA256SUMS | 14 +++++---
STAR_SETUP.md | 50 ++++++++++++++++++++++++++
assistant.js | 2 +-
index.html | 2 ++
local-preview.cjs | 5 ++-
qa-stars.cjs | 33 +++++++++++++++++
star-repositories.json | 13 +++++++
star-service.cjs | 81 ++++++++++++++++++++++++++++++++++++++++++
stars.css | 16 +++++++++
stars.js | 30 ++++++++++++++++
11 files changed, 242 insertions(+), 6 deletions(-)
create mode 100644 STAR_SETUP.md
create mode 100644 qa-stars.cjs
create mode 100644 star-repositories.json
create mode 100644 star-service.cjs
create mode 100644 stars.css
create mode 100644 stars.js
diff --git a/LOCAL_PREVIEW.md b/LOCAL_PREVIEW.md
index 1dc6567..2580643 100644
--- a/LOCAL_PREVIEW.md
+++ b/LOCAL_PREVIEW.md
@@ -10,6 +10,8 @@ node local-preview.cjs
当前助手为本地概念与交互演示:使用预设资料导航和示例回复,没有连接真实模型,也不会发送模型 API 请求。
+项目区新增“一键 Star 全部仓库”。实际使用需配置项目自有的 GitHub App 和授权服务,见 [STAR_SETUP.md](STAR_SETUP.md)。未配置时会显示仓库清单和明确提示,不会假报 Star 成功。
+
建议测试:导航栏右上角的小矩头像;导航栏滚出视野后出现的右下角透明全身贴图(桌面高 120px,手机高 100px);回到顶部时贴图收起;面板展开与收起、快捷问题、输入发送、项目上下文、手机窄屏布局及键盘操作。首页正文没有角色立绘或欢迎卡片。资料链接可跳转至外部项目页面。
若在终端前台启动,测试结束后可在该终端按 `Ctrl+C` 结束本次服务。
diff --git a/SHA256SUMS b/SHA256SUMS
index c42efb6..4e39f66 100644
--- a/SHA256SUMS
+++ b/SHA256SUMS
@@ -4,9 +4,10 @@ cd03e6eab67eed80970fc2e7fb8a0d6e3f65c9bda7e8ea27e02a87adde06ecd2 .gitignore
dc2b68dea37313b64e7a5e585f68be9164e3ebfae84ed5614a28519159a68454 ATTRIBUTION.md
a7b206789701abc1335853910298dcd9b397e9521704759c225dcd0d5fd2f61c BENCHMARK_EVIDENCE.json
304b20c046a82dd9cd6269151ecbe09f7807fd81376c227121f77517321cb126 INFERRSI.md
-9b37f8f61e35ca9998499716bd299a03f05d81208acae3bb35900807a0fc55ad LOCAL_PREVIEW.md
+63a0150c8d85d98620be1fbd2693c446680f7911ccb77fc347870f48b79eb0e9 LOCAL_PREVIEW.md
51ce2153087f31b813b2ba275c3d9456af4e5a951f46ebaf97d59ee9ce10d8aa README.md
fafa3b155e999c060cdd9b884484c968348929397362a03483dabfd84ef2a62e SHOWCASE.md
+73813d0d5fa345698a7b7642f04847888a0153316e9ef532f0b9cfe8f8ba16b3 STAR_SETUP.md
0bc3d5f7bd34ac53892f39ab97de48a9d771ddbfc35676255fa46292e4dcae81 app.js
53cf5937ee480102fbabd8e26a52adf1e936a49c2c24d0fc1948465982466017 assets/architecture/afd.svg
c2d920840043570ad8b25f1f43f87a2afe72c7e7bbac5a7309b599929032bd45 assets/architecture/afd.svg.license
@@ -39,17 +40,22 @@ ca3598fb776abba7c70b96199440da23ef9ba6af9456f594c810e00b9b1761ec assets/showcas
8e7e0bdd2bbe622dd85b5c06ba508293ab9bb3ae8f4f530c4104ed89af5bd536 assets/showcase/workswarm.jpg
f1e5d4f2cc41c9c1da69dfb3523c5659012ac8169d06c267b165af5ecd2aa698 assets/showcase/workswarm.mp4
30e9b46a6d06ab864bbc7ca0289143822d1192392074dc965b0683559f6911e0 assistant.css
-71ba38902b5a33b28d7bb66f5a90d83a73cbc4ca9b0d037289b7abce221a30c2 assistant.js
+28b135f8c6626c4ae8bd98140a2c32794a1bf718a899ea6030f47f4d7830a1d7 assistant.js
1c13002720c8d65a3fc98084c708b9058827ab66740ae52105991cfebc60eb8e dom-fixture.json
-e8a9bbc48eca6225629cc2cbb31629886dfd72228fac74875856bd31b597e245 index.html
+55f84f9a0a4f1f63709cd9fb43693d5552f86b658305410375061444ba456b7d index.html
acfa2bbd15ebbe680b46d0a542b4af11b893f425340d8ffe13e35f1f66523706 inferrsi.js
ae0fe01b8c81c20e4a607f8608b84ac0df3b42f9e38bcaaefde92cda5c63a782 initial-data.json
0e8566ea362276878e1f38f97da49964dbea301c15d4aa9d727f97a4c39fd943 licenses/afd-plugin-LICENSE.txt
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 licenses/vllm-omni-LICENSE.txt
-19cc4e8b177647501dc4c8fa02ce38e90f0c1f3efe70fe2aa3409930bde7aae1 local-preview.cjs
+0c7bb254b590efd08f16e91d5677a79d96ee5acad1dde7ee1a0bc141f69950aa local-preview.cjs
250f9ee919523549bbabe67fa4585a2772a405f8364b2a370b4020e61aedb8b6 prepare-initial-content.cjs
33b901a79c77f170fd4f11d7a67113f31f96bf91c58006d8d3d5a7530bb90d58 qa-interactions.cjs
+0d3d6a2ba3b21100c5d86a8a325244ae205eb78f69551709bedd41db5cbf6cc4 qa-stars.cjs
51a3881b90c67476605580f3a859becf174b8bf67d37c5c1200324613f2ab42d robots.txt
62f5e8d2444784d0e8df287e3b28931d1a259bf61d71c0636abb8a16add3ba31 showcase.js
741e991c7d0bc12c49acc061ba697dcc5e57946380c708cc823f7fadcb3d6485 sitemap.xml
+a6c7baab91939560795b458fe0246e7a111f128badc135e1b766f5a46f806ff2 star-repositories.json
+e1c71a66f7059821849ff403f9834d17eecef086462d6b08ed285164fc15009f star-service.cjs
+c892cd8794c3cba5a4b6b3bee0b2c57921a0fa6ab8df1269acc6b4046012df39 stars.css
+4445f6e99f1af780aaf2ca3ecb556a07346065dc267d75d88c5d3f00f4010589 stars.js
8ad287859fb0665c8114c44cce235ec47974c9dae9204bf9775540f3c430c712 styles.css
diff --git a/STAR_SETUP.md b/STAR_SETUP.md
new file mode 100644
index 0000000..0b4dd5e
--- /dev/null
+++ b/STAR_SETUP.md
@@ -0,0 +1,50 @@
+# Bulk Star service
+
+The homepage lists all 11 GitHub repositories linked in its HTML, including the
+seven ecosystem projects, three engine examples and the marketing asset library.
+`star-repositories.json` is the server allowlist; update it alongside homepage
+links. `node --test qa-stars.cjs` checks coverage and the authorization boundary.
+
+## Configure a project-owned GitHub App
+
+1. Register a public GitHub App with the project's homepage URL. Enable **Device
+ Flow**. Disable webhooks if unused. No callback or client secret is needed for
+ this flow.
+2. Grant **Account permissions → Starring: read and write** and the required
+ **Repository permissions → Metadata: read**. Do not grant code-write access.
+3. Inject the public Client ID as `GITHUB_STAR_CLIENT_ID` in the server process.
+ Do not paste user access tokens or secrets into the page or tracked files.
+4. Run `node local-preview.cjs` (Node 22+). Open .
+ The first click lists repositories; “Authorize & star all” requests a code.
+ The visitor opens GitHub, enters that code, and grants access. The page then
+ automatically sends the batch and shows individual results. Retry skips
+ successful items; existing stars are preserved by GitHub's idempotent PUT.
+
+## Hosting
+
+GitHub Pages serves static files and cannot run the authorization API. Keep the
+static site on Pages and route `/api/stars/*` through a same-origin HTTPS reverse
+proxy to a Node service invoking `createStarService` from `star-service.cjs`.
+Set `STAR_PUBLIC_ORIGIN` to the exact public HTTPS origin. The included preview
+server binds loopback only; a production process can remain behind the local
+reverse proxy. Forward API responses unchanged, including `Set-Cookie`, and do
+not cache them. Do not enable cross-origin API access or log cookies, tokens,
+upstream payloads, or authorization codes. Apply edge request rate limits before
+public exposure, especially to `/start`. Host the single-process API separately
+from static file delivery; in-memory sessions are not shared between replicas.
+
+Tokens stay only in server memory, bound to a random HttpOnly/SameSite cookie.
+Sessions expire within 15 minutes; browser close of the dialog discards the
+session, stopping remaining requests after any in-flight request. Successful
+stars remain. Server restart requires fresh authorization. No refresh token is
+retained. Users can revoke app authorization in GitHub settings. Errors and
+limits are reported without exposing GitHub payloads. No real stars are made by
+the test suite: it injects a simulated GitHub transport. Production authorization
+has not been verified until a project-owned app and hosting are configured.
+
+If the service is unavailable or unconfigured, the page shows a clear notice and
+individual repository links. It never substitutes opening tabs for a successful
+batch operation.
+
+References: [GitHub App device flow](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token),
+[Starring API and permissions](https://docs.github.com/en/rest/activity/starring#star-a-repository-for-the-authenticated-user).
diff --git a/assistant.js b/assistant.js
index 8b7e7f1..d8371e3 100644
--- a/assistant.js
+++ b/assistant.js
@@ -180,7 +180,7 @@
function syncMotion(){document.body.classList.toggle('mira-paused',motion.matches || $('#motion-toggle')?.getAttribute('aria-pressed')==='true');}
new MutationObserver(syncMotion).observe($('#motion-toggle'),{attributes:true,attributeFilter:['aria-pressed']});motion.addEventListener('change',syncMotion);mobile.addEventListener('change',syncMobile);
function syncDialogs(){const blocked=!!document.querySelector('dialog[open]') || !!document.fullscreenElement;document.body.classList.toggle('mira-modal-blocked',blocked);if(blocked && isOpen)closePanel(false);}
- document.querySelectorAll('dialog').forEach(d=>new MutationObserver(syncDialogs).observe(d,{attributes:true,attributeFilter:['open']}));document.addEventListener('fullscreenchange',syncDialogs);
+ new MutationObserver(syncDialogs).observe(document.body,{subtree:true,attributes:true,attributeFilter:['open']});document.addEventListener('fullscreenchange',syncDialogs);
if(hidden)document.body.classList.add('mira-hidden');
translate();syncMotion();syncDialogs();syncLaunchers();dataReady.then(()=>{if(conversation.length)renderThread();});
})();
diff --git a/index.html b/index.html
index 138cd15..59ad651 100644
--- a/index.html
+++ b/index.html
@@ -16,10 +16,12 @@
+
+
';
+ document.body.append(dialog);
+ const $=s=>dialog.querySelector(s),button=$('.star-all-submit'),status=$('.star-all-status');
+ let configured=false,busy=false,authorized=false,timer=null,generation=0,catalog=[],results=null;
+ const request=async path=>{const r=await fetch('/api/stars/'+path,{method:'POST',credentials:'same-origin',headers:{'X-Requested-With':'InferMatrixStars'}});const d=await r.json();if(!r.ok)throw Error(d.error||'unavailable');return d;};
+ let authorizationRequests=Promise.resolve();
+ // Order cookie-changing requests so a late start cannot replace a newer session.
+ const api=path=>{if(path==='all')return request(path);const operation=authorizationRequests.then(()=>request(path));authorizationRequests=operation.catch(()=>{});return operation;};
+ function translate(){entry.textContent=copy('☆ 一键 Star 全部仓库','☆ Star all repositories');$('.star-all-close').setAttribute('aria-label',copy('关闭 Star 窗口','Close Star dialog'));$('#star-all-title').textContent=copy('支持 InferMatrix 开源生态','Support the InferMatrix ecosystem');$('.star-all-description').textContent=copy(`为主页展示的 ${catalog.length} 个 GitHub 仓库添加 Star。首次需在 GitHub 登录并授权,授权后自动完成;已经 Star 的仓库保持不变。`,`Star all ${catalog.length} GitHub repositories linked on this homepage. First sign in and authorize on GitHub; the batch then runs automatically. Existing stars stay starred.`);button.textContent=busy?copy('正在处理…','Working…'):authorized?copy('重试未成功的仓库','Retry unsuccessful repositories'):copy('授权并一键 Star','Authorize & star all');$('.star-all-auth p').textContent=copy('在 GitHub 输入此验证码,确认仅授予 Star 权限:','Enter this code on GitHub and review the Starring permission:');$('.star-all-auth a').textContent=copy('前往 GitHub 授权 ↗','Authorize on GitHub ↗');renderList();}
+ function renderList(){const list=$('.star-all-list');list.replaceChildren();for(const repository of catalog){const li=document.createElement('li'),a=document.createElement('a');a.href='https://github.com/'+repository;a.target='_blank';a.rel='noopener noreferrer';a.textContent=repository;li.append(a);const r=results?.find(x=>x.repository===repository);if(r){const label=document.createElement('span');label.textContent=r.ok?copy('已 Star ✓','Starred ✓'):copy('未成功 · 可重试','Unsuccessful · retry');label.className=r.ok?'star-ok':'star-error';li.append(label);}list.append(li);}}
+ function reset(){clearTimeout(timer);generation++;busy=false;authorized=false;results=null;$('.star-all-auth').hidden=true;button.disabled=!configured;translate();}
+ async function load(){reset();status.textContent=copy('正在获取仓库清单…','Loading repositories…');try{const r=await fetch('/api/stars/status',{cache:'no-store'});if(!r.ok)throw Error();const d=await r.json();configured=d.configured;catalog=d.repositories;}catch{configured=false;try{catalog=await (await fetch('star-repositories.json')).json();}catch{catalog=[];}}button.disabled=!configured;status.textContent=configured?copy('仅操作以下仓库,GitHub 访问令牌仅保存在服务端。','Only the repositories below will be starred. GitHub access tokens stay on the server.'):copy('批量 Star 服务尚未配置。你可以先通过清单中的链接访问各仓库。','Bulk Star is not configured yet. You can visit each repository below.');translate();}
+ async function star(run){status.textContent=copy('正在逐个添加 Star,请稍候…','Starring repositories, please wait…');const d=await api('all');if(run!==generation)return;results=d.results;const count=results.filter(r=>r.ok).length;status.textContent=copy(`已 Star ${count} / ${catalog.length} 个仓库。`,`Starred ${count} / ${catalog.length} repositories.`);busy=false;button.disabled=count===catalog.length;translate();}
+ async function poll(run,interval){timer=setTimeout(async()=>{if(run!==generation)return;try{const d=await api('poll');if(run!==generation)return;if(d.authorized){authorized=true;$('.star-all-auth').hidden=true;await star(run);}else poll(run,d.interval||interval);}catch{if(run===generation)fail();}},interval*1000);}
+ function fail(){clearTimeout(timer);busy=false;status.textContent=copy('操作未完成,可能是授权过期、权限不足或 GitHub 暂时不可用。请关闭窗口后重试;已添加的 Star 会保留。','Not completed: authorization may have expired, permissions may be missing, or GitHub may be unavailable. Close and reopen to retry; completed stars are retained.');button.disabled=true;translate();}
+ entry.addEventListener('click',()=>{dialog.showModal();load();});
+ button.addEventListener('click',async()=>{if(busy)return;busy=true;button.disabled=true;translate();const run=generation;try{if(authorized){await star(run);return;}await api('logout');if(run!==generation)return;const d=await api('start');if(run!==generation)return;$('.star-all-code').textContent=d.userCode;$('.star-all-auth').hidden=false;status.textContent=copy('等待 GitHub 授权;完成后会自动 Star 上方全部仓库。','Waiting for GitHub authorization; all repositories above will then be starred automatically.');poll(run,d.interval);}catch{if(run===generation)fail();}});
+ $('.star-all-close').addEventListener('click',()=>dialog.close());
+ dialog.addEventListener('close',()=>{reset();api('logout').catch(()=>{});entry.focus();});
+ new MutationObserver(translate).observe(document.documentElement,{attributes:true,attributeFilter:['lang']});translate();
+})();
From 3dcb15a58f99ccdaf3a29d77e216bfac4f59d243 Mon Sep 17 00:00:00 2001
From: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com>
Date: Sun, 25 Oct 2026 13:40:58 +0800
Subject: [PATCH 2/2] feat: simplify Star authorization and add local
simulation
Use GitHub web authorization with state and S256 PKCE so visitors return directly to automatic starring. Add an explicit loopback-only simulation for testing without a GitHub App, and prevent repeated authorization while restoring the session.
Agent-assisted-by: Codex (implementation)
Signed-off-by: Zeng Chuang <215986001+zengchuang-hw@users.noreply.github.com>
---
LOCAL_PREVIEW.md | 2 +
SHA256SUMS | 14 ++---
STAR_SETUP.md | 41 +++++++++----
local-preview.cjs | 5 +-
qa-stars.cjs | 47 +++++++--------
star-service.cjs | 144 ++++++++++++++++++++++++----------------------
stars.css | 5 +-
stars.js | 54 +++++++++++------
8 files changed, 177 insertions(+), 135 deletions(-)
diff --git a/LOCAL_PREVIEW.md b/LOCAL_PREVIEW.md
index 2580643..874f112 100644
--- a/LOCAL_PREVIEW.md
+++ b/LOCAL_PREVIEW.md
@@ -12,6 +12,8 @@ node local-preview.cjs
项目区新增“一键 Star 全部仓库”。实际使用需配置项目自有的 GitHub App 和授权服务,见 [STAR_SETUP.md](STAR_SETUP.md)。未配置时会显示仓库清单和明确提示,不会假报 Star 成功。
+无需 GitHub App 也能体验完整操作:运行 `node local-preview.cjs --star-demo`,点击“一键 Star 全部仓库”→“体验模拟授权”→“模拟允许并返回主页”。返回后自动展示 11 个仓库的模拟结果。页面始终有模拟标识,不会请求 GitHub 或修改真实 Star。正式授权采用 GitHub 登录后自动返回,无需用户输入验证码或复制令牌。
+
建议测试:导航栏右上角的小矩头像;导航栏滚出视野后出现的右下角透明全身贴图(桌面高 120px,手机高 100px);回到顶部时贴图收起;面板展开与收起、快捷问题、输入发送、项目上下文、手机窄屏布局及键盘操作。首页正文没有角色立绘或欢迎卡片。资料链接可跳转至外部项目页面。
若在终端前台启动,测试结束后可在该终端按 `Ctrl+C` 结束本次服务。
diff --git a/SHA256SUMS b/SHA256SUMS
index 4e39f66..3db08f2 100644
--- a/SHA256SUMS
+++ b/SHA256SUMS
@@ -4,10 +4,10 @@ cd03e6eab67eed80970fc2e7fb8a0d6e3f65c9bda7e8ea27e02a87adde06ecd2 .gitignore
dc2b68dea37313b64e7a5e585f68be9164e3ebfae84ed5614a28519159a68454 ATTRIBUTION.md
a7b206789701abc1335853910298dcd9b397e9521704759c225dcd0d5fd2f61c BENCHMARK_EVIDENCE.json
304b20c046a82dd9cd6269151ecbe09f7807fd81376c227121f77517321cb126 INFERRSI.md
-63a0150c8d85d98620be1fbd2693c446680f7911ccb77fc347870f48b79eb0e9 LOCAL_PREVIEW.md
+49ba892018cdb9707293ffa38351b63834234ad258430efbbd792b42043c124e LOCAL_PREVIEW.md
51ce2153087f31b813b2ba275c3d9456af4e5a951f46ebaf97d59ee9ce10d8aa README.md
fafa3b155e999c060cdd9b884484c968348929397362a03483dabfd84ef2a62e SHOWCASE.md
-73813d0d5fa345698a7b7642f04847888a0153316e9ef532f0b9cfe8f8ba16b3 STAR_SETUP.md
+5b52331f0c9bfa13ec0174f62be1efd764fe03ed19ca659c5868bf42245dc387 STAR_SETUP.md
0bc3d5f7bd34ac53892f39ab97de48a9d771ddbfc35676255fa46292e4dcae81 app.js
53cf5937ee480102fbabd8e26a52adf1e936a49c2c24d0fc1948465982466017 assets/architecture/afd.svg
c2d920840043570ad8b25f1f43f87a2afe72c7e7bbac5a7309b599929032bd45 assets/architecture/afd.svg.license
@@ -47,15 +47,15 @@ acfa2bbd15ebbe680b46d0a542b4af11b893f425340d8ffe13e35f1f66523706 inferrsi.js
ae0fe01b8c81c20e4a607f8608b84ac0df3b42f9e38bcaaefde92cda5c63a782 initial-data.json
0e8566ea362276878e1f38f97da49964dbea301c15d4aa9d727f97a4c39fd943 licenses/afd-plugin-LICENSE.txt
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 licenses/vllm-omni-LICENSE.txt
-0c7bb254b590efd08f16e91d5677a79d96ee5acad1dde7ee1a0bc141f69950aa local-preview.cjs
+9cf05d3e1319bd85148c179fb934925883df8dff4f836dbfc53d2626ccfcedb4 local-preview.cjs
250f9ee919523549bbabe67fa4585a2772a405f8364b2a370b4020e61aedb8b6 prepare-initial-content.cjs
33b901a79c77f170fd4f11d7a67113f31f96bf91c58006d8d3d5a7530bb90d58 qa-interactions.cjs
-0d3d6a2ba3b21100c5d86a8a325244ae205eb78f69551709bedd41db5cbf6cc4 qa-stars.cjs
+536fe19b242a6c8e5bebf4229c7081b4ab82bb08627b7c6c3c1d32495ffd590c qa-stars.cjs
51a3881b90c67476605580f3a859becf174b8bf67d37c5c1200324613f2ab42d robots.txt
62f5e8d2444784d0e8df287e3b28931d1a259bf61d71c0636abb8a16add3ba31 showcase.js
741e991c7d0bc12c49acc061ba697dcc5e57946380c708cc823f7fadcb3d6485 sitemap.xml
a6c7baab91939560795b458fe0246e7a111f128badc135e1b766f5a46f806ff2 star-repositories.json
-e1c71a66f7059821849ff403f9834d17eecef086462d6b08ed285164fc15009f star-service.cjs
-c892cd8794c3cba5a4b6b3bee0b2c57921a0fa6ab8df1269acc6b4046012df39 stars.css
-4445f6e99f1af780aaf2ca3ecb556a07346065dc267d75d88c5d3f00f4010589 stars.js
+20c327926e78e187d970c6e83a6ea1e4131cbea0fdf703781aa3f88ce37e1116 star-service.cjs
+90f0dbbeb5f1e180c83769982124c455fe21fe1489a01aaa0e93845a5932b51d stars.css
+8b53a00d84c1e1096e7f0fc91da3f2af203e4709fa0bae9d5ee062174eb97125 stars.js
8ad287859fb0665c8114c44cce235ec47974c9dae9204bf9775540f3c430c712 styles.css
diff --git a/STAR_SETUP.md b/STAR_SETUP.md
index 0b4dd5e..65d868b 100644
--- a/STAR_SETUP.md
+++ b/STAR_SETUP.md
@@ -5,20 +5,34 @@ seven ecosystem projects, three engine examples and the marketing asset library.
`star-repositories.json` is the server allowlist; update it alongside homepage
links. `node --test qa-stars.cjs` checks coverage and the authorization boundary.
-## Configure a project-owned GitHub App
+## Local test without a GitHub App
-1. Register a public GitHub App with the project's homepage URL. Enable **Device
- Flow**. Disable webhooks if unused. No callback or client secret is needed for
- this flow.
+Run `node local-preview.cjs --star-demo`, then open
+. Click the bulk-Star entry, choose simulated
+authorization, and approve on the clearly labelled local confirmation page.
+It returns to the homepage and shows simulated per-repository results. There
+are no verification codes, credentials or GitHub requests in this mode. The
+banner and results explicitly say simulation. Demo mode refuses public origins
+and is disabled by default. Do not expose the demo server publicly.
+
+## Configure a project-owned GitHub App (once, by the operator)
+
+1. Register a public GitHub App with the project's homepage URL. Add the exact
+ callback URL `/api/stars/callback`. For local real integration,
+ register `http://127.0.0.1:4173/api/stars/callback` as well. Disable webhooks if
+ unused. Device Flow is not needed.
2. Grant **Account permissions → Starring: read and write** and the required
**Repository permissions → Metadata: read**. Do not grant code-write access.
-3. Inject the public Client ID as `GITHUB_STAR_CLIENT_ID` in the server process.
- Do not paste user access tokens or secrets into the page or tracked files.
+3. Inject the Client ID as `GITHUB_STAR_CLIENT_ID` and the Client Secret as
+ `GITHUB_STAR_CLIENT_SECRET` into the server process at runtime. Keep the
+ secret in the hosting provider's secret store; never send it in chat or
+ commit it to this repository. User tokens remain server-side as well.
4. Run `node local-preview.cjs` (Node 22+). Open .
- The first click lists repositories; “Authorize & star all” requests a code.
- The visitor opens GitHub, enters that code, and grants access. The page then
- automatically sends the batch and shows individual results. Retry skips
- successful items; existing stars are preserved by GitHub's idempotent PUT.
+ Visitors click “Authorize on GitHub & star”, sign in if needed, and approve.
+ GitHub returns them to the homepage, which automatically sends the batch and
+ shows individual results. No code entry, token copy or extra callback click
+ is needed. The repository list is expandable before authorization. Retry
+ skips successful items; existing stars are preserved by GitHub's idempotent PUT.
## Hosting
@@ -33,7 +47,10 @@ upstream payloads, or authorization codes. Apply edge request rate limits before
public exposure, especially to `/start`. Host the single-process API separately
from static file delivery; in-memory sessions are not shared between replicas.
-Tokens stay only in server memory, bound to a random HttpOnly/SameSite cookie.
+Tokens stay only in server memory, bound to a random HttpOnly/SameSite=Lax cookie.
+The web callback checks a session-bound random state and exchanges the code
+with S256 PKCE. The callback can be consumed only once; the frontend consumes
+an additional one-time resume marker before starting the automatic batch.
Sessions expire within 15 minutes; browser close of the dialog discards the
session, stopping remaining requests after any in-flight request. Successful
stars remain. Server restart requires fresh authorization. No refresh token is
@@ -46,5 +63,5 @@ If the service is unavailable or unconfigured, the page shows a clear notice and
individual repository links. It never substitutes opening tabs for a successful
batch operation.
-References: [GitHub App device flow](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token),
+References: [GitHub App web authorization](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token),
[Starring API and permissions](https://docs.github.com/en/rest/activity/starring#star-a-repository-for-the-authenticated-user).
diff --git a/local-preview.cjs b/local-preview.cjs
index eb08840..78b1318 100644
--- a/local-preview.cjs
+++ b/local-preview.cjs
@@ -4,7 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const root = __dirname;
const {createStarService}=require('./star-service.cjs');
-const starService=createStarService({clientId:process.env.GITHUB_STAR_CLIENT_ID,origin:process.env.STAR_PUBLIC_ORIGIN || `http://127.0.0.1:${process.env.MIRA_PREVIEW_PORT || 4173}`});
+const starDemo=process.argv.includes('--star-demo') || process.env.STAR_LOCAL_DEMO==='1';
+const starService=createStarService({clientId:process.env.GITHUB_STAR_CLIENT_ID,clientSecret:process.env.GITHUB_STAR_CLIENT_SECRET,demo:starDemo,origin:process.env.STAR_PUBLIC_ORIGIN || `http://127.0.0.1:${process.env.MIRA_PREVIEW_PORT || 4173}`});
const types = {'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp','.jpg':'image/jpeg','.jpeg':'image/jpeg','.mp4':'video/mp4','.txt':'text/plain; charset=utf-8'};
const server = http.createServer((req,res)=>{
if(req.url.startsWith('/api/stars/')){starService(req,res).catch(()=>{if(!res.headersSent)res.writeHead(500);res.end();});return;}
@@ -20,4 +21,4 @@ const server = http.createServer((req,res)=>{
});
});
server.on('error',error=>{console.error(error.message);process.exit(1);});
-server.listen(Number(process.env.MIRA_PREVIEW_PORT || 4173),'127.0.0.1',()=>console.log(`MIRA local preview: http://127.0.0.1:${server.address().port}`));
+server.listen(Number(process.env.MIRA_PREVIEW_PORT || 4173),'127.0.0.1',()=>console.log(`MIRA local preview: http://127.0.0.1:${server.address().port}${starDemo?' (local Star simulation, no GitHub requests)':''}`));
diff --git a/qa-stars.cjs b/qa-stars.cjs
index 48a2977..92f9c36 100644
--- a/qa-stars.cjs
+++ b/qa-stars.cjs
@@ -1,33 +1,28 @@
'use strict';
-const {test}=require('node:test');
-const assert=require('node:assert/strict');
-const http=require('node:http');
-const fs=require('node:fs');
+const {test}=require('node:test'),assert=require('node:assert/strict'),http=require('node:http'),fs=require('node:fs'),{createHash}=require('node:crypto');
const {createStarService}=require('./star-service.cjs');
-const repos=require('./star-repositories.json');
-const origin='https://example.com';
-async function fixture(t,{configured=true,status=204,pollError=null}={}){
- let time=100000,puts=[],tokenCalls=0;
+const repos=require('./star-repositories.json'),origin='https://example.com';
+async function fixture(t,{configured=true,status=204,exchangeError=false,demo=false}={}){
+ let time=100000,puts=[],exchanges=[],challenge;
const fetcher=async(url,options)=>{
- if(url.endsWith('/device/code'))return Response.json({device_code:'synthetic-device',user_code:'TEST-CODE',interval:5,expires_in:900});
- if(url.endsWith('/access_token')){tokenCalls++;return Response.json(pollError?{error:pollError}:{access_token:'synthetic-token'});}
- assert.match(url,/^https:\/\/api.github.com\/user\/starred\//);assert.equal(options.method,'PUT');assert.equal(options.headers.Authorization,'Bearer synthetic-token');
- puts.push(url.split('/starred/')[1]);return new Response(null,{status:typeof status==='function'?status(puts.length):status});
+ if(demo)throw Error('Demo must never call GitHub');
+ if(url.endsWith('/access_token')){const params=new URLSearchParams(options.body);exchanges.push(params);assert.equal(params.get('client_secret'),'synthetic-secret');assert.equal(params.get('redirect_uri'),origin+'/api/stars/callback');assert.equal(createHash('sha256').update(params.get('code_verifier')).digest('base64url'),challenge);return Response.json(exchangeError?{error:'bad_code'}:{access_token:'synthetic-token'});}
+ assert.match(url,/^https:\/\/api.github.com\/user\/starred\//);assert.equal(options.method,'PUT');assert.equal(options.headers.Authorization,'Bearer synthetic-token');puts.push(url.split('/starred/')[1]);return new Response(null,{status:typeof status==='function'?status(puts.length):status});
};
- const handler=createStarService({clientId:configured?'synthetic-client':'',origin,fetcher,now:()=>time,pause:async()=>{}});
+ const serviceOrigin=demo?'http://127.0.0.1:4173':origin;
+ const handler=createStarService({clientId:configured?'synthetic-client':'',clientSecret:configured?'synthetic-secret':'',origin:serviceOrigin,fetcher,now:()=>time,pause:async()=>{},demo});
const server=http.createServer((req,res)=>handler(req,res));await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r)));
let cookie='';
- const request=async(path,options={})=>{const r=await fetch(`http://127.0.0.1:${server.address().port}/api/stars/${path}`,{method:'POST',headers:{Origin:origin,'X-Requested-With':'InferMatrixStars',Cookie:cookie,...options.headers},...options});const c=r.headers.get('set-cookie');if(c)cookie=c.split(';')[0];return {status:r.status,data:await r.json(),cookie:c};};
- return {request,advance:ms=>time+=ms,puts,tokenCalls:()=>tokenCalls,login:async()=>{await request('start');time+=5000;return request('poll');}};
+ const request=async(path,options={})=>{const r=await fetch(`http://127.0.0.1:${server.address().port}/api/stars/${path}`,{method:'POST',redirect:'manual',...options,headers:{Origin:serviceOrigin,'X-Requested-With':'InferMatrixStars',Cookie:cookie,...options.headers}});const c=r.headers.get('set-cookie');if(c)cookie=c.split(';')[0];const raw=await r.text();return {status:r.status,data:r.headers.get('content-type')?.includes('application/json')?JSON.parse(raw):null,text:raw,cookie:c,location:r.headers.get('location')};};
+ const start=async()=>{const r=await request('start');const u=new URL(r.data.authorizeUrl);challenge=u.searchParams.get('code_challenge');return {response:r,url:u,state:u.searchParams.get('state')};};
+ return {request,start,advance:ms=>time+=ms,puts,exchanges,login:async()=>{const d=await start();return request('callback?state='+d.state+'&code=synthetic-code',{method:'GET'});}};
}
-test('catalog matches every homepage GitHub repository, without duplicate case variants',()=>{
- const linked=[...fs.readFileSync('index.html','utf8').matchAll(/https:\/\/github.com\/([\w.-]+\/[\w.-]+)/g)].map(m=>m[1].toLowerCase());
- assert.deepEqual([...new Set(linked)].sort(),repos.map(r=>r.toLowerCase()).sort());assert.equal(new Set(repos.map(r=>r.toLowerCase())).size,repos.length);
-});
-test('unconfigured service is explicit and does not authorize',async t=>{const f=await fixture(t,{configured:false});assert.equal((await f.request('status',{method:'GET'})).data.configured,false);assert.equal((await f.request('start')).status,503);});
-test('reject cross-origin and missing custom header; batch requires login',async t=>{const f=await fixture(t);assert.equal((await f.request('start',{headers:{Origin:'https://other.example'}})).status,403);assert.equal((await f.request('start',{headers:{Origin:origin}})).status,403);assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);});
-test('device polling is throttled and tokens never leave the server; retries are idempotent',async t=>{const f=await fixture(t);const start=await f.request('start');assert.match(start.cookie,/HttpOnly; SameSite=Strict; Path=\/; Max-Age=900; Secure/);assert.equal(start.data.device_code,undefined);assert.equal((await f.request('poll')).data.pending,true);assert.equal(f.tokenCalls(),0);f.advance(5000);assert.deepEqual((await f.request('poll')).data,{authorized:true});const batch=await f.request('all');assert.ok(batch.data.results.every(r=>r.ok));assert.deepEqual(f.puts,repos);await f.request('all');assert.equal(f.puts.length,repos.length);assert.ok(!JSON.stringify(batch).includes('synthetic-token'));});
-test('rate limit stops the batch and never reports unattempted stars as success',async t=>{const f=await fixture(t,{status:403});await f.login();const batch=await f.request('all');assert.equal(f.puts.length,1);assert.equal(batch.data.results.length,repos.length);assert.ok(batch.data.results.every(r=>!r.ok));});
-test('partial success retries only failed repositories',async t=>{const f=await fixture(t,{status:n=>n===2?500:204});await f.login();assert.equal((await f.request('all')).data.results.filter(r=>r.ok).length,repos.length-1);await f.request('all');assert.equal(f.puts.length,repos.length+1);assert.equal(f.puts.at(-1),repos[1]);});
-test('slow_down extends the poll interval; denial and expiry clear sessions',async t=>{const f=await fixture(t,{pollError:'slow_down'});await f.request('start');f.advance(5000);assert.equal((await f.request('poll')).data.interval,10);f.advance(5000);await f.request('poll');assert.equal(f.tokenCalls(),1);f.advance(900000);assert.equal((await f.request('poll')).status,401);const g=await fixture(t,{pollError:'access_denied'});await g.request('start');g.advance(5000);assert.equal((await g.request('poll')).status,401);assert.equal((await g.request('all')).status,401);});
-test('logout discards authorization',async t=>{const f=await fixture(t);await f.login();await f.request('logout');assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);});
+test('catalog matches all homepage repositories without duplicates',()=>{const linked=[...fs.readFileSync('index.html','utf8').matchAll(/https:\/\/github.com\/([\w.-]+\/[\w.-]+)/g)].map(m=>m[1].toLowerCase());assert.deepEqual([...new Set(linked)].sort(),repos.map(r=>r.toLowerCase()).sort());assert.equal(new Set(repos.map(r=>r.toLowerCase())).size,repos.length);});
+test('unconfigured service explicitly disables authorization',async t=>{const f=await fixture(t,{configured:false});assert.equal((await f.request('status',{method:'GET'})).data.configured,false);assert.equal((await f.request('start')).status,503);});
+test('cross-origin or missing header cannot start or star',async t=>{const f=await fixture(t);assert.equal((await f.request('start',{headers:{Origin:'https://other.example'}})).status,403);assert.equal((await f.request('start',{headers:{'X-Requested-With':''}})).status,403);assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);});
+test('web authorization uses state, PKCE and server-only credentials; callback resumes once',async t=>{const f=await fixture(t),d=await f.start();assert.match(d.response.cookie,/HttpOnly; SameSite=Lax; Path=\/; Max-Age=900; Secure/);assert.equal(d.url.origin,'https://github.com');assert.equal(d.url.searchParams.get('code_challenge_method'),'S256');assert.ok(!JSON.stringify(d.response).includes('synthetic-secret'));const callback=await f.request('callback?state='+d.state+'&code=synthetic-code',{method:'GET'});assert.equal(callback.location,'/?star=authorized#projects');assert.ok(!JSON.stringify(callback).includes('synthetic-token'));assert.equal((await f.request('resume')).data.authorized,true);assert.equal((await f.request('resume')).data.authorized,false);assert.equal((await f.request('callback?state='+d.state+'&code=synthetic-code',{method:'GET'})).status,400);assert.ok((await f.request('all')).data.results.every(r=>r.ok));assert.deepEqual(f.puts,repos);await f.request('all');assert.equal(f.puts.length,repos.length);});
+test('missing/wrong state or cookie does not exchange code; expired callback fails',async t=>{const f=await fixture(t),d=await f.start();for(const state of ['bad', '💠'.repeat(16),''])assert.equal((await f.request('callback?code=x&state='+encodeURIComponent(state),{method:'GET'})).status,400);assert.equal((await f.request('callback?code=x&state='+d.state,{method:'GET',headers:{Cookie:''}})).status,400);assert.equal(f.exchanges.length,0);f.advance(900001);assert.equal((await f.request('callback?code=x&state='+d.state,{method:'GET'})).status,400);});
+test('denial or failed exchange never grants Star access',async t=>{const f=await fixture(t),d=await f.start();assert.equal((await f.request('callback?state='+d.state+'&error=access_denied',{method:'GET'})).location,'/?star=denied#projects');assert.equal((await f.request('all')).status,401);const g=await fixture(t,{exchangeError:true});assert.equal((await g.login()).location,'/?star=error#projects');assert.equal((await g.request('all')).status,401);});
+test('partial retry skips success and rate limit stops the batch',async t=>{const f=await fixture(t,{status:n=>n===2?500:204});await f.login();assert.equal((await f.request('all')).data.results.filter(r=>r.ok).length,repos.length-1);await f.request('all');assert.equal(f.puts.length,repos.length+1);assert.equal(f.puts.at(-1),repos[1]);const g=await fixture(t,{status:403});await g.login();assert.ok((await g.request('all')).data.results.every(r=>!r.ok));assert.equal(g.puts.length,1);});
+test('logout discards session and tokens',async t=>{const f=await fixture(t);await f.login();await f.request('logout');assert.equal((await f.request('all')).status,401);assert.equal(f.puts.length,0);});
+test('local simulation completes with no GitHub traffic and is prohibited on public origins',async t=>{assert.throws(()=>createStarService({demo:true,origin}),/loopback/);const f=await fixture(t,{demo:true}),d=await f.start();assert.equal(d.url.pathname,'/api/stars/demo-consent');const consent=await f.request('demo-consent?state='+d.state,{method:'GET'});assert.match(consent.text,/不会修改真实 Star/);assert.equal((await f.request('callback?code=synthetic&state='+d.state,{method:'GET'})).status,303);const batch=await f.request('all');assert.equal(batch.data.demo,true);assert.ok(batch.data.results.every(r=>r.ok));assert.equal(f.exchanges.length+f.puts.length,0);});
diff --git a/star-service.cjs b/star-service.cjs
index 8a86db8..ee67de9 100644
--- a/star-service.cjs
+++ b/star-service.cjs
@@ -1,81 +1,89 @@
'use strict';
-const {randomBytes}=require('node:crypto');
+const {randomBytes,createHash,timingSafeEqual}=require('node:crypto');
const repositories=require('./star-repositories.json');
-// Single-process, short-lived sessions. Never serialize tokens or log upstream responses.
-function createStarService({clientId='',origin='http://127.0.0.1:4173',fetcher=fetch,now=Date.now,pause=ms=>new Promise(r=>setTimeout(r,ms))}={}) {
+function createStarService({clientId='',clientSecret='',origin='http://127.0.0.1:4173',demo=false,fetcher=fetch,now=Date.now,pause=ms=>new Promise(r=>setTimeout(r,ms))}={}) {
+ const site=new URL(origin);
+ if(demo && !['127.0.0.1','localhost','[::1]'].includes(site.hostname))throw Error('Demo is loopback-only');
+ const configured=demo || !!(clientId && clientSecret);
+ const callback=origin+'/api/stars/callback';
const sessions=new Map();
const forget=key=>{clearTimeout(sessions.get(key)?.timer);sessions.delete(key);};
- const cookieName=new URL(origin).protocol==='https:'?'__Host-infermatrix-star':'infermatrix-star';
+ const cookieName=site.protocol==='https:'?'__Host-infermatrix-star':'infermatrix-star';
+ const matches=(a,b)=>typeof a==='string' && typeof b==='string' && Buffer.byteLength(a)===Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a),Buffer.from(b));
async function upstream(url,options){return fetcher(url,{...options,redirect:'error',signal:AbortSignal.timeout(15000)});}
- async function oauth(path,params){const r=await upstream('https://github.com/login/'+path,{method:'POST',headers:{Accept:'application/json','Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams(params)});if(!r.ok)throw Error('upstream');return r.json();}
return async function handle(req,res){
- const path=new URL(req.url,origin).pathname;
- const send=(status,data)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'});res.end(JSON.stringify(data));};
- if(path==='/api/stars/status' && req.method==='GET'){send(200,{configured:!!clientId,repositories});return;}
+ const url=new URL(req.url,origin),path=url.pathname;
+ const headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'};
+ const send=(status,data)=>{res.writeHead(status,{...headers,'Content-Type':'application/json'});res.end(JSON.stringify(data));};
+ const redirect=result=>{res.writeHead(303,{...headers,Location:'/?star='+result+'#projects'});res.end();};
+ for(const [key,s]of sessions)if(s.expires<=now())forget(key);
+ const id=(req.headers.cookie||'').split(';').map(c=>c.trim()).find(c=>c.startsWith(cookieName+'='))?.slice(cookieName.length+1);
+ const session=sessions.get(id);
+ // Lax is required for GitHub's top-level callback; state + PKCE bind that callback.
+ const cookie=(value,age)=>res.setHeader('Set-Cookie',`${cookieName}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${age}${site.protocol==='https:'?'; Secure':''}`);
+ if(path==='/api/stars/status' && req.method==='GET'){send(200,{configured,demo,repositories});return;}
+ if(path==='/api/stars/demo-consent' && req.method==='GET'){
+ if(!demo || !session || !matches(url.searchParams.get('state'),session.state)){send(404,{error:'not_found'});return;}
+ const state=session.state;
+ res.writeHead(200,{...headers,'Content-Type':'text/html; charset=utf-8','Content-Security-Policy':"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"});
+ res.end(`本地模拟授权
';
document.body.append(dialog);
const $=s=>dialog.querySelector(s),button=$('.star-all-submit'),status=$('.star-all-status');
- let configured=false,busy=false,authorized=false,timer=null,generation=0,catalog=[],results=null;
+ let configured=false,demo=false,busy=false,authorized=false,generation=0,catalog=[],results=null,phase='loading';
const request=async path=>{const r=await fetch('/api/stars/'+path,{method:'POST',credentials:'same-origin',headers:{'X-Requested-With':'InferMatrixStars'}});const d=await r.json();if(!r.ok)throw Error(d.error||'unavailable');return d;};
let authorizationRequests=Promise.resolve();
- // Order cookie-changing requests so a late start cannot replace a newer session.
- const api=path=>{if(path==='all')return request(path);const operation=authorizationRequests.then(()=>request(path));authorizationRequests=operation.catch(()=>{});return operation;};
- function translate(){entry.textContent=copy('☆ 一键 Star 全部仓库','☆ Star all repositories');$('.star-all-close').setAttribute('aria-label',copy('关闭 Star 窗口','Close Star dialog'));$('#star-all-title').textContent=copy('支持 InferMatrix 开源生态','Support the InferMatrix ecosystem');$('.star-all-description').textContent=copy(`为主页展示的 ${catalog.length} 个 GitHub 仓库添加 Star。首次需在 GitHub 登录并授权,授权后自动完成;已经 Star 的仓库保持不变。`,`Star all ${catalog.length} GitHub repositories linked on this homepage. First sign in and authorize on GitHub; the batch then runs automatically. Existing stars stay starred.`);button.textContent=busy?copy('正在处理…','Working…'):authorized?copy('重试未成功的仓库','Retry unsuccessful repositories'):copy('授权并一键 Star','Authorize & star all');$('.star-all-auth p').textContent=copy('在 GitHub 输入此验证码,确认仅授予 Star 权限:','Enter this code on GitHub and review the Starring permission:');$('.star-all-auth a').textContent=copy('前往 GitHub 授权 ↗','Authorize on GitHub ↗');renderList();}
- function renderList(){const list=$('.star-all-list');list.replaceChildren();for(const repository of catalog){const li=document.createElement('li'),a=document.createElement('a');a.href='https://github.com/'+repository;a.target='_blank';a.rel='noopener noreferrer';a.textContent=repository;li.append(a);const r=results?.find(x=>x.repository===repository);if(r){const label=document.createElement('span');label.textContent=r.ok?copy('已 Star ✓','Starred ✓'):copy('未成功 · 可重试','Unsuccessful · retry');label.className=r.ok?'star-ok':'star-error';li.append(label);}list.append(li);}}
- function reset(){clearTimeout(timer);generation++;busy=false;authorized=false;results=null;$('.star-all-auth').hidden=true;button.disabled=!configured;translate();}
- async function load(){reset();status.textContent=copy('正在获取仓库清单…','Loading repositories…');try{const r=await fetch('/api/stars/status',{cache:'no-store'});if(!r.ok)throw Error();const d=await r.json();configured=d.configured;catalog=d.repositories;}catch{configured=false;try{catalog=await (await fetch('star-repositories.json')).json();}catch{catalog=[];}}button.disabled=!configured;status.textContent=configured?copy('仅操作以下仓库,GitHub 访问令牌仅保存在服务端。','Only the repositories below will be starred. GitHub access tokens stay on the server.'):copy('批量 Star 服务尚未配置。你可以先通过清单中的链接访问各仓库。','Bulk Star is not configured yet. You can visit each repository below.');translate();}
- async function star(run){status.textContent=copy('正在逐个添加 Star,请稍候…','Starring repositories, please wait…');const d=await api('all');if(run!==generation)return;results=d.results;const count=results.filter(r=>r.ok).length;status.textContent=copy(`已 Star ${count} / ${catalog.length} 个仓库。`,`Starred ${count} / ${catalog.length} repositories.`);busy=false;button.disabled=count===catalog.length;translate();}
- async function poll(run,interval){timer=setTimeout(async()=>{if(run!==generation)return;try{const d=await api('poll');if(run!==generation)return;if(d.authorized){authorized=true;$('.star-all-auth').hidden=true;await star(run);}else poll(run,d.interval||interval);}catch{if(run===generation)fail();}},interval*1000);}
- function fail(){clearTimeout(timer);busy=false;status.textContent=copy('操作未完成,可能是授权过期、权限不足或 GitHub 暂时不可用。请关闭窗口后重试;已添加的 Star 会保留。','Not completed: authorization may have expired, permissions may be missing, or GitHub may be unavailable. Close and reopen to retry; completed stars are retained.');button.disabled=true;translate();}
- entry.addEventListener('click',()=>{dialog.showModal();load();});
- button.addEventListener('click',async()=>{if(busy)return;busy=true;button.disabled=true;translate();const run=generation;try{if(authorized){await star(run);return;}await api('logout');if(run!==generation)return;const d=await api('start');if(run!==generation)return;$('.star-all-code').textContent=d.userCode;$('.star-all-auth').hidden=false;status.textContent=copy('等待 GitHub 授权;完成后会自动 Star 上方全部仓库。','Waiting for GitHub authorization; all repositories above will then be starred automatically.');poll(run,d.interval);}catch{if(run===generation)fail();}});
+ const api=path=>{if(path==='all')return request(path);const op=authorizationRequests.then(()=>request(path));authorizationRequests=op.catch(()=>{});return op;};
+ function translate(){
+ entry.textContent=copy('☆ 一键 Star 全部仓库','☆ Star all repositories');
+ banner.hidden=!demo;banner.textContent=copy('本地模拟测试 · 不会登录 GitHub 或修改真实 Star','Local simulation · no GitHub login or real stars');
+ $('.star-all-close').setAttribute('aria-label',copy('关闭 Star 窗口','Close Star dialog'));
+ $('#star-all-title').textContent=copy('支持 InferMatrix 开源生态','Support the InferMatrix ecosystem');
+ $('.star-all-description').textContent=copy(`一次支持主页展示的 ${catalog.length} 个仓库。前往 GitHub 登录并授权,返回后自动完成。已添加的 Star 保持不变。`,`Support all ${catalog.length} repositories. Sign in and authorize on GitHub; stars are added automatically when you return. Existing stars stay starred.`);
+ $('summary').textContent=copy(`查看全部 ${catalog.length} 个仓库`,`View all ${catalog.length} repositories`);
+ button.textContent=busy?copy('正在处理…','Working…'):phase==='done'&&results?.every(r=>r.ok)?copy(demo?'模拟体验完成 ✓':'已完成 ✓',demo?'Simulation complete ✓':'Complete ✓'):authorized?copy('重试未成功的仓库','Retry unsuccessful repositories'):demo?copy('体验模拟授权 →','Try simulated authorization →'):copy('前往 GitHub 授权并 Star →','Authorize on GitHub & star →');
+ const messages={loading:copy('正在加载…','Loading…'),ready:demo?copy('模拟流程:确认授权 → 自动返回 → 展示结果。不会产生真实 Star。','Simulation: approve → return → view results. No real stars are added.'):copy('只需在 GitHub 确认一次,无需复制验证码或提供访问令牌。','Approve on GitHub. No verification code or access token to copy.'),unconfigured:copy('授权服务尚未配置。你仍可展开清单访问各仓库。','Authorization is not configured. Expand the list to visit the repositories.'),authorizing:copy('正在前往授权页面…','Opening authorization…'),resuming:copy('正在恢复授权,请稍候…','Restoring authorization, please wait…'),starring:copy('正在逐个添加 Star,请稍候…','Starring repositories, please wait…'),error:copy('操作未完成,请重试。已添加的 Star 会保留。','Not completed. Retry to continue; completed stars are retained.'),denied:copy('已取消授权,没有执行新的 Star 操作。','Authorization cancelled. No new stars were added.'),expired:copy('授权已过期,请重新授权。','Authorization expired. Please authorize again.'),done:copy(`${demo?'模拟结果:':''}已 Star ${results?.filter(r=>r.ok).length||0} / ${catalog.length} 个仓库。`,`${demo?'Simulated result: ':''}Starred ${results?.filter(r=>r.ok).length||0} / ${catalog.length} repositories.`)};
+ status.textContent=messages[phase]||messages.error;renderList();
+ }
+ function renderList(){const list=$('.star-all-list');list.replaceChildren();for(const repository of catalog){const li=document.createElement('li'),a=document.createElement('a');a.href='https://github.com/'+repository;a.target='_blank';a.rel='noopener noreferrer';a.textContent=repository;li.append(a);const r=results?.find(x=>x.repository===repository);if(r){const label=document.createElement('span');label.textContent=r.ok?copy(demo?'模拟成功 ✓':'已 Star ✓',demo?'Simulated ✓':'Starred ✓'):copy('未成功 · 可重试','Unsuccessful · retry');label.className=r.ok?'star-ok':'star-error';li.append(label);}list.append(li);}}
+ async function configuration(){try{const r=await fetch('/api/stars/status',{cache:'no-store'});if(!r.ok)throw Error();const d=await r.json();configured=d.configured;demo=d.demo===true;catalog=d.repositories;}catch{configured=false;demo=false;try{catalog=await (await fetch('star-repositories.json')).json();}catch{catalog=[];}}}
+ async function open(resuming=false){const run=++generation;busy=true;phase='loading';authorized=false;results=null;$('details').open=false;button.disabled=true;dialog.showModal();translate();await configuration();if(run!==generation)return null;busy=resuming;phase=resuming?'resuming':configured?'ready':'unconfigured';button.disabled=resuming || !configured;translate();return run;}
+ async function star(run){phase='starring';busy=true;button.disabled=true;translate();const d=await api('all');if(run!==generation)return;results=d.results;const expired=results.some(r=>r.status===401);if(expired)authorized=false;phase=expired?'expired':'done';busy=false;button.disabled=results.every(r=>r.ok);$('details').open=true;translate();}
+ function fail(error){const expired=['expired','authorize_first'].includes(error?.message);if(expired)authorized=false;phase=expired?'expired':'error';busy=false;button.disabled=!configured;translate();}
+ entry.addEventListener('click',()=>{open();});
+ button.addEventListener('click',async()=>{
+ if(busy)return;busy=true;button.disabled=true;phase='authorizing';translate();const run=generation;
+ try{if(authorized){await star(run);return;}await api('logout');if(run!==generation)return;const d=await api('start');if(run!==generation)return;
+ const destination=new URL(d.authorizeUrl);
+ if(demo ? destination.origin!==location.origin || destination.pathname!=='/api/stars/demo-consent' : destination.origin!=='https://github.com' || destination.pathname!=='/login/oauth/authorize')throw Error('authorization_url');
+ location.assign(destination.href);
+ }catch(error){if(run===generation)fail(error);}
+ });
$('.star-all-close').addEventListener('click',()=>dialog.close());
- dialog.addEventListener('close',()=>{reset();api('logout').catch(()=>{});entry.focus();});
- new MutationObserver(translate).observe(document.documentElement,{attributes:true,attributeFilter:['lang']});translate();
+ dialog.addEventListener('close',()=>{generation++;busy=false;authorized=false;results=null;api('logout').catch(()=>{});entry.focus();});
+ new MutationObserver(translate).observe(document.documentElement,{attributes:true,attributeFilter:['lang']});
+ const returned=new URL(location.href),result=returned.searchParams.get('star');
+ if(result){returned.searchParams.delete('star');history.replaceState(null,'',returned.pathname+returned.search+returned.hash);open(result==='authorized').then(async run=>{if(run===null || run!==generation)return;try{if(result==='authorized'){const d=await api('resume');if(run!==generation)return;if(!d.authorized){busy=false;button.disabled=!configured;phase='expired';translate();return;}authorized=true;await star(run);}else{phase=['denied','expired'].includes(result)?result:'error';translate();}}catch(error){if(run===generation)fail(error);}});}
+ else configuration().then(translate);
+ translate();
})();