yuque-cookie-plugin 0.1.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/dist/auth.js ADDED
@@ -0,0 +1,439 @@
1
+ import { createServer } from 'node:http';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
4
+ import { execFile } from 'node:child_process';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'yuque-cookie-plugin');
10
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
11
+ export function getConfigFilePath() {
12
+ return CONFIG_FILE;
13
+ }
14
+ export function getCredentials(flags = {}) {
15
+ const envSession = process.env[String(flags.sessionEnv || 'YUQUE_SESSION')];
16
+ const envCtoken = process.env[String(flags.ctokenEnv || 'YUQUE_CTOKEN')];
17
+ const extraCookies = getExtraCookies(flags);
18
+ if (envSession && envCtoken) {
19
+ return {
20
+ session: envSession,
21
+ ctoken: envCtoken,
22
+ homeUrl: getConfiguredHomeUrl(flags),
23
+ extraCookies,
24
+ source: 'env'
25
+ };
26
+ }
27
+ const saved = readSavedCredentials();
28
+ if (saved?.session && saved?.ctoken) {
29
+ return { ...saved, extraCookies: { ...(saved.extraCookies || {}), ...extraCookies }, source: CONFIG_FILE };
30
+ }
31
+ throw new Error('Missing Yuque credentials. Run "yuque-local login" to configure them in your browser.');
32
+ }
33
+ export function getCredentialStatus(flags = {}) {
34
+ const envSession = process.env[String(flags.sessionEnv || 'YUQUE_SESSION')];
35
+ const envCtoken = process.env[String(flags.ctokenEnv || 'YUQUE_CTOKEN')];
36
+ if (envSession && envCtoken) {
37
+ return {
38
+ configured: true,
39
+ source: 'env',
40
+ home_url: getConfiguredHomeUrl(flags) || null,
41
+ saved_at: null,
42
+ age_hours: null,
43
+ last_validated_at: null,
44
+ last_failed_at: null
45
+ };
46
+ }
47
+ const saved = readSavedCredentials();
48
+ if (!saved?.session || !saved?.ctoken) {
49
+ return {
50
+ configured: false,
51
+ source: CONFIG_FILE
52
+ };
53
+ }
54
+ return {
55
+ configured: true,
56
+ source: CONFIG_FILE,
57
+ home_url: saved.homeUrl || null,
58
+ saved_at: saved.saved_at || null,
59
+ updated_at: saved.updated_at || null,
60
+ age_hours: ageHours(saved.saved_at),
61
+ last_validated_at: saved.last_validated_at || null,
62
+ last_failed_at: saved.last_failed_at || null,
63
+ last_validation_error: saved.last_validation_error || null
64
+ };
65
+ }
66
+ export async function recordCredentialValidation(ok, error) {
67
+ const saved = readSavedCredentials();
68
+ if (!saved?.session || !saved?.ctoken)
69
+ return;
70
+ const now = new Date().toISOString();
71
+ if (ok) {
72
+ await saveCredentials({
73
+ ...saved,
74
+ last_validated_at: now,
75
+ last_failed_at: undefined,
76
+ last_validation_error: undefined
77
+ });
78
+ return;
79
+ }
80
+ await saveCredentials({
81
+ ...saved,
82
+ last_failed_at: now,
83
+ last_validation_error: error instanceof Error ? error.message : String(error || 'Unknown validation error')
84
+ });
85
+ }
86
+ function getExtraCookies(flags) {
87
+ const fromFlag = typeof flags.cookieKey === 'string' && typeof flags.cookieValue === 'string'
88
+ ? { [flags.cookieKey]: flags.cookieValue }
89
+ : {};
90
+ const envKey = process.env.YUQUE_EXTRA_COOKIE_KEY;
91
+ const envValue = process.env.YUQUE_EXTRA_COOKIE_VALUE;
92
+ const fromEnv = envKey && envValue ? { [envKey]: envValue } : {};
93
+ return { ...fromEnv, ...fromFlag };
94
+ }
95
+ export async function loginWithBrowser(flags = {}) {
96
+ if (flags.manual) {
97
+ await loginManually(flags);
98
+ return;
99
+ }
100
+ const port = Number(flags.port || 0);
101
+ const result = await startLoginServer(port);
102
+ console.log(`Opening browser for Yuque cookie setup: ${result.url}`);
103
+ await openBrowser(result.url);
104
+ const credentials = await result.done;
105
+ await saveCredentials(credentials);
106
+ console.log(`Saved Yuque credentials to ${CONFIG_FILE}`);
107
+ }
108
+ export async function loginManually(flags = {}) {
109
+ const sessionFromFlag = typeof flags.session === 'string' ? flags.session.trim() : '';
110
+ const ctokenFromFlag = typeof flags.ctoken === 'string' ? flags.ctoken.trim() : '';
111
+ const homeUrlFromFlag = typeof flags.homeUrl === 'string' ? flags.homeUrl.trim() : '';
112
+ if (sessionFromFlag && ctokenFromFlag && homeUrlFromFlag) {
113
+ await saveCredentials({
114
+ session: sessionFromFlag,
115
+ ctoken: ctokenFromFlag,
116
+ homeUrl: normalizeYuqueHomeUrl(homeUrlFromFlag),
117
+ saved_at: new Date().toISOString()
118
+ });
119
+ console.log(`Saved Yuque credentials to ${CONFIG_FILE}`);
120
+ return;
121
+ }
122
+ if (!process.stdin.isTTY) {
123
+ throw new Error('Manual login requires a TTY, or pass --home-url, --session and --ctoken together.');
124
+ }
125
+ console.log('Manual Yuque cookie setup');
126
+ console.log('Paste values from an already logged-in browser. Input is saved only to the local user config.');
127
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
128
+ try {
129
+ const homeUrlInput = homeUrlFromFlag || (await rl.question('Yuque home URL, e.g. https://www.yuque.com/your-login/: '));
130
+ const session = sessionFromFlag || (await rl.question('_yuque_session: '));
131
+ const ctoken = ctokenFromFlag || (await rl.question('yuque_ctoken: '));
132
+ if (!session.trim() || !ctoken.trim())
133
+ throw new Error('Both _yuque_session and yuque_ctoken are required.');
134
+ await saveCredentials({
135
+ session: session.trim(),
136
+ ctoken: ctoken.trim(),
137
+ homeUrl: normalizeYuqueHomeUrl(homeUrlInput.trim()),
138
+ saved_at: new Date().toISOString()
139
+ });
140
+ console.log(`Saved Yuque credentials to ${CONFIG_FILE}`);
141
+ }
142
+ finally {
143
+ rl.close();
144
+ }
145
+ }
146
+ function readSavedCredentials() {
147
+ if (!existsSync(CONFIG_FILE))
148
+ return null;
149
+ try {
150
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ async function saveCredentials(credentials) {
157
+ const now = new Date().toISOString();
158
+ const data = {
159
+ ...credentials,
160
+ saved_at: credentials.saved_at || now,
161
+ updated_at: now
162
+ };
163
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
164
+ await writeFile(CONFIG_FILE, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
165
+ await chmod(CONFIG_FILE, 0o600);
166
+ }
167
+ function ageHours(value) {
168
+ if (!value)
169
+ return null;
170
+ const time = new Date(value).getTime();
171
+ if (Number.isNaN(time))
172
+ return null;
173
+ return Math.max(0, Math.round(((Date.now() - time) / 3_600_000) * 10) / 10);
174
+ }
175
+ function getConfiguredHomeUrl(flags) {
176
+ const value = typeof flags.homeUrl === 'string' ? flags.homeUrl : process.env.YUQUE_HOME_URL;
177
+ return value ? normalizeYuqueHomeUrl(value) : undefined;
178
+ }
179
+ function normalizeYuqueHomeUrl(value) {
180
+ const input = value.trim();
181
+ try {
182
+ const url = new URL(input);
183
+ if (url.hostname !== 'www.yuque.com' && !url.hostname.endsWith('.yuque.com')) {
184
+ throw new Error('Yuque home URL must be a yuque.com URL.');
185
+ }
186
+ const login = url.pathname.split('/').filter(Boolean)[0];
187
+ if (!login)
188
+ throw new Error('Yuque home URL must include your login or organization path.');
189
+ url.hash = '';
190
+ url.search = '';
191
+ url.pathname = `/${login}/`;
192
+ return url.toString();
193
+ }
194
+ catch (error) {
195
+ if (error instanceof Error && error.message.startsWith('Yuque home URL'))
196
+ throw error;
197
+ throw new Error('Yuque home URL must look like https://www.yuque.com/<your-login>/');
198
+ }
199
+ }
200
+ async function startLoginServer(port) {
201
+ let resolveDone;
202
+ let rejectDone;
203
+ const done = new Promise((resolve, reject) => {
204
+ resolveDone = resolve;
205
+ rejectDone = reject;
206
+ });
207
+ const sockets = new Set();
208
+ const server = createServer(async (req, res) => {
209
+ try {
210
+ if (req.method === 'GET' && req.url === '/') {
211
+ sendHtml(res, loginPage());
212
+ return;
213
+ }
214
+ if (req.method === 'POST' && req.url === '/save') {
215
+ const body = await readRequestBody(req);
216
+ const params = new URLSearchParams(body);
217
+ const session = params.get('session')?.trim();
218
+ const ctoken = params.get('ctoken')?.trim();
219
+ const homeUrlInput = params.get('homeUrl')?.trim();
220
+ if (!session || !ctoken) {
221
+ sendHtml(res, loginPage('Both _yuque_session and yuque_ctoken are required.'), 400);
222
+ return;
223
+ }
224
+ if (!homeUrlInput) {
225
+ sendHtml(res, loginPage('Yuque personal or organization home URL is required.'), 400);
226
+ return;
227
+ }
228
+ const homeUrl = normalizeYuqueHomeUrl(homeUrlInput);
229
+ sendHtml(res, successPage());
230
+ res.on('finish', () => {
231
+ resolveDone({ session, ctoken, homeUrl, saved_at: new Date().toISOString() });
232
+ closeLoginServer(server, sockets);
233
+ });
234
+ return;
235
+ }
236
+ res.writeHead(404);
237
+ res.end('Not found');
238
+ }
239
+ catch (error) {
240
+ rejectDone(error);
241
+ res.writeHead(500);
242
+ res.end('Internal error');
243
+ closeLoginServer(server, sockets);
244
+ }
245
+ });
246
+ server.on('connection', (socket) => {
247
+ sockets.add(socket);
248
+ socket.on('close', () => sockets.delete(socket));
249
+ });
250
+ server.listen(port, '0.0.0.0');
251
+ return new Promise((resolve) => {
252
+ server.on('listening', () => {
253
+ const address = server.address();
254
+ if (!address || typeof address === 'string')
255
+ throw new Error('Unable to determine login server address.');
256
+ resolve({
257
+ url: `http://127.0.0.1:${address.port}/`,
258
+ done
259
+ });
260
+ });
261
+ });
262
+ }
263
+ function closeLoginServer(server, sockets) {
264
+ server.close();
265
+ server.closeIdleConnections?.();
266
+ setTimeout(() => {
267
+ for (const socket of sockets)
268
+ socket.destroy();
269
+ }, 250).unref();
270
+ }
271
+ function readRequestBody(req) {
272
+ return new Promise((resolve, reject) => {
273
+ let body = '';
274
+ req.setEncoding('utf8');
275
+ req.on('data', (chunk) => {
276
+ body += chunk;
277
+ if (body.length > 1024 * 1024) {
278
+ reject(new Error('Request body too large.'));
279
+ req.destroy();
280
+ }
281
+ });
282
+ req.on('end', () => resolve(body));
283
+ req.on('error', reject);
284
+ });
285
+ }
286
+ async function openBrowser(url) {
287
+ const candidates = process.platform === 'darwin'
288
+ ? [['open', [url]]]
289
+ : process.platform === 'win32'
290
+ ? [['cmd', ['/c', 'start', '', url]]]
291
+ : [['xdg-open', [url]], ['gio', ['open', url]]];
292
+ for (const [command, args] of candidates) {
293
+ try {
294
+ await execFilePromise(command, args);
295
+ return;
296
+ }
297
+ catch {
298
+ // Try the next opener.
299
+ }
300
+ }
301
+ console.log(`Could not open a browser automatically. Open this URL manually: ${url}`);
302
+ }
303
+ function execFilePromise(command, args) {
304
+ return new Promise((resolve, reject) => {
305
+ execFile(command, args, (error) => {
306
+ if (error)
307
+ reject(error);
308
+ else
309
+ resolve();
310
+ });
311
+ });
312
+ }
313
+ function sendHtml(res, html, status = 200) {
314
+ res.writeHead(status, {
315
+ 'content-type': 'text/html; charset=utf-8',
316
+ 'cache-control': 'no-store'
317
+ });
318
+ res.end(html);
319
+ }
320
+ function loginPage(error = '') {
321
+ return `<!doctype html>
322
+ <html lang="zh-CN">
323
+ <head>
324
+ <meta charset="utf-8" />
325
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
326
+ <title>Yuque Cookie Setup</title>
327
+ <style>
328
+ :root { color-scheme: light; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --ink: #18202f; --muted: #667085; --line: #d9dee8; --panel: #ffffff; --accent: #16835f; --accent-dark: #0f6a4c; --soft: #eef6f2; }
329
+ * { box-sizing: border-box; }
330
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: linear-gradient(135deg, #f7f8fb 0%, #eef3f1 100%); color: var(--ink); padding: 24px; }
331
+ .shell { width: min(1040px, 100%); display: grid; grid-template-columns: .9fr 1.1fr; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--panel); box-shadow: 0 20px 60px rgba(24, 32, 47, .12); }
332
+ .side { background: #18202f; color: #f8fafc; padding: 34px; display: grid; align-content: space-between; min-height: 560px; }
333
+ .brand { display: grid; gap: 12px; }
334
+ .mark { width: 42px; height: 42px; border-radius: 8px; background: #d7f3e6; color: #0f6a4c; display: grid; place-items: center; font-weight: 800; letter-spacing: .02em; }
335
+ h1 { margin: 0; font-size: 28px; line-height: 1.15; letter-spacing: 0; }
336
+ .side p { color: #c8d0dc; line-height: 1.65; margin: 0; }
337
+ .facts { display: grid; gap: 12px; margin-top: 28px; }
338
+ .fact { border: 1px solid rgba(255,255,255,.14); border-radius: 8px; padding: 12px; color: #e5eaf1; background: rgba(255,255,255,.04); }
339
+ .fact strong { display: block; color: #fff; margin-bottom: 4px; font-size: 14px; }
340
+ main { padding: 34px; }
341
+ .eyebrow { margin: 0 0 8px; color: var(--accent-dark); font-size: 13px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
342
+ h2 { margin: 0; font-size: 24px; line-height: 1.25; letter-spacing: 0; }
343
+ .lead { margin: 10px 0 24px; color: var(--muted); line-height: 1.65; }
344
+ label { display: block; margin: 18px 0 8px; font-weight: 700; font-size: 14px; }
345
+ input { width: 100%; min-height: 46px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; font: inherit; color: var(--ink); background: #fbfcfd; }
346
+ input:focus { outline: 3px solid rgba(22, 131, 95, .18); border-color: var(--accent); background: #fff; }
347
+ .hint { margin: 7px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
348
+ button { width: 100%; margin-top: 24px; min-height: 46px; border: 0; border-radius: 6px; background: var(--accent); color: white; font-weight: 800; cursor: pointer; font-size: 15px; }
349
+ button:hover { background: var(--accent-dark); }
350
+ .notice { margin-top: 20px; border: 1px solid #cde8da; background: var(--soft); color: #214c3a; padding: 12px; border-radius: 6px; line-height: 1.55; font-size: 14px; }
351
+ .error { color: #991b1b; background: #fee2e2; border: 1px solid #fecaca; padding: 10px 12px; border-radius: 6px; }
352
+ code { background: #eef1f5; padding: 2px 5px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
353
+ @media (max-width: 820px) { body { padding: 12px; } .shell { grid-template-columns: 1fr; } .side { min-height: auto; gap: 26px; padding: 24px; } main { padding: 24px; } }
354
+ </style>
355
+ </head>
356
+ <body>
357
+ <section class="shell">
358
+ <aside class="side">
359
+ <div class="brand">
360
+ <div class="mark">YQ</div>
361
+ <h1>Yuque Session Console</h1>
362
+ <p>本地保存语雀网页 Session,让 CLI 使用浏览器同款 Cookie 访问语雀。</p>
363
+ </div>
364
+ <div class="facts">
365
+ <div class="fact"><strong>保存位置</strong><span>~/.config/yuque-cookie-plugin/config.json</span></div>
366
+ <div class="fact"><strong>安全边界</strong><span>只写入本机配置,不写入项目仓库。</span></div>
367
+ <div class="fact"><strong>失效处理</strong><span>后续可用 auth-status 检测并提示重新登录。</span></div>
368
+ </div>
369
+ </aside>
370
+ <main>
371
+ <p class="eyebrow">Local credential setup</p>
372
+ <h2>填写语雀 Cookie</h2>
373
+ <p class="lead">从已登录语雀的浏览器 Cookie 中复制 <code>_yuque_session</code> 和 <code>yuque_ctoken</code>。这两个值会记录保存时间,用于后续判断登录态是否可能失效。</p>
374
+ ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
375
+ <form method="post" action="/save" autocomplete="off">
376
+ <label for="homeUrl">语雀个人/团队主页 URL</label>
377
+ <input id="homeUrl" name="homeUrl" required autofocus spellcheck="false" placeholder="https://www.yuque.com/your-login/" />
378
+ <p class="hint">填写你自己的语雀主页,例如 <code>https://www.yuque.com/your-login/</code>。如果粘贴具体文档 URL,工具只保存第一段用户/团队路径。</p>
379
+ <label for="session">_yuque_session</label>
380
+ <input id="session" name="session" required spellcheck="false" />
381
+ <p class="hint">浏览器登录态 Cookie,通常较长。不要粘贴到聊天或仓库文件。</p>
382
+ <label for="ctoken">yuque_ctoken</label>
383
+ <input id="ctoken" name="ctoken" required spellcheck="false" />
384
+ <p class="hint">CSRF Cookie,会同时作为 x-csrf-token 请求头使用。</p>
385
+ <button type="submit">保存到本机配置</button>
386
+ </form>
387
+ <div class="notice">保存后请运行 <code>npm run yuque-local -- auth-status &lt;语雀URL&gt;</code> 做一次真实检测。</div>
388
+ </main>
389
+ </section>
390
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
391
+ <script>
392
+ if (window.gsap && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
393
+ gsap.from('.shell', { autoAlpha: 0, y: 18, duration: 0.5, ease: 'power2.out' });
394
+ gsap.from('.mark, h1, .side p, .fact, .eyebrow, h2, .lead, form, .notice', { autoAlpha: 0, y: 12, duration: 0.45, ease: 'power1.out', stagger: 0.045, delay: 0.08 });
395
+ }
396
+ </script>
397
+ </body>
398
+ </html>`;
399
+ }
400
+ function successPage() {
401
+ return `<!doctype html>
402
+ <html lang="zh-CN">
403
+ <head>
404
+ <meta charset="utf-8" />
405
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
406
+ <title>Yuque Credentials Saved</title>
407
+ <style>
408
+ :root { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #18202f; }
409
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: linear-gradient(135deg, #f7f8fb 0%, #eef3f1 100%); padding: 20px; }
410
+ main { width: min(640px, 100%); background: #fff; border: 1px solid #d9dee8; border-radius: 8px; padding: 34px; box-shadow: 0 20px 60px rgba(24, 32, 47, .12); }
411
+ .status { display: inline-grid; place-items: center; width: 44px; height: 44px; border-radius: 8px; background: #eaf7ef; color: #16835f; font-weight: 900; margin-bottom: 16px; }
412
+ h1 { margin: 0; font-size: 26px; letter-spacing: 0; }
413
+ p { color: #667085; line-height: 1.65; }
414
+ code { background: #eef1f5; padding: 2px 5px; border-radius: 4px; }
415
+ </style>
416
+ </head>
417
+ <body>
418
+ <main>
419
+ <div class="status">OK</div>
420
+ <h1>凭据已保存</h1>
421
+ <p>配置已写入本机用户目录。请回到终端继续操作,或运行 <code>npm run yuque-local -- auth-status &lt;语雀URL&gt;</code> 进行真实登录态检测。</p>
422
+ </main>
423
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
424
+ <script>
425
+ if (window.gsap && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
426
+ gsap.from('main', { autoAlpha: 0, y: 18, duration: 0.45, ease: 'power2.out' });
427
+ gsap.from('.status, h1, p', { autoAlpha: 0, y: 10, duration: 0.35, ease: 'power1.out', stagger: 0.07, delay: 0.08 });
428
+ }
429
+ </script>
430
+ </body>
431
+ </html>`;
432
+ }
433
+ function escapeHtml(value) {
434
+ return value
435
+ .replace(/&/g, '&amp;')
436
+ .replace(/</g, '&lt;')
437
+ .replace(/>/g, '&gt;')
438
+ .replace(/"/g, '&quot;');
439
+ }