@qmuse/qmuse-cli 1.0.1 → 1.0.2-dev.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/README.md CHANGED
@@ -42,6 +42,8 @@ qmuse import /path/to/react-project --space-id <spaceId>
42
42
 
43
43
  已选择空间时,可省略 `--space-id`。不希望自动打开浏览器时,添加 `--no-open`。
44
44
 
45
+ 首次导入时,可通过 `--title "应用名称"` 指定标题;不指定时,QMuse 会根据代码生成标题。应用描述由 QMuse 根据代码生成。
46
+
45
47
  导入遵循项目根目录的 `.gitignore`,自动排除依赖目录、构建产物和 `.env*` 等文件。检测到私钥或凭证时会停止导入,请按提示处理后重试。
46
48
 
47
49
  导入失败时,按终端提示处理;如果生成了 `.qmuse/last-import-error.json`,可查看该文件定位问题。修复后重新执行导入命令。
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * 用法见 `qmuse help`。
6
6
  */
7
- import { applyEnvironment, loadConfig, saveConfig, findConfigPath } from './config.js';
7
+ import { loadConfig, saveConfig, deleteSession, findConfigPath } from './config.js';
8
8
  import { hasAuth } from './client.js';
9
9
  import { log } from './logger.js';
10
10
  import * as api from './api.js';
@@ -69,14 +69,11 @@ const num = (f, key, def) => {
69
69
  };
70
70
  /** 仅允许使用内置环境,避免命令行参数把登录凭证重定向到任意地址。 */
