/**
* parse-client.mjs — Parse REST 客户端 + 凭据四级解析(零硬编码)
*
* 上报后端:https://server.fmode.cn/parse/classes/
* 鉴权:X-Parse-Application-Id + X-Parse-Master-Key(或用户 sessionToken)
*
* 凭据解析链(优先级从高到低,全部运行时读取,零密钥入库):
* 1. 环境变量 PARSE_APP_ID / PARSE_MASTER_KEY(或 sessionToken 走 X-Parse-Session-Token)
* 2. ~/.fmode/config.json → parse 段({ appId, masterKey } 或顶层同名键)
* 3. future-server/config/config.nova.json → parse 段(平台开发机标准位置)
* 4. 都找不到 → 打印指引并退出(绝不猜、绝不硬编码)
*
* Date 字段铁律(schema 111 坑):
* 裸字符串 "2026-09-12T08:00:00.000Z" 直接写 Parse 报 schema 111;
* 必须 wrapDate() 包装为 {"__type":"Date","iso":"..."} —— 本模块统一处理。
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
const DEFAULT_SERVER_URL = process.env.PARSE_SERVER_URL || 'https://server.fmode.cn/parse';
function readJsonMaybe(filePath) {
try {
if (!filePath || !fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf-8').replace(/^/, ''));
} catch {
return null;
}
}
/** 在任意层级找 parse 配置段:{parse:{appId,masterKey}} 或顶层 {appId,masterKey} */
function extractParseSection(obj) {
if (!obj || typeof obj !== 'object') return null;
const sec = obj.parse && typeof obj.parse === 'object' ? obj.parse : obj;
if (sec.appId && sec.masterKey) {
return { appId: String(sec.appId), masterKey: String(sec.masterKey), serverURL: sec.serverURLOnline || sec.publicServerURL || sec.serverURL || null };
}
return null;
}
/** 凭据四级解析。返回 {appId, masterKey, serverURL, source} 或 null。 */
export function resolveCredentials() {
// 第 1 级:环境变量
if (process.env.PARSE_APP_ID && process.env.PARSE_MASTER_KEY) {
return {
appId: process.env.PARSE_APP_ID.trim(),
masterKey: process.env.PARSE_MASTER_KEY.trim(),
serverURL: process.env.PARSE_SERVER_URL || DEFAULT_SERVER_URL,
source: 'env',
};
}
// 第 2 级:~/.fmode/config.json
const homeCfg = readJsonMaybe(path.join(os.homedir(), '.fmode', 'config.json'));
const fromHome = extractParseSection(homeCfg);
if (fromHome) {
return { ...fromHome, serverURL: fromHome.serverURL || DEFAULT_SERVER_URL, source: '~/.fmode/config.json' };
}
// 第 3 级:future-server config.nova.json(平台开发机标准位置)
const candidates = [
path.join(os.homedir(), 'git-repos', 'future-server', 'config', 'config.nova.json'),
'/opt/data/git-repos/future-server/config/config.nova.json',
process.env.FUTURE_SERVER_DIR ? path.join(process.env.FUTURE_SERVER_DIR, 'config', 'config.nova.json') : null,
].filter(Boolean);
for (const p of candidates) {
const cfg = readJsonMaybe(p);
const sec = extractParseSection(cfg);
if (sec) {
return { ...sec, serverURL: sec.serverURL || DEFAULT_SERVER_URL, source: p };
}
}
return null;
}
/** 未配置凭据时的统一报错指引(退出码 2) */
export function failNoCredentials() {
console.error('[task-progress] 未找到 Parse 凭据。按以下任一方式配置(零硬编码,凭据不入仓):');
console.error(' 1. 环境变量: export PARSE_APP_ID= PARSE_MASTER_KEY=');
console.error(' 2. ~/.fmode/config.json 写入 {"parse":{"appId":"...","masterKey":"..."}}');
console.error(' 3. 平台开发机: future-server/config/config.nova.json 的 parse 段自动读取');
process.exit(2);
}
/** Date 字段包装:裸字符串 → {"__type":"Date","iso":...}(防 schema 111) */
export function wrapDate(value) {
if (!value) return value;
if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) return value;
const d = new Date(value);
if (isNaN(d.getTime())) throw new Error(`无效日期: ${value}`);
return { __type: 'Date', iso: d.toISOString() };
}
/** Pointer 包装 */
export function wrapPointer(className, objectId) {
return { __type: 'Pointer', className, objectId };
}
function headers(cred, extra) {
const h = {
'X-Parse-Application-Id': cred.appId,
'X-Parse-Master-Key': cred.masterKey,
'Content-Type': 'application/json',
...extra,
};
return h;
}
async function parseFetch(url, options) {
const res = await fetch(url, { ...options, signal: AbortSignal.timeout(30000) });
const body = await res.json().catch(() => null);
if (!res.ok) {
const code = body && body.code ? body.code : res.status;
const err = new Error(`Parse ${res.status} code=${code}: ${JSON.stringify(body && body.error ? body.error : body)}`);
err.parseCode = code;
throw err;
}
return body;
}
/** GET /classes/(filters: {where, limit, order, keys}) */
export async function queryObjects(table, cred, filters = {}) {
const qs = new URLSearchParams();
if (filters.where) qs.set('where', JSON.stringify(filters.where));
if (filters.limit) qs.set('limit', String(filters.limit));
if (filters.order) qs.set('order', filters.order);
if (filters.keys) qs.set('keys', filters.keys);
const url = `${cred.serverURL}/classes/${table}${qs.toString() ? `?${qs}` : ''}`;
const body = await parseFetch(url, { method: 'GET', headers: headers(cred) });
return body.results || [];
}
/** POST /classes/ 创建;data 中 Date 字段自动包装 */
export async function createObject(table, cred, data) {
const url = `${cred.serverURL}/classes/${table}`;
return parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify(data) });
}
/** PUT /classes// 更新;data 中 Date 字段自动包装 */
export async function updateObject(table, cred, objectId, data) {
const url = `${cred.serverURL}/classes/${table}/${objectId}`;
return parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify(data) });
}
/** GET /schemas/(schema 校验用) */
export async function getSchema(table, cred) {
const url = `${cred.serverURL}/schemas/${table}`;
try {
return await parseFetch(url, { method: 'GET', headers: headers(cred) });
} catch (e) {
if (e.parseCode === 103 || (e.message && e.message.includes('class'))) return null; // 表不存在
throw e;
}
}
/** POST /schemas/ 建表;已存在的表(code 105)回落 PUT 增量补列(幂等) */
export async function createSchema(table, cred, fields) {
const url = `${cred.serverURL}/schemas/${table}`;
try {
return await parseFetch(url, { method: 'POST', headers: headers(cred), body: JSON.stringify({ className: table, fields }) });
} catch (e) {
if (e.parseCode === 105 || (e.message && e.message.includes('already exist'))) {
// 表已存在:PUT /schemas/ 增量加缺失列(已存在的列 Parse 自动忽略)
const body = await parseFetch(url, { method: 'PUT', headers: headers(cred), body: JSON.stringify({ fields }) });
return { idempotent: true, ...body };
}
// 106 = 字段已存在(幂等场景视为成功)
if (e.parseCode === 106 || (e.message && e.message.includes('already exist'))) {
return { idempotent: true, parseCode: e.parseCode };
}
throw e;
}
}
/** 幂等 upsert:按 where 查第一条,命中则覆盖更新,未命中则创建。返回 {objectId, created} */
export async function upsertObject(table, cred, where, data) {
const existing = await queryObjects(table, cred, { where, limit: 1 });
if (existing.length > 0) {
const objectId = existing[0].objectId;
await updateObject(table, cred, objectId, data);
return { objectId, created: false };
}
const created = await createObject(table, cred, data);
return { objectId: created.objectId, created: true };
}