1
0
mirror of https://gitcode.com/github-mirrors/react-native-update-cli.git synced 2025-09-17 18:06:10 +08:00
Code Issues Packages Projects Releases Wiki Activity GitHub Gitee

Compare commits

..

60 Commits

Author SHA1 Message Date
sunnylqm
4326c08d79 downgrade commander 2025-04-09 10:52:38 +08:00
sunnylqm
20010a9ea6 Update version to 1.43.1 and handle optional chaining for origin URL in getCommitInfo function 2025-04-07 19:56:17 +08:00
sunnylqm
36220a48aa Update package description for clarity and specificity 2025-04-02 08:39:26 +08:00
sunnylqm
d281d72737 check lockfiles 2025-04-02 08:27:05 +08:00
sunnylqm
d351243ab7 check lockfile 2025-04-01 23:24:02 +08:00
sunnylqm
fbdacfffd2 add gitignore check 2025-04-01 23:13:20 +08:00
sunnylqm
b725d2b08e i18n 2025-03-30 23:40:28 +08:00
sunnylqm
3f1b43e38e update i18n 2025-03-25 09:36:58 +08:00
sunnylqm
328b1f5447 update i18n 2025-03-24 23:16:09 +08:00
sunnylqm
ef7f6a2087 Swap React Native CLI paths based on version compatibility in runReactNativeBundleCommand function 2025-03-17 11:25:11 +08:00
sunnylqm
d2798d3177 Remove cache usage in getLatestVersion function for improved reliability 2025-03-16 11:01:10 +08:00
sunnylqm
b0a8fc928a fix format 2025-03-15 19:08:34 +08:00
sunnylqm
a559140b72 fix number format 2025-03-15 19:03:19 +08:00
sunnylqm
8bd19bc0f7 add commit info 2025-03-15 18:50:39 +08:00
sunnylqm
d974be6706 add i18n 2025-03-15 18:33:05 +08:00
Sunny Luo
d0495fb271 Feat/deps (#10)
* init

* add deps

* sort key
2025-03-14 13:44:47 +08:00
Sunny Luo
613f39a59e Delete yarn.lock 2025-03-14 13:37:56 +08:00
sunnylqm
94c06e9908 add timeout for version query 2025-02-26 16:11:40 +08:00
sunnylqm
ac1a000a3b v1.41.0-beta.1 2025-02-21 11:43:24 +08:00
sunnylqm
4e51f291ca Refactor project configuration and dependencies
- Move IS_CRESC logic to constants module
- Update package.json with new type definitions
- Simplify global variable usage
- Update bun.lock with latest package versions
- Improve type safety and configuration management
2025-02-19 22:53:24 +08:00
sunnylqm
04b5d12daa deps 2025-02-19 22:24:54 +08:00
sunnylqm
67644b5c11 v1.41.0-beta.0 2025-02-19 22:06:24 +08:00
sunnylqm
546faef83f more cresc config 2025-02-18 22:37:09 +08:00
sunnylqm
e713f4bbd1 Bump package version to 1.40.1 2025-02-15 23:14:41 +08:00
sunnylqm
5a6463de33 Remove debug console.log in zip utility 2025-02-15 23:14:11 +08:00
sunnylqm
1fb308af94 Refactor i18n locales from JSON to TypeScript modules 2025-02-15 22:51:26 +08:00
sunnylqm
f10d4d3004 Add option to disable Hermes for React Native bundle 2025-02-15 21:40:28 +08:00
sunnylqm
0f44de772f init i18n 2025-02-15 00:38:55 +08:00
Sunny Luo
814a9d10fb Update package.json 2025-02-13 17:17:31 +08:00
sunnylqm
c08c5c0b07 fix taro cli path 2025-02-13 16:13:48 +08:00
sunnylqm
dc8c134ff0 v1.40.0-beta.0 2025-02-13 16:07:25 +08:00
sunnylqm
1d1e6cde0f support taro 2025-02-13 16:02:04 +08:00
sunny.luo
f16aff5674 Improve file filtering during bundle packing
# Conflicts:
#	package.json
#	src/bundle.js
2025-02-10 17:22:00 +08:00
Sunny Luo
d7da311c5e Update package.json 2025-02-09 16:15:04 +08:00
波仔糕
abef760f43 add logic to remove soucemap and merge sourcemap params (#9)
* add logic to remove soucemap and merge sourcemap params

* Update bundle.js

---------

Co-authored-by: Sunny Luo <sunnylqm@gmail.com>
2025-02-09 16:14:45 +08:00
Sunny Luo
467ef0c60d Update package.json 2025-01-23 23:15:22 +08:00
波仔糕
0b08c7760d Support sentry (#8)
* add logic to support sentry

* udpate

* change reference path

* support git commits and version info

* udate

* add try catch for require.resolve

* update upload sourcemap workflow
2025-01-23 22:02:13 +08:00
sunnylqm
b24b27d100 ts 2025-01-11 20:23:25 +08:00
sunnylqm
4f0784172f fix expo cli 2025-01-11 20:20:16 +08:00
sunnylqm
26725200b7 bump 2025-01-11 15:47:29 +08:00
sunnylqm
8bd31b8dc1 fix get 2025-01-11 15:45:28 +08:00
Sunny Luo
59f2421067 Update package.json 2025-01-09 21:00:29 +08:00
sunnylqm
8f0ca60d24 fix error message 2025-01-09 20:58:06 +08:00
Sunny Luo
6fcdb988c8 Merge pull request #6 from bozaigao/master
implement getAppInfo and uploadApp methods
2025-01-07 14:19:42 +08:00
HeYanbo
040f723305 implement getAppInfo and uploadApp methods 2025-01-07 13:52:28 +08:00
sunnylqm
12bddc446d Enhance package.json: add lint script and update devDependencies 2025-01-05 21:22:14 +08:00
sunnylqm
ba3d88627a support harmony 2025-01-05 21:16:52 +08:00
Sunny Luo
3987a5191f Update publish.yml 2024-12-26 15:41:57 +08:00
Sunny Luo
9903fb97e4 bump 2024-12-26 15:40:05 +08:00
Sunny Luo
834c0bb351 Create lint.yml 2024-12-26 14:57:21 +08:00
Sunny Luo
fa8ba44bfa Create publish.yml 2024-12-26 14:56:49 +08:00
Sunny Luo
6e4e89428c Merge pull request #5 from bozaigao/master
适配鸿蒙
2024-12-26 12:14:14 +08:00
HeYanbo
e018761fc8 init 2024-12-25 23:38:29 +08:00
sunny.luo
6534379d81 use expo cli if available 2024-12-11 17:23:28 +08:00
sunny.luo
bb777bf973 update 2024-12-02 16:32:34 +08:00
sunny.luo
ea05d4ab66 fix chalk 2024-11-25 10:51:35 +08:00
sunny.luo
a259ff671d check latest version 2024-11-24 11:51:55 +08:00
sunny.luo
3f68fa0f11 compose sourcemap 2024-11-19 23:28:54 +08:00
sunnylqm
fa750ba157 update 2024-11-16 18:54:34 +08:00
sunnylqm
91fc0caa07 v1.32.3 2024-11-16 18:33:09 +08:00
37 changed files with 3342 additions and 3944 deletions

47
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: lint
on:
pull_request:
branches:
- master
push:
branches:
- master
# Cancel a currently running workflow from the same PR/branch/tag
# when a new workflow is triggered
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Install Dependency
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: '--max_old_space_size=4096'
run: bun install --frozen-lockfile
- name: Run lint
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: '--max_old_space_size=4096'
run: bun lint

22
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Publish Package to npmjs
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- run: bun install --frozen-lockfile
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry = "https://registry.npmjs.org"

39
biome.json Normal file
View File

@@ -0,0 +1,39 @@
{
"files": {
"ignore": ["lib"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off"
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
"useValidAnchor": "off"
},
"performance": {
"noDelete": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto"
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
}
}

1150
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

@@ -3,11 +3,9 @@
"defaultCommand": "help", "defaultCommand": "help",
"commands": { "commands": {
"help": {}, "help": {},
"login": {}, "login": {},
"logout": {}, "logout": {},
"me": {}, "me": {},
"createApp": { "createApp": {
"options": { "options": {
"name": { "name": {
@@ -33,9 +31,10 @@
} }
} }
}, },
"uploadIpa": {}, "uploadIpa": {},
"uploadApk": {}, "uploadApk": {},
"uploadApp": {},
"parseApp": {},
"parseIpa": {}, "parseIpa": {},
"parseApk": {}, "parseApk": {},
"packages": { "packages": {
@@ -45,7 +44,6 @@
} }
} }
}, },
"publish": { "publish": {
"options": { "options": {
"platform": { "platform": {
@@ -69,7 +67,6 @@
} }
} }
}, },
"update": { "update": {
"options": { "options": {
"platform": { "platform": {
@@ -95,7 +92,6 @@
} }
} }
}, },
"updateVersionInfo": { "updateVersionInfo": {
"options": { "options": {
"platform": { "platform": {
@@ -118,7 +114,6 @@
} }
} }
}, },
"build": { "build": {
"description": "Bundle javascript and copy assets." "description": "Bundle javascript and copy assets."
}, },
@@ -141,15 +136,27 @@
"hasValue": true "hasValue": true
}, },
"intermediaDir": { "intermediaDir": {
"default": ".pushy/intermedia/${platform}", "default": "${tempDir}/intermedia/${platform}",
"hasValue": true "hasValue": true
}, },
"output": { "output": {
"default": ".pushy/output/${platform}.${time}.ppk", "default": "${tempDir}/output/${platform}.${time}.ppk",
"hasValue": true "hasValue": true
}, },
"sourcemap": { "sourcemap": {
"default": false "default": false
},
"taro": {
"default": false
},
"expo": {
"default": false
},
"rncli": {
"default": false
},
"disableHermes": {
"default": false
} }
} }
}, },
@@ -160,7 +167,7 @@
"description": "Create diff patch", "description": "Create diff patch",
"options": { "options": {
"output": { "output": {
"default": ".pushy/output/diff", "default": "${tempDir}/output/diff",
"hasValue": true "hasValue": true
} }
} }
@@ -169,7 +176,7 @@
"description": "Create diff patch from a Android package(.apk)", "description": "Create diff patch from a Android package(.apk)",
"options": { "options": {
"output": { "output": {
"default": ".pushy/output/diff-${time}.apk-patch", "default": "${tempDir}/output/diff-${time}.apk-patch",
"hasValue": true "hasValue": true
} }
} }
@@ -178,7 +185,7 @@
"description": "Create diff patch from a iOS package(.ipa)", "description": "Create diff patch from a iOS package(.ipa)",
"options": { "options": {
"output": { "output": {
"default": ".pushy/output/diff-${time}.ipa-patch", "default": "${tempDir}/output/diff-${time}.ipa-patch",
"hasValue": true "hasValue": true
} }
} }
@@ -187,7 +194,7 @@
"description": "Create hdiff patch", "description": "Create hdiff patch",
"options": { "options": {
"output": { "output": {
"default": ".pushy/output/hdiff", "default": "${tempDir}/output/hdiff",
"hasValue": true "hasValue": true
} }
} }
@@ -196,7 +203,25 @@
"description": "Create hdiff patch from a Android package(.apk)", "description": "Create hdiff patch from a Android package(.apk)",
"options": { "options": {
"output": { "output": {
"default": ".pushy/output/hdiff-${time}.apk-patch", "default": "${tempDir}/output/hdiff-${time}.apk-patch",
"hasValue": true
}
}
},
"hdiffFromPPK": {
"description": "Create hdiff patch from a Prepare package(.ppk)",
"options": {
"output": {
"default": "${tempDir}/output/hdiff-${time}.ppk-patch",
"hasValue": true
}
}
},
"hdiffFromApp": {
"description": "Create hdiff patch from a Harmony package(.app)",
"options": {
"output": {
"default": "${tempDir}/output/hdiff-${time}.app-patch",
"hasValue": true "hasValue": true
} }
} }
@@ -205,7 +230,7 @@
"description": "Create hdiff patch from a iOS package(.ipa)", "description": "Create hdiff patch from a iOS package(.ipa)",
"options": { "options": {
"output": { "output": {
"default": ".pushy/output/hdiff-${time}.ipa-patch", "default": "${tempDir}/output/hdiff-${time}.ipa-patch",
"hasValue": true "hasValue": true
} }
} }

