@qmuse/qmuse-cli 1.0.1 → 1.0.2-dev.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/cli.js +8 -12
- package/dist/config.js +105 -37
- package/dist/credentials.js +63 -0
- package/dist/login.js +4 -12
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 用法见 `qmuse help`。
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
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
|
-
|
|
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)
|
|
@@ -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 =
|
|
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 = { ...
|
|
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
|
-
|
|
626
|
-
|
|
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
|
-
*
|
|
4
|
+
* 登录凭证存入系统凭证库;项目级配置只能提供空间选择,不能改变请求目标
|
|
5
|
+
* 或注入请求头。各环境凭证按 API Origin 隔离。
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
: {};
|
|
47
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
48
|
+
return parsed;
|
|
47
49
|
}
|
|
48
|
-
catch {
|
|
49
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
122
|
+
const credentials = options.credentials ?? systemCredentialStore;
|
|
123
|
+
const user = migrateLegacy(userPath, credentials);
|
|
74
124
|
const project = readConfig(projectPath);
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
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
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.2-dev.0",
|
|
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
|
},
|