react-native-paperplane 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/package.json +36 -0
- package/src/cli.js +698 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="pp-social-dark.png" />
|
|
4
|
+
<img src="pp-social.png" alt="React Native paper plane" width="900" />
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<h1 align="center">React Native Paperplane</h1>
|
|
9
|
+
|
|
10
|
+
Tiny CLI to bump iOS build numbers, build/export with Xcode, and upload to TestFlight.
|
|
11
|
+
|
|
12
|
+
License: MIT
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- One-command TestFlight release flow for iOS
|
|
17
|
+
- Supports app.config.ts, app.config.js, and app.json (text-only parsing)
|
|
18
|
+
- Dry run mode and clean git enforcement
|
|
19
|
+
- Deterministic output paths for build artifacts
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Node 18+ or Bun
|
|
24
|
+
- Xcode Command Line Tools
|
|
25
|
+
- Transporter app installed and signed in (for iTMSTransporter)
|
|
26
|
+
- Repo with ios/ and an Xcode workspace
|
|
27
|
+
- One of: app.config.ts, app.config.js, app.json
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -D react-native-paperplane
|
|
33
|
+
# or
|
|
34
|
+
bun add -d react-native-paperplane
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx react-native-paperplane --dry-run
|
|
41
|
+
npx react-native-paperplane
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Bun alternative:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bunx react-native-paperplane --dry-run
|
|
48
|
+
bunx react-native-paperplane
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Local development
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# run locally without publishing
|
|
55
|
+
bun src/cli.js --help
|
|
56
|
+
# or
|
|
57
|
+
node src/cli.js --help
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Optional global-style bin for development:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm link
|
|
64
|
+
paperplane --help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
paperplane [options]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Options
|
|
74
|
+
|
|
75
|
+
- `--build-number <n>`: Set explicit build number (default: current + 1)
|
|
76
|
+
- `--message <msg>`: Override git commit message
|
|
77
|
+
- `--dry-run`: Show actions without modifying files or running build/upload
|
|
78
|
+
- `--allow-dirty`: Skip clean git check
|
|
79
|
+
- `--skip-upload`: Build/export only; skip upload
|
|
80
|
+
- `-h, --help`: Show help
|
|
81
|
+
|
|
82
|
+
## Environment variables
|
|
83
|
+
|
|
84
|
+
Required for upload (Apple ID auth only):
|
|
85
|
+
|
|
86
|
+
- `ASC_APPLE_ID`
|
|
87
|
+
- `ASC_APP_PASSWORD`
|
|
88
|
+
|
|
89
|
+
Optional:
|
|
90
|
+
|
|
91
|
+
- `ASC_ITC_PROVIDER`
|
|
92
|
+
|
|
93
|
+
iOS overrides:
|
|
94
|
+
|
|
95
|
+
- `IOS_APP_NAME`
|
|
96
|
+
- `IOS_SCHEME`
|
|
97
|
+
- `IOS_WORKSPACE`
|
|
98
|
+
|
|
99
|
+
## Project assumptions
|
|
100
|
+
|
|
101
|
+
- ios/ contains an Xcode workspace (or set IOS_WORKSPACE).
|
|
102
|
+
- Info.plist is at ios/<AppName>/Info.plist.
|
|
103
|
+
- Build number is read from text in app.config.ts, app.config.js, or app.json.
|
|
104
|
+
|
|
105
|
+
## Output paths
|
|
106
|
+
|
|
107
|
+
Artifacts are written to:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
ios/build/testflight/<runId>/
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Examples
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
# dry run
|
|
117
|
+
paperplane --dry-run
|
|
118
|
+
|
|
119
|
+
# explicit build number
|
|
120
|
+
paperplane --build-number 42
|
|
121
|
+
|
|
122
|
+
# build only, no upload
|
|
123
|
+
paperplane --skip-upload
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## FAQ
|
|
127
|
+
|
|
128
|
+
**Why Bun?**
|
|
129
|
+
Bun is fast and works great for CLI workflows. This package also runs on Node 18+.
|
|
130
|
+
|
|
131
|
+
**Transporter errors?**
|
|
132
|
+
Install Transporter from the Mac App Store and sign in once.
|
|
133
|
+
|
|
134
|
+
## Publishing checklist
|
|
135
|
+
|
|
136
|
+
- Confirm the package name is available on npm: `npm view react-native-paperplane`.
|
|
137
|
+
- Update `package.json` version.
|
|
138
|
+
- Run a quick help check: `node src/cli.js --help`.
|
|
139
|
+
- Dry pack: `npm pack` and sanity-check the tarball contents.
|
|
140
|
+
- Publish: `npm publish --access public`.
|
|
141
|
+
|
|
142
|
+
Notes:
|
|
143
|
+
- `--access public` is only required for scoped packages, but harmless for unscoped.
|
|
144
|
+
|
|
145
|
+
```text
|
|
146
|
+
:
|
|
147
|
+
= ..=
|
|
148
|
+
+
|
|
149
|
+
=..= @%@..%@@
|
|
150
|
+
+%%@.....+%@...@
|
|
151
|
+
@@@..:...-:.+%@@.-..@
|
|
152
|
+
- @%@.-........-..#++@....--@@
|
|
153
|
+
%@%@...:.....-......#++%@..:...:.@ .
|
|
154
|
+
#%%@....+.-....=.:.+.. .#+*@%..-....:..@ =
|
|
155
|
+
@@@.....-................=.#++=%@..=...-:::=.@
|
|
156
|
+
@%%=...::...*......*....:......++==%@........:=::-.@ - +
|
|
157
|
+
= @@..::.-.:::...... ...-.......-.@*++@%........::=::--.*@ .
|
|
158
|
+
. @@.::::.:.......+.......@+%++@#.........=#::::=..@
|
|
159
|
+
+ + @%.....*.:. ...-..++*+%%..........:=:::.+:-.@@
|
|
160
|
+
+ . @+.......#++++@%.....-...=.----.-=---..@ =
|
|
161
|
+
+ . @+@%.**+++%@......#..=.--:.-+-:=--+.@@
|
|
162
|
+
@=+@.***%......:....*:---:::%:.--.=@
|
|
163
|
+
@-@:+*@.%@ ......::::.=:::--:-..@@
|
|
164
|
+
+ ---=+@==#%#%..:.:.::-.:*::-..@@
|
|
165
|
+
@:-+*.===*%#*@..:....::::+..@@
|
|
166
|
+
@%=@+==-+==@@ @%.....*...@@
|
|
167
|
+
@@+.:-=-@@ @%..=...@%
|
|
168
|
+
@@.-@@ @@..@@
|
|
169
|
+
#:@ * @%
|
|
170
|
+
|
|
171
|
+
PPPPPP AAA PPPPPP EEEEEEE RRRRRR PPPPPP L AAA N N EEEEEEE
|
|
172
|
+
P P A A P P E R R P P L A A NN N E
|
|
173
|
+
P P A A P P E R R P P L A A N N N E
|
|
174
|
+
PPPPPP A A PPPPPP EEEEEE RRRRRR PPPPPP L A A N N N EEEEEE
|
|
175
|
+
P AAAAAAA P E R R P L AAAAAAA N NN E
|
|
176
|
+
P A A P E R R P L A A N N E
|
|
177
|
+
P A A P EEEEEEE R R P LLLLLLL A A N N EEEEEEE
|
|
178
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-paperplane",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Tiny CLI to bump iOS build numbers, archive/export, and upload to TestFlight.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"paperplane": "src/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "node src/cli.js --help",
|
|
10
|
+
"dev:bun": "bun src/cli.js --help"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"image.png"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"testflight",
|
|
20
|
+
"testflight-upload",
|
|
21
|
+
"ios",
|
|
22
|
+
"xcode",
|
|
23
|
+
"xcodebuild",
|
|
24
|
+
"app-store",
|
|
25
|
+
"app-store-connect",
|
|
26
|
+
"react-native",
|
|
27
|
+
"expo",
|
|
28
|
+
"ios-build",
|
|
29
|
+
"ipa",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} = require("fs");
|
|
12
|
+
const { basename, join, resolve } = require("path");
|
|
13
|
+
const { spawn, spawnSync } = require("child_process");
|
|
14
|
+
|
|
15
|
+
const options = parseArgs(process.argv.slice(2));
|
|
16
|
+
|
|
17
|
+
if (options.help) {
|
|
18
|
+
printBanner();
|
|
19
|
+
printHelp();
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
main().catch((error) => {
|
|
24
|
+
console.error(error?.message ?? error);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const rootDir = process.cwd();
|
|
30
|
+
printBanner();
|
|
31
|
+
loadDotEnv(resolve(rootDir, ".env"));
|
|
32
|
+
|
|
33
|
+
const iosDir = resolve(rootDir, "ios");
|
|
34
|
+
ensureDir(iosDir, "ios directory");
|
|
35
|
+
|
|
36
|
+
const config = resolveConfigFile(rootDir);
|
|
37
|
+
const workspacePath = resolveWorkspacePath(iosDir, rootDir);
|
|
38
|
+
const appName = resolveAppName(workspacePath);
|
|
39
|
+
const scheme = process.env.IOS_SCHEME ?? appName;
|
|
40
|
+
const infoPlistPath = resolve(iosDir, appName, "Info.plist");
|
|
41
|
+
|
|
42
|
+
ensureFile(infoPlistPath, "Info.plist");
|
|
43
|
+
|
|
44
|
+
if (!options.allowDirty) {
|
|
45
|
+
ensureCleanGit(rootDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const configText = readFileSync(config.path, "utf8");
|
|
49
|
+
const buildInfo = readBuildNumber(config, configText);
|
|
50
|
+
|
|
51
|
+
if (!buildInfo) {
|
|
52
|
+
die(`Unable to detect build number in ${config.path}.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const nextBuildNumber =
|
|
56
|
+
options.buildNumber ??
|
|
57
|
+
(() => {
|
|
58
|
+
if (buildInfo.current === null) {
|
|
59
|
+
die(`Unable to detect current build number in ${config.path}.`);
|
|
60
|
+
}
|
|
61
|
+
return buildInfo.current + 1;
|
|
62
|
+
})();
|
|
63
|
+
|
|
64
|
+
if (!Number.isInteger(nextBuildNumber) || nextBuildNumber <= 0) {
|
|
65
|
+
die("Build number must be a positive integer.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (buildInfo.current !== null && nextBuildNumber === buildInfo.current) {
|
|
69
|
+
die("Build number is already set to that value.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const updatedConfigText = updateBuildNumber(
|
|
73
|
+
config,
|
|
74
|
+
configText,
|
|
75
|
+
nextBuildNumber,
|
|
76
|
+
buildInfo.source,
|
|
77
|
+
);
|
|
78
|
+
const infoPlistText = readFileSync(infoPlistPath, "utf8");
|
|
79
|
+
const updatedInfoPlistText = updatePlistBuildNumber(
|
|
80
|
+
infoPlistText,
|
|
81
|
+
nextBuildNumber,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (options.dryRun) {
|
|
85
|
+
const currentPlistBuild = readPlistBuildNumber(infoPlistText) ?? "unknown";
|
|
86
|
+
console.log("Dry run:");
|
|
87
|
+
console.log(`- App: ${appName}`);
|
|
88
|
+
console.log(`- Workspace: ${workspacePath}`);
|
|
89
|
+
console.log(`- Scheme: ${scheme}`);
|
|
90
|
+
console.log(
|
|
91
|
+
`- ${basename(config.path)} buildNumber: ${buildInfo.current ?? "unknown"} -> ${nextBuildNumber}`,
|
|
92
|
+
);
|
|
93
|
+
console.log(
|
|
94
|
+
`- Info.plist CFBundleVersion: ${currentPlistBuild} -> ${nextBuildNumber}`,
|
|
95
|
+
);
|
|
96
|
+
console.log(
|
|
97
|
+
"- No files were written, no commit made, no build/export/upload executed.",
|
|
98
|
+
);
|
|
99
|
+
printSuccess({ dryRun: true });
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
writeFileSync(config.path, updatedConfigText);
|
|
104
|
+
writeFileSync(infoPlistPath, updatedInfoPlistText);
|
|
105
|
+
|
|
106
|
+
const commitMessage =
|
|
107
|
+
options.message ?? `chore(release): bump iOS build to ${nextBuildNumber}`;
|
|
108
|
+
const pathsToStage = [config.path];
|
|
109
|
+
if (isTracked(rootDir, infoPlistPath)) {
|
|
110
|
+
pathsToStage.push(infoPlistPath);
|
|
111
|
+
}
|
|
112
|
+
runOrThrow(rootDir, "git", ["add", ...pathsToStage]);
|
|
113
|
+
runOrThrow(rootDir, "git", ["commit", "-m", commitMessage]);
|
|
114
|
+
|
|
115
|
+
const runId = makeRunId();
|
|
116
|
+
const buildRoot = resolve(rootDir, "ios/build/testflight", runId);
|
|
117
|
+
const archivePath = join(buildRoot, `${appName}.xcarchive`);
|
|
118
|
+
const exportPath = join(buildRoot, "export");
|
|
119
|
+
const exportOptionsPath = join(buildRoot, "exportOptions.plist");
|
|
120
|
+
|
|
121
|
+
mkdirSync(buildRoot, { recursive: true });
|
|
122
|
+
mkdirSync(exportPath, { recursive: true });
|
|
123
|
+
writeFileSync(exportOptionsPath, buildExportOptionsPlist(), "utf8");
|
|
124
|
+
|
|
125
|
+
await runOrThrowAsync(rootDir, "xcodebuild", [
|
|
126
|
+
"-workspace",
|
|
127
|
+
workspacePath,
|
|
128
|
+
"-scheme",
|
|
129
|
+
scheme,
|
|
130
|
+
"-configuration",
|
|
131
|
+
"Release",
|
|
132
|
+
"-sdk",
|
|
133
|
+
"iphoneos",
|
|
134
|
+
"-destination",
|
|
135
|
+
"generic/platform=iOS",
|
|
136
|
+
"-archivePath",
|
|
137
|
+
archivePath,
|
|
138
|
+
"-allowProvisioningUpdates",
|
|
139
|
+
"archive",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
await runOrThrowAsync(rootDir, "xcodebuild", [
|
|
143
|
+
"-exportArchive",
|
|
144
|
+
"-archivePath",
|
|
145
|
+
archivePath,
|
|
146
|
+
"-exportOptionsPlist",
|
|
147
|
+
exportOptionsPath,
|
|
148
|
+
"-exportPath",
|
|
149
|
+
exportPath,
|
|
150
|
+
"-allowProvisioningUpdates",
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const ipaPath = findIpa(exportPath);
|
|
154
|
+
if (options.skipUpload) {
|
|
155
|
+
console.log(`Build/export complete. IPA ready at: ${ipaPath}`);
|
|
156
|
+
printSuccess({ ipaPath });
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ensureTransporterAvailable(rootDir);
|
|
161
|
+
const uploadArgs = buildUploadArgs(ipaPath);
|
|
162
|
+
|
|
163
|
+
await runOrThrowAsync(rootDir, "xcrun", uploadArgs);
|
|
164
|
+
|
|
165
|
+
console.log(`Upload complete. IPA: ${ipaPath}`);
|
|
166
|
+
printSuccess({ ipaPath });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseArgs(args) {
|
|
170
|
+
const options = {
|
|
171
|
+
buildNumber: undefined,
|
|
172
|
+
message: undefined,
|
|
173
|
+
dryRun: false,
|
|
174
|
+
allowDirty: false,
|
|
175
|
+
skipUpload: false,
|
|
176
|
+
help: false,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
180
|
+
const arg = args[i];
|
|
181
|
+
|
|
182
|
+
if (arg === "--build-number" || arg.startsWith("--build-number=")) {
|
|
183
|
+
const value = arg.includes("=") ? arg.split("=")[1] : args[i + 1];
|
|
184
|
+
if (!value) {
|
|
185
|
+
die("Missing value for --build-number.");
|
|
186
|
+
}
|
|
187
|
+
const parsed = Number(value);
|
|
188
|
+
if (!Number.isInteger(parsed)) {
|
|
189
|
+
die(`Invalid build number: ${value}`);
|
|
190
|
+
}
|
|
191
|
+
options.buildNumber = parsed;
|
|
192
|
+
if (!arg.includes("=")) {
|
|
193
|
+
i += 1;
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (arg === "--message" || arg.startsWith("--message=")) {
|
|
199
|
+
const value = arg.includes("=") ? arg.split("=")[1] : args[i + 1];
|
|
200
|
+
if (!value) {
|
|
201
|
+
die("Missing value for --message.");
|
|
202
|
+
}
|
|
203
|
+
options.message = value;
|
|
204
|
+
if (!arg.includes("=")) {
|
|
205
|
+
i += 1;
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (arg === "--dry-run") {
|
|
211
|
+
options.dryRun = true;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (arg === "--allow-dirty") {
|
|
216
|
+
options.allowDirty = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (arg === "--skip-upload") {
|
|
221
|
+
options.skipUpload = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (arg === "--help" || arg === "-h") {
|
|
226
|
+
options.help = true;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
die(`Unknown argument: ${arg}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return options;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function printHelp() {
|
|
237
|
+
console.log(`Usage: paperplane [options]
|
|
238
|
+
|
|
239
|
+
Options:
|
|
240
|
+
--build-number <n> Set an explicit build number (default: +1)
|
|
241
|
+
--message <msg> Commit message override
|
|
242
|
+
--dry-run Show actions without modifying files
|
|
243
|
+
--allow-dirty Skip clean git check
|
|
244
|
+
--skip-upload Build/export only; skip Transporter upload
|
|
245
|
+
--help, -h Show help
|
|
246
|
+
|
|
247
|
+
Environment:
|
|
248
|
+
IOS_APP_NAME iOS target folder + scheme (defaults to workspace name)
|
|
249
|
+
IOS_SCHEME Override the Xcode scheme
|
|
250
|
+
IOS_WORKSPACE Override the workspace path (relative to repo root)
|
|
251
|
+
ASC_APPLE_ID Apple ID (required for upload)
|
|
252
|
+
ASC_APP_PASSWORD App-specific password (required for upload)
|
|
253
|
+
ASC_ITC_PROVIDER iTMSTransporter provider short name (optional)
|
|
254
|
+
`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function printBanner() {
|
|
258
|
+
const banner = [
|
|
259
|
+
" :",
|
|
260
|
+
" = ..=",
|
|
261
|
+
" +",
|
|
262
|
+
" =..= @%@..%@@",
|
|
263
|
+
" +%%@.....+%@...@",
|
|
264
|
+
" @@@..:...-:.+%@@.-..@",
|
|
265
|
+
" - @%@.-........-..#++@....--@@",
|
|
266
|
+
" %@%@...:.....-......#++%@..:...:.@ .",
|
|
267
|
+
" #%%@....+.-....=.:.+.. .#+*@%..-....:..@ =",
|
|
268
|
+
" @@@.....-................=.#++=%@..=...-:::=.@",
|
|
269
|
+
" @%%=...::...*......*....:......++==%@........:=::-.@ - +",
|
|
270
|
+
" = @@..::.-.:::...... ...-.......-.@*++@%........::=::--.*@ .",
|
|
271
|
+
" . @@.::::.:.......+.......@+%++@#.........=#::::=..@",
|
|
272
|
+
" + + @%.....*.:. ...-..++*+%%..........:=:::.+:-.@@",
|
|
273
|
+
" + . @+.......#++++@%.....-...=.----.-=---..@ =",
|
|
274
|
+
" + . @+@%.**+++%@......#..=.--:.-+-:=--+.@@",
|
|
275
|
+
" @=+@.***%......:....*:---:::%:.--.=@",
|
|
276
|
+
" @-@:+*@.%@ ......::::.=:::--:-..@@",
|
|
277
|
+
" + ---=+@==#%#%..:.:.::-.:*::-..@@",
|
|
278
|
+
" @:-+*.===*%#*@..:....::::+..@@",
|
|
279
|
+
" @%=@+==-+==@@ @%.....*...@@",
|
|
280
|
+
" @@+.:-=-@@ @%..=...@%",
|
|
281
|
+
" @@.-@@ @@..@@",
|
|
282
|
+
" #:@ * @%",
|
|
283
|
+
"",
|
|
284
|
+
" PPPPPP AAA PPPPPP EEEEEEE RRRRRR PPPPPP L AAA N N EEEEEEE",
|
|
285
|
+
" P P A A P P E R R P P L A A NN N E ",
|
|
286
|
+
" P P A A P P E R R P P L A A N N N E ",
|
|
287
|
+
" PPPPPP A A PPPPPP EEEEEE RRRRRR PPPPPP L A A N N N EEEEEE ",
|
|
288
|
+
" P AAAAAAA P E R R P L AAAAAAA N NN E ",
|
|
289
|
+
" P A A P E R R P L A A N N E ",
|
|
290
|
+
" P A A P EEEEEEE R R P LLLLLLL A A N N EEEEEEE",
|
|
291
|
+
];
|
|
292
|
+
console.log(banner.join("\n"));
|
|
293
|
+
console.log("");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function printSuccess({ dryRun = false, ipaPath } = {}) {
|
|
297
|
+
console.log("");
|
|
298
|
+
if (dryRun) {
|
|
299
|
+
console.log("paperplane: dry run complete.");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (ipaPath) {
|
|
303
|
+
console.log(`paperplane: done -> ${ipaPath}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
console.log("paperplane: done.");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function loadDotEnv(path) {
|
|
310
|
+
try {
|
|
311
|
+
const content = readFileSync(path, "utf8");
|
|
312
|
+
const lines = content.split(/\r?\n/);
|
|
313
|
+
for (const rawLine of lines) {
|
|
314
|
+
const line = rawLine.trim();
|
|
315
|
+
if (!line || line.startsWith("#")) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const match = line.match(
|
|
319
|
+
/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/,
|
|
320
|
+
);
|
|
321
|
+
if (!match) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const key = match[1];
|
|
325
|
+
let value = match[2].trim();
|
|
326
|
+
if (
|
|
327
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
328
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
329
|
+
) {
|
|
330
|
+
value = value.slice(1, -1);
|
|
331
|
+
if (rawLine.includes('"')) {
|
|
332
|
+
value = value
|
|
333
|
+
.replace(/\\n/g, "\n")
|
|
334
|
+
.replace(/\\r/g, "\r")
|
|
335
|
+
.replace(/\\t/g, "\t")
|
|
336
|
+
.replace(/\\\\/g, "\\");
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
value = value.replace(/\s+#.*$/, "");
|
|
340
|
+
}
|
|
341
|
+
if (process.env[key] === undefined) {
|
|
342
|
+
process.env[key] = value;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error.code !== "ENOENT") {
|
|
347
|
+
die(`Failed to read .env file at ${path}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function resolveConfigFile(rootDir) {
|
|
353
|
+
const candidates = ["app.config.ts", "app.config.js", "app.json"];
|
|
354
|
+
for (const candidate of candidates) {
|
|
355
|
+
const candidatePath = resolve(rootDir, candidate);
|
|
356
|
+
if (existsSync(candidatePath)) {
|
|
357
|
+
return {
|
|
358
|
+
path: candidatePath,
|
|
359
|
+
type: candidate.endsWith(".json") ? "appJson" : "appConfig",
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
die(
|
|
364
|
+
"No app config found. Expected one of app.config.ts, app.config.js, or app.json.",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function resolveWorkspacePath(iosDir, rootDir) {
|
|
369
|
+
const override = process.env.IOS_WORKSPACE;
|
|
370
|
+
if (override) {
|
|
371
|
+
const resolved = override.startsWith("/")
|
|
372
|
+
? override
|
|
373
|
+
: resolve(rootDir, override);
|
|
374
|
+
ensureDir(resolved, "workspace");
|
|
375
|
+
return resolved;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const entries = readdirSync(iosDir, { withFileTypes: true })
|
|
379
|
+
.filter((entry) => entry.name.endsWith(".xcworkspace"))
|
|
380
|
+
.map((entry) => entry.name);
|
|
381
|
+
|
|
382
|
+
if (entries.length === 0) {
|
|
383
|
+
die("No .xcworkspace found in ios/. Set IOS_WORKSPACE.");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (entries.length > 1) {
|
|
387
|
+
console.warn(
|
|
388
|
+
`Multiple workspaces found. Using ${entries[0]}. Set IOS_WORKSPACE to override.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return resolve(iosDir, entries[0]);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function resolveAppName(workspacePath) {
|
|
396
|
+
return process.env.IOS_APP_NAME ?? basename(workspacePath, ".xcworkspace");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function ensureFile(path, label) {
|
|
400
|
+
try {
|
|
401
|
+
const stats = statSync(path);
|
|
402
|
+
if (!stats.isFile()) {
|
|
403
|
+
die(`Expected ${label ?? "file"} at ${path}`);
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
die(`Missing required ${label ?? "file"}: ${path}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function ensureDir(path, label) {
|
|
411
|
+
try {
|
|
412
|
+
const stats = statSync(path);
|
|
413
|
+
if (!stats.isDirectory()) {
|
|
414
|
+
die(`Expected ${label ?? "directory"} at ${path}`);
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
die(`Missing required ${label ?? "directory"}: ${path}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function readBuildNumber(config, text) {
|
|
422
|
+
if (config.type === "appJson") {
|
|
423
|
+
return readBuildNumberFromAppJson(text);
|
|
424
|
+
}
|
|
425
|
+
return readBuildNumberFromAppConfig(text);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function readBuildNumberFromAppConfig(text) {
|
|
429
|
+
const iosRegex = /ios\s*:\s*{[\s\S]*?buildNumber\s*:\s*(['"])(\d+)\1/;
|
|
430
|
+
const anyRegex = /buildNumber\s*:\s*(['"])(\d+)\1/;
|
|
431
|
+
|
|
432
|
+
const iosMatch = text.match(iosRegex);
|
|
433
|
+
if (iosMatch) {
|
|
434
|
+
return {
|
|
435
|
+
current: Number(iosMatch[2]),
|
|
436
|
+
source: "appConfig:ios",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const anyMatch = text.match(anyRegex);
|
|
441
|
+
if (anyMatch) {
|
|
442
|
+
return {
|
|
443
|
+
current: Number(anyMatch[2]),
|
|
444
|
+
source: "appConfig:any",
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function readBuildNumberFromAppJson(text) {
|
|
452
|
+
let json;
|
|
453
|
+
try {
|
|
454
|
+
json = JSON.parse(text);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
die("app.json is not valid JSON.");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const iosBuild = json?.expo?.ios?.buildNumber;
|
|
460
|
+
if (iosBuild !== undefined && iosBuild !== null) {
|
|
461
|
+
if (!isDigits(String(iosBuild))) {
|
|
462
|
+
die("expo.ios.buildNumber must be a numeric string.");
|
|
463
|
+
}
|
|
464
|
+
return { current: Number(iosBuild), source: "appJson:expo.ios" };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const expoBuild = json?.expo?.buildNumber;
|
|
468
|
+
if (expoBuild !== undefined && expoBuild !== null) {
|
|
469
|
+
if (!isDigits(String(expoBuild))) {
|
|
470
|
+
die("expo.buildNumber must be a numeric string.");
|
|
471
|
+
}
|
|
472
|
+
return { current: Number(expoBuild), source: "appJson:expo" };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function updateBuildNumber(config, text, nextBuildNumber, source) {
|
|
479
|
+
if (config.type === "appJson") {
|
|
480
|
+
return updateBuildNumberInAppJson(text, nextBuildNumber, source);
|
|
481
|
+
}
|
|
482
|
+
return updateBuildNumberInAppConfig(text, nextBuildNumber);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function updateBuildNumberInAppConfig(text, nextBuildNumber) {
|
|
486
|
+
const iosRegex = /ios\s*:\s*{[\s\S]*?buildNumber\s*:\s*(['"])(\d+)\1/;
|
|
487
|
+
const anyRegex = /buildNumber\s*:\s*(['"])(\d+)\1/;
|
|
488
|
+
|
|
489
|
+
if (iosRegex.test(text)) {
|
|
490
|
+
const updated = text.replace(iosRegex, (match, quote) =>
|
|
491
|
+
match.replace(
|
|
492
|
+
/buildNumber\s*:\s*(['"])\d+\1/,
|
|
493
|
+
`buildNumber: ${quote}${nextBuildNumber}${quote}`,
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
if (updated === text) {
|
|
497
|
+
die("Failed to update buildNumber in app.config.");
|
|
498
|
+
}
|
|
499
|
+
return updated;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (anyRegex.test(text)) {
|
|
503
|
+
const updated = text.replace(anyRegex, (_, quote) => {
|
|
504
|
+
return `buildNumber: ${quote}${nextBuildNumber}${quote}`;
|
|
505
|
+
});
|
|
506
|
+
if (updated === text) {
|
|
507
|
+
die("Failed to update buildNumber in app.config.");
|
|
508
|
+
}
|
|
509
|
+
return updated;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
die("Failed to update buildNumber in app.config.");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function updateBuildNumberInAppJson(text, nextBuildNumber, source) {
|
|
516
|
+
if (source === "appJson:expo.ios") {
|
|
517
|
+
const iosRegex = /("expo"\s*:\s*{[\s\S]*?"ios"\s*:\s*{[\s\S]*?"buildNumber"\s*:\s*")(\d+)(")/;
|
|
518
|
+
if (!iosRegex.test(text)) {
|
|
519
|
+
die("Failed to update expo.ios.buildNumber in app.json.");
|
|
520
|
+
}
|
|
521
|
+
return text.replace(iosRegex, `$1${nextBuildNumber}$3`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const expoRegex = /("expo"\s*:\s*{[\s\S]*?"buildNumber"\s*:\s*")(\d+)(")/;
|
|
525
|
+
if (!expoRegex.test(text)) {
|
|
526
|
+
die("Failed to update expo.buildNumber in app.json.");
|
|
527
|
+
}
|
|
528
|
+
return text.replace(expoRegex, `$1${nextBuildNumber}$3`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function readPlistBuildNumber(plistText) {
|
|
532
|
+
const match = plistText.match(
|
|
533
|
+
/<key>CFBundleVersion<\/key>\s*<string>([^<]*)<\/string>/,
|
|
534
|
+
);
|
|
535
|
+
return match ? match[1] : null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function updatePlistBuildNumber(plistText, buildNumber) {
|
|
539
|
+
const updated = plistText.replace(
|
|
540
|
+
/(<key>CFBundleVersion<\/key>\s*<string>)([^<]*)(<\/string>)/,
|
|
541
|
+
`$1${buildNumber}$3`,
|
|
542
|
+
);
|
|
543
|
+
if (updated === plistText) {
|
|
544
|
+
die("Failed to update CFBundleVersion in Info.plist.");
|
|
545
|
+
}
|
|
546
|
+
return updated;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function buildExportOptionsPlist() {
|
|
550
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
551
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
552
|
+
<plist version="1.0">
|
|
553
|
+
<dict>
|
|
554
|
+
<key>method</key>
|
|
555
|
+
<string>app-store-connect</string>
|
|
556
|
+
<key>signingStyle</key>
|
|
557
|
+
<string>automatic</string>
|
|
558
|
+
<key>uploadBitcode</key>
|
|
559
|
+
<false/>
|
|
560
|
+
<key>compileBitcode</key>
|
|
561
|
+
<false/>
|
|
562
|
+
</dict>
|
|
563
|
+
</plist>
|
|
564
|
+
`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function findIpa(exportPath) {
|
|
568
|
+
const entries = readdirSync(exportPath);
|
|
569
|
+
const direct = entries.find((entry) => entry.endsWith(".ipa"));
|
|
570
|
+
if (direct) {
|
|
571
|
+
return join(exportPath, direct);
|
|
572
|
+
}
|
|
573
|
+
for (const entry of entries) {
|
|
574
|
+
const fullPath = join(exportPath, entry);
|
|
575
|
+
const stats = statSync(fullPath);
|
|
576
|
+
if (!stats.isDirectory()) {
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const nested = readdirSync(fullPath).find((file) => file.endsWith(".ipa"));
|
|
580
|
+
if (nested) {
|
|
581
|
+
return join(fullPath, nested);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
die("No .ipa found after export.");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function ensureTransporterAvailable(rootDir) {
|
|
588
|
+
const result = spawnSync("xcrun", ["--find", "iTMSTransporter"], {
|
|
589
|
+
cwd: rootDir,
|
|
590
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
591
|
+
});
|
|
592
|
+
if (result.status !== 0) {
|
|
593
|
+
die(
|
|
594
|
+
"iTMSTransporter not found. Install the Transporter app from the Mac App Store, then try again.",
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function buildUploadArgs(ipaPath) {
|
|
600
|
+
const appleId = process.env.ASC_APPLE_ID;
|
|
601
|
+
const applePassword = process.env.ASC_APP_PASSWORD;
|
|
602
|
+
const provider = process.env.ASC_ITC_PROVIDER;
|
|
603
|
+
|
|
604
|
+
if (!appleId || !applePassword) {
|
|
605
|
+
die(
|
|
606
|
+
"Missing App Store Connect credentials. Set ASC_APPLE_ID and ASC_APP_PASSWORD.",
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const args = [
|
|
611
|
+
"iTMSTransporter",
|
|
612
|
+
"-m",
|
|
613
|
+
"upload",
|
|
614
|
+
"-assetFile",
|
|
615
|
+
ipaPath,
|
|
616
|
+
"-u",
|
|
617
|
+
appleId,
|
|
618
|
+
"-p",
|
|
619
|
+
applePassword,
|
|
620
|
+
];
|
|
621
|
+
|
|
622
|
+
if (provider) {
|
|
623
|
+
args.push("-itc_provider", provider);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return args;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function makeRunId() {
|
|
630
|
+
const iso = new Date().toISOString();
|
|
631
|
+
return iso.replace("T", "-").replace(/[:]/g, "").replace(/\..+/, "");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function runCapture(rootDir, cmd, args) {
|
|
635
|
+
const result = spawnSync(cmd, args, {
|
|
636
|
+
cwd: rootDir,
|
|
637
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
638
|
+
});
|
|
639
|
+
return {
|
|
640
|
+
stdout: result.stdout?.toString() ?? "",
|
|
641
|
+
stderr: result.stderr?.toString() ?? "",
|
|
642
|
+
status: result.status ?? 0,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function ensureCleanGit(rootDir) {
|
|
647
|
+
const status = runCapture(rootDir, "git", ["status", "--porcelain"]);
|
|
648
|
+
if (status.stdout.trim().length > 0) {
|
|
649
|
+
die(
|
|
650
|
+
"Working tree is not clean. Commit or stash changes first, or pass --allow-dirty.",
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function isTracked(rootDir, path) {
|
|
656
|
+
const result = spawnSync("git", ["ls-files", "--error-unmatch", path], {
|
|
657
|
+
cwd: rootDir,
|
|
658
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
659
|
+
});
|
|
660
|
+
return result.status === 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function runOrThrow(rootDir, cmd, args) {
|
|
664
|
+
const result = spawnSync(cmd, args, {
|
|
665
|
+
cwd: rootDir,
|
|
666
|
+
stdio: "inherit",
|
|
667
|
+
});
|
|
668
|
+
if (result.status !== 0) {
|
|
669
|
+
die(`Command failed: ${cmd} ${args.join(" ")}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function runOrThrowAsync(rootDir, cmd, args) {
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
const proc = spawn(cmd, args, {
|
|
676
|
+
cwd: rootDir,
|
|
677
|
+
stdio: "inherit",
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
proc.on("error", reject);
|
|
681
|
+
proc.on("close", (code) => {
|
|
682
|
+
if (code !== 0) {
|
|
683
|
+
reject(new Error(`Command failed: ${cmd} ${args.join(" ")}`));
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
resolve();
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isDigits(value) {
|
|
692
|
+
return /^\d+$/.test(value);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function die(message) {
|
|
696
|
+
console.error(message);
|
|
697
|
+
process.exit(1);
|
|
698
|
+
}
|