@qmuse/qmuse-cli 1.0.0-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.
@@ -0,0 +1,246 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { lstat, readFile, readdir } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import ignore from 'ignore';
6
+ const IGNORED_DIRECTORIES = new Set([
7
+ '.git', '.agents', '.local', '.qmuse', 'node_modules', 'dist', 'build', '.vite', '.turbo',
8
+ ]);
9
+ const IGNORED_ROOT_FILES = new Set(['AGENTS.md']);
10
+ const LOCAL_CONTROL_FILES = new Set(['.qmuse-cli.json']);
11
+ const SENSITIVE_FILE_NAMES = new Set([
12
+ '.netrc', '.npmrc', '.pnpmrc', '.pypirc', '.yarnrc', '.yarnrc.yml',
13
+ 'credentials.json', 'id_ed25519', 'id_rsa', 'service-account.json',
14
+ ]);
15
+ const SENSITIVE_DIRECTORIES = new Set(['.aws', '.azure', '.gnupg', '.ssh']);
16
+ const SENSITIVE_EXTENSIONS = new Set(['.jks', '.key', '.keystore', '.p12', '.pem', '.pfx']);
17
+ const MAX_FILE_COUNT = 20_000;
18
+ const MAX_FILE_BYTES = 100 * 1024 * 1024;
19
+ const MAX_TOTAL_BYTES = 500 * 1024 * 1024;
20
+ const MAX_INSPECT_TEXT_BYTES = 2 * 1024 * 1024;
21
+ const TEXT_EXTENSIONS = new Set([
22
+ '.css', '.cjs', '.html', '.js', '.jsx', '.json', '.less', '.md', '.mjs',
23
+ '.mts', '.scss', '.sass', '.ts', '.tsx', '.txt', '.yaml', '.yml',
24
+ ]);
25
+ const ASSET_MIME_TYPES = {
26
+ '.avif': 'image/avif',
27
+ '.eot': 'application/vnd.ms-fontobject',
28
+ '.gif': 'image/gif',
29
+ '.ico': 'image/x-icon',
30
+ '.jpeg': 'image/jpeg',
31
+ '.jpg': 'image/jpeg',
32
+ '.mp3': 'audio/mpeg',
33
+ '.mp4': 'video/mp4',
34
+ '.ogg': 'audio/ogg',
35
+ '.otf': 'font/otf',
36
+ '.png': 'image/png',
37
+ '.svg': 'image/svg+xml',
38
+ '.ttf': 'font/ttf',
39
+ '.wav': 'audio/wav',
40
+ '.webm': 'video/webm',
41
+ '.webp': 'image/webp',
42
+ '.woff': 'font/woff',
43
+ '.woff2': 'font/woff2',
44
+ };
45
+ const ENV_PATTERNS = [
46
+ /import\.meta\.env\.([A-Z][A-Z0-9_]*)/gu,
47
+ /import\.meta\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/gu,
48
+ /process\.env\.([A-Z][A-Z0-9_]*)/gu,
49
+ /process\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/gu,
50
+ ];
51
+ const PUBLIC_ENV_KEY = /^(?:VITE_|REACT_APP_)[A-Z0-9_]+$/u;
52
+ const UNSUPPORTED_PRIVATE_RUNTIME = '@alipay/weavefox-vibe-web';
53
+ const CLOUD_PROVIDER_PATTERNS = [
54
+ ['supabase', /from\s+['"]@supabase\/supabase-js['"]/u],
55
+ ['firebase', /from\s+['"]firebase(?:\/[^'"]+)?['"]/u],
56
+ ['appwrite', /from\s+['"](?:node-)?appwrite['"]/u],
57
+ ['pocketbase', /from\s+['"]pocketbase['"]/u],
58
+ ];
59
+ const RUNTIME_SOURCE_EXTENSIONS = new Set([
60
+ '.cjs', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx',
61
+ ]);
62
+ const SECRET_CONTENT_PATTERNS = [
63
+ /-----BEGIN (?:EC |OPENSSH |RSA )?PRIVATE KEY-----/u,
64
+ /(?:^|\n)\s*(?:AWS_SECRET_ACCESS_KEY|_authToken)\s*=/u,
65
+ /["']private_key["']\s*:\s*["']-----BEGIN PRIVATE KEY/u,
66
+ ];
67
+ function rejectUnsupportedPrivateRuntime() {
68
+ throw new Error(`暂不支持依赖 ${UNSUPPORTED_PRIVATE_RUNTIME} 的项目:QMuse 隔离构建环境无法安装该内网包`);
69
+ }
70
+ function isDotEnv(name) {
71
+ return name === '.env' || name.startsWith('.env.');
72
+ }
73
+ function normalizeRelativePath(value) {
74
+ const normalized = value.replace(/\\/gu, '/');
75
+ if (!normalized ||
76
+ normalized.startsWith('/') ||
77
+ normalized === '..' ||
78
+ normalized.startsWith('../') ||
79
+ normalized.includes('/../') ||
80
+ normalized.includes('\0')) {
81
+ throw new Error(`源码包含不安全路径:${value}`);
82
+ }
83
+ return normalized;
84
+ }
85
+ async function collectFiles(rootDir) {
86
+ const rootStat = await lstat(rootDir).catch(() => undefined);
87
+ if (!rootStat?.isDirectory()) {
88
+ throw new Error(`目录不存在或不可读:${rootDir}`);
89
+ }
90
+ if (rootStat.isSymbolicLink()) {
91
+ throw new Error('导入目录不能是符号链接');
92
+ }
93
+ const files = [];
94
+ let totalBytes = 0;
95
+ const rootIgnore = ignore();
96
+ const gitIgnorePath = path.join(rootDir, '.gitignore');
97
+ try {
98
+ rootIgnore.add(await readFile(gitIgnorePath, 'utf8'));
99
+ }
100
+ catch (error) {
101
+ const code = error.code;
102
+ if (code !== 'ENOENT')
103
+ throw error;
104
+ }
105
+ async function visit(directory) {
106
+ const entries = await readdir(directory, { withFileTypes: true });
107
+ entries.sort((left, right) => left.name.localeCompare(right.name));
108
+ for (const entry of entries) {
109
+ if (directory === rootDir && IGNORED_ROOT_FILES.has(entry.name))
110
+ continue;
111
+ if (LOCAL_CONTROL_FILES.has(entry.name))
112
+ continue;
113
+ if (entry.name === '.DS_Store' ||
114
+ entry.name.endsWith('.log') ||
115
+ entry.name.endsWith('.tsbuildinfo'))
116
+ continue;
117
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name))
118
+ continue;
119
+ if (isDotEnv(entry.name))
120
+ continue;
121
+ const absolutePath = path.join(directory, entry.name);
122
+ const stat = await lstat(absolutePath);
123
+ const relativePath = normalizeRelativePath(path.relative(rootDir, absolutePath));
124
+ const ignorePath = stat.isDirectory() ? `${relativePath}/` : relativePath;
125
+ if (rootIgnore.ignores(ignorePath))
126
+ continue;
127
+ if (stat.isSymbolicLink()) {
128
+ throw new Error(`不支持符号链接:${relativePath}`);
129
+ }
130
+ if (stat.isDirectory()) {
131
+ if (SENSITIVE_DIRECTORIES.has(entry.name)) {
132
+ throw new Error(`源码包含敏感凭证目录,拒绝上传:${relativePath}`);
133
+ }
134
+ await visit(absolutePath);
135
+ continue;
136
+ }
137
+ if (!stat.isFile()) {
138
+ throw new Error(`不支持的文件类型:${relativePath}`);
139
+ }
140
+ if (stat.size > MAX_FILE_BYTES) {
141
+ throw new Error(`单文件超过 100MB:${relativePath}`);
142
+ }
143
+ if (SENSITIVE_FILE_NAMES.has(entry.name) ||
144
+ SENSITIVE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
145
+ throw new Error(`源码包含敏感凭证文件,拒绝上传:${relativePath}`);
146
+ }
147
+ if (stat.size <= MAX_INSPECT_TEXT_BYTES) {
148
+ const content = await readFile(absolutePath, 'utf8');
149
+ if (SECRET_CONTENT_PATTERNS.some(pattern => pattern.test(content))) {
150
+ throw new Error(`源码疑似包含明文凭证,拒绝上传:${relativePath}`);
151
+ }
152
+ }
153
+ files.push({
154
+ absolutePath,
155
+ relativePath,
156
+ sizeBytes: stat.size,
157
+ sha256: await sha256File(absolutePath),
158
+ });
159
+ totalBytes += stat.size;
160
+ if (files.length > MAX_FILE_COUNT) {
161
+ throw new Error(`源码文件数超过 ${MAX_FILE_COUNT}`);
162
+ }
163
+ if (totalBytes > MAX_TOTAL_BYTES) {
164
+ throw new Error('源码未压缩大小超过 500MB');
165
+ }
166
+ }
167
+ }
168
+ await visit(rootDir);
169
+ return files;
170
+ }
171
+ async function updateHashFromFile(hash, filePath) {
172
+ for await (const chunk of createReadStream(filePath))
173
+ hash.update(chunk);
174
+ }
175
+ async function sha256File(filePath) {
176
+ const hash = createHash('sha256');
177
+ await updateHashFromFile(hash, filePath);
178
+ return hash.digest('hex');
179
+ }
180
+ export async function preflightImport(directory) {
181
+ const rootDir = path.resolve(directory);
182
+ const files = await collectFiles(rootDir);
183
+ const packageFile = files.find(file => file.relativePath === 'package.json');
184
+ if (!packageFile)
185
+ throw new Error('一期仅支持包含 package.json 的 React 项目');
186
+ let packageJson;
187
+ try {
188
+ packageJson = JSON.parse(await readFile(packageFile.absolutePath, 'utf8'));
189
+ }
190
+ catch (error) {
191
+ throw new Error(`package.json 无效:${error instanceof Error ? error.message : String(error)}`);
192
+ }
193
+ const dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies };
194
+ if (!dependencies.react)
195
+ throw new Error('一期仅支持 React 项目');
196
+ if (dependencies[UNSUPPORTED_PRIVATE_RUNTIME])
197
+ rejectUnsupportedPrivateRuntime();
198
+ const sourceHash = createHash('sha256');
199
+ const envKeys = new Set();
200
+ const cloudProviders = new Set();
201
+ for (const file of files) {
202
+ sourceHash.update(file.relativePath);
203
+ sourceHash.update('\0');
204
+ await updateHashFromFile(sourceHash, file.absolutePath);
205
+ sourceHash.update('\0');
206
+ if (TEXT_EXTENSIONS.has(path.extname(file.relativePath).toLowerCase()) &&
207
+ file.sizeBytes <= MAX_INSPECT_TEXT_BYTES) {
208
+ const content = await readFile(file.absolutePath, 'utf8');
209
+ if (content.includes(`'${UNSUPPORTED_PRIVATE_RUNTIME}'`) ||
210
+ content.includes(`"${UNSUPPORTED_PRIVATE_RUNTIME}"`)) {
211
+ rejectUnsupportedPrivateRuntime();
212
+ }
213
+ for (const pattern of ENV_PATTERNS) {
214
+ pattern.lastIndex = 0;
215
+ for (const match of content.matchAll(pattern)) {
216
+ if (match[1] && PUBLIC_ENV_KEY.test(match[1]))
217
+ envKeys.add(match[1]);
218
+ }
219
+ }
220
+ if (!file.relativePath.endsWith('.d.ts') &&
221
+ RUNTIME_SOURCE_EXTENSIONS.has(path.extname(file.relativePath).toLowerCase())) {
222
+ for (const [provider, pattern] of CLOUD_PROVIDER_PATTERNS) {
223
+ if (pattern.test(content))
224
+ cloudProviders.add(provider);
225
+ }
226
+ }
227
+ }
228
+ }
229
+ const assets = files.flatMap(file => {
230
+ const mimeType = ASSET_MIME_TYPES[path.extname(file.relativePath).toLowerCase()];
231
+ return mimeType
232
+ ? [{ sourcePath: file.relativePath, mimeType, sizeBytes: file.sizeBytes }]
233
+ : [];
234
+ });
235
+ return {
236
+ rootDir,
237
+ title: packageJson.name?.trim() || path.basename(rootDir),
238
+ sourceHash: sourceHash.digest('hex'),
239
+ fileCount: files.length,
240
+ totalBytes: files.reduce((sum, file) => sum + file.sizeBytes, 0),
241
+ envKeys: [...envKeys].sort(),
242
+ cloudProviders: [...cloudProviders].sort(),
243
+ assets,
244
+ files,
245
+ };
246
+ }
@@ -0,0 +1,189 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFile, rename, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ export const QMUSE_PROJECT_PATH = '.qmuse/project.json';
5
+ function isRecord(value) {
6
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ }
8
+ function requireNonEmptyString(value, field) {
9
+ if (typeof value !== 'string' || !value.trim()) {
10
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 ${field} 必须是非空字符串`);
11
+ }
12
+ return value;
13
+ }
14
+ function normalizeApiBaseUrl(value) {
15
+ let url;
16
+ try {
17
+ url = new URL(value);
18
+ }
19
+ catch {
20
+ throw new Error(`${QMUSE_PROJECT_PATH} 包含无效 apiBaseUrl:${value}`);
21
+ }
22
+ if (!['http:', 'https:'].includes(url.protocol)) {
23
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 apiBaseUrl 必须使用 HTTP(S)`);
24
+ }
25
+ url.hash = '';
26
+ url.search = '';
27
+ url.pathname = url.pathname.replace(/\/+$/u, '') || '/';
28
+ return url.toString().replace(/\/$/u, '');
29
+ }
30
+ function parseTarget(value, field) {
31
+ if (!isRecord(value))
32
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 ${field} 无效`);
33
+ return {
34
+ apiBaseUrl: normalizeApiBaseUrl(requireNonEmptyString(value.apiBaseUrl, `${field}.apiBaseUrl`)),
35
+ appId: requireNonEmptyString(value.appId, `${field}.appId`),
36
+ spaceId: requireNonEmptyString(value.spaceId, `${field}.spaceId`),
37
+ lastVersionId: requireNonEmptyString(value.lastVersionId, `${field}.lastVersionId`),
38
+ lastSourceHash: requireNonEmptyString(value.lastSourceHash, `${field}.lastSourceHash`),
39
+ updatedAt: requireNonEmptyString(value.updatedAt, `${field}.updatedAt`),
40
+ };
41
+ }
42
+ function parsePendingImport(value, field) {
43
+ if (!isRecord(value))
44
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 ${field} 无效`);
45
+ const operation = requireNonEmptyString(value.operation, `${field}.operation`);
46
+ if (operation !== 'CREATE' && operation !== 'UPDATE') {
47
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 ${field}.operation 无效`);
48
+ }
49
+ return {
50
+ apiBaseUrl: normalizeApiBaseUrl(requireNonEmptyString(value.apiBaseUrl, `${field}.apiBaseUrl`)),
51
+ jobId: requireNonEmptyString(value.jobId, `${field}.jobId`),
52
+ operation,
53
+ sourceHash: requireNonEmptyString(value.sourceHash, `${field}.sourceHash`),
54
+ spaceId: requireNonEmptyString(value.spaceId, `${field}.spaceId`),
55
+ createdAt: requireNonEmptyString(value.createdAt, `${field}.createdAt`),
56
+ };
57
+ }
58
+ function parseEnvironmentMap(value, field, parse) {
59
+ if (!isRecord(value))
60
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 ${field} 必须是对象`);
61
+ const result = {};
62
+ for (const [key, item] of Object.entries(value)) {
63
+ if (!['prod', 'pre', 'local'].includes(key)) {
64
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 ${field}.${key} 不是受支持的环境`);
65
+ }
66
+ result[key] = parse(item, `${field}.${key}`);
67
+ }
68
+ return result;
69
+ }
70
+ function parseManifest(value) {
71
+ if (!isRecord(value))
72
+ throw new Error(`${QMUSE_PROJECT_PATH} 必须是 JSON 对象`);
73
+ if (value.schemaVersion !== 1) {
74
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 schemaVersion 不受支持`);
75
+ }
76
+ if (value.projectKind !== 'QMUSE_TEMPLATE') {
77
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 projectKind 必须是 QMUSE_TEMPLATE`);
78
+ }
79
+ if (value.appType !== 'GENERAL') {
80
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 appType 必须是 GENERAL`);
81
+ }
82
+ if (!Number.isSafeInteger(value.packageVersion) || Number(value.packageVersion) < 1) {
83
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 packageVersion 无效`);
84
+ }
85
+ if (!isRecord(value.skillVersions)) {
86
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 skillVersions 必须是对象`);
87
+ }
88
+ const skillVersions = {};
89
+ for (const [name, version] of Object.entries(value.skillVersions)) {
90
+ skillVersions[requireNonEmptyString(name, 'skillVersions key')] = requireNonEmptyString(version, `skillVersions.${name}`);
91
+ }
92
+ const defaultSpaceId = value.defaultSpaceId === undefined
93
+ ? undefined
94
+ : requireNonEmptyString(value.defaultSpaceId, 'defaultSpaceId');
95
+ return {
96
+ schemaVersion: 1,
97
+ packageVersion: Number(value.packageVersion),
98
+ projectKind: 'QMUSE_TEMPLATE',
99
+ appType: 'GENERAL',
100
+ templateCommit: requireNonEmptyString(value.templateCommit, 'templateCommit'),
101
+ skillVersions,
102
+ ...(defaultSpaceId ? { defaultSpaceId } : {}),
103
+ targets: parseEnvironmentMap(value.targets, 'targets', parseTarget),
104
+ ...(value.pendingImports === undefined
105
+ ? {}
106
+ : { pendingImports: parseEnvironmentMap(value.pendingImports, 'pendingImports', parsePendingImport) }),
107
+ };
108
+ }
109
+ export function resolveProjectEnvironment(cfg, explicitEnvironment) {
110
+ if (explicitEnvironment)
111
+ return explicitEnvironment;
112
+ const apiBaseUrl = normalizeApiBaseUrl(cfg.baseUrl);
113
+ if (apiBaseUrl === 'https://api.qmuse.cn')
114
+ return 'prod';
115
+ if (apiBaseUrl === 'https://api-pre.qmuse.cn')
116
+ return 'pre';
117
+ if (apiBaseUrl === 'http://127.0.0.1:7001' || apiBaseUrl === 'http://local.dev.alipay.net:7001') {
118
+ return 'local';
119
+ }
120
+ throw new Error('受管 QMuse 项目使用自定义 API 地址时必须显式指定 --env prod|pre|local');
121
+ }
122
+ export async function loadManagedProject(rootDir, cfg, explicitEnvironment) {
123
+ const manifestPath = path.join(rootDir, QMUSE_PROJECT_PATH);
124
+ let raw;
125
+ try {
126
+ raw = await readFile(manifestPath, 'utf8');
127
+ }
128
+ catch (error) {
129
+ if (error.code === 'ENOENT')
130
+ return undefined;
131
+ throw error;
132
+ }
133
+ let json;
134
+ try {
135
+ json = JSON.parse(raw);
136
+ }
137
+ catch (error) {
138
+ throw new Error(`${QMUSE_PROJECT_PATH} 不是有效 JSON:${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ const manifest = parseManifest(json);
141
+ const environment = resolveProjectEnvironment(cfg, explicitEnvironment);
142
+ const target = manifest.targets[environment];
143
+ const pending = manifest.pendingImports?.[environment];
144
+ const expectedApiBaseUrl = normalizeApiBaseUrl(cfg.baseUrl);
145
+ for (const binding of [target, pending]) {
146
+ if (binding && normalizeApiBaseUrl(binding.apiBaseUrl) !== expectedApiBaseUrl) {
147
+ throw new Error(`${QMUSE_PROJECT_PATH} 的 ${environment} 绑定与当前 API 地址不一致`);
148
+ }
149
+ }
150
+ return { manifestPath, manifest, environment, target };
151
+ }
152
+ export async function saveManagedProject(context) {
153
+ const temporaryPath = `${context.manifestPath}.${process.pid}.${randomUUID()}.tmp`;
154
+ const content = `${JSON.stringify(context.manifest, null, 2)}\n`;
155
+ try {
156
+ await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
157
+ await rename(temporaryPath, context.manifestPath);
158
+ }
159
+ catch (error) {
160
+ const { rm } = await import('node:fs/promises');
161
+ await rm(temporaryPath, { force: true });
162
+ throw error;
163
+ }
164
+ }
165
+ export function setPendingImport(context, pending) {
166
+ context.manifest.pendingImports = {
167
+ ...context.manifest.pendingImports,
168
+ [context.environment]: pending,
169
+ };
170
+ }
171
+ export function clearPendingImport(context, jobId) {
172
+ if (context.manifest.pendingImports?.[context.environment]?.jobId !== jobId)
173
+ return;
174
+ const pendingImports = { ...context.manifest.pendingImports };
175
+ delete pendingImports[context.environment];
176
+ context.manifest.pendingImports = Object.keys(pendingImports).length ? pendingImports : undefined;
177
+ }
178
+ export function completeManagedImport(context, input) {
179
+ context.manifest.targets[context.environment] = {
180
+ apiBaseUrl: normalizeApiBaseUrl(input.apiBaseUrl),
181
+ appId: input.appId,
182
+ spaceId: input.spaceId,
183
+ lastVersionId: input.versionId,
184
+ lastSourceHash: input.sourceHash,
185
+ updatedAt: new Date().toISOString(),
186
+ };
187
+ context.target = context.manifest.targets[context.environment];
188
+ clearPendingImport(context, input.jobId);
189
+ }
@@ -0,0 +1,15 @@
1
+ export const SOURCE_MANIFEST_PATH = '.local/qmuse-import/source-manifest-v1.json';
2
+ export function createSourceManifest(preflight) {
3
+ return {
4
+ version: 1,
5
+ algorithm: 'sha256-path-content-v1',
6
+ sourceHash: preflight.sourceHash,
7
+ fileCount: preflight.fileCount,
8
+ totalBytes: preflight.totalBytes,
9
+ files: preflight.files.map(file => ({
10
+ path: file.relativePath,
11
+ sizeBytes: file.sizeBytes,
12
+ sha256: file.sha256,
13
+ })),
14
+ };
15
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,28 @@
1
+ /** 轻量终端日志器,避免引入额外依赖。 */
2
+ const COLORS = {
3
+ info: s => `\x1b[36m${s}\x1b[0m`,
4
+ success: s => `\x1b[32m${s}\x1b[0m`,
5
+ warn: s => `\x1b[33m${s}\x1b[0m`,
6
+ error: s => `\x1b[31m${s}\x1b[0m`,
7
+ dim: s => `\x1b[2m${s}\x1b[0m`,
8
+ };
9
+ const ICON = {
10
+ info: '›',
11
+ success: '✓',
12
+ warn: '!',
13
+ error: '✗',
14
+ dim: '·',
15
+ };
16
+ function emit(level, msg) {
17
+ const icon = COLORS[level](`${ICON[level]}`);
18
+ // eslint-disable-next-line no-console
19
+ console.log(`${icon} ${msg}`);
20
+ }
21
+ export const log = {
22
+ info: (m) => emit('info', m),
23
+ success: (m) => emit('success', m),
24
+ warn: (m) => emit('warn', m),
25
+ error: (m) => emit('error', m),
26
+ dim: (m) => emit('dim', m),
27
+ raw: (m) => process.stdout.write(m),
28
+ };
package/dist/login.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * QMuse 网页确认登录:服务端创建短期请求,浏览器确认后由 CLI 轮询换取会话。
3
+ */
4
+ import { execFile } from 'node:child_process';
5
+ import { createInterface } from 'node:readline';
6
+ import * as api from './api.js';
7
+ import { buildUrl } from './client.js';
8
+ import { apiOrigin, loadConfig, saveConfig } from './config.js';
9
+ import { log } from './logger.js';
10
+ const SESSION_EXCHANGE_POLL_INTERVAL_MS = 2_000;
11
+ const SESSION_EXCHANGE_POLL_TIMEOUT_MS = 120_000;
12
+ const SESSION_EXCHANGE_TOKEN_PATTERN = /^muse_session_exchange_[A-Za-z0-9_-]{43}$/;
13
+ const SESSION_EXCHANGE_PENDING_CODES = new Set(['SESSION_EXCHANGE_PENDING']);
14
+ let rl = null;
15
+ const pendingLines = [];
16
+ let lineWaiter = null;
17
+ function ensureRl() {
18
+ if (rl)
19
+ return;
20
+ rl = createInterface({
21
+ input: process.stdin,
22
+ output: process.stdout,
23
+ terminal: process.stdin.isTTY === true,
24
+ });
25
+ rl.on('line', (line) => {
26
+ if (lineWaiter) {
27
+ const waiter = lineWaiter;
28
+ lineWaiter = null;
29
+ waiter(line);
30
+ }
31
+ else {
32
+ pendingLines.push(line);
33
+ }
34
+ });
35
+ }
36
+ export function prompt(question) {
37
+ ensureRl();
38
+ process.stdout.write(question);
39
+ return new Promise(resolve => {
40
+ if (pendingLines.length > 0) {
41
+ resolve(pendingLines.shift());
42
+ }
43
+ else {
44
+ lineWaiter = resolve;
45
+ }
46
+ }).then(answer => answer.trim());
47
+ }
48
+ export function closePrompt() {
49
+ if (rl) {
50
+ rl.close();
51
+ rl = null;
52
+ }
53
+ }
54
+ export function openBrowser(url) {
55
+ const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
56
+ return new Promise(resolve => {
57
+ execFile(command, [url], error => resolve(!error));
58
+ });
59
+ }
60
+ export function persistSession(sessionId, targetConfig = loadConfig()) {
61
+ const origin = apiOrigin(targetConfig.baseUrl);
62
+ if (!origin)
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;
75
+ }
76
+ export async function loginBrowser(cfg, options = {}, dependencyOverrides) {
77
+ const dependencies = {
78
+ createRequest: api.createSessionExchangeRequest,
79
+ open: openBrowser,
80
+ consume: api.consumeSessionExchangeToken,
81
+ verify: api.exchangeSessionCookie,
82
+ persist: persistSession,
83
+ now: Date.now,
84
+ wait: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
85
+ ...dependencyOverrides,
86
+ };
87
+ log.info('正在创建 QMuse CLI 登录请求...');
88
+ if (options.logRequestDetails) {
89
+ log.dim(`登录请求接口:${buildUrl(cfg, '/api/login/session-exchange/request')}`);
90
+ }
91
+ const request = await dependencies.createRequest({ cfg });
92
+ const token = request.data?.token;
93
+ const expiresInSeconds = request.data?.expiresInSeconds;
94
+ if (!request.ok ||
95
+ !token ||
96
+ !SESSION_EXCHANGE_TOKEN_PATTERN.test(token) ||
97
+ typeof expiresInSeconds !== 'number' ||
98
+ !Number.isFinite(expiresInSeconds) ||
99
+ expiresInSeconds <= 0) {
100
+ return {
101
+ success: false,
102
+ method: 'browser',
103
+ message: `创建登录请求失败:${request.errorMessage ?? request.errorCode ?? `HTTP ${request.status}`}`,
104
+ };
105
+ }
106
+ const authUrl = new URL('/cli-auth', cfg.webBaseUrl);
107
+ authUrl.searchParams.set('request', token);
108
+ if (options.logRequestDetails) {
109
+ log.warn('以下调试信息包含短期有效的一次性登录凭证,请勿分享');
110
+ log.dim(`登录页面:${authUrl.toString()}`);
111
+ log.dim(`request token:${token}`);
112
+ }
113
+ log.info('正在打开 QMuse 登录页面...');
114
+ log.info(`确认码:${token.slice(-6).toUpperCase()}`);
115
+ if (!await dependencies.open(authUrl.toString())) {
116
+ return {
117
+ success: false,
118
+ method: 'browser',
119
+ message: '无法打开 QMuse 登录页面,请检查系统默认浏览器配置后重试',
120
+ };
121
+ }
122
+ log.info('等待网页确认登录...');
123
+ const deadline = dependencies.now() + Math.min(SESSION_EXCHANGE_POLL_TIMEOUT_MS, expiresInSeconds * 1000);
124
+ while (dependencies.now() < deadline) {
125
+ const exchanged = await dependencies.consume(token, { cfg });
126
+ const sessionId = exchanged.data?.sessionId;
127
+ if (exchanged.ok && sessionId) {
128
+ const verified = await dependencies.verify(sessionId, { cfg });
129
+ if (!verified.ok || !verified.data?.sessionId) {
130
+ return {
131
+ success: false,
132
+ method: 'browser',
133
+ message: `登录态校验失败:${verified.errorMessage ?? verified.errorCode ?? `HTTP ${verified.status}`}`,
134
+ };
135
+ }
136
+ dependencies.persist(verified.data.sessionId, cfg);
137
+ return { success: true, sessionId: verified.data.sessionId, method: 'browser' };
138
+ }
139
+ if (!SESSION_EXCHANGE_PENDING_CODES.has(exchanged.errorCode || '')) {
140
+ return {
141
+ success: false,
142
+ method: 'browser',
143
+ message: `同步登录态失败:${exchanged.errorMessage ?? exchanged.errorCode ?? `HTTP ${exchanged.status}`}`,
144
+ };
145
+ }
146
+ const remaining = deadline - dependencies.now();
147
+ if (remaining > 0) {
148
+ await dependencies.wait(Math.min(SESSION_EXCHANGE_POLL_INTERVAL_MS, remaining));
149
+ }
150
+ }
151
+ return {
152
+ success: false,
153
+ method: 'browser',
154
+ message: '等待网页确认超时,请重新执行 qmuse login',
155
+ };
156
+ }
157
+ export async function loginStatus(cfg) {
158
+ if (!(cfg.sessionId || '').trim() || cfg.credentialOrigin !== apiOrigin(cfg.baseUrl)) {
159
+ return { success: false, method: 'browser', message: '当前环境未登录,请先执行 qmuse login' };
160
+ }
161
+ const result = await api.exchangeSessionCookie(cfg.sessionId, { cfg });
162
+ if (!result.ok || !result.data?.sessionId) {
163
+ return {
164
+ success: false,
165
+ method: 'browser',
166
+ message: `会话无效或已过期:${result.errorMessage ?? result.errorCode ?? `HTTP ${result.status}`}`,
167
+ };
168
+ }
169
+ if (result.data.sessionId !== cfg.sessionId) {
170
+ persistSession(result.data.sessionId, cfg);
171
+ }
172
+ return {
173
+ success: true,
174
+ sessionId: result.data.sessionId,
175
+ method: 'browser',
176
+ message: '会话有效(已续期)',
177
+ };
178
+ }
@@ -0,0 +1,9 @@
1
+ import { createRequire } from 'node:module';
2
+ const packageMetadata = createRequire(import.meta.url)('../package.json');
3
+ export const QMUSE_CLI_NAME = 'qmuse-cli';
4
+ export const QMUSE_CLI_VERSION = typeof packageMetadata.version === 'string' && packageMetadata.version.trim()
5
+ ? packageMetadata.version.trim()
6
+ : 'unknown';
7
+ export const QMUSE_CLI_DISTRIBUTION = typeof packageMetadata.name === 'string' && packageMetadata.name.startsWith('@qmuse/')
8
+ ? 'PUBLIC'
9
+ : 'INTERNAL';