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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/package.json +36 -0
  4. 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
+ }