@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.
package/dist/client.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * QMuse HTTP 客户端:
3
+ * - 拼接 baseUrl + apiPrefix + 路径
4
+ * - 仅向登录时绑定的 API Origin 注入 sessionId
5
+ * - 兼容两种响应包装:
6
+ * * CommonHttpResult<T> = { success, data?, errorCode?, errorMessage?, traceId? }
7
+ * * IQmuseResult<T> = { success, errorCode, errorMessage, retriable?, resultObj? }
8
+ */
9
+ import { apiOrigin, loadConfig } from './config.js';
10
+ import { QMUSE_CLI_DISTRIBUTION, QMUSE_CLI_NAME, QMUSE_CLI_VERSION, } from './version.js';
11
+ /** 拼接完整请求地址。 */
12
+ export function buildUrl(cfg, path) {
13
+ const base = cfg.baseUrl.replace(/\/+$/, '');
14
+ const prefix = cfg.apiPrefix || '';
15
+ return `${base}${prefix}${path}`;
16
+ }
17
+ export function requestSourceHeaders(cfg) {
18
+ const origin = apiOrigin(cfg.webBaseUrl);
19
+ if (!origin)
20
+ throw new Error('QMuse Web 地址不是有效的 HTTP(S) 地址');
21
+ return {
22
+ Referer: `${origin}/`,
23
+ Origin: origin,
24
+ 'x-qmuse-client': QMUSE_CLI_NAME,
25
+ 'x-qmuse-client-version': QMUSE_CLI_VERSION,
26
+ 'x-qmuse-client-distribution': QMUSE_CLI_DISTRIBUTION,
27
+ };
28
+ }
29
+ function credentialMatchesTarget(cfg) {
30
+ const expected = (cfg.credentialOrigin || '').trim();
31
+ const target = apiOrigin(cfg.baseUrl);
32
+ return !!expected && !!target && expected === target;
33
+ }
34
+ /** 构造鉴权请求头;凭证与请求 Origin 不匹配时不会发送凭证。 */
35
+ export function authHeaders(cfg) {
36
+ const headers = {
37
+ 'Content-Type': 'application/json',
38
+ Accept: 'application/json',
39
+ 'x-qmuse-client': QMUSE_CLI_NAME,
40
+ 'x-qmuse-client-version': QMUSE_CLI_VERSION,
41
+ 'x-qmuse-client-distribution': QMUSE_CLI_DISTRIBUTION,
42
+ };
43
+ const sid = (cfg.sessionId || '').trim();
44
+ if (sid && credentialMatchesTarget(cfg)) {
45
+ headers['x-session-id'] = sid;
46
+ headers['Cookie'] = `muse_user=${sid}`;
47
+ }
48
+ // 当前操作空间(后端 UserAuthService 优先读 x-space-id 头,高于缓存)
49
+ const spaceId = (cfg.spaceId || '').trim();
50
+ if (spaceId) {
51
+ headers['x-space-id'] = spaceId;
52
+ }
53
+ return headers;
54
+ }
55
+ /** 是否缺少鉴权配置。 */
56
+ export function hasAuth(cfg) {
57
+ return !!(cfg.sessionId || '').trim() && credentialMatchesTarget(cfg);
58
+ }
59
+ function extractData(json) {
60
+ if (json && typeof json === 'object' && typeof json.success === 'boolean') {
61
+ const ok = json.success === true;
62
+ // CommonHttpResult 优先取 data,IQmuseResult 取 resultObj
63
+ const data = json.data !== undefined ? json.data : json.resultObj;
64
+ return {
65
+ ok,
66
+ data: data,
67
+ errorCode: json.errorCode,
68
+ errorMessage: json.errorMessage,
69
+ };
70
+ }
71
+ // 非标准包装:按 HTTP 状态判定,原始体作为 data 返回
72
+ return { ok: false, data: json };
73
+ }
74
+ /** 发起一次 musepub 调用。 */
75
+ export async function apiCall(method, path, body, cfgOverrideOrOpts) {
76
+ const opts = cfgOverrideOrOpts && typeof cfgOverrideOrOpts === 'object' && !('baseUrl' in cfgOverrideOrOpts)
77
+ ? cfgOverrideOrOpts
78
+ : { cfg: cfgOverrideOrOpts };
79
+ const cfg = opts.cfg ?? loadConfig();
80
+ const url = buildUrl(cfg, path);
81
+ const headers = opts.anonymous
82
+ ? {
83
+ 'Content-Type': 'application/json',
84
+ Accept: 'application/json',
85
+ 'x-qmuse-client': QMUSE_CLI_NAME,
86
+ 'x-qmuse-client-version': QMUSE_CLI_VERSION,
87
+ 'x-qmuse-client-distribution': QMUSE_CLI_DISTRIBUTION,
88
+ }
89
+ : authHeaders(cfg);
90
+ if (method === 'POST' && !headers['Referer']) {
91
+ Object.assign(headers, requestSourceHeaders(cfg));
92
+ }
93
+ const init = { method, headers };
94
+ if (body !== undefined && method !== 'GET') {
95
+ init.body = JSON.stringify(body);
96
+ }
97
+ let res;
98
+ try {
99
+ res = await fetch(url, init);
100
+ }
101
+ catch (err) {
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ return {
104
+ ok: false,
105
+ status: 0,
106
+ raw: null,
107
+ setCookie: [],
108
+ errorCode: 'NETWORK_ERROR',
109
+ errorMessage: `请求失败 ${url}: ${msg}`,
110
+ };
111
+ }
112
+ let json = null;
113
+ const text = await res.text();
114
+ if (text) {
115
+ try {
116
+ json = JSON.parse(text);
117
+ }
118
+ catch {
119
+ json = null;
120
+ }
121
+ }
122
+ const setCookie = readSetCookie(res);
123
+ const extracted = extractData(json);
124
+ return {
125
+ ok: extracted.ok,
126
+ data: extracted.data,
127
+ errorCode: extracted.errorCode,
128
+ errorMessage: extracted.errorMessage,
129
+ raw: json,
130
+ status: res.status,
131
+ setCookie,
132
+ };
133
+ }
134
+ /** 从响应读取所有 Set-Cookie 头(Node undici Headers.getSetCookie)。 */
135
+ function readSetCookie(res) {
136
+ const h = res.headers;
137
+ if (typeof h.getSetCookie === 'function') {
138
+ try {
139
+ return h.getSetCookie() ?? [];
140
+ }
141
+ catch {
142
+ return [];
143
+ }
144
+ }
145
+ // 回退:getSetCookie 不可用时从原始头拼接
146
+ const raw = res.headers.get('set-cookie');
147
+ return raw ? [raw] : [];
148
+ }
package/dist/cloud.js ADDED
@@ -0,0 +1,36 @@
1
+ import { apiCall } from './client.js';
2
+ import { loadManagedProject } from './import/project.js';
3
+ export const CLOUD_INIT_SUCCESS_MESSAGE = 'Cloud service entitlement verified successfully. Continue implementing the requested ' +
4
+ 'cloud features by following the cloud service skill instructions.';
5
+ function entitlementError(snapshot) {
6
+ if (snapshot.reason === 'CLOUD_SERVICE_LIMIT_EXCEEDED') {
7
+ const usage = snapshot.appLimit === undefined
8
+ ? ''
9
+ : `(已使用 ${snapshot.enabledAppCount ?? 0}/${snapshot.appLimit})`;
10
+ return new Error(`当前空间云服务额度已满${usage}`);
11
+ }
12
+ return new Error('当前空间没有可用的云服务权益');
13
+ }
14
+ export async function checkCloudInit(rootDir, cfg, explicitEnvironment) {
15
+ const project = await loadManagedProject(rootDir, cfg, explicitEnvironment);
16
+ const appId = project?.target?.appId;
17
+ const spaceId = project?.target?.spaceId ||
18
+ project?.manifest.defaultSpaceId ||
19
+ cfg.spaceId;
20
+ if (!spaceId) {
21
+ throw new Error('请先执行 qmuse space use <spaceId>,或通过 --space-id 指定目标空间');
22
+ }
23
+ const response = appId
24
+ ? await apiCall('POST', '/api/appwrite/entitlement/check', { appId }, cfg)
25
+ : await apiCall('POST', '/api/appwrite/entitlement/space/check', { spaceId }, cfg);
26
+ if (!response.ok || !response.data) {
27
+ throw new Error(`校验 QMuse 云服务权益失败:${response.errorMessage ?? response.errorCode ?? `HTTP ${response.status}`}`);
28
+ }
29
+ if (!response.data.entitled)
30
+ throw entitlementError(response.data);
31
+ return {
32
+ ...response.data,
33
+ ...(appId ? { appId } : {}),
34
+ spaceId,
35
+ };
36
+ }
package/dist/config.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * QMuse CLI 配置。
3
+ *
4
+ * 登录凭证只从用户级配置读取;项目级配置只能提供空间选择,不能改变请求目标
5
+ * 或注入请求头。凭证同时绑定登录时的 API Origin,切换环境后必须重新登录。
6
+ */
7
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ const CONFIG_FILE = '.qmuse-cli.json';
11
+ export const DEFAULT_CONFIG = {
12
+ baseUrl: 'https://api.qmuse.cn',
13
+ apiPrefix: '',
14
+ webBaseUrl: 'https://qmuse.cn',
15
+ sessionId: '',
16
+ credentialOrigin: '',
17
+ };
18
+ const ENVIRONMENT_CONFIG = {
19
+ prod: {
20
+ baseUrl: 'https://api.qmuse.cn',
21
+ apiPrefix: '',
22
+ webBaseUrl: 'https://qmuse.cn',
23
+ },
24
+ pre: {
25
+ baseUrl: 'https://api-pre.qmuse.cn',
26
+ apiPrefix: '',
27
+ webBaseUrl: 'https://qmuse-pre.qmuse.cn',
28
+ },
29
+ local: {
30
+ baseUrl: 'http://127.0.0.1:7001',
31
+ apiPrefix: '',
32
+ webBaseUrl: 'https://local.dev.qmuse.cn:8000',
33
+ },
34
+ };
35
+ /** 候选配置路径:项目级在前、用户级在后。 */
36
+ export function configPaths() {
37
+ return [join(process.cwd(), CONFIG_FILE), join(homedir(), CONFIG_FILE)];
38
+ }
39
+ function readConfig(path) {
40
+ if (!existsSync(path))
41
+ return {};
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
44
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
45
+ ? parsed
46
+ : {};
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ export function apiOrigin(baseUrl) {
53
+ try {
54
+ const url = new URL(baseUrl);
55
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : '';
56
+ }
57
+ catch {
58
+ return '';
59
+ }
60
+ }
61
+ function storedEnvironment(value) {
62
+ if (typeof value !== 'string')
63
+ return undefined;
64
+ const normalized = value.replace(/\/+$/u, '');
65
+ return Object.values(ENVIRONMENT_CONFIG).find(environment => environment.baseUrl === normalized);
66
+ }
67
+ /**
68
+ * 加载配置。项目配置即使由仓库提交,也只能覆盖 spaceId。
69
+ * 旧配置没有 credentialOrigin 时,将凭证绑定到用户配置自身的 baseUrl。
70
+ */
71
+ export function loadConfig(paths = configPaths()) {
72
+ const [projectPath, userPath] = paths;
73
+ const user = readConfig(userPath);
74
+ 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
+ : '';
84
+ return {
85
+ ...DEFAULT_CONFIG,
86
+ sessionId,
87
+ credentialOrigin,
88
+ ...(typeof user.spaceId === 'string' && user.spaceId.trim()
89
+ ? { spaceId: user.spaceId.trim() }
90
+ : {}),
91
+ ...(typeof project.spaceId === 'string' && project.spaceId.trim()
92
+ ? { spaceId: project.spaceId.trim() }
93
+ : {}),
94
+ };
95
+ }
96
+ export function applyEnvironment(cfg, environment) {
97
+ return { ...cfg, ...ENVIRONMENT_CONFIG[environment] };
98
+ }
99
+ /** 保存配置。项目级文件仅允许保存不敏感的空间选择。 */
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);
110
+ return path;
111
+ }
112
+ export function findConfigPath() {
113
+ for (const path of configPaths()) {
114
+ if (existsSync(path))
115
+ return path;
116
+ }
117
+ return null;
118
+ }
@@ -0,0 +1,57 @@
1
+ import { openAsBlob } from 'node:fs';
2
+ import { apiCall, authHeaders, buildUrl, requestSourceHeaders, } from '../client.js';
3
+ export async function uploadImportArchive(input, cfg) {
4
+ const archive = await openAsBlob(input.filePath, { type: 'application/zip' });
5
+ const headers = authHeaders(cfg);
6
+ headers['Content-Type'] = 'application/zip';
7
+ headers['Content-Length'] = String(archive.size);
8
+ headers['x-space-id'] = input.spaceId;
9
+ Object.assign(headers, requestSourceHeaders(cfg));
10
+ let response;
11
+ try {
12
+ response = await fetch(buildUrl(cfg, '/api/app-import/source'), {
13
+ method: 'POST',
14
+ headers,
15
+ body: archive,
16
+ });
17
+ }
18
+ catch (error) {
19
+ throw new Error(`上传源码失败:${error instanceof Error ? error.message : String(error)}`);
20
+ }
21
+ let result;
22
+ try {
23
+ result = await response.json();
24
+ }
25
+ catch {
26
+ throw new Error('上传源码失败:服务端响应格式错误');
27
+ }
28
+ if (!response.ok || result.success !== true || !result.data?.sourceToken) {
29
+ throw new Error(`上传源码失败:${result.errorMessage ?? result.errorCode ?? `HTTP ${response.status}`}`);
30
+ }
31
+ return result.data.sourceToken;
32
+ }
33
+ export function startAppImport(body, cfg) {
34
+ return apiCall('POST', '/api/app-import/start', body, cfg);
35
+ }
36
+ export function queryAppImportStatus(jobId, cfg) {
37
+ return apiCall('POST', '/api/app-import/status', { jobId }, cfg);
38
+ }
39
+ export function configureAppImport(body, cfg) {
40
+ return apiCall('POST', '/api/app-import/configure', body, cfg);
41
+ }
42
+ export function getAppOpenUrl(webBaseUrl, appId) {
43
+ let base;
44
+ try {
45
+ base = new URL(webBaseUrl);
46
+ }
47
+ catch {
48
+ throw new Error(`QMuse Web 地址无效:${webBaseUrl}`);
49
+ }
50
+ if (!['http:', 'https:'].includes(base.protocol)) {
51
+ throw new Error(`QMuse Web 地址必须使用 HTTP(S):${webBaseUrl}`);
52
+ }
53
+ return new URL(`/app/${encodeURIComponent(appId)}`, base).toString();
54
+ }
55
+ export function getImportStatusUrl(cfg) {
56
+ return buildUrl(cfg, '/api/app-import/status');
57
+ }
@@ -0,0 +1,52 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { ZipFile } from 'yazl';
6
+ import { createSourceManifest, SOURCE_MANIFEST_PATH } from './source-manifest.js';
7
+ const MAX_ARCHIVE_BYTES = 500 * 1024 * 1024;
8
+ const STABLE_ZIP_MTIME = new Date('1980-01-01T00:00:00.000Z');
9
+ export async function createImportArchive(preflight) {
10
+ const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'qmuse-import-'));
11
+ const fileName = `${path.basename(preflight.rootDir)}.zip`;
12
+ const filePath = path.join(temporaryDirectory, fileName);
13
+ const zip = new ZipFile();
14
+ const output = createWriteStream(filePath, { mode: 0o600 });
15
+ const completed = new Promise((resolve, reject) => {
16
+ output.once('close', resolve);
17
+ output.once('error', reject);
18
+ zip.outputStream.once('error', reject);
19
+ });
20
+ zip.outputStream.pipe(output);
21
+ for (const file of preflight.files) {
22
+ zip.addFile(file.absolutePath, file.relativePath, {
23
+ mtime: STABLE_ZIP_MTIME,
24
+ mode: 0o100644,
25
+ compress: true,
26
+ });
27
+ }
28
+ zip.addBuffer(Buffer.from(`${JSON.stringify(createSourceManifest(preflight), null, 2)}\n`, 'utf8'), SOURCE_MANIFEST_PATH, {
29
+ mtime: STABLE_ZIP_MTIME,
30
+ mode: 0o100644,
31
+ compress: true,
32
+ });
33
+ zip.end();
34
+ try {
35
+ await completed;
36
+ const sizeBytes = (await stat(filePath)).size;
37
+ if (sizeBytes <= 0)
38
+ throw new Error('生成的源码压缩包为空');
39
+ if (sizeBytes > MAX_ARCHIVE_BYTES)
40
+ throw new Error('源码压缩包超过 500MB');
41
+ return {
42
+ filePath,
43
+ fileName,
44
+ sizeBytes,
45
+ cleanup: () => rm(temporaryDirectory, { recursive: true, force: true }),
46
+ };
47
+ }
48
+ catch (error) {
49
+ await rm(temporaryDirectory, { recursive: true, force: true });
50
+ throw error;
51
+ }
52
+ }
@@ -0,0 +1,39 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ export const QMUSE_IMPORT_DIAGNOSTIC_PATH = '.qmuse/last-import-error.json';
5
+ export async function writeImportFailureDiagnostic(input) {
6
+ if (input.status.status !== 'FAILED') {
7
+ throw new Error('只能为失败的导入任务写入诊断文件');
8
+ }
9
+ const filePath = path.join(input.rootDir, QMUSE_IMPORT_DIAGNOSTIC_PATH);
10
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
11
+ await mkdir(path.dirname(filePath), { recursive: true });
12
+ const payload = {
13
+ schemaVersion: 1,
14
+ jobId: input.status.jobId,
15
+ status: input.status.status,
16
+ stage: input.status.stage,
17
+ message: input.status.message,
18
+ diagnostic: input.status.diagnostic,
19
+ failureDiagnostic: input.status.failureDiagnostic,
20
+ retryCommand: 'qmuse import .',
21
+ generatedAt: new Date().toISOString(),
22
+ };
23
+ try {
24
+ await writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, {
25
+ encoding: 'utf8',
26
+ mode: 0o600,
27
+ flag: 'wx',
28
+ });
29
+ await rename(temporaryPath, filePath);
30
+ }
31
+ catch (error) {
32
+ await rm(temporaryPath, { force: true });
33
+ throw error;
34
+ }
35
+ return filePath;
36
+ }
37
+ export async function clearImportFailureDiagnostic(rootDir) {
38
+ await rm(path.join(rootDir, QMUSE_IMPORT_DIAGNOSTIC_PATH), { force: true });
39
+ }
@@ -0,0 +1,45 @@
1
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
2
+ const DEFAULT_POLL_TIMEOUT_MS = 35 * 60 * 1_000;
3
+ const MAX_CONSECUTIVE_RETRIABLE_ERRORS = 5;
4
+ function isRetriable(result) {
5
+ return result.status === 0 || result.status === 429 || result.status >= 500;
6
+ }
7
+ function apiFailure(result) {
8
+ return new Error(`查询导入任务失败:${result.errorMessage ?? result.errorCode ?? `HTTP ${result.status}`}`);
9
+ }
10
+ export async function pollAppImport(query, options = {}) {
11
+ const now = options.now ?? Date.now;
12
+ const wait = options.wait ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)));
13
+ const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
14
+ const deadline = now() + (options.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS);
15
+ let consecutiveErrors = 0;
16
+ let lastStatus;
17
+ while (now() < deadline) {
18
+ if (options.signal?.aborted) {
19
+ return { reason: 'INTERRUPTED', status: lastStatus };
20
+ }
21
+ const result = await query();
22
+ if (!result.ok || !result.data) {
23
+ if (!isRetriable(result) || ++consecutiveErrors > MAX_CONSECUTIVE_RETRIABLE_ERRORS) {
24
+ throw apiFailure(result);
25
+ }
26
+ }
27
+ else {
28
+ consecutiveErrors = 0;
29
+ lastStatus = result.data;
30
+ options.onStatus?.(result.data);
31
+ if (result.data.status === 'READY') {
32
+ return { reason: 'READY', status: result.data };
33
+ }
34
+ if (result.data.status === 'FAILED') {
35
+ return { reason: 'FAILED', status: result.data };
36
+ }
37
+ if (result.data.status === 'WAITING_CLOUD_CONFIRMATION' ||
38
+ result.data.status === 'NEEDS_CONFIGURATION') {
39
+ return { reason: 'CONFIGURATION_REQUIRED', status: result.data };
40
+ }
41
+ }
42
+ await wait(intervalMs);
43
+ }
44
+ throw new Error('等待导入任务超时,可使用 `qmuse import status <jobId> --wait` 继续查询');
45
+ }