@nado-language/mcp 0.1.2 → 0.1.3
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 +23 -3
- package/dist/nado-language-server.mjs +45 -34
- package/dist/nado-mcp-auth.mjs +10 -1
- package/dist/nado-mcp-cli.mjs +179 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,12 +6,19 @@ This stdio MCP server lets AI chat clients save and practice Nado Language study
|
|
|
6
6
|
|
|
7
7
|
- `nado_whoami`: validates the configured Nado account.
|
|
8
8
|
- `nado_save_flashcard`: saves a structured flashcard generated by the user's chat AI. Nado does not call AI for this path.
|
|
9
|
+
- `nado_save_study_item`: alias of `nado_save_flashcard` for natural memorization requests such as "암기할래", "외울래", "암기장에 넣어줘", and "단어장에 추가".
|
|
9
10
|
- `nado_analyze_and_save_flashcard`: Pro/Admin only. Nado AI generates the learner definition, usage note, examples, and variants, then saves the flashcard.
|
|
10
11
|
- `nado_list_study_items`: loads saved flashcards for the authenticated user.
|
|
11
12
|
- `nado_generate_practice`: builds practice exercises from saved flashcards only.
|
|
12
13
|
|
|
13
14
|
The default MCP path is designed to avoid double charging for AI. ChatGPT, Claude, Codex, or another user-paid AI model should fill the structured flashcard fields, and Nado only validates/saves them. Nado AI quality generation is a separate Pro/Admin tool.
|
|
14
15
|
|
|
16
|
+
Intent routing:
|
|
17
|
+
|
|
18
|
+
- If the user provides a new English item and says "암기할래", "외울래", "암기장에 추가", "단어장에 넣어줘", "remember this", or "add this to my flashcards", use `nado_save_flashcard` or `nado_save_study_item`.
|
|
19
|
+
- If the user asks to study already saved cards with phrases like "외울래", "암기 연습", "퀴즈 내줘", "쓰기연습", "영작하기", or "복습할래", use `nado_generate_practice`.
|
|
20
|
+
- If the user wants to inspect saved items first, use `nado_list_study_items`.
|
|
21
|
+
|
|
15
22
|
## Authentication
|
|
16
23
|
|
|
17
24
|
Install from npm:
|
|
@@ -139,6 +146,7 @@ For Codex, the command uses the Codex CLI when it is on `PATH`. If the user only
|
|
|
139
146
|
- Windows: `%USERPROFILE%\.codex\config.toml`
|
|
140
147
|
|
|
141
148
|
Restart Codex Desktop after login completes.
|
|
149
|
+
If a client was already open, start a new chat/session after restart. Most local MCP clients load tool definitions when the session starts, so a config that was just written may not appear inside an already-running conversation.
|
|
142
150
|
|
|
143
151
|
ChatGPT is different: it uses hosted/remote MCP apps configured from ChatGPT Apps settings, not local stdio config files. The local package can prepare local clients such as Codex, Claude Desktop, OpenCode, and generic JSON-based MCP clients.
|
|
144
152
|
|
|
@@ -180,6 +188,7 @@ When using the installed package, `nado-mcp login` saves auth to the user's OS c
|
|
|
180
188
|
Basic tool discovery:
|
|
181
189
|
|
|
182
190
|
```bash
|
|
191
|
+
nado-mcp doctor
|
|
183
192
|
nado-mcp probe list
|
|
184
193
|
```
|
|
185
194
|
|
|
@@ -245,9 +254,13 @@ npm run mcp:nado:probe -- save-nado-ai "serendipity"
|
|
|
245
254
|
Install/register/login flow:
|
|
246
255
|
|
|
247
256
|
```bash
|
|
248
|
-
nado-mcp connect
|
|
249
|
-
|
|
250
|
-
|
|
257
|
+
nado-mcp connect
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Force all supported local config writers:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
nado-mcp connect all
|
|
251
264
|
```
|
|
252
265
|
|
|
253
266
|
Generic MCP fallback:
|
|
@@ -256,3 +269,10 @@ Generic MCP fallback:
|
|
|
256
269
|
nado-mcp config
|
|
257
270
|
nado-mcp login
|
|
258
271
|
```
|
|
272
|
+
|
|
273
|
+
Diagnostics:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
nado-mcp doctor
|
|
277
|
+
nado-mcp probe list
|
|
278
|
+
```
|
|
@@ -30,6 +30,40 @@ let cachedRefreshGrant = null;
|
|
|
30
30
|
const FLASHCARD_TYPES = new Set(['word', 'phrase', 'sentence_structure']);
|
|
31
31
|
const PRACTICE_MODES = new Set(['writing', 'vocabulary_quiz', 'conversation', 'sentence_completion', 'translation']);
|
|
32
32
|
|
|
33
|
+
const saveFlashcardInputSchema = {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['original', 'definition'],
|
|
36
|
+
properties: {
|
|
37
|
+
original: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
minLength: 1,
|
|
40
|
+
description: 'English word, phrase, or sentence to save. Example: for "hello 암기할래", use "hello".',
|
|
41
|
+
},
|
|
42
|
+
type: { type: 'string', enum: ['word', 'phrase', 'sentence_structure'], default: 'phrase' },
|
|
43
|
+
definition: { type: 'string', minLength: 1, description: 'Learner-language definition or translation generated by the chat AI.' },
|
|
44
|
+
inlineDefinition: { type: 'string', description: 'Short review-side meaning. Defaults to a compact definition.' },
|
|
45
|
+
explanation: { type: 'string', description: 'Optional nuance, usage note, or grammar explanation.' },
|
|
46
|
+
exampleSentences: {
|
|
47
|
+
type: 'array',
|
|
48
|
+
items: { type: 'string' },
|
|
49
|
+
description: 'Optional examples generated by the user chat AI.',
|
|
50
|
+
},
|
|
51
|
+
variants: {
|
|
52
|
+
type: 'array',
|
|
53
|
+
items: { type: 'string' },
|
|
54
|
+
description: 'Optional related forms, collocations, or variants.',
|
|
55
|
+
},
|
|
56
|
+
contextSentence: { type: 'string', description: 'Original sentence/context from the chat. Defaults to text.' },
|
|
57
|
+
articleTitle: { type: 'string', description: 'Source title shown in analysis. Defaults to MCP Chat.' },
|
|
58
|
+
sourceLang: { type: 'string', default: 'ko', description: 'User language for definitions.' },
|
|
59
|
+
targetLang: { type: 'string', default: 'en', description: 'Language being learned.' },
|
|
60
|
+
userLevel: { type: 'string', default: 'B1', description: 'CEFR level used by Nado analysis.' },
|
|
61
|
+
articleId: { type: 'string', default: 'mcp-chat' },
|
|
62
|
+
sourceHighlightId: { type: 'string', default: 'mcp-chat' },
|
|
63
|
+
},
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
};
|
|
66
|
+
|
|
33
67
|
const tools = [
|
|
34
68
|
{
|
|
35
69
|
name: 'nado_whoami',
|
|
@@ -42,40 +76,17 @@ const tools = [
|
|
|
42
76
|
},
|
|
43
77
|
{
|
|
44
78
|
name: 'nado_save_flashcard',
|
|
45
|
-
description: 'Save a structured flashcard that the chat model already generated. This does not call Nado AI; the user/chat AI
|
|
46
|
-
inputSchema:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
definition: { type: 'string', minLength: 1, description: 'Learner-language definition or translation.' },
|
|
53
|
-
inlineDefinition: { type: 'string', description: 'Short review-side meaning. Defaults to a compact definition.' },
|
|
54
|
-
explanation: { type: 'string', description: 'Optional nuance, usage note, or grammar explanation.' },
|
|
55
|
-
exampleSentences: {
|
|
56
|
-
type: 'array',
|
|
57
|
-
items: { type: 'string' },
|
|
58
|
-
description: 'Optional examples generated by the user chat AI.',
|
|
59
|
-
},
|
|
60
|
-
variants: {
|
|
61
|
-
type: 'array',
|
|
62
|
-
items: { type: 'string' },
|
|
63
|
-
description: 'Optional related forms, collocations, or variants.',
|
|
64
|
-
},
|
|
65
|
-
contextSentence: { type: 'string', description: 'Original sentence/context from the chat. Defaults to text.' },
|
|
66
|
-
articleTitle: { type: 'string', description: 'Source title shown in analysis. Defaults to MCP Chat.' },
|
|
67
|
-
sourceLang: { type: 'string', default: 'ko', description: 'User language for definitions.' },
|
|
68
|
-
targetLang: { type: 'string', default: 'en', description: 'Language being learned.' },
|
|
69
|
-
userLevel: { type: 'string', default: 'B1', description: 'CEFR level used by Nado analysis.' },
|
|
70
|
-
articleId: { type: 'string', default: 'mcp-chat' },
|
|
71
|
-
sourceHighlightId: { type: 'string', default: 'mcp-chat' },
|
|
72
|
-
},
|
|
73
|
-
additionalProperties: false,
|
|
74
|
-
},
|
|
79
|
+
description: 'Save a structured Nado Language flashcard that the chat model already generated. Use this when the user gives an English item and asks to memorize/save it, for example "암기장에 추가해줘", "암기할래", "외울래", "단어장에 넣어줘", "remember this", or "add this to my flashcards". This does not call Nado AI; the user/chat AI supplies the content quality.',
|
|
80
|
+
inputSchema: saveFlashcardInputSchema,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'nado_save_study_item',
|
|
84
|
+
description: 'Alias of nado_save_flashcard for study-list and memorization intents. Prefer this or nado_save_flashcard when the user asks to save a specific word/phrase/sentence to Nado for later memorization, including Korean requests like "암기할래", "외울래", "암기장에 넣어줘", or "단어장에 추가". This free path does not call Nado AI.',
|
|
85
|
+
inputSchema: saveFlashcardInputSchema,
|
|
75
86
|
},
|
|
76
87
|
{
|
|
77
88
|
name: 'nado_analyze_and_save_flashcard',
|
|
78
|
-
description: 'Pro/Admin only: use Nado AI to produce validated definitions/examples for a word, phrase, or sentence, then save the flashcard.',
|
|
89
|
+
description: 'Pro/Admin only: use Nado AI to produce validated definitions/examples for a word, phrase, or sentence, then save the flashcard. Use only when the user explicitly wants Nado-verified/pro quality analysis or examples; normal memorize/save requests should use nado_save_flashcard so the user AI supplies the content.',
|
|
79
90
|
inputSchema: {
|
|
80
91
|
type: 'object',
|
|
81
92
|
required: ['original'],
|
|
@@ -94,7 +105,7 @@ const tools = [
|
|
|
94
105
|
},
|
|
95
106
|
{
|
|
96
107
|
name: 'nado_list_study_items',
|
|
97
|
-
description: 'Load saved Nado Language vocabulary and sentence cards
|
|
108
|
+
description: 'Load saved Nado Language vocabulary and sentence cards. Use when the user asks what is saved, wants to inspect the memorization list, or asks to review existing Nado study items before practice.',
|
|
98
109
|
inputSchema: {
|
|
99
110
|
type: 'object',
|
|
100
111
|
properties: {
|
|
@@ -107,7 +118,7 @@ const tools = [
|
|
|
107
118
|
},
|
|
108
119
|
{
|
|
109
120
|
name: 'nado_generate_practice',
|
|
110
|
-
description: 'Generate English practice content using only the authenticated user saved Nado Language study cards.',
|
|
121
|
+
description: 'Generate English practice content using only the authenticated user saved Nado Language study cards. Use when the user wants to study/review already saved items: "외울래", "암기 연습", "퀴즈 내줘", "쓰기연습", "영작하기", "복습할래", "practice my saved words". If the user supplies a new English item to save, use nado_save_flashcard instead.',
|
|
111
122
|
inputSchema: {
|
|
112
123
|
type: 'object',
|
|
113
124
|
required: ['mode'],
|
|
@@ -883,8 +894,8 @@ function stableHash(value) {
|
|
|
883
894
|
async function callTool(name, args = {}) {
|
|
884
895
|
if (name === 'nado_whoami') return handleWhoami();
|
|
885
896
|
if (name === 'nado_save_flashcard') return handleSaveFlashcard(args);
|
|
897
|
+
if (name === 'nado_save_study_item') return handleSaveFlashcard(args);
|
|
886
898
|
if (name === 'nado_analyze_and_save_flashcard') return handleAnalyzeAndSaveFlashcard(args);
|
|
887
|
-
if (name === 'nado_save_study_item') return handleAnalyzeAndSaveFlashcard({ ...args, original: args.original || args.text });
|
|
888
899
|
if (name === 'nado_list_study_items') return handleListStudyItems(args);
|
|
889
900
|
if (name === 'nado_generate_practice') return handleGeneratePractice(args);
|
|
890
901
|
throw new Error(`UNKNOWN_TOOL: ${name}`);
|
package/dist/nado-mcp-auth.mjs
CHANGED
|
@@ -199,7 +199,7 @@ async function login(options) {
|
|
|
199
199
|
|
|
200
200
|
let timeoutId;
|
|
201
201
|
const timeout = new Promise((_, reject) => {
|
|
202
|
-
timeoutId = setTimeout(() => reject(
|
|
202
|
+
timeoutId = setTimeout(() => reject(loginTimeoutError(options)), options.timeoutMs);
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
try {
|
|
@@ -212,6 +212,15 @@ async function login(options) {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
function loginTimeoutError(options) {
|
|
216
|
+
return new Error([
|
|
217
|
+
'Timed out waiting for browser login.',
|
|
218
|
+
`Rerun \`nado-mcp login --provider ${options.provider} --timeout-ms 900000\` and keep the terminal open until the browser says login completed.`,
|
|
219
|
+
'If the browser did not open, copy the printed URL into the same desktop browser where you can sign in.',
|
|
220
|
+
'If you already signed in but it still timed out, check that the browser can reach the printed 127.0.0.1 local callback URL and that Supabase Auth allows the relay URL.',
|
|
221
|
+
].join(' '));
|
|
222
|
+
}
|
|
223
|
+
|
|
215
224
|
function buildRedirectTo(options, localCallbackUrl) {
|
|
216
225
|
if (options.redirectMode === 'local') return localCallbackUrl;
|
|
217
226
|
|
package/dist/nado-mcp-cli.mjs
CHANGED
|
@@ -45,15 +45,19 @@ try {
|
|
|
45
45
|
await runNode(probePath, args, { stdio: 'inherit' });
|
|
46
46
|
} else if (command === 'setup') {
|
|
47
47
|
const parsed = splitClientAndSetupOptions(args);
|
|
48
|
-
await setup(parsed.client, parsed.setupOptions);
|
|
48
|
+
const didRegister = await setup(parsed.client, parsed.setupOptions);
|
|
49
|
+
if (didRegister) printToolProbeSummary();
|
|
49
50
|
} else if (command === 'connect' || command === 'install') {
|
|
50
51
|
const parsed = splitClientAndSetupOptions(args);
|
|
51
52
|
const didRegister = await setup(parsed.client, parsed.setupOptions, { loginAfter: true });
|
|
52
|
-
if (didRegister)
|
|
53
|
+
if (didRegister) {
|
|
54
|
+
printToolProbeSummary();
|
|
55
|
+
await runAuth(['login', ...parsed.rest]);
|
|
56
|
+
}
|
|
53
57
|
} else if (command === 'config') {
|
|
54
58
|
printConfig(args[0] || 'all');
|
|
55
59
|
} else if (command === 'doctor') {
|
|
56
|
-
doctor();
|
|
60
|
+
await doctor();
|
|
57
61
|
} else {
|
|
58
62
|
throw new Error(`Unknown command: ${command}`);
|
|
59
63
|
}
|
|
@@ -228,15 +232,182 @@ function setupMcpServersJson(configPath, flow = {}) {
|
|
|
228
232
|
printLoginNext(flow);
|
|
229
233
|
}
|
|
230
234
|
|
|
231
|
-
function doctor() {
|
|
235
|
+
async function doctor() {
|
|
236
|
+
const auth = authStatus(configuredAuthEnvFile());
|
|
237
|
+
const codex = codexRegistrationStatus();
|
|
238
|
+
const claude = jsonRegistrationStatus(claudeDesktopConfigPath(), ['mcpServers', serverName]);
|
|
239
|
+
const opencode = jsonRegistrationStatus(opencodeConfigPath(), ['mcp', serverName]);
|
|
240
|
+
const probe = probeTools();
|
|
241
|
+
|
|
232
242
|
console.log('Nado MCP doctor');
|
|
233
243
|
console.log(`Node: ${process.version}`);
|
|
234
244
|
console.log(`Server: ${serverPath}${existsSync(serverPath) ? '' : ' (missing)'}`);
|
|
235
245
|
console.log(`Auth CLI: ${authPath}${existsSync(authPath) ? '' : ' (missing)'}`);
|
|
236
|
-
console.log(`
|
|
237
|
-
console.log(`
|
|
238
|
-
console.log(`
|
|
239
|
-
console.log(`
|
|
246
|
+
console.log(`Local MCP server check: ${probe.ok ? `ok (${probe.tools.join(', ')})` : `failed (${probe.error})`}`);
|
|
247
|
+
console.log(`Auth file: ${auth.filePath}${auth.fileExists ? ' (present)' : ' (missing)'}`);
|
|
248
|
+
console.log(`Auth access token: ${auth.accessToken ? `present${tokenExpiryText(auth.accessToken)}` : 'missing'}`);
|
|
249
|
+
console.log(`Auth refresh token: ${auth.refreshToken ? 'present' : 'missing'}`);
|
|
250
|
+
console.log(`Auth email/password fallback: ${auth.email ? `configured for ${auth.email}` : 'missing'}`);
|
|
251
|
+
console.log(`Codex Desktop config: ${registrationText(codex)}`);
|
|
252
|
+
console.log(`Claude Desktop config: ${registrationText(claude)}`);
|
|
253
|
+
console.log(`OpenCode config: ${registrationText(opencode)}`);
|
|
254
|
+
console.log('');
|
|
255
|
+
console.log('If the server check is ok but Nado tools are not visible in the AI app:');
|
|
256
|
+
console.log(' 1. Fully quit and restart the desktop app after `nado-mcp connect`.');
|
|
257
|
+
console.log(' 2. Start a new chat/session; existing sessions may not reload newly added MCP tools.');
|
|
258
|
+
console.log(' 3. Run `nado-mcp status` for auth and `nado-mcp probe list` for local server tools.');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function printToolProbeSummary() {
|
|
262
|
+
const probe = probeTools();
|
|
263
|
+
if (probe.ok) {
|
|
264
|
+
console.log(`Local MCP server check: ok (${probe.tools.join(', ')})`);
|
|
265
|
+
} else {
|
|
266
|
+
console.log(`Local MCP server check failed: ${probe.error}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function probeTools() {
|
|
271
|
+
if (!existsSync(probePath)) return { ok: false, error: `probe script missing at ${probePath}` };
|
|
272
|
+
|
|
273
|
+
const result = spawnSync(process.execPath, [probePath, 'list'], {
|
|
274
|
+
encoding: 'utf8',
|
|
275
|
+
env: {
|
|
276
|
+
...process.env,
|
|
277
|
+
NADO_MCP_SKIP_DOTENV: '1',
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (result.error) return { ok: false, error: result.error.message };
|
|
282
|
+
if (result.status !== 0) {
|
|
283
|
+
const message = (result.stderr || result.stdout || `probe exited with code ${result.status}`).trim();
|
|
284
|
+
return { ok: false, error: message };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(result.stdout);
|
|
289
|
+
const tools = (parsed.tools || []).map((tool) => tool.name).filter(Boolean);
|
|
290
|
+
if (tools.length === 0) return { ok: false, error: 'no tools returned by server' };
|
|
291
|
+
return { ok: true, tools };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return { ok: false, error: `could not parse probe output: ${error instanceof Error ? error.message : String(error)}` };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function configuredAuthEnvFile() {
|
|
298
|
+
return process.env.NADO_MCP_AUTH_ENV_FILE ? expandHome(process.env.NADO_MCP_AUTH_ENV_FILE) : defaultUserAuthEnvFile();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function authStatus(filePath) {
|
|
302
|
+
const values = readEnvFile(filePath);
|
|
303
|
+
const accessToken = process.env.NADO_MCP_ACCESS_TOKEN || values.NADO_MCP_ACCESS_TOKEN || process.env.NADO_ACCESS_TOKEN || '';
|
|
304
|
+
const refreshToken = process.env.NADO_MCP_REFRESH_TOKEN || values.NADO_MCP_REFRESH_TOKEN || process.env.NADO_REFRESH_TOKEN || '';
|
|
305
|
+
const email = process.env.NADO_MCP_EMAIL || values.NADO_MCP_EMAIL || '';
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
filePath,
|
|
309
|
+
fileExists: existsSync(filePath),
|
|
310
|
+
accessToken,
|
|
311
|
+
refreshToken,
|
|
312
|
+
email,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function codexRegistrationStatus() {
|
|
317
|
+
const configPath = codexDesktopConfigPath();
|
|
318
|
+
if (!existsSync(configPath)) return { path: configPath, fileExists: false, registered: false };
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const text = readFileSync(configPath, 'utf8');
|
|
322
|
+
const sectionPattern = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(serverName)}]\\s*$`, 'm');
|
|
323
|
+
return { path: configPath, fileExists: true, registered: sectionPattern.test(text) };
|
|
324
|
+
} catch (error) {
|
|
325
|
+
return { path: configPath, fileExists: true, registered: false, error: error instanceof Error ? error.message : String(error) };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function jsonRegistrationStatus(configPath, keyPath) {
|
|
330
|
+
if (!existsSync(configPath)) return { path: configPath, fileExists: false, registered: false };
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const config = readJsonConfig(configPath);
|
|
334
|
+
return {
|
|
335
|
+
path: configPath,
|
|
336
|
+
fileExists: true,
|
|
337
|
+
registered: getPathValue(config, keyPath) !== undefined,
|
|
338
|
+
};
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return { path: configPath, fileExists: true, registered: false, error: error instanceof Error ? error.message : String(error) };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function registrationText(status) {
|
|
345
|
+
if (status.error) return `${status.path} (error: ${status.error})`;
|
|
346
|
+
if (!status.fileExists) return `${status.path} (missing)`;
|
|
347
|
+
return `${status.path} (${status.registered ? 'registered' : 'not registered'})`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function getPathValue(value, keys) {
|
|
351
|
+
let current = value;
|
|
352
|
+
for (const key of keys) {
|
|
353
|
+
if (!current || typeof current !== 'object' || !(key in current)) return undefined;
|
|
354
|
+
current = current[key];
|
|
355
|
+
}
|
|
356
|
+
return current;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readEnvFile(filePath) {
|
|
360
|
+
if (!existsSync(filePath)) return {};
|
|
361
|
+
const values = {};
|
|
362
|
+
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/)) {
|
|
363
|
+
const parsed = parseEnvLine(line);
|
|
364
|
+
if (parsed) values[parsed.key] = parsed.value;
|
|
365
|
+
}
|
|
366
|
+
return values;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function parseEnvLine(line) {
|
|
370
|
+
const trimmed = line.trim();
|
|
371
|
+
if (!trimmed || trimmed.startsWith('#')) return null;
|
|
372
|
+
|
|
373
|
+
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length).trimStart() : trimmed;
|
|
374
|
+
const separator = withoutExport.indexOf('=');
|
|
375
|
+
if (separator <= 0) return null;
|
|
376
|
+
|
|
377
|
+
const key = withoutExport.slice(0, separator).trim();
|
|
378
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
|
|
379
|
+
|
|
380
|
+
let value = withoutExport.slice(separator + 1).trim();
|
|
381
|
+
const quote = value[0];
|
|
382
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
383
|
+
value = value.slice(1, -1);
|
|
384
|
+
} else {
|
|
385
|
+
value = value.replace(/\s+#.*$/, '').trim();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { key, value };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function tokenExpiryText(token) {
|
|
392
|
+
const expiresAt = jwtExpiresAtMs(token);
|
|
393
|
+
if (!expiresAt) return '';
|
|
394
|
+
return `, expires ${new Date(expiresAt).toISOString()}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function jwtExpiresAtMs(token) {
|
|
398
|
+
const parts = String(token || '').split('.');
|
|
399
|
+
if (parts.length < 2) return null;
|
|
400
|
+
try {
|
|
401
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
402
|
+
const exp = Number(payload.exp);
|
|
403
|
+
return Number.isFinite(exp) ? exp * 1000 : null;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function escapeRegExp(value) {
|
|
410
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
240
411
|
}
|
|
241
412
|
|
|
242
413
|
function firstExisting(candidates) {
|