magpie-plugin-msmoli 0.2.0 → 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.
Files changed (5) hide show
  1. package/README.md +550 -372
  2. package/bin/sync.mjs +1569 -148
  3. package/index.mjs +2456 -272
  4. package/package.json +3 -4
  5. package/README.zh-CN.md +0 -358
package/bin/sync.mjs CHANGED
@@ -1,22 +1,28 @@
1
1
  #!/usr/bin/env node
2
- // magpie-plugin-msmoli — 把 magpie 里已有的 ModelScope 令牌同步给签到插件,或反过来。
2
+ // magpie-plugin-msmoli — sync the ModelScope token magpie already has to the
3
+ // check-in plugin, or the other way round.
3
4
  //
4
- // 为什么有这个工具:magpie 的内置 ModelScope 预设(providers.json)和本插件
5
- // 的登录态(plugin-auth.json)是两处独立存储,同一把 ms- 令牌要配两次。
6
- // 这个工具读一次、写一次,输出里不留令牌。
5
+ // Why this tool exists: magpie's built-in ModelScope preset (providers.json)
6
+ // and this plugin's login state (plugin-auth.json) are two separate stores, so
7
+ // one ms- token has to be configured twice. This tool reads once, writes once,
8
+ // and never leaves the token in its output.
7
9
  //
8
- // 安全边界(改这里之前先想清楚):
9
- // - 只读 magpie 的两个文件;写入一律交给 magpie 自己的 CLI
10
- // (provider key / plugin login),不裸写任何 magpie 的文件——锁、备份、
11
- // .bak 回退、事件通知都归 magpie 管。
12
- // - 令牌只走 stdin 和进程参数(provider key 的接口如此),日志里只出现
13
- // 掩码(前 4 + … + 后 4,与 magpie 自己的显示一致)。
14
- // - 宿主按令牌分账号:同一把令牌重复 login 是幂等的;换一把会新增第二个
15
- // 账号;logout 会登出该 provider 的全部账号。所以「两边不同」时默认
16
- // 拒绝改动,只有显式 --force 且插件只有一个账号时才替换。
10
+ // Security boundaries (think these through before changing anything here):
11
+ // - It only reads magpie's two files; every write goes through magpie's own
12
+ // CLI (provider key / plugin login), never by writing magpie's files
13
+ // directly — locks, backups, .bak rollback and event notifications are all
14
+ // magpie's business.
15
+ // - The token only travels over stdin and process arguments (that is the
16
+ // provider key interface), and the log only shows a mask (first 4 + … + last
17
+ // 4, matching magpie's own display).
18
+ // - The host keys accounts by token: logging in again with the same token is
19
+ // idempotent; a different token adds a second account; logout signs out
20
+ // every account of that provider. So when the two sides differ, it refuses
21
+ // to change anything by default, and only replaces with an explicit --force
22
+ // and only one account in the plugin.
17
23
 
18
24
  import { spawnSync } from "node:child_process"
19
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
25
+ import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"
20
26
  import { homedir, platform } from "node:os"
21
27
  import { dirname, isAbsolute, join, sep } from "node:path"
22
28
  import { fileURLToPath, pathToFileURL } from "node:url"
@@ -24,6 +30,20 @@ import { fileURLToPath, pathToFileURL } from "node:url"
24
30
  const ID = "msmoli"
25
31
  const PRESET = "modelscope"
26
32
  const HOST = "api-inference.modelscope.cn"
33
+ // the only site the built-in preset and the vendors this tool recognises serve
34
+ const CN_SITE = "https://modelscope.cn"
35
+
36
+ // VERSION is what --version answers. Read from the package's own manifest so
37
+ // the two can never drift — a published tarball carries package.json beside
38
+ // bin/, and so does a clone. An unreadable manifest is not a reason to fail a
39
+ // version question: the answer is just unknown.
40
+ const VERSION = (() => {
41
+ try {
42
+ return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version ?? "unknown"
43
+ } catch {
44
+ return "unknown"
45
+ }
46
+ })()
27
47
 
28
48
  const USAGE = `magpie-plugin-msmoli — 把 magpie 里已有的 ModelScope 令牌同步给签到插件
29
49
 
@@ -40,29 +60,39 @@ const USAGE = `magpie-plugin-msmoli — 把 magpie 里已有的 ModelScope 令
40
60
  --status 只报告,不执行
41
61
  --dry-run 打印将要执行的命令,不执行
42
62
  -h, --help 这一页
63
+ -v, --version 打印版本号
43
64
 
