@whyshu/argent 0.1.27 → 0.2.1

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.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SkuFlow FULL runner 的纯辅助:任务→orchestrator record、产物 PDF 分类。
3
+ * 与 office-runtime 解耦,便于回归(不启 dsh / 不跑浏览器)。
4
+ */
5
+ import { readdirSync, statSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ export function buildSkuFlowRecord(task = {}) {
9
+ return {
10
+ record_id: `skuflow:${String(task.skuFlowId || '')}`,
11
+ store_name: String(task.store || ''),
12
+ sku: String(task.sku || ''),
13
+ qty: String(task.quantity ?? ''),
14
+ box: String(task.boxCount ?? ''),
15
+ }
16
+ }
17
+
18
+ /** 从下载目录找本次 FULL 产生的产品标/箱唛 PDF(mtime >= startedAt-60s,最新优先)。 */
19
+ export function findSkuFlowArtifacts({
20
+ dir, startedAt = Date.now(), shipmentNo = '',
21
+ readdir = readdirSync, stat = statSync,
22
+ } = {}) {
23
+ const files = readdir(dir)
24
+ .filter((n) => /\.pdf$/i.test(n))
25
+ .map((n) => ({ name: n, path: join(dir, n), mtime: stat(join(dir, n)).mtimeMs }))
26
+ .filter((f) => f.mtime >= startedAt - 60_000)
27
+ .sort((a, b) => b.mtime - a.mtime)
28
+ const product = files.find((f) => /产品标|Etiquetas-de-producto|producto/i.test(f.name))
29
+ const carton = files.find((f) => f !== product && (
30
+ /Envio-|Etiquetas-de-volumenes|箱唛|bultos/i.test(f.name) ||
31
+ (shipmentNo && f.name.includes(String(shipmentNo)))
32
+ ))
33
+ return { product, carton, files }
34
+ }
35
+
36
+ /**
37
+ * 多目录兜底(2026-11-03):
38
+ * 紫鸟的下载目录有两种历史形态 —— `<ziniaobrowserdatas>/ziniao browser/<店铺名>/`
39
+ * 与直接落在 `<ziniao browser>/`(编排器默认 ZINIAO_DL 就是后者)。
40
+ * 之前只查带店铺名的子目录,导致「PDF 明明已生成却报未找到」。
41
+ * 依次尝试,拿到 product+carton 即返回;都拿不到时返回最后一个能读到的结果(便于报错诊断)。
42
+ */
43
+ export function findSkuFlowArtifactsIn({
44
+ dirs = [], startedAt = Date.now(), shipmentNo = '',
45
+ readdir = readdirSync, stat = statSync,
46
+ } = {}) {
47
+ let last = { product: null, carton: null, files: [] }
48
+ for (const dir of dirs) {
49
+ if (!dir) continue
50
+ try {
51
+ const found = findSkuFlowArtifacts({ dir, startedAt, shipmentNo, readdir, stat })
52
+ last = found
53
+ if (found.product && found.carton) return found
54
+ } catch { /* 目录不存在/不可读:试下一个 */ }
55
+ }
56
+ return last
57
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 日报定时器状态(桌宠侧,2026-10-30)。
3
+ *
4
+ * 背景:日报从「launchd 唤醒 LLM 会话」改成「launchd 直跑确定性脚本」后,
5
+ * 台账/会话里**再也看不到**它 —— 这正是 2026-10-30 事故的静默面:
6
+ * 定时器每 5 分钟都在跑、每轮都失败,launchctl 只看得到"最后 exit 0"的那一轮,
7
+ * 客户侧没有任何提示。引擎侧现在每轮落 `<数据根>/daily-report-timer-status.json`,
8
+ * 本模块把它 + LaunchAgent 的在册/加载状态合成一条**桌宠可显示**的判据。
9
+ *
10
+ * 纪律:
11
+ * · 判据是**纯函数**(`analyzeTimer()`),文件/launchctl 只是取数(`collectTimer()`);
12
+ * · 只读,不写任何文件、不 bootstrap/bootout;
13
+ * · 不返回 token/消息正文;状态文件缺失 ⇒ 如实说"没安装",⛔ 不假装正常。
14
+ */
15
+ import { execFile } from 'node:child_process'
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ export const TIMER_LABEL = 'com.whyshu.daily-report-timer'
21
+ export const STATUS_FILE = 'daily-report-timer-status.json'
22
+ /** 定时器每小时跑一次 ⇒ 超过这个窗口没运行就是"停摆"(2 轮)。 */
23
+ export const STALE_MS = 2 * 60 * 60 * 1000
24
+ /** 桌宠每 5s 拉一次健康;launchctl 探测按这个 TTL 缓存(别每 5s spawn 一次)。 */
25
+ const LAUNCHCTL_TTL = 60_000
26
+ const cache = { at: 0, value: null }
27
+
28
+ export const plistPath = () => join(homedir(), 'Library', 'LaunchAgents', `${TIMER_LABEL}.plist`)
29
+ export const statusPath = (dataDir) => join(dataDir, STATUS_FILE)
30
+
31
+ function readJson(file) {
32
+ try {
33
+ const v = JSON.parse(readFileSync(file, 'utf8'))
34
+ return v && typeof v === 'object' ? v : null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ const fmtTime = (iso) => {
41
+ const t = Date.parse(iso)
42
+ if (!Number.isFinite(t)) return '—'
43
+ return new Date(t).toLocaleString('zh-CN', {
44
+ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
45
+ })
46
+ }
47
+ /** "今天 14:05 / 明天 11:05 / 10-31 11:05" —— 比"周五 15:06"更像人话。 */
48
+ const fmtDue = (iso, now = Date.now()) => {
49
+ const t = Date.parse(iso)
50
+ if (!Number.isFinite(t)) return '—'
51
+ const d = new Date(t)
52
+ const today = new Date(now)
53
+ const tomorrow = new Date(now)
54
+ tomorrow.setDate(tomorrow.getDate() + 1)
55
+ const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
56
+ const day = sameDay(d, today) ? '今天' : sameDay(d, tomorrow) ? '明天'
57
+ : d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
58
+ const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
59
+ return `${day} ${hm}`
60
+ }
61
+
62
+ /**
63
+ * 纯判据:状态文件 + LaunchAgent 事实 → 一条 health check + 给 UI 的结构化载荷。
64
+ * @param {{status?:object|null, installed?:boolean, loaded?:boolean, now?:number}} input
65
+ */
66
+ export function analyzeTimer({ status = null, installed = false, loaded = false, enabled = null, now = Date.now() } = {}) {
67
+ const base = {
68
+ label: TIMER_LABEL,
69
+ installed: Boolean(installed),
70
+ loaded: Boolean(loaded),
71
+ nextDueAt: status?.nextDueAt || '',
72
+ lastRunAt: status?.lastRunAt || '',
73
+ lastPushAt: status?.lastPushAt || '',
74
+ lastExit: status?.lastExit ?? null,
75
+ lastError: status?.lastError || '',
76
+ missedTotal: Number(status?.missedTotal || 0),
77
+ pending: Array.isArray(status?.pending) ? status.pending : [],
78
+ stores: status?.stores && typeof status.stores === 'object' ? status.stores : {},
79
+ }
80
+ const check = (level, detail) => ({
81
+ id: 'daily-report',
82
+ label: '日报定时器',
83
+ level,
84
+ detail,
85
+ timer: { ...base, level, detail },
86
+ })
87
+
88
+ // 2026-11-02:本机**明确不需要日报**(config.json 的 dailyReport.enabled=false)⇒ 一律"不适用"。
89
+ // 放在最前面:卸载后**状态文件会残留**,否则会一直报"状态文件在、但 LaunchAgent 不在册"。
90
+ // ⛔ 未声明(null)保持原语义,别把客户机的"忘装"静音。
91
+ if (enabled === false) return check('ok', '本机未启用日报(dailyReport.enabled=false,不适用)')
92
+ if (!installed && !status) {
93
+ if (enabled === true) return check('error', '已声明启用日报,但定时器未安装 ⇒ 不会推送(修:bash scripts/daily-report-timers.sh install)')
94
+ return check('warn', '未安装(客户机应有日报:bash scripts/daily-report-timers.sh install)')
95
+ }
96
+ if (!installed) {
97
+ return check('warn', `状态文件在、但 LaunchAgent 不在册(${TIMER_LABEL})⇒ 不会再自动跑`)
98
+ }
99
+ if (!loaded) {
100
+ return check('error', `LaunchAgent 未加载(${TIMER_LABEL})⇒ 定时器不会触发`)
101
+ }
102
+ if (!status || !status.lastRunAt) {
103
+ return check('warn', '已安装,但还没有运行记录(等下一轮,或看 daily-report-timer.err.log)')
104
+ }
105
+ const runAge = now - Date.parse(status.lastRunAt)
106
+ const nextLine = status.nextDueAt ? `下次 ${fmtDue(status.nextDueAt, now)}` : '下次时间未知'
107
+ const pushLine = status.lastPushAt ? `上次推送 ${fmtTime(status.lastPushAt)}` : '还没有成功推送记录'
108
+ if (status.lastExit) {
109
+ return check('error', `上次运行失败(exit ${status.lastExit}):${String(status.lastError || '无错误尾巴').slice(0, 160)}`)
110
+ }
111
+ if (Number.isFinite(runAge) && runAge > STALE_MS) {
112
+ return check('error', `超过 2h 没运行(上次 ${fmtTime(status.lastRunAt)})⇒ 定时器疑似停摆;${nextLine}`)
113
+ }
114
+ if (base.missedTotal > 0) {
115
+ const names = base.pending.slice(0, 3).map((p) => {
116
+ const days = Array.isArray(p.missed) ? p.missed : []
117
+ return `${p.store}${days.length ? `(${days[days.length - 1]})` : ''}`
118
+ }).join(';')
119
+ return check('warn', `有 ${base.missedTotal} 天未推送:${names}${base.pending.length > 3 ? ' 等' : ''};${nextLine}`)
120
+ }
121
+ return check('ok', `${nextLine} · ${pushLine} · 上次运行 ${fmtTime(status.lastRunAt)}`)
122
+ }
123
+
124
+ /** 取 LaunchAgent 是否在册/已加载(只读;`launchctl print` 非零 ⇒ 未加载)。 */
125
+ async function probeLaunchAgent({ exec = execFile, uid = typeof process.getuid === 'function' ? process.getuid() : 501, label = TIMER_LABEL } = {}) {
126
+ const installed = existsSync(plistPath())
127
+ if (!installed) return { installed, loaded: false }
128
+ const loaded = await new Promise((resolve) => {
129
+ try {
130
+ exec('launchctl', ['print', `gui/${uid}/${label}`], { timeout: 1500 }, (err, stdout, stderr) => {
131
+ resolve(!err && !/could not find service/i.test(String(stderr || stdout || '')))
132
+ })
133
+ } catch {
134
+ resolve(false)
135
+ }
136
+ })
137
+ return { installed, loaded }
138
+ }
139
+
140
+ /**
141
+ * 本机是否声明启用日报:`<数据根>/config.json` → `dailyReport.enabled`。
142
+ * 三态:true(应启用)/ false(本机不需要)/ null(未声明 ⇒ 保持旧语义)。
143
+ */
144
+ export function dailyReportEnabled(dataDir) {
145
+ const cfg = dataDir ? readJson(join(dataDir, 'config.json')) : null
146
+ const v = cfg && cfg.dailyReport && cfg.dailyReport.enabled
147
+ return typeof v === 'boolean' ? v : null
148
+ }
149
+
150
+ /** 取一次(带 TTL 缓存);`refresh=true` 强制重探。 */
151
+ export async function collectTimer({ dataDir, refresh = false, now = Date.now() } = {}) {
152
+ let la = cache.value
153
+ if (refresh || !la || now - cache.at > LAUNCHCTL_TTL) {
154
+ la = await probeLaunchAgent()
155
+ cache.value = la
156
+ cache.at = now
157
+ }
158
+ const status = dataDir ? readJson(statusPath(dataDir)) : null
159
+ return analyzeTimer({ status, installed: la.installed, loaded: la.loaded, enabled: dailyReportEnabled(dataDir), now })
160
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * 问述账号一键登录(RFC 8628 设备码)—— desktop host 侧。
3
+ *
4
+ * 复用 whyshu-svr 已注册的 client_id=argent_cli:
5
+ * POST https://whyshu.com/api/oauth/device → user_code / verification_uri / interval
6
+ * 用户浏览器打开 https://whyshu.com/oauth?user_code=… 授权
7
+ * POST https://whyshu.com/api/oauth/token → pending / access_token(当前 30 天 JWT)
8
+ * GET https://whyshu.com/api/oauth/userinfo → 昵称/手机号/plan
9
+ *
10
+ * 只把 token 写进 dsh credentials(WHYSHU_API_KEY):⛔ 不返回给 client、⛔ 不打印、⛔ 不写仓库。
11
+ */
12
+ import { execFile } from 'node:child_process'
13
+ import { promisify } from 'node:util'
14
+
15
+ const OAUTH_BASE = 'https://whyshu.com'
16
+ const CLIENT_ID = 'argent_cli' // whyshu-svr 已注册;换成 argent_desktop 需服务端加 Client 行
17
+ const REF = 'WHYSHU_API_KEY'
18
+ // 2026-11-06(P3b):refresh_token 槽位;access 30 天到期前自动续期,避免「每 30 天重新登录」
19
+ const REFRESH_REF = 'WHYSHU_REFRESH_TOKEN'
20
+ const CACHE_MS = 20_000
21
+ const execFileAsync = promisify(execFile)
22
+
23
+ let cache = { at: 0, status: null }
24
+
25
+ const json = (res, code, body) => {
26
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
27
+ res.end(JSON.stringify(body))
28
+ }
29
+
30
+ const readBody = async (req) => {
31
+ const chunks = []
32
+ for await (const c of req) chunks.push(c)
33
+ return Buffer.concat(chunks).toString('utf8')
34
+ }
35
+
36
+ async function oauth(path, init = {}, token = null) {
37
+ const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) }
38
+ if (token) headers.Authorization = `Bearer ${token}`
39
+ const res = await fetch(`${OAUTH_BASE}${path}`, { ...init, headers, signal: AbortSignal.timeout(8000) })
40
+ const body = await res.json().catch(() => null)
41
+ return { status: res.status, ok: res.ok, body }
42
+ }
43
+
44
+ /** 仅用于展示剩余天数;不验签(验签在服务端)。 */
45
+ export function decodeJwtExp(token) {
46
+ try {
47
+ const part = String(token).split('.')[1]
48
+ if (!part) return null
49
+ const payload = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'))
50
+ return Number.isFinite(payload.exp) ? payload.exp * 1000 : null
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ function credentialService(ctx) {
57
+ const svc = typeof ctx?.get === 'function' ? ctx.get('credentials') : null
58
+ return svc && typeof svc.resolve === 'function' ? svc : null
59
+ }
60
+
61
+ async function readCredential(ctx, ref) {
62
+ const svc = credentialService(ctx)
63
+ if (svc) {
64
+ try {
65
+ const hit = await svc.resolve(ref)
66
+ if (hit?.value) return hit.value
67
+ } catch { /* 落到 env */ }
68
+ }
69
+ return process.env[ref] || null
70
+ }
71
+
72
+ const readToken = (ctx) => readCredential(ctx, REF)
73
+ const readRefreshToken = (ctx) => readCredential(ctx, REFRESH_REF)
74
+
75
+ async function setCredential(ctx, ref, value) {
76
+ const svc = credentialService(ctx)
77
+ if (!svc || typeof svc.set !== 'function') throw new Error(`dsh credentials 服务不可用,无法保存 ${ref}`)
78
+ await svc.set(ref, value)
79
+ }
80
+
81
+ async function writeToken(ctx, token) {
82
+ await setCredential(ctx, REF, token)
83
+ cache = { at: 0, status: null }
84
+ }
85
+
86
+ async function writeRefreshToken(ctx, token) {
87
+ if (!token) return
88
+ try { await setCredential(ctx, REFRESH_REF, token) } catch { /* refresh 没存上不阻断登录 */ }
89
+ }
90
+
91
+ async function clearToken(ctx) {
92
+ const svc = credentialService(ctx)
93
+ if (svc && typeof svc.unset === 'function') {
94
+ await svc.unset(REF).catch(() => {})
95
+ await svc.unset(REFRESH_REF).catch(() => {})
96
+ }
97
+ cache = { at: 0, status: null }
98
+ }
99
+
100
+ /**
101
+ * 用 refresh_token 续期(服务端轮换):成功则写回新的 access + refresh;失败返回 {ok:false}。
102
+ * 由 401/过期自动触发,也可手动调用。
103
+ */
104
+ export async function refreshWhyshuToken(ctx) {
105
+ const refresh = await readRefreshToken(ctx).catch(() => null)
106
+ if (!refresh) return { ok: false, reason: 'no-refresh-token' }
107
+ try {
108
+ const r = await oauth('/api/oauth/token', {
109
+ method: 'POST',
110
+ body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refresh, client_id: CLIENT_ID }),
111
+ })
112
+ const b = r.body || {}
113
+ if (!r.ok || !b.access_token) {
114
+ const { logClientError } = await import('./error-log.mjs')
115
+ logClientError({ scope: 'whyshu-auth', code: b.error || `http-${r.status}`, message: b.error_description || 'refresh 失败' })
116
+ return { ok: false, reason: b.error || `http-${r.status}` }
117
+ }
118
+ await writeToken(ctx, b.access_token)
119
+ await writeRefreshToken(ctx, b.refresh_token)
120
+ return { ok: true, expiresAt: decodeJwtExp(b.access_token) }
121
+ } catch (e) {
122
+ const { logClientError } = await import('./error-log.mjs')
123
+ logClientError({ scope: 'whyshu-auth', code: e?.code || 'refresh_error', message: e?.message || String(e) })
124
+ return { ok: false, reason: String(e?.message || e) }
125
+ }
126
+ }
127
+
128
+ const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null)
129
+ const daysLeft = (ms) => (Number.isFinite(ms) ? Math.max(0, Math.ceil((ms - Date.now()) / 86_400_000)) : null)
130
+
131
+ /**
132
+ * 当前问述账号状态(带 20s 缓存;refresh=true 强制重拉)。
133
+ * @returns {Promise<{loggedIn:boolean,expired?:boolean,nickname?:string,phone?:string,plan?:string,expiresAt?:string|null,daysLeft?:number|null,error?:string}>}
134
+ */
135
+ export async function getWhyshuStatus(ctx, { refresh = false } = {}) {
136
+ const now = Date.now()
137
+ if (!refresh && cache.status && now - cache.at < CACHE_MS) return cache.status
138
+ const token = await readToken(ctx)
139
+ if (!token) {
140
+ cache = { at: now, status: { loggedIn: false, reason: 'not-logged-in' } }
141
+ return cache.status
142
+ }
143
+ let tokenValue = token
144
+ let exp = decodeJwtExp(tokenValue)
145
+ // 2026-11-06:access 过期 ⇒ 先用 refresh_token 续期(服务端轮换),成功则继续用新 token
146
+ if (exp !== null && exp <= now) {
147
+ const refreshed = await refreshWhyshuToken(ctx).catch(() => null)
148
+ if (refreshed && refreshed.ok) {
149
+ tokenValue = await readToken(ctx)
150
+ exp = decodeJwtExp(tokenValue)
151
+ }
152
+ }
153
+ if (exp !== null && exp <= now) {
154
+ cache = { at: now, status: { loggedIn: false, expired: true, expiresAt: iso(exp), daysLeft: 0 } }
155
+ return cache.status
156
+ }
157
+ try {
158
+ let info = await oauth('/api/oauth/userinfo', {}, tokenValue)
159
+ if (info.status === 401) {
160
+ const refreshed = await refreshWhyshuToken(ctx).catch(() => null)
161
+ if (refreshed && refreshed.ok) {
162
+ tokenValue = await readToken(ctx)
163
+ exp = decodeJwtExp(tokenValue)
164
+ info = await oauth('/api/oauth/userinfo', {}, tokenValue)
165
+ }
166
+ }
167
+ if (!info.ok || !info.body?.id) {
168
+ cache = { at: now, status: { loggedIn: false, invalid: true, expiresAt: iso(exp), status: info.status } }
169
+ return cache.status
170
+ }
171
+ cache = {
172
+ at: now,
173
+ status: {
174
+ loggedIn: true,
175
+ nickname: info.body.nickname || '',
176
+ phone: info.body.phone || '',
177
+ plan: info.body.plan || 'free',
178
+ expiresAt: iso(exp),
179
+ daysLeft: daysLeft(exp),
180
+ },
181
+ }
182
+ return cache.status
183
+ } catch (e) {
184
+ return { loggedIn: false, error: String(e?.message || e).slice(0, 200), expiresAt: iso(exp) }
185
+ }
186
+ }
187
+
188
+ function openerForPlatform() {
189
+ if (process.platform === 'darwin') return { cmd: 'open', args: (u) => [u] }
190
+ if (process.platform === 'win32') return { cmd: 'cmd', args: (u) => ['/c', 'start', '', u] }
191
+ return { cmd: 'xdg-open', args: (u) => [u] }
192
+ }
193
+
194
+ /**
195
+ * 注册 /api/argent/auth/*(宿主全局认证兜底;不返回 token)。
196
+ */
197
+ export function registerWhyshuAuth(ctx) {
198
+ ctx.effect(() => ctx.webServer.register({
199
+ kind: 'prefix',
200
+ path: '/api/argent/auth',
201
+ handler: async (req, res) => {
202
+ const url = new URL(req.url || '/', 'http://localhost')
203
+ const method = (req.method || 'GET').toUpperCase()
204
+ try {
205
+ if (url.pathname === '/api/argent/auth/device') {
206
+ if (method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
207
+ const r = await oauth('/api/oauth/device', {
208
+ method: 'POST',
209
+ body: JSON.stringify({ client_id: CLIENT_ID }),
210
+ })
211
+ const b = r.body || {}
212
+ if (!r.ok || !b.device_code) return json(res, 200, { ok: false, error: b.error || `device 端点失败(${r.status})` })
213
+ return json(res, 200, {
214
+ ok: true,
215
+ device_code: b.device_code,
216
+ user_code: b.user_code,
217
+ verification_uri: b.verification_uri,
218
+ verification_uri_complete: b.verification_uri_complete,
219
+ interval: b.interval || 5,
220
+ expires_in: b.expires_in || 600,
221
+ })
222
+ }
223
+
224
+ if (url.pathname === '/api/argent/auth/poll') {
225
+ if (method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
226
+ const body = JSON.parse((await readBody(req)) || '{}')
227
+ const deviceCode = String(body.device_code || '').trim()
228
+ if (!deviceCode) return json(res, 200, { status: 'error', error: '缺少 device_code' })
229
+ const r = await oauth('/api/oauth/token', {
230
+ method: 'POST',
231
+ body: JSON.stringify({
232
+ device_code: deviceCode,
233
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
234
+ }),
235
+ })
236
+ const b = r.body || {}
237
+ if (b.error === 'authorization_pending') return json(res, 200, { status: 'pending' })
238
+ if (b.error === 'access_denied' || !b.access_token) {
239
+ return json(res, 200, { status: 'denied', error: b.error_description || b.error || '授权失败或已过期' })
240
+ }
241
+ await writeToken(ctx, b.access_token)
242
+ await writeRefreshToken(ctx, b.refresh_token)
243
+ const status = await getWhyshuStatus(ctx, { refresh: true })
244
+ return json(res, 200, { status: 'approved', account: status })
245
+ }
246
+
247
+ if (url.pathname === '/api/argent/auth/status') {
248
+ const status = await getWhyshuStatus(ctx, { refresh: url.searchParams.get('refresh') === '1' })
249
+ return json(res, 200, status)
250
+ }
251
+
252
+ if (url.pathname === '/api/argent/auth/logout') {
253
+ if (method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
254
+ await clearToken(ctx)
255
+ return json(res, 200, { ok: true })
256
+ }
257
+
258
+ if (url.pathname === '/api/argent/auth/open') {
259
+ if (method !== 'POST') return json(res, 405, { ok: false, error: 'method-not-allowed' })
260
+ const body = JSON.parse((await readBody(req)) || '{}')
261
+ let target
262
+ try { target = new URL(String(body.url || '')) } catch { return json(res, 400, { ok: false, error: 'bad-url' }) }
263
+ if (target.protocol !== 'https:' || target.hostname !== 'whyshu.com' || !target.pathname.startsWith('/oauth')) {
264
+ return json(res, 400, { ok: false, error: 'url-not-allowed' })
265
+ }
266
+ const opener = openerForPlatform()
267
+ await execFileAsync(opener.cmd, opener.args(target.href), { timeout: 5000 })
268
+ return json(res, 200, { ok: true })
269
+ }
270
+
271
+ return json(res, 404, { ok: false, error: 'not-found' })
272
+ } catch (e) {
273
+ return json(res, 500, { ok: false, error: String(e?.message || e).slice(0, 240) })
274
+ }
275
+ },
276
+ }), 'argent: whyshu auth endpoints')
277
+ }