@magpie-community/opencode-qoder-auth 0.2.28 → 0.2.30

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.
Files changed (3) hide show
  1. package/README.md +22 -2
  2. package/index.mjs +63 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -285,8 +285,28 @@ the account's credentials.
285
285
  international account gets no daily claim until Qoder is installed.
286
286
  - A helper that is there but can't be read, or gives an invalid identity,
287
287
  is `failed` and asked again later, rather than a false "no activity"
288
- result. Qoder CN keeps its existing requests and does not need this
289
- local helper.
288
+ result.
289
+
290
+ Qoder CN (since 0.2.30) sends its own device identity the same way when
291
+ Qoder CN is installed: its desktop 0.4.9 and CLI (`qoderclicn`) both ask
292
+ Qoder CN's `runtime-info` for environment `0` (the international one is
293
+ `3`) and send it on the campaigns pages (yetone/magpie#2911, #304).
294
+
295
+ - The plugin finds Qoder CN desktop's `resources/umid` (`Qoder CN.app` on
296
+ macOS, `%LOCALAPPDATA%\Programs\Qoder CN` or `%ProgramFiles%\Qoder CN`
297
+ on Windows, `/opt/Qoder-CN` on Linux) or the runtime its CLI caches in
298
+ `~/.qoder-cn/.bin` (`QODERCN_CONFIG_DIR`, `QODERCN_CLI_HOME` and
299
+ `QODERCN_CONFIG_DIR_NAME` move it). `QODERCN_RUNTIME_INFO` names
300
+ another location; `QODER_RUNTIME_INFO` is only the international one's.
301
+ - Without it the requests go out as before, as Qoder CN's desktop does
302
+ when it can't read the identity, and a claim the list still offers is
303
+ made. When the list then offers no daily claim, the check-in is
304
+ `ineligible` for the day with a message to install Qoder CN desktop (or
305
+ sign in to its CLI); a helper that is there but can't be read makes it
306
+ `failed`, asked again later.
307
+ - Not tried with a real Qoder CN account: whether Qoder CN now hides the
308
+ daily claim from a request without the identity, as the international
309
+ site does, is inferred from its clients, not seen.
290
310
 
291
311
  - **List:** `GET <openapi>/sash/api/v1/me/campaigns`: `campaigns[]` with
292
312
  `campaignId`, `actionType`, `claimStatus` (`CLAIMABLE`, `CLAIMED`),
package/index.mjs CHANGED
@@ -1526,23 +1526,45 @@ const CAMPAIGN_MACHINE_TTL = 60 * 60 * 1000 // Qoder's desktop client renews its
1526
1526
  // (or .qoder): a file runtime-info-<platform>-<arch>-<hash> on macOS and
1527
1527
  // Linux, a folder umid-win32-<arch>-<hash> holding runtime-info.exe on
1528
1528
  // Windows.
