From 51497269488a32aad65b92c6b17b0a9cb9934d61 Mon Sep 17 00:00:00 2001 From: rnet Date: Sun, 21 Apr 2024 21:29:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201.=20makecode=E5=AD=90=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=A2=9E=E5=8A=A0-j=E5=85=A5=E5=8F=82=EF=BC=8C?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=E8=BF=98=E5=8E=9F$=5Fts.l=5F=5F=E5=A4=84?= =?UTF-8?q?=E7=90=86=E7=9A=84js=E4=BB=A3=E7=A0=81=202.=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=96=87=E4=BB=B6=E8=B0=83=E6=95=B4=203.=20r?= =?UTF-8?q?eadme=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 81 ++++++++++++++++--------------- main.js | 24 ++++++---- package.json | 4 +- src/handler/AppCode.js | 28 +++++++---- src/makeCode.js | 105 ++++++++++++++++++++++++----------------- src/makeCodeHigh.js | 21 ++------- src/makeCookie.js | 2 +- utils/getCode.js | 24 ++++++++-- utils/paths.js | 1 + 9 files changed, 164 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 4e7b28c..923dc75 100644 --- a/README.md +++ b/README.md @@ -35,34 +35,41 @@ npm包不能保证最新代码,最新代码以仓库代码为准! ```console $ npx rs-reverse makecode -h - rs-reverse makecode +rs-reverse makecode -生成动态代码 +接收ts.json文件生成immucfg、ts、ts-full文件,如果传入的是url则还会生成html、主代 +码、动态代码文件,还可通过-j命令接收多个$_ts.l__处理的文件url并生成该js文件及解 +密后的js文件。 + +**`-j`参数需要注意,链接地址必须带上查询参数,不带的话返回的是未经过瑞数处理的文件,可以从浏览器控制台查看带参数的完整地址,如果待解密的js文件存在多个时,为了保证结果中变量名与瑞数解析的变量名一致,需要按浏览器的解析顺序依序传入(因为变量名存在复用逻辑)。** Options: -h 显示帮助信息 [boolean] -f, --file 含有nsd, cd值的json文件 [string] -l, --level 日志打印等级,参考log4js,默认为info [string] -u, --url 瑞数返回204状态码的请求地址 [string] - -v, --version 显示版本号 [boolean] - -Examples: - rs-reverse makecode -f example/codes/1-$_ts.json - rs-reverse makecode -u http://url/path + -a, --adapt 已经做了适配的网站名称,不传则为cnipa [string] + -j, --jsurls $_ts.__l方法执行的js文件链接(必须带上查询参数),多个时需要按顺 + 序传入,如:-j "https://host/chunk.js?4VGu1xaT=a728b2" -j + "https://host/app.js?4VGu1xaT=a728b2" [array] + -v, --version 显示版本号 ``` 调用示例: ```bash - $ npx rs-reverse makecode -u https://wcjs.sbj.cnipa.gov.cn/sgtmi + $ npx rs-reverse makecode -u https://wcjs.sbj.cnipa.gov.cn/sgtmi -j 'https://wcjs.sbj.cnipa.gov.cn/js/chunk-vendors.66e24864.js?查询参数' -j 'https://wcjs.sbj.cnipa.gov.cn/js/app.9f7a91c9.js?查询参数' - url方式提取的ts:/path/to/output/makecode_input_ts.json - url方式提取的静态文本:/path/to/output/makecode_input_immucfg.json - url方式提取的javascript代码:/path/to/output/makecode_input_js.js - url方式提取的html代码:/path/to/output/makecode_input_html.html - - 程序生成的ts:/path/to/output/makecode_output_ts.json - 程序生成的动态代码:/path/to/output/makecode_output_code.js + url方式提取的ts:output/makecode/ts.json + url方式提取的静态文本:output/makecode/immucfg.json + 程序生成的ts:output/makecode/ts-full.json + url方式提取的html代码:output/makecode/sgtmi.html + url方式提取的javascript代码:output/makecode/cCdzB9ZjDFks.a728b22.js + cCdzB9ZjDFks.a728b22.js生成的动态代码:output/makecode/cCdzB9ZjDFks.a728b22-dynamic.js + url方式提取的javascript代码:output/makecode/chunk-vendors.66e24864.js + chunk-vendors.66e24864.js生成的解密代码:output/makecode/chunk-vendors.66e24864-decrypt.js + url方式提取的javascript代码:output/makecode/app.9f7a91c9.js + app.9f7a91c9.js生成的解密代码:output/makecode/app.9f7a91c9-decrypt.js ``` @@ -79,7 +86,7 @@ Examples: $ npx rs-reverse makecookie -h rs-reverse makecookie -生成动态代码 +生成cookie值并打印 Options: -h 显示帮助信息 [boolean] @@ -88,10 +95,6 @@ Options: -u, --url 瑞数返回204状态码的请求地址 [string] -a, --adapt 已经做了适配的网站名称,不传则为cnipa [string] -v, --version 显示版本号 [boolean] - -Examples: - rs-reverse makecookie -f example/codes/1-$_ts.json - rs-reverse makecookie -u http://url/path ``` 调用示例: @@ -99,9 +102,6 @@ Examples: ```bash $ npx rs-reverse makecookie -u https://wcjs.sbj.cnipa.gov.cn/sgtmi - url方式提取的ts:/path/to/output/makecookie_url_ts_1704391389883.json - url方式提取的静态文本:/path/to/output/makecookie_url_immutext_1704391389883.json - 存在meta-content值:n5fQ9G1lGvUzfS_yMHx30yYAbp2_NDZI 解析结果:/sgtmi Cookie值: 0yk64LrpoFnc8Wi4Mmu_rijgRRoC2SHY1bQlR2_QZ805_CqRd1uOgGRnlEvHvXSoQuwkx_fwn4iQnPDFrQigm1b4GnD61Pf9vU5XKtJtAWIoWeG_22OLiccUwGjI0lQaJ_jaYIBFygNsPSPf_0GnJyT1umFrFgAkAoqh1s0G9IDE1uPEM3PV8M1J.wbKdSgMLg8T3bGD5w2sHHohKfnwsT7bMNbb8xbjSxsn8qb8AvY0 @@ -111,7 +111,7 @@ Examples: ### 2.3. makecode-high子命令 -执行子命令`makecode-high`生成cookie,解码两次请求返回的网站代码(功能涵盖makecode子命令),调用示例: +执行子命令`makecode-high`生成网站代码,解码两次请求返回的网站代码(功能涵盖makecode子命令),调用示例: 1. npx方式:`npx rs-reverse makecode-high -u url` 2. 文件方式:`node main.js makecode-high -u url` @@ -130,7 +130,6 @@ rs-reverse makecode-high Options: -h 显示帮助信息 [boolean] - -f -l, --level 日志打印等级,参考log4js,默认为info [string] -u, --url 瑞数返回204状态码的请求地址 [string] [required] -a, --adapt 已经做了适配的网站名称,不传则为cnipa [string] @@ -147,25 +146,25 @@ Examples: 第1次请求: - url方式提取的ts:/path/to/output/makecode-high/first/ts.json - url方式提取的静态文本:/path/to/output/makecode-high/first/immucfg.json - 程序生成的ts:/path/to/output/makecode-high/first/ts-full.json - url方式提取的javascript代码:/path/to/output/makecode-high/first/cCdzB9ZjDFks.a728b22.js - url方式提取的html代码:/path/to/output/makecode-high/first/sgtmi.html - cCdzB9ZjDFks.a728b22.js生成的动态代码:/path/to/output/makecode-high/first/cCdzB9ZjDFks.a728b22-dynamic.js + url方式提取的ts:output/makecode-high/first/ts.json + url方式提取的静态文本:output/makecode-high/first/immucfg.json + 程序生成的ts:output/makecode-high/first/ts-full.json + url方式提取的javascript代码:output/makecode-high/first/cCdzB9ZjDFks.a728b22.js + url方式提取的html代码:output/makecode-high/first/sgtmi.html + cCdzB9ZjDFks.a728b22.js生成的动态代码:output/makecode-high/first/cCdzB9ZjDFks.a728b22-dynamic.js 第2次请求: - url方式提取的ts:/path/to/output/makecode-high/second/ts.json - url方式提取的静态文本:/path/to/output/makecode-high/second/immucfg.json - 程序生成的ts:/path/to/output/makecode-high/second/ts-full.json - url方式提取的javascript代码:/path/to/output/makecode-high/second/cCdzB9ZjDFks.a728b22.js - url方式提取的html代码:/path/to/output/makecode-high/second/sgtmi.html - cCdzB9ZjDFks.a728b22.js生成的动态代码:/path/to/output/makecode-high/second/cCdzB9ZjDFks.a728b22-dynamic.js - url方式提取的javascript代码:/path/to/output/makecode-high/second/chunk-vendors.66e24864.js - url方式提取的javascript代码:/path/to/output/makecode-high/second/app.9f7a91c9.js - chunk-vendors.66e24864.js生成的解密代码:/path/to/output/makecode-high/second/chunk-vendors.66e24864-decrypt.js - app.9f7a91c9.js生成的解密代码:/path/to/output/makecode-high/second/app.9f7a91c9-decrypt.js + url方式提取的ts:output/makecode-high/second/ts.json + url方式提取的静态文本:output/makecode-high/second/immucfg.json + 程序生成的ts:output/makecode-high/second/ts-full.json + url方式提取的javascript代码:output/makecode-high/second/cCdzB9ZjDFks.a728b22.js + url方式提取的html代码:output/makecode-high/second/sgtmi.html + cCdzB9ZjDFks.a728b22.js生成的动态代码:output/makecode-high/second/cCdzB9ZjDFks.a728b22-dynamic.js + url方式提取的javascript代码:output/makecode-high/second/chunk-vendors.66e24864.js + url方式提取的javascript代码:output/makecode-high/second/app.9f7a91c9.js + chunk-vendors.66e24864.js生成的解密代码:output/makecode-high/second/chunk-vendors.66e24864-decrypt.js + app.9f7a91c9.js生成的解密代码:output/makecode-high/second/app.9f7a91c9-decrypt.js ``` diff --git a/main.js b/main.js index adf507a..d12b986 100755 --- a/main.js +++ b/main.js @@ -14,6 +14,8 @@ const pkg = require(paths.package); const log4js = require('log4js'); const adapt = require('@src/adapt'); const gv = require('@src/handler/globalVarible'); +const _merge = require('lodash/merge'); +const _omit = require('lodash/omit'); function debugLog(level) { if (level) { @@ -67,11 +69,8 @@ const commandHandler = (command, argv) => { logger.trace(`传入的$_ts.cd: ${ts.cd}`); gv._setAttr('argv', argv); try { - if (argv.url) { - command(ts, adapt(argv.url, argv.adapt), argv.url); - } else { - command(ts); - } + const immucfg = argv.url ? adapt(argv.url, argv.adapt) : undefined; + command(ts, immucfg, _merge(argv.url || {}, argv.jsurls || {})); } catch (err) { logger.error(err.stack); } @@ -84,16 +83,23 @@ module.exports = yargs .usage('使用: node $0 [options]') .command({ command: 'makecode', - describe: '生成动态代码', - builder: commandBuilder, + describe: '接收ts.json文件生成immucfg、ts、ts-full文件,如果传入的是url则还会生成html、主代码、动态代码文件,还可通过-j命令接收多个$_ts.l__处理的文件url并生成该js文件及解密后的js文件', + builder: { + ...commandBuilder, + j: { + alias: 'jsurls', + describe: '$_ts.__l方法执行的js文件链接(必须带上查询参数),多个时需要按顺序传入,如:-j "https://host/chunk.js?4VGu1xaT=a728b2" -j "https://host/app.js?4VGu1xaT=a728b2"', + type: 'array', + coerce: getCode, + } + }, handler: commandHandler.bind(null, makeCode), }) .command({ command: 'makecode-high', describe: '解码两次请求返回的网站代码(功能涵盖makecode子命令)', builder: { - ...commandBuilder, - f: undefined, + ..._omit(commandBuilder, ['f']), u: { ...commandBuilder.u, demandOption: true, diff --git a/package.json b/package.json index c879c31..075de4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rs-reverse", - "version": "1.7.1", + "version": "1.7.2", "description": "瑞数算法逆向,website reverse engineering", "main": "main.js", "directories": { @@ -36,7 +36,7 @@ "registry": "https://registry.npmjs.org/" }, "author": "pysunday", - "license": "ISC", + "license": "BSD 3-Clause", "dependencies": { "cheerio": "^1.0.0-rc.12", "fs-extra": "^11.2.0", diff --git a/src/handler/AppCode.js b/src/handler/AppCode.js index c955802..ac650d5 100644 --- a/src/handler/AppCode.js +++ b/src/handler/AppCode.js @@ -1,5 +1,7 @@ const gv = require('@src/handler/globalVarible'); +let maxLen = 0; // 用于与瑞数代码逻辑一致,瑞数逻辑是当数组长度少于上一次生成的数组则复用上一次的数组 + module.exports = class { constructor(params, idx) { this.oper = 0; @@ -38,15 +40,18 @@ module.exports = class { getKeys(len) { const keys = []; for(let i = 0; i <= len; i++) { - const j = Math.floor((this.random || Math.random()) * 4294967295) % len + 0; + const j = Math.floor((Math.random()) * 4294967295) % len + 0; + const temp = keys[i]; keys[i] = keys[j] || `$_${j}`; - keys[j] = `$_${i}`; + keys[j] = temp || `$_${i}`; } return keys; } - decrypt() { - const keys = this.getKeys(this.getLength()); + decrypt(isMerge = false) { + // isMerge:是否将变量数组合并到代码中 + maxLen = Math.max(this.getLength(), maxLen); + const keys = this.getKeys(maxLen); const name = `$$_${this.idx}`; const num = this.getLength(); const ret = new Array(num), res = []; @@ -71,16 +76,14 @@ module.exports = class { ret[i] = val; break; case 4: - // ret[i] = `${name}[${next}]`; - ret[i] = `"${staticText[next]}"`; + ret[i] = isMerge ? `"${staticText[next]}"` : `${name}[${next}]`; break; case 5: ret[i] = this.params[2][next] break; } } - // return `window[${name}]=${JSON.stringify(staticText)};${ret.join('')}`; - return ret.join(''); + return isMerge ? ret.join('') : `window.${name}=${JSON.stringify(staticText)};\n${ret.join('')}`; } run() { @@ -91,4 +94,13 @@ module.exports = class { }; return code; } + + static getParams(code) { + // 去除外层的$_ts.l__方法 + if (typeof code !== 'string' || code.indexOf('$_ts.l__') !== 0) { + throw new Error('解码网站渲染代码未发现$_ts.l__前缀,请检查!'); + } + const $_ts = { l__: (...params) => params }; + return eval(code); + } } diff --git a/src/makeCode.js b/src/makeCode.js index 73defae..b25af75 100644 --- a/src/makeCode.js +++ b/src/makeCode.js @@ -1,56 +1,73 @@ +const AppCode = require('./handler/AppCode'); const Coder = require('./handler/Coder'); const paths = require('@utils/paths'); const fs = require('fs'); +const fse = require('fs-extra'); const logger = require('@utils/logger'); +const { init } = require('@src/handler/parser/'); + +function filenameAddDesc(name, desc) { + const arr = name.split('.'); + if (arr.length < 2) throw new Error(`文件名不正确: ${name}`); + arr[arr.length - 2] += desc; + return arr.join('.'); +} + +function writeFile(ts, immucfg, { jscode, html, appcode = [] }, $_ts, code) { + const files = [ + { + name: 'ts.json', + desc: immucfg ? 'url方式提取的ts:' : '程序接收的ts:', + text: JSON.stringify(ts), + }, + immucfg ? { + name: 'immucfg.json', + desc: 'url方式提取的静态文本:', + text: JSON.stringify(immucfg), + } : null, + { + name: 'ts-full.json', + desc: '程序生成的ts:', + text: JSON.stringify($_ts), + }, + html, + jscode, + { + name: jscode ? filenameAddDesc(jscode.name, '-dynamic') : 'dynamic.js', + desc: `${jscode?.name || '程序'}生成的动态代码:`, + text: '// 该行标记来源,非动态代码生成: ' + JSON.stringify(ts) + '\n\n' + code, + }, + ...appcode.reduce((ans, it) => { + ans.push(it); + if (it.decryptCode) { + ans.push({ + name: filenameAddDesc(it.name, '-decrypt'), + desc: `${it.name}生成的解密代码:`, + text: it.decryptCode, + }); + } + return ans; + }, []), + ].filter(Boolean).map(it => ({ ...it, filepath: paths.outputResolve('makecode', it.name) })) + if (!fs.existsSync(paths.outputResolve('makecode'))) fse.ensureDirSync(paths.outputResolve('makecode')); + files.forEach(({ filepath, text, code }) => filepath && fs.writeFileSync(filepath, text || code)); + return files; +} module.exports = function (ts, immucfg, mate = {}) { const startTime = new Date().getTime(); + if (fs.existsSync(paths.outputResolve('makecode'))) { + fse.moveSync(paths.outputResolve('makecode'), paths.outputResolve('makecode-old'), { overwrite: true }); + } const coder = new Coder(ts, immucfg); const { code, $_ts } = coder.run(); - const files = [ - immucfg ? { - name: 'makecode_input_ts', - desc: 'url方式提取的ts:', - text: JSON.stringify(ts), - extend: 'json', - } : null, - immucfg ? { - name: 'makecode_input_immucfg', - desc: 'url方式提取的静态文本:', - text: JSON.stringify(immucfg), - extend: 'json', - } : null, - mate.jscode ? { - name: 'makecode_input_js', - desc: 'url方式提取的javascript代码:', - text: mate.jscode.code, - extend: 'js', - } : null, - mate.html ? { - name: 'makecode_input_html', - desc: 'url方式提取的html代码:', - text: mate.html.code, - extend: 'html', - newLine: true, - } : null, - { - name: 'makecode_output_ts', - desc: '程序生成的ts:', - text: JSON.stringify($_ts), - extend: 'json', - }, - { - name: 'makecode_output_code', - desc: '程序生成的动态代码:', - text: '// 该行标记来源,非动态代码生成: ' + JSON.stringify(ts) + '\n\n' + code, - extend: 'js', - }, - ].filter(Boolean).map(it => ({ ...it, filepath: `${paths.outputResolve(it.name)}.${it.extend}` })) - if (!fs.existsSync(paths.outputPath)) fs.mkdirSync(paths.outputPath); - files.forEach(({ filepath, text }) => fs.writeFileSync(filepath, text)) + init($_ts); + mate.appcode?.forEach((appcode, idx) => { + appcode.decryptCode = new AppCode(AppCode.getParams(appcode.code)).run(); + }); + const files = writeFile(ts, immucfg, mate, $_ts, code); logger.info([ - `生成动态代码成功!用时:${new Date().getTime() - startTime}ms\n`, - ...files.reduce((ans, it, idx) => ([...ans, `${it.desc}${it.filepath}${idx === files.length - 1 || it.newLine ? '\n' : ''}`]), []), + `代码还原成功!用时:${new Date().getTime() - startTime}ms\n`, + ...files.reduce((ans, it, idx) => ([...ans, typeof it === 'string' ? it : `${it.desc}${paths.relative(it.filepath)}${idx === files.length - 1 || it.newLine ? '\n' : ''}`]), []), ].join('\n ')); } - diff --git a/src/makeCodeHigh.js b/src/makeCodeHigh.js index 05c7f9e..9333314 100644 --- a/src/makeCodeHigh.js +++ b/src/makeCodeHigh.js @@ -18,13 +18,6 @@ function parseR2mka(text) { return unescape(text.substr(start, end)); } -function mkdirsSync(dirPath) { - if (!fs.existsSync(dirPath)) { - mkdirsSync(path.dirname(dirPath)); - fs.mkdirSync(dirPath); - } -} - function filenameAddDesc(name, desc) { const arr = name.split('.'); if (arr.length < 2) throw new Error(`文件名不正确: ${name}`); @@ -63,16 +56,10 @@ function writeFile(step, ts, immucfg, { jscode, html, appcode = [] }, $_ts, code text: it.decryptCode, })) ].filter(Boolean).map(it => ({ ...it, filepath: paths.outputResolve('makecode-high', step, it.name) })) - if (!fs.existsSync(paths.outputResolve('makecode-high', step))) mkdirsSync(paths.outputResolve('makecode-high', step)); + if (!fs.existsSync(paths.outputResolve('makecode-high', step))) fse.ensureDirSync(paths.outputResolve('makecode-high', step)); return files; } -function decryptAppCode(appcode, idx) { - const $_ts = { l__: (...params) => params }; - const codeParams = eval(appcode.code); - appcode.decryptCode = new AppCode(codeParams, idx + 1).run(); -} - function firstStep(ts, immucfg, mate) { gv._setAttr('_ts', ts); const coder = new Coder(ts, immucfg); @@ -88,7 +75,9 @@ function secondStep(ts, immucfg, mate) { gv._setAttr('_ts', ts); const coder = new Coder(ts, immucfg); const { code, $_ts } = coder.run(); - mate.appcode.map(decryptAppCode); + mate.appcode.forEach((appcode, idx) => { + appcode.decryptCode = new AppCode(AppCode.getParams(appcode.code)).run(); + }); return writeFile('second', ts, immucfg, mate, $_ts, code); } @@ -104,7 +93,7 @@ module.exports = async function (ts, immucfg, mate) { files.forEach(({ filepath, text, code }) => filepath && fs.writeFileSync(filepath, text || code)); logger.info([ `代码还原成功!用时:${new Date().getTime() - startTime}ms\n`, - ...files.reduce((ans, it, idx) => ([...ans, typeof it === 'string' ? it : `${it.desc}${it.filepath}${idx === files.length - 1 || it.newLine ? '\n' : ''}`]), []), + ...files.reduce((ans, it, idx) => ([...ans, typeof it === 'string' ? it : `${it.desc}${paths.relative(it.filepath)}${idx === files.length - 1 || it.newLine ? '\n' : ''}`]), []), ].join('\n ')); } diff --git a/src/makeCookie.js b/src/makeCookie.js index a81108b..fe30015 100644 --- a/src/makeCookie.js +++ b/src/makeCookie.js @@ -34,7 +34,7 @@ function writefile(ts, immucfg) { module.exports = function (ts, immucfg) { gv._setAttr('_ts', ts); - if (immucfg) writefile(ts, immucfg); + // if (immucfg) writefile(ts, immucfg); const startTime = new Date().getTime(); const coder = new Coder(ts, immucfg); const { code, $_ts } = coder.run(); diff --git a/utils/getCode.js b/utils/getCode.js index bebb95e..405ab2e 100644 --- a/utils/getCode.js +++ b/utils/getCode.js @@ -26,7 +26,7 @@ function nameHandle(name, extend) { return name.split('.').pop() === extend ? name : `${name}.${extend}`; } -module.exports = async function getCode(url, cookieStr) { +async function getCodeByHtml(url, cookieStr) { if (cookieStr) { cookieJar.setCookie(request.cookie(cookieStr), url); } @@ -53,8 +53,14 @@ module.exports = async function getCode(url, cookieStr) { appcode: [], url, } - for(let src of remotes) { - const jsurl = urlresolve(url, src); + await getCodeByJs(remotes.map(it => urlresolve(url, it)), ret); + if (ret.jscode) return ret; + throw new Error('js外链中没有瑞数的代码文件'); +} + +async function getCodeByJs(urls, ret = { appcode: [] }) { + for(let url of urls) if (!isValidUrl(url)) throw new Error(`输入链接不正确:${url}`); + for(let jsurl of urls) { const name = jsurl.split('?')[0].split('/').pop(); const jscode = await request(addRequestHead(jsurl)); const data = { @@ -69,6 +75,14 @@ module.exports = async function getCode(url, cookieStr) { ret.jscode = data; } } - if (ret.jscode) return ret; - throw new Error('js外链中没有瑞数的代码文件'); + return ret; +} + +module.exports = function getCode(url, ...params) { + if (typeof url === 'string') { + return getCodeByHtml(url, ...params); + } + if (Array.isArray(url)) { + return getCodeByJs(url); + } } diff --git a/utils/paths.js b/utils/paths.js index 811df84..f5f69cf 100644 --- a/utils/paths.js +++ b/utils/paths.js @@ -23,4 +23,5 @@ module.exports = { outputResolve: (...p) => path.resolve('output', ...p), examplePath: resolveApp('example'), exampleResolve: (...p) => resolveApp('example', ...p), + relative: (p) => path.relative(path.resolve(), p), };