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.
- package/README.md +550 -372
- package/bin/sync.mjs +1569 -148
- package/index.mjs +2456 -272
- package/package.json +3 -4
- 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 —
|
|
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
|
-
//
|
|
5
|
-
//
|
|
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
|
-
// -
|
|
10
|
-
//
|
|
11
|
-
// .bak
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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(`缺少值:${
|
|
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 &&
|
|
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 (
|
|
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
|
-
// ----
|
|
96
|
-
// .portable
|
|
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
|
-
// ----
|
|
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
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
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
|
-
//
|
|
327
|
-
// "api-inference.modelscope.cn.evil.tld"
|
|
328
|
-
//
|
|
329
|
-
// modelscope.cn
|
|
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
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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) =>
|
|
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
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
511
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
//
|
|
1614
|
+
// The plugin-side accounts: the primary key msmoli plus msmoli#xxxxxx, told
|
|
1615
|
+
// apart by token.
|
|
558
1616
|
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
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]) => ({
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
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
|
-
//
|
|
600
|
-
// and --force would not help: the file is the
|
|
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
|
|
625
|
-
//
|
|
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
|
|
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 "
|
|
640
|
-
// plugin is signed out" while --to-plugin answers "
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
//
|
|
671
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
691
|
-
//
|
|
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
|
-
|
|
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"
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
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
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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 =
|
|
1936
|
+
from = end[v - 1]
|
|
744
1937
|
k = v
|
|
745
1938
|
} else {
|
|
746
|
-
out += s.slice(from,
|
|
747
|
-
from =
|
|
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
|
-
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
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; "
|
|
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
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
955
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
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
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
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
|
|
1086
|
-
//
|
|
1087
|
-
// node bin/sync.mjs
|
|
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
|
|
1091
|
-
// import.meta.url
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
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
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
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`
|
|
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
|