71
71
  function resolveCfg(flags) {
72
- let cfg = loadConfig();
73
72
  const environment = str(flags, '--env');
74
- if (environment) {
75
- if (!['prod', 'pre', 'local'].includes(environment)) {
76
- fail('--env 仅支持 prod / pre / local');
77
- }
78
- cfg = applyEnvironment(cfg, environment);
73
+ if (environment && !['prod', 'pre', 'local'].includes(environment)) {
74
+ fail('--env 仅支持 prod / pre / local');
79
75
  }
76
+ const cfg = loadConfig(undefined, { environment: environment });
80
77
  // --space-id 覆盖当前空间
81
78
  const sp = str(flags, '--space-id');
82
79
  if (sp)
@@ -450,7 +447,7 @@ async function cmdImport(command, flags) {
450
447
  const started = await startAppImport({
451
448
  sourceToken,
452
449
  spaceId,
453
- title: str(flags, '--title') || preflight.title,
450
+ title: str(flags, '--title')?.trim() || undefined,
454
451
  sourceHash: preflight.sourceHash,
455
452
  sourceType: 'REACT_VITE',
456
453
  cloudMode,
@@ -538,7 +535,7 @@ async function cmdWhoami(flags) {
538
535
  async function cmdConfig(command, flags) {
539
536
  const sub = command[1];
540
537
  if (!sub || sub === 'get') {
541
- const cfg = loadConfig();
538
+ const cfg = resolveCfg(flags);
542
539
  const redacted = {
543
540
  ...cfg,
544
541
  sessionId: cfg.sessionId ? `<已设置,长度${cfg.sessionId.length}>` : '',
@@ -577,7 +574,7 @@ async function cmdSpace(command, flags) {
577
574
  const spaceId = command[2] || str(flags, '--space-id');
578
575
  if (!spaceId)
579
576
  fail('用法: qmuse space use <spaceId>');
580
- const next = { ...loadConfig(), spaceId };
577
+ const next = { ...cfg, spaceId };
581
578
  saveConfig(next, true);
582
579
  log.success(`已设为当前空间 spaceId=${spaceId}(后续请求带 x-space-id 头)`);
583
580
  return;
@@ -622,9 +619,8 @@ async function cmdLogout(flags) {
622
619
  log.warn('当前未配置登录态,无需退出');
623
620
  return;
624
621
  }
625
- const cleared = { ...cfg, sessionId: '', credentialOrigin: '' };
626
- const path = saveConfig(cleared, true);
627
- log.success(`已清除登录态并写入 ${path}`);
622
+ deleteSession(cfg);
623
+ log.success('已从系统凭证库清除当前环境的登录态');
628
624
  }
629
625
  // ---------------- 帮助 & 入口 ----------------
630
626
  const HELP = `QMuse CLI —— 将本地 React 项目导入 QMuse
package/dist/config.js CHANGED
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * QMuse CLI 配置。
3
3
  *
4
- * 登录凭证只从用户级配置读取;项目级配置只能提供空间选择,不能改变请求目标
5
- * 或注入请求头。凭证同时绑定登录时的 API Origin,切换环境后必须重新登录。
4
+ * 登录凭证存入系统凭证库;项目级配置只能提供空间选择,不能改变请求目标
5
+ * 或注入请求头。各环境凭证按 API Origin 隔离。
6
6
  */
7
- import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { randomUUID } from 'node:crypto';
8
9
  import { homedir } from 'node:os';
9
10
  import { join } from 'node:path';
11
+ import { systemCredentialStore } from './credentials.js';
10
12
  const CONFIG_FILE = '.qmuse-cli.json';
11
13
  export const DEFAULT_CONFIG = {
12
14
  baseUrl: 'https://api.qmuse.cn',
@@ -36,18 +38,20 @@ const ENVIRONMENT_CONFIG = {
36
38
  export function configPaths() {
37
39
  return [join(process.cwd(), CONFIG_FILE), join(homedir(), CONFIG_FILE)];
38
40
  }
39
- function readConfig(path) {
40
- if (!existsSync(path))
41
- return {};
41
+ function readConfig(path, userLevel = false) {
42
42
  try {
43
+ if (userLevel && !lstatSync(path).isFile()) {
44
+ throw new Error('User configuration must be a regular file');
45
+ }
43
46
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
44
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
45
- ? parsed
46
- : {};
47
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
48
+ return parsed;
47
49
  }
48
- catch {
49
- return {};
50
+ catch (error) {
51
+ if (error.code === 'ENOENT')
52
+ return {};
50
53
  }
54
+ throw new Error(`无法读取配置文件,请检查文件权限和 JSON 格式:${path}`);
51
55
  }
52
56
  export function apiOrigin(baseUrl) {
53
57
  try {
@@ -64,27 +68,68 @@ function storedEnvironment(value) {
64
68
  const normalized = value.replace(/\/+$/u, '');
65
69
  return Object.values(ENVIRONMENT_CONFIG).find(environment => environment.baseUrl === normalized);
66
70
  }
67
- /**
68
- * 加载配置。项目配置即使由仓库提交,也只能覆盖 spaceId。
69
- * 旧配置没有 credentialOrigin 时,将凭证绑定到用户配置自身的 baseUrl。
70
- */
71
- export function loadConfig(paths = configPaths()) {
71
+ function credentialOrigins(user) {
72
+ return Array.isArray(user.credentialOrigins)
73
+ ? [...new Set(user.credentialOrigins
74
+ .filter(origin => typeof origin === 'string' && storedEnvironment(origin))
75
+ .map(origin => apiOrigin(origin)))]
76
+ : [];
77
+ }
78
+ function writeConfig(path, user) {
79
+ const value = {
80
+ ...(typeof user.spaceId === 'string' && user.spaceId.trim() ? { spaceId: user.spaceId.trim() } : {}),
81
+ ...(credentialOrigins(user).length ? { credentialOrigins: credentialOrigins(user) } : {}),
82
+ };
83
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
84
+ try {
85
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {
86
+ encoding: 'utf8', mode: 0o600, flag: 'wx',
87
+ });
88
+ renameSync(temporaryPath, path);
89
+ }
90
+ finally {
91
+ rmSync(temporaryPath, { force: true });
92
+ }
93
+ }
94
+ function storeVerified(credentials, origin, sessionId) {
95
+ credentials.set(origin, sessionId);
96
+ if (credentials.get(origin) !== sessionId) {
97
+ throw new Error('系统凭证库写入校验失败,未保存登录配置');
98
+ }
99
+ }
100
+ function migrateLegacy(path, credentials) {
101
+ const user = readConfig(path, true);
102
+ if (!Object.hasOwn(user, 'sessionId'))
103
+ return user;
104
+ const endpoint = storedEnvironment(user.baseUrl);
105
+ const origin = endpoint ? apiOrigin(endpoint.baseUrl) : '';
106
+ const token = typeof user.sessionId === 'string' ? user.sessionId.trim() : '';
107
+ const boundOrigin = typeof user.credentialOrigin === 'string' ? user.credentialOrigin.trim() : '';
108
+ const origins = credentialOrigins(user);
109
+ if (token && origin && (!boundOrigin || boundOrigin === origin)) {
110
+ // Prefer an existing keyring session when retrying an interrupted migration.
111
+ if (!credentials.get(origin))
112
+ storeVerified(credentials, origin, token);
113
+ origins.push(origin);
114
+ }
115
+ const migrated = { spaceId: user.spaceId, credentialOrigins: [...new Set(origins)] };
116
+ writeConfig(path, migrated);
117
+ return migrated;
118
+ }
119
+ /** 加载当前环境凭证;项目配置只能覆盖 spaceId。 */
120
+ export function loadConfig(paths = configPaths(), options = {}) {
72
121
  const [projectPath, userPath] = paths;
73
- const user = readConfig(userPath);
122
+ const credentials = options.credentials ?? systemCredentialStore;
123
+ const user = migrateLegacy(userPath, credentials);
74
124
  const project = readConfig(projectPath);
75
- const storedEndpoint = storedEnvironment(user.baseUrl);
76
- const sessionId = storedEndpoint && typeof user.sessionId === 'string'
77
- ? user.sessionId.trim()
78
- : '';
79
- const credentialOrigin = sessionId
80
- ? (typeof user.credentialOrigin === 'string' && user.credentialOrigin.trim()
81
- ? user.credentialOrigin.trim()
82
- : apiOrigin(storedEndpoint?.baseUrl ?? ''))
83
- : '';
125
+ const environment = ENVIRONMENT_CONFIG[options.environment ?? 'prod'];
126
+ const origin = apiOrigin(environment.baseUrl);
127
+ const sessionId = credentialOrigins(user).includes(origin) ? (credentials.get(origin) ?? '') : '';
84
128
  return {
85
129
  ...DEFAULT_CONFIG,
130
+ ...environment,
86
131
  sessionId,
87
- credentialOrigin,
132
+ credentialOrigin: sessionId ? origin : '',
88
133
  ...(typeof user.spaceId === 'string' && user.spaceId.trim()
89
134
  ? { spaceId: user.spaceId.trim() }
90
135
  : {}),
@@ -97,18 +142,41 @@ export function applyEnvironment(cfg, environment) {
97
142
  return { ...cfg, ...ENVIRONMENT_CONFIG[environment] };
98
143
  }
99
144
  /** 保存配置。项目级文件仅允许保存不敏感的空间选择。 */
100
- export function saveConfig(cfg, global = true) {
101
- const path = global ? join(homedir(), CONFIG_FILE) : join(process.cwd(), CONFIG_FILE);
102
- const value = global
103
- ? cfg
104
- : { ...(cfg.spaceId ? { spaceId: cfg.spaceId } : {}) };
105
- writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, {
106
- encoding: 'utf8',
107
- mode: 0o600,
108
- });
109
- chmodSync(path, 0o600);
145
+ export function saveConfig(cfg, global = true, options = {}) {
146
+ const paths = options.paths ?? configPaths();
147
+ const path = global ? paths[1] : paths[0];
148
+ const user = global ? migrateLegacy(path, options.credentials ?? systemCredentialStore) : {};
149
+ writeConfig(path, { ...user, spaceId: cfg.spaceId });
110
150
  return path;
111
151
  }
152
+ /** 登录与续期使用独立写入入口,普通配置永不携带凭证。 */
153
+ export function saveSession(sessionId, cfg, options = {}) {
154
+ const endpoint = storedEnvironment(cfg.baseUrl);
155
+ if (!endpoint || !sessionId.trim())
156
+ throw new Error('登录凭证或目标环境无效');
157
+ const origin = apiOrigin(endpoint.baseUrl);
158
+ const path = (options.paths ?? configPaths())[1];
159
+ const credentials = options.credentials ?? systemCredentialStore;
160
+ const user = migrateLegacy(path, credentials);
161
+ storeVerified(credentials, origin, sessionId);
162
+ writeConfig(path, {
163
+ spaceId: user.spaceId,
164
+ credentialOrigins: [...new Set([...credentialOrigins(user), origin])],
165
+ });
166
+ }
167
+ export function deleteSession(cfg, options = {}) {
168
+ const endpoint = storedEnvironment(cfg.baseUrl);
169
+ if (!endpoint)
170
+ throw new Error('退出登录的目标环境无效');
171
+ const origin = apiOrigin(endpoint.baseUrl);
172
+ const path = (options.paths ?? configPaths())[1];
173
+ const credentials = options.credentials ?? systemCredentialStore;
174
+ const user = migrateLegacy(path, credentials);
175
+ credentials.delete(origin);
176
+ if (credentials.get(origin))
177
+ throw new Error('系统凭证库删除校验失败,请重试退出登录');
178
+ writeConfig(path, { spaceId: user.spaceId, credentialOrigins: credentialOrigins(user).filter(value => value !== origin) });
179
+ }
112
180
  export function findConfigPath() {
113
181
  for (const path of configPaths()) {
114
182
  if (existsSync(path))
@@ -0,0 +1,63 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ const require = createRequire(import.meta.url);
4
+ const KEYRING_ERROR = '无法访问系统凭证库。请解锁 macOS 钥匙串或 Windows 凭据管理器;Linux 请安装 libsecret 的 secret-tool 并启用、解锁 Secret Service。未回退到明文文件。';
5
+ function protect(operation) {
6
+ try {
7
+ return operation();
8
+ }
9
+ catch {
10
+ // Native errors and child-process output may contain credentials.
11
+ throw new Error(KEYRING_ERROR);
12
+ }
13
+ }
14
+ export function createNativeCredentialStore(entry) {
15
+ return {
16
+ get: origin => protect(() => entry(origin).getPassword()),
17
+ set: (origin, sessionId) => protect(() => entry(origin).setPassword(sessionId)),
18
+ delete: origin => protect(() => { entry(origin).deleteCredential(); }),
19
+ };
20
+ }
21
+ export function createSecretServiceStore(service, run = spawnSync) {
22
+ function call(command, origin, secret) {
23
+ return protect(() => {
24
+ const result = run('/usr/bin/secret-tool', [
25
+ command,
26
+ ...(command === 'store' ? ['--label=QMuse CLI'] : []),
27
+ 'service', service, 'account', origin,
28
+ ], {
29
+ encoding: 'utf8',
30
+ input: secret,
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ timeout: 15_000,
33
+ maxBuffer: 64 * 1024,
34
+ });
35
+ if (result.error || result.signal)
36
+ throw new Error('Secret Service unavailable');
37
+ if (result.status === 0)
38
+ return result.stdout.replace(/\r?\n$/u, '');
39
+ // secret-tool uses exit 1 with no diagnostic for an absent item.
40
+ if (command !== 'store' && result.status === 1 && !result.stderr.trim())
41
+ return null;
42
+ throw new Error('Secret Service operation failed');
43
+ });
44
+ }
45
+ return {
46
+ get: origin => call('lookup', origin),
47
+ set: (origin, sessionId) => { call('store', origin, sessionId); },
48
+ delete: origin => { call('clear', origin); },
49
+ };
50
+ }
51
+ export function createSystemCredentialStore(service = 'cn.qmuse.cli') {
52
+ if (process.platform === 'linux')
53
+ return createSecretServiceStore(service);
54
+ return createNativeCredentialStore(origin => {
55
+ if (process.platform !== 'darwin' && process.platform !== 'win32') {
56
+ throw new Error('Unsupported credential store');
57
+ }
58
+ // Lazy loading keeps help and non-authenticated commands usable without a native binding.
59
+ const { Entry } = require('@napi-rs/keyring');
60
+ return new Entry(service, origin);
61
+ });
62
+ }
63
+ export const systemCredentialStore = createSystemCredentialStore();
package/dist/login.js CHANGED
@@ -5,7 +5,7 @@ import { execFile } from 'node:child_process';
5
5
  import { createInterface } from 'node:readline';
6
6
  import * as api from './api.js';
7
7
  import { buildUrl } from './client.js';
8
- import { apiOrigin, loadConfig, saveConfig } from './config.js';
8
+ import { apiOrigin, loadConfig, saveSession } from './config.js';
9
9
  import { log } from './logger.js';
10
10
  const SESSION_EXCHANGE_POLL_INTERVAL_MS = 2_000;
11
11
  const SESSION_EXCHANGE_POLL_TIMEOUT_MS = 120_000;
@@ -61,17 +61,9 @@ export function persistSession(sessionId, targetConfig = loadConfig()) {
61
61
  const origin = apiOrigin(targetConfig.baseUrl);
62
62
  if (!origin)
63
63
  throw new Error('登录目标不是有效的 HTTP(S) 地址');
64
- const config = {
65
- ...loadConfig(),
66
- baseUrl: targetConfig.baseUrl,
67
- apiPrefix: targetConfig.apiPrefix,
68
- webBaseUrl: targetConfig.webBaseUrl,
69
- sessionId,
70
- credentialOrigin: origin,
71
- };
72
- const path = saveConfig(config, true);
73
- log.success(`登录态已保存到 ${path}`);
74
- return path;
64
+ saveSession(sessionId, targetConfig);
65
+ log.success('登录态已保存到系统凭证库');
66
+ return origin;
75
67
  }
76
68
  export async function loginBrowser(cfg, options = {}, dependencyOverrides) {
77
69
  const dependencies = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qmuse/qmuse-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2-dev.1",
4
4
  "description": "将本地 React 项目安全导入 QMuse 的命令行客户端",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "publish:public": "bash scripts/publish-public.sh"
19
19
  },
20
20
  "dependencies": {
21
+ "@napi-rs/keyring": "2.0.0",
21
22
  "ignore": "^7.0.8",
22
23
  "yazl": "^3.3.1"
23
24
  },