@rikaidev/yomi 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +14 -0
- package/dist/auth/credential-store.js +11 -1
- package/dist/mcp/client-capabilities.js +55 -0
- package/dist/mcp/handlers/login-copy.js +200 -0
- package/dist/mcp/handlers/login-mrtr.js +150 -0
- package/dist/mcp/handlers/login-session.js +132 -0
- package/dist/mcp/handlers/login.js +66 -213
- package/dist/mcp/server.js +22 -3
- package/dist/mcp/tools.js +3 -2
- package/dist/util/update-check.js +121 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/PRIVACY.md
CHANGED
|
@@ -19,6 +19,20 @@ The only data that leaves your device is whatever the agent surfaces in its
|
|
|
19
19
|
replies to you — and in a cloud assistant, that surfaced text is processed by
|
|
20
20
|
that assistant's model. Yomi itself makes no outbound copy of your messages.
|
|
21
21
|
|
|
22
|
+
## The one non-LINE request Yomi makes
|
|
23
|
+
|
|
24
|
+
At startup Yomi asks the public npm registry whether a newer version has been
|
|
25
|
+
published, and tells the agent if so. This exists because a Desktop Extension
|
|
26
|
+
(`.mcpb`) installed by hand never auto-updates — without the check you could
|
|
27
|
+
sit on an old build, security fixes included, and never know.
|
|
28
|
+
|
|
29
|
+
It sends **no message content and no account identifier**: it is a plain GET of
|
|
30
|
+
a public package document. What it does reveal to the registry is that your IP
|
|
31
|
+
address is running Yomi, and roughly which version. Nothing is uploaded.
|
|
32
|
+
|
|
33
|
+
Set **`YOMI_NO_UPDATE_CHECK=1`** to turn it off; Yomi then makes no outbound
|
|
34
|
+
request except to LINE itself.
|
|
35
|
+
|
|
22
36
|
## Excluding conversations
|
|
23
37
|
|
|
24
38
|
You can exclude any conversation at any time, in plain language to the agent
|
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* KeychainService service name: see keychain.ts — writes go to Yomi's own
|
|
17
17
|
* canonical namespace; a legacy namespace from before this split is read
|
|
18
18
|
* as a fallback and migrated forward, never written to or deleted.
|
|
19
|
+
*
|
|
20
|
+
* The Keychain is machine-global: it is NOT scoped by YOMI_DATA_DIR, so on
|
|
21
|
+
* macOS an automated run that only redirected the data dir would still find —
|
|
22
|
+
* and act on — the developer's real LINE session (a stored login certificate
|
|
23
|
+
* is enough for `login` to start a device sign-in against the live account).
|
|
24
|
+
* `YOMI_NO_KEYCHAIN=1` forces the file backend so tests and smoke runs are
|
|
25
|
+
* genuinely isolated by pointing YOMI_DATA_DIR somewhere disposable. It is a
|
|
26
|
+
* testing escape hatch: setting it in normal use downgrades credential
|
|
27
|
+
* storage from the Keychain to a plain JSON file.
|
|
19
28
|
*/
|
|
20
29
|
import { dirname, join } from 'node:path';
|
|
21
30
|
import { getKeychainService } from './keychain.js';
|
|
@@ -188,7 +197,8 @@ export class CredentialStore {
|
|
|
188
197
|
this.filePath = fallbackFilePath;
|
|
189
198
|
this.cache = new Map();
|
|
190
199
|
this.loaded = false;
|
|
191
|
-
this.secureStorageEnabled =
|
|
200
|
+
this.secureStorageEnabled =
|
|
201
|
+
process.platform === 'darwin' && process.env.YOMI_NO_KEYCHAIN !== '1';
|
|
192
202
|
this.keychainService = this.secureStorageEnabled
|
|
193
203
|
? getKeychainService()
|
|
194
204
|
: null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
|
|
2
|
+
/**
|
|
3
|
+
* The capabilities the CURRENT request's client declared.
|
|
4
|
+
*
|
|
5
|
+
* @param server - MCP Server instance (the legacy, handshake-scoped source).
|
|
6
|
+
* @param ctx - Request handler context (the modern, per-request source).
|
|
7
|
+
* @returns The declared capabilities, or undefined when the client declared
|
|
8
|
+
* none and no envelope carried any.
|
|
9
|
+
*/
|
|
10
|
+
export function clientCapabilitiesOf(server, ctx) {
|
|
11
|
+
// The envelope type is opaque (`RequestMetaEnvelope = {}`) — the SDK models
|
|
12
|
+
// it as a bag of reserved keys, so reaching the declared capabilities off it
|
|
13
|
+
// needs an index-signature view rather than a property access.
|
|
14
|
+
const envelope = ctx?.mcpReq.envelope;
|
|
15
|
+
return (envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.getClientCapabilities());
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Does the connected client accept form-mode elicitation (`elicitation/create`
|
|
19
|
+
* carrying a `requestedSchema`)?
|
|
20
|
+
*
|
|
21
|
+
* @param capabilities - Result of `server.getClientCapabilities()`.
|
|
22
|
+
* @returns True when the client declared `elicitation.form`, or declared a
|
|
23
|
+
* bare `elicitation` with no mode sub-keys at all (pre-2026-07-28 shape).
|
|
24
|
+
*/
|
|
25
|
+
export function supportsFormElicitation(capabilities) {
|
|
26
|
+
const elicitation = capabilities?.elicitation;
|
|
27
|
+
if (!elicitation || typeof elicitation !== 'object') {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if ('form' in elicitation && elicitation.form) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// A client that named `url` and not `form` is url-only; one that named
|
|
34
|
+
// neither predates the split and can only have meant form.
|
|
35
|
+
return !('url' in elicitation && elicitation.url);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Does the connected client accept url-mode elicitation (`elicitation/create`
|
|
39
|
+
* with `mode: "url"`, answered out-of-band and closed by a
|
|
40
|
+
* `notifications/elicitation/complete`)?
|
|
41
|
+
*
|
|
42
|
+
* Not used by any flow yet — url mode has no observed client support, the
|
|
43
|
+
* same trap ./ui/login-app.ts documents for MCP Apps. Exported so a future
|
|
44
|
+
* url-mode path can gate on the real declaration rather than assume it.
|
|
45
|
+
*
|
|
46
|
+
* @param capabilities - Result of `server.getClientCapabilities()`.
|
|
47
|
+
* @returns True only when the client explicitly declared `elicitation.url`.
|
|
48
|
+
*/
|
|
49
|
+
export function supportsUrlElicitation(capabilities) {
|
|
50
|
+
const elicitation = capabilities?.elicitation;
|
|
51
|
+
if (!elicitation || typeof elicitation !== 'object') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return 'url' in elicitation && Boolean(elicitation.url);
|
|
55
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
|
|
2
|
+
/** LINE region codes offered on the login form. */
|
|
3
|
+
export const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
|
|
4
|
+
/** Where the toggle lives on the primary phone, in LINE's own words. */
|
|
5
|
+
export const PRIMARY_DEVICE_SETTING_PATH = '設定 > 我的帳號 > 允許自其他裝置登入';
|
|
6
|
+
/**
|
|
7
|
+
* The prerequisite as a single line, for texts that report on a login
|
|
8
|
+
* already in flight (the PIN and cert steps) rather than gating one.
|
|
9
|
+
*/
|
|
10
|
+
const PRIMARY_DEVICE_SETTING_NOTE = `0. This only works if the primary phone has ${PRIMARY_DEVICE_SETTING_PATH} ` +
|
|
11
|
+
'(Settings > Account > allow logging in from other devices; the label is ' +
|
|
12
|
+
'localized) enabled — without it LINE will not offer this device a sign-in ' +
|
|
13
|
+
'prompt at all.\n';
|
|
14
|
+
/**
|
|
15
|
+
* Step-by-step instructions for turning the setting on, returned when the
|
|
16
|
+
* human says it is off or that they do not know where it is.
|
|
17
|
+
*/
|
|
18
|
+
const PRIMARY_DEVICE_SETTING_STEPS = 'On the PRIMARY phone (the one already logged in to this LINE account):\n\n' +
|
|
19
|
+
'1. Open LINE and go to the 主頁 / Home tab.\n' +
|
|
20
|
+
'2. Tap the gear icon in the top corner to open 設定 (Settings).\n' +
|
|
21
|
+
'3. Open 我的帳號 (Account).\n' +
|
|
22
|
+
'4. Turn ON 允許自其他裝置登入 (the toggle allowing login from other ' +
|
|
23
|
+
'devices — the exact wording is localized).\n\n' +
|
|
24
|
+
'This toggle is what lets LINE prompt a second device to sign in. With it ' +
|
|
25
|
+
'off, nothing at all appears on the phone and the login simply never ' +
|
|
26
|
+
'completes.';
|
|
27
|
+
/** Answers offered by the pre-flight prerequisite check. */
|
|
28
|
+
const PREREQ_ANSWERS = {
|
|
29
|
+
enabled: 'enabled',
|
|
30
|
+
notEnabled: 'not_enabled',
|
|
31
|
+
unsure: 'unsure',
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The prerequisite question, in the 2026-07-28 `oneOf`-with-`title`
|
|
35
|
+
* single-select shape (`TitledSingleSelectEnumSchema`) so each choice carries
|
|
36
|
+
* human-facing text of its own, rather than an enum of bare machine values
|
|
37
|
+
* the client has to render raw.
|
|
38
|
+
*/
|
|
39
|
+
const PREREQ_SCHEMA_PROPERTY = {
|
|
40
|
+
type: 'string',
|
|
41
|
+
title: 'Is 允許自其他裝置登入 enabled on your primary phone?',
|
|
42
|
+
description: `On the phone already logged in to LINE: ${PRIMARY_DEVICE_SETTING_PATH}. ` +
|
|
43
|
+
'Without it, LINE never prompts this device and the login cannot finish.',
|
|
44
|
+
oneOf: [
|
|
45
|
+
{ const: PREREQ_ANSWERS.enabled, title: 'Yes — it is already on' },
|
|
46
|
+
{ const: PREREQ_ANSWERS.notEnabled, title: 'No / not yet — show me how' },
|
|
47
|
+
{
|
|
48
|
+
const: PREREQ_ANSWERS.unsure,
|
|
49
|
+
title: "I don't know where that setting is",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Build the one form shown before a login starts: the prerequisite, plus
|
|
55
|
+
* `phone`/`region` when the caller did not supply them.
|
|
56
|
+
*
|
|
57
|
+
* Both live in ONE form on purpose — the human answers a single prompt, and
|
|
58
|
+
* the prerequisite is raised while it can still change the outcome.
|
|
59
|
+
*
|
|
60
|
+
* @param phone - Phone already supplied by the tool call, if any.
|
|
61
|
+
* @param region - Region already supplied by the tool call, if any.
|
|
62
|
+
* @returns The elicitation body, for `elicitInput` or `inputRequired.elicit`.
|
|
63
|
+
*/
|
|
64
|
+
export function buildPreflightForm(phone, region) {
|
|
65
|
+
const needsCredentials = !phone || !region;
|
|
66
|
+
const properties = {
|
|
67
|
+
primaryDeviceLogin: PREREQ_SCHEMA_PROPERTY,
|
|
68
|
+
};
|
|
69
|
+
const required = ['primaryDeviceLogin'];
|
|
70
|
+
if (needsCredentials) {
|
|
71
|
+
properties.phone = {
|
|
72
|
+
type: 'string',
|
|
73
|
+
title: 'Phone number',
|
|
74
|
+
description: 'E.164 form, e.g. +8869XXXXXXXX.',
|
|
75
|
+
...(phone ? { default: phone } : {}),
|
|
76
|
+
};
|
|
77
|
+
properties.region = {
|
|
78
|
+
type: 'string',
|
|
79
|
+
title: 'Region',
|
|
80
|
+
enum: LOGIN_REGIONS,
|
|
81
|
+
...(region ? { default: region } : {}),
|
|
82
|
+
};
|
|
83
|
+
required.push('phone', 'region');
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
message: 'Yomi signs in as a SECOND LINE device. That requires ' +
|
|
87
|
+
`${PRIMARY_DEVICE_SETTING_PATH} to be enabled on the phone already ` +
|
|
88
|
+
'logged in — confirm that below' +
|
|
89
|
+
(needsCredentials
|
|
90
|
+
? ', along with your number and region.'
|
|
91
|
+
: ' before the login starts.'),
|
|
92
|
+
requestedSchema: { type: 'object', properties, required },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read an accepted pre-flight form back. Shared by every path so the answer
|
|
97
|
+
* is interpreted identically no matter how it was collected.
|
|
98
|
+
*
|
|
99
|
+
* @param content - The accepted form content (untrusted client input).
|
|
100
|
+
* @param phone - Phone supplied by the tool call, used when the form did not
|
|
101
|
+
* carry the field because it was already known.
|
|
102
|
+
* @param region - Region, same rule.
|
|
103
|
+
* @returns What the login should do next.
|
|
104
|
+
*/
|
|
105
|
+
export function readPreflightForm(content, phone, region) {
|
|
106
|
+
const answer = String(content?.primaryDeviceLogin ?? '');
|
|
107
|
+
if (answer !== PREREQ_ANSWERS.enabled) {
|
|
108
|
+
return { ok: false, reason: 'prerequisite', answer };
|
|
109
|
+
}
|
|
110
|
+
const collectedPhone = String(content?.phone ?? phone ?? '');
|
|
111
|
+
const collectedRegion = String(content?.region ?? region ?? '');
|
|
112
|
+
if (!collectedPhone || !collectedRegion) {
|
|
113
|
+
return { ok: false, reason: 'cancelled' };
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, phone: collectedPhone, region: collectedRegion };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The numbered steps for acting on an issued PIN. Step 0 repeats the
|
|
119
|
+
* prerequisite even though the pre-flight form already asked about it: a
|
|
120
|
+
* human who answered "yes" without checking finds out here, while the code
|
|
121
|
+
* is still valid.
|
|
122
|
+
*
|
|
123
|
+
* @param pin - The PIN LINE just issued.
|
|
124
|
+
* @returns The steps, ending in a blank line.
|
|
125
|
+
*/
|
|
126
|
+
export function pinSteps(pin) {
|
|
127
|
+
const lifetimeSeconds = Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000);
|
|
128
|
+
return (`LINE PIN:\n\n ${pin}\n\n` +
|
|
129
|
+
PRIMARY_DEVICE_SETTING_NOTE +
|
|
130
|
+
'1. Open LINE on the primary phone. If a verification prompt does not ' +
|
|
131
|
+
'appear immediately, bringing the app to the foreground surfaces it.\n' +
|
|
132
|
+
`2. Enter this PIN and tick the device that is signing in, then tap ` +
|
|
133
|
+
`「用戶確認」 — LINE gives you ${lifetimeSeconds} seconds (about 3 ` +
|
|
134
|
+
'minutes) from when the code was shown to do this.\n' +
|
|
135
|
+
'3. LINE will then ask that same phone to approve this new device ' +
|
|
136
|
+
'signing in — approve it there.\n\n');
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The steps when a stored login certificate skipped the PIN entirely — the
|
|
140
|
+
* flow is already waiting on phone approval of this device.
|
|
141
|
+
*
|
|
142
|
+
* @returns The steps, ending in a blank line.
|
|
143
|
+
*/
|
|
144
|
+
export function certSteps() {
|
|
145
|
+
return ('No PIN needed — LINE recognized a stored login certificate from a ' +
|
|
146
|
+
'previous login, so the PIN step was skipped.\n\n' +
|
|
147
|
+
PRIMARY_DEVICE_SETTING_NOTE +
|
|
148
|
+
'1. Bring the LINE app to the foreground on your primary phone — it ' +
|
|
149
|
+
'should prompt you to approve this new device signing in.\n' +
|
|
150
|
+
'2. Approve the device from there.\n\n');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The acknowledgement form shown alongside an issued PIN (or alongside the
|
|
154
|
+
* cert-skipped approval prompt).
|
|
155
|
+
*
|
|
156
|
+
* @param pin - The PIN, or `null` when a stored certificate skipped it.
|
|
157
|
+
* @returns The elicitation body.
|
|
158
|
+
*/
|
|
159
|
+
export function buildAcknowledgementForm(pin) {
|
|
160
|
+
return {
|
|
161
|
+
message: (pin ? pinSteps(pin) : certSteps()) +
|
|
162
|
+
'Acknowledge below once you have done this.',
|
|
163
|
+
requestedSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
entered: {
|
|
167
|
+
type: 'boolean',
|
|
168
|
+
title: pin
|
|
169
|
+
? 'I entered the PIN and approved the device on my phone'
|
|
170
|
+
: 'I approved the device on my phone',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Build the tool result returned when the human has not confirmed the
|
|
178
|
+
* prerequisite. Deliberately NOT a tool error: nothing failed, the login was
|
|
179
|
+
* simply never started, and the human has one concrete thing to do.
|
|
180
|
+
*
|
|
181
|
+
* @param answer - Which non-confirming answer they gave, for wording.
|
|
182
|
+
* @returns MCP tool result carrying the enabling steps and the retry.
|
|
183
|
+
*/
|
|
184
|
+
export function prerequisiteNotMetResult(answer) {
|
|
185
|
+
const lead = answer === PREREQ_ANSWERS.unsure
|
|
186
|
+
? 'Login was not started — here is where that setting lives.\n\n'
|
|
187
|
+
: 'Login was not started, because LINE would never prompt the phone.\n\n';
|
|
188
|
+
return {
|
|
189
|
+
content: [
|
|
190
|
+
{
|
|
191
|
+
type: 'text',
|
|
192
|
+
text: lead +
|
|
193
|
+
PRIMARY_DEVICE_SETTING_STEPS +
|
|
194
|
+
'\n\nOnce the toggle is on, call `login` again. Relay these steps ' +
|
|
195
|
+
'to the human as they are — do not summarize the settings path ' +
|
|
196
|
+
'away, it is the whole point.',
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `login` on a MCP 2026-07-28 connection: the multi-round-trip (MRTR) path.
|
|
3
|
+
*
|
|
4
|
+
* That revision removed the server→client request channel — `elicitInput`
|
|
5
|
+
* throws on a modern-era instance before any wire traffic (SDK:
|
|
6
|
+
* `_assertPushApiInServedEra`, "the 2026-07-28 revision has no server→client
|
|
7
|
+
* request channel"). The same conversation is instead carried by
|
|
8
|
+
* `input_required` RESULTS: the handler returns the elicitation it needs,
|
|
9
|
+
* the client fulfils it through its own `elicitation/create` handler, then
|
|
10
|
+
* RE-CALLS `login` with the answers in `inputResponses`.
|
|
11
|
+
*
|
|
12
|
+
* So this file is ./login.ts's pushed elicitation flow turned inside out —
|
|
13
|
+
* same three beats, one tool call each:
|
|
14
|
+
*
|
|
15
|
+
* round 1 no responses → ask the pre-flight form
|
|
16
|
+
* round 2 `preflight` answered → start the login, ask for PIN ack
|
|
17
|
+
* round 3 `ack` answered → await completion, return the profile
|
|
18
|
+
*
|
|
19
|
+
* The in-flight login survives between rounds in ./login-session.ts, exactly
|
|
20
|
+
* as it survives between `login` and `login_complete` on the fallback path.
|
|
21
|
+
*
|
|
22
|
+
* `requestState` (the other half of MRTR) is deliberately NOT used: it
|
|
23
|
+
* round-trips through the client and comes back attacker-controlled, so the
|
|
24
|
+
* spec requires integrity-protecting anything that influences behavior. The
|
|
25
|
+
* only state this flow needs is the pending login, which never leaves the
|
|
26
|
+
* server.
|
|
27
|
+
*/
|
|
28
|
+
import { acceptedContent, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
|
|
29
|
+
import { createCliLogger } from '../../util/log.js';
|
|
30
|
+
import { buildAcknowledgementForm, buildPreflightForm, prerequisiteNotMetResult, readPreflightForm, } from './login-copy.js';
|
|
31
|
+
import { finishPendingLogin, getLivePendingLogin, getPendingLogin, PIN_WAIT_TIMEOUT_MS, startPendingLogin, waitForPin, } from './login-session.js';
|
|
32
|
+
import { jsonText, toolError } from './shared.js';
|
|
33
|
+
const log = createCliLogger('Yomi');
|
|
34
|
+
/** Keys this flow assigns to its embedded input requests. */
|
|
35
|
+
const PREFLIGHT_KEY = 'preflight';
|
|
36
|
+
const ACK_KEY = 'ack';
|
|
37
|
+
/**
|
|
38
|
+
* Round 3 — the human says they have acted on their phone. Await the login
|
|
39
|
+
* `startPendingLogin` began in round 2.
|
|
40
|
+
*
|
|
41
|
+
* @returns The logged-in profile, or an honest error.
|
|
42
|
+
*/
|
|
43
|
+
async function completeRound() {
|
|
44
|
+
const pending = getPendingLogin();
|
|
45
|
+
if (!pending) {
|
|
46
|
+
return toolError('No login is pending — the attempt this acknowledgement belongs to has already finished or ' +
|
|
47
|
+
'expired (LINE gives the human about 3 minutes to act on a shown code). Calling `login` ' +
|
|
48
|
+
'again is safe and starts a fresh attempt.');
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const { mid, displayName } = await finishPendingLogin(pending);
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: 'text',
|
|
56
|
+
text: jsonText({ loggedIn: true, mid, displayName }),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return toolError(error?.message ?? String(error));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Round 2 — the pre-flight form came back accepted and confirming. Start
|
|
67
|
+
* (or reuse) the passwordless login and ask the human to act on the PIN.
|
|
68
|
+
*
|
|
69
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
70
|
+
* @param phone - Confirmed phone number.
|
|
71
|
+
* @param region - Confirmed region code.
|
|
72
|
+
* @returns An `input_required` result carrying the PIN acknowledgement form,
|
|
73
|
+
* or an honest error.
|
|
74
|
+
*/
|
|
75
|
+
async function startRound(service, phone, region) {
|
|
76
|
+
const active = getLivePendingLogin(Date.now());
|
|
77
|
+
if (active && (active.phone !== phone || active.region !== region)) {
|
|
78
|
+
// A live login for a DIFFERENT number is still in flight. Never hand
|
|
79
|
+
// back its PIN under this call's identity. Name nothing sensitive.
|
|
80
|
+
return toolError('A login for a different phone number is still in progress. Wait for its login code to expire ' +
|
|
81
|
+
'(LINE gives the human about 3 minutes to act on it) before starting a login for another number.');
|
|
82
|
+
}
|
|
83
|
+
const pending = active ?? startPendingLogin(service, phone, region);
|
|
84
|
+
const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
|
|
85
|
+
if (!pin && !pending.certSkippedPin) {
|
|
86
|
+
return toolError('LINE has not issued a login PIN within 20s. The attempt is still running in the background ' +
|
|
87
|
+
'(do not worry, nothing was lost) — call `login` again shortly to check for the PIN.');
|
|
88
|
+
}
|
|
89
|
+
log.warn('login.pin', {
|
|
90
|
+
action: pin ? 'returned_in_input_required' : 'skipped_cert_valid',
|
|
91
|
+
});
|
|
92
|
+
// The PIN travels inside the elicitation the client is about to render,
|
|
93
|
+
// and the login keeps running while the human acts on it — this handler
|
|
94
|
+
// returns immediately rather than blocking through LINE's ~3-minute
|
|
95
|
+
// window, so the human's clock starts now and not after a timeout.
|
|
96
|
+
return inputRequired({
|
|
97
|
+
inputRequests: {
|
|
98
|
+
[ACK_KEY]: inputRequired.elicit(buildAcknowledgementForm(pin)),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Handle `login` on a modern-era connection. Which round this call is comes
|
|
104
|
+
* from the responses it carries: none means round 1.
|
|
105
|
+
*
|
|
106
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
107
|
+
* @param args - Tool arguments.
|
|
108
|
+
* @param inputResponses - `ctx.mcpReq.inputResponses` from a retried call.
|
|
109
|
+
* Untrusted client input — read only through the SDK's response readers.
|
|
110
|
+
* @returns An `input_required` result, or a terminal tool result.
|
|
111
|
+
*/
|
|
112
|
+
export async function handleLoginMrtr(service, args, inputResponses) {
|
|
113
|
+
// Round 3 first: a client that accumulates responses across rounds would
|
|
114
|
+
// send both keys, and the later beat is the one to act on.
|
|
115
|
+
const ack = inputResponse(inputResponses, ACK_KEY);
|
|
116
|
+
if (ack.kind === 'elicit') {
|
|
117
|
+
if (ack.action !== 'accept') {
|
|
118
|
+
return toolError('Login was cancelled by the user.');
|
|
119
|
+
}
|
|
120
|
+
return await completeRound();
|
|
121
|
+
}
|
|
122
|
+
const preflight = inputResponse(inputResponses, PREFLIGHT_KEY);
|
|
123
|
+
if (preflight.kind === 'elicit') {
|
|
124
|
+
if (preflight.action !== 'accept') {
|
|
125
|
+
return toolError('Login was cancelled by the user.');
|
|
126
|
+
}
|
|
127
|
+
const outcome = readPreflightForm(acceptedContent(inputResponses, PREFLIGHT_KEY), args.phone, args.region);
|
|
128
|
+
if (!outcome.ok) {
|
|
129
|
+
if (outcome.reason === 'prerequisite') {
|
|
130
|
+
log.warn('login.prerequisite', { answer: outcome.answer });
|
|
131
|
+
return prerequisiteNotMetResult(outcome.answer);
|
|
132
|
+
}
|
|
133
|
+
return toolError('Login was cancelled by the user.');
|
|
134
|
+
}
|
|
135
|
+
return await startRound(service, outcome.phone, outcome.region);
|
|
136
|
+
}
|
|
137
|
+
// Round 1. Persisted credentials still pre-fill the form, so a returning
|
|
138
|
+
// user only confirms rather than retypes.
|
|
139
|
+
const phone = args.phone ??
|
|
140
|
+
(await service.credentialStore?.get?.('line_phone')) ??
|
|
141
|
+
undefined;
|
|
142
|
+
const region = args.region ??
|
|
143
|
+
(await service.credentialStore?.get?.('line_region')) ??
|
|
144
|
+
undefined;
|
|
145
|
+
return inputRequired({
|
|
146
|
+
inputRequests: {
|
|
147
|
+
[PREFLIGHT_KEY]: inputRequired.elicit(buildPreflightForm(phone, region)),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { runPwlessLogin } from '../../cli/login.js';
|
|
2
|
+
import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
|
|
3
|
+
import { createCliLogger } from '../../util/log.js';
|
|
4
|
+
const log = createCliLogger('Yomi');
|
|
5
|
+
/**
|
|
6
|
+
* How long a live pending login may be reused by a later `login` call for
|
|
7
|
+
* the same phone/region, before treating it as stale and starting fresh.
|
|
8
|
+
*
|
|
9
|
+
* This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
|
|
10
|
+
* an external fact from LINE Help Center, ~3 minutes) — NOT to how long
|
|
11
|
+
* yomi's client is willing to keep polling (~16 minutes). A login started
|
|
12
|
+
* 90 seconds ago is still very much alive and its PIN is still valid; the
|
|
13
|
+
* only thing that actually kills a pending login early is LINE's own code
|
|
14
|
+
* expiring, so that is the correct bound for "is this still worth reusing".
|
|
15
|
+
*/
|
|
16
|
+
const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
|
|
17
|
+
/** How long a `login` call waits for LINE to issue a PIN before giving up. */
|
|
18
|
+
export const PIN_WAIT_TIMEOUT_MS = 20000;
|
|
19
|
+
let pendingLogin = null;
|
|
20
|
+
/** @returns The in-flight login, or null when none is pending. */
|
|
21
|
+
export function getPendingLogin() {
|
|
22
|
+
return pendingLogin;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The in-flight login only if it is still worth reusing: started within
|
|
26
|
+
* LINE's PIN lifetime.
|
|
27
|
+
*
|
|
28
|
+
* @param now - Current epoch millis.
|
|
29
|
+
* @returns The live pending login, or null.
|
|
30
|
+
*/
|
|
31
|
+
export function getLivePendingLogin(now) {
|
|
32
|
+
if (!pendingLogin) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return now - pendingLogin.startedAt < PIN_WINDOW_MS ? pendingLogin : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Start a new passwordless login in the background and register it as the
|
|
39
|
+
* one in-flight pending login.
|
|
40
|
+
*
|
|
41
|
+
* The stored promise is the actual `runPwlessLogin` promise, so a later call
|
|
42
|
+
* can await it and see the real success/failure. A `.catch()` is attached
|
|
43
|
+
* immediately (its result discarded, not stored) so an abandoned login — one
|
|
44
|
+
* nobody ever finishes — never surfaces as an unhandled rejection; this does
|
|
45
|
+
* not affect what `promise` itself resolves/rejects with.
|
|
46
|
+
*
|
|
47
|
+
* @param service - LineProtocolService (not yet authenticated).
|
|
48
|
+
* @param phone - Phone number in E.164 form.
|
|
49
|
+
* @param region - Region code.
|
|
50
|
+
* @returns The newly registered pending-login record.
|
|
51
|
+
*/
|
|
52
|
+
export function startPendingLogin(service, phone, region) {
|
|
53
|
+
let resolvePinReady = () => { };
|
|
54
|
+
const pinReady = new Promise((resolve) => {
|
|
55
|
+
resolvePinReady = resolve;
|
|
56
|
+
});
|
|
57
|
+
const pending = {
|
|
58
|
+
phone,
|
|
59
|
+
region,
|
|
60
|
+
pin: null,
|
|
61
|
+
certSkippedPin: false,
|
|
62
|
+
startedAt: Date.now(),
|
|
63
|
+
pinReady,
|
|
64
|
+
};
|
|
65
|
+
const onPin = (pin) => {
|
|
66
|
+
pending.pin = pin;
|
|
67
|
+
resolvePinReady();
|
|
68
|
+
};
|
|
69
|
+
const onBiometric = () => {
|
|
70
|
+
log.warn('login.waiting_biometric', {
|
|
71
|
+
action: 'approve the new device on your phone',
|
|
72
|
+
});
|
|
73
|
+
// Reaching biometric wait with no PIN ever set means a valid stored
|
|
74
|
+
// certificate skipped the PIN step entirely (see LinePwlessLogin.login,
|
|
75
|
+
// step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
|
|
76
|
+
// its full timeout waiting for a PIN that will never arrive.
|
|
77
|
+
if (!pending.pin) {
|
|
78
|
+
pending.certSkippedPin = true;
|
|
79
|
+
resolvePinReady();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const promise = runPwlessLogin(service, phone, region, {
|
|
83
|
+
onPin,
|
|
84
|
+
onWaitingBiometric: onBiometric,
|
|
85
|
+
});
|
|
86
|
+
promise.catch(() => {
|
|
87
|
+
// Swallow here only to prevent an unhandled-rejection warning for a
|
|
88
|
+
// login nobody completes; the real error still surfaces to whoever
|
|
89
|
+
// awaits `promise` below.
|
|
90
|
+
});
|
|
91
|
+
pending.promise = promise;
|
|
92
|
+
pendingLogin = pending;
|
|
93
|
+
return pendingLogin;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Wait for a pending login's PIN to arrive, bounded by a timeout.
|
|
97
|
+
*
|
|
98
|
+
* @param pending - The pending login to wait on.
|
|
99
|
+
* @param timeoutMs - Maximum time to wait.
|
|
100
|
+
* @returns The PIN, or `null` if it did not arrive within `timeoutMs` (also
|
|
101
|
+
* the answer when a stored certificate skipped the PIN — check
|
|
102
|
+
* `certSkippedPin` to tell the two apart).
|
|
103
|
+
*/
|
|
104
|
+
export async function waitForPin(pending, timeoutMs) {
|
|
105
|
+
if (pending.pin) {
|
|
106
|
+
return pending.pin;
|
|
107
|
+
}
|
|
108
|
+
return await Promise.race([
|
|
109
|
+
pending.pinReady.then(() => pending.pin ?? null),
|
|
110
|
+
new Promise((resolve) => {
|
|
111
|
+
setTimeout(() => resolve(null), timeoutMs);
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Await the in-flight login to completion and clear it either way. Never
|
|
117
|
+
* touches credentials on the failure path — `runPwlessLogin` owns that.
|
|
118
|
+
*
|
|
119
|
+
* @param pending - The pending login to finish.
|
|
120
|
+
* @returns The logged-in profile.
|
|
121
|
+
* @throws Whatever `runPwlessLogin` failed with.
|
|
122
|
+
*/
|
|
123
|
+
export async function finishPendingLogin(pending) {
|
|
124
|
+
try {
|
|
125
|
+
const result = await pending.promise;
|
|
126
|
+
log.info('login.complete', { mid: result.mid });
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
pendingLogin = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,78 +1,24 @@
|
|
|
1
1
|
import { runPwlessLogin } from '../../cli/login.js';
|
|
2
2
|
import { LINE_PIN_CODE_LIFETIME_MS, PAAK_AUTH_CLIENT_CEILING_MS, PIN_VERIFY_CLIENT_CEILING_MS, } from '../../line/auth/pwless/index.js';
|
|
3
3
|
import { createCliLogger } from '../../util/log.js';
|
|
4
|
+
import { supportsFormElicitation } from '../client-capabilities.js';
|
|
4
5
|
import { supportsMcpApps } from '../ui/capability.js';
|
|
6
|
+
import { buildAcknowledgementForm, buildPreflightForm, certSteps, LOGIN_REGIONS, PRIMARY_DEVICE_SETTING_PATH, pinSteps, prerequisiteNotMetResult, readPreflightForm, } from './login-copy.js';
|
|
7
|
+
import { handleLoginMrtr } from './login-mrtr.js';
|
|
8
|
+
import { finishPendingLogin, getLivePendingLogin, getPendingLogin, PIN_WAIT_TIMEOUT_MS, startPendingLogin, waitForPin, } from './login-session.js';
|
|
5
9
|
import { jsonText, toolError } from './shared.js';
|
|
6
10
|
const log = createCliLogger('Yomi');
|
|
7
|
-
/** LINE region codes offered on the login elicitation form. */
|
|
8
|
-
const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
|
|
9
|
-
/**
|
|
10
|
-
* How long a live `pendingLogin` may be reused by a later `login` call for
|
|
11
|
-
* the same phone/region, before treating it as stale and starting fresh.
|
|
12
|
-
*
|
|
13
|
-
* This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
|
|
14
|
-
* an external fact from LINE Help Center, ~3 minutes) — NOT to how long
|
|
15
|
-
* yomi's client is willing to keep polling (~16 minutes). A login started
|
|
16
|
-
* 90 seconds ago is still very much alive and its PIN is still valid; the
|
|
17
|
-
* only thing that actually kills a pending login early is LINE's own code
|
|
18
|
-
* expiring, so that is the correct bound for "is this still worth reusing".
|
|
19
|
-
*/
|
|
20
|
-
const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
|
|
21
11
|
/**
|
|
22
12
|
* How long yomi's client keeps polling for the device-approval step once a
|
|
23
13
|
* PIN has been verified (or skipped via a stored certificate) — the real
|
|
24
|
-
* ceiling
|
|
14
|
+
* ceiling the completing call blocks for, used only to describe that wait to
|
|
25
15
|
* the human, not to bound anything server-side.
|
|
26
16
|
*/
|
|
27
17
|
const APPROVAL_WINDOW_MS = PAAK_AUTH_CLIENT_CEILING_MS;
|
|
28
|
-
/** How long `login` waits for LINE to issue a PIN before reporting a failure. */
|
|
29
|
-
const PIN_WAIT_TIMEOUT_MS = 20000;
|
|
30
|
-
/**
|
|
31
|
-
* Elicit `phone`/`region` from the human in one form, pre-filling whatever
|
|
32
|
-
* the caller already supplied.
|
|
33
|
-
*
|
|
34
|
-
* @param server - MCP Server instance (elicitation already capability-gated
|
|
35
|
-
* by the caller).
|
|
36
|
-
* @param phone - Phone already supplied by the tool call, if any.
|
|
37
|
-
* @param region - Region already supplied by the tool call, if any.
|
|
38
|
-
* @returns The collected phone/region, or `null` if the human declined/cancelled.
|
|
39
|
-
*/
|
|
40
|
-
async function elicitPhoneRegion(server, phone, region) {
|
|
41
|
-
const collected = await server.elicitInput({
|
|
42
|
-
message: 'Yomi needs your LINE phone number and region to start passwordless login.',
|
|
43
|
-
requestedSchema: {
|
|
44
|
-
type: 'object',
|
|
45
|
-
properties: {
|
|
46
|
-
phone: {
|
|
47
|
-
type: 'string',
|
|
48
|
-
title: 'Phone number',
|
|
49
|
-
description: 'E.164 form, e.g. +8869XXXXXXXX.',
|
|
50
|
-
...(phone ? { default: phone } : {}),
|
|
51
|
-
},
|
|
52
|
-
region: {
|
|
53
|
-
type: 'string',
|
|
54
|
-
title: 'Region',
|
|
55
|
-
enum: LOGIN_REGIONS,
|
|
56
|
-
...(region ? { default: region } : {}),
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
required: ['phone', 'region'],
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
if (collected.action !== 'accept') {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
const collectedPhone = String(collected.content?.phone ?? '');
|
|
66
|
-
const collectedRegion = String(collected.content?.region ?? '');
|
|
67
|
-
if (!collectedPhone || !collectedRegion) {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
return { phone: collectedPhone, region: collectedRegion };
|
|
71
|
-
}
|
|
72
18
|
/**
|
|
73
19
|
* Elicit acknowledgement of the login PIN — fire-and-forget from the
|
|
74
|
-
* caller's perspective (must not block `
|
|
75
|
-
*
|
|
20
|
+
* caller's perspective (must not block `runPwlessLogin`, which is already
|
|
21
|
+
* running concurrently and waiting on phone biometric approval).
|
|
76
22
|
*
|
|
77
23
|
* @param server - MCP Server instance.
|
|
78
24
|
* @param pin - The PIN LINE just issued.
|
|
@@ -82,19 +28,7 @@ async function elicitPhoneRegion(server, phone, region) {
|
|
|
82
28
|
*/
|
|
83
29
|
function elicitPinAcknowledgement(server, pin, onDeclined) {
|
|
84
30
|
server
|
|
85
|
-
.elicitInput(
|
|
86
|
-
message: `LINE PIN: ${pin}\n\nEnter this PIN in the LINE app on your primary phone, then approve ` +
|
|
87
|
-
'the new device from there. Acknowledge below once you have done this.',
|
|
88
|
-
requestedSchema: {
|
|
89
|
-
type: 'object',
|
|
90
|
-
properties: {
|
|
91
|
-
entered: {
|
|
92
|
-
type: 'boolean',
|
|
93
|
-
title: 'I entered the PIN and approved the device on my phone',
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
})
|
|
31
|
+
.elicitInput(buildAcknowledgementForm(pin))
|
|
98
32
|
.then((result) => {
|
|
99
33
|
if (result.action !== 'accept') {
|
|
100
34
|
onDeclined();
|
|
@@ -107,12 +41,13 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
|
|
|
107
41
|
});
|
|
108
42
|
}
|
|
109
43
|
/**
|
|
110
|
-
* Handle `login`
|
|
111
|
-
* passwordless flow entirely through
|
|
112
|
-
*
|
|
44
|
+
* Handle `login` on a LEGACY-era connection whose client supports form
|
|
45
|
+
* elicitation — drive the passwordless flow entirely through pushed
|
|
46
|
+
* elicitation requests, with no out-of-band channel required. A pre-flight
|
|
47
|
+
* form confirms the 允許自其他裝置登入 prerequisite (and collects
|
|
48
|
+
* phone/region when missing) BEFORE any PIN is issued; the PIN is then
|
|
113
49
|
* surfaced through a second elicitation fired without blocking
|
|
114
|
-
* `
|
|
115
|
-
* phone. See ../cli/login.ts for the shared login sequence.
|
|
50
|
+
* `runPwlessLogin`, which keeps running while the human acts on their phone.
|
|
116
51
|
*
|
|
117
52
|
* @param server - MCP Server instance (for elicitInput).
|
|
118
53
|
* @param service - LineProtocolService (not yet authenticated).
|
|
@@ -120,15 +55,19 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
|
|
|
120
55
|
* @returns MCP tool result describing the logged-in profile.
|
|
121
56
|
*/
|
|
122
57
|
async function handleLoginElicitation(server, service, args) {
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
58
|
+
const collected = await server.elicitInput(buildPreflightForm(args.phone, args.region));
|
|
59
|
+
if (collected.action !== 'accept') {
|
|
60
|
+
return toolError('Login was cancelled by the user.');
|
|
61
|
+
}
|
|
62
|
+
const preflight = readPreflightForm(collected.content, args.phone, args.region);
|
|
63
|
+
if (!preflight.ok) {
|
|
64
|
+
if (preflight.reason === 'prerequisite') {
|
|
65
|
+
log.warn('login.prerequisite', { answer: preflight.answer });
|
|
66
|
+
return prerequisiteNotMetResult(preflight.answer);
|
|
128
67
|
}
|
|
129
|
-
;
|
|
130
|
-
({ phone, region } = collected);
|
|
68
|
+
return toolError('Login was cancelled by the user.');
|
|
131
69
|
}
|
|
70
|
+
const { phone, region } = preflight;
|
|
132
71
|
let pinDeclined = false;
|
|
133
72
|
const onPin = (pin) => {
|
|
134
73
|
log.warn('login.pin', { action: 'elicit_acknowledgement' });
|
|
@@ -163,90 +102,12 @@ async function handleLoginElicitation(server, service, args) {
|
|
|
163
102
|
return toolError(error?.message ?? String(error));
|
|
164
103
|
}
|
|
165
104
|
}
|
|
166
|
-
let pendingLogin = null;
|
|
167
105
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* `login_complete`
|
|
173
|
-
* `.catch()` is attached immediately (its result discarded, not stored) so
|
|
174
|
-
* an abandoned login — one nobody ever calls `login_complete` for — never
|
|
175
|
-
* surfaces as an unhandled rejection; this does not affect what
|
|
176
|
-
* `pendingLogin.promise` itself resolves/rejects with.
|
|
177
|
-
*
|
|
178
|
-
* @param service - LineProtocolService (not yet authenticated).
|
|
179
|
-
* @param phone - Phone number in E.164 form.
|
|
180
|
-
* @param region - Region code.
|
|
181
|
-
* @returns The newly registered pending-login record.
|
|
182
|
-
*/
|
|
183
|
-
function startPendingLogin(service, phone, region) {
|
|
184
|
-
let resolvePinReady = () => { };
|
|
185
|
-
const pinReady = new Promise((resolve) => {
|
|
186
|
-
resolvePinReady = resolve;
|
|
187
|
-
});
|
|
188
|
-
const pending = {
|
|
189
|
-
phone,
|
|
190
|
-
region,
|
|
191
|
-
pin: null,
|
|
192
|
-
certSkippedPin: false,
|
|
193
|
-
startedAt: Date.now(),
|
|
194
|
-
pinReady,
|
|
195
|
-
};
|
|
196
|
-
const onPin = (pin) => {
|
|
197
|
-
pending.pin = pin;
|
|
198
|
-
resolvePinReady();
|
|
199
|
-
};
|
|
200
|
-
const onBiometric = () => {
|
|
201
|
-
log.warn('login.waiting_biometric', {
|
|
202
|
-
action: 'approve the new device on your phone',
|
|
203
|
-
});
|
|
204
|
-
// Reaching biometric wait with no PIN ever set means a valid stored
|
|
205
|
-
// certificate skipped the PIN step entirely (see LinePwlessLogin.login,
|
|
206
|
-
// step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
|
|
207
|
-
// its full timeout waiting for a PIN that will never arrive.
|
|
208
|
-
if (!pending.pin) {
|
|
209
|
-
pending.certSkippedPin = true;
|
|
210
|
-
resolvePinReady();
|
|
211
|
-
}
|
|
212
|
-
};
|
|
213
|
-
const promise = runPwlessLogin(service, phone, region, {
|
|
214
|
-
onPin,
|
|
215
|
-
onWaitingBiometric: onBiometric,
|
|
216
|
-
});
|
|
217
|
-
promise.catch(() => {
|
|
218
|
-
// Swallow here only to prevent an unhandled-rejection warning for a
|
|
219
|
-
// login nobody completes; the real error still surfaces to whoever
|
|
220
|
-
// awaits `pending.promise` (login_complete) below.
|
|
221
|
-
});
|
|
222
|
-
pending.promise = promise;
|
|
223
|
-
return pending;
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Wait for a pending login's PIN to arrive, bounded by a timeout.
|
|
227
|
-
*
|
|
228
|
-
* @param pending - The pending login to wait on.
|
|
229
|
-
* @param timeoutMs - Maximum time to wait.
|
|
230
|
-
* @returns The PIN, or `null` if it did not arrive within `timeoutMs`.
|
|
231
|
-
*/
|
|
232
|
-
async function waitForPin(pending, timeoutMs) {
|
|
233
|
-
if (pending.pin) {
|
|
234
|
-
return pending.pin;
|
|
235
|
-
}
|
|
236
|
-
const outcome = await Promise.race([
|
|
237
|
-
pending.pinReady.then(() => pending.pin ?? null),
|
|
238
|
-
new Promise((resolve) => {
|
|
239
|
-
setTimeout(() => resolve(null), timeoutMs);
|
|
240
|
-
}),
|
|
241
|
-
]);
|
|
242
|
-
return outcome;
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Handle `login` for a client that does NOT support MCP elicitation
|
|
246
|
-
* (confirmed empirically for Claude Desktop). Splits the flow into two
|
|
247
|
-
* tool calls since the PIN cannot be prompted for in-band: this call
|
|
248
|
-
* starts (or reuses) the passwordless flow and returns the PIN as soon as
|
|
249
|
-
* LINE issues it; `login_complete` finishes it once the human has acted.
|
|
106
|
+
* Handle `login` for a client that supports no form of elicitation at all
|
|
107
|
+
* (confirmed empirically for Claude Desktop). Splits the flow into two tool
|
|
108
|
+
* calls since the PIN cannot be prompted for in-band: this call starts (or
|
|
109
|
+
* reuses) the passwordless flow and returns the PIN as soon as LINE issues
|
|
110
|
+
* it; `login_complete` finishes it once the human has acted.
|
|
250
111
|
*
|
|
251
112
|
* The MCP Apps view (./ui/login-app.ts) has never been observed to render on
|
|
252
113
|
* any deployed client (see that file's header) — resources/read succeeds
|
|
@@ -297,6 +158,9 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
|
|
|
297
158
|
type: 'text',
|
|
298
159
|
text: 'Ask the human for their LINE phone number (E.164 form, e.g. +8869XXXXXXXX) and region ' +
|
|
299
160
|
`(one of ${LOGIN_REGIONS.join(', ')}), then call \`login\` again with those as arguments. ` +
|
|
161
|
+
'In the same message, tell them yomi signs in as a SECOND device, which requires ' +
|
|
162
|
+
`${PRIMARY_DEVICE_SETTING_PATH} to be enabled on the phone already logged in — if it is ` +
|
|
163
|
+
'off, LINE never prompts that phone and the login cannot finish. ' +
|
|
300
164
|
'A login form may also appear for the human to fill in directly — if they use it, the ' +
|
|
301
165
|
'login proceeds on its own and there is nothing further for you to do — but do not wait ' +
|
|
302
166
|
'for that; if no form appears, asking the human directly is how this login makes progress.',
|
|
@@ -310,14 +174,13 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
|
|
|
310
174
|
missing.push('phone');
|
|
311
175
|
if (!region)
|
|
312
176
|
missing.push('region');
|
|
313
|
-
return toolError(`Missing ${missing.join(' and ')} for login. This MCP client does not support elicitation, so ` +
|
|
177
|
+
return toolError(`Missing ${missing.join(' and ')} for login. This MCP client does not support form elicitation, so ` +
|
|
314
178
|
`${missing.join('/')} must be supplied as a tool argument, or persisted by running ` +
|
|
315
|
-
'`npx @rikaidev/yomi login` once in a terminal.'
|
|
179
|
+
'`npx @rikaidev/yomi login` once in a terminal. Also tell the human that yomi signs in as a ' +
|
|
180
|
+
`SECOND device, which requires ${PRIMARY_DEVICE_SETTING_PATH} enabled on the phone already ` +
|
|
181
|
+
'logged in — with it off, LINE never prompts that phone and no login can succeed.');
|
|
316
182
|
}
|
|
317
|
-
const
|
|
318
|
-
const active = pendingLogin && now - pendingLogin.startedAt < PIN_WINDOW_MS
|
|
319
|
-
? pendingLogin
|
|
320
|
-
: null;
|
|
183
|
+
const active = getLivePendingLogin(Date.now());
|
|
321
184
|
if (active && (active.phone !== phone || active.region !== region)) {
|
|
322
185
|
// A live login for a DIFFERENT number is still in flight. Never hand
|
|
323
186
|
// back its PIN under this call's identity, and never silently start a
|
|
@@ -327,10 +190,8 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
|
|
|
327
190
|
'before starting a login for another number.');
|
|
328
191
|
}
|
|
329
192
|
// Reuse the in-flight login only when it matches this number; otherwise
|
|
330
|
-
// start a fresh one.
|
|
331
|
-
// the module-level `pendingLogin` is needed downstream.
|
|
193
|
+
// start a fresh one.
|
|
332
194
|
const pending = active ?? startPendingLogin(service, phone, region);
|
|
333
|
-
pendingLogin = pending;
|
|
334
195
|
const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
|
|
335
196
|
if (!pin) {
|
|
336
197
|
if (pending.certSkippedPin) {
|
|
@@ -343,14 +204,7 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
|
|
|
343
204
|
pin: null,
|
|
344
205
|
clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
|
|
345
206
|
};
|
|
346
|
-
const
|
|
347
|
-
'so the PIN step was skipped.\n\n' +
|
|
348
|
-
'0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 ' +
|
|
349
|
-
'enabled — without it LINE will not offer this device a sign-in prompt at all.\n' +
|
|
350
|
-
'1. Bring the LINE app to the foreground on your primary phone — it should prompt you ' +
|
|
351
|
-
'to approve this new device signing in.\n' +
|
|
352
|
-
'2. Approve the device from there.\n\n';
|
|
353
|
-
const certText = certSteps +
|
|
207
|
+
const certText = certSteps() +
|
|
354
208
|
'Call the `login_complete` tool (no arguments) IMMEDIATELY now — do not wait for ' +
|
|
355
209
|
'confirmation that the approval happened first. `login_complete` blocks by itself while ' +
|
|
356
210
|
`you approve (up to about ${Math.round(APPROVAL_WINDOW_MS / 60000)} minutes) and returns ` +
|
|
@@ -374,16 +228,7 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
|
|
|
374
228
|
clientPinPollCeilingSeconds: Math.round(PIN_VERIFY_CLIENT_CEILING_MS / 1000),
|
|
375
229
|
clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
|
|
376
230
|
};
|
|
377
|
-
const
|
|
378
|
-
` ${pin}\n\n` +
|
|
379
|
-
'0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 enabled — ' +
|
|
380
|
-
'without it LINE will not offer this device a sign-in prompt at all.\n' +
|
|
381
|
-
'1. Open LINE on the primary phone. If a verification prompt does not appear immediately, ' +
|
|
382
|
-
'bringing the app to the foreground surfaces it.\n' +
|
|
383
|
-
`2. Enter this PIN and tick the device that is signing in, then tap 「用戶確認」 — LINE gives ` +
|
|
384
|
-
`you ${pinCodeLifetimeSeconds} seconds (about 3 minutes) from when the code was shown to do this.\n` +
|
|
385
|
-
'3. LINE will then ask that same phone to approve this new device signing in — approve it there.\n\n';
|
|
386
|
-
const pinText = pinSteps +
|
|
231
|
+
const pinText = pinSteps(pin) +
|
|
387
232
|
`Call the \`login_complete\` tool (no arguments) IMMEDIATELY now — do not wait until you have ` +
|
|
388
233
|
'entered the PIN or approved the device. `login_complete` blocks by itself through both of ' +
|
|
389
234
|
`those steps: it keeps waiting up to about ${clientPinPollCeilingMinutes} minutes for step 2 and ` +
|
|
@@ -397,41 +242,50 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
|
|
|
397
242
|
};
|
|
398
243
|
}
|
|
399
244
|
/**
|
|
400
|
-
* Handle `login` — dispatches to the
|
|
401
|
-
*
|
|
402
|
-
* `login_complete`.
|
|
245
|
+
* Handle `login` — dispatches to the path the connected client can actually
|
|
246
|
+
* complete. See this module's header for the three paths.
|
|
403
247
|
*
|
|
404
|
-
*
|
|
248
|
+
* Elicitation is gated on the FORM sub-capability specifically, not on the
|
|
249
|
+
* presence of an `elicitation` object: since MCP 2026-07-28 a client may
|
|
250
|
+
* declare `elicitation: { url: {} }` alone, and both elicitation paths send
|
|
251
|
+
* a `requestedSchema` that such a client would reject. See
|
|
252
|
+
* ../client-capabilities.ts, which also owns WHERE the capabilities are read
|
|
253
|
+
* from — the accessor is empty on a modern-era stdio connection.
|
|
254
|
+
*
|
|
255
|
+
* @param server - MCP Server instance (for elicitInput on the legacy path).
|
|
405
256
|
* @param service - LineProtocolService (not yet authenticated).
|
|
406
257
|
* @param args - Tool arguments.
|
|
407
|
-
* @
|
|
258
|
+
* @param context - Era, client capabilities, and the multi-round-trip input
|
|
259
|
+
* responses a retried `tools/call` carried (`ctx.mcpReq.inputResponses`).
|
|
260
|
+
* @returns MCP tool result, or an `input_required` result on the MRTR path.
|
|
408
261
|
*/
|
|
409
|
-
export async function handleLogin(server, service, args,
|
|
410
|
-
|
|
411
|
-
|
|
262
|
+
export async function handleLogin(server, service, args, context = {}) {
|
|
263
|
+
const { era = 'legacy', capabilities = server.getClientCapabilities(), inputResponses, } = context;
|
|
264
|
+
if (supportsFormElicitation(capabilities)) {
|
|
265
|
+
return era === 'legacy'
|
|
266
|
+
? handleLoginElicitation(server, service, args)
|
|
267
|
+
: handleLoginMrtr(service, args, inputResponses);
|
|
412
268
|
}
|
|
413
269
|
return handleLoginNoElicitation(service, args, supportsMcpApps(capabilities));
|
|
414
270
|
}
|
|
415
271
|
/**
|
|
416
272
|
* Handle `login_complete` — finish a login started by a prior `login` call
|
|
417
|
-
* on a
|
|
418
|
-
* promise `login` started, so the real success/failure
|
|
419
|
-
*
|
|
273
|
+
* on a client with no elicitation. Awaits the same in-flight
|
|
274
|
+
* `runPwlessLogin` promise `login` started, so the real success/failure
|
|
275
|
+
* surfaces here.
|
|
420
276
|
*
|
|
421
277
|
* @returns MCP tool result describing the logged-in profile, or an honest error.
|
|
422
278
|
*/
|
|
423
279
|
export async function handleLoginComplete() {
|
|
424
|
-
|
|
280
|
+
const pending = getPendingLogin();
|
|
281
|
+
if (!pending) {
|
|
425
282
|
return toolError('No login is pending. Call `login` first to start a passwordless login. ' +
|
|
426
283
|
'(If you already called `login`, a previous attempt may have finished or its PIN code may have ' +
|
|
427
284
|
`expired — LINE only gives the human about ${Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000)} seconds ` +
|
|
428
285
|
'(about 3 minutes) to act on a shown code. Calling `login` again is safe.)');
|
|
429
286
|
}
|
|
430
|
-
const pending = pendingLogin;
|
|
431
287
|
try {
|
|
432
|
-
const { mid, displayName } = await pending
|
|
433
|
-
pendingLogin = null;
|
|
434
|
-
log.info('login.complete', { mid });
|
|
288
|
+
const { mid, displayName } = await finishPendingLogin(pending);
|
|
435
289
|
return {
|
|
436
290
|
content: [
|
|
437
291
|
{
|
|
@@ -442,7 +296,6 @@ export async function handleLoginComplete() {
|
|
|
442
296
|
};
|
|
443
297
|
}
|
|
444
298
|
catch (error) {
|
|
445
|
-
pendingLogin = null;
|
|
446
299
|
return toolError(error?.message ?? String(error));
|
|
447
300
|
}
|
|
448
301
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -12,7 +12,9 @@ import { LineProtocolService } from '../line/core/service.js';
|
|
|
12
12
|
import { startCapture } from '../search/capture.js';
|
|
13
13
|
import { getDefaultEmbedder } from '../search/default-embedder.js';
|
|
14
14
|
import { createCliLogger } from '../util/log.js';
|
|
15
|
+
import { checkForUpdate } from '../util/update-check.js';
|
|
15
16
|
import { YOMI_VERSION } from '../version.js';
|
|
17
|
+
import { clientCapabilitiesOf } from './client-capabilities.js';
|
|
16
18
|
import { handleAcceptInvitation, handleAddFriend, handleBlockContact, handleCancelReaction, handleCollectMessages, handleCreateGroup, handleExcludeChats, handleFindContact, handleGetChatMessages, handleGetGroupMembers, handleGetInsight, handleGetMessageImage, handleGetMessageMedia, handleGetScopePolicy, handleGetUnreadDigest, handleIncludeChats, handleInviteMember, handleKickMember, handleLeaveGroup, handleListContacts, handleListConversations, handleListExcludedChats, handleListStickers, handleLogin, handleLoginComplete, handleMarkRead, handlePreviewSticker, handleReactMessage, handleRenameGroup, handleSearchMessages, handleSearchStickers, handleSendAudio, handleSendContact, handleSendFile, handleSendImage, handleSendLocation, handleSendMessage, handleSendSticker, handleSendVideo, handleUnblockContact, handleUnsendMessage, NO_CREDENTIALS_MESSAGE, sessionExpiredError, sessionRequiredError, sessionRevokedError, toolError, } from './handlers/index.js';
|
|
17
19
|
import { getPrivacyPolicyText } from './policy.js';
|
|
18
20
|
import { TOOLS } from './tools.js';
|
|
@@ -42,12 +44,21 @@ async function main() {
|
|
|
42
44
|
});
|
|
43
45
|
});
|
|
44
46
|
}
|
|
47
|
+
// Asked once per process, bounded, best-effort: a hand-installed .mcpb
|
|
48
|
+
// bundle never auto-updates, so this notice is the only way a GUI-only user
|
|
49
|
+
// learns a newer Yomi exists (see ../util/update-check.ts). Awaited here
|
|
50
|
+
// because `instructions` is built once, before any client connects.
|
|
51
|
+
const updateNotice = await checkForUpdate();
|
|
52
|
+
if (updateNotice) {
|
|
53
|
+
log.info('update.available', { current: YOMI_VERSION });
|
|
54
|
+
}
|
|
45
55
|
// `instructions` is surfaced by the MCP SDK to the client/model on
|
|
46
56
|
// initialize — a "TOS on connect" privacy disclosure. It is a consent
|
|
47
57
|
// notice, not decoration. A short model directive precedes the canonical
|
|
48
58
|
// policy prose, which lives ONLY in PRIVACY.md (see ./policy.ts) so the
|
|
49
59
|
// disclosure is single-sourced and never drifts from get_scope_policy.
|
|
50
|
-
const instructions =
|
|
60
|
+
const instructions = (updateNotice ? `${updateNotice}\n\n` : '') +
|
|
61
|
+
'SESSION ERRORS — if a tool fails with a login-required or signed-out ' +
|
|
51
62
|
'error, the MCP server and its connection are healthy: LINE revoked this ' +
|
|
52
63
|
"device's session (usually because the same account logged in somewhere " +
|
|
53
64
|
'else). Tell the user that plainly and offer the `login` tool. Never ' +
|
|
@@ -101,7 +112,7 @@ async function main() {
|
|
|
101
112
|
server.setRequestHandler('tools/list', async () => {
|
|
102
113
|
return { tools: toolsForClient(TOOLS, true) };
|
|
103
114
|
});
|
|
104
|
-
server.setRequestHandler('tools/call', async (request) => {
|
|
115
|
+
server.setRequestHandler('tools/call', async (request, ctx) => {
|
|
105
116
|
const { name, arguments: args } = request.params;
|
|
106
117
|
// `login` is the one tool allowed without an existing session — it is
|
|
107
118
|
// how a session gets created. `search_messages` also runs without a
|
|
@@ -136,7 +147,15 @@ async function main() {
|
|
|
136
147
|
try {
|
|
137
148
|
switch (name) {
|
|
138
149
|
case 'login':
|
|
139
|
-
|
|
150
|
+
// `era` selects HOW the login talks to the human: the legacy era
|
|
151
|
+
// can push `elicitation/create`, the modern one answers with
|
|
152
|
+
// multi-round-trip `input_required` results the client fulfils
|
|
153
|
+
// and retries (`ctx.mcpReq.inputResponses` carries the answers).
|
|
154
|
+
return await handleLogin(server, service, (args ?? {}), {
|
|
155
|
+
era,
|
|
156
|
+
capabilities: clientCapabilitiesOf(server, ctx),
|
|
157
|
+
inputResponses: ctx.mcpReq.inputResponses,
|
|
158
|
+
});
|
|
140
159
|
case 'login_complete':
|
|
141
160
|
return await handleLoginComplete();
|
|
142
161
|
case 'list_conversations':
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { PRIMARY_DEVICE_SETTING_PATH } from './handlers/login-copy.js';
|
|
1
2
|
export const TOOLS = [
|
|
2
3
|
{
|
|
3
|
-
description:
|
|
4
|
+
description: `Log in to LINE via the passwordless secondary-device flow. Requires ${PRIMARY_DEVICE_SETTING_PATH} enabled on the primary phone — with it off LINE never prompts that phone and NO login can succeed, so raise this with the human up front rather than after a failure. On MCP clients with form elicitation, this call first confirms that setting, then prompts for phone/region and PIN and completes login by itself; if the human says the setting is off, it returns the enabling steps without starting a login (relay them verbatim, then call \`login\` again). On clients without it (e.g. Claude Desktop), phone/region come from the arguments or a persisted login, and this returns as soon as LINE issues the PIN (or reports none needed) — then call login_complete IMMEDIATELY (do not wait for the human). LINE gives ~3 minutes from PIN display to confirm on the phone; login_complete blocks past that, so calling it late only wastes that window.`,
|
|
4
5
|
inputSchema: {
|
|
5
6
|
type: 'object',
|
|
6
7
|
properties: {
|
|
@@ -17,7 +18,7 @@ export const TOOLS = [
|
|
|
17
18
|
name: 'login',
|
|
18
19
|
},
|
|
19
20
|
{
|
|
20
|
-
description: "Finish a passwordless login that `login` started on a client without elicitation (not needed on elicitation-capable clients). No arguments. Call immediately after `login` returns — do not wait for the human. Blocks while they enter the PIN (skipped if a stored certificate is valid) and approve the device, then returns the profile. LINE's real deadline is ~3 minutes from PIN display. Errors if no login is pending.",
|
|
21
|
+
description: "Finish a passwordless login that `login` started on a client without form elicitation (not needed on form-elicitation-capable clients). No arguments. Call immediately after `login` returns — do not wait for the human. Blocks while they enter the PIN (skipped if a stored certificate is valid) and approve the device, then returns the profile. LINE's real deadline is ~3 minutes from PIN display. Errors if no login is pending.",
|
|
21
22
|
inputSchema: {
|
|
22
23
|
type: 'object',
|
|
23
24
|
properties: {},
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is this copy of Yomi out of date?" — asked once at startup, answered to
|
|
3
|
+
* the model through the MCP `instructions` so it can tell the human.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the drag-and-drop `.mcpb` bundle has NO auto-update. The
|
|
6
|
+
* MCPB manifest spec defines no update URL or endpoint (its `version` is
|
|
7
|
+
* plain metadata), and Claude Desktop only auto-updates extensions installed
|
|
8
|
+
* from the official directory — "for privately distributed extensions, users
|
|
9
|
+
* will need to install updated .mcpb files manually". Yomi's bundles are
|
|
10
|
+
* attached to GitHub Releases, so without this a user can sit on an old
|
|
11
|
+
* build indefinitely without ever knowing.
|
|
12
|
+
*
|
|
13
|
+
* It cannot self-update: a bundle carries native binaries (onnxruntime-node,
|
|
14
|
+
* sharp) and overwriting itself while running is neither safe nor portable.
|
|
15
|
+
* Telling the human, with the right link for how they installed, is the whole
|
|
16
|
+
* scope.
|
|
17
|
+
*
|
|
18
|
+
* PRIVACY. This is the ONE outbound request Yomi makes that is not to LINE,
|
|
19
|
+
* and it is disclosed in PRIVACY.md. It sends no message content and no
|
|
20
|
+
* account identifier — it is a plain GET of a public npm registry document.
|
|
21
|
+
* What it does reveal to the registry is that some IP is running Yomi, and
|
|
22
|
+
* (by what it does with the answer) roughly which version. `YOMI_NO_UPDATE_CHECK=1`
|
|
23
|
+
* turns it off completely.
|
|
24
|
+
*/
|
|
25
|
+
import { YOMI_VERSION } from '../version.js';
|
|
26
|
+
/**
|
|
27
|
+
* The registry document for the published package. Queried directly rather
|
|
28
|
+
* than through `npm view`, which reads a CDN-cached view that lags behind a
|
|
29
|
+
* publish by minutes.
|
|
30
|
+
*/
|
|
31
|
+
const LATEST_URL = 'https://registry.npmjs.org/@rikaidev/yomi/latest';
|
|
32
|
+
/** How long the check may take before startup gives up on it. */
|
|
33
|
+
const TIMEOUT_MS = 1500;
|
|
34
|
+
/**
|
|
35
|
+
* Compare two semver core versions (`major.minor.patch`).
|
|
36
|
+
*
|
|
37
|
+
* Pre-release suffixes are ignored on BOTH sides, which is deliberate: the
|
|
38
|
+
* only question here is "is a newer release available", and a local
|
|
39
|
+
* pre-release of the same core version should not nag.
|
|
40
|
+
*
|
|
41
|
+
* @param a - Left version.
|
|
42
|
+
* @param b - Right version.
|
|
43
|
+
* @returns Positive when `a` is newer, negative when older, 0 when equal.
|
|
44
|
+
*/
|
|
45
|
+
export function compareVersions(a, b) {
|
|
46
|
+
const parse = (value) => value
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/^v/, '')
|
|
49
|
+
.split('-')[0]
|
|
50
|
+
.split('.')
|
|
51
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
52
|
+
const left = parse(a);
|
|
53
|
+
const right = parse(b);
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
56
|
+
if (diff !== 0) {
|
|
57
|
+
return diff;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* How this copy of Yomi was installed, which decides what "update" means.
|
|
64
|
+
* The `.mcpb` build stamps `YOMI_DISTRIBUTION=mcpb` into the manifest's
|
|
65
|
+
* server env (scripts/build-mcpb.mjs); anything else is an npm/npx install.
|
|
66
|
+
*
|
|
67
|
+
* @returns The distribution channel.
|
|
68
|
+
*/
|
|
69
|
+
function distribution() {
|
|
70
|
+
return process.env.YOMI_DISTRIBUTION === 'mcpb' ? 'mcpb' : 'npm';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build the notice handed to the model when a newer version exists.
|
|
74
|
+
*
|
|
75
|
+
* @param latest - The newest published version.
|
|
76
|
+
* @returns Notice text, phrased for how this copy was installed.
|
|
77
|
+
*/
|
|
78
|
+
export function updateNotice(latest) {
|
|
79
|
+
const how = distribution() === 'mcpb'
|
|
80
|
+
? 'Desktop Extension bundles do NOT auto-update when installed by hand: the human has to ' +
|
|
81
|
+
'download the new .mcpb for their platform from ' +
|
|
82
|
+
'https://github.com/RikaiDev/yomi/releases/latest and drag it into Claude Desktop again ' +
|
|
83
|
+
'(same extension, it replaces this one).'
|
|
84
|
+
: 'Update with `npx @rikaidev/yomi@latest` (or reinstall the package) to pick it up.';
|
|
85
|
+
return (`UPDATE AVAILABLE — this Yomi is ${YOMI_VERSION}; ${latest} has been published. ` +
|
|
86
|
+
`${how} Mention this ONCE, early, in your own words, then carry on with what the user ` +
|
|
87
|
+
'asked — it is not urgent and never a reason to refuse work.');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Ask the registry whether a newer Yomi has been published.
|
|
91
|
+
*
|
|
92
|
+
* Best-effort by construction: offline, slow, rate-limited, or malformed
|
|
93
|
+
* answers all resolve to `null`. A version check must never delay or fail a
|
|
94
|
+
* server that is otherwise ready to work.
|
|
95
|
+
*
|
|
96
|
+
* @returns The notice text when an update exists, otherwise null.
|
|
97
|
+
*/
|
|
98
|
+
export async function checkForUpdate() {
|
|
99
|
+
if (process.env.YOMI_NO_UPDATE_CHECK === '1') {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(LATEST_URL, {
|
|
104
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
105
|
+
headers: { accept: 'application/json' },
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const body = (await response.json());
|
|
111
|
+
const latest = typeof body.version === 'string' ? body.version : null;
|
|
112
|
+
if (!latest || compareVersions(latest, YOMI_VERSION) <= 0) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return updateNotice(latest);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Offline is the normal case here, not an error worth surfacing.
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"engines": {
|
|
4
4
|
"node": ">=22.13.0 <23.0.0 || >=23.4.0"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.4.
|
|
6
|
+
"version": "0.4.1",
|
|
7
7
|
"description": "Open-source personal LINE MCP server for AI agents. Read, reply, send images, and search LINE conversations locally with Letter-Sealing E2EE.",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"license": "MIT",
|