View File

@@ -1,10 +1,11 @@
{ {
"name": "react-native-update-cli", "name": "react-native-update-cli",
"version": "1.32.2", "version": "1.43.2",
"description": "Command tools for javaScript updater with `pushy` service for react native apps.", "description": "command line tool for react-native-update (remote updates for react native)",
"main": "index.js", "main": "index.js",
"bin": { "bin": {
"pushy": "lib/index.js" "pushy": "lib/index.js",
"cresc": "lib/index.js"
}, },
"files": [ "files": [
"lib", "lib",
@@ -13,7 +14,8 @@
], ],
"scripts": { "scripts": {
"build": "swc src -d lib --strip-leading-paths", "build": "swc src -d lib --strip-leading-paths",
"prepare": "npm run build && chmod +x lib/index.js" "prepare": "npm run build && chmod +x lib/index.js",
"lint": "tsc --noEmit & biome check --write ."
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -23,6 +25,7 @@
"react-native", "react-native",
"ios", "ios",
"android", "android",
"harmony",
"update" "update"
], ],
"author": "reactnativecn", "author": "reactnativecn",
@@ -32,38 +35,50 @@
}, },
"homepage": "https://github.com/reactnativecn/react-native-pushy/tree/master/react-native-pushy-cli", "homepage": "https://github.com/reactnativecn/react-native-pushy/tree/master/react-native-pushy-cli",
"dependencies": { "dependencies": {
"@badisi/latest-version": "^7.0.12",
"bplist-parser": "^0.3.2", "bplist-parser": "^0.3.2",
"bytebuffer": "^5.0.1", "bytebuffer": "^5.0.1",
"cgbi-to-png": "^1.0.7", "cgbi-to-png": "^1.0.7",
"chalk": "4",
"cli-arguments": "^0.2.1", "cli-arguments": "^0.2.1",
"commander": "^12.1.0", "commander": "^10",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
"filesize-parser": "^1.5.1", "filesize-parser": "^1.5.1",
"form-data": "^4.0.1", "form-data": "^4.0.2",
"fs-extra": "8", "fs-extra": "8",
"gradle-to-js": "^2.0.1", "gradle-to-js": "^2.0.1",
"i18next": "^24.2.3",
"isomorphic-git": "^1.29.0",
"isomorphic-unzip": "^1.1.5", "isomorphic-unzip": "^1.1.5",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"plist": "^3.1.0", "plist": "^3.1.0",
"progress": "^2.0.3", "progress": "^2.0.3",
"properties": "^1.2.1", "properties": "^1.2.1",
"read": "^4.0.0", "read": "^4.1.0",
"semver": "^7.6.3", "semver": "^7.7.1",
"tcp-ping": "^0.1.1", "tcp-ping": "^0.1.1",
"tty-table": "4.2", "tty-table": "4.2",
"update-notifier": "^5.1.0", "update-notifier": "^5.1.0",
"yauzl": "^3.2.0", "yauzl": "^3.2.0",
"yazl": "3.3.0" "yazl": "3.3.1"
}, },
"engines": { "engines": {
"node": ">= 10" "node": ">= 14"
}, },
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.5.0", "@biomejs/biome": "^1.9.4",
"@swc/core": "^1.9.2", "@swc/cli": "^0.6.0",
"@types/node": "^22.9.0", "@swc/core": "^1.11.9",
"oxlint": "^0.11.1", "@types/filesize-parser": "^1.5.3",
"typescript": "^5.6.3" "@types/fs-extra": "^11.0.4",
}, "@types/node": "^22.13.10",
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" "@types/node-fetch": "^2.6.12",
"@types/progress": "^2.0.7",
"@types/semver": "^7.5.8",
"@types/tcp-ping": "^0.1.6",
"@types/update-notifier": "^6.0.8",
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.6",
"typescript": "^5.8.2"
}
} }

View File