1529
- async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env, opt = "/opt" } = {}) {
1530
- if (env.QODER_RUNTIME_INFO) return resolve(env.QODER_RUNTIME_INFO)
1529
+ //
1530
+ // Qoder CN keeps its own (Qoder CN desktop 0.4.9's packages, qoderclicn
1531
+ // 1.1.77): Qoder CN.app on macOS (its installer's expectedBundleName),
1532
+ // /opt/Qoder-CN on Linux (the .deb), Qoder CN on Windows (the user
1533
+ // installer's "Uninstall Qoder CN.exe", electron-builder's
1534
+ // Programs\<productName>); its CLI's config is QODERCN_CONFIG_DIR, else
1535
+ // QODERCN_CLI_HOME (or home) joined with QODERCN_CONFIG_DIR_NAME (or
1536
+ // .qoder-cn). Both ask the helper for environment 0, the international
1537
+ // ones for 3 (the desktop's `regionCode === "global" ? 3 : 0`, the CLI's
1538
+ // UMID environment ONLINE = 0, US_ONLINE = 3).
1539
+ //
1540
+ // required: international Qoder lists the daily credits only on a request
1541
+ // carrying the identity. Qoder CN's desktop sends it when it can read it
1542
+ // and goes on without it when it can't, and a Qoder CN account was offered
1543
+ // and claimed the credits without one (0.2.21), so there it is sent when
1544
+ // Qoder CN is installed and left out when it isn't.
1545
+ const RUNTIMES = {
1546
+ qoder: { name: "Qoder", environment: "3", override: "QODER_RUNTIME_INFO", app: "Qoder", linux: "Qoder", cli: "QODER_", dir: ".qoder", required: true },
1547
+ "qoder-cn": { name: "Qoder CN", environment: "0", override: "QODERCN_RUNTIME_INFO", app: "Qoder CN", linux: "Qoder-CN", cli: "QODERCN_", dir: ".qoder-cn", required: false },
1548
+ }
1549
+
1550
+ async function campaignRuntime({ home = homedir(), platform = process.platform, arch = process.arch, env = process.env, opt = "/opt", runtime = RUNTIMES.qoder } = {}) {
1551
+ if (env[runtime.override]) return resolve(env[runtime.override])
1531
1552
  const name = platform === "win32" ? "runtime-info.exe" : "runtime-info"
1532
1553
  const isFile = async (path) => (await stat(path).catch(() => null))?.isFile() ?? false
1533
1554
  const paths = []
1534
1555
  if (platform === "win32") {
1535
1556
  const local = env.LOCALAPPDATA || join(home, "AppData", "Local")
1536
- paths.push(join(local, "Programs", "Qoder", "resources", "umid", name))
1557
+ paths.push(join(local, "Programs", runtime.app, "resources", "umid", name))
1537
1558
  // the machine-wide installer, as VS Code's system setup places it
1538
- for (const dir of [env.ProgramFiles || env.PROGRAMFILES, env.ProgramW6432]) if (dir) paths.push(join(dir, "Qoder", "resources", "umid", name))
1559
+ for (const dir of [env.ProgramFiles || env.PROGRAMFILES, env.ProgramW6432]) if (dir) paths.push(join(dir, runtime.app, "resources", "umid", name))
1539
1560
  } else if (platform === "darwin") {
1540
- for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, "Qoder.app", "Contents", "Resources", "umid", name))
1561
+ for (const dir of ["/Applications", join(home, "Applications")]) paths.push(join(dir, runtime.app + ".app", "Contents", "Resources", "umid", name))
1541
1562
  } else if (platform === "linux") {
1542
- paths.push(join(opt, "Qoder", "resources", "umid", name))
1563
+ paths.push(join(opt, runtime.linux, "resources", "umid", name))
1543
1564
  }
1544
1565
  for (const path of paths) if (await isFile(path)) return path
1545
- const config = env.QODER_CONFIG_DIR || join(env.QODER_CLI_HOME || env.GEMINI_CLI_HOME || home, env.QODER_CONFIG_DIR_NAME || ".qoder")
1566
+ const cli = (key) => env[runtime.cli + key]
1567
+ const config = cli("CONFIG_DIR") || join(cli("CLI_HOME") || env.GEMINI_CLI_HOME || home, cli("CONFIG_DIR_NAME") || runtime.dir)
1546
1568
  const cache = join(config, ".bin")
1547
1569
  const entries = await readdir(cache, { withFileTypes: true }).catch(() => [])
1548
1570
  const found = []
@@ -1556,7 +1578,7 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1556
1578
  if (path && (await isFile(path))) found.push({ path, time: (await stat(path)).mtimeMs })
1557
1579
  }
1558
1580
  if (found.length) return found.sort((a, b) => b.time - a.time)[0].path
1559
- throw new RuntimeMissing()
1581
+ throw new RuntimeMissing(runtime)
1560
1582
  }
1561
1583
 
1562
1584
  // RuntimeMissing: no Qoder desktop or CLI on this machine to read the