44
65
  令牌不会出现在输出里。写入一律走 magpie 自己的 CLI。`
45
66
 
46
67
  function parseArgs(argv) {
47
- const out = { dir: "", magpie: "", from: "", to: "", force: false, dryRun: false, status: false, help: false }
68
+ const out = { dir: "", magpie: "", from: "", to: "", force: false, dryRun: false, status: false, help: false, version: false }
48
69
  // help wins over everything else on the line: `--help --nope` and
49
- // `--nope --help` should show the page, not an error about --nope
70
+ // `--nope --help` should show the page, not an error about --nope. Version
71
+ // is the same courtesy one step down: it is how a packaged CLI answers
72
+ // "which one am I running", and a stray argument beside it should not turn
73
+ // that into an error.
50
74
  if (argv.includes("-h") || argv.includes("--help")) return { ...out, help: true }
75
+ if (argv.includes("-v") || argv.includes("--version")) return { ...out, version: true }
51
76
  // A flag is not a value. `--dir --dry-run` would otherwise take "--dry-run"
52
77
  // as the folder and then fail somewhere far from the mistake, and
53
78
  // `--dir ""` would read as "not given" and silently fall back to the
54
79
  // machine's own magpie — writing a credential to a target nobody named.
55
- const take = (i) => {
80
+ const take = (i, flag) => {
56
81
  const v = argv[i]
57
82
  // A single "-h" or "-x" is a flag too: `--dir -h` must not put "-h" in
58
83
  // the folder position and silently look there.
59
- if (v === undefined || v.trim() === "" || v.startsWith("-")) throw new Error(`缺少值:${argv[i - 1]} 后面要跟一个参数`)
84
+ if (v === undefined || v.trim() === "" || v.startsWith("-")) throw new Error(`缺少值:${flag} 后面要跟一个参数`)
60
85
  return v
61
86
  }
62
87
  // A value flag given twice is refused, as the two directions are: the
63
88
  // second silently winning dropped the first, and `--magpie A --magpie B`
64
89
  // wrote through B with nothing said. `--dir=x` is read as `--dir x`.
65
90
  const VALUE = { "--dir": "dir", "--magpie": "magpie", "--from": "from" }
91
+ // hasOwn, not `VALUE[a]`: a plain object literal answers for "constructor",
92
+ // "toString", "__proto__" and the rest of Object's own names, so those were
93
+ // taken as value flags and silently accepted while every other unknown
94
+ // argument was refused.
95
+ const isValue = (k) => Object.hasOwn(VALUE, k)
66
96
  const seen = new Set()
67
97
  const set = (flag, v) => {
68
98
  if (seen.has(flag)) throw new Error(`${flag} 只能给一次`)
@@ -73,13 +103,14 @@ function parseArgs(argv) {
73
103
  const a = argv[i]
74
104
  const eq = a.indexOf("=")
75
105
  const flag = eq > 0 ? a.slice(0, eq) : ""
76
- if (flag && VALUE[flag]) {
106
+ if (flag && isValue(flag)) {
77
107
  const v = a.slice(eq + 1)
78
108
  if (v.trim() === "" || v.startsWith("-")) throw new Error(`缺少值:${flag}= 后面要跟一个参数`)
79
109
  set(flag, v)
80
- } else if (VALUE[a]) set(a, take(++i))
110
+ } else if (isValue(a)) set(a, take(++i, a))
81
111
  else if (a === "--to-plugin" || a === "--to-preset") {
82
- // 两个方向同时给时后者静默获胜,等于悄悄丢掉前一个:说清楚比择一好
112
+ // When both directions are given, the later one silently wins and the
113
+ // earlier is quietly dropped: saying so is better than picking one
83
114
  const want = a === "--to-plugin" ? "plugin" : "preset"
84
115
  if (out.to && out.to !== want) throw new Error("--to-plugin 与 --to-preset 只能给一个")
85
116
  out.to = want
@@ -92,8 +123,9 @@ function parseArgs(argv) {
92
123
  return out
93
124
  }
94
125
 
95
- // ---- 探测:与 magpie 的 appdir 对齐。便携版认可执行文件旁的 data/ 或
96
- // .portable;安装版认 $XDG_CONFIG_HOME/magpie,否则 ~/.config/magpie。
126
+ // ---- discovery: aligned with magpie's appdir. The portable build accepts a
127
+ // data/ folder or a .portable file beside the executable; the installed build
128
+ // accepts $XDG_CONFIG_HOME/magpie, otherwise ~/.config/magpie.
97
129
 
98
130
  export function findMagpieBin({ env = process.env, os = platform(), exists = existsSync, home = homedir() } = {}) {
99
131
  const cands = []
@@ -287,7 +319,8 @@ export function findConfigDir({
287
319
  return looksLikeMagpie(d, exists) ? d : ""
288
320
  }
289
321
 
290
- // ---- 读:只读,剥 BOM(magpie 自己也这么做),读不动就当没有
322
+ // ---- reading: read-only, strips the BOM (magpie does the same), and treats
323
+ // an unreadable file as absent
291
324
 
292
325
  // readJson returns undefined for "not there", null for "there but unreadable".
293
326
  // The two are not the same decision: a file that exists and doesn't parse may
@@ -302,7 +335,7 @@ export function readJson(file, exists = existsSync) {
302
335
  }
303
336
  }
304
337
 
305
- // ---- 领域逻辑(纯函数,导出给测试)
338
+ // ---- domain logic (pure functions, exported for the tests)
306
339
 
307
340
  // mask reveals only the ends. Under 12 characters there is no middle worth
308
341
  // hiding and no tail worth showing: 8 characters would print the whole thing
@@ -318,30 +351,60 @@ export const mask = (t) => {
318
351
  return `${show(t.slice(0, 4))}…${show(t.slice(-4))}`
319
352
  }
320
353
 
321
- // 内置侧的候选:默认先认 id/preset=modelscope(magpie 预设的正规路径),没有
322
- // 再按推理域名认用户自建的 vendor。--from 是消歧手段,必须只认 id——默认搜索
323
- // 按 preset 匹配才产生歧义,消歧若还按 preset 就永远解不开(演练抓出的 bug)。
324
- // 多于一个候选时工具不猜。
354
+ // The preset-side candidates: by default it accepts id/preset=modelscope first
355
+ // (magpie's proper preset path), and otherwise recognises a user-built vendor
356
+ // by its inference hostname. --from disambiguates and must match by id only —
357
+ // the default search matching by preset is what creates the ambiguity, and a
358
+ // disambiguation that also matched by preset could never resolve it (a bug the
359
+ // rehearsal caught). With more than one candidate the tool does not guess.
325
360
  //
326
- // 域名按 hostname 精确比较,不是子串:includes() 对
327
- // "api-inference.modelscope.cn.evil.tld" 也为真,而那会把真令牌写进一个
328
- // chat 指向第三方的 provider(--to-preset)或把第三方的 key 当 cookie 发给
329
- // modelscope.cn(--to-plugin)。
361
+ // The hostname is compared exactly, not as a substring: includes() is also
362
+ // true for "api-inference.modelscope.cn.evil.tld", and that would write the
363
+ // real token into a provider whose chat points at a third party (--to-preset)
364
+ // or send a third party's key as a cookie to modelscope.cn (--to-plugin).
330
365
  //
331
- // --from 只在这道校验**之内**挑一个:它先是「内置侧」的候选,然后才是消歧。
332
- // 早先它短路在域名校验之前,于是 `--from openai` 会把 OpenAI 的 key 登录给
333
- // 插件、或用 ms 令牌覆盖掉那个第三方 provider —— 两个方向都静默写错凭据。
334
- export function presetCandidates(providers, from = "") {
366
+ // --from picks one **inside** that check: it is a "preset-side" candidate
367
+ // first, and only then a disambiguation. It used to short-circuit before the
368
+ // hostname check, so `--from openai` would log OpenAI's key into the plugin,
369
+ // or overwrite that third-party provider with an ms token — both directions
370
+ // silently writing the wrong credential.
371
+ // The key is a source-side requirement (--to-plugin needs a token to hand
372
+ // over), not a destination-side one: a provider that is ours but has an empty
373
+ // key is exactly what --to-preset is meant to fill. `keyed` is that switch;
374
+ // main passes false for the reverse direction. With --from the same rule
375
+ // holds — an id the user named that is ours is a destination even without a
376
+ // key, while --to-plugin must still refuse one that has no token.
377
+ export function presetCandidates(providers, from = "", keyed = true) {
335
378
  const list = Array.isArray(providers) ? providers : []
336
379
  // the key is trimmed where it is read: a token pasted with a trailing
337
380
  // newline is the same token, and every comparison and every stdin below
338
381
  // must see that one — the check-in plugin trims it too (tokenOf), so the
339
382
  // two halves of the tool otherwise disagree about the same credential
340
- const out = (ps) => ps.map((p) => ({ ...p, key: p.key.trim() }))
341
- if (from) return out(list.filter((p) => keyedOf(p) && p.id === from && oursOf(p)))
342
- const byId = list.filter((p) => keyedOf(p) && (p.id === PRESET || p.preset === PRESET))
383
+ // keyRaw keeps the type as written. key is the string the rest of the tool
384
+ // compares with, but normalizing a non-string to "" here made every
385
+ // downstream type check read "an empty slot with nothing to lose" — the
386
+ // refusal in planToPreset could not be reached from the command line, and
387
+ // --to-plugin's own check was skipped too.
388
+ const out = (ps) =>
389
+ ps.map((p) => ({
390
+ ...p,
391
+ key: typeof p.key === "string" ? p.key.trim() : "",
392
+ // only a key of another type carries its raw value: a string's own
393
+ // spelling is `key`, trimmed
394
+ ...(typeof p.key === "string" ? {} : { keyRaw: p.key }),
395
+ }))
396
+ // An entry that is not an object (a null in the list, a string) is not a
397
+ // provider: it is dropped before anything reads p.id, and dropping it
398
+ // filters it here the way keyedOf does for --to-plugin, rather than leaving
399
+ // --to-preset (keyed=false) to dereference the null and crash with a raw
400
+ // TypeError the refusal that stands over every other malformed file never
401
+ // gets to say
402
+ const entry = (p) => p !== null && typeof p === "object" && !Array.isArray(p)
403
+ const usable = (p) => entry(p) && (!keyed || keyedOf(p))
404
+ if (from) return out(list.filter((p) => usable(p) && p.id === from && oursOf(p)))
405
+ const byId = list.filter((p) => usable(p) && (p.id === PRESET || p.preset === PRESET))
343
406
  if (byId.length) return out(byId)
344
- return out(list.filter((p) => keyedOf(p) && hostOf(p.chat) === HOST))
407
+ return out(list.filter((p) => usable(p) && hostOf(p.chat) === HOST))
345
408
  }
346
409
 
347
410
  // show is what goes to the terminal: a provider id or a file's path comes
@@ -359,11 +422,175 @@ export function presetCandidates(providers, from = "") {
359
422
  // two, each piece under the floor, and both were printed whole. A line break
360
423
  // still ends a run — a token never spans one.
361
424
  const INV = String.raw`(?:(?![\n\r])[\p{Cc}\p{Cf}])`
362
- const invisible = (c) => /[\p{Cc}\p{Cf}]/u.test(c)
363
- const tokenShaped = new RegExp(String.raw`m${INV}*s${INV}*-(?:${INV}*[\x21-\x7e]){11,}`, "gu")
425
+ const tokenShaped = new RegExp(String.raw`m${INV}*s${INV}*-(?:${INV}*[\x21-\x7e]){11,}`, "giu")
426
+ // A key cut into pieces by whitespace is still one key, and the key starts
427
+ // where the ms- front does. The value may carry anything before it: the flag
428
+ // it was pasted behind ("--token=ms-01234 56789abc …"), a path (--dir
429
+ // "/tmp/ms-01234 56789abc …"), an account id ("msmoli#ms-…"), or a sentence a
430
+ // child printed around it ("no key ms-… for provider"). Masking the whole
431
+ // value hid the flag or the folder the user meant to see; not joining across
432
+ // the cut left the tail in pieces each under every floor, and the longest of
433
+ // them (15 characters) was printed whole. So the key is everything from its
434
+ // front to the end of the value, and what came before it keeps its shape.
435
+ // how a cut arrives: a space, tab or line break, a no-break or ideographic
436
+ // space, a middot or a bullet, or a slash. One or two in a row is a cut;
437
+ // three or more ("a b") is a frame, and ends the run. A piece is a stretch
438
+ // between cuts; control and format characters are not a cut's spelling, and a
439
+ // value carrying one is left to the per-word pass.
440
+ const CUT_RUN = /[ \t\r\n\u00a0\u3000·•∙‧・/]+/g
441
+ const CUT_ONE = /^[ \t\r\n\u00a0\u3000·•∙‧・/]+/
442
+ const PIECE = /[^ \t\r\n\u00a0\u3000·•∙‧・/]+/g
443
+ const KEYCHARS = /^[A-Za-z0-9+/=_.-]+$/
444
+ // RUNCUT is the length at which a run is long enough to be a key: the floor
445
+ // every reading asks for, and all a mask needs to show
446
+ const RUNCUT = 20
447
+
448
+ // spacedKey reads one value as a key cut into pieces by whitespace (or a
449
+ // middot, a slash): from the key's front to the first piece that is not key
450
+ // material. The front is found in the value with its cuts taken out, so it is
451
+ // found cut anywhere ("m s - 0 1 …", "ghp·_12…"), and what came before it and
452
+ // after the key keeps its shape. Exactly one front: a value carrying two is
453
+ // two keys already, and the per-word pass masks each.
454
+ //
455
+ // A piece of four or more letters is prose and ends the run, unless it is a
456
+ // bare piece of a front ("ghp" then "_12" is a cut, and "sk-a nt-a pi03" is a
457
+ // key whose body is all its pieces) or the body so far already reads as a key.
458
+ // A piece of three or more digits with no digit before it is a version
459
+ // ("ms-access 2019"), not key material. A run already RUNCUT long is not
460
+ // extended by prose: the mask covers 20 characters as well as 40.
461
+ const VERNUM = /^\d{3,}$/
462
+ const PROSE = /^[A-Za-z]{3,}$/
463
+ const FRONT_PIECE = /^(?:m|s|k|ms|sk|s-|g|h|p|u|r|o|x|ox|a|i|l|ali|as|akia|aida|ghp|gho|ghu|ghs|ghr|xox|asia)$/i
464
+
465
+ function spacedKey(s) {
466
+ // a control or format character is not a cut's spelling (a key with one in
467
+ // it is one word); a line break is, and is read like a space
468
+ if (/(?:(?![\n\r])[\p{Cc}\p{Cf}])/u.test(s)) return null
469
+ // the pieces, and where each starts in the value
470
+ const pieces = []
471
+ for (const m of s.matchAll(PIECE)) pieces.push({ text: m[0], at: m.index })
472
+ if (pieces.length < 2) return null
473
+ const joined = pieces.map((p) => p.text).join("")
474
+ const at = joined.search(/ms-/i)
475
+ const sk = joined.search(/sk-/i)
476
+ const frontAt = at >= 0 ? at : sk
477
+ if (frontAt < 0) return null
478
+ // exactly one front of its kind: two is two keys already
479
+ const mine = at >= 0 ? /ms-/gi : /sk-/gi
480
+ if ([...joined.matchAll(mine)].length !== 1) return null
481
+ // where in the value the front starts (the index in the joined string, not
482
+ // an index in the value: the pieces keep the gaps between them)
483
+ let cum = 0
484
+ let start = -1
485
+ let idx = -1
486
+ for (let k = 0; k < pieces.length; k += 1) {
487
+ if (frontAt >= cum && frontAt < cum + pieces[k].text.length) {
488
+ start = pieces[k].at + (frontAt - cum)
489
+ idx = k
490
+ break
491
+ }
492
+ cum += pieces[k].text.length
493
+ }
494
+ if (start < 0) return null
495
+ // the key's own characters from the front; a digit is what separates a key
496
+ // cut into pieces from a name with a version after it
497
+ const bodyFrom = (i) => s.slice(start, i).replace(CUT_RUN, "")
498
+ // the key's own span, not the rest of the line: a bracket, a comma or a
499
+ // Chinese character abutting the key's end is the text's, and judging the
500
+ // whole remainder as key material refused a valid key ("(sk-ant-…vTuH)")
501
+ const headSpan = /^[A-Za-z0-9+/=_.-]*/.exec(joined.slice(frontAt + 3))[0]
502
+ if (headSpan.length < 11 || !/\d/.test(headSpan)) return null
503
+ // walk forward: a piece is part of the key while it is key characters and
504
+ // not prose; a gap joins them only when it is a cut (one or two characters).
505
+ // The walk starts at the end of the piece the front's own characters sit in:
506
+ // the rest of that piece ("--ms-01234567") is the key's first body, not a
507
+ // gap
508
+ if (idx < 0) return null
509
+ // the front's own characters, then whatever of its piece follows them with
510
+ // no gap ("ms-aaaa1111", "--ms-01234"): the key begins right after its
511
+ // front, so the rest of that piece is key material, not filler
512
+ let end = start + 3
513
+ while (end < s.length && !CUT_ONE.test(s[end]) && KEYCHARS.test(s[end])) end += 1
514
+ // how long the front is ("ms-", "sk-", "ghp_"), for the walk's prose rule
515
+ const frontLen = (joined.slice(frontAt).match(/^(?:ms-|sk-|gh[pousr]_|xox[abposr]-|akia|asia|aida)/i)?.[0] ?? "").length
516
+ let bare = false
517
+ let strong = false
518
+ for (let guard = 0; guard < 512 && idx < pieces.length - 1; guard += 1) {
519
+ // whether the body read so far already reads as a key. Asked before the gap
520
+ // is judged, not after: the readings that step over a frame (a bracket in
521
+ // the cut, a punctuation-marked cut) are worth taking only once it does, and
522
+ // asking at the end of the loop meant the first cut was judged with the
523
+ // answer still false — "…KbwtX9( ZqLmNeR2vTuH" lost its second half there
524
+ strong = strong || isKey(bodyFrom(end))
525
+ // the gap before the next piece: one cut joins, a short run of cuts joins
526
+ // too (the card's own separator is a middot with a space either side,
527
+ // " · ", three characters, and a key cut by one is still one key), and a
528
+ // long run is a frame; any other character ("--ms-", "#ms-", "/tmp/ms-",
529
+ // a path segment) is the same filler, once the body reads as a key
530
+ // a gap longer than three joins only after the body reads as a key, and
531
+ // then only a middot's spelling ("a····b"): a run of cuts that is no
532
+ // separator ("a---b") is the frame a display draws instead of a hidden
533
+ // piece, and the pieces after it are printed as written
534
+ let gap = CUT_ONE.exec(s.slice(end))
535
+ // a key cut by a space with a closing frame in the cut ("…KbwtX9( ZqLmNeR2vTuH")
536
+ // is still one key: the bracket is the cut's, and stopping at it left the
537
+ // second half on the terminal. Read across only once the body already reads
538
+ // as a key, so a sentence's bracket is not a cut
539
+ if (!gap && strong) {
540
+ const bridged = /^[^\sA-Za-z0-9]{1,2}[ \t\r\n\u00a0\u3000]+/.exec(s.slice(end))
541
+ if (bridged) gap = bridged
542
+ }
543
+ if (!gap) break
544
+ if (gap[0].length > 3 && (!strong || gap[0].length > 5)) break
545
+ idx += 1
546
+ // the piece's own key span: what follows it inside the piece ("vTuH)" ,
547
+ // "abc," , "…无效") is the text's frame, not the key
548
+ // a bracket opened inside the cut ("…Kbwt (X9ZqLmNeR2vTuH") is the cut's
549
+ // too, once the body reads as a key; the piece's key span starts after it
550
+ const lead = strong ? (/^[^\sA-Za-z0-9]{1,2}/.exec(pieces[idx].text)?.[0].length ?? 0) : 0
551
+ const piece = /^[A-Za-z0-9+/=_.-]*/.exec(pieces[idx].text.slice(lead))[0]
552
+ if (!piece) break
553
+ if (VERNUM.test(piece) && !/\d/.test(bodyFrom(end))) break
554
+ // a piece of three or more letters is prose and ends the run ("no key
555
+ // ms-… for provider" keeps its frame), unless it is the piece a bare
556
+ // front was cut into ("sk" then "ant" is a cut, not a word)
557
+ // prose ends the run even after a bare front: "sk-preset-20261031 is a
558
+ // provider id" is a sentence, and a key whose pieces are letters is read
559
+ // by the reading that masks the whole body ("sk-a nt-a pi03", below)
560
+ // a front's own letters ("ant", "sk", "ghp") are not prose: the walk
561
+ // reads past them while the front has not been completed yet
562
+ if (PROSE.test(piece) && (frontLen === 0 || bodyFrom(end).length >= frontLen)) break
563
+ end = pieces[idx].at + lead + piece.length
564
+ bare = bare || FRONT_PIECE.test(piece)
565
+ }
566
+ const body = bodyFrom(end)
567
+ if (body.length <= 3 || !/\d/.test(body)) return null
568
+ // a vendor front that carries more than a front ("sk-preset-20261031") is a
569
+ // provider id, not a key cut up: the per-word and cut readings judge it
570
+ if (!/^ms-/i.test(body) && !isKey(body)) return null
571
+ return { pre: s.slice(0, start), body, post: s.slice(end) }
572
+ }
573
+ const keyish = (x) => isKey(x) || /^(?:ms-|sk-)/i.test(x)
574
+
575
+ // msBody is whether what follows an ms- front reads as key material, the same
576
+ // question the card's keyBody asks: an ordinary word after the front ("The
577
+ // ms-interviewing feature is unavailable") is not a key, and the CLI masked it
578
+ // while the card kept it. A ModelScope key is hex or base64 with a digit in it.
579
+ const msBody = /(?:[0-9a-f]{11,}(?![0-9a-z])|(?=[\x21-\x7e]{11,})(?!\w)[\x21-\x7e]*\d)/
580
+ // msKey is whether an ms- run is a key, and the two shapes that are one
581
+ // whatever their body says: a run in capitals (a child that upper-cases its
582
+ // output) and a value that is nothing but the run (a token pasted into a
583
+ // provider id — "ms-abcdefghijklmnop" is a credential there, not a word).
584
+ // Everything else is asked of its body, as the card asks.
585
+ // a version's dotted groups are not key material ("ms-cli 2026.11.9" is a
586
+ // program and its version, as the card reads it)
587
+ const msKey = (m, whole) => whole || m === m.toUpperCase() || (!m.includes(".") && msBody.test(m))
364
588
  const show = (s) =>
365
589
  String(s ?? "")
366
- .replace(tokenShaped, (m) => mask(m.replace(/[\p{Cc}\p{Cf}]/gu, "")))
590
+ .replace(tokenShaped, (m) => (msKey(m, String(s).trim() === m) ? mask(m.replace(/[\p{Cc}\p{Cf}]/gu, "")) : m))
591
+ // an upper-cased vendor key is the same key, and its pieces are too short
592
+ // for the per-word floors ("SK-ANT-API03-EYAI1PKJRF_KBWT")
593
+ .replace(/[^\s]+/g, (w) => (isKey(w) && VENDOR.test(w) && w === w.toUpperCase()) || (w.startsWith("--") && w.includes("=") && isKey(w.slice(w.indexOf("=") + 1))) ? mask(w) : w)
367
594
  .replace(/[\p{Cc}\p{Cf}]/gu, " ")
368
595
 
369
596
  // arg is show() for a value the user typed: --from, a stray argument, a bare
@@ -474,7 +701,51 @@ const alternates = (word) => (word.match(/[a-z][A-Z]/g) ?? []).length >= 2
474
701
  // so there is no whitespace in it and no value of nothing but spaces to answer
475
702
  // for. It was written for a whole value once, and a value with a trailing space
476
703
  // in it answered "not a key": that is the frame this round closed
477
- const isKey = (word) => {
704
+ // a run with no vowels and plenty of digits is a generated key with no
705
+ // separator at all ("5gli9zadx8zg9jez"): the card judges this shape and the
706
+ // CLI did not. A word followed by its numeral ("file1file2file3") is a list,
707
+ // the card's own exclusion.
708
+ const VOWELS = /[aeiouy]/g
709
+ const randomish = (r) => {
710
+ if (r.length < 12 || r.length > 40 || !/[a-z]/.test(r)) return false
711
+ // a short letter lead and then nothing but digits is a build number or a
712
+ // serial ("v202610310001"): the vowels a letter has are all in that lead,
713
+ // and the share below is kind to a long run of digits. A generated key
714
+ // spreads its letters through the run ("5gli9zadx8zg9jez"), which is what
715
+ // the transitions below are for
716
+ if (/^[a-z]{1,3}\d{8,}$/.test(r)) return false
717
+ if ((r.match(VOWELS) ?? []).length / r.length >= 0.2) return false
718
+ const digits = (r.match(/\d/g) ?? []).length
719
+ const transitions = (r.match(/[a-z][0-9]|[0-9][a-z]/g) ?? []).length
720
+ return transitions >= 2 || digits >= 4
721
+ }
722
+ const LISTY = /^(?:[A-Za-z]+\d+)+$/
723
+ // isKey judges one word as it was written. A word the readings cut and join
724
+ // back together is asked with runs = false: it was never one word, and the
725
+ // vowel-less arm below reads a run of numerals as a key ("no 42 20261031"
726
+ // joined to "no4220261031"). Its own pieces were each judged where they were
727
+ // written, so a join that masks nothing on its own loses nothing that a piece
728
+ // already masked; a join holding one key-shaped piece (a key an invisible
729
+ // character cut) is still a key by its run arm.
730
+ const isKey = (word, runs = true) => {
731
+ // an upper-cased vendor key is the same key, and its pieces are too short
732
+ // for the run floors ("SK-ANT-API03-EYAI1PKJRF_KBWT"); the rhythm a key has
733
+ // (a capital beside a digit) tells it from a sentence in capitals
734
+ if (VENDOR.test(word) && word === word.toUpperCase() && /[0-9]/.test(word) && !/[A-Z]{6,}/.test(word.replace(/^[A-Z]+_[A-Z]+_/, ""))) return true
735
+ // a vendor front and a body whose piece mixes case and carries a digit is a
736
+ // key even when every piece is short of the run floors: "ghp_Abcd1234Ef" (a
737
+ // token copied with its tail cut off) went out whole, and no word of prose
738
+ // carries a vendor front. A provider id behind the same front keeps its name
739
+ // ("sk-preset-20261031"): its pieces are a word and a number, and neither
740
+ // mixes case
741
+ if (
742
+ runs &&
743
+ !word.includes("…") &&
744
+ VENDOR.test(word) &&
745
+ /[0-9]/.test(word) &&
746
+ word.split(/[-_.]/).some((p) => /[a-z]/.test(p) && /[A-Z]/.test(p) && /[0-9]/.test(p))
747
+ ) return true
748
+ if (runs && /^[a-z0-9]+$/.test(word) && !(LISTY.test(word) && /[A-Za-z]{3,}/.test(word)) && randomish(word)) return true
478
749
  if (!/[\\/]/.test(word)) {
479
750
  // no separator: the whole word is one key, and one key-like run in it is
480
751
  // enough (a generated key has no separator at all). PATHY's leading "." is
@@ -507,8 +778,38 @@ const isKey = (word) => {
507
778
  // without taking the folder away. Two segments of 12+ characters mixing
508
779
  // cases is the whole of this arm, and it is what keeps
509
780
  // /Users/mison/Documents/SomeLongDirectoryName a path
510
- const segs = word.split(/[\\/]/).filter((seg) => seg.length >= 12 && /[a-z]/.test(seg) && /[A-Z]/.test(seg))
511
- return !PATHY.test(word) && segs.length >= 2
781
+ const pieces = word.split(/[\\/]/)
782
+ const segs = pieces.filter((seg) => seg.length >= 12 && /[a-z]/.test(seg) && /[A-Z]/.test(seg))
783
+ if (!PATHY.test(word) && segs.length >= 2) return true
784
+ // an AWS secret written in capitals is the same secret: a word with no
785
+ // lower-case letter at all whose slashes cut it into two or more pieces of
786
+ // 12+ capitals ("WJALRXUTNFEMI/K7MDENG/BPXRFICYEXAMPLEKEY" — measured, all
787
+ // 40 characters reached the terminal, and the README says upper-cased keys
788
+ // are masked). A path in capitals is not: /USERS/MISON/DOCUMENTS is a path
789
+ // (PATHY) and its pieces are short anyway, and a word with a lower-case
790
+ // letter in it is the case-mix arm's business, not this one
791
+ // a key cut into pieces by slashes ("ghp_1234Ab/Cd5678EfGh/9012IjKl34/…"):
792
+ // many parts, each key material rather than a word, and the whole reads as a
793
+ // key with the slashes taken out. A path's parts carry lower-case words
794
+ // ("SomeLongDirName" is a name). A key behind a frame starts at its front
795
+ const frontAt = word.search(/ms-|sk-|gh[pousr]_|xox[abposr]-|akia|asia|aida/i)
796
+ const sub = frontAt > 0 ? word.slice(frontAt) : word
797
+ if (!PATHY.test(sub) && !/^[\\/]/.test(sub)) {
798
+ const core = sub.replace(/[^A-Za-z0-9]+$/, "")
799
+ const coreSegs = core.split(/[\\/]/).filter((seg) => seg !== "")
800
+ const frontLen = (core.match(/^(?:ms-|sk-|gh[pousr]_|xox[abposr]-|akia|asia|aida)/i)?.[0] ?? "").length
801
+ const wordy = (seg, i) => /[a-z]{2,}/.test(i === 0 ? seg.slice(frontLen) : seg)
802
+ if (
803
+ coreSegs.length >= 3 &&
804
+ coreSegs.every((seg) => /^[A-Za-z0-9_+=.-]+$/.test(seg)) &&
805
+ !coreSegs.some((seg, i) => wordy(seg, i)) &&
806
+ isKey(compact(core))
807
+ )
808
+ return true
809
+ }
810
+ if (/[a-z]/.test(word)) return false
811
+ const caps = pieces.filter((seg) => seg.length >= 12 && /^[A-Z0-9]+$/.test(seg) && /[A-Z]/.test(seg))
812
+ return !PATHY.test(word) && caps.length >= 2
512
813
  }
513
814
  const UUID_ANY = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
514
815
  // A key of another shape pasted with an invisible character inside it — a
@@ -524,10 +825,766 @@ const UUID_ANY = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g
524
825
  // each lookaround, so it is a linear pass, not the kind of scan that backtracked
525
826
  // to minutes on a body of control characters.
526
827
  const GLUE = new RegExp(String.raw`(?<=[A-Za-z0-9+\/=_.-])${INV}(?=[A-Za-z0-9+\/=_.-])`, "gu")
527
- const arg = (v) =>
528
- show(String(v ?? "").replace(GLUE, "")).replace(/[^\s]+/g, (w) =>
529
- isKey(w) ? mask(w) : w.replace(RUN, (r) => (keyRun(r) ? mask(r) : r)).replace(UUID_ANY, (m) => mask(m)),
530
- )
828
+ // VENDOR and MASKED are index.mjs's, to the letter: a key's front that is no
829
+ // key on its own, and the ellipsis a mask carries.
830
+ const VENDOR = /^(?:sk-|sk_(?:live|test)_|gh[pousr]_|xox[abposr]-|akia|asia|aida|aliyun|ssh-rsa|-----begin)/i
831
+ const MASKED = "…"
832
+ // keyAt: a word is the front of a key when it is key-shaped on its own or
833
+ // carries a vendor's front. A front like "ghp_" or "sk-" is under every word
834
+ // floor, and a key cut by whitespace leaves it as a word of its own.
835
+ // keyAt: a word is the front of a key when it carries a vendor's front
836
+ // ("ghp_", "sk-", "ms-") or is key-shaped on its own
837
+ const keyAt = (w) => VENDOR.test(w) || /^ms-/i.test(w) || isKey(w)
838
+ // a body of a vendor key cut by whitespace keeps its prefix together — "ghp_"
839
+ // on one line and 1234 on the next is ghp_ 1234 — and nothing else is joined:
840
+ // a key-shaped word next to a prose word is a key's end (its own pass masks
841
+ // it by its own ends), and joining the prose would move the ends
842
+ const PREFIX = /^(?:sk_(?:live|test)_|sk-|gh[pousr]_|github_pat_|xox[abposr]-|akia|asia|aida)/i
843
+ // a word after the front continues it when it is key material the floors miss
844
+ // on its own: a digit in it, or a short piece with a capital
845
+ const continues = (w) => (/[0-9]/.test(w) || /[A-Z]/.test(w)) && !VENDOR.test(w)
846
+ const glued = (ws) => ws.map((w) => w[0]).join("")
847
+ // startProse: a run's first word is a word of prose — all lower-case letters,
848
+ // two or more of them — and a run that starts with one is no key's head.
849
+ const startProse = (w) => /^[a-z]{4,}$/.test(w) && !VENDOR.test(w) && !/^ms-/.test(w)
850
+ // a word of English written in capitals, three or more letters
851
+ const WORDS = /^[A-Z]{3,}$/
852
+ // splitKey is spacedKey's reading for the keys that carry no front: a UUID, a
853
+ // ghp_ token, a digest, an ms- key cut where the front's own walk does not
854
+ // reach. spacedKey begins at an ms-/sk- front and reads on from there; a value
855
+ // with no such front was printed piece by piece, each under every floor on its
856
+ // own — measured: "123e4567-e89b-12d3-a45 6-426614174000" and
857
+ // "ghp_Abcd1234Efgh 5678Ijkl9012Mnop 3456Qrst" both went out as written.
858
+ //
859
+ // The reading is the same walk: pieces joined across a cut, and the moment the
860
+ // join reads as a key the run has found it. A piece that is a word ends the
861
+ // search, so a sentence's words are never glued into a key that is not there,
862
+ // and the join is only taken when it carries a digit — a key does, and the
863
+ // lists and prose a message carries do not.
864
+ function splitKey(s) {
865
+ // a page of one- and two-character pieces is the worst case for a walk that
866
+ // starts at every piece, and no value a person types or a child prints this
867
+ // long is one key cut up: past this the other readings still run
868
+ if (s.length > 8 * 1024) return null
869
+ if (/(?:(?![\n\r])[\p{Cc}\p{Cf}])/u.test(s)) return null
870
+ const pieces = [...s.matchAll(PIECE)].map((m) => ({ text: m[0], at: m.index }))
871
+ if (pieces.length < 2) return null
872
+ // a piece the cut left: key characters, under the width a cut's piece has
873
+ // (a whole key is one piece, and spacedKey's business), and not a word
874
+ // a piece of capitals is key material, not a word ("AKIAIOSF · ODNN7EXAMPLE"
875
+ // is one key cut in two): the run reading exempts capitals for the same reason
876
+ // a piece of letters that alternates case is key material too ("wJalrXUtnFEMI"
877
+ // is the front of an AWS secret cut by its slashes and a space): a word
878
+ // capitalises once ("McDonald"), and the run is only taken when the join
879
+ // reads as a key, so a sentence's words are still never glued. A 16+
880
+ // mixed-case run is key material whatever its transitions ("MNOPqrstUVWXZZZZ"
881
+ // is a token's back half cut at the middle, one l-to-M capitalisation and
882
+ // nothing more) — the same shape keyRun masks when the half stands alone
883
+ const piece = (t) =>
884
+ t.length <= 48 &&
885
+ KEYCHARS.test(t) &&
886
+ !(PROSE.test(t) && !/^[A-Z]+$/.test(t) && !keyedBody(t) && !((t.match(RUN) ?? []).some(keyRun)))
887
+ // a frame's punctuation ("key=…", "(…)") is read by maskCut, whose head
888
+ // begins after it; joining from the piece's own first character would take
889
+ // the frame for key material ("key=AKIA…" masked from the "k")
890
+ const framed = (t) => !/^[A-Za-z0-9]/.test(t) || /[=:([{,"']/.test(t)
891
+ const MID = /[·•∙‧・]/
892
+ const GAP = /^[ \t\r\n  ·•∙‧・]{1,3}$/
893
+ // the slash is a key's own separator (an AWS secret: wJalr…/K7MDENG/…), so a
894
+ // slash gap reads on like a space does — but only when the value does not
895
+ // start like a path. "/var/folders/hq/q19jry…/T/…" joins into one key-ish
896
+ // read (the segment carries digits and case), and a path must keep its shape.
897
+ const SLASH = /^\/{1,3}$/
898
+ const pathLed = PATHY.test(s) || /^[\\/]/.test(s)
899
+ for (let from = 0; from < pieces.length - 1; from += 1) {
900
+ if (!piece(pieces[from].text) || framed(pieces[from].text)) continue
901
+ // a middot before the piece is a cut the other readings own: their walk
902
+ // starts at the key's front, and starting a second one here took the
903
+ // front's pieces for the text ("key=g·hp_1·23·…" came out
904
+ // "key=g·hp_1…7890")
905
+ if (from > 0 && MID.test(s.slice(pieces[from - 1].at + pieces[from - 1].text.length, pieces[from].at))) continue
906
+ let joined = pieces[from].text
907
+ let best = -1
908
+ // a capitalised word either side of a middot is the card's own separator
909
+ // between two captions ("Qwen3-Max · Qwen3-Coder"), not one key's cut. A
910
+ // key's front is exempt ("Ghp_Abcd1234" is capitalised like a caption)
911
+ const caption = (t) => /^[A-Z][a-z]{2,}/.test(t) && !VENDOR.test(t)
912
+ for (let k = from + 1; k < pieces.length && k - from <= 48 && joined.length < 96; k += 1) {
913
+ const gapHere = s.slice(pieces[k - 1].at + pieces[k - 1].text.length, pieces[k].at)
914
+ if (!GAP.test(gapHere) && !(SLASH.test(gapHere) && !pathLed)) break
915
+ if (MID.test(gapHere) && !VENDOR.test(pieces[from].text) && caption(pieces[k - 1].text) && caption(pieces[k].text)) break
916
+ // a piece that opens its own front is a second key, not the first one's body:
917
+ // joining them hid both ends behind one mask ("ms-a…5555")
918
+ if (/^(?:ms-|sk-)/i.test(pieces[k].text)) break
919
+ if (!piece(pieces[k].text)) {
920
+ // a display cuts a key one piece at a time, and its last piece can be a
921
+ // short word of capitals ("Ghp_…·Qrst", "…·789·0"): once the join reads
922
+ // as a key, a piece of at most four letters behind a middot is its end.
923
+ // A space is the text's ("no key ms-… for provider").
924
+ if (best === k - 1 && MID.test(gapHere) && pieces[k].text.length <= 4 && /^[A-Za-z]+$/.test(pieces[k].text)) best = k
925
+ break
926
+ }
927
+ // a long numeral after the key is a count or a year, not the key's own
928
+ if (VERNUM.test(pieces[k].text) && !/\d/.test(joined)) break
929
+ joined += pieces[k].text
930
+ if (joined.length < 12) continue
931
+ if (/\d/.test(joined) && isKey(joined, false)) best = k
932
+ }
933
+ // the run's longest reading is the key: a key cut into three pieces reads
934
+ // as one at the second of them too, and masking there left the last piece
935
+ // to the per-word pass ("ghp_…9012·IjKl…7890")
936
+ if (best > 0) {
937
+ const last = pieces[best]
938
+ return { pre: s.slice(0, pieces[from].at), body: pieces.slice(from, best + 1).map((p) => p.text).join(""), post: s.slice(last.at + last.text.length) }
939
+ }
940
+ }
941
+ return null
942
+ }
943
+
944
+ // a run of capitals and digits is a key written without its spaces
945
+ // ("AKIAIOSF", "AKIAIOSFODNN7EXA")
946
+ const capsPiece = (w) => /^[A-Z0-9]{4,}$/.test(w) && /[A-Z]/.test(w)
947
+ // tokenShaped is a /g pattern (used with replace); test() on it would carry
948
+ // lastIndex from one call to the next and answer false on the second look
949
+ const tokenShaped1 = new RegExp(tokenShaped.source, "iu")
950
+ const keyedBody = (body) => (body.match(/[a-z][A-Z]/g) ?? []).length >= 2 && /[A-Z]/.test(body) && /[a-z]/.test(body)
951
+ const maskCut = (s) => {
952
+ if (s.length > 32 * 1024) return s
953
+ // a middot or a bullet splits words here: it is one of the spellings a cut
954
+ // arrives in ("ms-a·b·c"), and one word with middots inside it is a shape no
955
+ // reading below can take apart
956
+ const words = [...s.matchAll(/[^\s·•∙‧・]+/g)]
957
+ if (words.length < 2) return s
958
+ const at = (i) => words[i].index
959
+ const end = (i) => words[i].index + words[i][0].length
960
+ const gap = (i) => s.slice(end(i - 1), at(i))
961
+ // a middot or a bullet between pieces is a cut's other spelling
962
+ // ("ms-a·b·c") when the run starts at a key's front or at one-character
963
+ // pieces, or the join already reads as a key; a card's or a log's own
964
+ // separators do not start at either
965
+ const run = (i, k) => glued(words.slice(i, k + 1)).replace(MASKED, "")
966
+ // a vendor front may itself be cut ("xox b-1 234 …"): the head's leading
967
+ // letters and the next word's together carry it
968
+ const frontSplit = (i) => i + 1 < words.length && /^[A-Za-z]+$/.test(words[i][0]) && words[i][0].length <= 5 && VENDOR.test(words[i][0] + words[i + 1][0])
969
+ const MIDDOT_GAP = /^(?:[ \t\u00a0\u3000]?[·•∙‧・]){1,2}[ \t\u00a0\u3000]?$/
970
+ const wordLike = (w) => /^[A-Za-z]+$/.test(w) && /[a-z]/.test(w) && /[aeiouy]/i.test(w)
971
+ // (the join's own reading is asked once a body of key length is on the
972
+ // table: asking it at every middot re-read the run to its square)
973
+ // a capitalised word is a caption, not a piece of a key, and two of them
974
+ // either side of a middot are a list the card keeps ("Qwen3-Max ·
975
+ // Qwen3-Coder"). A key's own front is exempt: "Ghp_Abcd1234" is capitalised
976
+ // like a caption and is still the key's first piece
977
+ const caption = (w) => /^[A-Z][a-z]{2,}/.test(w)
978
+ const middotGap = (k, w0, i) => MIDDOT_GAP.test(gap(k)) && !(caption(words[i][0]) && caption(words[k][0]) && !VENDOR.test(words[i][0]) && !VENDOR.test(words[k][0])) && (w0.length <= 2 || VENDOR.test(w0) || /^ms-/i.test(w0) || frontSplit(i) || !wordLike(words[i][0]) || !wordLike(words[k][0]) || (end(k) - at(i) >= 20 && isKey(run(i, k - 1))))
979
+ // a character cut: every piece one character ("A· K· I· A· …"), or a
980
+ // vendor front cut in two and then one character at a time ("G· H· P· _· 1· 2· …")
981
+ // — a shape the run below cannot read, because its prose test ends a run at
982
+ // the first lower-case piece. Read here, on the pieces' own alphabet
983
+ const cutGap = (k) => /^[ \t\u00a0\u3000\r\n]+$/.test(gap(k)) || MIDDOT_GAP.test(gap(k))
984
+ // the key material of a word: what follows a frame's punctuation ("key=AK",
985
+ // "(AK"), the word itself when it carries none. The frame stays as printed
986
+ const frameAt = (w) => {
987
+ // a path is not a frame: its words are the per-word pass's
988
+ if (/[\\/]/.test(w)) return { mat: w }
989
+ const lead = w.replace(/^[^A-Za-z0-9]+/, "")
990
+ // a vendor's front carries its own hyphens ("sk-ant-"), so it is kept
991
+ // whole; anything else is the letters and digits it ends with ("key=123e")
992
+ const sep = Math.max(...'=:([{,"\''.split("").map((c) => lead.lastIndexOf(c)))
993
+ const tail = sep >= 0 ? lead.slice(sep + 1) : lead
994
+ // a piece of a digest is hex and its hyphens ("…=[123e4567-")
995
+ // (a word with a middot in it is the cut's: its material is the word, so
996
+ // the frame keeps no copy of key text — "123e4567-e89·b-" used to leave
997
+ // "123e4567" in the frame and again in the mask)
998
+ const cutMid = /[·•∙‧・]/.test(tail)
999
+ // the word's trailing letters and digits are its material ("key=AK",
1000
+ // "NickName=[ghp_…"). A bare short numeral is not: "字ABC12" ends in
1001
+ // "12", and taking that for a head made every word of a page one, each
1002
+ // with a 48-word scan behind it (measured: 25 KB took 19 s before this
1003
+ // floor). A key behind a frame is never two digits long
1004
+ const trailing = tail.match(/[A-Za-z0-9]+$/)?.[0] ?? ""
1005
+ // a piece of a digest is four characters at least, or carries a letter
1006
+ // (a hex run of "12" is no key's front, and reading it as one made every
1007
+ // word of a page a head)
1008
+ const digestTail = /^[0-9a-f-]+$/i.test(tail) && (tail.length >= 4 || /[A-Za-z]/.test(tail))
1009
+ return { mat: VENDOR.test(tail) || /^ms-/i.test(tail) || digestTail || cutMid ? tail : trailing.length >= 4 || /[A-Za-z]/.test(trailing) ? trailing : "" }
1010
+ }
1011
+ // a piece's own spelling may carry the text's punctuation ("0.", "1,")
1012
+ // a single hyphen a key was cut at ("… 7·-·e …") is a piece too
1013
+ const one = (w) => w.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "").length === 1 || /^[^A-Za-z0-9]$/.test(w)
1014
+ // a key written one character per word is at most a few hundred pieces; a
1015
+ // page of them is not a key typed out, and reading every start of it is
1016
+ // quadratic (measured: 32 KiB of "A A A …" took 2 s)
1017
+ const out = []
1018
+ let from = 0
1019
+ for (let i = 0; i < words.length; i += 1) {
1020
+ // a pair either side of a line break — the break of a wrapped copy, where
1021
+ // nothing in between is prose — read joined: the second half of a key
1022
+ // behind its front ("ghp_\n1234AbCd…", "no key ghp_\n1234…"). The front
1023
+ // is a bare prefix ("ghp_", "sk-", "AKIA"), a word no key floor keeps.
1024
+ const brk = i > 0 && /^\r?\n$/.test(gap(i))
1025
+ const bare = i > 0 && (PREFIX.test(words[i - 1][0]) || (!keyAt(words[i - 1][0]) && words[i - 1][0].length < 12))
1026
+ if (brk && bare && continues(words[i][0]) && end(i) - at(i - 1) >= 20) {
1027
+ const pair = glued([words[i - 1], words[i]])
1028
+ if (!pair.includes(MASKED) && isKey(pair, false)) {
1029
+ out.push(s.slice(from, at(i - 1)), mask(pair))
1030
+ from = end(i)
1031
+ i += 1
1032
+ continue
1033
+ }
1034
+ }
1035
+ // a run is read from a head: a word carrying a vendor's front or the ms-
1036
+ // shape written with its body cut off ("ms-f"), or a run of capitals and
1037
+ // digits (a key written without its spaces), or a piece of a key written
1038
+ // one character per word. Anything else is the per-word pass's
1039
+ // ("spawnSync /tmp/…" is a sentence, not a cut).
1040
+ const w0 = words[i][0]
1041
+ // a key written one character per word runs five or more such pieces
1042
+ // together ("s k - a n t") — a single short word beside a long one is not
1043
+ // a head, and asking after it at every position is what made a body of
1044
+ // them quadratic
1045
+ const tiny = w0.length <= 2 && words[i + 4] !== undefined && words.slice(i, i + 5).every((x) => x[0].length <= 2)
1046
+ const msHead = /^ms-/i.test(w0) && !isKey(w0) && !/^ms-[\x21-\x7e]{11,}$/i.test(w0)
1047
+ // a key cut inside a mixed-case word ("wJal rXUt nFEM …" is an AWS
1048
+ // secret): the join has to be a key of its own, which a sentence's first
1049
+ // word after a path is not
1050
+ const mixedHead = w0.length <= 16 && /[a-z]/.test(w0) && /[A-Z]/.test(w0)
1051
+ // a vendor front itself cut in two ("xox b-1 234 …", "ghp _1234 …"): the
1052
+ // front is not a word any floor keeps, and the cut falls inside it
1053
+ const frontish =
1054
+ w0.length <= 5 &&
1055
+ /^[A-Za-z]+$/.test(w0) &&
1056
+ i + 1 < words.length &&
1057
+ (VENDOR.test(w0 + words[i + 1][0]) || VENDOR.test(w0 + words[i + 1][0].slice(0, 2)))
1058
+ // a piece of a digest ("9f8e 7d6c …", "9f8e·7d6c·…", "123e4567-e"): hex
1059
+ // with a digit in it, which no word of prose is, and the hyphens a UUID's
1060
+ // pieces carry. The card reads the same run
1061
+ const digestPiece = (w) => /[0-9]/.test(w) && /^[0-9a-f-]+$/i.test(w) && !/^-+$/.test(w)
1062
+ const hexHead = digestPiece(w0)
1063
+ // a key written in one lower-case alphabet ("abcd1234efgh 5678ijkl9012
1064
+ // mnop3456"): the card takes the same run, and no head reading above does
1065
+ // — a piece of it is no vendor front, carries no capital, is no digest and
1066
+ // is longer than a front's. The run still has to read as one key joined
1067
+ // below, so a page of words with numerals is not taken
1068
+ const lcHead = w0.length >= 4 && /^[a-z0-9]+$/.test(w0) && /[0-9]/.test(w0) && /[a-z]/.test(w0)
1069
+ // a head behind a frame's punctuation ("key=AK IA…", a JSON quote, a
1070
+ // bracket): the key material is what follows the frame, and the frame
1071
+ // itself is kept as printed. Two characters of it at least — a single
1072
+ // letter behind a frame is the per-word pass's ("NickName=[A") — and the
1073
+ // run still has to read as a key below
1074
+ const { mat } = frameAt(w0)
1075
+ // a word with no frame may carry the key's head inside it ("no key
1076
+ // ms-aaaa…"): its part that reads as a digest. A path is not read that
1077
+ // way ("/tmp/ms-ZZZZ…" is masked as the token it is, by the per-word pass)
1078
+ // (a bare short numeral is no key's band either: "字ABC12" carries a
1079
+ // "12", and reading that as a head made every word of a page one. A key
1080
+ // behind a frame is four characters at least, or carries a letter)
1081
+ const band = /[\\/]/.test(w0) ? undefined : (w0.match(/[A-Za-z0-9]+/g) ?? []).find((b) => digestPiece(b) && (b.length >= 4 || /[A-Za-z]/.test(b)))
1082
+ // (a word that opens with a vendor front is the key's from its first
1083
+ // character: a digest band inside it — the "12" of "ghp_12" — is not a
1084
+ // frame, and the four characters the mask shows would be that band)
1085
+ const t0 = mat !== w0 && mat.length >= 2 ? mat : band && band.length >= 2 && !VENDOR.test(w0) && !/^ms-/i.test(w0) ? band : w0
1086
+ // the head's key material with its frame kept outside the mask
1087
+ const tailHead = t0 !== w0
1088
+ // (a frame whose material is one character is no head's: "NickName=[1,"
1089
+ // read the name into the key and masked a list of ones. The word's own
1090
+ // shape can still start a run — a whole key-shaped word is the key
1091
+ // whatever stands in front of it — but being a mixed-case word behind a
1092
+ // frame is not enough)
1093
+ const framedOne = mat !== w0 && mat.length < 2
1094
+ if (!VENDOR.test(w0) && !capsPiece(w0) && !msHead && !tiny && !mixedHead && !frontish && !hexHead && !tailHead && !lcHead) continue
1095
+ if (framedOne && !VENDOR.test(w0) && !capsPiece(w0) && !msHead && !frontish && !hexHead && !lcHead) continue
1096
+ // two key-shaped words are two keys: one mask for both would hide the
1097
+ // second's front ("ms-aaaa… ms-zzzz…" is not one key)
1098
+ const pairOfKeys = i + 1 < words.length && /^[ \t\u00a0\u3000]+$/.test(gap(i + 1)) && keyAt(words[i][0]) && keyAt(words[i + 1][0])
1099
+ let j = i
1100
+ let head = i
1101
+ // the last word a run read, and whether anything in it was key material:
1102
+ // a start that found neither hands the scan on to there ("A A A …" is one
1103
+ // pass, not one run per word), while a run that saw a digit or a front may
1104
+ // still hold a key's head further on and is read word by word
1105
+ let last = i
1106
+ let sawKey = false
1107
+ let body = t0
1108
+ let sawHard = /[A-Z]/.test(t0) || /\d/.test(t0)
1109
+ // the pieces read so far, kept as they are read: slicing them out of the
1110
+ // word list at every piece is what made a page of tiny words take seconds
1111
+ const pieces = [t0]
1112
+ // the questions below asked of the pieces, kept as they are read (asking
1113
+ // each of them again at every piece read a page of tiny words to the square)
1114
+ // A run of pieces of nothing but digits is no key whatever follows it
1115
+ // (the cut test says so), and a page of short words with digits in them
1116
+ // made every word a head with a 48-word scan behind it: asked per piece,
1117
+ // that was quadratic (measured: 25 KB of them took 1.1 s). Kept as they
1118
+ // are read, and the scan below leaves the run as soon as it cannot be a
1119
+ // key.
1120
+ let allNumeric = /^[0-9]*$/.test(t0)
1121
+ let proseCount = 0
1122
+ const notePiece = (w) => {
1123
+ if (!/^[0-9]*$/.test(w)) allNumeric = false
1124
+ if (w.length >= 4 && /^[a-z]{4,}$/.test(w) && !VENDOR.test(w)) proseCount += 1
1125
+ }
1126
+ if (!/^[0-9]*$/.test(t0)) allNumeric = false
1127
+ let allAlphabet = /^[A-Za-z0-9+/=_.-]*$/.test(t0)
1128
+ let anyDigit = /[0-9]/.test(t0)
1129
+ let anyCap = /[A-Z]/.test(t0)
1130
+ // (a key is shorter than 128 characters, and every reading below is asked
1131
+ // of the body joined so far: a page of short words that each looked like a
1132
+ // head read bodies of 300 characters 48 times apiece, which was quadratic
1133
+ // in the page — measured, 25 KB took 5 s. The bound is not reached by a
1134
+ // key, and it stops the walk instead of reading a body that cannot be one)
1135
+ for (let k = i + 1; !pairOfKeys && k < words.length && k - i <= 48 && body.length < 128 && (/^[ \t\u00a0\u3000\r\n]+$/.test(gap(k)) || middotGap(k, w0, i)); k += 1) {
1136
+ const x = words[k][0]
1137
+ // a short word of the text's ends the run once a cut has been read
1138
+ // ("no key abcd… mnop3456 for provider" keeps its "for"): the pieces of
1139
+ // a key that are one to three letters are read before the cut is found
1140
+ // (the "a" of "sk-a nt-a pi03"), and a word after it is the text's.
1141
+ // (a run whose body already reads as a key is complete: the word after
1142
+ // it is the frame's — "ghp_…Qrst, for provider" read "for" into the
1143
+ // mask, and the mask ended ",for")
1144
+ if (/^[a-z]{1,3}$/.test(x) && !VENDOR.test(x) && (j > i || isKey(body))) break
1145
+ last = k
1146
+ if (/[0-9]/.test(x) || VENDOR.test(x)) sawKey = true
1147
+ // every piece is short enough or a vendor front; a word of a sentence in
1148
+ // between ("the log said") goes on only when what follows is key-shaped
1149
+ // again and the body has a capital or a digit — an echo's own words
1150
+ // ("aaabbb… x aaaccc… 1234", one piece between two of the key's)
1151
+ if (x.length > 16 && !VENDOR.test(x) && !(/^[0-9a-f-]+$/i.test(x) && /[0-9]/.test(x))) break
1152
+ // a piece of capitals and digits is key material ("xox b-1 234 567 890
1153
+ // 12- …" is one key cut into threes): a key is written in capitals and
1154
+ // digits more often than a word of prose is
1155
+ // the cut of a vendor front into capitals and digits ("xox b-1 234 567
1156
+ // 890 12- …"): the piece after the front starts with a digit, the pieces
1157
+ // are short, and none is a capitalised word ("Release", "Notes")
1158
+ const capsy = i + 1 < words.length && /^-?[0-9]/.test(words[i + 1][0]) && x.length <= 16 && /[A-Z0-9]/.test(x)
1159
+ body += x
1160
+ pieces.push(x)
1161
+ notePiece(x)
1162
+ if (allAlphabet && !/^[A-Za-z0-9+/=_.-]*$/.test(x)) allAlphabet = false
1163
+ if (!sawHard && (/[A-Z]/.test(x) || /\d/.test(x))) sawHard = true
1164
+ if (!anyDigit && /[0-9]/.test(x)) anyDigit = true
1165
+ if (!anyCap && /[A-Z]/.test(x)) anyCap = true
1166
+ // the head is not a word of the run's own: "xoxb -123 4567 …" begins at
1167
+ // a front cut in two, and reading the front as prose ended the run
1168
+ // a piece joined by a middot or a bullet is key material whether or not
1169
+ // it reads as a word ("ms-f·orei·gn-0·…"): the card's separators are the
1170
+ // ones with a word on both sides, and those a run started at a key's
1171
+ // front can still be checked against the key below
1172
+ const mid = MIDDOT_GAP.test(gap(k))
1173
+ const prose = !mid && proseCount > 0
1174
+ // (the head's own material counts: "abcd1234efgh 5678ijkl9012 mnop3456"
1175
+ // read its first piece as prose and ended the run there, and the key
1176
+ // stayed on the terminal whole)
1177
+ if (prose && !(/^ms-/i.test(w0) && tokenShaped1.test(body) && msKey(body, false)) && !(keyAt(x) && sawHard)) continue
1178
+ // the body of a key cut by spaces: 20+, a key-shaped run in the head —
1179
+ // the front, or the word after it ("ghp_ 1234 AbCd…"; "sk-a nt-a pi03…"
1180
+ // is a vendor key whose body is all its pieces, and the head's run is
1181
+ // no key on its own: sk / ant / a are all short)
1182
+ // the body is a key when it reads as one written: joined it reads as the
1183
+ // ms- shape with its body intact ("ms-f orei gn-0 1234 …"), or the key it
1184
+ // writes joined is one word ("sk-a nt-a pi03…" is
1185
+ // sk-ant-api03-Eyai1pkJrF_Kbwt), or — from a vendor front — the head
1186
+ // flips case inside itself. A word after the first piece is prose
1187
+ // ("sk-preset-20261031 is a provider id")
1188
+ // ("sk-a" is not a vendor front, and the vendor front of "sk-preset" is
1189
+ // the whole word; either way the words after the first piece are prose)
1190
+ // a lower-case run of four or more letters after the head is prose, or
1191
+ // a path's word ("/tmp/ms-ZZZZ…"): a key's pieces carry capitals or
1192
+ // digits
1193
+ const plain = x.length > 4 && /^[a-z]{4,}$/.test(x) && !VENDOR.test(x)
1194
+ // a bare front (ghp_, sk-, xoxb-) is a word no key floor keeps, and the
1195
+ // pieces after it are the key; a vendor-like word that carries more than
1196
+ // the front ("sk-preset-20261031") is a provider id, and its run only
1197
+ // counts when the join is a key of its own
1198
+ const bareFront = VENDOR.test(pieces[0]) && pieces[0].length <= 8
1199
+ // a front cut in two leaves a shorter body than the floor reads: the
1200
+ // front itself is half of it ("xoxb-12345678901" is 16 characters), and
1201
+ // the front's own evidence stands in for the missing four
1202
+ const capMixed = anyDigit && anyCap
1203
+ const capFront = /^[a-z]{1,5}$/.test(pieces[0]) && /[0-9]/.test(pieces[1]) && /[A-Za-z]/.test(pieces[1])
1204
+ const shortFront = frontish && pieces.length >= 3 && anyDigit
1205
+ // a body of digits only is no key's: the run cannot become one by
1206
+ // reading on, and every reading below would be asked at every piece
1207
+ if (allNumeric) continue
1208
+ const cut =
1209
+ /\d/.test(body) &&
1210
+ body.length >= (frontish ? 16 : 20) &&
1211
+ !body.includes(MASKED) &&
1212
+ // a middot or a bullet is a cut's spelling too, and the key it writes
1213
+ // is the characters joined without them
1214
+ // (a body of nothing but digits is no key's, and some of the readings
1215
+ // below re-read it at every piece: asked once, at the floor)
1216
+ (anyCap || !/^\d+$/.test(body)) &&
1217
+ !/^\d+$/.test(body.replace(/[^A-Za-z0-9]/g, "")) &&
1218
+ // (a body whose letters are one word — "NickName=[1, 1, …]" — is a
1219
+ // frame, not a key's front: a key carries other marks with its letters)
1220
+ (/[\d]/.test(body.replace(/[^A-Za-z0-9]/g, "").replace(/^[A-Za-z]+/, "")) || !isKey(body.replace(/[^A-Za-z]/g, ""), false)) &&
1221
+ (isKey(body, false) ||
1222
+ // the key the pieces write when the card's own separators — the
1223
+ // middots and the hyphens a UUID is drawn with — are left out
1224
+ (mid && isKey(body.replace(/[·•∙‧・]/g, ""), false)) ||
1225
+ (mid && isKey(body.replace(/[·•∙‧・-]/g, ""), false)) ||
1226
+ (msHead && tokenShaped1.test(body) && msKey(body, false) && !pieces.some((w) => /^[A-Za-z]{2,}$/.test(w) && !VENDOR.test(w) && w !== w0)) ||
1227
+ (bareFront && !plain && (pieces[0] + pieces[1]).match(/[A-Za-z0-9]+/g) ? (pieces[0] + pieces[1]).match(/[A-Za-z0-9]+/g).some((r) => keyRun(r) || r.length >= 4 || keyedBody(body)) : false) ||
1228
+ (capsy && (capMixed || capFront) && !pieces.some((w) => /^[A-Z][a-z]{3,}$/.test(w))) ||
1229
+ (shortFront && !pieces.some((w) => /^[A-Z][a-z]{3,}$/.test(w)))) &&
1230
+ (k - i >= 2 || !bareFront)
1231
+ if (!cut) continue
1232
+ j = k
1233
+ }
1234
+ if (j === i) {
1235
+ if (!sawKey && last > i) i = last
1236
+ continue
1237
+ }
1238
+ // the head's frame is kept as printed and only its key material goes into
1239
+ // the mask ("key=AK IA…", "no key ms-aaaa…" loses none of its "no key")
1240
+ // a word read whole starts its key material where its material does: the
1241
+ // frame in front of it ("(ghp_…") is kept, and the four characters the
1242
+ // mask shows are the key's, not the frame's leftovers
1243
+ const keep = s.slice(at(i), at(i) + w0.length).indexOf(t0)
1244
+ const parts = [t0, ...words.slice(i + 1, j + 1).map((w) => w[0])]
1245
+ // a word of nothing but punctuation is the frame's ("(… )"), not the
1246
+ // key's: the mask's last four characters are the key's own
1247
+ let tailP = ""
1248
+ if (parts.length > 1 && !/[A-Za-z0-9]/.test(parts[parts.length - 1])) tailP = parts.pop().replace(/^[A-Za-z0-9]+/, "")
1249
+ const keyText = parts.join("").replace(MASKED, "")
1250
+ out.push(s.slice(from, at(i) + keep), mask(keyText.replaceAll(MASKED, "")), tailP)
1251
+ from = end(j)
1252
+ i = j
1253
+ }
1254
+ // the character runs are read after the runs above, on the text they left:
1255
+ // a key typed one character at a time is the last shape to look for, and a
1256
+ // page of such pieces is not a key typed out
1257
+
1258
+ const singles = words.reduce((n, w) => n + (w[0].length === 1 ? 1 : 0), 0)
1259
+ const chars = []
1260
+ let cfrom = 0
1261
+ for (let i = 0; i < words.length && singles <= 512; i += 1) {
1262
+ // a piece inside a run already read is not a head
1263
+ if (words[i][0].length === 1 && i > 0 && MIDDOT_GAP.test(gap(i)) && words[i - 1][0].length === 1) continue
1264
+ // a piece that stands behind a frame's punctuation ("key=1·2·3·…") is
1265
+ // the first of the run: the frame is kept as printed
1266
+ const piece = words[i][0]
1267
+ const { mat } = frameAt(piece)
1268
+ const pieceOk = piece.length === 1 || (mat.length === 1 && piece.endsWith(mat))
1269
+ if (!(pieceOk || (/^[A-Za-z]{1,3}$/.test(piece) && i + 1 < words.length && one(words[i + 1][0]) && cutGap(i + 1)))) continue
1270
+ let k = i
1271
+ while (k + 1 < words.length && k - i <= 96 && cutGap(k + 1) && one(words[k + 1][0])) k += 1
1272
+ // the last piece's trailing punctuation is the text's own ("… 0."), not
1273
+ // key material: the mask stops before it
1274
+ const last = words[k][0].replace(/[^A-Za-z0-9]+$/, "")
1275
+ const got = (piece.length === 1 ? piece : mat) + glued(words.slice(i + 1, k)) + last
1276
+ if (k > i && got.length >= 20 && isKey(got, false)) {
1277
+ const keep = piece.length - (piece.length === 1 ? 1 : mat.length)
1278
+ chars.push(s.slice(cfrom, at(i) + keep), mask(got))
1279
+ cfrom = at(k) + last.length
1280
+ i = k
1281
+ }
1282
+ }
1283
+ // what the character runs left is read by the same pass again, on the
1284
+ // text it produced (a second pass finds no character runs to read)
1285
+ if (chars.length) return maskCut(chars.join("") + s.slice(cfrom))
1286
+ return out.join("") + s.slice(from)
1287
+ }
1288
+
1289
+ // maskCaps is the vendor key written without its spaces, a run of capitals and
1290
+ // digits ("AKIAIOSF ODNN7EXA MPLE"): a whole word is the general pass's, and
1291
+ // this reading joins a run of pieces of capitals and digits when they add up
1292
+ // to an AWS key id or a long run of capitals.
1293
+ const capsRun = (s) => {
1294
+ if (s.length > 32 * 1024 || !/[A-Z]/.test(s)) return s
1295
+ const words = [...s.matchAll(/[\x21-\x7e]+/g)]
1296
+ const out = []
1297
+ let from = 0
1298
+ for (let i = 0; i < words.length; i += 1) {
1299
+ // three characters is a key's piece too ("AKI AIO SFO …" is a key cut
1300
+ // into threes); the join still has to be long enough to be one, so a
1301
+ // sentence of two capitals is not read
1302
+ if (!/^[A-Z0-9]{3,}$/.test(words[i][0]) || !/[A-Z0-9]/.test(words[i][0])) continue
1303
+ let j = i
1304
+ let body = words[i][0]
1305
+ // a piece carries the card's own separator on its end ("AKIAIO· SFODNN·…")
1306
+ const piece = (w) => w.replace(/[·•∙‧・]+$/, "")
1307
+ // the join stops at four times RUNCUT: a key is never longer, and a body
1308
+ // of tiny words ("1A1 1A1 …") is otherwise re-read from every start
1309
+ while (j + 1 < words.length && body.length < 4 * RUNCUT && s.slice(words[j].index + words[j][0].length, words[j + 1].index).length <= 2 && /^[A-Z0-9]{2,}$/.test(piece(words[j + 1][0]))) {
1310
+ j += 1
1311
+ body += piece(words[j][0])
1312
+ }
1313
+ if (j === i) continue
1314
+ const aws = /^AKIA[0-9A-Z]{16}$/.test(body)
1315
+ const long = body.length >= 16 && !/[a-z]/.test(body)
1316
+ if (!aws && !long) continue
1317
+ // a run of English words in capitals ("SEE ALSO THE DOCUMENTATION")
1318
+ // joins to one: a sentence is longer than any key this reads, and its
1319
+ // words are three letters or more
1320
+ if (!aws && body.length > 40) {
1321
+ // a sentence of capitals: no key starts inside it (a key's suffix
1322
+ // inside a long run would have been a body over the limit too), and the
1323
+ // next start is the end of what was read, less the length a key can be
1324
+ i = Math.max(i, j - 13)
1325
+ continue
1326
+ }
1327
+ // the same rhythm rule as the card's capitals reading: an all-capitals
1328
+ // run needs a capital beside a digit ("AKIA…7"), or it is prose in caps
1329
+ if (!aws && !/[A-Z][0-9]/.test(body) && !/[0-9][A-Z]/.test(body)) {
1330
+ if (j > i) i = Math.max(i, j - 13)
1331
+ continue
1332
+ }
1333
+ out.push(s.slice(from, words[i].index), mask(body))
1334
+ from = words[j].index + words[j][0].length
1335
+ i = j
1336
+ }
1337
+ return out.join("") + s.slice(from)
1338
+ }
1339
+
1340
+ // joinedKey is the whole-value judgement the full-width reading needs: every
1341
+ // word joined back up begins at one ms- front and the rest reads as key
1342
+ // material. Unlike spacedKey's walk it does not stop at a word of letters: a
1343
+ // key cut into four-character groups has such pieces ("qmjh") in it, and the
1344
+ // walk was the full-width reading's only path to a mask — a full-width space
1345
+ // between the groups then went out with every piece printed. The mask covers
1346
+ // the whole value, so a trailing frame is hidden with it, which is what the
1347
+ // full-width reading has always done (the same value with ASCII spaces keeps
1348
+ // its frame: spacedKey finds the span there).
1349
+ const joinedKey = (s) => {
1350
+ const words = s.trim().split(/\s+/)
1351
+ if (words.length < 2) return false
1352
+ const joined = words.join("")
1353
+ const at = joined.search(/ms-/i)
1354
+ // only punctuation may precede the front: a letter or digit before it means
1355
+ // the "ms-" is part of a word ("Microsoftms-access…" is not a key)
1356
+ if (at < 0 || /[A-Za-z0-9]/.test(joined.slice(0, at))) return false
1357
+ if (joined.slice(at + 3).search(/ms-/i) >= 0) return false
1358
+ const rest = joined.slice(at + 3)
1359
+ return rest.length >= 11 && /\d/.test(rest) && /^[A-Za-z0-9+/=_.-]+$/.test(rest)
1360
+ }
1361
+
1362
+ export function arg(v) {
1363
+ const orig = String(v ?? "")
1364
+ // the ms- front has its own joiner for pieces of any width (one to a
1365
+ // character), and it reads the value as it was typed
1366
+ const cut = spacedKey(orig.replace(GLUE, ""))
1367
+ // the body spans the gaps the pieces were cut with; the key is the
1368
+ // characters themselves, and mask() keeps its first and last four — a
1369
+ // three-character last piece ("… nop3 456") otherwise shows the gap in the
1370
+ // mask ("ms-a… 456")
1371
+ // (the join is only a key when the body reads as one: "ms-cli 2026.11.9"
1372
+ // is a program and its version, and the card keeps it)
1373
+ if (cut && msKey(cut.body, false)) return argRun(cut.pre) + mask(cut.body.replace(/\s+/g, "")) + argRun(cut.post)
1374
+ // a key with no ms-/sk- front, cut the same way: the pieces are joined and
1375
+ // asked whether they read as one key (splitKey)
1376
+ const split = splitKey(orig.replace(GLUE, ""))
1377
+ if (split) return argRun(split.pre) + mask(split.body.replace(/\s+/g, "")) + arg(split.post)
1378
+ // full-width pieces fold to a key; an ellipsis folds to three dots and would
1379
+ // read as key material ("ms-a…5555" → "ms-a...5555"), so a value that
1380
+ // already carries a mask is not asked again
1381
+ const raw = orig.replace(GLUE, "")
1382
+ if (!raw.includes("…")) {
1383
+ const folded = foldedSpans(raw)
1384
+ if (folded) return folded
1385
+ }
1386
+ return argRun(raw)
1387
+ }
1388
+
1389
+ // foldedSpans reads the words that fold (a full-width key, its spaces), and
1390
+ // nothing else. The readings above have no ASCII to work on for one — it
1391
+ // carries no path, no front, no lowercase run — so their NFKC copy (case kept,
1392
+ // which is what the card judges) is read. Two things that reading must not do,
1393
+ // and both were measured:
1394
+ // * it must not mask the value whole. "Key AKIA…PLE is invalid, please
1395
+ // retry." went out as "KeyA…try.": the sentence around a bad key is the
1396
+ // user's, and what the tool read has to stay readable around what it hid.
1397
+ // * it must not read the whole value through the cut readings. Every word of
1398
+ // a page written in full width folds, each became a head with a scan behind
1399
+ // it, and 25 KB of them took seconds (the reading is quadratic in the
1400
+ // words); the cost has to be paid per span, not per page.
1401
+ // A span is a run of words joined by spaces or wide spaces, none of them prose
1402
+ // (a key is split by spaces, never by the words of a sentence), and it has to
1403
+ // fold to something a key could be: a key's front and its body, or a run long
1404
+ // enough and shaped like key material. The span as typed is masked, and the
1405
+ // text around it goes through the ordinary readings.
1406
+ const FOLDABLE = /[^\x00-\x7f]/
1407
+ const foldedSpans = (raw) => {
1408
+ const parts = []
1409
+ let at = 0
1410
+ let hit = false
1411
+ const words = []
1412
+ for (const m of raw.matchAll(/[^\s]+/g)) words.push({ text: m[0], at: m.index })
1413
+ // a full-width word folds; the wide space between two of them is a cut, not
1414
+ // a sentence's space, and joins them ("AKIA… ODNN…" is one key)
1415
+ const folds = (w) => FOLDABLE.test(w) && w.normalize("NFKC") !== w
1416
+ // a key is shorter than 96 characters and a page of them is not a value a
1417
+ // person typed: the span stops there, and at 128 spans, so the whole reading
1418
+ // is linear in the value (a Chinese paragraph whose every word carries a
1419
+ // full-width comma folds word by word, and one span over all of it was the
1420
+ // quadratic reading this replaced)
1421
+ const MAX_SPAN = 96
1422
+ // a word of key material after the folded span continues the key: a
1423
+ // full-width front ("AKIAIOSF") with its ASCII body ("ODNN7EXAMPLE") is
1424
+ // one key cut by the copy, and the body does not fold, so the fold-only span
1425
+ // stopped at the front and the body reached the terminal. Joining is gated by
1426
+ // the same keyish test below, and a word that starts with a lowercase letter
1427
+ // is the sentence's ("… ODNN7EXAMPLE is invalid"), not the key's, so a
1428
+ // sentence's words are never taken.
1429
+ const isCutGap = (g) => /^[ \t\r\n  ]+$/.test(g)
1430
+ // a word that continues the key: key characters and carrying a digit or a
1431
+ // capital. A word of lower-case letters is the sentence's ("… is invalid"),
1432
+ // and "aaa1111-bbbb-…" (a body written in ASCII) carries digits, so it is.
1433
+ const keyMat = (t) => KEYCHARS.test(t) && (/\d/.test(t) || /[A-Z]/.test(t)) && !(PROSE.test(t) && !/^[A-Z]+$/.test(t) && !keyedBody(t))
1434
+ const endAt = (w) => w.at + w.text.length
1435
+ let spans = 0
1436
+ for (let i = 0; i < words.length && spans < 128; i += 1) {
1437
+ if (!folds(words[i].text)) continue
1438
+ let j = i
1439
+ while (j + 1 < words.length && folds(words[j + 1].text) && endAt(words[j + 1]) - words[i].at <= MAX_SPAN) j += 1
1440
+ const keyishOf = (k) => {
1441
+ const folded = raw.slice(words[i].at, endAt(words[k])).normalize("NFKC")
1442
+ return joinedKey(folded.toLowerCase()) || (folded.length >= 12 && /\d/.test(folded) && argRun(folded) !== folded)
1443
+ }
1444
+ // read on over the key-material words that follow the folded front (a cut
1445
+ // is a space or a wide space), and take the longest join that reads as a
1446
+ // key; the fold-only span is the floor
1447
+ for (let k = j + 1; k < words.length && k - i <= 48 && endAt(words[k]) - words[i].at <= MAX_SPAN; k += 1) {
1448
+ if (!isCutGap(raw.slice(endAt(words[k - 1]), words[k].at)) || !keyMat(words[k].text)) break
1449
+ if (keyishOf(k)) j = k
1450
+ }
1451
+ // the same reading backward: an ASCII key front ("ms-a") with a full-width
1452
+ // body is one key too, and the front does not fold. A front of a vendor's
1453
+ // shape ("ms-", "sk-") is key material whatever its length.
1454
+ const keyFront = (t) => keyMat(t) || VENDOR.test(t) || /^ms-/i.test(t)
1455
+ let s0 = i
1456
+ for (let k = i - 1; k >= 0 && i - k <= 48 && endAt(words[j]) - words[k].at <= MAX_SPAN; k -= 1) {
1457
+ if (!isCutGap(raw.slice(endAt(words[k]), words[k + 1].at)) || !keyFront(words[k].text)) break
1458
+ const folded = raw.slice(words[k].at, endAt(words[j])).normalize("NFKC")
1459
+ if (!(joinedKey(folded.toLowerCase()) || (folded.length >= 12 && /\d/.test(folded) && argRun(folded) !== folded))) break
1460
+ s0 = k
1461
+ }
1462
+ const start = words[s0].at
1463
+ const stop = endAt(words[j])
1464
+ const text = raw.slice(start, stop)
1465
+ const folded = text.normalize("NFKC")
1466
+ // the joined copy is the key the fold makes: the whole-value judgement
1467
+ // (a front and its body across the pieces), or the cut readings on the
1468
+ // span alone (a full-width vendor key: sk-ant-… is a word of its own)
1469
+ const keyish = joinedKey(folded.toLowerCase()) || (folded.length >= 12 && /\d/.test(folded) && argRun(folded) !== folded)
1470
+ if (keyish) {
1471
+ parts.push(raw.slice(at, start), mask(text.replace(/\s+/g, "").trim()))
1472
+ at = stop
1473
+ hit = true
1474
+ spans += 1
1475
+ }
1476
+ i = j
1477
+ }
1478
+ if (!hit) return ""
1479
+ parts.push(raw.slice(at))
1480
+ return parts.map((p) => (p.includes("…") ? p : argRun(p))).join("")
1481
+ }
1482
+
1483
+ // argRun is the whitespace routes of one value, tried until none finds more
1484
+ // ("ghp_ 1234 AbCd 5678…" is one body only from 1234 on, and the front is
1485
+ // part of a key-shaped piece by then): a run of words; a line break (or a
1486
+ // wide space a full-width copy left) between two words; then the per-word
1487
+ // judgement. Each route masks strictly more, so the walk ends.
1488
+ function argRun(text) {
1489
+ // a body of many KiB of one-character words (a pretty-printed page) costs
1490
+ // far more reading than the masking is worth here: past 32 KiB the cut
1491
+ // reading is skipped and the per-word pass (linear) still runs
1492
+ if (text.length > 32 * 1024) return argWords(text)
1493
+ // The two readings the body has are applied together, until neither finds
1494
+ // anything: the whitespace routes (maskCut, and capsRun for a vendor key
1495
+ // written without its spaces, "AKIAIOSF ODNN7EXA MPLE") each mask what they
1496
+ // join, and argWords then judges every word the join did not take. One
1497
+ // pass of each per round, so a round over ordinary text is a couple of
1498
+ // scans, not a dozen.
1499
+ let s = text
1500
+ for (let n = 0; n < 4; n += 1) {
1501
+ const next = argWords(maskCut(capsRun(s)))
1502
+ if (next === s) break
1503
+ s = next
1504
+ }
1505
+ return s
1506
+ }
1507
+
1508
+ // frameSpan is the key's own span of a word a frame was written round:
1509
+ // "(ghp_…)" is the key and two brackets, not a key of the brackets' making.
1510
+ // The mask spends four characters at each end on the key's own characters, so
1511
+ // the frame has to stay outside it. Only punctuation is a frame here: a word
1512
+ // in front of the bracket ("x(ghp_…") is the text's, and its own reading
1513
+ // already masks the key it carries. A path is no frame either.
1514
+ const frameSpan = (w) => {
1515
+ if (/[\\/]/.test(w)) return null
1516
+ let a = 0
1517
+ while (a < w.length && !/[A-Za-z0-9]/.test(w[a])) a += 1
1518
+ let b = w.length
1519
+ while (b > a && !/[A-Za-z0-9]/.test(w[b - 1])) b -= 1
1520
+ if (a === 0 && b === w.length) return null
1521
+ const body = w.slice(a, b)
1522
+ return body ? { lead: w.slice(0, a), body, tail: w.slice(b) } : null
1523
+ }
1524
+
1525
+ // argWords is the per-word judgement: one word key-shaped is masked whole,
1526
+ // and inside a word that is not, its key-like runs and UUIDs are masked. The
1527
+ // word in the other spellings a key is printed in — full-width characters,
1528
+ // mostly — is judged by its folded copy and masked as written.
1529
+ const argWords = (x) =>
1530
+ show(x).replace(/[^\s]+/g, (w) => {
1531
+ const f = w.normalize("NFKC").toLowerCase()
1532
+ // the word as written and its fold. The capitals need no third call: a
1533
+ // word that is already all capitals is judged whole by isKey (its slashed
1534
+ // arm), and upper-casing a lower-case word to ask the question would read
1535
+ // every long provider id with a date in it as a key (modelscope20261031)
1536
+ // (what is masked is the key's own span: a word quoted in a frame —
1537
+ // "(ghp_…)", "[ghp_…]", '"ghp_…"' — is judged whole, and masking the whole
1538
+ // word gave the frame's punctuation the two characters the mask spends on
1539
+ // the key's own ends: "(ghp_…Qrst)" printed "(ghp…rst)")
1540
+ const inFrame = frameSpan(w)
1541
+ // NAME=value: an identifier on the left of "=" or ":" and a key on the
1542
+ // right is the variable and its value, and only the value is the secret.
1543
+ // Judged before the whole word, because isKey reads "token=ghp_…" as one
1544
+ // key (its compact is 20+ and alternates) and masked the variable's name
1545
+ // too: a short "token=ghp_Abcd1234Ef" kept its name and a long one did
1546
+ // not, so the same shape went out two ways. The left side has to read as a
1547
+ // name (letters, digits, underscores, two or more letters) so "retry=true"
1548
+ // and "code=ENOENT" are left alone, and the value is still a key on its own.
1549
+ const eq = Math.max(w.indexOf("="), w.indexOf(":"))
1550
+ if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(w.slice(0, eq)) && (w.slice(0, eq).match(/[A-Za-z]/g) ?? []).length >= 2) {
1551
+ const value = w.slice(eq + 1)
1552
+ if (!value.includes("…") && isKey(value)) return `${w.slice(0, eq + 1)}${mask(value)}`
1553
+ }
1554
+ // the word as written and its lowercase fold; a full-width key with no
1555
+ // ASCII at all is the whole-value NFKC reading's business (above), which
1556
+ // sees it as one value rather than word by word
1557
+ if (isKey(w) || isKey(f)) return inFrame ? inFrame.lead + mask(inFrame.body) + inFrame.tail : mask(w)
1558
+ // the frame's own punctuation is not key material, and judging the word
1559
+ // with it still on failed a key quoted in brackets: "(5gli9zadx8zg9jez)"
1560
+ // went out whole — the parentheses are characters isKey's run never reads,
1561
+ // and the word without them is a key
1562
+ if (inFrame && isKey(inFrame.body)) return inFrame.lead + mask(inFrame.body) + inFrame.tail
1563
+ // a value behind a flag or a frame ("--bad=SK-ANT-…", "NickName=[ghp_…")
1564
+ // is judged by what the frame encloses
1565
+ const kAt = w.search(/[A-Za-z]/)
1566
+ if (kAt > 0 && !/[\\/]/.test(w.slice(0, kAt)) && isKey(w.slice(kAt))) return inFrame ? inFrame.lead + mask(inFrame.body) + inFrame.tail : mask(w)
1567
+ // a key behind a flag or a header ("token=ghp_…", "Authorization: ghp_…")
1568
+ // is judged by what follows the separator: it names a vendor, and a value
1569
+ // that short is under every run floor, so nothing else here would read it
1570
+ const sep = Math.max(w.lastIndexOf("="), w.lastIndexOf(":"))
1571
+ if (sep > 0 && sep < w.length - 1) {
1572
+ const value = w.slice(sep + 1)
1573
+ if (!value.includes("…") && VENDOR.test(value) && isKey(value)) return `${w.slice(0, sep + 1)}${mask(value)}`
1574
+ }
1575
+ const marks = (y) => y.replace(RUN, (r) => (keyRun(r) ? mask(r) : r)).replace(UUID_ANY, (m) => mask(m))
1576
+ const out = marks(w)
1577
+ // the folded copy can be key-shaped where the original is not — a token
1578
+ // written in full width has no ASCII runs and no ASCII path prefix, so the
1579
+ // passes above saw a word of ordinary text. When the folded copy would be
1580
+ // masked, the word is masked whole: mapping the mask back onto the
1581
+ // original spelling is not something NFKC can promise, and the whole word
1582
+ // is what the user typed anyway. A key already masked ("ms-a…5555") is left
1583
+ // as it is: it is a short string of letters now, and the token shape no
1584
+ // longer matches it — but its mask is not a word to be judged again.
1585
+ if (out === w && (marks(f) !== f || (f.match(tokenShaped) ?? []).some((m) => msKey(m, x.trim() === w))) && !w.includes("…")) return mask(w)
1586
+ return out
1587
+ })
531
1588
 
532
1589
  // SENDABLE is the plugin's own test (index.mjs's tokenOf): a key with
533
1590
  // anything but visible ASCII in it goes out in a Cookie/Authorization header
@@ -554,33 +1611,50 @@ function hostOf(chat) {
554
1611
  const oursOf = (p) => p?.id === PRESET || p?.preset === PRESET || hostOf(p?.chat) === HOST
555
1612
  const keyedOf = (p) => typeof p?.key === "string" && Boolean(p.key.trim())
556
1613
 
557
- // 插件侧的账号:主键 msmoli 与 msmoli#xxxxxx,按令牌区分。
1614
+ // The plugin-side accounts: the primary key msmoli plus msmoli#xxxxxx, told
1615
+ // apart by token.
558
1616
  //
559
- // 令牌只从 type === "api" 的条目上读——和插件自己(index.mjs 的 rawKey)同一条
560
- // 规则。少这一条时,一份把 key 放错位置或类型写歪的 plugin-auth.json,会让本
561
- // 工具说「插件已用同一把令牌、无需操作」,而插件读不到令牌、卡片永远显示未登录:
562
- // 一个静默的错误状态,比报错更难发现。
1617
+ // The token is only read from entries with type === "api" — the same rule the
1618
+ // plugin itself uses (rawKey in index.mjs). Without that rule, a
1619
+ // plugin-auth.json that puts the key in the wrong place or writes the wrong
1620
+ // type would make this tool say the plugin already uses the same token and
1621
+ // nothing needs doing, while the plugin cannot read the token and the card
1622
+ // always shows signed out: a silent wrong state is harder to notice than an
1623
+ // error.
1624
+ // site is the origin the plugin recorded for the account after its own probe
1625
+ // ("https://modelscope.ai" for an international one, cleared for a domestic
1626
+ // one). It is empty when the account has never been probed, and an empty site
1627
+ // is not evidence of either.
563
1628
  export function pluginAccounts(auth) {
564
1629
  const obj = auth && typeof auth === "object" && !Array.isArray(auth) ? auth : {}
565
1630
  return Object.entries(obj)
566
1631
  .filter(([k]) => k === ID || k.startsWith(`${ID}#`))