@@ -6,96 +6,95 @@ import ProgressBar from 'progress';
import packageJson from '../package.json'; import packageJson from '../package.json';
import tcpp from 'tcp-ping'; import tcpp from 'tcp-ping';
import filesizeParser from 'filesize-parser'; import filesizeParser from 'filesize-parser';
import { pricingPageUrl } from './utils'; import {
import { Session } from 'types'; pricingPageUrl,
credentialFile,
defaultEndpoint,
} from './utils/constants';
import type { Session } from 'types';
import FormData from 'form-data'; import FormData from 'form-data';
import { t } from './utils/i18n';
const tcpPing = util.promisify(tcpp.ping); const tcpPing = util.promisify(tcpp.ping);
let session: Session | undefined; let session: Session | undefined;
let savedSession: Session | undefined; let savedSession: Session | undefined;
const defaultEndpoint = 'https://update.reactnative.cn/api'; const host =
let host = process.env.PUSHY_REGISTRY || defaultEndpoint; process.env.PUSHY_REGISTRY || process.env.RNU_API || defaultEndpoint;
const userAgent = `react-native-update-cli/${packageJson.version}`; const userAgent = `react-native-update-cli/${packageJson.version}`;
export const getSession = function () { export const getSession = () => session;
return session;
};
export const replaceSession = function (newSession: { token: string }) { export const replaceSession = (newSession: { token: string }) => {
session = newSession; session = newSession;
}; };
export const loadSession = async function () { export const loadSession = async () => {
if (fs.existsSync('.update')) { if (fs.existsSync(credentialFile)) {
try { try {
replaceSession(JSON.parse(fs.readFileSync('.update', 'utf8'))); replaceSession(JSON.parse(fs.readFileSync(credentialFile, 'utf8')));
savedSession = session; savedSession = session;
} catch (e) { } catch (e) {
console.error( console.error(
'Failed to parse file `.update`. Try to remove it manually.', `Failed to parse file ${credentialFile}. Try to remove it manually.`,
); );
throw e; throw e;
} }
} }
}; };
export const saveSession = function () { export const saveSession = () => {
// Only save on change. // Only save on change.
if (session !== savedSession) { if (session !== savedSession) {
const current = session; const current = session;
const data = JSON.stringify(current, null, 4); const data = JSON.stringify(current, null, 4);
fs.writeFileSync('.update', data, 'utf8'); fs.writeFileSync(credentialFile, data, 'utf8');
savedSession = current; savedSession = current;
} }
}; };
export const closeSession = function () { export const closeSession = () => {
if (fs.existsSync('.update')) { if (fs.existsSync(credentialFile)) {
fs.unlinkSync('.update'); fs.unlinkSync(credentialFile);
savedSession = undefined; savedSession = undefined;
} }
session = undefined; session = undefined;
host = process.env.PUSHY_REGISTRY || defaultEndpoint;
}; };
async function query(url: string, options: fetch.RequestInit) { async function query(url: string, options: fetch.RequestInit) {
const resp = await fetch(url, options); const resp = await fetch(url, options);
const text = await resp.text(); const text = await resp.text();
let json; let json: any;
try { try {
json = JSON.parse(text); json = JSON.parse(text);
} catch (e) { } catch (e) {}
if (resp.statusText.includes('Unauthorized')) {
throw new Error('登录信息已过期,请使用 pushy login 命令重新登录');
} else {
throw new Error(`Server error: ${resp.statusText}`);
}
}
if (resp.status !== 200) { if (resp.status !== 200) {
throw new Error(`${resp.status}: ${resp.statusText}`); const message = json?.message || resp.statusText;
if (resp.status === 401) {
throw new Error(t('loginExpired'));
}
throw new Error(message);
} }
return json; return json;
} }
function queryWithoutBody(method: string) { function queryWithoutBody(method: string) {
return function (api: string) { return (api: string) =>
return query(host + api, { query(host + api, {
method, method,
headers: { headers: {
'User-Agent': userAgent, 'User-Agent': userAgent,
'X-AccessToken': session ? session.token : '', 'X-AccessToken': session ? session.token : '',
}, },
}); });
};
} }
function queryWithBody(method: string) { function queryWithBody(method: string) {
return function (api: string, body: Record<string, any>) { return (api: string, body?: Record<string, any>) =>
return query(host + api, { query(host + api, {
method, method,
headers: { headers: {
'User-Agent': userAgent, 'User-Agent': userAgent,
@@ -104,7 +103,6 @@ function queryWithBody(method: string) {
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
};
} }
export const get = queryWithoutBody('GET'); export const get = queryWithoutBody('GET');
@@ -127,7 +125,7 @@ export async function uploadFile(fn: string, key?: string) {
timeout: 1000, timeout: 1000,
}); });
// console.log({pingResult}); // console.log({pingResult});
if (isNaN(pingResult.avg) || pingResult.avg > 150) { if (Number.isNaN(pingResult.avg) || pingResult.avg > 150) {
realUrl = backupUrl; realUrl = backupUrl;
} }
} }
@@ -136,14 +134,17 @@ export async function uploadFile(fn: string, key?: string) {
const fileSize = fs.statSync(fn).size; const fileSize = fs.statSync(fn).size;
if (maxSize && fileSize > filesizeParser(maxSize)) { if (maxSize && fileSize > filesizeParser(maxSize)) {
const readableFileSize = `${(fileSize / 1048576).toFixed(1)}m`;
throw new Error( throw new Error(
`此文件大小 ${(fileSize / 1048576).toFixed( t('fileSizeExceeded', {
1, fileSize: readableFileSize,
)}m , 超出当前额度 ${maxSize} 。您可以考虑升级付费业务以提升此额度。详情请访问: ${pricingPageUrl}`, maxSize,
pricingPageUrl,
}),
); );
} }
const bar = new ProgressBar(' 上传中 [:bar] :percent :etas', { const bar = new ProgressBar(' Uploading [:bar] :percent :etas', {
complete: '=', complete: '=',
incomplete: ' ', incomplete: ' ',
total: fileSize, total: fileSize,
@@ -151,11 +152,11 @@ export async function uploadFile(fn: string, key?: string) {
const form = new FormData(); const form = new FormData();
Object.entries(formData).forEach(([k, v]) => { for (const [k, v] of Object.entries(formData)) {
form.append(k, v); form.append(k, v);
}); }
const fileStream = fs.createReadStream(fn); const fileStream = fs.createReadStream(fn);
fileStream.on('data', function (data) { fileStream.on('data', (data) => {
bar.tick(data.length); bar.tick(data.length);
}); });

View File

@@ -1,127 +0,0 @@
import { question } from './utils';
import fs from 'fs';
import Table from 'tty-table';
import { post, get, doDelete } from './api';
const validPlatforms = {
ios: 1,
android: 1,
};
export function checkPlatform(platform) {
if (!validPlatforms[platform]) {
throw new Error(`无法识别的平台 '${platform}'`);
}
return platform;
}
export function getSelectedApp(platform) {
checkPlatform(platform);
if (!fs.existsSync('update.json')) {
throw new Error(
`App not selected. run 'pushy selectApp --platform ${platform}' first!`,
);
}
const updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
if (!updateInfo[platform]) {
throw new Error(
`App not selected. run 'pushy selectApp --platform ${platform}' first!`,
);
}
return updateInfo[platform];
}
export async function listApp(platform) {
const { data } = await get('/app/list');
const list = platform ? data.filter((v) => v.platform === platform) : data;
const header = [
{ value: '应用 id' },
{ value: '应用名称' },
{ value: '平台' },
];
const rows = [];
for (const app of list) {
rows.push([app.id, app.name, app.platform]);
}
console.log(Table(header, rows).render());
if (platform) {
console.log(`\共 ${list.length} ${platform} 个应用`);
} else {
console.log(`\共 ${list.length} 个应用`);
}
return list;
}
export async function chooseApp(platform) {
const list = await listApp(platform);
while (true) {
const id = await question('输入应用 id:');
const app = list.find((v) => v.id === (id | 0));
if (app) {
return app;
}
}
}
export const commands = {
createApp: async function ({ options }) {
const name = options.name || (await question('应用名称:'));
const { downloadUrl } = options;
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const { id } = await post('/app/create', { name, platform });
console.log(`已成功创建应用id: ${id}`);
await this.selectApp({
args: [id],
options: { platform, downloadUrl },
});
},
deleteApp: async function ({ args, options }) {
const { platform } = options;
const id = args[0] || chooseApp(platform);
if (!id) {
console.log('已取消');
}
await doDelete(`/app/${id}`);
console.log('操作成功');
},
apps: async function ({ options }) {
const { platform } = options;
listApp(platform);
},
selectApp: async function ({ args, options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const id = args[0] ? parseInt(args[0]) : (await chooseApp(platform)).id;
let updateInfo = {};
if (fs.existsSync('update.json')) {
try {
updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
} catch (e) {
console.error(
'Failed to parse file `update.json`. Try to remove it manually.',
);
throw e;
}
}
const { appKey } = await get(`/app/${id}`);
updateInfo[platform] = {
appId: id,
appKey,
};
fs.writeFileSync(
'update.json',
JSON.stringify(updateInfo, null, 4),
'utf8',
);
},
};

123
src/app.ts Normal file
View File

@@ -0,0 +1,123 @@
import { question } from './utils';
import fs from 'node:fs';
import Table from 'tty-table';
import { post, get, doDelete } from './api';
import type { Platform } from './types';
import { t } from './utils/i18n';
const validPlatforms = ['ios', 'android', 'harmony'];
export function checkPlatform(platform: Platform) {
if (!validPlatforms.includes(platform)) {
throw new Error(t('unsupportedPlatform', { platform }));
}
return platform;
}
export function getSelectedApp(platform: Platform) {
checkPlatform(platform);
if (!fs.existsSync('update.json')) {
throw new Error(t('appNotSelected', { platform }));
}
const updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
if (!updateInfo[platform]) {
throw new Error(t('appNotSelected', { platform }));
}
return updateInfo[platform];
}
export async function listApp(platform: Platform | '' = '') {
const { data } = await get('/app/list');
const list = platform ? data.filter((v) => v.platform === platform) : data;
const header = [
{ value: t('appId') },
{ value: t('appName') },
{ value: t('platform') },
];
const rows = [];
for (const app of list) {
rows.push([app.id, app.name, app.platform]);
}
console.log(Table(header, rows).render());
console.log(`\n${t('totalApps', { count: list.length, platform })}`);
return list;
}
export async function chooseApp(platform: Platform) {
const list = await listApp(platform);
while (true) {
const id = await question(t('enterAppIdQuestion'));
const app = list.find((v) => v.id === Number(id));
if (app) {
return app;
}
}
}
export const commands = {
createApp: async function ({
options,
}: {
options: { name: string; downloadUrl: string; platform: Platform };
}) {
const name = options.name || (await question(t('appNameQuestion')));
const { downloadUrl } = options;
const platform = checkPlatform(
options.platform || (await question(t('platformQuestion'))),
);
const { id } = await post('/app/create', { name, platform, downloadUrl });
console.log(t('createAppSuccess', { id }));
await this.selectApp({
args: [id],
options: { platform },
});
},
deleteApp: async ({ args, options }: { args: string[]; options: { platform: Platform } }) => {
const { platform } = options;
const id = args[0] || chooseApp(platform);
if (!id) {
console.log(t('cancelled'));
}
await doDelete(`/app/${id}`);
console.log(t('operationSuccess'));
},
apps: async ({ options }: { options: { platform: Platform } }) => {
const { platform } = options;
listApp(platform);
},
selectApp: async ({ args, options }: { args: string[]; options: { platform: Platform } }) => {
const platform = checkPlatform(
options.platform || (await question(t('platformQuestion'))),
);
const id = args[0]
? Number.parseInt(args[0])
: (await chooseApp(platform)).id;
let updateInfo: Partial<Record<Platform, { appId: number; appKey: string }>> = {};
if (fs.existsSync('update.json')) {
try {
updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
} catch (e) {
console.error(t('failedToParseUpdateJson'));
throw e;
}
}
const { appKey } = await get(`/app/${id}`);
updateInfo[platform] = {
appId: id,
appKey,
};
fs.writeFileSync(
'update.json',
JSON.stringify(updateInfo, null, 4),
'utf8',
);
},
};

View File

@@ -1,712 +0,0 @@
import path from 'node:path';
import { getRNVersion, translateOptions } from './utils';
import * as fs from 'fs-extra';
import { ZipFile } from 'yazl';
import { open as openZipFile } from 'yauzl';
import { question, printVersionCommand } from './utils';
import { checkPlatform } from './app';
import { spawn, spawnSync } from 'node:child_process';
const g2js = require('gradle-to-js/lib/parser');
import os from 'os';
const properties = require('properties');
let bsdiff, hdiff, diff;
try {
bsdiff = require('node-bsdiff').diff;
} catch (e) {}
try {
hdiff = require('node-hdiffpatch').diff;
} catch (e) {}
async function runReactNativeBundleCommand(
bundleName,
development,
entryFile,
outputFolder,
platform,
sourcemapOutput,
config,
) {
let gradleConfig = {};
if (platform === 'android') {
gradleConfig = await checkGradleConfig();
if (gradleConfig.crunchPngs !== false) {
console.warn(
'android 的 crunchPngs 选项似乎尚未禁用(如已禁用则请忽略此提示),这可能导致热更包体积异常增大,具体请参考 https://pushy.reactnative.cn/docs/getting-started.html#%E7%A6%81%E7%94%A8-android-%E7%9A%84-crunch-%E4%BC%98%E5%8C%96 \n',
);
}
}
let reactNativeBundleArgs = [];
let envArgs = process.env.PUSHY_ENV_ARGS;
if (envArgs) {
Array.prototype.push.apply(
reactNativeBundleArgs,
envArgs.trim().split(/\s+/),
);
}
fs.emptyDirSync(outputFolder);
let cliPath;
try {
// rn >= 0.75
cliPath = require.resolve('@react-native-community/cli/build/bin.js', {
paths: [process.cwd()],
});
} catch (e) {
// rn < 0.75
cliPath = require.resolve('react-native/local-cli/cli.js', {
paths: [process.cwd()],
});
}
let usingExpo = false;
try {
require.resolve('expo-router', {
paths: [process.cwd()],
});
console.log(`expo-router detected, will use @expo/cli to bundle.\n`);
// if using expo-router, use expo-cli
cliPath = require.resolve('@expo/cli', {
paths: [process.cwd()],
});
usingExpo = true;
} catch (e) {}
const bundleCommand = usingExpo ? 'export:embed' : 'bundle';
Array.prototype.push.apply(reactNativeBundleArgs, [
cliPath,
bundleCommand,
'--assets-dest',
outputFolder,
'--bundle-output',
path.join(outputFolder, bundleName),
'--dev',
development,
'--entry-file',
entryFile,
'--platform',
platform,
'--reset-cache',
]);
if (sourcemapOutput) {
reactNativeBundleArgs.push('--sourcemap-output', sourcemapOutput);
}
if (config) {
reactNativeBundleArgs.push('--config', config);
}
const reactNativeBundleProcess = spawn('node', reactNativeBundleArgs);
console.log(
`Running bundle command: node ${reactNativeBundleArgs.join(' ')}`,
);
return new Promise((resolve, reject) => {
reactNativeBundleProcess.stdout.on('data', (data) => {
console.log(data.toString().trim());
});
reactNativeBundleProcess.stderr.on('data', (data) => {
console.error(data.toString().trim());
});
reactNativeBundleProcess.on('close', async (exitCode) => {
if (exitCode) {
reject(
new Error(
`"react-native bundle" command exited with code ${exitCode}.`,
),
);
} else {
let hermesEnabled = false;
if (platform === 'android') {
const gradlePropeties = await new Promise((resolve) => {
properties.parse(
'./android/gradle.properties',
{ path: true },
function (error, props) {
if (error) {
console.error(error);
resolve(null);
}
resolve(props);
},
);
});
hermesEnabled = gradlePropeties.hermesEnabled;
if (typeof hermesEnabled !== 'boolean')
hermesEnabled = gradleConfig.enableHermes;
} else if (
platform === 'ios' &&
fs.existsSync('ios/Pods/hermes-engine')
) {
hermesEnabled = true;
}
if (hermesEnabled) {
await compileHermesByteCode(
bundleName,
outputFolder,
sourcemapOutput,
);
}
resolve(null);
}
});
});
}
function getHermesOSBin() {
if (os.platform() === 'win32') return 'win64-bin';
if (os.platform() === 'darwin') return 'osx-bin';
if (os.platform() === 'linux') return 'linux64-bin';
}
async function checkGradleConfig() {
let enableHermes = false;
let crunchPngs;
try {
const gradleConfig = await g2js.parseFile('android/app/build.gradle');
crunchPngs = gradleConfig.android.buildTypes.release.crunchPngs;
const projectConfig = gradleConfig['project.ext.react'];
if (projectConfig) {
for (const packagerConfig of projectConfig) {
if (
packagerConfig.includes('enableHermes') &&
packagerConfig.includes('true')
) {
enableHermes = true;
break;
}
}
}
} catch (e) {}
return {
enableHermes,
crunchPngs,
};
}
async function compileHermesByteCode(
bundleName,
outputFolder,
sourcemapOutput,
) {
console.log(`Hermes enabled, now compiling to hermes bytecode:\n`);
// >= rn 0.69
const rnDir = path.dirname(
require.resolve('react-native', {
paths: [process.cwd()],
}),
);
let hermesPath = path.join(rnDir, `/sdks/hermesc/${getHermesOSBin()}`);
// < rn 0.69
if (!fs.existsSync(hermesPath)) {
hermesPath = `node_modules/hermes-engine/${getHermesOSBin()}`;
}
const hermesCommand = `${hermesPath}/hermesc`;
const args = [
'-emit-binary',
'-out',
path.join(outputFolder, bundleName),
path.join(outputFolder, bundleName),
'-O',
];
if (sourcemapOutput) {
args.push('-output-source-map');
}
console.log(
'Running hermesc: ' + hermesCommand + ' ' + args.join(' ') + '\n',
);
spawnSync(hermesCommand, args, {
stdio: 'ignore',
});
}
async function pack(dir, output) {
console.log('Packing');
fs.ensureDirSync(path.dirname(output));
await new Promise((resolve, reject) => {
const zipfile = new ZipFile();
function addDirectory(root, rel) {
if (rel) {
zipfile.addEmptyDirectory(rel);
}
const childs = fs.readdirSync(root);
for (const name of childs) {
if (name === '.' || name === '..' || name === 'index.bundlejs.map') {
continue;
}
const fullPath = path.join(root, name);
const stat = fs.statSync(fullPath);
if (stat.isFile()) {
//console.log('adding: ' + rel+name);
zipfile.addFile(fullPath, rel + name);
} else if (stat.isDirectory()) {
//console.log('adding: ' + rel+name+'/');
addDirectory(fullPath, rel + name + '/');
}
}
}
addDirectory(dir, '');
zipfile.outputStream.on('error', (err) => reject(err));
zipfile.outputStream
.pipe(fs.createWriteStream(output))
.on('close', function () {
resolve();
});
zipfile.end();
});
console.log('ppk热更包已生成并保存到: ' + output);
}
function readEntire(entry, zipFile) {
const buffers = [];
return new Promise((resolve, reject) => {
zipFile.openReadStream(entry, (err, stream) => {
stream.pipe({
write(chunk) {
buffers.push(chunk);
},
end() {
resolve(Buffer.concat(buffers));
},
prependListener() {},
on() {},
once() {},
emit() {},
});
});
});
}
function basename(fn) {
const m = /^(.+\/)[^\/]+\/?$/.exec(fn);
return m && m[1];
}
async function diffFromPPK(origin, next, output) {
fs.ensureDirSync(path.dirname(output));
const originEntries = {};
const originMap = {};
let originSource;
await enumZipEntries(origin, (entry, zipFile) => {
originEntries[entry.fileName] = entry;
if (!/\/$/.test(entry.fileName)) {
// isFile
originMap[entry.crc32] = entry.fileName;
if (entry.fileName === 'index.bundlejs') {
// This is source.
return readEntire(entry, zipFile).then((v) => (originSource = v));
}
}
});
if (!originSource) {
throw new Error(
`Bundle file not found! Please use default bundle file name and path.`,
);
}
const copies = {};
const zipfile = new ZipFile();
const writePromise = new Promise((resolve, reject) => {
zipfile.outputStream.on('error', (err) => {
throw err;
});
zipfile.outputStream
.pipe(fs.createWriteStream(output))
.on('close', function () {
resolve();
});
});
const addedEntry = {};
function addEntry(fn) {
//console.log(fn);
if (!fn || addedEntry[fn]) {
return;
}
const base = basename(fn);
if (base) {
addEntry(base);
}
zipfile.addEmptyDirectory(fn);
}
const newEntries = {};
await enumZipEntries(next, (entry, nextZipfile) => {
newEntries[entry.fileName] = entry;
if (/\/$/.test(entry.fileName)) {
// Directory
if (!originEntries[entry.fileName]) {
addEntry(entry.fileName);
}
} else if (entry.fileName === 'index.bundlejs') {
//console.log('Found bundle');
return readEntire(entry, nextZipfile).then((newSource) => {
//console.log('Begin diff');
zipfile.addBuffer(
diff(originSource, newSource),
'index.bundlejs.patch',
);
//console.log('End diff');
});
} else {
// If same file.
const originEntry = originEntries[entry.fileName];
if (originEntry && originEntry.crc32 === entry.crc32) {
// ignore
return;
}
// If moved from other place
if (originMap[entry.crc32]) {
const base = basename(entry.fileName);
if (!originEntries[base]) {
addEntry(base);
}
copies[entry.fileName] = originMap[entry.crc32];
return;
}
// New file.
addEntry(basename(entry.fileName));
return new Promise((resolve, reject) => {
nextZipfile.openReadStream(entry, function (err, readStream) {
if (err) {
return reject(err);
}
zipfile.addReadStream(readStream, entry.fileName);
readStream.on('end', () => {
//console.log('add finished');
resolve();
});
});
});
}
});
const deletes = {};
for (let k in originEntries) {
if (!newEntries[k]) {
console.log('Delete ' + k);
deletes[k] = 1;
}
}
//console.log({copies, deletes});
zipfile.addBuffer(
Buffer.from(JSON.stringify({ copies, deletes })),
'__diff.json',
);
zipfile.end();
await writePromise;
}
async function diffFromPackage(
origin,
next,
output,
originBundleName,
transformPackagePath = (v) => v,
) {
fs.ensureDirSync(path.dirname(output));
const originEntries = {};
const originMap = {};
let originSource;
await enumZipEntries(origin, (entry, zipFile) => {
if (!/\/$/.test(entry.fileName)) {
const fn = transformPackagePath(entry.fileName);
if (!fn) {
return;
}
//console.log(fn);
// isFile
originEntries[fn] = entry.crc32;
originMap[entry.crc32] = fn;
if (fn === originBundleName) {
// This is source.
return readEntire(entry, zipFile).then((v) => (originSource = v));
}
}
});
if (!originSource) {
throw new Error(
`Bundle file not found! Please use default bundle file name and path.`,
);
}
const copies = {};
const zipfile = new ZipFile();
const writePromise = new Promise((resolve, reject) => {
zipfile.outputStream.on('error', (err) => {
throw err;
});
zipfile.outputStream
.pipe(fs.createWriteStream(output))
.on('close', function () {
resolve();
});
});
await enumZipEntries(next, (entry, nextZipfile) => {
if (/\/$/.test(entry.fileName)) {
// Directory
zipfile.addEmptyDirectory(entry.fileName);
} else if (entry.fileName === 'index.bundlejs') {
//console.log('Found bundle');
return readEntire(entry, nextZipfile).then((newSource) => {
//console.log('Begin diff');
zipfile.addBuffer(
diff(originSource, newSource),
'index.bundlejs.patch',
);
//console.log('End diff');
});
} else {
// If same file.
if (originEntries[entry.fileName] === entry.crc32) {
copies[entry.fileName] = '';
return;
}
// If moved from other place
if (originMap[entry.crc32]) {
copies[entry.fileName] = originMap[entry.crc32];
return;
}
return new Promise((resolve, reject) => {
nextZipfile.openReadStream(entry, function (err, readStream) {
if (err) {
return reject(err);
}
zipfile.addReadStream(readStream, entry.fileName);
readStream.on('end', () => {
//console.log('add finished');
resolve();
});
});
});
}
});
zipfile.addBuffer(Buffer.from(JSON.stringify({ copies })), '__diff.json');
zipfile.end();
await writePromise;
}
function enumZipEntries(zipFn, callback) {
return new Promise((resolve, reject) => {
openZipFile(zipFn, { lazyEntries: true }, (err, zipfile) => {
if (err) {
return reject(err);
}
zipfile.on('end', resolve);
zipfile.on('error', reject);
zipfile.on('entry', (entry) => {
const result = callback(entry, zipfile);
if (result && typeof result.then === 'function') {
result.then(() => zipfile.readEntry());
} else {
zipfile.readEntry();
}
});
zipfile.readEntry();
});
});
}
function diffArgsCheck(args, options, diffFn) {
const [origin, next] = args;
if (!origin || !next) {
console.error(`Usage: pushy ${diffFn} <origin> <next>`);
process.exit(1);
}
if (diffFn.startsWith('hdiff')) {
if (!hdiff) {
console.error(
`This function needs "node-hdiffpatch".
Please run "npm i node-hdiffpatch" to install`,
);
process.exit(1);
}
diff = hdiff;
} else {
if (!bsdiff) {
console.error(
`This function needs "node-bsdiff".
Please run "npm i node-bsdiff" to install`,
);
process.exit(1);
}
diff = bsdiff;
}
const { output } = options;
return {
origin,
next,
realOutput: output.replace(/\$\{time\}/g, '' + Date.now()),
};
}
export const commands = {
bundle: async function ({ options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
let { bundleName, entryFile, intermediaDir, output, dev, sourcemap } =
translateOptions({
...options,
platform,
});
const sourcemapOutput = path.join(intermediaDir, bundleName + '.map');
const realOutput = output.replace(/\$\{time\}/g, '' + Date.now());
if (!platform) {
throw new Error('Platform must be specified.');
}
const { version, major, minor } = getRNVersion();
console.log('Bundling with react-native: ', version);
await runReactNativeBundleCommand(
bundleName,
dev,
entryFile,
intermediaDir,
platform,
sourcemap ? sourcemapOutput : '',
);
await pack(path.resolve(intermediaDir), realOutput);
const v = await question('是否现在上传此热更包?(Y/N)');
if (v.toLowerCase() === 'y') {
await this.publish({
args: [realOutput],
options: {
platform,
},
});
}
},
async diff({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(args, options, 'diff');
await diffFromPPK(origin, next, realOutput, 'index.bundlejs');
console.log(`${realOutput} generated.`);
},
async hdiff({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(args, options, 'hdiff');
await diffFromPPK(origin, next, realOutput, 'index.bundlejs');
console.log(`${realOutput} generated.`);
},
async diffFromApk({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'diffFromApk',
);
await diffFromPackage(
origin,
next,
realOutput,
'assets/index.android.bundle',
);
console.log(`${realOutput} generated.`);
},
async hdiffFromApk({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'hdiffFromApk',
);
await diffFromPackage(
origin,
next,
realOutput,
'assets/index.android.bundle',
);
console.log(`${realOutput} generated.`);
},
async diffFromIpa({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'diffFromIpa',
);
await diffFromPackage(origin, next, realOutput, 'main.jsbundle', (v) => {
const m = /^Payload\/[^/]+\/(.+)$/.exec(v);
return m && m[1];
});
console.log(`${realOutput} generated.`);
},
async hdiffFromIpa({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'hdiffFromIpa',
);
await diffFromPackage(origin, next, realOutput, 'main.jsbundle', (v) => {
const m = /^Payload\/[^/]+\/(.+)$/.exec(v);
return m && m[1];
});
console.log(`${realOutput} generated.`);
},
};

1062
src/bundle.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,18 +2,22 @@
import { loadSession } from './api'; import { loadSession } from './api';
import updateNotifier from 'update-notifier'; import updateNotifier from 'update-notifier';
import { printVersionCommand } from './utils/index.js'; import { printVersionCommand } from './utils';
import pkg from '../package.json'; import pkg from '../package.json';
import { t } from './utils/i18n';
updateNotifier({ pkg }).notify({ isGlobal: true }); updateNotifier({ pkg }).notify({
isGlobal: true,
message: t('updateNotifier'),
});
function printUsage({ args }) { function printUsage() {
// const commandName = args[0]; // const commandName = args[0];
// TODO: print usage of commandName, or print global usage. // TODO: print usage of commandName, or print global usage.
console.log('Usage is under development now.'); console.log('Usage is under development now.');
console.log( console.log(
'Visit `https://github.com/reactnativecn/react-native-pushy` for early document.', 'Visit `https://github.com/reactnativecn/react-native-update` for document.',
); );
process.exit(1); process.exit(1);
} }
@@ -27,21 +31,21 @@ const commands = {
help: printUsage, help: printUsage,
}; };
function run() { async function run() {
printVersionCommand(); await printVersionCommand();
if (process.argv.indexOf('-v') >= 0 || process.argv[2] === 'version') { if (process.argv.indexOf('-v') >= 0 || process.argv[2] === 'version') {
process.exit(); process.exit();
} }
const argv = require('cli-arguments').parse(require('../cli.json')); const argv = require('cli-arguments').parse(require('../cli.json'));
global.NO_INTERACTIVE = argv.options['no-interactive']; global.NO_INTERACTIVE = argv.options['no-interactive'];
global.USE_ACC_OSS = argv.options['acc']; global.USE_ACC_OSS = argv.options.acc;
loadSession() loadSession()
.then(() => commands[argv.command](argv)) .then(() => commands[argv.command](argv))
.catch((err) => { .catch((err) => {
if (err.status === 401) { if (err.status === 401) {
console.log('尚未登录。\n请在项目目录中运行`pushy login`命令来登录'); console.log(t('loginFirst'));
return; return;
} }
console.error(err.stack); console.error(err.stack);

43
src/locales/en.ts Normal file
View File

@@ -0,0 +1,43 @@
export default {
updateNotifier:
'Run `{updateCommand}` to update the CLI to get continuous improvements in features, performance, and security.',
loginFirst:
'Not logged in.\nPlease run `cresc login` in the project directory to login.',
lockNotFound:
'No lock file detected, which may cause inconsistent dependencies and hot-updating issues.',
multipleLocksFound:
'Multiple lock files detected ({{lockFiles}}), which may cause inconsistent dependencies and hot-updating issues.',
lockBestPractice: `
Best practices for lock files:
1. All members of the development team should use the same package manager to maintain a single lock file.
2. Add the lock file to version control (but do not commit multiple lock files of different formats).
3. Pay attention to changes in the lock file during code review.
This can reduce the risk of inconsistent dependencies and supply chain attacks.
`,
loginExpired:
'Login information has expired. Please use `cresc login` command to re-login',
fileSizeExceeded:
'This file size is {{fileSize}} , exceeding the current quota {{maxSize}} . You may consider upgrading to a higher plan to increase this quota. Details can be found at: {{pricingPageUrl}}',
bundleNotFound:
'Bundle file not found. Please ensure that this {{packageType}} is a release version and the bundle file name is the default `{{entryFile}}`',
buildTimeNotFound:
'Cannot get the build timestamp of this package. Please update `react-native-update` to the latest version and re-package and upload.',
latestVersionTag: '(latest: {{version}})',
rnuVersionNotFound:
'react-native-update: Cannot get the version number. Please run the command in the project directory',
unsupportedPlatform: 'Unsupported platform `{{platform}}`',
appId: 'App ID',
appName: 'App Name',
platform: 'Platform',
totalApps: 'Total {{count}} apps',
appNotSelected:
'App not selected. run `cresc selectApp --platform {{platform}}` first!',
enterAppIdQuestion: 'Enter AppId:',
appNameQuestion: 'App Name:',
platformQuestion: 'Platform(ios/android/harmony):',
createAppSuccess: 'App created successfully (id: {{id}})',
cancelled: 'Cancelled',
operationSuccess: 'Operation successful',
failedToParseUpdateJson: 'Failed to parse file `update.json`. Try to remove it manually.',
ppkPackageGenerated: 'ppk package generated and saved to: {{output}}',
};

41
src/locales/zh.ts Normal file
View File

@@ -0,0 +1,41 @@
export default {
updateNotifier:
'建议运行 `{updateCommand}` 来更新命令行工具以获得功能、性能和安全性的持续改进',
loginFirst: '尚未登录。\n请在项目目录中运行`pushy login`命令来登录',
lockNotFound:
'没有检测到任何 lock 文件,这可能导致依赖关系不一致而使热更异常。',
lockBestPractice: `
关于 lock 文件的最佳实践:
1. 开发团队中的所有成员应该使用相同的包管理器,维护同一份 lock 文件。
2. 将 lock 文件添加到版本控制中(但不要同时提交多种不同格式的 lock 文件)。
3. 代码审核时应关注 lock 文件的变化。
这样可以最大限度避免因依赖关系不一致而导致的热更异常,也降低供应链攻击等安全隐患。
`,
multipleLocksFound:
'检测到多种不同格式的锁文件({{lockFiles}}),这可能导致依赖关系不一致而使热更异常。',
loginExpired: '登录信息已过期,请使用 `pushy login` 命令重新登录',
fileSizeExceeded:
'此文件大小 {{fileSize}} , 超出当前额度 {{maxSize}} 。您可以考虑升级付费业务以提升此额度。详情请访问: {{pricingPageUrl}}',
bundleNotFound:
'找不到 bundle 文件。请确保此 {{packageType}} 为 release 版本,且 bundle 文件名为默认的 `{{entryFile}}`',
buildTimeNotFound:
'无法获取此包的编译时间戳。请更新 `react-native-update` 到最新版本后重新打包上传。',
latestVersionTag: '(最新:{{version}}',
rnuVersionNotFound:
'react-native-update: 无法获取版本号。请在项目目录中运行命令',
unsupportedPlatform: '无法识别的平台 `{{platform}}`',
appId: '应用 id',
appName: '应用名称',
platform: '平台',
totalApps: '共 {{count}} 个{{platform}}应用',
appNotSelected:
'尚未选择应用。请先运行 `pushy selectApp --platform {{platform}}` 来选择应用',
enterAppIdQuestion: '输入应用 id:',
appNameQuestion: '应用名称:',
platformQuestion: '平台(ios/android/harmony):',
createAppSuccess: '已成功创建应用id: {{id}}',
cancelled: '已取消',
operationSuccess: '操作成功',
failedToParseUpdateJson: '无法解析文件 `update.json`。请手动删除它。',
ppkPackageGenerated: 'ppk 热更包已生成并保存到: {{output}}',
};

View File

@@ -3,10 +3,13 @@ import { question, saveToLocal } from './utils';
import { checkPlatform, getSelectedApp } from './app'; import { checkPlatform, getSelectedApp } from './app';
import { getApkInfo, getIpaInfo } from './utils'; import { getApkInfo, getIpaInfo, getAppInfo } from './utils';
import Table from 'tty-table'; import Table from 'tty-table';
import { depVersions } from './utils/dep-versions';
import { getCommitInfo } from './utils/git';
import type { Platform } from 'types';
export async function listPackage(appId) { export async function listPackage(appId: string) {
const { data } = await get(`/app/${appId}/package/list?limit=1000`); const { data } = await get(`/app/${appId}/package/list?limit=1000`);
const header = [{ value: '原生包 Id' }, { value: '原生版本' }]; const header = [{ value: '原生包 Id' }, { value: '原生版本' }];
@@ -35,12 +38,12 @@ export async function listPackage(appId) {
return data; return data;
} }
export async function choosePackage(appId) { export async function choosePackage(appId: string) {
const list = await listPackage(appId); const list = await listPackage(appId);
while (true) { while (true) {
const id = await question('输入原生包 id:'); const id = await question('输入原生包 id:');
const app = list.find((v) => v.id === (id | 0)); const app = list.find((v) => v.id === Number(id));
if (app) { if (app) {
return app; return app;
} }
@@ -48,7 +51,7 @@ export async function choosePackage(appId) {
} }
export const commands = { export const commands = {
uploadIpa: async function ({ args }) { uploadIpa: async ({ args }: { args: string[] }) => {
const fn = args[0]; const fn = args[0];
if (!fn || !fn.endsWith('.ipa')) { if (!fn || !fn.endsWith('.ipa')) {
throw new Error('使用方法: pushy uploadIpa ipa后缀文件'); throw new Error('使用方法: pushy uploadIpa ipa后缀文件');
@@ -79,13 +82,15 @@ export const commands = {
name: versionName, name: versionName,
hash, hash,
buildTime, buildTime,
deps: depVersions,
commit: await getCommitInfo(),
}); });
saveToLocal(fn, `${appId}/package/${id}.ipa`); saveToLocal(fn, `${appId}/package/${id}.ipa`);
console.log( console.log(
`已成功上传ipa原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`, `已成功上传ipa原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`,
); );
}, },
uploadApk: async function ({ args }) { uploadApk: async ({ args }: { args: string[] }) => {
const fn = args[0]; const fn = args[0];
if (!fn || !fn.endsWith('.apk')) { if (!fn || !fn.endsWith('.apk')) {
throw new Error('使用方法: pushy uploadApk apk后缀文件'); throw new Error('使用方法: pushy uploadApk apk后缀文件');
@@ -116,29 +121,77 @@ export const commands = {
name: versionName, name: versionName,
hash, hash,
buildTime, buildTime,
deps: depVersions,
commit: await getCommitInfo(),
}); });
saveToLocal(fn, `${appId}/package/${id}.apk`); saveToLocal(fn, `${appId}/package/${id}.apk`);
console.log( console.log(
`已成功上传apk原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`, `已成功上传apk原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`,
); );
}, },
parseIpa: async function ({ args }) { uploadApp: async ({ args }: { args: string[] }) => {
const fn = args[0];
if (!fn || !fn.endsWith('.app')) {
throw new Error('使用方法: pushy uploadApp app后缀文件');
}
const {
versionName,
buildTime,
appId: appIdInPkg,
appKey: appKeyInPkg,
} = await getAppInfo(fn);
const { appId, appKey } = await getSelectedApp('harmony');
if (appIdInPkg && appIdInPkg != appId) {
throw new Error(
`appId不匹配当前app: ${appIdInPkg}, 当前update.json: ${appId}`,
);
}
if (appKeyInPkg && appKeyInPkg !== appKey) {
throw new Error(
`appKey不匹配当前app: ${appKeyInPkg}, 当前update.json: ${appKey}`,
);
}
const { hash } = await uploadFile(fn);
const { id } = await post(`/app/${appId}/package/create`, {
name: versionName,
hash,
buildTime,
deps: depVersions,
commit: await getCommitInfo(),
});
saveToLocal(fn, `${appId}/package/${id}.app`);
console.log(
`已成功上传app原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`,
);
},
parseApp: async ({ args }: { args: string[] }) => {
const fn = args[0];
if (!fn || !fn.endsWith('.app')) {
throw new Error('使用方法: pushy parseApp app后缀文件');
}
console.log(await getAppInfo(fn));
},
parseIpa: async ({ args }: { args: string[] }) => {
const fn = args[0]; const fn = args[0];
if (!fn || !fn.endsWith('.ipa')) { if (!fn || !fn.endsWith('.ipa')) {
throw new Error('使用方法: pushy parseIpa ipa后缀文件'); throw new Error('使用方法: pushy parseIpa ipa后缀文件');
} }
console.log(await getIpaInfo(fn)); console.log(await getIpaInfo(fn));
}, },
parseApk: async function ({ args }) { parseApk: async ({ args }: { args: string[] }) => {
const fn = args[0]; const fn = args[0];
if (!fn || !fn.endsWith('.apk')) { if (!fn || !fn.endsWith('.apk')) {
throw new Error('使用方法: pushy parseApk apk后缀文件'); throw new Error('使用方法: pushy parseApk apk后缀文件');
} }
console.log(await getApkInfo(fn)); console.log(await getApkInfo(fn));
}, },
packages: async function ({ options }) { packages: async ({ options }: { options: { platform: Platform } }) => {
const platform = checkPlatform( const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')), options.platform || (await question('平台(ios/android/harmony):')),
); );
const { appId } = await getSelectedApp(platform); const { appId } = await getSelectedApp(platform);
await listPackage(appId); await listPackage(appId);

View File

@@ -6,3 +6,5 @@ declare global {
export interface Session { export interface Session {
token: string; token: string;
} }
export type Platform = 'ios' | 'android' | 'harmony';

View File

@@ -1,13 +1,13 @@
import { question } from './utils'; import { question } from './utils';
import { post, get, replaceSession, saveSession, closeSession } from './api'; import { post, get, replaceSession, saveSession, closeSession } from './api';
import crypto from 'crypto'; import crypto from 'node:crypto';
function md5(str) { function md5(str: string) {
return crypto.createHash('md5').update(str).digest('hex'); return crypto.createHash('md5').update(str).digest('hex');
} }
export const commands = { export const commands = {
login: async function ({ args }) { login: async ({ args }: { args: string[] }) => {
const email = args[0] || (await question('email:')); const email = args[0] || (await question('email:'));
const pwd = args[1] || (await question('password:', true)); const pwd = args[1] || (await question('password:', true));
const { token, info } = await post('/user/login', { const { token, info } = await post('/user/login', {
@@ -18,11 +18,11 @@ export const commands = {
await saveSession(); await saveSession();
console.log(`欢迎使用 pushy 热更新服务, ${info.name}.`); console.log(`欢迎使用 pushy 热更新服务, ${info.name}.`);
}, },
logout: async function () { logout: async () => {
await closeSession(); await closeSession();
console.log('已退出登录'); console.log('已退出登录');
}, },
me: async function () { me: async () => {
const me = await get('/user/me'); const me = await get('/user/me');
for (const k in me) { for (const k in me) {
if (k !== 'ok') { if (k !== 'ok') {

View File

@@ -0,0 +1,34 @@
import fs from 'node:fs';
// import path from 'node:path';
import { credentialFile, tempDir } from './constants';
export function addGitIgnore() {
const shouldIgnore = [credentialFile, tempDir];
const gitignorePath = '.gitignore';
if (!fs.existsSync(gitignorePath)) {
return;
}
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
const gitignoreLines = gitignoreContent.split('\n');
for (const line of gitignoreLines) {
const index = shouldIgnore.indexOf(line.trim());
if (index !== -1) {
shouldIgnore.splice(index, 1);
}
}
if (shouldIgnore.length > 0) {
gitignoreLines.push('# react-native-update');
for (const line of shouldIgnore) {
gitignoreLines.push(line);
console.log(`Added ${line} to .gitignore`);
}
fs.writeFileSync(gitignorePath, gitignoreLines.join('\n'));
}
}

View File

@@ -0,0 +1,16 @@
const Zip = require('./zip')
class AppParser extends Zip {
/**
* parser for parsing .apk file
* @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser
*/
constructor (file) {
super(file)
if (!(this instanceof AppParser)) {
return new AppParser(file)
}
}
}
module.exports = AppParser

View File

@@ -1,35 +0,0 @@
const ApkParser = require('./apk')
const IpaParser = require('./ipa')
const supportFileTypes = ['ipa', 'apk']
class AppInfoParser {
/**
* parser for parsing .ipa or .apk file
* @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser
*/
constructor (file) {
if (!file) {
throw new Error('Param miss: file(file\'s path in Node, instance of File or Blob in browser).')
}
const splits = (file.name || file).split('.')
const fileType = splits[splits.length - 1].toLowerCase()
if (!supportFileTypes.includes(fileType)) {
throw new Error('Unsupported file type, only support .ipa or .apk file.')
}
this.file = file
switch (fileType) {
case 'ipa':
this.parser = new IpaParser(this.file)
break
case 'apk':
this.parser = new ApkParser(this.file)
break
}
}
parse () {
return this.parser.parse()
}
}
module.exports = AppInfoParser

View File

@@ -0,0 +1,45 @@
const ApkParser = require('./apk');
const IpaParser = require('./ipa');
const AppParser = require('./app');
const supportFileTypes = ['ipa', 'apk', 'app'];
class AppInfoParser {
file: string | File;
parser: any;
/**
* parser for parsing .ipa or .apk file
* @param {String | File} file // file's path in Node, instance of File in Browser
*/
constructor(file: string | File) {
if (!file) {
throw new Error(
"Param miss: file(file's path in Node, instance of File in browser).",
);
}
const splits = (typeof file === 'string' ? file : file.name).split('.');
const fileType = splits[splits.length - 1].toLowerCase();
if (!supportFileTypes.includes(fileType)) {
throw new Error(
'Unsupported file type, only support .ipa or .apk or .app file.',
);
}
this.file = file;
switch (fileType) {
case 'ipa':
this.parser = new IpaParser(this.file);
break;
case 'apk':
this.parser = new ApkParser(this.file);
break;
case 'app':
this.parser = new AppParser(this.file);
break;
}
}
parse() {
return this.parser.parse();
}
}
export default AppInfoParser;

View File

@@ -30,7 +30,7 @@ function mapInfoResource (apkInfo, resourceMap) {
iteratorObj(apkInfo) iteratorObj(apkInfo)
return apkInfo return apkInfo
function iteratorObj (obj) { function iteratorObj (obj) {
for (var i in obj) { for (const i in obj) {
if (isArray(obj[i])) { if (isArray(obj[i])) {
iteratorArray(obj[i]) iteratorArray(obj[i])
} else if (isObject(obj[i])) { } else if (isObject(obj[i])) {

View File

@@ -1,20 +1,23 @@
const Unzip = require('isomorphic-unzip') const Unzip = require('isomorphic-unzip');
const { isBrowser, decodeNullUnicode } = require('./utils') const { isBrowser, decodeNullUnicode } = require('./utils');
import { enumZipEntries, readEntire } from '../../bundle';
class Zip { class Zip {
constructor (file) { constructor(file) {
if (isBrowser()) { if (isBrowser()) {
if (!(file instanceof window.Blob || typeof file.size !== 'undefined')) { if (!(file instanceof window.Blob || typeof file.size !== 'undefined')) {
throw new Error('Param error: [file] must be an instance of Blob or File in browser.') throw new Error(
'Param error: [file] must be an instance of Blob or File in browser.',
);
} }
this.file = file this.file = file;
} else { } else {
if (typeof file !== 'string') { if (typeof file !== 'string') {
throw new Error('Param error: [file] must be file path in Node.') throw new Error('Param error: [file] must be file path in Node.');
} }
this.file = require('path').resolve(file) this.file = require('path').resolve(file);
} }
this.unzip = new Unzip(this.file) this.unzip = new Unzip(this.file);
} }
/** /**
@@ -22,27 +25,42 @@ class Zip {
* @param {Array} regexps // regexps for matching files * @param {Array} regexps // regexps for matching files
* @param {String} type // return type, can be buffer or blob, default buffer * @param {String} type // return type, can be buffer or blob, default buffer
*/ */
getEntries (regexps, type = 'buffer') { getEntries(regexps, type = 'buffer') {
regexps = regexps.map(regex => decodeNullUnicode(regex)) regexps = regexps.map((regex) => decodeNullUnicode(regex));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.unzip.getBuffer(regexps, { type }, (err, buffers) => { this.unzip.getBuffer(regexps, { type }, (err, buffers) => {
err ? reject(err) : resolve(buffers) err ? reject(err) : resolve(buffers);
}) });
}) });
} }
/** /**
* get entry by regex, return an instance of Buffer or Blob * get entry by regex, return an instance of Buffer or Blob
* @param {Regex} regex // regex for matching file * @param {Regex} regex // regex for matching file
* @param {String} type // return type, can be buffer or blob, default buffer * @param {String} type // return type, can be buffer or blob, default buffer
*/ */
getEntry (regex, type = 'buffer') { getEntry(regex, type = 'buffer') {
regex = decodeNullUnicode(regex) regex = decodeNullUnicode(regex);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.unzip.getBuffer([regex], { type }, (err, buffers) => { this.unzip.getBuffer([regex], { type }, (err, buffers) => {
err ? reject(err) : resolve(buffers[regex]) // console.log(buffers);
}) err ? reject(err) : resolve(buffers[regex]);
}) });
});
}
async getEntryFromHarmonyApp(regex) {
try {
let originSource;
await enumZipEntries(this.file, (entry, zipFile) => {
if (regex.test(entry.fileName)) {
return readEntire(entry, zipFile).then((v) => (originSource = v));
}
});
return originSource;
} catch (error) {
console.error('Error in getEntryFromHarmonyApp:', error);
}
} }
} }
module.exports = Zip module.exports = Zip;

View File

@@ -0,0 +1,29 @@
import fs from 'node:fs';
import { t } from './i18n';
const lockFiles = [
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'bun.lockb',
'bun.lock',
];
const existingLockFiles: string[] = [];
export function checkLockFiles() {
for (const file of lockFiles) {
if (fs.existsSync(file)) {
existingLockFiles.push(file);
}
}
if (existingLockFiles.length === 1) {
return;
}
console.warn(t('lockBestPractice'));
if (existingLockFiles.length === 0) {
throw new Error(t('lockNotFound'));
}
throw new Error(
t('multipleLocksFound', { lockFiles: existingLockFiles.join(', ') }),
);
}

28
src/utils/check-plugin.ts Normal file
View File

@@ -0,0 +1,28 @@
import { plugins } from './plugin-config';
interface BundleParams {
sentry: boolean;
sourcemap: boolean;
[key: string]: any;
}
export async function checkPlugins(): Promise<BundleParams> {
const params: BundleParams = {
sentry: false,
sourcemap: false,
};
for (const plugin of plugins) {
try {
const isEnabled = await plugin.detect();
if (isEnabled && plugin.bundleParams) {
Object.assign(params, plugin.bundleParams);
console.log(`detected ${plugin.name} plugin`);
}
} catch (err) {
console.warn(`error while detecting ${plugin.name} plugin:`, err);
}
}
return params;
}

15
src/utils/constants.ts Normal file
View File

@@ -0,0 +1,15 @@
import path from 'node:path';
const scriptName = path.basename(process.argv[1]) as 'cresc' | 'pushy';
export const IS_CRESC = scriptName === 'cresc';
export const credentialFile = IS_CRESC ? '.cresc.token' : '.update';
export const updateJson = IS_CRESC ? 'cresc.config.json' : 'update.json';
export const tempDir = IS_CRESC ? '.cresc.temp' : '.pushy';
export const pricingPageUrl = IS_CRESC
? 'https://cresc.dev/pricing'
: 'https://pushy.reactnative.cn/pricing.html';
export const defaultEndpoint = IS_CRESC
? 'https://api.cresc.dev'
: 'https://update.reactnative.cn/api';

26
src/utils/dep-versions.ts Normal file
View File

@@ -0,0 +1,26 @@
const currentPackage = require(`${process.cwd()}/package.json`);
const depKeys = Object.keys(currentPackage.dependencies);
const devDepKeys = Object.keys(currentPackage.devDependencies);
const dedupedDeps = [...new Set([...depKeys, ...devDepKeys])];
const _depVersions: Record<string, string> = {};
for (const dep of dedupedDeps) {
try {
const packageJsonPath = require.resolve(`${dep}/package.json`, {
paths: [process.cwd()],
});
const version = require(packageJsonPath).version;
_depVersions[dep] = version;
} catch (e) {}
}
export const depVersions = Object.keys(_depVersions)
.sort() // Sort the keys alphabetically
.reduce((obj, key) => {
obj[key] = _depVersions[key]; // Rebuild the object with sorted keys
return obj;
}, {} as Record<string, string>);
// console.log({ depVersions });

50
src/utils/git.ts Normal file
View File

@@ -0,0 +1,50 @@
import git from 'isomorphic-git';
import fs from 'node:fs';
import path from 'node:path';
export interface CommitInfo {
hash: string;
message: string;
author: string;
timestamp: string;
origin: string;
}
function findGitRoot(dir = process.cwd()) {
const gitRoot = fs.readdirSync(dir).find((dir) => dir === '.git');
if (gitRoot) {
// console.log({ gitRoot });
return path.join(dir, gitRoot);
}
const parentDir = path.dirname(dir);
if (parentDir === dir) {
return null;
}
return findGitRoot(parentDir);
}
const gitRoot = findGitRoot();
export async function getCommitInfo(): Promise<CommitInfo | undefined> {
if (!gitRoot) {
return;
}
try {
const remotes = await git.listRemotes({ fs, gitdir: gitRoot });
const origin =
remotes.find((remote) => remote.remote === 'origin') || remotes[0];
const { commit, oid } = (
await git.log({ fs, gitdir: gitRoot, depth: 1 })
)[0];
return {
hash: oid,
message: commit.message,
author: commit.author.name || commit.committer.name,
timestamp: String(commit.committer.timestamp),
origin: origin?.url,
};
} catch (error) {
console.error(error);
return;
}
}

34
src/utils/i18n.ts Normal file
View File

@@ -0,0 +1,34 @@
import i18next from 'i18next';
import en from '../locales/en';
import zh from '../locales/zh';
import { IS_CRESC } from './constants';
i18next.init({
lng: IS_CRESC ? 'en' : 'zh',
// debug: process.env.NODE_ENV !== 'production',
// debug: true,
resources: {
en: {
translation: en,
},
zh: {
translation: zh,
},
},
});
declare module 'i18next' {
// Extend CustomTypeOptions
interface CustomTypeOptions {
// custom namespace type, if you changed it
defaultNS: 'en';
// custom resources type
resources: {
en: typeof en;
zh: typeof zh;
};
// other
}
}
export const t = i18next.t;

View File

@@ -1,164 +0,0 @@
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import pkg from '../../package.json';
import AppInfoParser from './app-info-parser';
import semverSatisfies from 'semver/functions/satisfies';
import { read } from 'read';
export async function question(query, password) {
if (NO_INTERACTIVE) {
return '';
}
return read({
prompt: query,
silent: password,
replace: password ? '*' : undefined,
});
}
export function translateOptions(options) {
const ret = {};
for (let key in options) {
const v = options[key];
if (typeof v === 'string') {
ret[key] = v.replace(/\$\{(\w+)\}/g, function (v, n) {
return options[n] || process.env[n] || v;
});
} else {
ret[key] = v;
}
}
return ret;
}
export function getRNVersion() {
const version = JSON.parse(
fs.readFileSync(
require.resolve('react-native/package.json', {
paths: [process.cwd()],
}),
),
).version;
// We only care about major and minor version.
const match = /^(\d+)\.(\d+)\./.exec(version);
return {
version,
major: match[1] | 0,
minor: match[2] | 0,
};
}
export async function getApkInfo(fn) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/assets\/index.android.bundle/,
);
if (!bundleFile) {
throw new Error(
'找不到bundle文件。请确保此apk为release版本且bundle文件名为默认的index.android.bundle',
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/res\/raw\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).android;
}
const { versionName, application } = await appInfoParser.parse();
let buildTime = 0;
if (Array.isArray(application.metaData)) {
for (const meta of application.metaData) {
if (meta.name === 'pushy_build_time') {
buildTime = meta.value[0];
}
}
}
if (buildTime == 0) {
throw new Error(
'无法获取此包的编译时间戳。请更新 react-native-update 到最新版本后重新打包上传。',
);
}
return { versionName, buildTime, ...appCredential };
}
export async function getIpaInfo(fn) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/main.jsbundle/,
);
if (!bundleFile) {
throw new Error(
'找不到bundle文件。请确保此ipa为release版本且bundle文件名为默认的main.jsbundle',
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/assets\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).ios;
}
const { CFBundleShortVersionString: versionName } =
await appInfoParser.parse();
let buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/pushy_build_time.txt/,
);
if (!buildTimeTxtBuffer) {
// Not in root bundle when use `use_frameworks`
buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/frameworks\/react_native_update.framework\/pushy_build_time.txt/,
);
}
if (!buildTimeTxtBuffer) {
throw new Error(
'无法获取此包的编译时间戳。请更新 react-native-update 到最新版本后重新打包上传。',
);
}
const buildTime = buildTimeTxtBuffer.toString().replace('\n', '');
return { versionName, buildTime, ...appCredential };
}
const localDir = path.resolve(os.homedir(), '.pushy');
fs.ensureDirSync(localDir);
export function saveToLocal(originPath, destName) {
// TODO
// const destPath = path.join(localDir, destName);
// fs.ensureDirSync(path.dirname(destPath));
// fs.copyFileSync(originPath, destPath);
}
export function printVersionCommand() {
console.log('react-native-update-cli: ' + pkg.version);
let pushyVersion = '';
try {
const PACKAGE_JSON_PATH = require.resolve(
'react-native-update/package.json',
{
paths: [process.cwd()],
},
);
pushyVersion = require(PACKAGE_JSON_PATH).version;
console.log('react-native-update: ' + pushyVersion);
} catch (e) {
console.log('react-native-update: 无法获取版本号,请在项目目录中运行命令');
}
if (pushyVersion) {
if (semverSatisfies(pushyVersion, '<8.5.1')) {
console.warn(
`当前版本已不再支持,请至少升级到 v8 的最新小版本后重新打包(代码无需改动): npm i react-native-update@8 .
如有使用安装 apk 的功能,请注意添加所需权限 https://pushy.reactnative.cn/docs/api#async-function-downloadandinstallapkurl`,
);
} else if (semverSatisfies(pushyVersion, '9.0.0 - 9.2.0')) {
console.warn(
`当前版本已不再支持,请至少升级到 v9 的最新小版本后重新打包(代码无需改动): npm i react-native-update@9 .
如有使用安装 apk 的功能,请注意添加所需权限 https://pushy.reactnative.cn/docs/api#async-function-downloadandinstallapkurl`,
);
}
}
}
export const pricingPageUrl = 'https://pushy.reactnative.cn/pricing.html';

223
src/utils/index.ts Normal file
View File

@@ -0,0 +1,223 @@
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import pkg from '../../package.json';
import AppInfoParser from './app-info-parser';
import semverSatisfies from 'semver/functions/satisfies';
import chalk from 'chalk';
import latestVersion from '@badisi/latest-version';
import { checkPlugins } from './check-plugin';
import { read } from 'read';
import { IS_CRESC, tempDir } from './constants';
import { depVersions } from './dep-versions';
import { t } from './i18n';
export async function question(query: string, password?: boolean) {
if (NO_INTERACTIVE) {
return '';
}
return read({
prompt: query,
silent: password,
replace: password ? '*' : undefined,
});
}
export function translateOptions(options: Record<string, string>) {
const ret: Record<string, string> = {};
for (const key in options) {
const v = options[key];
if (typeof v === 'string') {
ret[key] = v.replace(
/\$\{(\w+)\}/g,
(v, n) => options[n] || process.env[n] || v,
);
} else {
ret[key] = v;
}
}
return ret;
}
export async function getApkInfo(fn: string) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/assets\/index.android.bundle/,
);
if (!bundleFile) {
throw new Error(
t('bundleNotFound', {
packageType: 'apk',
entryFile: 'index.android.bundle',
}),
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/res\/raw\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).android;
}
const { versionName, application } = await appInfoParser.parse();
let buildTime = 0;
if (Array.isArray(application.metaData)) {
for (const meta of application.metaData) {
if (meta.name === 'pushy_build_time') {
buildTime = meta.value[0];
}
}
}
if (buildTime == 0) {
throw new Error(t('buildTimeNotFound'));
}
return { versionName, buildTime, ...appCredential };
}
export async function getAppInfo(fn: string) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntryFromHarmonyApp(
/rawfile\/bundle.harmony.js/,
);
if (!bundleFile) {
throw new Error(
t('bundleNotFound', {
packageType: 'app',
entryFile: 'bundle.harmony.js',
}),
);
}
const updateJsonFile = await appInfoParser.parser.getEntryFromHarmonyApp(
/rawfile\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).harmony;
}
const metaJsonFile = await appInfoParser.parser.getEntryFromHarmonyApp(
/rawfile\/meta.json/,
);
let metaData = {};
if (metaJsonFile) {
metaData = JSON.parse(metaJsonFile.toString());
}
const { versionName, pushy_build_time } = metaData;
let buildTime = 0;
if (pushy_build_time) {
buildTime = pushy_build_time;
}
if (buildTime == 0) {
throw new Error(t('buildTimeNotFound'));
}
return { versionName, buildTime, ...appCredential };
}
export async function getIpaInfo(fn: string) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/main.jsbundle/,
);
if (!bundleFile) {
throw new Error(
t('bundleNotFound', {
packageType: 'ipa',
entryFile: 'main.jsbundle',
}),
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/assets\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).ios;
}
const { CFBundleShortVersionString: versionName } =
await appInfoParser.parse();
let buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/pushy_build_time.txt/,
);
if (!buildTimeTxtBuffer) {
// Not in root bundle when use `use_frameworks`
buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/frameworks\/react_native_update.framework\/pushy_build_time.txt/,
);
}
if (!buildTimeTxtBuffer) {
throw new Error(t('buildTimeNotFound'));
}
const buildTime = buildTimeTxtBuffer.toString().replace('\n', '');
return { versionName, buildTime, ...appCredential };
}
const localDir = path.resolve(os.homedir(), tempDir);
fs.ensureDirSync(localDir);
export function saveToLocal(originPath: string, destName: string) {
// TODO
// const destPath = path.join(localDir, destName);
// fs.ensureDirSync(path.dirname(destPath));
// fs.copyFileSync(originPath, destPath);
}
async function getLatestVersion(pkgNames: string[]) {
return latestVersion(pkgNames, {
// useCache: true,
requestOptions: {
timeout: 2000,
},
})
.then((pkgs) => pkgs.map((pkg) => pkg.latest))
.catch(() => []);
}
export async function printVersionCommand() {
let [latestRnuCliVersion, latestRnuVersion] = await getLatestVersion([
'react-native-update-cli',
'react-native-update',
]);
latestRnuCliVersion = latestRnuCliVersion
? ` ${t('latestVersionTag', {
version: chalk.green(latestRnuCliVersion),
})}`
: '';
console.log(
`react-native-update-cli: ${pkg.version}${latestRnuCliVersion}`,
);
let rnuVersion = '';
rnuVersion = depVersions['react-native-update'];
latestRnuVersion = latestRnuVersion
? ` ${t('latestVersionTag', { version: chalk.green(latestRnuVersion) })}`
: '';
console.log(`react-native-update: ${rnuVersion}${latestRnuVersion}`);
if (rnuVersion) {
if (IS_CRESC) {
if (semverSatisfies(rnuVersion, '<10.27.0')) {
console.error(
'Unsupported version, please update to the latest version: npm i react-native-update@latest',
);
process.exit(1);
}
} else {
if (semverSatisfies(rnuVersion, '<8.5.2')) {
console.warn(
`当前版本已不再支持,请至少升级到 v8 的最新小版本后重新打包(代码无需改动): npm i react-native-update@8 .
如有使用安装 apk 的功能,请注意添加所需权限 https://pushy.reactnative.cn/docs/api#async-function-downloadandinstallapkurl`,
);
} else if (semverSatisfies(rnuVersion, '9.0.0 - 9.2.1')) {
console.warn(
`当前版本已不再支持,请至少升级到 v9 的最新小版本后重新打包(代码无需改动,可直接热更): npm i react-native-update@9 .
如有使用安装 apk 的功能,请注意添加所需权限 https://pushy.reactnative.cn/docs/api#async-function-downloadandinstallapkurl`,
);
} else if (semverSatisfies(rnuVersion, '10.0.0 - 10.17.0')) {
console.warn(
'当前版本已不再支持,请升级到 v10 的最新小版本(代码无需改动,可直接热更): npm i react-native-update@10',
);
}
}
} else {
console.log(t('rnuVersionNotFound'));
}
}
export { checkPlugins };

View File

@@ -0,0 +1,32 @@
import fs from 'fs-extra';
interface PluginConfig {
name: string;
bundleParams?: {
[key: string]: any;
};
detect: () => Promise<boolean>;
}
export const plugins: PluginConfig[] = [
{
name: 'sentry',
bundleParams: {
sentry: true,
sourcemap: true,
},
detect: async () => {
try {
await fs.access('ios/sentry.properties');
return true;
} catch {
try {
await fs.access('android/sentry.properties');
return true;
} catch {
return false;
}
}
}
}
];

View File

@@ -4,8 +4,11 @@ import { question, saveToLocal } from './utils';
import { checkPlatform, getSelectedApp } from './app'; import { checkPlatform, getSelectedApp } from './app';
import { choosePackage } from './package'; import { choosePackage } from './package';
import { compare } from 'compare-versions'; import { compare } from 'compare-versions';
import { depVersions } from './utils/dep-versions';
import { getCommitInfo } from './utils/git';
import { Platform } from 'types';
async function showVersion(appId, offset) { async function showVersion(appId: string, offset: number) {
const { data, count } = await get(`/app/${appId}/version/list`); const { data, count } = await get(`/app/${appId}/version/list`);
console.log(`Offset ${offset}`); console.log(`Offset ${offset}`);
for (const version of data) { for (const version of data) {
@@ -13,12 +16,12 @@ async function showVersion(appId, offset) {
.slice(0, 3) .slice(0, 3)
.map((v) => v.name) .map((v) => v.name)
.join(', '); .join(', ');
const count = version.packages.length; const pkgCount = version.packages.length;
if (count > 3) { if (pkgCount > 3) {
packageInfo += `...and ${count - 3} more`; packageInfo += `...and ${pkgCount - 3} more`;
} }
if (count === 0) { if (pkgCount === 0) {
packageInfo = `(no package)`; packageInfo = 'no package';
} else { } else {
packageInfo = `[${packageInfo}]`; packageInfo = `[${packageInfo}]`;
} }
@@ -31,7 +34,7 @@ async function showVersion(appId, offset) {
return data; return data;
} }
async function listVersions(appId) { async function listVersions(appId: string) {
let offset = 0; let offset = 0;
while (true) { while (true) {
await showVersion(appId, offset); await showVersion(appId, offset);
@@ -52,14 +55,14 @@ async function listVersions(appId) {
} }
} }
async function chooseVersion(appId) { async function chooseVersion(appId: string) {
let offset = 0; let offset = 0;
while (true) { while (true) {
const data = await showVersion(appId, offset); const data = await showVersion(appId, offset);
const cmd = await question( const cmd = await question(
'Enter versionId or page Up/page Down/Begin(U/D/B)', 'Enter versionId or page Up/page Down/Begin(U/D/B)',
); );
switch (cmd.toLowerCase()) { switch (cmd.toUpperCase()) {
case 'U': case 'U':
offset = Math.max(0, offset - 10); offset = Math.max(0, offset - 10);
break; break;
@@ -80,28 +83,37 @@ async function chooseVersion(appId) {
} }
export const commands = { export const commands = {
publish: async function ({ args, options }) { publish: async function ({ args, options }: { args: string[]; options: {
name: string;
description?: string;
metaInfo?: string;
platform?: Platform;
} }) {
const fn = args[0]; const fn = args[0];
const { name, description, metaInfo } = options; const { name, description, metaInfo } = options;
if (!fn || !fn.endsWith('.ppk')) { if (!fn || !fn.endsWith('.ppk')) {
throw new Error( throw new Error(
'使用方法: pushy publish ppk后缀文件 --platform ios|android', '使用方法: pushy publish ppk后缀文件 --platform ios|android|harmony',
); );
} }
const platform = checkPlatform( const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')), options.platform || (await question('平台(ios/android/harmony):')),
); );
const { appId } = await getSelectedApp(platform); const { appId } = await getSelectedApp(platform);
const { hash } = await uploadFile(fn); const { hash } = await uploadFile(fn);
const versionName =
name || (await question('输入版本名称: ')) || '(未命名)';
const { id } = await post(`/app/${appId}/version/create`, { const { id } = await post(`/app/${appId}/version/create`, {
name: name || (await question('输入版本名称: ')) || '(未命名)', name: versionName,
hash, hash,
description: description || (await question('输入版本描述:')), description: description || (await question('输入版本描述:')),
metaInfo: metaInfo || (await question('输入自定义的 meta info:')), metaInfo: metaInfo || (await question('输入自定义的 meta info:')),
deps: depVersions,
commit: await getCommitInfo(),
}); });
// TODO local diff // TODO local diff
saveToLocal(fn, `${appId}/ppk/${id}.ppk`); saveToLocal(fn, `${appId}/ppk/${id}.ppk`);
@@ -111,17 +123,18 @@ export const commands = {
if (v.toLowerCase() === 'y') { if (v.toLowerCase() === 'y') {
await this.update({ args: [], options: { versionId: id, platform } }); await this.update({ args: [], options: { versionId: id, platform } });
} }
return versionName;
}, },
versions: async function ({ options }) { versions: async ({ options }) => {
const platform = checkPlatform( const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')), options.platform || (await question('平台(ios/android/harmony):')),
); );
const { appId } = await getSelectedApp(platform); const { appId } = await getSelectedApp(platform);
await listVersions(appId); await listVersions(appId);
}, },
update: async function ({ args, options }) { update: async ({ args, options }) => {
const platform = checkPlatform( const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')), options.platform || (await question('平台(ios/android/harmony):')),
); );
const { appId } = await getSelectedApp(platform); const { appId } = await getSelectedApp(platform);
let versionId = options.versionId || (await chooseVersion(appId)).id; let versionId = options.versionId || (await chooseVersion(appId)).id;
@@ -129,7 +142,7 @@ export const commands = {
versionId = null; versionId = null;
} }
let pkgId; let pkgId: string | undefined;
let pkgVersion = options.packageVersion; let pkgVersion = options.packageVersion;
let minPkgVersion = options.minPackageVersion; let minPkgVersion = options.minPackageVersion;
let maxPkgVersion = options.maxPackageVersion; let maxPkgVersion = options.maxPackageVersion;
@@ -138,7 +151,7 @@ export const commands = {
rollout = null; rollout = null;
} else { } else {
try { try {
rollout = parseInt(rollout); rollout = Number.parseInt(rollout);
} catch (e) { } catch (e) {
throw new Error('rollout 必须是 1-100 的整数'); throw new Error('rollout 必须是 1-100 的整数');
} }
@@ -173,7 +186,9 @@ export const commands = {
await put(`/app/${appId}/package/${pkg.id}`, { await put(`/app/${appId}/package/${pkg.id}`, {
versionId, versionId,
}); });
console.log(`已将热更版本 ${versionId} 绑定到原生版本 ${pkg.name} (id: ${pkg.id})`); console.log(
`已将热更版本 ${versionId} 绑定到原生版本 ${pkg.name} (id: ${pkg.id})`,
);
} }
console.log(`操作完成,共已绑定 ${pkgs.length} 个原生版本`); console.log(`操作完成,共已绑定 ${pkgs.length} 个原生版本`);
return; return;
@@ -205,7 +220,9 @@ export const commands = {
await put(`/app/${appId}/package/${pkg.id}`, { await put(`/app/${appId}/package/${pkg.id}`, {
versionId, versionId,
}); });
console.log(`已将热更版本 ${versionId} 绑定到原生版本 ${pkg.name} (id: ${pkg.id})`); console.log(
`已将热更版本 ${versionId} 绑定到原生版本 ${pkg.name} (id: ${pkg.id})`,
);
} }
console.log(`操作完成,共已绑定 ${pkgs.length} 个原生版本`); console.log(`操作完成,共已绑定 ${pkgs.length} 个原生版本`);
return; return;
@@ -251,11 +268,13 @@ export const commands = {
await put(`/app/${appId}/package/${pkgId}`, { await put(`/app/${appId}/package/${pkgId}`, {
versionId, versionId,
}); });
console.log(`已将热更版本 ${versionId} 绑定到原生版本 ${pkgVersion} (id: ${pkgId})`); console.log(
`已将热更版本 ${versionId} 绑定到原生版本 ${pkgVersion} (id: ${pkgId})`,
);
}, },
updateVersionInfo: async function ({ args, options }) { updateVersionInfo: async ({ args, options }) => {
const platform = checkPlatform( const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')), options.platform || (await question('平台(ios/android/harmony):')),
); );
const { appId } = await getSelectedApp(platform); const { appId } = await getSelectedApp(platform);
const versionId = options.versionId || (await chooseVersion(appId)).id; const versionId = options.versionId || (await chooseVersion(appId)).id;

View File

@@ -52,9 +52,9 @@
] /* List of folders to include type definitions from. */, ] /* List of folders to include type definitions from. */,
// "types": [], /* Type declaration files to be included in compilation. */ // "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
// "paths": { "paths": {
// "@/*": ["src/*"] "@/*": ["src/*"]
// }, },
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */

2761
yarn.lock

File diff suppressed because it is too large Load Diff