@@ -1566,19 +1588,21 @@ async function campaignRuntime({ home = homedir(), platform = process.platform,
1566
1588
  // never makes up. Asking again won't change that until the user installs
1567
1589
  // Qoder, so the check-in says what to do once instead of "tries again later".
1568
1590
  class RuntimeMissing extends Error {
1569
- constructor() {
1591
+ constructor(runtime = RUNTIMES.qoder) {
1592
+ const { name, override } = runtime
1570
1593
  super(
1571
- "Qoder check-in: device identity runtime not found. Qoder gives the daily credits only to a device its own app identifies, and this machine has neither Qoder desktop nor Qoder CLI to read that identity from. Install Qoder desktop (or install Qoder CLI and sign in to it once), or set QODER_RUNTIME_INFO to its runtime-info executable; magpie checks in again the next day",
1594
+ `${name} check-in: device identity runtime not found. ${name} gives the daily credits only to a device its own app identifies, and this machine has neither ${name} desktop nor ${name} CLI to read that identity from. Install ${name} desktop (or install ${name} CLI and sign in to it once), or set ${override} to its runtime-info executable; magpie checks in again the next day`,
1572
1595
  )
1573
1596
  }
1574
1597
  }
1575
1598
 
1576
- // Read the SDK identity as Qoder does: environment 3 is international; on
1577
- // Windows and macOS the uid goes over stdin, never into the command line.
1578
- async function campaignMachine(uid) {
1599
+ // Read the SDK identity as Qoder does: environment 3 is international, 0
1600
+ // Qoder CN; on Windows and macOS the uid goes over stdin, never into the
1601
+ // command line.
1602
+ async function campaignMachine(uid, runtime = RUNTIMES.qoder) {
1579
1603
  if (!uid) throw new Error("Qoder check-in: device identity needs the account uid; sign in again")
1580
- const file = await campaignRuntime()
1581
- const args = process.platform === "linux" ? ["3"] : ["3", "--account-stdin"]
1604
+ const file = await campaignRuntime({ runtime })
1605
+ const args = process.platform === "linux" ? [runtime.environment] : [runtime.environment, "--account-stdin"]
1582
1606
  const text = await new Promise((resolve, reject) => {
1583
1607
  const child = execFile(file, args, { timeout: 20_000, windowsHide: true, maxBuffer: 1 << 20 }, (err, out) => {
1584
1608
  if (err) reject(err)
@@ -1587,7 +1611,7 @@ async function campaignMachine(uid) {
1587
1611
  child.stdin?.on("error", () => {})
1588
1612
  child.stdin?.end(process.platform === "linux" ? undefined : JSON.stringify({ account: uid }) + "\n")
1589
1613
  }).catch(() => {
1590
- throw new Error("Qoder check-in: could not read device identity; restart or update Qoder, or check QODER_RUNTIME_INFO")
1614
+ throw new Error(`${runtime.name} check-in: could not read device identity; restart or update ${runtime.name}, or check ${runtime.override}`)
1591
1615
  })
1592
1616
  try {
1593
1617
  const identity = JSON.parse(text.trim().split(/\r?\n/, 1)[0])
@@ -1599,7 +1623,7 @@ async function campaignMachine(uid) {
1599
1623
  }
1600
1624
  return headers
1601
1625
  } catch {
1602
- throw new Error("Qoder check-in: invalid device identity from Qoder; restart or update Qoder")
1626
+ throw new Error(`${runtime.name} check-in: invalid device identity from ${runtime.name}; restart or update ${runtime.name}`)
1603
1627
  }
1604
1628
  }
1605
1629
 
@@ -1754,12 +1778,13 @@ const makePlugin = (site) => async ({ client }) => {
1754
1778
  const ID = site.id
1755
1779
  // The SDK identity is account-specific. Share an in-flight read, refresh
1756
1780
  // after an hour, and leave a failed read available for the next attempt.
1781
+ const runtime = RUNTIMES[ID]
1757
1782
  const campaignMachines = new Map()
1758
1783
  const machineForCampaign = async (uid) => {
1759
1784
  const now = Date.now()
1760
1785
  let entry = campaignMachines.get(uid)
1761
1786
  if (!entry || now - entry.at >= CAMPAIGN_MACHINE_TTL) {
1762
- entry = { at: now, promise: campaignMachine(uid) }
1787
+ entry = { at: now, promise: campaignMachine(uid, runtime) }
1763
1788
  campaignMachines.set(uid, entry)
1764
1789
  }
1765
1790
  try {
@@ -1769,6 +1794,16 @@ const makePlugin = (site) => async ({ client }) => {
1769
1794
  throw e
1770
1795
  }
1771
1796
  }
1797
+ // campaignIdentity: the headers a campaigns request carries, and why
1798
+ // there are none where the site goes on without them (Qoder CN).
1799
+ const campaignIdentity = async (uid) => {
1800
+ if (runtime.required) return { machine: await machineForCampaign(uid) }
1801
+ try {
1802
+ return { machine: await machineForCampaign(uid) }
1803
+ } catch (e) {
1804
+ return { machine: {}, unread: e }
1805
+ }
1806
+ }
1772
1807
  // serializes checking, rotating and saving tokens: a refresh token is
1773
1808
  // spent once, so two refreshes would spend it twice
1774
1809
  let lock = Promise.resolve()
@@ -1927,7 +1962,7 @@ const makePlugin = (site) => async ({ client }) => {
1927
1962
  if (!url) return errorResponse({ status: 404, message: "only chat completions are served" })
1928
1963
  const method = String(init.method ?? input?.method ?? "GET").toUpperCase()
1929
1964
  try {
1930
- const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1965
+ const { machine } = await campaignIdentity(cred.uid)
1931
1966
  // A PAT account has no device token: its job token signs the campaigns
1932
1967
  // pages too, as it does the usage read. Nothing is rotated on that
1933
1968
  // path — the refusal is the answer.
@@ -1956,7 +1991,7 @@ const makePlugin = (site) => async ({ client }) => {
1956
1991
  const base = campaignTarget(site, siteOf(site, cred), site.openapi + CAMPAIGNS)
1957
1992
  if (!base) return { outcome: "inactive", message: "no campaigns page for this account" }
1958
1993
  try {
1959
- const machine = site.id === "qoder" ? await machineForCampaign(cred.uid) : {}
1994
+ const { machine, unread } = await campaignIdentity(cred.uid)
1960
1995
  // A PAT account has no device token: the campaigns pages take the job
1961
1996
  // token the exchange gave, as the usage endpoint does. A live Qoder CN
1962
1997
  // PAT account answered 200 to the list and CLAIMED to a claim, so the
@@ -1994,7 +2029,13 @@ const makePlugin = (site) => async ({ client }) => {
1994
2029
  const amount = Number(done.benefit?.amount)
1995
2030
  return { outcome: "done", ...(Number.isFinite(amount) && amount > 0 ? { credit: amount } : {}) }
1996
2031
  }
1997
- return campaigns.length ? { outcome: "failed", message: "campaigns: today's credits aren't offered yet" } : { outcome: "inactive" }
2032
+ if (campaigns.length) return { outcome: "failed", message: "campaigns: today's credits aren't offered yet" }
2033
+ // Asked without Qoder CN's identity and offered nothing: what an
2034
+ // international list says to a request without one, so say why
2035
+ // (no Qoder CN on this machine) or ask again (it didn't answer).
2036
+ if (unread instanceof RuntimeMissing) return { outcome: "ineligible", message: unread.message }
2037
+ if (unread) return { outcome: "failed", message: unread.message }
2038
+ return { outcome: "inactive" }
1998
2039
  }
1999
2040
  const claimRes = await call(base.replace(/\/$/, "") + "/" + encodeURIComponent(campaign.campaignId) + "/claim", "POST", "{}")
2000
2041
  let claim = {}
@@ -2233,4 +2274,4 @@ export const QoderAuthPlugin = makePlugin(SITES.qoder)
2233
2274
  export const QoderCNAuthPlugin = makePlugin(SITES["qoder-cn"])
2234
2275
 
2235
2276
  // for tests
2236
- export const _internal = { campaignRuntime, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, SITES, decodeBody, events, QUEUE, queueState }
2277
+ export const _internal = { campaignRuntime, RUNTIMES, campaignPage, campaignTarget, vpcInstance, vpcSite, parseUsage, gfmt, when, failure, modelInfo, qoderBody, qoderMessages, cacheMarked, sessionOf, SITES, decodeBody, events, QUEUE, queueState }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-qoder-auth",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Qoder subscriptions in OpenCode and magpie: Qoder (qoder.com) and Qoder CN (qoder.cn)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",