1. 新增隐私配置同步脚本,自动从manifest和site.js同步真实隐私协议URL到鸿蒙构建产物 2. 补充鸿蒙元服务隐私托管双轨制适配逻辑 3. 新增完整的隐私弹窗组件单元测试覆盖 4. 修复构建产物隐私配置被占位符覆盖的问题
513 lines
18 KiB
JavaScript
513 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* harmony.patch.js
|
||
*
|
||
* 该脚本用于鸿蒙元服务的构建后处理(仅在生产构建时由 vue.config.js 触发):
|
||
* 1. 拷贝 harmony-configs 配置文件到构建产物目录
|
||
* 2. 拷贝 site.js 到构建产物目录
|
||
* 3. 创建 ZIP 压缩包
|
||
*
|
||
* 使用:
|
||
* node harmony.patch.js # 拷贝文件并创建 ZIP(默认 mode=production)
|
||
* node harmony.patch.js --no-zip # 只拷贝文件,不创建 ZIP
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const fsp = fs.promises;
|
||
const path = require('path');
|
||
|
||
const { openFileDirectory } = require('./open-directory');
|
||
const { createZipWithSystemCommand } = require('./create-zip');
|
||
|
||
async function syncSiteToStub(mode = 'production') {
|
||
try {
|
||
const cwd = path.join(__dirname, '..');
|
||
const srcSitePath = path.join(cwd, 'site.js');
|
||
const stubSitePath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'site.js');
|
||
|
||
if (await exists(srcSitePath)) {
|
||
await fsp.copyFile(srcSitePath, stubSitePath);
|
||
console.log('site.js 已同步到 stub 项目源码目录');
|
||
}
|
||
} catch (err) {
|
||
console.error('同步 site.js 到 stub 失败:', err);
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 同步 manifest.json 的 mp-harmony.privacy 配置到 stub 项目的 app.json
|
||
*
|
||
* 背景:
|
||
* 鸿蒙元服务上架审核需要从 app.json 读 privacy.privacyContractUrl 等字段,
|
||
* 实际生效的是编译产物里 ascf/ascf_src/app.json (ascf_build 拷贝自此)。
|
||
* 但当前 build 流程不会自动从 manifest.json 注入 privacy 字段,
|
||
* 如果只改 manifest.json 而忘记改 app.json, 审核时拿到的是旧 URL。
|
||
*
|
||
* 同步策略:
|
||
* 1. 读取 manifest.json 的 mp-harmony.privacy, 期望里面含占位符 YOUR_DOMAIN / YOUR_UNIACID
|
||
* 2. 从 site.js 解析 baseUrl (域名) + uniacid
|
||
* 3. 把占位符替换成 site.js 里的真实值, 写入 stub app.json
|
||
* 4. 若 manifest.json 中无 mp-harmony.privacy → 保留 app.json 现有配置 (向后兼容)
|
||
* 5. 若 site.js 缺 baseUrl 或 uniacid → 跳过同步, 警告用户
|
||
*
|
||
* 设计取舍:
|
||
* - manifest.json 用占位符占位 (避免每个 uniacid 切换都要改一次 manifest)
|
||
* - site.js 永远是真实值来源 (这是项目里已经存在的部署配置)
|
||
* - 同步在构建时执行, 一份配置适配所有 uniacid
|
||
*
|
||
* 依赖: syncSiteToStub 必须先于本函数执行 (在 main() 中已保证)
|
||
*/
|
||
|
||
/**
|
||
* 鲁棒的 JSONC 注释剥离 (跳过字符串字面量, 只剥真正的注释)
|
||
* 原因: manifest.json 顶部有 /* ... *\/ 注释, 用正则 `/\*[\s\S]*?\*\//g` 简单剥离时,
|
||
* 会跨过字符串字面量, 把"https://..."注释中的字符拼到真实 URL 后面,
|
||
* 引入换行符导致 JSON.parse 失败 (Bad control character)。
|
||
* 必须逐字符扫描, 跳过字符串内的 /*、//。
|
||
*/
|
||
function stripJsonComments(input) {
|
||
let out = '';
|
||
let i = 0;
|
||
const n = input.length;
|
||
while (i < n) {
|
||
const c = input[i];
|
||
const next = i + 1 < n ? input[i + 1] : '';
|
||
if (c === '/' && next === '*') {
|
||
i += 2;
|
||
while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if (c === '/' && next === '/') {
|
||
i += 2;
|
||
while (i < n && input[i] !== '\n') i++;
|
||
continue;
|
||
}
|
||
if (c === '"') {
|
||
out += c;
|
||
i++;
|
||
while (i < n) {
|
||
const sc = input[i];
|
||
if (sc === '\\') {
|
||
out += sc + (input[i + 1] || '');
|
||
i += 2;
|
||
continue;
|
||
}
|
||
out += sc;
|
||
i++;
|
||
if (sc === '"') break;
|
||
}
|
||
continue;
|
||
}
|
||
out += c;
|
||
i++;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 从 site.js 提取 baseUrl + uniacid
|
||
*
|
||
* site.js 形如:
|
||
* module.exports = {
|
||
* baseUrl: "https://xcx.aigc-quickapp.com/",//修改域名
|
||
* uniacid: 2482,//后台对应uniacid
|
||
* };
|
||
*
|
||
* 不直接 require, 因为:
|
||
* 1. site.js 在前端构建产物中用, 可能依赖一些运行时变量 (uni/Config/...)
|
||
* 2. 同步逻辑要在 vue.config.js 触发, 不希望引入额外依赖
|
||
* 3. regex 提取足够安全, 避免执行任意 JS
|
||
*
|
||
* @returns {{baseUrl: string, uniacid: string}|null} 解析失败返回 null
|
||
*/
|
||
function parseSiteConfig(siteJsSource) {
|
||
// baseUrl: 单/双引号字符串, 可带尾斜杠
|
||
const baseUrlMatch = siteJsSource.match(/baseUrl\s*:\s*['"]([^'"]+)['"]/)
|
||
// uniacid: 数字或字符串
|
||
const uniacidMatch = siteJsSource.match(/uniacid\s*:\s*['"]?(\d+)['"]?/)
|
||
if (!baseUrlMatch || !uniacidMatch) {
|
||
return null
|
||
}
|
||
return {
|
||
baseUrl: baseUrlMatch[1],
|
||
uniacid: uniacidMatch[1],
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把 baseUrl 拆成 protocol + host (无尾斜杠)
|
||
* 因为 site.js 的 baseUrl 通常带尾斜杠, 占位符 YOUR_DOMAIN
|
||
* 在 URL 中间出现 (例: https://YOUR_DOMAIN/api/xxx), 直接拼接
|
||
* 会产生双斜杠. 这里规范化为"协议://域名(无尾斜杠)".
|
||
*
|
||
* @param {string} baseUrl 例: "https://xcx.aigc-quickapp.com/"
|
||
* @returns {string} 例: "https://xcx.aigc-quickapp.com"
|
||
*/
|
||
function normalizeBaseUrlForPlaceholder(baseUrl) {
|
||
// 去掉尾斜杠
|
||
let url = baseUrl.replace(/\/+$/, '')
|
||
// 若没有协议, 加 https:// (避免出现 //xcx... 这种相对协议)
|
||
if (!/^https?:\/\//i.test(url)) {
|
||
url = 'https://' + url
|
||
}
|
||
return url
|
||
}
|
||
|
||
/**
|
||
* 递归替换隐私配置对象中的占位符
|
||
* @param {*} value 任意 JSON 值
|
||
* @param {object} siteConfig {baseUrl, uniacid}
|
||
* @returns {*} 替换后的新值 (不修改原对象)
|
||
*/
|
||
function replacePlaceholders(value, siteConfig) {
|
||
if (typeof value === 'string') {
|
||
return value
|
||
.replace(/YOUR_DOMAIN/g, siteConfig.baseUrl)
|
||
.replace(/YOUR_UNIACID/g, siteConfig.uniacid)
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.map(v => replacePlaceholders(v, siteConfig))
|
||
}
|
||
if (value !== null && typeof value === 'object') {
|
||
const out = {}
|
||
for (const k of Object.keys(value)) {
|
||
out[k] = replacePlaceholders(value[k], siteConfig)
|
||
}
|
||
return out
|
||
}
|
||
return value
|
||
}
|
||
|
||
/**
|
||
* 自定义错误: 验证 app.json 写后内容失败时抛出
|
||
* 上层 try/catch 会捕获并 process.exitCode = 1, 让 vue.config.js / npm run build 失败
|
||
*/
|
||
class AppJsonVerificationError extends Error {
|
||
constructor(messages) {
|
||
super('app.json 写后验证失败:\n - ' + messages.join('\n - '))
|
||
this.name = 'AppJsonVerificationError'
|
||
this.messages = messages
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证写完的 app.json 内容正确
|
||
*
|
||
* 关键字段:
|
||
* - 顶级 privacy 块存在
|
||
* - privacyContractUrl / serviceContractUrl 都以 https:// + baseUrl 开头
|
||
* - URL 不含占位符 YOUR_DOMAIN / YOUR_UNIACID
|
||
* - URL 不含双协议头 (https://https://)
|
||
* - URL 不含双斜杠 (https://x.com//api)
|
||
* - URL 的 uniacid= 参数值 == siteConfig.uniacid
|
||
* - JSON 仍能正确 parse (没有序列化失败)
|
||
*
|
||
* 失败行为: 抛 AppJsonVerificationError, 包含所有不通过项
|
||
*
|
||
* @param {string} appJsonPath
|
||
* @param {{baseUrl: string, uniacid: string}} siteConfig
|
||
*/
|
||
async function verifyWrittenAppJson(appJsonPath, siteConfig) {
|
||
const errors = []
|
||
|
||
// 1. 重新读取 + parse
|
||
let written
|
||
try {
|
||
const raw = await fsp.readFile(appJsonPath, 'utf8')
|
||
written = JSON.parse(raw)
|
||
} catch (e) {
|
||
throw new AppJsonVerificationError([`文件读取/解析失败: ${e.message}`])
|
||
}
|
||
|
||
// 2. 顶级 privacy 块
|
||
const privacy = written.privacy
|
||
if (!privacy || typeof privacy !== 'object') {
|
||
throw new AppJsonVerificationError(['privacy 块缺失或不是对象'])
|
||
}
|
||
|
||
// 3. URL 字段检查
|
||
const urlFields = ['privacyContractUrl', 'serviceContractUrl']
|
||
for (const field of urlFields) {
|
||
const url = privacy[field]
|
||
if (typeof url !== 'string') {
|
||
errors.push(`${field} 缺失或不是字符串 (实际: ${typeof url})`)
|
||
continue
|
||
}
|
||
|
||
// 不含占位符
|
||
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(url)) {
|
||
errors.push(`${field} 仍含占位符: ${url}`)
|
||
}
|
||
|
||
// 不含双协议头
|
||
if (/^https:\/\/https:|^http:\/\/http:/i.test(url)) {
|
||
errors.push(`${field} 含双协议头: ${url}`)
|
||
}
|
||
|
||
// 不含双斜杠 (协议头之后, 路径开始之前)
|
||
// 关键: 截到第一个 ? 或 # 之前, 检查是否含 // (避免误报 query 里的 //)
|
||
const afterProtocol = url.replace(/^https?:\/\//i, '')
|
||
const queryStart = afterProtocol.search(/[?#]/)
|
||
const beforeQuery = queryStart === -1 ? afterProtocol : afterProtocol.slice(0, queryStart)
|
||
if (beforeQuery.includes('//')) {
|
||
errors.push(`${field} 路径部分含双斜杠 (双斜杠 bug): ${url}`)
|
||
}
|
||
|
||
// 必须以 https:// + siteConfig.baseUrl 开头
|
||
const expectedPrefix = siteConfig.baseUrl + '/'
|
||
if (!url.startsWith(expectedPrefix)) {
|
||
errors.push(`${field} 不是以 "${expectedPrefix}" 开头 (实际: ${url.slice(0, expectedPrefix.length + 10)})`)
|
||
}
|
||
|
||
// uniacid= 参数值 == siteConfig.uniacid
|
||
const uniacidMatch = url.match(/[?&]uniacid=([^&]+)/)
|
||
if (!uniacidMatch) {
|
||
errors.push(`${field} 缺少 uniacid= 参数: ${url}`)
|
||
} else if (uniacidMatch[1] !== siteConfig.uniacid) {
|
||
errors.push(`${field} 的 uniacid=${uniacidMatch[1]} 不等于 site.js 的 uniacid=${siteConfig.uniacid}`)
|
||
}
|
||
}
|
||
|
||
// 4. contractName 字段未被破坏 (manifest 里的 zh/en name 应该原样保留)
|
||
const nameFields = ['privacyContractName', 'serviceContractName']
|
||
for (const field of nameFields) {
|
||
const name = privacy[field]
|
||
if (!name || typeof name !== 'object' || !name.zh || !name.en) {
|
||
errors.push(`${field} 结构损坏 (应有 zh/en): ${JSON.stringify(name)}`)
|
||
}
|
||
}
|
||
|
||
if (errors.length > 0) {
|
||
throw new AppJsonVerificationError(errors)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 同步 privacy 到**任意**目标 app.json 路径
|
||
*
|
||
* @param {string} appJsonPath 目标 app.json 文件绝对路径
|
||
* @param {string} [label] 标识 (用于日志, 例 "stub" / "build")
|
||
* @returns {Promise<{written: boolean, siteConfig?: object}>}
|
||
*
|
||
* 应用场景:
|
||
* - syncPrivacyToPath('stub-app-projects/.../app.json', 'stub')
|
||
* → 写 stub ascf_src/app.json (HBuilderX 之外的源)
|
||
* - syncPrivacyToPath('unpackage/dist/build/.mp-harmony/app.json', 'build')
|
||
* → 写 HBuilderX 构建产物的 app.json, **必须**在打 ZIP 之前
|
||
* → 否则发行 ZIP 里的 app.json 仍是占位符
|
||
*/
|
||
async function syncPrivacyToPath(appJsonPath, label = '') {
|
||
try {
|
||
const cwd = path.join(__dirname, '..');
|
||
const manifestPath = path.join(cwd, 'manifest.json');
|
||
const siteJsPath = path.join(cwd, 'site.js');
|
||
|
||
if (!await exists(manifestPath)) {
|
||
console.warn('[harmony.patch] manifest.json 不存在, 跳过 privacy 同步');
|
||
return { written: false };
|
||
}
|
||
if (!await exists(appJsonPath)) {
|
||
console.warn(`[harmony.patch] app.json 不存在 (${label || appJsonPath}), 跳过 privacy 同步`);
|
||
return { written: false };
|
||
}
|
||
|
||
// 1. 读 manifest.json, 提取 mp-harmony.privacy 配置 (含占位符)
|
||
const manifestRaw = await fsp.readFile(manifestPath, 'utf8');
|
||
const manifestClean = stripJsonComments(manifestRaw);
|
||
const manifest = JSON.parse(manifestClean);
|
||
const privacyConfigTemplate = manifest && manifest['mp-harmony'] && manifest['mp-harmony'].privacy;
|
||
|
||
if (!privacyConfigTemplate) {
|
||
return { written: false };
|
||
}
|
||
|
||
// 2. 读 site.js, 提取真实 baseUrl + uniacid
|
||
if (!await exists(siteJsPath)) {
|
||
console.warn('[harmony.patch] site.js 不存在, 跳过 privacy 同步');
|
||
return { written: false };
|
||
}
|
||
const siteJsSource = await fsp.readFile(siteJsPath, 'utf8');
|
||
const siteConfigRaw = parseSiteConfig(siteJsSource);
|
||
if (!siteConfigRaw) {
|
||
console.warn('[harmony.patch] ⚠️ site.js 解析失败 (找不到 baseUrl / uniacid), 跳过 privacy 同步');
|
||
return { written: false };
|
||
}
|
||
const siteConfig = {
|
||
baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl),
|
||
uniacid: siteConfigRaw.uniacid,
|
||
};
|
||
|
||
// 3. 占位符替换
|
||
const privacyConfig = replacePlaceholders(privacyConfigTemplate, siteConfig)
|
||
|
||
// 4. 校验替换结果
|
||
const finalStr = JSON.stringify(privacyConfig)
|
||
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(finalStr)) {
|
||
console.warn('[harmony.patch] ⚠️ 替换后仍含占位符, 跳过写入. 请检查 site.js / manifest.json 配置')
|
||
return { written: false };
|
||
}
|
||
|
||
// 5. 写目标 app.json
|
||
const appJsonRaw = await fsp.readFile(appJsonPath, 'utf8');
|
||
const appJson = JSON.parse(appJsonRaw);
|
||
appJson.privacy = privacyConfig;
|
||
await fsp.writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8');
|
||
const labelSuffix = label ? ` (${label})` : '';
|
||
console.log(`[harmony.patch] privacy 配置已同步到 app.json${labelSuffix} (baseUrl=${siteConfig.baseUrl}, uniacid=${siteConfig.uniacid})`);
|
||
|
||
// 6. 写后验证
|
||
await verifyWrittenAppJson(appJsonPath, siteConfig)
|
||
return { written: true, siteConfig }
|
||
} catch (err) {
|
||
console.error(`[harmony.patch] 同步 privacy 配置失败 (${label || appJsonPath}):`, err);
|
||
process.exitCode = 1;
|
||
return { written: false }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 同步 privacy 到 stub ascf_src/app.json (兼容旧调用)
|
||
*/
|
||
async function syncPrivacyToStub() {
|
||
const cwd = path.join(__dirname, '..');
|
||
const stubAppJsonPath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'app.json');
|
||
return syncPrivacyToPath(stubAppJsonPath, 'stub');
|
||
}
|
||
|
||
async function copyAssets(mode = 'production') {
|
||
try {
|
||
const cwd = path.join(__dirname, '..');
|
||
const destDir = path.join(cwd, 'unpackage', 'dist', mode === 'production' ? 'build' : 'dev', '.mp-harmony');
|
||
|
||
await ensureDir(destDir);
|
||
|
||
const copyPromises = [];
|
||
|
||
if (await exists(path.join(cwd, 'harmony-configs'))) {
|
||
copyPromises.push(copyDirectory(path.join(cwd, 'harmony-configs'), path.join(destDir, 'harmony-configs')));
|
||
}
|
||
|
||
const srcSitePath = path.join(cwd, 'site.js');
|
||
if (await exists(srcSitePath)) {
|
||
copyPromises.push(fsp.copyFile(srcSitePath, path.join(destDir, 'site.js')));
|
||
// 同步一份到 entry rawfile,让 FormAbility 能在运行时 getRawFileContent('site.js') 读取
|
||
// (FormAbility 进程读不到 ascf_src/ 那份 webpack bundle,需要 rawfile 副本)
|
||
const rawfileSitePath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'entry', 'src', 'main', 'resources', 'rawfile', 'site.js');
|
||
copyPromises.push(fsp.copyFile(srcSitePath, rawfileSitePath));
|
||
}
|
||
|
||
await Promise.all(copyPromises);
|
||
|
||
// 给所有子组件 .json 注入 componentPlaceholder, 避免 "diy-* is asyncComponent" 报错.
|
||
// 见 scripts/harmony-component-placeholder.js 说明. 该脚本跳过页面级, 只处理组件.
|
||
try {
|
||
const { execFileSync } = require('child_process');
|
||
execFileSync(process.execPath, [
|
||
path.join(__dirname, 'harmony-component-placeholder.js'),
|
||
destDir,
|
||
], { stdio: 'inherit', cwd });
|
||
} catch (e) {
|
||
console.warn('[harmony.patch] ⚠️ harmony-component-placeholder 注入失败:', e.message);
|
||
}
|
||
|
||
console.log('HarmonyOS 资源拷贝完成');
|
||
} catch (err) {
|
||
console.error('发生错误:', err);
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
async function copyDirectory(src, dest) {
|
||
await ensureDir(dest);
|
||
const entries = await fsp.readdir(src, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const srcPath = path.join(src, entry.name);
|
||
const destPath = path.join(dest, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await copyDirectory(srcPath, destPath);
|
||
} else {
|
||
await fsp.copyFile(srcPath, destPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const argv = process.argv.slice(2);
|
||
const options = {
|
||
noZip: argv.includes('--no-zip'),
|
||
mode: 'production',
|
||
syncPrivacyOnly: argv.includes('--sync-privacy-only'),
|
||
};
|
||
|
||
const modeIndex = argv.indexOf('--mode');
|
||
if (modeIndex !== -1 && modeIndex + 1 < argv.length) {
|
||
options.mode = argv[modeIndex + 1];
|
||
}
|
||
|
||
// 子命令: --sync-privacy-only
|
||
// vue.config.js _copyMpHarmonyBuild() 后, 构建产物的 app.json
|
||
// 会覆盖 stub ascf_src/app.json (因为产物里 app.json 含占位符)。
|
||
// vue.config.js 此时再 exec 这个子命令, 强制重新同步一次。
|
||
if (options.syncPrivacyOnly) {
|
||
console.log('[harmony.patch] --sync-privacy-only: 重新同步 privacy (覆盖 _copyMpHarmonyBuild 的拷贝)');
|
||
await syncPrivacyToStub();
|
||
return;
|
||
}
|
||
|
||
// 同步 site.js 到 stub 项目源码目录,确保编译时能动态导入
|
||
await syncSiteToStub(options.mode);
|
||
|
||
// 同步 manifest.json 的 mp-harmony.privacy 到 stub app.json (防止上架时 URL 不同步)
|
||
await syncPrivacyToStub();
|
||
|
||
await copyAssets(options.mode);
|
||
|
||
// 关键: 在打 ZIP 之前, 同步一次 **构建产物** 的 app.json
|
||
// HBuilderX 构建产物的 app.json 含占位符, 不修正就直接打 ZIP,
|
||
// 发行包里的 app.json 还是占位符, 上架会被审核驳回。
|
||
// 这步必须在 ZIP 之前, 必须在 _copyMpHarmonyBuild 之后 (vue.config.js 顺序)。
|
||
if (!options.noZip && options.mode === 'production') {
|
||
const cwd = path.join(__dirname, '..');
|
||
const buildAppJsonPath = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony', 'app.json');
|
||
if (await exists(buildAppJsonPath)) {
|
||
console.log('[harmony.patch] 同步构建产物的 app.json (打 ZIP 前)');
|
||
await syncPrivacyToPath(buildAppJsonPath, 'build');
|
||
} else {
|
||
console.warn(`[harmony.patch] 构建产物的 app.json 不存在: ${buildAppJsonPath}`);
|
||
}
|
||
}
|
||
|
||
if (!options.noZip && options.mode === 'production') {
|
||
const cwd = path.join(__dirname, '..');
|
||
const sourceDir = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony');
|
||
const destDir = path.join(cwd, 'unpackage', 'dist', 'build');
|
||
const zipFilePath = await createZipWithSystemCommand(sourceDir, destDir, 'mp-harmony');
|
||
console.log(`ZIP 文件路径: ${zipFilePath}`);
|
||
|
||
await openFileDirectory(zipFilePath);
|
||
} else {
|
||
console.log('跳过创建 ZIP 文件和打开目录');
|
||
}
|
||
}
|
||
|
||
async function exists(p) {
|
||
try {
|
||
await fsp.access(p, fs.constants.F_OK);
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function ensureDir(dirPath) {
|
||
try {
|
||
await fsp.mkdir(dirPath, { recursive: true });
|
||
} catch (e) {
|
||
if (e.code !== 'EEXIST') throw e;
|
||
}
|
||
}
|
||
|
||
main();
|