567
- .map(([name, v]) => ({ name, token: v?.type === "api" && typeof v.key === "string" ? v.key.trim() : "" }))
1632
+ .map(([name, v]) => ({
1633
+ name,
1634
+ token: v?.type === "api" && typeof v.key === "string" ? v.key.trim() : "",
1635
+ site: typeof v?.site === "string" ? v.site : "",
1636
+ }))
568
1637
  }
569
1638
 
570
1639
  // { action: "noop" | "run" | "refuse", lines: string[], cmds: [{ argv, stdin? }] }
571
1640
  export function planToPlugin({ preset, accounts, force }) {
572
1641
  const lines = []
573
- const same = accounts.find((a) => a.token === preset.key)
574
- if (same) {
575
- lines.push(`✓ 插件已用同一把令牌(${mask(preset.key)}),无需操作。`)
576
- return { action: "noop", lines, cmds: [] }
1642
+ // A key field of the wrong type is a source this tool cannot read, not an
1643
+ // empty one: SENDABLE.test() would coerce 12345 to "12345" and plan a login
1644
+ // with it. Refused here for the same reason planToPreset refuses it.
1645
+ // keyRaw is what presetCandidates kept for a non-string field; a caller
1646
+ // that hands the raw entry over directly has none, and its `key` still
1647
+ // holds the original value.
1648
+ const rawKey = "keyRaw" in preset ? preset.keyRaw : preset.key
1649
+ if (rawKey !== undefined && typeof rawKey !== "string") {
1650
+ lines.push(`✗ 内置预设 ${arg(preset.id)} 的 key 不是字符串(${rawKey === null ? "null" : typeof rawKey}),读不出它现在是什么。`)
1651
+ lines.push(` 先修好 providers.json 里那一项(或删掉它),再重跑:magpie provider key ${arg(preset.id)} <key>。`)
1652
+ return { action: "refuse", lines, cmds: [] }
577
1653
  }
578
- // The plugin refuses a key with anything but visible ASCII in it (tokenOf:
579
- // it would go out in a Cookie header), so logging one in would report a
580
- // success that leaves every check-in saying "令牌内容无法识别".
581
- // planToPreset has the same guard for the same reason; this direction was
582
- // missing it, and a key with a zero-width character in it — invisible in
583
- // every terminal and in this tool's own mask — went through.
1654
+ // The SENDABLE guard runs before the "already the same" check: two files
1655
+ // holding the same key with a space in it are "the same" and unusable, and
1656
+ // reporting noop would tell the user everything is fine while every
1657
+ // check-in reports a token it cannot recognise.
584
1658
  if (!SENDABLE.test(preset.key)) {
585
1659
  lines.push(`✗ 内置预设的 key 含空格、换行或非 ASCII 字符,插件发不出去,不登录。`)
586
1660
  // the provider the user actually has, not the preset's default id: a
@@ -590,14 +1664,20 @@ export function planToPlugin({ preset, accounts, force }) {
590
1664
  lines.push(` 先修好内置那边的 key(magpie provider key ${arg(preset.id)} <key>)。`)
591
1665
  return { action: "refuse", lines, cmds: [] }
592
1666
  }
1667
+ const same = accounts.find((a) => a.token === preset.key)
1668
+ if (same) {
1669
+ lines.push(`✓ 插件已用同一把令牌(${mask(preset.key)}),无需操作。`)
1670
+ return { action: "noop", lines, cmds: [] }
1671
+ }
593
1672
  if (accounts.length === 0) {
594
1673
  lines.push(`→ 将把内置预设(${arg(preset.id)})的令牌登录给插件:magpie plugin login ${ID}(令牌走 stdin)`)
595
1674
  return { action: "run", lines, cmds: [{ argv: ["plugin", "login", ID], stdin: `${preset.key}\n`, secret: preset.key }] }
596
1675
  }
597
1676
  // Every entry is there but none carries a token the plugin itself would read
598
- // (a msmoli entry of the wrong type, or a key that is not a string). "两边
599
- // 令牌不同" is a claim we cannot make here — we never read the plugin's side —
600
- // and --force would not help: the file is the problem, not the choice.
1677
+ // (a msmoli entry of the wrong type, or a key that is not a string). "The
1678
+ // tokens differ on the two sides" is a claim we cannot make here — we never
1679
+ // read the plugin's side — and --force would not help: the file is the
1680
+ // problem, not the choice.
601
1681
  if (accounts.every((a) => !a.token)) {
602
1682
  lines.push(`✗ 插件里 ${ID} 的条目读不出令牌(type 不是 api,或 key 不是字符串)。`)
603
1683
  lines.push(` magpie 也不会把它算作登录。${arg(accounts[0].name)} 那个条目请先删掉或重新登录,再重跑。`)
@@ -621,25 +1701,47 @@ export function planToPlugin({ preset, accounts, force }) {
621
1701
  action: "run",
622
1702
  lines,
623
1703
  cmds: [
624
- // logout 也带 secret:被换掉的那把是真实凭据,magpie 的报错若回显
625
- // 账号("no key ms-… for provider")不该把整串放上终端
1704
+ // logout carries the secret too: the key being replaced is a real
1705
+ // credential, and a magpie error that echoes the account ("no key ms-…
1706
+ // for provider") must not put the whole string on the terminal
626
1707
  { argv: ["plugin", "logout", ID], stdin: "", secret: accounts[0].token },
627
1708
  { argv: ["plugin", "login", ID], stdin: `${preset.key}\n`, secret: preset.key },
628
1709
  ],
629
1710
  }
630
1711
  }
631
1712
 
632
- // provider key 是整条替换主 key,聊天请求正在用它——所以默认也要求 --force。
1713
+ // provider key replaces the whole main key and chat requests are using it — so
1714
+ // this direction demands --force by default too.
633
1715
  export function planToPreset({ preset, accounts, force }) {
634
1716
  const lines = []
635
1717
  const main = accounts.find((a) => a.name === ID)
1718
+ // An entry that is there but carries no token the plugin would read (a
1719
+ // msmoli entry of the wrong type, or a key that is not a string) is not a
1720
+ // missing login: telling the user to log in sends them nowhere, and the
1721
+ // `--to-plugin` advice the message below carries refuses the same file. The
1722
+ // forward direction already says it this way.
1723
+ if (main && !main.token) {
1724
+ lines.push(`✗ 插件里 ${arg(main.name)} 的条目读不出令牌(type 不是 api,或 key 不是字符串)。`)
1725
+ lines.push(` magpie 也不会把它算作登录。请先删掉这个条目或重新登录(magpie plugin login ${ID}),再重跑。`)
1726
+ return { action: "refuse", lines, cmds: [] }
1727
+ }
636
1728
  if (!main?.token) {
637
1729
  // A second account holding the token the preset already has is the state
638
1730
  // a forced replace leaves behind (or a re-login after a nickname write).
639
- // There is nothing to sync, and "主账号没有登录" would read as "your
640
- // plugin is signed out" while --to-plugin answers "无需操作" for the very
641
- // same pair of files.
1731
+ // There is nothing to sync, and "the main account is not signed in" would
1732
+ // read as "your plugin is signed out" while --to-plugin answers "nothing
1733
+ // to do" for the very same pair of files.
642
1734
  const same = preset && accounts.find((a) => a.token && a.token === preset.key)
1735
+ // SENDABLE runs before the noop, here as in planToPlugin: two sides that
1736
+ // hold the same unusable key are "the same", and noop would call that
1737
+ // healthy. A key with a space in it cannot be sent at all, whichever side
1738
+ // it came from — this is the reverse direction's only early exit, and the
1739
+ // main-account and forward paths refuse it.
1740
+ if (same && preset && !SENDABLE.test(preset.key)) {
1741
+ lines.push(`✗ 两边是同一把令牌,但它含空格、换行或非 ASCII 字符,发不出去。`)
1742
+ lines.push(` 先修好内置那边的 key(magpie provider key ${arg(preset.id)} <key>),或重新登录插件。`)
1743
+ return { action: "refuse", lines, cmds: [] }
1744
+ }
643
1745
  if (same) {
644
1746
  lines.push(`✓ 插件的 ${arg(same.name)} 已与内置预设用同一把令牌(${mask(same.token)}),无需操作。`)
645
1747
  return { action: "noop", lines, cmds: [] }
@@ -653,27 +1755,62 @@ export function planToPreset({ preset, accounts, force }) {
653
1755
  lines.push(` 要加的话:magpie provider add ${PRESET} <key>(工具不代建 provider)。`)
654
1756
  return { action: "refuse", lines, cmds: [] }
655
1757
  }
656
- if (preset.key === main.token) {
657
- lines.push(`✓ 内置预设已用同一把令牌(${mask(main.token)}),无需操作。`)
658
- return { action: "noop", lines, cmds: [] }
1758
+ // A key field that is there but is not a string is not an empty slot. keyedOf
1759
+ // answers "no key" for any other type, and this direction treats that as a
1760
+ // preset with nothing to lose — so {"key": null} was written over without
1761
+ // --force, and {"key": 12345} was announced as "现有 key((不可用))会被整条
1762
+ // 替换". Either way the user's replacement decision was skipped or unreadable.
1763
+ // Absent and "" are the two spellings of a slot with nothing to lose, and
1764
+ // both have a type of "string" (or undefined): the test is the type alone.
1765
+ const rawKey = "keyRaw" in preset ? preset.keyRaw : preset.key
1766
+ if (rawKey !== undefined && typeof rawKey !== "string") {
1767
+ lines.push(`✗ 内置预设 ${arg(preset.id)} 的 key 不是字符串(${rawKey === null ? "null" : typeof rawKey}),读不出它现在是什么。`)
1768
+ lines.push(` 请先修好 providers.json 里那一项(或删掉它),再重跑:magpie provider key ${arg(preset.id)} <key>。`)
1769
+ return { action: "refuse", lines, cmds: [] }
659
1770
  }
660
- lines.push(` 插件主账号:${ID}(${mask(main.token)})`)
661
- lines.push(` 内置预设:${arg(preset.id)},现有 key(${mask(preset.key)})会被整条替换。`)
662
1771
  // The plugin refuses a key with anything but visible ASCII in it (tokenOf,
663
1772
  // it would go out in an Authorization header). Writing such a key into the
664
- // preset would break chat requests while this tool reported success.
1773
+ // preset would break chat requests while this tool reported success — and
1774
+ // "both sides already hold this key" is not a healthy state to call done.
665
1775
  if (!SENDABLE.test(main.token)) {
666
1776
  lines.push(`✗ 插件里那把令牌含空格、换行或非 ASCII 字符,发不出去,不写进内置预设。`)
667
1777
  lines.push(` 先修好插件那边的登录(magpie plugin login ${ID})。`)
668
1778
  return { action: "refuse", lines, cmds: [] }
669
1779
  }
670
- // provider key 的第一个参数是 id:一个以 "-" 开头的 id 会被 magpie 的 CLI
671
- // 当成 flag(`provider key --help <token>` 只打印帮助、不写 key)。
1780
+ // An ms- token answers 401 on the site it did not come from, and the plugin
1781
+ // records which site an account belongs to after its own probe. The preset
1782
+ // this direction writes into chats with api-inference.modelscope.cn only, so
1783
+ // an international token there would 401 on every request while this tool
1784
+ // said ✓. Measured before the check: `--to-preset --force` wrote it and
1785
+ // answered rc=0.
1786
+ if (main.site && main.site !== CN_SITE) {
1787
+ lines.push(`✗ 插件主账号是国际站(${arg(main.site)})的令牌,内置预设只对国内站有效,写进去聊天会 401。`)
1788
+ lines.push(` 国际站账号请在 magpie 里单独加一个 provider(chat 指向 api-inference.modelscope.ai)。`)
1789
+ return { action: "refuse", lines, cmds: [] }
1790
+ }
1791
+ if (preset.key === main.token) {
1792
+ lines.push(`✓ 内置预设已用同一把令牌(${mask(main.token)}),无需操作。`)
1793
+ return { action: "noop", lines, cmds: [] }
1794
+ }
1795
+ lines.push(` 插件主账号:${ID}(${mask(main.token)})`)
1796
+ // an empty key is a preset with nothing to lose: this is the write that
1797
+ // fills it, and the wording should not claim a key is being replaced
1798
+ lines.push(
1799
+ preset.key
1800
+ ? ` 内置预设:${arg(preset.id)},现有 key(${mask(preset.key)})会被整条替换。`
1801
+ : ` 内置预设:${arg(preset.id)},现在没有 key,会被填上。`,
1802
+ )
1803
+ // The first argument of provider key is the id: an id starting with "-"
1804
+ // would be taken as a flag by magpie's CLI (`provider key --help <token>`
1805
+ // only prints help and writes no key).
672
1806
  if (typeof preset.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(preset.id)) {
673
1807
  lines.push(`✗ 这个 provider id 不是 magpie 接受的形状(${arg(preset.id)}),不能用 provider key 写入。`)
674
1808
  return { action: "refuse", lines, cmds: [] }
675
1809
  }
676
- if (!force) {
1810
+ // --force is the permission to *replace* a key chat may be using. A preset
1811
+ // with no key has nothing to replace: filling it is the whole point, and
1812
+ // asking for --force there was a refusal the README never promised.
1813
+ if (!force && preset.key) {
677
1814
  lines.push(`✗ 内置这把 key 是聊天请求正在用的,替换会让那边跟着换。确认的话加 --force 重跑。`)
678
1815
  return { action: "refuse", lines, cmds: [] }
679
1816
  }
@@ -685,10 +1822,11 @@ export function planToPreset({ preset, accounts, force }) {
685
1822
  return { action: "run", lines, cmds: [{ argv: ["provider", "key", preset.id, main.token], secret: main.token, also: preset.key }] }
686
1823
  }
687
1824
 
688
- // ---- 执行
1825
+ // ---- execution
689
1826
 
690
- // 多步序列(--force 的 logout→login)中途失败:logout 已经发生了,用户此刻
691
- // 是被登出的状态。超时(r.error)与退出码非零(r.status)都要说这句话。
1827
+ // A multi-step sequence (--force's logout→login) failing midway: the logout
1828
+ // has already happened, so the user is signed out at this moment. Both a
1829
+ // timeout (r.error) and a nonzero exit code (r.status) must say this.
692
1830
  const RECOVER = `注意:前面的步骤已经执行,现有登录已被登出。重新运行同一条命令即可继续(此时插件没有账号,会直接登录,无需 --force)。`
693
1831
 
694
1832
  function hidden(argv, secret, log) {
@@ -707,25 +1845,46 @@ function hidden(argv, secret, log) {
707
1845
  // the text that is a contiguous substring of the secret, at least MIN_ECHO
708
1846
  // characters long — below that a coincidence is likelier than an echo. It is
709
1847
  // the same rule index.mjs's cardText carries, and the same reason.
710
- const MIN_ECHO = 12
1848
+ // the same floor as index.mjs's card mask: a piece of the secret this long is
1849
+ // an echo, and the scan starts one character past a shorter run
1850
+ const MIN_ECHO = 10
1851
+ // An echo is compared folded: NFKC (full-width look-alikes are the same
1852
+ // credential) and lower case (upper-casing a log does not make it another
1853
+ // key), letters and digits only. Comparing byte for byte let either change
1854
+ // print the whole secret.
1855
+ const fold = (s) => (typeof s === "string" ? s.normalize("NFKC").toLowerCase().replace(/[^a-z0-9]/g, "") : "")
711
1856
  function maskEchoes(s, secret) {
712
- if (typeof secret !== "string" || secret.length < MIN_ECHO) return s
713
- // a control or format character inside the echo is stepped over, for the
714
- // reason show()'s shape steps over one — a line break too: this is a piece
715
- // of the real secret, and one broken across two lines is still the secret
1857
+ if (typeof secret !== "string") return s
1858
+ // The secret's own alphanumeric run is what the scan compares against.
1859
+ // Collecting "the characters the secret is made of" instead let a character
1860
+ // the secret genuinely holds be used as a cutter: a hyphen every 11
1861
+ // characters (every ms- key carries hyphens) split the echo into runs each
1862
+ // under MIN_ECHO. Punctuation is a separator whether the secret holds it or
1863
+ // not; only its letters and digits say "this is the secret". Anything else —
1864
+ // a control character, a space, a comma, a piece of punctuation a child
1865
+ // wrapped the echo in — is stepped over and the pieces on either side joined
1866
+ // for the comparison. It is the same rule index.mjs's cardText carries (a
1867
+ // whitelist of separators cannot cover an open set), and here it is what
1868
+ // keeps a secret broken across a line, or printed under a prefix, whole.
716
1869
  //
717
- // The matching runs over the visible characters alone, in one pass, and the
718
- // spans are put back afterwards. Stepping over a character as the scan went
719
- // — and restarting one position later when the run came up short — cost a
720
- // run of them its own length at every position it held: a child's output of
721
- // 20 000 control characters took 79 seconds.
1870
+ // The matching runs over those characters alone, in one pass, and the spans
1871
+ // are put back afterwards. Stepping over a character as the scan went — and
1872
+ // restarting one position later when the run came up short — cost a run of
1873
+ // them its own length at every position it held: a child's output of 20 000
1874
+ // control characters took 79 seconds.
1875
+ const flat = fold(secret)
1876
+ if (flat.length < MIN_ECHO) return s
722
1877
  const vis = []
723
1878
  const at = []
1879
+ const end = []
724
1880
  for (let i = 0; i < s.length; ) {
725
1881
  const c = String.fromCodePoint(s.codePointAt(i))
726
- if (!invisible(c)) {
727
- vis.push(c)
728
- at.push(i)
1882
+ for (const f of c.normalize("NFKC").toLowerCase()) {
1883
+ if (f >= "a" && f <= "z" || f >= "0" && f <= "9") {
1884
+ vis.push(f)
1885
+ at.push(i)
1886
+ end.push(i + c.length)
1887
+ }
729
1888
  }
730
1889
  i += c.length
731
1890
  }
@@ -734,17 +1893,51 @@ function maskEchoes(s, secret) {
734
1893
  for (let k = 0; k < vis.length; ) {
735
1894
  let piece = ""
736
1895
  let v = k
737
- while (v < vis.length && secret.includes(piece + vis[v])) {
738
- piece += vis[v]
739
- v++
1896
+ let fillers = 0
1897
+ while (v < vis.length) {
1898
+ if (flat.includes(piece + vis[v])) {
1899
+ piece += vis[v]
1900
+ v++
1901
+ continue
1902
+ }
1903
+ // characters the secret does not hold here, between two pieces of it: a
1904
+ // child that prints "01234567x 89abcdefx …" writes pieces under MIN_ECHO
1905
+ // with one letter between them, and the pieces together are the key. Up
1906
+ // to as many as the run has already matched are stepped over — an echo
1907
+ // is mostly the secret — and the run resumes at the first that continues
1908
+ // it. Only inside a run: skipping a leading character before anything
1909
+ // matched swallows the word in front of the key. The same rule, and the
1910
+ // same reason, as index.mjs's card mask.
1911
+ if (piece.length === 0) break
1912
+ // one character is stepped over however little has matched; more than
1913
+ // one only when the run is already long enough to be an echo on its own
1914
+ // (MIN_ECHO, the same floor the mask applies). Without that second half,
1915
+ // "said: aaaa1111-b" matched the last s of "said" and then skipped "id"
1916
+ // to join the piece, and a 9-character slice that the floor is meant to
1917
+ // leave alone was masked as if it were longer. Here the source text
1918
+ // after the cut is "01234 56789…", and "234 56789" are not a piece to
1919
+ // bridge.
1920
+ if (piece.length < MIN_ECHO) break
1921
+ const stop = Math.min(vis.length, v + 1 + (piece.length - fillers))
1922
+ let hit = -1
1923
+ for (let j = v + 1; j < stop; j += 1) if (flat.includes(piece + vis[j])) { hit = j; break }
1924
+ if (hit < 0) break
1925
+ fillers += hit - v
1926
+ v = hit
740
1927
  }
1928
+ // the floor counts the secret's own characters, not the punctuation the
1929
+ // echo wrote them in — the same rule, and the same reason, as index.mjs's
1930
+ // card mask
741
1931
  if (piece.length >= MIN_ECHO) {
1932
+ // what sat between the last character written and the run's first one is
1933
+ // written out first, so masking does not swallow the words around it
1934
+ out += s.slice(from, at[k])
742
1935
  out += mask(secret)
743
- from = at[v - 1] + vis[v - 1].length
1936
+ from = end[v - 1]
744
1937
  k = v
745
1938
  } else {
746
- out += s.slice(from, at[k] + vis[k].length)
747
- from = at[k] + vis[k].length
1939
+ out += s.slice(from, end[k])
1940
+ from = end[k]
748
1941
  k++
749
1942
  }
750
1943
  }
@@ -766,15 +1959,33 @@ function maskEchoes(s, secret) {
766
1959
  // escape sequence in the child's output cannot reach the terminal either.
767
1960
  // scrub() alone, with only the known secrets, was the leak the failing path
768
1961
  // had.
769
- function scrub(out, ...secrets) {
1962
+ export function scrub(out, ...secrets) {
1963
+ // the known secrets first: maskEchoes finds a piece of one whatever cut it
1964
+ // into and prints it as that key's own mask ("ms-a…5555"), which says more
1965
+ // than the shape pass can. Run the other way round, a key echo cut by
1966
+ // spaces was already masked as an anonymous run ("aaaa…5555") and the
1967
+ // better reading never saw it.
770
1968
  let s = out
771
1969
  for (const secret of secrets) {
772
1970
  if (secret) s = maskEchoes(s.split(secret).join(mask(secret)), secret)
773
1971
  }
774
- // arg() per line, not over the whole text: its control-character pass turns
775
- // "\n" into a space too, and both callers split on the line breaks to echo
776
- // (or quote) the child's answer. A token never spans a line, so the shape
777
- // pass loses nothing by running per line either.
1972
+ if (s.length <= 32 * 1024) s = maskCut(s)
1973
+ // arg() over the whole text first: its run reading covers a key cut by a
1974
+ // line break or by spaces across the child's lines (a wrapped page, a
1975
+ // pretty-printed error), which a line at a time cannot see. Then per line,
1976
+ // as before: the control-character pass turns "\n" into a space, and both
1977
+ // callers split on the line breaks to echo (or quote) the child's answer.
1978
+ // a key the child wrapped is one key across the line break: the join is
1979
+ // read over the whole text here, before the lines are split. Measured: a UUID
1980
+ // cut at a line break went out whole, because each line held a piece under
1981
+ // every floor.
1982
+ for (const join of [spacedKey, splitKey]) {
1983
+ const cut = join(s)
1984
+ if (cut && msKey(cut.body, false)) {
1985
+ s = cut.pre + mask(cut.body.replace(/\s+/g, "")) + cut.post
1986
+ break
1987
+ }
1988
+ }
778
1989
  return s.replace(/\r\n?/g, "\n").split("\n").map(arg).join("\n")
779
1990
  }
780
1991
 
@@ -812,17 +2023,19 @@ function run(cmds, bin, { dryRun, log, spawn = spawnSync, timeout = 120_000 }) {
812
2023
  throw e
813
2024
  }
814
2025
  if (r.status !== 0) {
815
- // a child killed by a signal has status null; "退出码 null" tells nobody anything
2026
+ // a child killed by a signal has status null; "exit code null" tells nobody anything
816
2027
  const why = r.status === null && r.signal ? `被信号 ${r.signal} 终止` : `退出码 ${r.status}`
817
2028
  const said = scrub(out, secret, ...secrets).split("\n").filter(Boolean).slice(-3).join("\n")
818
2029
  const e = new Error(`magpie 命令失败(${why})${said ? `:\n${said}` : ""}`)
819
- // 多步序列(--force 的 logout→login)中途失败:logout 已经发生了,
820
- // 用户此刻是被登出的状态。说清楚,并告诉他重跑即可续上——
821
- // 此时插件已无账号,重跑走的是全新登录那条路,不再需要 --force。
2030
+ // A multi-step sequence (--force's logout→login) failing midway: the
2031
+ // logout has already happened, so the user is signed out at this moment.
2032
+ // Say so, and tell them a rerun simply continues — with no account left
2033
+ // in the plugin, the rerun takes the fresh-login path and needs no --force.
822
2034
  if (i > 0) e.recover = RECOVER
823
2035
  throw e
824
2036
  }
825
- // magpie 自己就把令牌显示成掩码;将来若变,这里兜底再滤一遍
2037
+ // magpie itself already displays the token masked; should that ever change,
2038
+ // this filters it again as a backstop
826
2039
  for (const line of scrub(out, secret, ...secrets).split("\n")) if (line.trim()) log(` ${line}`)
827
2040
  }
828
2041
  }
@@ -844,6 +2057,148 @@ function unreadableAfter(name, dir, want, log, where = "插件列表") {
844
2057
  const runnable = (bin, os, exists) =>
845
2058
  exists(bin) || (os === "win32" && !/\.[^\\/.]+$/.test(bin) && [".com", ".exe"].some((x) => exists(bin + x)))
846
2059
 
2060
+ // wrapperTarget follows a launcher script to the binary it finally runs.
2061
+ // appdir.Resolve judges the executable as the process really is — what
2062
+ // os.Executable() answers after an exec chain is the final binary, not the
2063
+ // shim — so the folder magpie keeps its config in is the exec target's, and a
2064
+ // shim with a data/ folder beside it must not win the --dir guard: the guard
2065
+ // passed while the write landed in the wrapped binary's own config, and the
2066
+ // read-back then blamed "magpie 是否在正常运行". "" means "not a launcher, or
2067
+ // a launcher whose target we cannot name"; a caller that needs to tell the two
2068
+ // apart asks isWrapper as well. A chain is followed hop by hop while each
2069
+ // target is itself a script, bounded and cycle-checked: eight is well past any
2070
+ // launcher chain a real install builds. The bound is what the result depends
2071
+ // on — a cycle also ends on it, so removing the cycle check changes nothing a
2072
+ // caller can see (pinned by the chain test; the check is here to keep a
2073
+ // longer bound from walking one file forever). Two shapes are deliberately
2074
+ // left unresolved rather than guessed: a target preceded by a flag
2075
+ // (`exec -a name /path`), and a target that is not a literal path
2076
+ // (`exec "$(command -v magpie)"`). A line that only looks like an exec — a
2077
+ // usage string, a heredoc body — is followed as if it were one; the guard
2078
+ // still refuses a mismatch and the read-back still says a wrapper was
2079
+ // involved, so the mistake lands on the side of asking.
2080
+ const wrapperTarget = (bin) => {
2081
+ let path = bin
2082
+ const seen = new Set()
2083
+ for (let hop = 0; hop < 8; hop++) {
2084
+ if (seen.has(path)) return ""
2085
+ seen.add(path)
2086
+ let fd
2087
+ try {
2088
+ fd = openSync(path, "r")
2089
+ const head = Buffer.alloc(4096)
2090
+ const n = readSync(fd, head, 0, head.length, 0)
2091
+ const text = head.subarray(0, n).toString("utf8")
2092
+ if (!text.startsWith("#!")) return hop === 0 ? "" : path
2093
+ // an `exec` line a launcher writes: "exec /path/magpie \"$@\"", with an
2094
+ // optional env assignment in front and an optional quote round the path.
2095
+ // The first one whose target exists is taken; a target that is not a
2096
+ // plain path ("$(command -v magpie)") never exists as one and is passed
2097
+ // over, which is what leaves a dynamic launcher unresolved.
2098
+ // A shebang that names another program (#!/usr/bin/env node, #!/usr/bin/python3)
2099
+ // is not a shell launcher: the file is what runs, and its body is that
2100
+ // program's source — a source with a shift ("1 << 16") is not a
2101
+ // here-document, and reading it as one refused a launcher whose target is
2102
+ // a node entry point.
2103
+ if (!/^#![^\n]*\b(?:sh|bash|zsh|dash|ksh|ash)\b/.test(text)) return hop === 0 ? "" : path
2104
+ // A here-document is text, not the script: its body is skipped and the
2105
+ // exec line after it is the target (a script that prints usage before it
2106
+ // starts is an ordinary shape). The word "exec" inside a body only matters
2107
+ // when the body is the only place one appears — a script whose sole exec
2108
+ // is inside a usage block starts something the text does not name, and
2109
+ // that is unresolved rather than guessed.
2110
+ const lines = text.split("\n")
2111
+ const live = []
2112
+ let heredocExec = false
2113
+ for (let li = 0; li < lines.length; li += 1) {
2114
+ live.push(lines[li])
2115
+ const open = /<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/.exec(lines[li])
2116
+ if (!open) continue
2117
+ const word = open[1] ?? open[2] ?? open[3]
2118
+ const stripTabs = /^<<-/.test(open[0])
2119
+ for (li += 1; li < lines.length; li += 1) {
2120
+ const body = stripTabs ? lines[li].replace(/^\t+/, "") : lines[li]
2121
+ if (body === word) break
2122
+ // the word may sit anywhere in the body ("usage: exec /usr/bin/x")
2123
+ if (/\bexec\s+\S/.test(lines[li])) heredocExec = true
2124
+ }
2125
+ }
2126
+
2127
+ let next = ""
2128
+ let sawExec = false
2129
+ let unsettled = false
2130
+ const targets = new Set()
2131
+ for (const line of live) {
2132
+ const m = /^\s*exec\s+(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(line)
2133
+ if (!m) continue
2134
+ sawExec = true
2135
+ const target = m[1] ?? m[2] ?? m[3] ?? ""
2136
+ if (!target || target.startsWith("-")) continue
2137
+ // a relative target is resolved against the shell's working folder,
2138
+ // not the script's: what lies beside the script is a guess, and this
2139
+ // program has no business writing a credential into a guess. It also
2140
+ // spoils the whole launcher: a script with one absolute exec and one
2141
+ // relative is two branches, and the branch that runs is not in the
2142
+ // text — dropping the one we could not read and calling the rest
2143
+ // settled was the same mistake as following the wrong branch.
2144
+ if (!isAbsolute(target)) {
2145
+ unsettled = true
2146
+ continue
2147
+ }
2148
+ targets.add(target)
2149
+ if (existsSync(target)) next = target
2150
+ }
2151
+ if (!sawExec && heredocExec) return ""
2152
+ if (unsettled) return ""
2153
+ // Two different exec lines are two possible targets — an if/else, a
2154
+ // fallback chain — and which one runs is not something a text read can
2155
+ // settle. Unresolved beats following the wrong branch.
2156
+ if (targets.size > 1) return ""
2157
+ // no exec line at all: this file is the bottom of the chain, the binary
2158
+ // the chain finally runs. One that execs a target which exists nowhere
2159
+ // claims to run something we cannot find — that launcher stays
2160
+ // unresolved, and a write is refused rather than guessed.
2161
+ if (!next) return sawExec ? "" : path
2162
+ path = next
2163
+ } catch {
2164
+ return ""
2165
+ } finally {
2166
+ if (fd !== undefined) {
2167
+ try {
2168
+ closeSync(fd)
2169
+ } catch {}
2170
+ }
2171
+ }
2172
+ }
2173
+ return ""
2174
+ }
2175
+
2176
+ // isWrapper is a path that is really a launcher — a file that begins with "#!".
2177
+ // Two bytes are enough, and a binary that cannot be read is not a wrapper.
2178
+ const isWrapper = (bin) => {
2179
+ let fd
2180
+ try {
2181
+ fd = openSync(bin, "r")
2182
+ const head = Buffer.alloc(2)
2183
+ return readSync(fd, head, 0, 2, 0) === 2 && head[0] === 0x23 && head[1] === 0x21
2184
+ } catch {
2185
+ return false
2186
+ } finally {
2187
+ if (fd !== undefined) {
2188
+ try {
2189
+ closeSync(fd)
2190
+ } catch {}
2191
+ }
2192
+ }
2193
+ }
2194
+
2195
+ // wrapperNote is the read-back suffix for a launcher we could not follow: the
2196
+ // guard asked about the script's own folder, so a write that went to the
2197
+ // wrapped binary's folder instead shows up exactly here, as a token that is
2198
+ // missing.
2199
+ const wrapperNote = (bin) =>
2200
+ isWrapper(bin) && !wrapperTarget(bin) ? ";--magpie 是包装脚本,它最终启动的 magpie 定位不到,写入可能落在别的目录里" : ""
2201
+
847
2202
  // showPath is a file inside the folder the user typed. The folder goes through
848
2203
  // arg() — a token pasted into --dir must not ride along inside the joined path
849
2204
  // — and the file's own name is ours, printed as is.
@@ -870,6 +2225,10 @@ export async function main({
870
2225
  log(USAGE)
871
2226
  return 0
872
2227
  }
2228
+ if (args.version) {
2229
+ log(`magpie-plugin-msmoli ${VERSION}`)
2230
+ return 0
2231
+ }
873
2232
 
874
2233
  // No PATH fallback: `magpie` resolved through PATH can be a file the user
875
2234
  // never installed — npx puts the project's node_modules/.bin first, so a
@@ -889,7 +2248,9 @@ export async function main({
889
2248
  return 1
890
2249
  }
891
2250
  const bin = args.magpie || findMagpieBin({ env, os, exists, home })
892
- const dir = args.dir || findConfigDir({ env, home, os, exists, binPath: bin })
2251
+ // The folder is the one the binary that actually runs keeps its config in:
2252
+ // for a launcher script that is the exec target, not the script's own folder.
2253
+ const dir = args.dir || findConfigDir({ env, home, os, exists, binPath: wrapperTarget(bin) || bin })
893
2254
  if (!dir) {
894
2255
  log(`✗ 没找到 magpie 的配置目录。`)
895
2256
  log(` 试过 $XDG_CONFIG_HOME/magpie 和 ~/.config/magpie。便携版请用 --dir 指定它的 data 目录。`)
@@ -933,9 +2294,12 @@ export async function main({
933
2294
  return 1
934
2295
  }
935
2296
  const accounts = pluginAccounts(authRaw)
936
- const cands = presetCandidates(providers, args.from)
2297
+ // --to-preset writes into the provider, so one that is there but has no key
2298
+ // is a destination, not "unconfigured"; --to-plugin needs a key to read.
2299
+ const cands = presetCandidates(providers, args.from, args.to !== "preset")
937
2300
 
938
- log(` 内置 ModelScope:${cands.length === 1 ? `${arg(cands[0].id)}(${mask(cands[0].key)})` : cands.length ? "多个候选" : "未配置"}`)
2301
+ const shownPreset = (p) => `${arg(p.id)}(${keyedOf(p) ? mask(p.key) : "还没有 key"})`
2302
+ log(` 内置 ModelScope:${cands.length === 1 ? shownPreset(cands[0]) : cands.length ? "多个候选" : "未配置"}`)
939
2303
  log(` 签到插件 msmoli:${accounts.length ? accounts.map((a) => `${arg(a.name)}(${mask(a.token)})`).join("、") : "未登录"}`)
940
2304
 
941
2305
  if (cands.length > 1) {
@@ -951,8 +2315,9 @@ export async function main({
951
2315
  return 1
952
2316
  }
953
2317
  const preset = cands[0] ?? null
954
- // 无预设时两条方向都该给指引:--to-preset 也有自己的话术(planToPreset 里),
955
- // 但这里先统一拦住,免得 null 一路传到 plan 里崩栈
2318
+ // With no preset, both directions deserve guidance: --to-preset has its own
2319
+ // wording (in planToPreset), but it is stopped here first so a null does not
2320
+ // travel all the way into plan and blow the stack
956
2321
  if (!preset) {
957
2322
  const all = Array.isArray(providers) ? providers : []
958
2323
  // A candidate needs a key, and a provider that is ours but has none is not
@@ -989,15 +2354,24 @@ export async function main({
989
2354
 
990
2355
  const plan = args.to === "preset" ? planToPreset({ preset, accounts, force: args.force }) : planToPlugin({ preset, accounts, force: args.force })
991
2356
  for (const line of plan.lines) log(line)
992
- if (plan.action !== "run") return plan.action === "noop" ? 0 : 1
2357
+ // --status promises "report only", and the annotation is what says so. It has
2358
+ // to cover the noop and refuse answers too: they are conclusions, not plans,
2359
+ // and a reader scripting the output could not otherwise tell a report from a
2360
+ // run that had happened. The exit code still carries the verdict.
2361
+ if (plan.action !== "run") {
2362
+ if (args.status) log(`(--status:以上是计划,未执行)`)
2363
+ return plan.action === "noop" ? 0 : 1
2364
+ }
993
2365
 
994
- // --status 是「只报告」,先于方向判断:只给 --status 时该说「这是计划」,
995
- // 而不是提示用户去指定方向——他要的就是报告
2366
+ // --status means "report only", and it comes before the direction check: with
2367
+ // only --status it should say "this is the plan", not tell the user to pick
2368
+ // a direction — a report is exactly what they asked for
996
2369
  if (args.status) {
997
2370
  log(`(--status:以上是计划,未执行)`)
998
2371
  return 0
999
2372
  }
1000
- // 不带 --to-* 时只报计划:裸跑 npx 不该静默写入一个凭据
2373
+ // Without --to-* it only reports the plan: a bare npx run must not silently
2374
+ // write a credential
1001
2375
  if (!args.to) {
1002
2376
  log(`(要执行请显式指定方向:--to-plugin 或 --to-preset)`)
1003
2377
  if (args.dryRun) log(`(--dry-run:未执行)`)
@@ -1021,8 +2395,26 @@ export async function main({
1021
2395
  // the magpie.exe beside it, and a check gated on exists(bin) alone skipped
1022
2396
  // itself there — a fixture's --dir was read while the real magpie wrote its
1023
2397
  // own folder.
2398
+ // The refusal comes first, outside the --dir guard: a run that lets the
2399
+ // folder be inferred writes just the same, and an unnameable launcher may
2400
+ // write anywhere — the folder it was inferred from is the script's, and the
2401
+ // script is not what runs.
2402
+ if (!args.dryRun && bin && runnable(bin, os, exists) && isWrapper(bin) && !wrapperTarget(bin)) {
2403
+ log(`✗ --magpie 是包装脚本(${arg(bin)}),但看不出它最终启动哪个 magpie,写入会落在它自己的目录里——那可能不是这里读的目录。`)
2404
+ log(` 读的是:${arg(dir)}`)
2405
+ log(` 请把 --magpie 指到真正的 magpie 二进制(不是启动脚本),或去掉 --magpie 让它自动探测。`)
2406
+ return 1
2407
+ }
1024
2408
  if (!args.dryRun && args.dir && bin && runnable(bin, os, exists)) {
1025
- const binDir = findConfigDir({ env, home, os, exists, binPath: bin })
2409
+ // A launcher script writes wherever the binary it execs keeps its config,
2410
+ // and that folder is not the one this path implies: a shim beside a data/
2411
+ // folder passed this guard while the write landed in the wrapped binary's
2412
+ // own config, and the read-back then blamed "magpie 是否在正常运行". The
2413
+ // exec target is read out of the script when it names one; a script that
2414
+ // names none has no folder of its own either way, and the guard still asks
2415
+ // about the path as before.
2416
+ const wrapped = wrapperTarget(bin)
2417
+ const binDir = findConfigDir({ env, home, os, exists, binPath: wrapped || bin })
1026
2418
  // "" is a mismatch too: the binary has no magpie folder of ours to write
1027
2419
  // to, so it would make one — and that one is not the folder named. Compared
1028
2420
  // by folder identity, not by string: a shell completes a directory with a
@@ -1047,9 +2439,11 @@ export async function main({
1047
2439
  return 0
1048
2440
  }
1049
2441
 
1050
- // 回读验证,别只信退出码(方向判断与上面 plan 那行保持一致)。
1051
- // 回读读不动(null)是另一回事:把它报成「没找到令牌」会把人推去查
1052
- // magpie 是否在运行,而写入其实成功了——两种失败要分开说。
2442
+ // Verify by reading back, do not trust only the exit code (the direction
2443
+ // check matches the plan line above). An unreadable read-back (null) is a
2444
+ // different matter: reporting it as "the token was not found" sends people
2445
+ // to check whether magpie is running, when the write actually succeeded —
2446
+ // the two failures must be told apart.
1053
2447
  const want = plan.cmds.at(-1).secret
1054
2448
  if (want) {
1055
2449
  if (args.to === "preset") {
@@ -1057,23 +2451,25 @@ export async function main({
1057
2451
  if (raw === null || !shaped(raw)) return unreadableAfter("providers.json", dir, want, log, "供应商列表")
1058
2452
  const after = presetCandidates(raw?.providers, preset.id)
1059
2453
  if (!after.some((p) => p.key === want)) {
1060
- log(`✗ 写入后回读没找到目标 key(${mask(want)})——检查 magpie 是否在正常运行。`)
2454
+ log(`✗ 写入后回读没找到目标 key(${mask(want)})——检查 magpie 是否在正常运行${wrapperNote(bin)}。`)
1061
2455
  return 1
1062
2456
  }
1063
2457
  } else {
1064
2458
  const raw = readJson(join(dir, "plugin-auth.json"), exists)
1065
2459
  if (raw === null || !shaped(raw)) return unreadableAfter("plugin-auth.json", dir, want, log)
1066
2460
  const after = pluginAccounts(raw)
1067
- // 回读要数账号:logout 没真删(退出码 0 却没动手)时,login 会把新
1068
- // 令牌加成第二个账号,而「有一个账号拿着目标令牌」照样成立——那正是
1069
- // planToPlugin 明说 --force 不适用、工具却亲手造出来的状态。
2461
+ // The read-back must count accounts: when logout did not really delete
2462
+ // (exit code 0 but no action), login adds the new token as a second
2463
+ // account, and "one account holds the target token" still holds — exactly
2464
+ // the state planToPlugin says --force does not apply to and the tool
2465
+ // would have created itself.
1070
2466
  if (plan.cmds.length > 1 && after.length > 1) {
1071
2467
  log(`✗ 回读发现插件现在有 ${after.length} 个账号(${after.map((a) => arg(a.name)).join("、")})。`)
1072
2468
  log(` 旧账号没被登出,login 追加了一个。请在 magpie 里删掉多余账号后重试。`)
1073
2469
  return 1
1074
2470
  }
1075
2471
  if (!after.some((a) => a.token === want)) {
1076
- log(`✗ 写入后回读没找到目标令牌(${mask(want)})——检查 magpie 是否在正常运行。`)
2472
+ log(`✗ 写入后回读没找到目标令牌(${mask(want)})——检查 magpie 是否正在正常运行${wrapperNote(bin)}。`)
1077
2473
  return 1
1078
2474
  }
1079
2475
  }
@@ -1082,22 +2478,28 @@ export async function main({
1082
2478
  return 0
1083
2479
  }
1084
2480
 
1085
- // npm 和 npx 通过符号链接调用 bin:argv[1] 是链接的路径,而 import.meta.url
1086
- // 是解析后的真实路径,直接比较字符串会漏掉「作为命令运行」的场景(测试里用
1087
- // node bin/sync.mjs 直跑恰好相等,掩盖了这个 bug)。先把 argv[1] 也 realpath
1088
- // 成真实路径再比。
2481
+ // npm and npx invoke bin through a symlink: argv[1] is the link's path while
2482
+ // import.meta.url is the resolved real path, so comparing the strings directly
2483
+ // misses the "run as a command" case (running `node bin/sync.mjs` directly in
2484
+ // the tests happened to be equal and hid this bug). So realpath argv[1] too
2485
+ // before comparing.
1089
2486
  //
1090
- // href 还不够的地方有两处,都不涉及大小写:node --preserve-symlinks-main 让
1091
- // import.meta.url 保留链接路径,而 realpathSync 仍把它解开;Windows 的 8.3 短
1092
- // 名(C:\Users\RUNNER~1\…)在 pathToFileURL 里不展开、realpathSync 里展开。
1093
- // 这两种都靠 inode 兜——那是文件的身份,与拼写无关。
2487
+ // href falls short in two places, neither about letter case:
2488
+ // node --preserve-symlinks-main makes import.meta.url keep the link path while
2489
+ // realpathSync still resolves it; Windows 8.3 short names
2490
+ // (C:\Users\RUNNER~1\…) are not expanded by pathToFileURL but are by
2491
+ // realpathSync. Both fall back to the inode — the file's identity, independent
2492
+ // of spelling.
1094
2493
  //
1095
- // 报 ino=0 的卷(exFAT/FAT 的 U 盘、部分 SMB 共享与 subst 映射)没有身份可比,
1096
- // 这时退回比 realpath 之后的字符串:它能覆盖上面两种 href 失配,唯一丢掉的
1097
- // 是大小写翻转,而那个场景 href 本来就命中。不这样做的话 isMain 恒 false,
1098
- // 整个文件被跳过——不打印任何东西、退出码 0,用户会以为同步成功了。
2494
+ // A volume that reports ino=0 (exFAT/FAT USB sticks, some SMB shares and subst
2495
+ // mappings) has no identity to compare, so it falls back to comparing the
2496
+ // realpath-ed strings: that covers the two href mismatches above, and the only
2497
+ // thing lost is a letter-case flip, where href matched anyway. Without this,
2498
+ // isMain would always be false and the whole file would be skipped — printing
2499
+ // nothing and exiting 0, so the user would think the sync succeeded.
1099
2500
  //
1100
- // `stat` 与 `real` 可注入:ino=0 的卷在本机造不出来,测试只能把手伸到这里。
2501
+ // `stat` and `real` are injectable: an ino=0 volume cannot be conjured up on
2502
+ // this machine, so the tests can only reach in here.
1101
2503
  export function isMain(entry = process.argv[1], self = import.meta.url, stat = statSync, real = realpathSync) {
1102
2504
  if (!entry) return false
1103
2505
  try {
@@ -1113,6 +2515,25 @@ export function isMain(entry = process.argv[1], self = import.meta.url, stat = s
1113
2515
  }
1114
2516
 
1115
2517
  if (isMain()) {
2518
+ // `sync --status | head -1` closes the pipe after the first line: without a
2519
+ // handler the EPIPE arrives as an unhandled error event and the crash trace
2520
+ // — not the plan — is what the user sees. Exiting quietly is what a shell
2521
+ // pipeline means; head's own exit code carries the verdict.
2522
+ process.stdout.on("error", (e) => {
2523
+ if (e && e.code === "EPIPE") process.exit(0)
2524
+ throw e
2525
+ })
2526
+ // A Ctrl-C or a TERM during a --force sequence can land between the logout
2527
+ // and the login: the logout has run and the account is signed out. The
2528
+ // child is left to finish on its own (spawnSync cannot be interrupted
2529
+ // mid-wait), but the run says so before it goes, so the shell is not left
2530
+ // showing a silent 130/143 and a signed-out account.
2531
+ for (const [sig, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
2532
+ process.on(sig, () => {
2533
+ process.stderr.write(`\nmagpie-plugin-msmoli: 被 ${sig} 中断。如果正在 --force 的登出→登录之间,现有登录可能已被登出;\n${RECOVER}\n`)
2534
+ process.exit(code)
2535
+ })
2536
+ }
1116
2537
  // process.exit() while stdout is a pipe drops whatever is still buffered:
1117
2538
  // a long plan or a big `--status` list would lose its tail — including the
1118
2539
  // ✓ line — and still exit 0. Setting the exit code lets the loop drain the