@qcplay/cli 1.0.4 → 1.0.6

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/bin/qcplay.js CHANGED
@@ -1,32 +1,116 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawn } from "child_process";
3
4
  import fs from "fs";
5
+ import http from "http";
6
+ import https from "https";
4
7
  import os from "os";
5
8
  import path from "path";
6
- import { spawn } from "child_process";
7
- import { fileURLToPath } from "url";
9
+ import readline from "readline";
10
+ import { fileURLToPath, pathToFileURL } from "url";
8
11
 
9
12
  const __filename = fileURLToPath(import.meta.url);
10
13
  const __dirname = path.dirname(__filename);
11
14
  const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
12
-
13
- function readPackageVersion() {
14
- try {
15
- const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
16
- return packageJson.version || "0.0.0";
17
- } catch {
18
- return "0.0.0";
15
+ const AUTH_PAGE = resolveAuthPagePath();
16
+ const DEFAULT_AUTH_BACKEND_URL = "https://cli.qcg.ink";
17
+ const DEFAULT_PUBLISH_BACKEND_URL = "https://cli.qcg.ink";
18
+ const DEFAULT_AUTH_PAGE_URL = "https://cli.qcg.ink/auth.html";
19
+ const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8787";
20
+ const ENV_AUTH_BACKEND_URL = process.env.QCPLAY_BACKEND_URL || process.env.QCPLAY_AUTH_BACKEND_URL;
21
+ const ENV_PUBLISH_BACKEND_URL = process.env.QCPLAY_PUBLISH_BACKEND_URL;
22
+ const ENV_AUTH_PAGE_URL = process.env.QCPLAY_AUTH_PAGE_URL;
23
+ const ENV_LOCAL_BASE_URL = process.env.QCPLAY_LOCAL_BASE_URL;
24
+
25
+ function resolveAuthPagePath() {
26
+ const repoAuthPage = path.resolve(__dirname, "../../web/auth.html");
27
+ if (fs.existsSync(repoAuthPage)) {
28
+ return repoAuthPage;
19
29
  }
30
+
31
+ return path.resolve(__dirname, "../web/auth.html");
20
32
  }
21
33
 
22
- const PACKAGE_VERSION = readPackageVersion();
34
+ const CATEGORY_MAP = {
35
+ "2": "2",
36
+ "3": "3",
37
+ "7": "7",
38
+ "8": "8",
39
+ "9": "9",
40
+ "25": "25",
41
+ "26": "26",
42
+ "27": "27",
43
+ "29": "29",
44
+ "综合": "2",
45
+ "活动": "3",
46
+ "游戏攻略": "7",
47
+ "视频中心": "8",
48
+ "萌新入门": "9",
49
+ "萌新入门-攻略专区": "25",
50
+ "高手进阶": "26",
51
+ "活动攻略": "27",
52
+ "视频攻略": "29"
53
+ };
54
+
55
+ const AREA_MAP = {
56
+ "1": "1",
57
+ "3": "3",
58
+ "4": "4",
59
+ pc: "1",
60
+ PC: "1",
61
+ "资料站": "3",
62
+ "公益网站": "4"
63
+ };
64
+
65
+ const GAME_MAP = {
66
+ "39": "39",
67
+ "最强蜗牛": "39"
68
+ };
69
+
70
+ const STATUS_MAP = {
71
+ "0": "0",
72
+ "1": "1",
73
+ "未发布": "0",
74
+ "已发布": "1",
75
+ "发布": "1"
76
+ };
77
+
78
+ const BOOLEAN_TEXT_MAP = {
79
+ "0": "0",
80
+ "1": "1",
81
+ false: "0",
82
+ true: "1",
83
+ no: "0",
84
+ yes: "1",
85
+ "非热门": "0",
86
+ "热门": "1",
87
+ "不推荐": "0",
88
+ "推荐": "1"
89
+ };
23
90
 
24
91
  const QCPLAY_DIR = path.join(os.homedir(), ".qcplay");
25
92
  const AGENTS_DIR = path.join(os.homedir(), ".agents");
26
- const AUTH_FILE = path.join(QCPLAY_DIR, "auth.json");
27
93
  const CONFIG_FILE = path.join(QCPLAY_DIR, "config.json");
28
94
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
29
95
 
96
+ function readPackageMetadata() {
97
+ try {
98
+ const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
99
+ return {
100
+ name: packageJson.name || "@qcplay/cli",
101
+ version: packageJson.version || "0.0.0"
102
+ };
103
+ } catch {
104
+ return {
105
+ name: "@qcplay/cli",
106
+ version: "0.0.0"
107
+ };
108
+ }
109
+ }
110
+
111
+ const PACKAGE_METADATA = readPackageMetadata();
112
+ const PACKAGE_NAME = PACKAGE_METADATA.name;
113
+ const PACKAGE_VERSION = PACKAGE_METADATA.version;
30
114
  const colorsEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
31
115
 
32
116
  const chalk = {
@@ -49,13 +133,14 @@ function printRootHelp() {
49
133
  console.log(`QCPlay CLI
50
134
 
51
135
  Usage:
52
- qcplay-cli install
53
- qcplay-cli auth [login|status|logout]
54
- qcplay-cli auth permissions [--key <key>] [--json]
136
+ qcplay-cli install [--local]
137
+ qcplay-cli update
138
+ qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]
139
+ qcplay-cli auth permissions [--local] [--key <key>] [--json]
55
140
  qcplay-cli article
56
141
  qcplay-cli article init [file]
57
- qcplay-cli article publish <file> [--env <env>] [--url <url>] [--dry-run]
58
- qcplay-cli www-article-list.store <file> [--env <env>] [--url <url>] [--dry-run]
142
+ qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
143
+ qcplay-cli www-article-list.store <file> [--local] [--backend <url>] [--dry-run]
59
144
  qcplay-cli features
60
145
  qcplay-cli where
61
146
 
@@ -65,109 +150,55 @@ Options:
65
150
  `);
66
151
  }
67
152
 
153
+ function printUpdateHelp() {
154
+ console.log("Usage:");
155
+ console.log(" qcplay-cli update");
156
+ }
157
+
68
158
  function printAuthHelp() {
69
159
  console.log("Usage:");
70
- console.log(" qcplay-cli auth");
71
- console.log(" qcplay-cli auth status");
72
- console.log(" qcplay-cli auth logout");
73
- console.log(" qcplay-cli auth permissions [--key <key>] [--json]");
160
+ console.log(" qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]");
161
+ console.log(" qcplay-cli auth permissions [--local] [--backend <url>] [--key <key>] [--json]");
74
162
  }
75
163
 
76
164
  function printArticleHelp() {
77
165
  console.log(`Usage:
78
166
  qcplay-cli article
79
167
  qcplay-cli article init [file]
80
- qcplay-cli article publish <file> [--env <env>] [--url <url>] [--dry-run]`);
81
- }
82
-
83
- function getVendorDir() {
84
- if (process.platform === "win32") {
85
- return path.resolve(__dirname, "../vendor/win32");
86
- }
87
-
88
- if (process.platform === "darwin") {
89
- return path.resolve(__dirname, "../vendor/darwin");
90
- }
91
-
92
- if (process.platform === "linux") {
93
- return path.resolve(__dirname, "../vendor/linux");
94
- }
95
-
96
- throw new Error(`Unsupported platform: ${process.platform}`);
168
+ qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]`);
97
169
  }
98
170
 
99
- function getExeCandidates(name) {
100
- if (process.platform === "win32") {
101
- return [`${name}.exe`, name];
102
- }
103
-
104
- if (process.platform === "darwin") {
105
- return [`${name}-darwin-${process.arch}`, name];
106
- }
107
-
108
- if (process.platform === "linux") {
109
- return [`${name}-linux-${process.arch}`, name];
110
- }
111
-
112
- return [name];
171
+ function printFeatures() {
172
+ console.log("");
173
+ console.log(chalk.green("你现在可以使用以下功能:"));
174
+ console.log("");
175
+ console.log(chalk.cyan("1. 登录认证"));
176
+ console.log(" qcplay-cli auth");
177
+ console.log(chalk.gray(" 会唤起网页登录页,不再在控制台输入密码。"));
178
+ console.log("");
179
+ console.log(chalk.cyan("2. 查看权限"));
180
+ console.log(" qcplay-cli auth permissions");
181
+ console.log(" qcplay-cli auth permissions --key www-article-list.store");
182
+ console.log("");
183
+ console.log(chalk.cyan("3. 发布官网文章"));
184
+ console.log(" qcplay-cli www-article-list.store article.md");
185
+ console.log("");
186
+ console.log(chalk.cyan("4. 查看本地目录"));
187
+ console.log(" qcplay-cli where");
188
+ console.log("");
113
189
  }
114
190
 
115
- function getExePath(name) {
116
- const vendorDir = getVendorDir();
117
- const candidates = getExeCandidates(name);
118
-
119
- for (const candidate of candidates) {
120
- const exePath = path.join(vendorDir, candidate);
121
- if (fs.existsSync(exePath)) {
122
- return exePath;
123
- }
124
- }
125
-
126
- return path.join(vendorDir, candidates[0]);
191
+ function printWhere() {
192
+ console.log(QCPLAY_DIR);
127
193
  }
128
194
 
129
- function runNative(name, args = []) {
130
- return new Promise((resolve, reject) => {
131
- const exePath = getExePath(name);
132
-
133
- if (!fs.existsSync(exePath)) {
134
- reject(new Error(`执行程序不存在: ${exePath}`));
135
- return;
136
- }
137
-
138
- const child = spawn(exePath, args, {
139
- stdio: "inherit",
140
- shell: false
141
- });
142
-
143
- child.on("error", err => {
144
- reject(err);
145
- });
146
-
147
- child.on("exit", code => {
148
- if (code === 0) {
149
- resolve();
150
- } else {
151
- reject(new Error(`${name} 执行失败,退出码: ${code}`));
152
- }
153
- });
195
+ function createReadline() {
196
+ return readline.createInterface({
197
+ input: process.stdin,
198
+ output: process.stdout
154
199
  });
155
200
  }
156
201
 
157
- async function runAuthLogin() {
158
- try {
159
- await runNative("qcplay-auth", []);
160
- } catch (err) {
161
- const message = String(err?.message || err);
162
-
163
- if (!message.includes("退出码")) {
164
- throw err;
165
- }
166
-
167
- await runNative("qcplay-auth", ["login"]);
168
- }
169
- }
170
-
171
202
  async function pathExists(targetPath) {
172
203
  try {
173
204
  await fs.promises.access(targetPath);
@@ -199,16 +230,19 @@ async function copyRecursive(sourcePath, targetPath) {
199
230
 
200
231
  async function writeJson(filePath, value) {
201
232
  await ensureDir(path.dirname(filePath));
202
- await fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
233
+ await fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
234
+ encoding: "utf8",
235
+ mode: 0o600
236
+ });
203
237
  }
204
238
 
205
239
  async function readJson(filePath) {
206
- const raw = await fs.promises.readFile(filePath, "utf8");
207
- return JSON.parse(raw);
208
- }
209
-
210
- async function removeFile(filePath) {
211
- await fs.promises.rm(filePath, { force: true });
240
+ try {
241
+ const raw = await fs.promises.readFile(filePath, "utf8");
242
+ return JSON.parse(raw);
243
+ } catch {
244
+ return {};
245
+ }
212
246
  }
213
247
 
214
248
  async function ensureLocalDirs() {
@@ -216,147 +250,564 @@ async function ensureLocalDirs() {
216
250
  await ensureDir(SKILLS_DIR);
217
251
  }
218
252
 
253
+ async function filesAreEqual(sourcePath, targetPath) {
254
+ try {
255
+ const [sourceContent, targetContent] = await Promise.all([
256
+ fs.promises.readFile(sourcePath),
257
+ fs.promises.readFile(targetPath)
258
+ ]);
259
+ return sourceContent.equals(targetContent);
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ async function syncDirectoryChanges(sourceDir, targetDir, rootDir = sourceDir, changes = { added: [], updated: [] }) {
266
+ await ensureDir(targetDir);
267
+ const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true });
268
+
269
+ for (const entry of entries) {
270
+ const sourcePath = path.join(sourceDir, entry.name);
271
+ const targetPath = path.join(targetDir, entry.name);
272
+
273
+ if (entry.isDirectory()) {
274
+ await syncDirectoryChanges(sourcePath, targetPath, rootDir, changes);
275
+ continue;
276
+ }
277
+
278
+ if (!entry.isFile()) {
279
+ continue;
280
+ }
281
+
282
+ const relativePath = path.relative(rootDir, sourcePath);
283
+ if (!(await pathExists(targetPath))) {
284
+ await ensureDir(path.dirname(targetPath));
285
+ await fs.promises.copyFile(sourcePath, targetPath);
286
+ changes.added.push(relativePath);
287
+ continue;
288
+ }
289
+
290
+ if (await filesAreEqual(sourcePath, targetPath)) {
291
+ continue;
292
+ }
293
+
294
+ await fs.promises.copyFile(sourcePath, targetPath);
295
+ changes.updated.push(relativePath);
296
+ }
297
+
298
+ return changes;
299
+ }
300
+
219
301
  async function installSkills() {
220
302
  const sourceDir = path.resolve(__dirname, "../templates/skills");
221
303
 
222
304
  if (!(await pathExists(sourceDir))) {
223
- return false;
305
+ return {
306
+ available: false,
307
+ added: [],
308
+ updated: []
309
+ };
224
310
  }
225
311
 
226
- await copyRecursive(sourceDir, SKILLS_DIR);
227
- return true;
312
+ const changes = await syncDirectoryChanges(sourceDir, SKILLS_DIR);
313
+ return {
314
+ available: true,
315
+ ...changes
316
+ };
228
317
  }
229
318
 
230
- async function saveConfig() {
319
+ async function saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode = false) {
231
320
  await writeJson(CONFIG_FILE, {
232
321
  cli: "qcplay-cli",
233
322
  version: PACKAGE_VERSION,
234
- auth_file: AUTH_FILE,
323
+ auth_backend_url: authBackendUrl,
324
+ publish_backend_url: publishBackendUrl,
325
+ auth_page_url: authPageUrl || "",
326
+ local_mode: localMode,
235
327
  skills_dir: SKILLS_DIR,
236
328
  configured_at: Date.now()
237
329
  });
238
330
  }
239
331
 
240
- async function getLoginStatus() {
241
- if (!(await pathExists(AUTH_FILE))) {
242
- return {
243
- ok: false,
244
- reason: `认证文件不存在: ${AUTH_FILE}`
245
- };
332
+ async function loadConfig() {
333
+ return readJson(CONFIG_FILE);
334
+ }
335
+
336
+ async function updateStoredConfigVersion(version) {
337
+ const config = await loadConfig();
338
+ if (Object.keys(config).length === 0) {
339
+ return;
340
+ }
341
+
342
+ await writeJson(CONFIG_FILE, {
343
+ ...config,
344
+ version,
345
+ updated_at: Date.now()
346
+ });
347
+ }
348
+
349
+ function normalizeText(value) {
350
+ return String(value ?? "").trim();
351
+ }
352
+
353
+ function normalizeMappedValue(value, map) {
354
+ const text = normalizeText(value);
355
+ if (!text) {
356
+ return "";
357
+ }
358
+
359
+ return map[text] ?? map[text.toLowerCase?.() || ""] ?? text;
360
+ }
361
+
362
+ function normalizeBoolLike(value, defaultValue = "0") {
363
+ const text = normalizeText(value);
364
+ if (!text) {
365
+ return defaultValue;
366
+ }
367
+
368
+ return BOOLEAN_TEXT_MAP[text] ?? BOOLEAN_TEXT_MAP[text.toLowerCase()] ?? text;
369
+ }
370
+
371
+ function parseBackendOptions(args) {
372
+ const options = {
373
+ backend: undefined,
374
+ authPage: undefined,
375
+ local: false
376
+ };
377
+ const passthrough = [];
378
+
379
+ for (let index = 0; index < args.length; index += 1) {
380
+ const current = args[index];
381
+
382
+ if (current === "--backend") {
383
+ const next = args[index + 1];
384
+ if (!next || next.startsWith("--")) {
385
+ throw new Error("--backend 缺少参数值");
386
+ }
387
+
388
+ options.backend = next;
389
+ index += 1;
390
+ continue;
391
+ }
392
+
393
+ if (current === "--auth-page") {
394
+ const next = args[index + 1];
395
+ if (!next || next.startsWith("--")) {
396
+ throw new Error("--auth-page 缺少参数值");
397
+ }
398
+
399
+ options.authPage = next;
400
+ index += 1;
401
+ continue;
402
+ }
403
+
404
+ if (current === "--local") {
405
+ options.local = true;
406
+ continue;
407
+ }
408
+
409
+ passthrough.push(current);
410
+ }
411
+
412
+ return {
413
+ options,
414
+ args: passthrough
415
+ };
416
+ }
417
+
418
+ function parsePermissionsOptions(args) {
419
+ const options = {
420
+ key: undefined,
421
+ json: false
422
+ };
423
+
424
+ for (let index = 0; index < args.length; index += 1) {
425
+ const current = args[index];
426
+
427
+ if (current === "--json") {
428
+ options.json = true;
429
+ continue;
430
+ }
431
+
432
+ if (current === "--key") {
433
+ const next = args[index + 1];
434
+ if (!next || next.startsWith("--")) {
435
+ throw new Error("--key 缺少参数值");
436
+ }
437
+
438
+ options.key = next;
439
+ index += 1;
440
+ continue;
441
+ }
442
+
443
+ throw new Error(`未知参数: ${current}`);
444
+ }
445
+
446
+ return options;
447
+ }
448
+
449
+ function parsePublishOptions(args) {
450
+ const options = {
451
+ backend: undefined,
452
+ local: false,
453
+ dryRun: false
454
+ };
455
+ const positional = [];
456
+
457
+ for (let index = 0; index < args.length; index += 1) {
458
+ const current = args[index];
459
+
460
+ if (current === "--dry-run") {
461
+ options.dryRun = true;
462
+ continue;
463
+ }
464
+
465
+ if (current === "--backend") {
466
+ const next = args[index + 1];
467
+ if (!next || next.startsWith("--")) {
468
+ throw new Error("--backend 缺少参数值");
469
+ }
470
+
471
+ options.backend = next;
472
+ index += 1;
473
+ continue;
474
+ }
475
+
476
+ if (current === "--local") {
477
+ options.local = true;
478
+ continue;
479
+ }
480
+
481
+ if (current.startsWith("--")) {
482
+ throw new Error(`未知参数: ${current}`);
483
+ }
484
+
485
+ positional.push(current);
486
+ }
487
+
488
+ return {
489
+ file: positional[0],
490
+ options
491
+ };
492
+ }
493
+
494
+ function requestJson(method, baseUrl, pathname, payload) {
495
+ return new Promise((resolve, reject) => {
496
+ const url = new URL(pathname, baseUrl);
497
+ const client = url.protocol === "https:" ? https : http;
498
+ const body = payload === undefined ? undefined : JSON.stringify(payload);
499
+
500
+ const req = client.request(
501
+ url,
502
+ {
503
+ method,
504
+ headers: {
505
+ Accept: "application/json",
506
+ "Content-Type": "application/json",
507
+ "User-Agent": `qcplay-cli/${PACKAGE_VERSION}`,
508
+ ...(body ? { "Content-Length": Buffer.byteLength(body) } : {})
509
+ }
510
+ },
511
+ res => {
512
+ const chunks = [];
513
+ res.on("data", chunk => chunks.push(chunk));
514
+ res.on("end", () => {
515
+ const text = Buffer.concat(chunks).toString("utf8");
516
+ let json = {};
517
+
518
+ try {
519
+ json = text ? JSON.parse(text) : {};
520
+ } catch {
521
+ reject(new Error(`后端返回了非 JSON 内容: ${text.slice(0, 160)}`));
522
+ return;
523
+ }
524
+
525
+ if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
526
+ reject(new Error(json.message || `接口请求失败 (${res.statusCode})`));
527
+ return;
528
+ }
529
+
530
+ resolve(json);
531
+ });
532
+ }
533
+ );
534
+
535
+ req.on("error", err => {
536
+ reject(new Error(`无法连接后端服务 ${baseUrl},请检查服务地址或网络连接。原始错误: ${err.message}`));
537
+ });
538
+
539
+ if (body) {
540
+ req.write(body);
541
+ }
542
+
543
+ req.end();
544
+ });
545
+ }
546
+
547
+ function openBrowser(targetUrl) {
548
+ return new Promise((resolve, reject) => {
549
+ let command;
550
+ let args;
551
+
552
+ if (process.platform === "win32") {
553
+ command = "cmd";
554
+ args = ["/c", "start", "", targetUrl];
555
+ } else if (process.platform === "darwin") {
556
+ command = "open";
557
+ args = [targetUrl];
558
+ } else {
559
+ command = "xdg-open";
560
+ args = [targetUrl];
561
+ }
562
+
563
+ const child = spawn(command, args, {
564
+ detached: true,
565
+ stdio: "ignore"
566
+ });
567
+
568
+ child.on("error", reject);
569
+ child.unref();
570
+ resolve();
571
+ });
572
+ }
573
+
574
+ function getNpmCommand() {
575
+ return process.platform === "win32" ? "npm.cmd" : "npm";
576
+ }
577
+
578
+ function runCommand(command, args) {
579
+ return new Promise((resolve, reject) => {
580
+ const child = spawn(command, args, {
581
+ stdio: "inherit"
582
+ });
583
+
584
+ child.on("error", reject);
585
+ child.on("close", code => {
586
+ if (code === 0) {
587
+ resolve();
588
+ return;
589
+ }
590
+
591
+ reject(new Error(`命令执行失败 (${code}): ${command} ${args.join(" ")}`));
592
+ });
593
+ });
594
+ }
595
+
596
+ function delay(ms) {
597
+ return new Promise(resolve => {
598
+ setTimeout(resolve, ms);
599
+ });
600
+ }
601
+
602
+ function normalizeOptionalUrl(value) {
603
+ const text = normalizeText(value);
604
+ return text || "";
605
+ }
606
+
607
+ function ensureAbsoluteUrl(value, flagName) {
608
+ try {
609
+ return new URL(value).toString();
610
+ } catch {
611
+ throw new Error(`${flagName} 必须是完整 URL,例如 https://example.com/auth.html`);
612
+ }
613
+ }
614
+
615
+ function resolveLocalBaseUrl() {
616
+ const localBaseUrl = normalizeOptionalUrl(ENV_LOCAL_BASE_URL) || DEFAULT_LOCAL_BASE_URL;
617
+ return ensureAbsoluteUrl(localBaseUrl, "QCPLAY_LOCAL_BASE_URL");
618
+ }
619
+
620
+ function isLocalMode(options, config = {}) {
621
+ return Boolean(options.local || config.local_mode);
622
+ }
623
+
624
+ function resolveAuthBackendUrl(options, config = {}) {
625
+ if (!normalizeOptionalUrl(options.backend) && isLocalMode(options, config)) {
626
+ return resolveLocalBaseUrl();
627
+ }
628
+
629
+ const backendUrl =
630
+ normalizeOptionalUrl(options.backend) ||
631
+ normalizeOptionalUrl(ENV_AUTH_BACKEND_URL) ||
632
+ normalizeOptionalUrl(config.auth_backend_url) ||
633
+ normalizeOptionalUrl(config.backend_url) ||
634
+ DEFAULT_AUTH_BACKEND_URL;
635
+ return ensureAbsoluteUrl(backendUrl, "--backend");
636
+ }
637
+
638
+ function resolveAuthPageUrl(options, config = {}) {
639
+ if (!normalizeOptionalUrl(options.authPage) && isLocalMode(options, config)) {
640
+ return "";
641
+ }
642
+
643
+ const authPageUrl =
644
+ normalizeOptionalUrl(options.authPage) ||
645
+ normalizeOptionalUrl(ENV_AUTH_PAGE_URL) ||
646
+ normalizeOptionalUrl(config.auth_page_url) ||
647
+ DEFAULT_AUTH_PAGE_URL;
648
+ return authPageUrl ? ensureAbsoluteUrl(authPageUrl, "--auth-page") : "";
649
+ }
650
+
651
+ function resolvePublishBackendUrl(options, config = {}) {
652
+ if (!normalizeOptionalUrl(options.backend) && isLocalMode(options, config)) {
653
+ return resolveLocalBaseUrl();
246
654
  }
247
655
 
248
- let auth;
656
+ const backendUrl =
657
+ normalizeOptionalUrl(options.backend) ||
658
+ normalizeOptionalUrl(ENV_PUBLISH_BACKEND_URL) ||
659
+ normalizeOptionalUrl(config.publish_backend_url) ||
660
+ DEFAULT_PUBLISH_BACKEND_URL;
661
+ return ensureAbsoluteUrl(backendUrl, "--backend");
662
+ }
249
663
 
250
- try {
251
- auth = await readJson(AUTH_FILE);
252
- } catch (err) {
253
- return {
254
- ok: false,
255
- reason: `认证文件不是合法 JSON: ${err.message}`
256
- };
257
- }
664
+ async function fetchLoginSuccessSequence(baseUrl) {
665
+ const response = await requestJson("GET", baseUrl, "/api/auth/login-success");
666
+ return Number(response.data?.sequence || 0);
667
+ }
258
668
 
259
- const accessToken =
260
- auth.access_token ||
261
- auth.accessToken ||
262
- auth.token ||
263
- auth.data?.access_token ||
264
- auth.data?.accessToken;
669
+ function isNonJsonResponseError(error) {
670
+ return String(error?.message || "").includes("后端返回了非 JSON 内容");
671
+ }
265
672
 
266
- if (!accessToken) {
267
- return {
268
- ok: false,
269
- reason: "认证文件中没有找到 access_token"
270
- };
673
+ function buildAuthBackendError(baseUrl, error) {
674
+ if (isNonJsonResponseError(error)) {
675
+ return new Error(
676
+ `认证接口 ${baseUrl} 返回了 HTML 页面,不是 JSON。请确认 ${baseUrl}/api/auth/login、${baseUrl}/api/auth/login-success、${baseUrl}/api/auth/status 已正确部署,而不是被站点首页或登录页接管。`
677
+ );
271
678
  }
272
679
 
273
- let expiresAt =
274
- auth.expires_at ||
275
- auth.expiresAt ||
276
- auth.data?.expires_at ||
277
- auth.data?.expiresAt;
680
+ return error;
681
+ }
278
682
 
279
- if (expiresAt) {
280
- expiresAt = Number(expiresAt);
683
+ async function waitForLoginSuccessSequence(baseUrl, previousSequence, intervalMs = 1000, timeoutMs = 60000) {
684
+ const startedAt = Date.now();
685
+ let lastError;
281
686
 
282
- if (expiresAt > 0 && expiresAt < 1000000000000) {
283
- expiresAt = expiresAt * 1000;
687
+ while (true) {
688
+ try {
689
+ const nextSequence = await fetchLoginSuccessSequence(baseUrl);
690
+ if (nextSequence > previousSequence) {
691
+ return nextSequence;
692
+ }
693
+ } catch (error) {
694
+ lastError = error;
284
695
  }
285
696
 
286
- if (Date.now() >= expiresAt) {
287
- return {
288
- ok: false,
289
- reason: `登录状态已过期: ${new Date(expiresAt).toLocaleString()}`
290
- };
697
+ if (Date.now() - startedAt >= timeoutMs) {
698
+ if (lastError) {
699
+ throw buildAuthBackendError(baseUrl, lastError);
700
+ }
701
+
702
+ throw new Error(`等待登录结果超时,请确认认证接口 ${baseUrl} 可用`);
291
703
  }
292
- }
293
704
 
294
- return {
295
- ok: true,
296
- auth
297
- };
705
+ await delay(intervalMs);
706
+ }
298
707
  }
299
708
 
300
- function printFeatures() {
301
- console.log("");
302
- console.log(chalk.green("你现在可以使用以下功能:"));
303
- console.log("");
709
+ function buildAuthPageUrl(backendUrl, authPageUrl = "") {
710
+ const targetUrl = authPageUrl ? new URL(authPageUrl) : pathToFileURL(AUTH_PAGE);
304
711
 
305
- console.log(chalk.cyan("1. 登录认证"));
306
- console.log(" qcplay-cli auth");
307
- console.log("");
712
+ if (shouldAttachBackendQuery(targetUrl, backendUrl)) {
713
+ targetUrl.searchParams.set("backend", backendUrl);
714
+ }
308
715
 
309
- console.log(chalk.cyan("2. 查看权限"));
310
- console.log(" qcplay-cli auth permissions");
311
- console.log(" qcplay-cli auth permissions --key www-article-list.store");
312
- console.log("");
716
+ return targetUrl.toString();
717
+ }
313
718
 
314
- console.log(chalk.cyan("4. 发布官网文章"));
315
- console.log(" qcplay-cli www-article-list.store article.md");
316
- console.log(chalk.gray(" 文章标题、分类、缩略图、标签等信息写在 article.md 顶部 Front Matter 中。"));
317
- console.log("");
719
+ function shouldAttachBackendQuery(targetUrl, backendUrl) {
720
+ if (!backendUrl) {
721
+ return false;
722
+ }
318
723
 
319
- console.log(chalk.cyan("5. 查看本地配置目录"));
320
- console.log(" qcplay-cli where");
321
- console.log("");
724
+ const normalizedBackendUrl = new URL(backendUrl);
725
+ if (targetUrl.protocol === "file:") {
726
+ return normalizedBackendUrl.origin !== new URL(DEFAULT_LOCAL_BASE_URL).origin;
727
+ }
728
+
729
+ return normalizedBackendUrl.origin !== targetUrl.origin;
322
730
  }
323
731
 
324
- function printWhere() {
325
- console.log(QCPLAY_DIR);
732
+ function flattenPermissionTree(items, bucket = []) {
733
+ for (const item of items) {
734
+ bucket.push(item);
735
+ if (Array.isArray(item.children) && item.children.length > 0) {
736
+ flattenPermissionTree(item.children, bucket);
737
+ }
738
+ }
739
+
740
+ return bucket;
326
741
  }
327
742
 
328
- function parsePermissionsOptions(args) {
329
- const options = {
330
- key: undefined,
331
- json: false
332
- };
743
+ function printPermissionTree(items) {
744
+ const flatItems = flattenPermissionTree(items);
745
+ const indexed = new Map();
746
+ const children = new Map();
747
+ const roots = [];
333
748
 
334
- for (let index = 0; index < args.length; index += 1) {
335
- const current = args[index];
749
+ for (const item of flatItems) {
750
+ const id = Number(item.id ?? 0);
751
+ indexed.set(id || item.key, item);
752
+ }
336
753
 
337
- if (current === "--json") {
338
- options.json = true;
754
+ for (const item of flatItems) {
755
+ const parentId = Number(item.menu_id ?? item.parent_id ?? 0);
756
+ if (parentId && indexed.has(parentId)) {
757
+ if (!children.has(parentId)) {
758
+ children.set(parentId, []);
759
+ }
760
+
761
+ children.get(parentId).push(item);
339
762
  continue;
340
763
  }
341
764
 
342
- if (current === "--key") {
343
- const next = args[index + 1];
344
- if (!next || next.startsWith("--")) {
345
- throw new Error("--key 缺少参数值");
346
- }
765
+ roots.push(item);
766
+ }
347
767
 
348
- options.key = next;
349
- index += 1;
350
- continue;
768
+ if (roots.length === 0) {
769
+ console.log(chalk.yellow("未返回任何权限"));
770
+ return;
771
+ }
772
+
773
+ for (const item of roots) {
774
+ console.log(item.key);
775
+ const nested = children.get(Number(item.id ?? 0)) || [];
776
+ for (const child of nested) {
777
+ console.log(` - ${child.key}`);
351
778
  }
779
+ }
780
+ }
352
781
 
353
- throw new Error(`未知参数: ${current}`);
782
+ function formatAccountSummary(data = {}) {
783
+ const id = Number(data.id || 0);
784
+ const name = normalizeText(data.name) || "未知账号";
785
+ const type = normalizeText(data.type);
786
+ const status = normalizeText(data.status);
787
+ const parts = [name];
788
+
789
+ if (id > 0) {
790
+ parts.push(`id=${id}`);
354
791
  }
355
792
 
356
- return options;
793
+ if (type) {
794
+ parts.push(`type=${type}`);
795
+ }
796
+
797
+ if (status) {
798
+ parts.push(`status=${status}`);
799
+ }
800
+
801
+ return parts.join(" | ");
357
802
  }
358
803
 
359
- async function installCommand() {
804
+ async function installCommand(options) {
805
+ const config = await loadConfig();
806
+ const localMode = isLocalMode(options, config);
807
+ const authBackendUrl = resolveAuthBackendUrl(options, config);
808
+ const publishBackendUrl = resolvePublishBackendUrl(options, config);
809
+ const authPageUrl = resolveAuthPageUrl(options, config);
810
+
360
811
  console.log("");
361
812
  console.log(chalk.cyan("正在安装 QCPlay CLI..."));
362
813
  console.log("");
@@ -365,177 +816,435 @@ async function installCommand() {
365
816
  console.log(chalk.green("✔ 本地目录已创建"));
366
817
 
367
818
  const skillsInstalled = await installSkills();
368
- if (skillsInstalled) {
819
+ if (skillsInstalled.available) {
369
820
  console.log(chalk.green("✔ Skills 已安装"));
370
821
  console.log(`Skills 目录: ${chalk.gray(SKILLS_DIR)}`);
371
822
  } else {
372
823
  console.log(chalk.yellow("! 未找到 Skills 模板,已跳过"));
373
824
  }
374
825
 
375
- await saveConfig();
826
+ await saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode);
376
827
  console.log(chalk.green("✔ 配置文件已生成"));
377
-
828
+ console.log(chalk.gray(`认证后端: ${authBackendUrl}`));
829
+ console.log(chalk.gray(`发布后端: ${publishBackendUrl}`));
830
+ console.log(chalk.gray(`登录页地址: ${authPageUrl || "本地内置页面"}`));
378
831
  console.log("");
379
- console.log(chalk.cyan("正在打开登录窗口..."));
380
- console.log(chalk.gray("请在浏览器中完成账号密码登录。"));
832
+ console.log("安装完成后可直接执行:");
833
+ console.log(" qcplay-cli auth");
381
834
  console.log("");
835
+ }
836
+
837
+ function printSkillsSyncResult(result) {
838
+ if (!result.available) {
839
+ console.log(chalk.yellow("! 未找到 Skills 模板,已跳过"));
840
+ return;
841
+ }
382
842
 
383
- await runAuthLogin();
843
+ const addedCount = result.added.length;
844
+ const updatedCount = result.updated.length;
845
+ if (addedCount === 0 && updatedCount === 0) {
846
+ console.log(chalk.green("✔ Skills 已检查,无新增或变更"));
847
+ console.log(`Skills 目录: ${chalk.gray(SKILLS_DIR)}`);
848
+ return;
849
+ }
384
850
 
385
- const loginStatus = await getLoginStatus();
386
- if (!loginStatus.ok) {
387
- throw new Error(`登录流程结束,但未检测到有效认证文件。\n原因: ${loginStatus.reason}`);
851
+ const parts = [];
852
+ if (addedCount > 0) {
853
+ parts.push(`新增 ${addedCount} 个文件`);
854
+ }
855
+ if (updatedCount > 0) {
856
+ parts.push(`更新 ${updatedCount} 个文件`);
388
857
  }
389
858
 
859
+ console.log(chalk.green(`✔ Skills 已同步(${parts.join(",")})`));
860
+ console.log(`Skills 目录: ${chalk.gray(SKILLS_DIR)}`);
861
+ }
862
+
863
+ async function updateCommand() {
390
864
  console.log("");
391
- console.log(chalk.green(" QCPlay CLI 安装完成"));
865
+ console.log(chalk.cyan("正在更新 QCPlay CLI..."));
866
+ console.log(chalk.gray(`当前版本: ${PACKAGE_VERSION}`));
867
+ console.log("");
868
+
869
+ await ensureLocalDirs();
870
+ await runCommand(getNpmCommand(), ["install", "-g", `${PACKAGE_NAME}@latest`]);
871
+
872
+ const latestPackage = readPackageMetadata();
873
+ await updateStoredConfigVersion(latestPackage.version);
874
+ const skillsInstalled = await installSkills();
392
875
 
393
- printFeatures();
876
+ console.log("");
877
+ console.log(chalk.green("CLI 更新完成"));
878
+ if (latestPackage.version !== PACKAGE_VERSION) {
879
+ console.log(chalk.gray(`版本: ${PACKAGE_VERSION} -> ${latestPackage.version}`));
880
+ } else {
881
+ console.log(chalk.gray(`版本: ${latestPackage.version}`));
882
+ }
883
+ printSkillsSyncResult(skillsInstalled);
884
+ console.log("");
394
885
  }
395
886
 
396
- async function authCommand(action, args = []) {
887
+ async function authCommand(rawArgs = []) {
888
+ const parsed = parseBackendOptions(rawArgs);
889
+ const options = parsed.options;
890
+ const args = parsed.args;
891
+ const action = args[0];
892
+ const config = await loadConfig();
893
+ const backendUrl = resolveAuthBackendUrl(options, config);
894
+ const authPageUrl = resolveAuthPageUrl(options, config);
895
+
397
896
  if (!action || action === "login") {
398
- console.log("");
399
- console.log(chalk.cyan("正在打开 QCPlay 登录窗口..."));
400
- console.log("");
897
+ const loginPageUrl = buildAuthPageUrl(backendUrl, authPageUrl);
898
+ let previousSequence = 0;
899
+
900
+ console.log(chalk.cyan("正在登录中"));
901
+ try {
902
+ await openBrowser(loginPageUrl);
903
+ } catch {
904
+ console.log(chalk.yellow("自动打开浏览器失败,请手动打开下面地址:"));
905
+ console.log(chalk.gray(loginPageUrl));
906
+ }
401
907
 
402
- await runAuthLogin();
908
+ try {
909
+ previousSequence = await fetchLoginSuccessSequence(backendUrl);
910
+ } catch {}
403
911
 
404
- const loginStatus = await getLoginStatus();
405
- if (!loginStatus.ok) {
406
- throw new Error(`登录失败。\n原因: ${loginStatus.reason}`);
912
+ await waitForLoginSuccessSequence(backendUrl, previousSequence);
913
+ const statusResponse = await requestJson("GET", backendUrl, "/api/auth/status");
914
+ console.log(chalk.green("登录成功"));
915
+ if (statusResponse.data?.logged_in) {
916
+ console.log(chalk.gray(`当前账号: ${formatAccountSummary(statusResponse.data)}`));
407
917
  }
408
-
409
918
  return;
410
919
  }
411
920
 
412
921
  if (action === "status") {
413
- const loginStatus = await getLoginStatus();
414
-
415
- if (!loginStatus.ok) {
416
- console.log(chalk.yellow("未登录或登录无效"));
417
- console.log(`原因: ${loginStatus.reason}`);
418
- console.log("");
419
- console.log("请执行:");
420
- console.log(" qcplay-cli auth");
922
+ const response = await requestJson("GET", backendUrl, "/api/auth/status");
923
+ if (response.data?.logged_in) {
924
+ console.log(chalk.green("已登录"));
925
+ console.log(chalk.gray(`当前账号: ${formatAccountSummary(response.data)}`));
421
926
  return;
422
927
  }
423
928
 
424
- console.log(chalk.green("已登录"));
929
+ console.log(chalk.yellow("未登录"));
425
930
  return;
426
931
  }
427
932
 
428
933
  if (action === "logout") {
429
- await removeFile(AUTH_FILE);
430
- console.log(chalk.green("已退出登录"));
934
+ const response = await requestJson("POST", backendUrl, "/api/auth/logout", {});
935
+ console.log(chalk.green(response.message || "已退出登录"));
431
936
  return;
432
937
  }
433
938
 
434
939
  if (action === "permissions") {
435
- const loginStatus = await getLoginStatus();
436
-
437
- if (!loginStatus.ok) {
438
- throw new Error(`未检测到有效登录状态。\n原因: ${loginStatus.reason}\n请先执行:qcplay-cli auth`);
439
- }
940
+ const flags = parsePermissionsOptions(args.slice(1));
941
+ const response = await requestJson("GET", backendUrl, "/api/permissions");
942
+ let permissions = response.data || [];
440
943
 
441
- const options = parsePermissionsOptions(args);
442
- const nativeArgs = ["permissions"];
443
-
444
- if (options.key) {
445
- nativeArgs.push("--key", options.key);
944
+ if (flags.key) {
945
+ permissions = permissions.filter(item => item.key === flags.key || item.key.startsWith(`${flags.key}.`));
446
946
  }
447
947
 
448
- if (options.json) {
449
- nativeArgs.push("--json");
948
+ if (flags.json) {
949
+ console.log(JSON.stringify(permissions, null, 2));
950
+ return;
450
951
  }
451
952
 
452
- await runNative("qcplay-auth", nativeArgs);
953
+ printPermissionTree(permissions);
453
954
  return;
454
955
  }
455
956
 
456
957
  printAuthHelp();
457
958
  }
458
959
 
459
- function parsePublishOptions(args) {
460
- const options = {
461
- env: undefined,
462
- url: undefined,
463
- dryRun: false
464
- };
465
- const positional = [];
960
+ function unquoteValue(input) {
961
+ const trimmed = String(input).trim();
962
+ if (
963
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
964
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
965
+ ) {
966
+ return trimmed.slice(1, -1);
967
+ }
466
968
 
467
- for (let index = 0; index < args.length; index += 1) {
468
- const current = args[index];
969
+ return trimmed;
970
+ }
469
971
 
470
- if (current === "--dry-run") {
471
- options.dryRun = true;
472
- continue;
972
+ function stripBom(input) {
973
+ return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
974
+ }
975
+
976
+ function escapeHtml(value) {
977
+ return String(value)
978
+ .replaceAll("&", "&amp;")
979
+ .replaceAll("<", "&lt;")
980
+ .replaceAll(">", "&gt;")
981
+ .replaceAll('"', "&quot;")
982
+ .replaceAll("'", "&#39;");
983
+ }
984
+
985
+ function applyInlineMarkdown(text) {
986
+ const tokens = [];
987
+ let output = String(text);
988
+
989
+ function storeToken(value) {
990
+ const token = `@@MDTOKEN${tokens.length}@@`;
991
+ tokens.push(value);
992
+ return token;
993
+ }
994
+
995
+ output = output.replace(/`([^`]+)`/g, (_, code) => {
996
+ return storeToken(`<code>${escapeHtml(code)}</code>`);
997
+ });
998
+
999
+ output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
1000
+ return storeToken(`<img src="${escapeHtml(url.trim())}" alt="${escapeHtml(alt.trim())}" />`);
1001
+ });
1002
+
1003
+ output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
1004
+ return storeToken(`<a href="${escapeHtml(url.trim())}" target="_blank" rel="noreferrer">${escapeHtml(label.trim())}</a>`);
1005
+ });
1006
+
1007
+ output = escapeHtml(output);
1008
+
1009
+ output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1010
+ output = output.replace(/__([^_]+)__/g, "<strong>$1</strong>");
1011
+ output = output.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
1012
+ output = output.replace(/(?<!_)_([^_]+)_(?!_)/g, "<em>$1</em>");
1013
+
1014
+ for (let index = 0; index < tokens.length; index += 1) {
1015
+ output = output.replace(`@@MDTOKEN${index}@@`, tokens[index]);
1016
+ }
1017
+
1018
+ return output;
1019
+ }
1020
+
1021
+ function markdownToHtml(markdown) {
1022
+ const lines = stripBom(markdown).replace(/\r\n/g, "\n").split("\n");
1023
+ const html = [];
1024
+ let paragraph = [];
1025
+ let quote = [];
1026
+ let listType = "";
1027
+ let listItems = [];
1028
+ let inCodeBlock = false;
1029
+ let codeLines = [];
1030
+
1031
+ function flushParagraph() {
1032
+ if (paragraph.length === 0) {
1033
+ return;
473
1034
  }
474
1035
 
475
- if (current === "--env" || current === "--url") {
476
- const next = args[index + 1];
477
- if (!next || next.startsWith("--")) {
478
- throw new Error(`${current} 缺少参数值`);
479
- }
1036
+ html.push(`<p>${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`);
1037
+ paragraph = [];
1038
+ }
1039
+
1040
+ function flushQuote() {
1041
+ if (quote.length === 0) {
1042
+ return;
1043
+ }
1044
+
1045
+ html.push(`<blockquote><p>${quote.map(line => applyInlineMarkdown(line)).join("<br />")}</p></blockquote>`);
1046
+ quote = [];
1047
+ }
1048
+
1049
+ function flushList() {
1050
+ if (!listType || listItems.length === 0) {
1051
+ listType = "";
1052
+ listItems = [];
1053
+ return;
1054
+ }
1055
+
1056
+ html.push(`<${listType}>${listItems.map(item => `<li>${applyInlineMarkdown(item)}</li>`).join("")}</${listType}>`);
1057
+ listType = "";
1058
+ listItems = [];
1059
+ }
1060
+
1061
+ function flushCodeBlock() {
1062
+ if (!inCodeBlock) {
1063
+ return;
1064
+ }
1065
+
1066
+ html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
1067
+ inCodeBlock = false;
1068
+ codeLines = [];
1069
+ }
1070
+
1071
+ function flushAll() {
1072
+ flushParagraph();
1073
+ flushQuote();
1074
+ flushList();
1075
+ }
1076
+
1077
+ for (const line of lines) {
1078
+ const trimmed = line.trim();
480
1079
 
481
- if (current === "--env") {
482
- options.env = next;
1080
+ if (trimmed.startsWith("```")) {
1081
+ if (inCodeBlock) {
1082
+ flushCodeBlock();
483
1083
  } else {
484
- options.url = next;
1084
+ flushAll();
1085
+ inCodeBlock = true;
1086
+ codeLines = [];
485
1087
  }
1088
+ continue;
1089
+ }
486
1090
 
487
- index += 1;
1091
+ if (inCodeBlock) {
1092
+ codeLines.push(line);
488
1093
  continue;
489
1094
  }
490
1095
 
491
- if (current.startsWith("--")) {
492
- throw new Error(`未知参数: ${current}`);
1096
+ if (!trimmed) {
1097
+ flushAll();
1098
+ continue;
493
1099
  }
494
1100
 
495
- positional.push(current);
1101
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
1102
+ if (headingMatch) {
1103
+ flushAll();
1104
+ const level = headingMatch[1].length;
1105
+ html.push(`<h${level}>${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`);
1106
+ continue;
1107
+ }
1108
+
1109
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
1110
+ flushAll();
1111
+ html.push("<hr />");
1112
+ continue;
1113
+ }
1114
+
1115
+ const quoteMatch = line.match(/^\s*>\s?(.*)$/);
1116
+ if (quoteMatch) {
1117
+ flushParagraph();
1118
+ flushList();
1119
+ quote.push(quoteMatch[1]);
1120
+ continue;
1121
+ }
1122
+
1123
+ const unorderedMatch = line.match(/^\s*[-*+]\s+(.+)$/);
1124
+ if (unorderedMatch) {
1125
+ flushParagraph();
1126
+ flushQuote();
1127
+ if (listType && listType !== "ul") {
1128
+ flushList();
1129
+ }
1130
+ listType = "ul";
1131
+ listItems.push(unorderedMatch[1].trim());
1132
+ continue;
1133
+ }
1134
+
1135
+ const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
1136
+ if (orderedMatch) {
1137
+ flushParagraph();
1138
+ flushQuote();
1139
+ if (listType && listType !== "ol") {
1140
+ flushList();
1141
+ }
1142
+ listType = "ol";
1143
+ listItems.push(orderedMatch[1].trim());
1144
+ continue;
1145
+ }
1146
+
1147
+ flushQuote();
1148
+ flushList();
1149
+ paragraph.push(trimmed);
496
1150
  }
497
1151
 
498
- return {
499
- file: positional[0],
500
- options
501
- };
1152
+ flushCodeBlock();
1153
+ flushAll();
1154
+ return html.join("\n");
502
1155
  }
503
1156
 
504
- async function publishArticleCommand(file, options) {
505
- if (!file) {
506
- throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
1157
+ function parseFrontMatter(raw) {
1158
+ const normalized = stripBom(raw).replace(/\r\n/g, "\n");
1159
+ const lines = normalized.split("\n");
1160
+
1161
+ if (lines[0]?.trim() !== "---") {
1162
+ throw new Error("文章文件缺少 Front Matter,首行必须是 ---");
507
1163
  }
508
1164
 
509
- const articleFile = path.resolve(process.cwd(), file);
510
- if (!(await pathExists(articleFile))) {
511
- throw new Error(`文章文件不存在: ${articleFile}`);
1165
+ let dividerIndex = -1;
1166
+ for (let index = 1; index < lines.length; index += 1) {
1167
+ if (lines[index].trim() === "---") {
1168
+ dividerIndex = index;
1169
+ break;
1170
+ }
512
1171
  }
513
1172
 
514
- const loginStatus = await getLoginStatus();
515
- if (!loginStatus.ok) {
516
- throw new Error(`未检测到有效登录状态。\n原因: ${loginStatus.reason}\n请先执行:qcplay-cli auth`);
1173
+ if (dividerIndex === -1) {
1174
+ throw new Error("文章文件缺少 Front Matter 结束标记 ---");
517
1175
  }
518
1176
 
519
- const args = ["--content-file", articleFile];
1177
+ const meta = {};
1178
+ for (const line of lines.slice(1, dividerIndex)) {
1179
+ if (!line.trim()) {
1180
+ continue;
1181
+ }
1182
+
1183
+ const separatorIndex = line.indexOf(":");
1184
+ if (separatorIndex === -1) {
1185
+ throw new Error(`Front Matter 格式错误: ${line}`);
1186
+ }
1187
+
1188
+ meta[line.slice(0, separatorIndex).trim()] = unquoteValue(line.slice(separatorIndex + 1));
1189
+ }
1190
+
1191
+ return {
1192
+ meta,
1193
+ content: lines.slice(dividerIndex + 1).join("\n").trim()
1194
+ };
1195
+ }
1196
+
1197
+ function normalizeArticlePayload(meta, content) {
1198
+ const payload = {
1199
+ article_title: normalizeText(meta.article_title),
1200
+ thumbnail: normalizeText(meta.thumbnail),
1201
+ move_thumbnail: normalizeText(meta.move_thumbnail),
1202
+ article_content: markdownToHtml(content),
1203
+ article_excerpt: normalizeText(meta.article_excerpt),
1204
+ article_url: normalizeText(meta.article_url),
1205
+ origin: normalizeText(meta.origin),
1206
+ status: normalizeMappedValue(meta.status, STATUS_MAP) || "0",
1207
+ cate_id: normalizeMappedValue(meta.cate_id, CATEGORY_MAP),
1208
+ video_link: normalizeText(meta.video_link),
1209
+ is_hot: normalizeBoolLike(meta.is_hot, "0"),
1210
+ is_index: normalizeBoolLike(meta.is_index, "0"),
1211
+ release_time: normalizeText(meta.release_time),
1212
+ area: normalizeMappedValue(meta.area, AREA_MAP) || "1",
1213
+ sort: normalizeText(meta.sort) || "1",
1214
+ game_id: normalizeMappedValue(meta.game_id, GAME_MAP) || "39",
1215
+ is_index2: "0",
1216
+ index_pc_img: normalizeText(meta.index_pc_img),
1217
+ index_move_img: normalizeText(meta.index_move_img),
1218
+ type: "1"
1219
+ };
520
1220
 
521
- if (options.env) {
522
- args.push("--env", options.env);
1221
+ if (!payload.article_title) {
1222
+ throw new Error("article_title 不能为空");
523
1223
  }
524
1224
 
525
- if (options.url) {
526
- args.push("--url", options.url);
1225
+ if (!payload.thumbnail) {
1226
+ throw new Error("thumbnail 不能为空");
527
1227
  }
528
1228
 
529
- if (options.dryRun) {
530
- args.push("--dry-run");
1229
+ if (!payload.article_content) {
1230
+ throw new Error("文章正文不能为空");
531
1231
  }
532
1232
 
533
- console.log("");
534
- console.log(chalk.cyan("正在发布官网文章..."));
535
- console.log(chalk.gray(`文章文件: ${articleFile}`));
536
- console.log("");
1233
+ return payload;
1234
+ }
1235
+
1236
+ async function parseArticleFile(file) {
1237
+ const articleFile = path.resolve(process.cwd(), file);
1238
+ if (!(await pathExists(articleFile))) {
1239
+ throw new Error(`文章文件不存在: ${articleFile}`);
1240
+ }
537
1241
 
538
- await runNative("publish-article", args);
1242
+ const raw = await fs.promises.readFile(articleFile, "utf8");
1243
+ const parsed = parseFrontMatter(raw);
1244
+ return {
1245
+ articleFile,
1246
+ payload: normalizeArticlePayload(parsed.meta, parsed.content)
1247
+ };
539
1248
  }
540
1249
 
541
1250
  async function initArticleTemplate(file = "article.md") {
@@ -579,15 +1288,15 @@ move_thumbnail: https://example.com/mobile-thumbnail.png
579
1288
  article_excerpt: 文章描述,可以为空
580
1289
  article_url: article-url-slug
581
1290
  origin: QCPlay
582
- status: "1"
583
- cate_id: "2"
1291
+ status: 未发布
1292
+ cate_id: 综合
584
1293
  video_link: ""
585
- is_hot: "0"
586
- is_index: "0"
587
- release_time: "2026-07-09"
588
- area: "1"
589
- sort: "100"
590
- game_id: "39"
1294
+ is_hot: 非热门
1295
+ is_index: 不推荐
1296
+ release_time: 2026-07-16
1297
+ area: pc
1298
+ sort: 100
1299
+ game_id: 最强蜗牛
591
1300
  index_pc_img: ""
592
1301
  index_move_img: ""
593
1302
  ---
@@ -595,77 +1304,39 @@ index_move_img: ""
595
1304
  # 文章标题
596
1305
 
597
1306
  这里填写文章正文内容。
598
-
599
- ## 一、文章小标题
600
-
601
- 这里填写正文内容。
602
-
603
- ## 二、内容说明
604
-
605
- 这里继续填写正文。
606
-
607
- ## 三、总结
608
-
609
- 这里填写文章结尾内容。
610
1307
  ${chalk.gray("--------------------------------------------------")}
611
1308
 
612
1309
  ${chalk.cyan("发布命令:")}
613
1310
 
614
1311
  qcplay-cli www-article-list.store article.md
1312
+ `);
1313
+ }
1314
+
1315
+ async function publishArticleCommand(file, options) {
1316
+ if (!file) {
1317
+ throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
1318
+ }
615
1319
 
616
- ${chalk.cyan("参数说明:")}
617
-
618
- article_title 文章标题,必填
619
- thumbnail 缩略图,必填
620
- move_thumbnail 移动端缩略图,可为空
621
- article_excerpt 文章描述,可为空
622
- article_url 文章链接,建议英文 slug
623
- origin 作者来源
624
- status 1 发布,0 未发布
625
- cate_id 分类 ID
626
- video_link 视频链接,可为空
627
- is_hot 1 热门,0 非热门
628
- is_index 1 推荐,0 不推荐
629
- release_time 发布时间,格式:年-月-日
630
- area 所属区域
631
- sort 排序,数字
632
- game_id 游戏 ID,最强蜗牛是 39
633
- index_pc_img 推荐 PC 端缩略图,可为空
634
- index_move_img 推荐移动端缩略图,可为空
635
-
636
- ${chalk.cyan("分类 ID:")}
637
-
638
- 综合 2
639
- 视频中心 8
640
- 活动 3
641
- 游戏攻略 7
642
- 萌新入门 9
643
- 萌新入门-攻略专区 25
644
- 高手进阶 26
645
- 活动攻略 27
646
- 视频攻略 29
647
-
648
- ${chalk.cyan("区域 ID:")}
649
-
650
- PC 1
651
- 资料站 3
652
- 公益网站 4
653
-
654
- ${chalk.cyan("游戏 ID:")}
655
-
656
- 最强蜗牛 39
657
-
658
- ${chalk.yellow("注意:")}
659
-
660
- 1. article_content 不需要写在 Front Matter 中。
661
- 2. Markdown 正文会自动作为 article_content。
662
- 3. is_index2 默认 0,不需要填写。
663
- 4. type 默认 1,不需要填写。
664
- 5. 发布前请先登录:
665
-
666
- qcplay-cli auth
1320
+ const { articleFile, payload } = await parseArticleFile(file);
1321
+ const config = await loadConfig();
1322
+ const backendUrl = resolvePublishBackendUrl(options, config);
667
1323
 
668
- `);
1324
+ if (options.dryRun) {
1325
+ console.log(JSON.stringify(payload, null, 2));
1326
+ return;
1327
+ }
1328
+
1329
+ console.log("");
1330
+ console.log(chalk.cyan("正在发布官网文章..."));
1331
+ console.log(chalk.gray(`文章文件: ${articleFile}`));
1332
+ console.log("");
1333
+
1334
+ const response = await requestJson("POST", backendUrl, "/api/articles/publish", payload);
1335
+ console.log(chalk.green(response.message || "发布成功"));
1336
+ const articleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
1337
+ if (articleId !== undefined) {
1338
+ console.log(`文章 ID: ${articleId}`);
1339
+ }
669
1340
  }
670
1341
 
671
1342
  async function runWithErrorBanner(title, action) {
@@ -694,7 +1365,18 @@ async function main() {
694
1365
  }
695
1366
 
696
1367
  if (command === "install") {
697
- await runWithErrorBanner("安装失败", installCommand);
1368
+ const parsed = parseBackendOptions([subcommand, ...rest].filter(Boolean));
1369
+ await runWithErrorBanner("安装失败", () => installCommand(parsed.options));
1370
+ return;
1371
+ }
1372
+
1373
+ if (command === "update") {
1374
+ if (subcommand === "-h" || subcommand === "--help") {
1375
+ printUpdateHelp();
1376
+ return;
1377
+ }
1378
+
1379
+ await runWithErrorBanner("更新失败", () => updateCommand());
698
1380
  return;
699
1381
  }
700
1382
 
@@ -704,7 +1386,7 @@ async function main() {
704
1386
  return;
705
1387
  }
706
1388
 
707
- await runWithErrorBanner("认证失败", () => authCommand(subcommand, rest));
1389
+ await runWithErrorBanner("认证失败", () => authCommand([subcommand, ...rest].filter(Boolean)));
708
1390
  return;
709
1391
  }
710
1392
 
@@ -725,8 +1407,8 @@ async function main() {
725
1407
  }
726
1408
 
727
1409
  if (subcommand === "publish") {
728
- const { file, options } = parsePublishOptions(rest);
729
- await runWithErrorBanner("发布失败", () => publishArticleCommand(file, options));
1410
+ const parsed = parsePublishOptions(rest);
1411
+ await runWithErrorBanner("发布失败", () => publishArticleCommand(parsed.file, parsed.options));
730
1412
  return;
731
1413
  }
732
1414