Repository: simonnilsson/ios-uploader
Branch: main
Commit: 82bd843efbed
Files: 16
Total size: 69.3 KB
Directory structure:
gitextract_w16i6k4z/
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── assets/
│ └── metadata_template.xml
├── bin/
│ └── cli.js
├── eslint.config.mjs
├── lib/
│ ├── index.js
│ └── utility.js
├── package.json
└── test/
├── index.test.js
└── utility.test.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
*.js eol=lf
*.json eol=lf
*.yml eol=lf
*.md eol=lf
*.xml eol=lf
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [simonnilsson]
================================================
FILE: .github/workflows/ci.yml
================================================
name: ci
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
coveralls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run coverage
- uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/release.yml
================================================
name: release
on:
release:
types: [published]
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org/
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
upload-binaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- uses: AButler/upload-release-assets@v3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
files: "build/ios-uploader-*"
================================================
FILE: .gitignore
================================================
# General
.vscode
.DS_Store
build/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [3.0.3] - 2025-04-21
### Changed
- Updated dependencies
## [3.0.2] - 2025-02-27
### Changed
- Updated dependencies
- Added Node v22 to CI tests
## [3.0.1] - 2024-06-25
### Changed
- Updated dependencies
- Switched from vercel/pkg to yao-pkg/pkg as vercel has deprecated pkg
- Updated to ESLint v9 and added formatting rules using @stylistic/eslint-plugin
## [3.0.0] - 2024-02-24
### Changed
- **BREAKING** Dropped support for Node versions below v18
- Bump binary releases from Node v14 to v18
- Added Node v20 to CI tests
- Updated dependencies
## [2.2.2] - 2023-02-08
### Changed
- Downgrade to axios@0.27.2 to fix pkg builds
## [2.2.1] - 2023-01-21
### Changed
- Updated dependencies
- Added Node v18 to CI workflow
## [2.2.0] - 2022-06-22
### Changed
- Reduced size of progress indicator
- Updated dependencies
## [2.1.1] - 2022-04-18
### Changed
- Updated dependencies
## [2.1.0] - 2022-03-27
### Fixed
- Correctly support http:// URLs.
- Add FTP support to help output
### Changed
- Progress bar improvements
- Updated dependencies
## [2.0.2] - 2022-02-17
### Changed
- Updated dependencies
## [2.0.1] - 2022-02-05
### Changed
- Updated dependencies
## [2.0.0] - 2022-01-18
### Changed
- **BREAKING** Dropped support for Node v10
- Bump binary releases from Node v12 to v14
- Updated dependencies
## [1.5.2] - 2021-11-14
### Changed
- Updated dependencies
## [1.5.1] - 2021-09-24
### Changed
- Updated dependencies
## [1.5.0] - 2021-08-27
### Added
- Added support for HTTP/HTTPS URLs to .ipa #16
### Fixed
- Fixed Coveralls badge link
### Changed
- Updated dependencies
## [1.4.0] - 2021-06-27
### Fixed
- Rework of bundle info lookup to solve issues with some IPA-files.
- Improved error message when bundle info lookup fails
### Changed
- Updated dependencies
## [1.3.0] - 2021-05-11
### Fixed
- Limit what files get published to npm
### Changed
- Updated dependencies
- Renamed npm token in build
- Added Node v16 to CI tests
- Binary releases now bundle Node v12
## [1.2.3] - 2021-04-30
### Fixed
- Minor README fixes
### Changed
- Updated dependencies
- Moved CI to Github Actions
## [1.2.2] - 2021-03-27
### Changed
- Updated dependencies
## [1.2.1] - 2021-01-16
### Fixed
- Catch error thrown by plist parsing #9
- Make Info.plist regex more specific #9
- Fix upload of big application archives #7
### Changed
- Updated dependencies
## [1.2.0] - 2020-12-29
### Fixed
- Fixed error handling on failed upload #7
### Changed
- Updated dependencies
## [1.1.3] - 2020-11-06
### Added
- Added CHANGELOG.md
### Fixed
- Fixed invalid code syntax and indentation
- Change spelling of "Licence" to "License" in README.md
### Changed
- Updated dependencies
## [1.1.2] - 2020-08-31
### Added
- Added version validation #4
### Changed
- Help message improvements #6
## [1.1.1] - 2020-08-27
### Added
- Include bundle info in validateAssets #4
### Fixed
- Fixed incorrect short version in metadata #5
### Changed
- Improved info messages
## [1.1.0] - 2020-08-26
### Added
- Added sanitation of input file name
- Added version validation #4
### Fixed
- Fixed typo
### Changed
- Improved error reporting #3
### Removed
- Removed support for bundle-id argument
## [1.0.2] - 2020-08-25
### Added
- Added gitattributes file
### Fixed
- Travis CI fixes
## [1.0.1] - 2020-08-20
### Changed
- Updated dependencies
### Fixed
- Travis CI fixes
- Time issue in tests
## [1.0.0] - 2020-07-02
- Initial release
================================================
FILE: LICENSE
================================================
Copyright 2020 Simon Nilsson
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# ios-uploader
[](https://www.npmjs.org/package/ios-uploader)
[](https://github.com/simonnilsson/ios-uploader/actions?query=workflow%3Aci+branch%3Amain)
[](https://coveralls.io/github/simonnilsson/ios-uploader?branch=main)
[](https://packagephobia.com/result?p=ios-uploader)
[](https://github.com/vsouza/awesome-ios)
Easy to use, cross-platform tool to upload iOS apps to App Store Connect.
## Installation
### System Requirements
* **OS**: Windows, macOS or Linux
* **Node.js**: v18 or newer (bundled with standalone binaries)
If you have Node.js and npm installed the simplest way is to just install the package globally. The tool will automatically be added to your PATH as `ios-uploader`.
```sh
npm install -g ios-uploader
```
The program is also available as standalone binaries for all major OS:es on [github.com](https://github.com/simonnilsson/ios-uploader/releases).
## Usage
If you have used `altool` previously to upload applications the process should be very familiar.
```sh
$ ios-uploader -u <username> -p <password> -f <path/to/app.ipa>
```
is equivalent to the following command using altool (macOS only):
```sh
$ xcrun altool --upload-app -u <username> -p <password> -f <path/to/app.ipa>
```
> See this page for information on how to generate an app specific password: <br>https://support.apple.com/en-us/HT204397
## Options
```
-v, --version output the current version and exit
-u, --username <string> your Apple ID
-p, --password <string> app-specific password for your Apple ID
-f, --file <string> path to .ipa file for upload (local file, http(s):// or ftp:// URL)
-c, --concurrency <number> number of concurrent upload tasks to use (default: 4)
-h, --help output this help message and exit
```
## Disclaimer
This package is not endorsed by or in any way associated with Apple Inc. It is provided as is without warranty of any kind. The program may stop working at any time without prior notice if Apple decides to change the API.
## License
[MIT](LICENSE)
================================================
FILE: assets/metadata_template.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://apple.com/itunes/importer" version="software5.4">
<software_assets apple_id="APPLE_ID" bundle_short_version_string="BUNDLE_SHORT_VERSION" bundle_version="BUNDLE_VERSION" bundle_identifier="BUNDLE_IDENTIFIER" app_platform="ios">
<asset type="bundle">
<data_file>
<size>FILE_SIZE</size>
<file_name>FILE_NAME</file_name>
<checksum type="md5">MD5</checksum>
</data_file>
</asset>
</software_assets>
</package>
================================================
FILE: bin/cli.js
================================================
#!/usr/bin/env node
const { queue } = require('async');
const { Command } = require('commander');
const cliProgress = require('cli-progress');
const prettyBytes = require('pretty-bytes');
const { version, name } = require('../package');
const utility = require('../lib/utility');
const api = require('../lib/index');
const cli = new Command()
.version(version, '-v, --version', 'output the current version and exit')
.name(name)
.usage('-u <username> -p <password> -f <file> [additional-options]')
.helpOption('-h, --help', 'output this help message and exit')
.requiredOption('-u, --username <string>', 'your Apple ID')
.requiredOption('-p, --password <string>', 'app-specific password for your Apple ID')
.requiredOption('-f, --file <string>', 'path to .ipa file for upload (local file, http(s):// or ftp:// URL)')
.option('-c, --concurrency <number>', 'number of concurrent upload tasks to use', 4);
const fileUrlRegex = /^(?:https?|ftp):\/\//;
function formatValue(v, options, type) {
switch (type) {
case 'value':
case 'total':
return prettyBytes(v);
default:
return v;
}
}
async function runUpload(ctx) {
let exitCode = 0;
const progressBar = new cliProgress.Bar({
format: '{task} |{bar}| {percentage}% | {value} / {total} | {speed}',
hideCursor: true,
barsize: 20,
formatValue,
}, cliProgress.Presets.shades_classic);
try {
// Handle URLs to ipa file.
if (fileUrlRegex.test(ctx.filePath)) {
ctx.originalFilePath = ctx.filePath;
try {
const transferStartTime = Date.now();
let started = false;
ctx.filePath = await utility.downloadTempFile(ctx.filePath, (current, total) => {
let { speed, eta } = utility.formatSpeedAndEta(current, total, Date.now() - transferStartTime);
!started
? progressBar.start(total, current, { task: 'Downloading', speed, etas: eta })
: progressBar.update(current, { speed, etas: eta });
started = true;
});
progressBar.stop();
}
catch (err) {
throw new Error(`Could not download file: ${err.message}`);
}
ctx.usingTempFile = true;
}
// Open the application file for reading.
ctx.fileHandle = await utility.openFile(ctx.filePath);
// Bundle ID and version lookup.
try {
let extracted = await utility.extractBundleIdAndVersion(ctx.fileHandle);
ctx.bundleId = extracted.bundleId;
ctx.bundleVersion = extracted.bundleVersion;
ctx.bundleShortVersion = extracted.bundleShortVersion;
console.log(`Found Bundle ID "${ctx.bundleId}", Version ${ctx.bundleVersion} (${ctx.bundleShortVersion}).`);
}
catch (err) {
console.error(err.message);
throw new Error('Failed to extract Bundle ID and version, are you supplying a valid IPA-file?');
}
// Authenticate with Apple.
await api.authenticateForSession(ctx);
// Find "Apple ID" of application.
await api.lookupSoftwareForBundleId(ctx);
console.log(`Identified application as "${ctx.appName}" (${ctx.appleId}).`);
// Generate metadata.
await api.generateMetadata(ctx);
// Validate metadata and assets.
await api.validateMetadata(ctx);
await api.validateAssets(ctx);
await api.clientChecksumCompleted(ctx);
// Make reservations for uploading.
let reservations = await api.createReservation(ctx);
// For time calculations.
ctx.transferStartTime = Date.now();
ctx.bytesSent = 0;
progressBar.start(ctx.metadataSize + ctx.fileSize, 0, { task: 'Uploading', speed: 'N/A', etas: 'N/A' });
let q = queue(api.executeOperation, ctx.concurrency);
// Start uploading.
for (let reservation of reservations) {
let tasks = reservation.operations.map((operation) => ({ ctx, reservation, operation }));
q.push(tasks, () => {
let { speed, eta } = utility.formatSpeedAndEta(ctx.bytesSent, ctx.metadataSize + ctx.fileSize, Date.now() - ctx.transferStartTime);
progressBar.update(ctx.bytesSent, { speed, etas: eta });
});
await Promise.race([q.drain(), q.error()]);
await api.commitReservation(ctx, reservation);
}
// Calculate transfer time.
ctx.transferTime = ctx.transferStartTime - Date.now();
// Finish
await api.uploadDoneWithArguments(ctx);
progressBar.stop();
console.log('The cookies are done.');
}
catch (err) {
progressBar.stop();
console.error(err.message);
exitCode = 1;
}
finally {
if (ctx.fileHandle) {
await utility.closeFile(ctx.fileHandle);
}
if (ctx.usingTempFile) {
await utility.removeTempFile(ctx.filePath);
}
}
process.exit(exitCode);
}
async function run() {
// Parse command line params
cli.parse(process.argv);
const options = cli.opts();
// Context variable keeping track of all the necessary information for upload procedure.
const ctx = {
username: options.username,
password: options.password,
filePath: options.file,
concurrency: options.concurrency,
packageName: 'app.itmsp',
};
await runUpload(ctx);
}
function stop(signal) {
// Fix to make sure cursor gets restored to visible state when exiting mid progress.
process.stderr.write('\u001B[?25h');
process.exit(128 + signal);
}
// Run only if called directly (e.g. not when tested)
if (require.main === module) {
process.on('SIGINT', () => stop(2));
process.on('SIGTERM', () => stop(15));
run();
}
================================================
FILE: eslint.config.mjs
================================================
import js from '@eslint/js';
import globals from 'globals';
import stylistic from '@stylistic/eslint-plugin';
import jsdoc from 'eslint-plugin-jsdoc';
export default [
js.configs.recommended,
stylistic.configs.customize({
indent: 2,
quotes: 'single',
quoteProps: 'as-needed',
arrowParens: true,
semi: true,
}),
{
files: ['**/*.js'],
plugins: {
jsdoc,
},
rules: {
'jsdoc/no-undefined-types': 1,
},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.node,
...globals.mocha,
},
},
},
];
================================================
FILE: lib/index.js
================================================
const axios = require('axios');
const path = require('path');
const utility = require('./utility');
const SOFTWARE_SERVICE_URL = 'https://contentdelivery.itunes.apple.com/WebObjects/MZLabelService.woa/json/MZITunesSoftwareService';
const PRODUCER_SERVICE_URL = 'https://contentdelivery.itunes.apple.com/WebObjects/MZLabelService.woa/json/MZITunesProducerService';
const USER_AGENT = 'iTMSTransporter/2.0.0';
const MAX_BODY_LENGTH = 1024 ** 3;
/**
* Construct error message using application error string and response object.
* @param {String} message Application error message
* @param {Object|undefined} response Response object from remote request,
* used to extract error message if any.
* @returns {Error} An error that can be thrown.
*/
function constructError(message, response) {
let errorMessage = message;
if (response && response.ErrorMessage) {
errorMessage += '\n' + response.ErrorMessage;
}
return new Error(errorMessage);
}
async function generateMetadata(ctx) {
let metaText = await utility.readFile(path.join(__dirname, '../assets/metadata_template.xml'));
const fileStats = await utility.getFileStats(ctx.fileHandle);
ctx.fileName = path.basename(ctx.filePath).replace(/[: ]/g, '_');
ctx.fileChecksum = await utility.getFileMD5(ctx.fileHandle);
ctx.fileSize = fileStats.size;
ctx.fileModifiedTime = Math.round(fileStats.mtimeMs);
metaText = metaText
.replace('APPLE_ID', ctx.appleId)
.replace('BUNDLE_SHORT_VERSION', ctx.bundleShortVersion)
.replace('BUNDLE_VERSION', ctx.bundleVersion)
.replace('BUNDLE_IDENTIFIER', ctx.bundleId)
.replace('FILE_SIZE', ctx.fileSize)
.replace('FILE_NAME', ctx.fileName)
.replace('MD5', ctx.fileChecksum);
ctx.metadataSize = metaText.length;
ctx.metadataChecksum = utility.getStringMD5(metaText);
ctx.metadataBuffer = Buffer.from(metaText, 'utf-8');
ctx.metadataCompressed = await utility.bufferToGZBase64(ctx.metadataBuffer);
}
async function makeSoftwareServiceRequest(ctx, method, params) {
const requestId = utility.generateIDString();
const request = {
jsonrpc: '2.0',
method,
id: requestId,
params,
};
const headers = {
'User-Agent': USER_AGENT,
'Content-Type': 'application/json',
};
const json = JSON.stringify(request);
const jsonChecksum = utility.getStringMD5Buffer(json);
if (ctx.sessionId) {
headers['x-request-id'] = requestId;
headers['x-session-digest'] = utility.makeSessionDigest(ctx.sessionId, jsonChecksum, requestId, ctx.sharedSecret);
headers['x-session-id'] = ctx.sessionId;
headers['x-session-version'] = '2';
}
let res = await axios.post(
SOFTWARE_SERVICE_URL,
json,
{ headers },
);
return res.data.result;
}
async function makeProducerServiceRequest(ctx, method, params) {
const requestId = utility.generateIDString();
const request = {
jsonrpc: '2.0',
method,
id: requestId,
params,
};
const headers = {
'User-Agent': USER_AGENT,
'Content-Type': 'application/json',
};
const json = JSON.stringify(request);
const jsonChecksum = utility.getStringMD5Buffer(json);
if (ctx.sessionId) {
headers['x-request-id'] = requestId;
headers['x-session-digest'] = utility.makeSessionDigest(ctx.sessionId, jsonChecksum, requestId, ctx.sharedSecret);
headers['x-session-id'] = ctx.sessionId;
headers['x-session-version'] = '2';
}
let res = await axios.post(
PRODUCER_SERVICE_URL,
json,
{ headers },
);
return res.data.result;
}
async function authenticateForSession(ctx) {
let res = await makeProducerServiceRequest(ctx, 'authenticateForSession', {
Username: ctx.username,
Password: ctx.password,
});
if (res.SessionId && res.SharedSecret) {
ctx.sessionId = res.SessionId;
ctx.sharedSecret = res.SharedSecret;
}
else {
throw constructError('Authentication failed!', res);
}
}
async function lookupSoftwareForBundleId(ctx) {
let res = await makeSoftwareServiceRequest(ctx, 'lookupSoftwareForBundleId', {
Application: 'altool',
ApplicationBundleId: 'com.apple.itunes.altool',
BundleId: ctx.bundleId,
Version: '4.0.1 (1182)',
});
if (!res.Success || res.Attributes.length < 1) {
throw constructError('Application lookup failed!', res);
}
ctx.appleId = res.Attributes[0].AppleID;
ctx.appName = res.Attributes[0].Application;
ctx.appIconUrl = res.Attributes[0].IconURL;
}
async function validateMetadata(ctx) {
let res = await makeProducerServiceRequest(ctx, 'validateMetadata', {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
Files: [
ctx.fileName,
'metadata.xml',
],
iTMSTransporterMode: 'upload',
MetadataChecksum: ctx.metadataChecksum,
MetadataCompressed: ctx.metadataCompressed,
MetadataInfo: {
app_platform: 'ios',
apple_id: ctx.appleId,
asset_types: [
'bundle',
],
bundle_identifier: ctx.bundleId,
bundle_short_version_string: ctx.bundleShortVersion,
bundle_version: ctx.bundleVersion,
device_id: '',
packageVersion: 'software5.4',
primary_bundle_identifier: '',
},
PackageName: ctx.packageName,
PackageSize: ctx.fileSize + ctx.metadataSize,
Username: ctx.username,
Version: '2.0.0',
});
if (!res.Success) {
throw constructError('Metadata validation failed!', res);
}
}
async function validateAssets(ctx) {
let res = await makeProducerServiceRequest(ctx, 'validateAssets', {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
AssetDescriptionsCompressed: [],
Files: [
ctx.fileName,
'metadata.xml',
],
iTMSTransporterMode: 'upload',
MetadataChecksum: ctx.metadataChecksum,
MetadataCompressed: ctx.metadataCompressed,
MetadataInfo: {
app_platform: 'ios',
apple_id: ctx.appleId,
asset_types: [
'bundle',
],
bundle_identifier: ctx.bundleId,
bundle_short_version_string: ctx.bundleShortVersion,
bundle_version: ctx.bundleVersion,
device_id: '',
packageVersion: 'software5.4',
primary_bundle_identifier: '',
},
PackageName: ctx.packageName,
PackageSize: ctx.fileSize + ctx.metadataSize,
StreamingInfoList: [],
Transport: 'HTTP',
Username: ctx.username,
Version: '2.0.0',
});
if (!res.Success) {
throw constructError('Asset validation failed!', res);
}
// validateAssets returns a new package name.
ctx.packageName = res.NewPackageName;
}
async function clientChecksumCompleted(ctx) {
let res = await makeProducerServiceRequest(ctx, 'clientChecksumCompleted', {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
iTMSTransporterMode: 'upload',
NewPackageName: ctx.packageName,
Username: ctx.username,
Version: '2.0.0',
});
if (!res.Success) {
throw constructError('Client checksum failed!', res);
}
}
async function createReservation(ctx) {
let res = await makeProducerServiceRequest(ctx, 'createReservation', {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
fileDescriptions: [
{
checksum: ctx.metadataChecksum,
checksumAlgorithm: 'MD5',
contentType: 'application/xml',
fileName: 'metadata.xml',
fileSize: ctx.metadataSize,
},
{
checksum: ctx.fileChecksum,
checksumAlgorithm: 'MD5',
contentType: 'application/octet-stream',
fileName: ctx.fileName,
fileSize: ctx.fileSize,
uti: 'com.apple.ipa',
},
],
iTMSTransporterMode: 'upload',
NewPackageName: ctx.packageName,
Username: ctx.username,
Version: '2.0.0',
});
if (!res.Success) {
throw constructError('Create reservation failed!', res);
}
return res.Reservations;
}
async function executeOperation({ ctx, reservation, operation }) {
let data;
if (reservation.file === 'metadata.xml') {
data = ctx.metadataBuffer.slice(operation.offset, operation.offset + operation.length);
}
else if (reservation.file === ctx.fileName) {
data = await utility.getFilePart(ctx.fileHandle, operation.offset, operation.length);
}
else {
// Unknown file
return;
}
let res;
try {
res = await axios({
url: operation.uri,
method: operation.method,
headers: Object.assign({
'User-Agent': USER_AGENT,
}, operation.headers),
validateStatus: null,
maxBodyLength: MAX_BODY_LENGTH,
data,
});
}
catch (err) {
throw new Error('Upload failed!\n' + err.message);
}
if (res.status != 200) {
throw new Error('Upload failed! (' + res.status + ')');
}
ctx.bytesSent += operation.length;
}
async function commitReservation(ctx, reservation) {
let res = await makeProducerServiceRequest(ctx, 'commitReservation', {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
iTMSTransporterMode: 'upload',
NewPackageName: ctx.packageName,
reservations: [
reservation.id,
],
Username: ctx.username,
Version: '2.0.0',
});
if (!res.Success) {
throw constructError('Commit reservation failed!', res);
}
}
async function uploadDoneWithArguments(ctx) {
let res = await makeProducerServiceRequest(ctx, 'uploadDoneWithArguments', {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
FileSizeInfo: {
[ctx.fileName]: ctx.fileSize,
'metadata.xml': ctx.metadataSize,
},
ClientChecksumInfo: [
{
CalculatedChecksum: ctx.fileChecksum,
CalculationTime: 100,
FileLastModified: ctx.fileModifiedTime,
Filename: ctx.fileName,
fileSize: ctx.fileSize,
},
],
StatisticsArray: [],
StreamingInfoList: [],
iTMSTransporterMode: 'upload',
PackagePathWithoutBase: null,
NewPackageName: ctx.packageName,
Transport: 'HTTP',
TransferTime: ctx.transferTime,
NumberBytesTransferred: ctx.fileSize + ctx.metadataSize,
Username: ctx.username,
Version: '2.0.0',
});
if (!res.Success) {
throw constructError('Upload completion failed!', res);
}
}
module.exports = {
SOFTWARE_SERVICE_URL,
PRODUCER_SERVICE_URL,
constructError,
generateMetadata,
makeSoftwareServiceRequest,
makeProducerServiceRequest,
authenticateForSession,
lookupSoftwareForBundleId,
validateMetadata,
validateAssets,
clientChecksumCompleted,
createReservation,
executeOperation,
commitReservation,
uploadDoneWithArguments,
};
================================================
FILE: lib/utility.js
================================================
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const stream = require('stream');
const zlib = require('zlib');
const axios = require('axios');
const yauzl = require('yauzl');
const plist = require('simple-plist');
const prettyBytes = require('pretty-bytes');
const concat = require('concat-stream');
const { promisify } = require('util');
const INFO_PLIST_FILE_PATTERN = /^Payload\/[^/]*.app\/Info\.plist$/;
exports.generateIDString = function () {
// YYYYMMDDHHmmss-sss
return new Date().toISOString().replace(/-|:|T|Z/g, '').replace('.', '-');
};
exports.makeSessionDigest = function (sessionId, requestChecksum, requestId, sharedSecret) {
return crypto.createHash('md5')
.update(sessionId)
.update(requestChecksum)
.update(requestId)
.update(sharedSecret)
.digest('hex');
};
exports.openFile = function (path, flags = 'r') {
return new Promise((resolve, reject) => {
fs.open(path, flags, (err, fd) => {
if (err) return reject(err);
resolve(fd);
});
});
};
exports.closeFile = function (fd) {
return new Promise((resolve, reject) => {
fs.close(fd, (err) => {
if (err) return reject(err);
resolve();
});
});
};
exports.readFileDataFromZip = function (fd, fileNamePattern) {
return new Promise((resolve, reject) => {
yauzl.fromFd(fd, { autoClose: false, lazyEntries: true }, (err, zipFile) => {
if (err) return reject(err);
zipFile.on('error', reject);
zipFile.on('entry', (entry) => {
if (fileNamePattern.test(entry.fileName)) {
zipFile.openReadStream(entry, (err, stream) => {
if (err) throw err;
stream.pipe(concat(resolve));
});
}
else {
zipFile.readEntry();
}
});
zipFile.on('end', () => {
resolve(null);
});
zipFile.readEntry();
});
});
};
exports.extractBundleIdAndVersion = async function (fd) {
let data;
try {
data = await exports.readFileDataFromZip(fd, INFO_PLIST_FILE_PATTERN);
}
catch {
// Ignore this error, handled below.
}
if (!data || data.length === 0) {
throw new Error('Info.plist not found');
}
let infoPlist;
try {
infoPlist = plist.parse(data, 'Info.plist');
}
catch {
throw new Error('Failed to parse Info.plist');
}
if (infoPlist && infoPlist.CFBundleIdentifier && infoPlist.CFBundleVersion && infoPlist.CFBundleShortVersionString) {
return {
bundleId: infoPlist.CFBundleIdentifier,
bundleVersion: infoPlist.CFBundleVersion,
bundleShortVersion: infoPlist.CFBundleShortVersionString,
};
}
throw new Error('Bundle info not found in Info.plist');
};
exports.ensureTempDir = async function () {
const tempDir = path.join(os.tmpdir(), 'ios-uploader');
await fs.promises.mkdir(tempDir, { recursive: true });
return tempDir;
};
exports.downloadTempFile = async function (fileUrl, onProgress = () => { }) {
const res = await axios.get(fileUrl, {
responseType: 'stream',
});
let newFilePath = path.join(
await exports.ensureTempDir(),
Math.random().toString(16).substr(2, 8) + '.ipa',
);
const writer = fs.createWriteStream(newFilePath);
const contentLength = Number(res.headers['content-length'] || 0);
let downloaded = 0;
if (contentLength > 0) {
onProgress(0, contentLength);
res.data.on('data', (chunk) => onProgress(downloaded += chunk.length, contentLength));
}
res.data.pipe(writer);
await promisify(stream.finished)(writer);
return newFilePath;
};
exports.removeTempFile = async function (filePath) {
await fs.promises.unlink(filePath);
};
exports.getFileStats = function (fd) {
return new Promise((resolve, reject) => {
fs.fstat(fd, (err, stats) => {
if (err) return reject(err);
resolve(stats);
});
});
};
exports.readFile = function (path, encoding = 'utf-8') {
return new Promise((resolve, reject) => {
fs.readFile(path, encoding, (err, f) => {
if (err) return reject(err);
resolve(f);
});
});
};
exports.getFileMD5 = function (fd) {
return new Promise((resolve, reject) => {
const output = crypto.createHash('md5');
const input = fs.createReadStream('', { fd, start: 0, autoClose: false });
input.on('error', (err) => reject(err));
output.once('readable', () => {
resolve(output.read().toString('hex'));
});
input.pipe(output);
});
};
exports.getFilePart = function (fd, offset, length) {
return new Promise((resolve, reject) => {
let buffer = Buffer.allocUnsafe(length);
fs.read(fd, buffer, 0, length, offset, (err) => {
if (err) return reject(err);
resolve(buffer);
});
});
};
exports.getStringMD5 = function (text) {
return crypto.createHash('md5').update(text).digest('hex');
};
exports.getStringMD5Buffer = function (text) {
return crypto.createHash('md5').update(text).digest();
};
exports.bufferToGZBase64 = function (buf) {
return new Promise((resolve, reject) => {
zlib.gzip(buf, (err, res) => {
if (err) return reject(err);
resolve(res.toString('base64'));
});
});
};
exports.formatSpeedAndEta = function (bytes, total, duration) {
return {
speed: prettyBytes(Math.round((bytes / duration) * 1000)) + '/s',
eta: Math.round(((total - bytes) / (bytes / duration)) / 1000) + 's',
};
};
================================================
FILE: package.json
================================================
{
"name": "ios-uploader",
"version": "3.0.3",
"description": "Easy to use, cross-platform tool to upload an iOS app to itunes-connect.",
"keywords": [
"ipa",
"upload",
"ios"
],
"homepage": "https://github.com/simonnilsson/ios-uploader#readme",
"repository": "https://github.com/simonnilsson/ios-uploader",
"author": "Simon Nilsson <simon@nilsson.ml>",
"license": "MIT",
"main": "./lib/index.js",
"bin": {
"ios-uploader": "./bin/cli.js"
},
"scripts": {
"start": "node bin/cli.js",
"build": "pkg --out-path build --compress Brotli .",
"lint": "eslint --max-warnings 0 ./bin/*.js ./lib/*.js",
"fix": "eslint --fix ./bin/*.js ./lib/*.js",
"test": "nyc --reporter=text mocha",
"coverage": "nyc --reporter=lcov mocha"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"async": "^3.2.6",
"axios": "^0.30.0",
"cli-progress": "^3.12.0",
"commander": "^13.1.0",
"concat-stream": "^2.0.0",
"pretty-bytes": "^5.6.0",
"simple-plist": "^1.3.1",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^4.2.0",
"@yao-pkg/pkg": "^6.4.0",
"eslint": "^9.25.0",
"eslint-plugin-jsdoc": "^50.6.9",
"mocha": "^11.1.0",
"nock": "^14.0.4",
"nyc": "^17.1.0",
"sinon": "^20.0.0"
},
"files": [
"assets/",
"bin/",
"lib/"
],
"pkg": {
"scripts": [
"./bin/**/*.js",
"./lib/**/*.js"
],
"assets": "./assets/**/*",
"targets": [
"node18-win-x64",
"node18-macos-x64",
"node18-linux-x64",
"node18-alpine-x64"
]
}
}
================================================
FILE: test/index.test.js
================================================
const assert = require('assert').strict;
const sinon = require('sinon');
const nock = require('nock');
const url = require('url');
const index = require('../lib/index');
const utility = require('../lib/utility');
describe('lib/index', () => {
const TEST_CTX = {
filePath: '/PATH/TO/FILE',
fileName: 'FILE',
fileHandle: 'FD',
fileSize: 12345,
fileModifiedTime: 1577930645678,
fileChecksum: 'FILE_CHECKSUM',
metadataChecksum: '95ceb84069b68b06b5d7820ef537d22a',
metadataCompressed: "H4sIAAAAAAAACl1QW0vDMBh9F/wP4Xu3cbqCSNIhs8PhhIHbc4jp1y2sudCk3n69bbfasbfk3L7DYbNvU5FPrIN2lsMkuQWCVrlC2x2H7WZx8wCz7PqKeakOcoekldvAYR+jf6RUel9hopyhOjYWA9XGuzpiDWNmcGX8kjWmyRTaJELYgAgZAsZA+hShCw5P6/UqF8tn6DDhKxlLVxsO2oWjt3X3JhJ/PHL4aGxR4UC1ZCGjFKWu8B/q7ulfzCZ399OU0f59xnVaYaXBbLFc5YyO/zOR2qM6hMacrpoihV4u5i/5/PV9+8boIBmr0MsujPbVjxvQixG6jelp5OwPNMlY5pYBAAA=",
metadataSize: 406,
appleId: 'APPLE_ID',
bundleId: 'BUNDLE_ID',
bundleVersion: 'BUNDLE_VERSION',
bundleShortVersion: 'BUNDLE_SHORT_VERSION',
sessionId: 'SESSION_ID',
sharedSecret: 'SECRET',
appName: 'APP_NAME',
appIconUrl: 'ICON_URL',
packageName: 'PACKAGE_NAME'
}
describe('constructError()', () => {
it('should return a formated error', () => {
let err = index.constructError("MESSAGE", { ErrorMessage: 'RESPONSE_ERROR' });
assert.ok(err instanceof Error);
assert.equal(err.message, 'MESSAGE\nRESPONSE_ERROR');
});
});
describe('generateMetadata()', () => {
before(() => {
sinon.stub(utility, 'getFileStats').withArgs(TEST_CTX.fileHandle).resolves({
size: TEST_CTX.fileSize,
mtimeMs: TEST_CTX.fileModifiedTime + 0.1
});
sinon.stub(utility, 'getFileMD5').withArgs(TEST_CTX.fileHandle).resolves(TEST_CTX.fileChecksum);
});
after(() => {
sinon.restore();
});
it('should correctly format ID based on current time', async () => {
const METADATA_INPUT = {
fileHandle: TEST_CTX.fileHandle,
filePath: TEST_CTX.filePath,
appleId: TEST_CTX.appleId
};
const ctx = Object.assign({}, METADATA_INPUT);
await index.generateMetadata(ctx);
const EXPECTED_METADATA = {
fileName: TEST_CTX.fileName,
fileSize: TEST_CTX.fileSize,
fileModifiedTime: TEST_CTX.fileModifiedTime,
fileChecksum: TEST_CTX.fileChecksum,
metadataBuffer: sinon.match.instanceOf(Buffer),
metadataChecksum: sinon.match.string,
metadataCompressed: sinon.match.string,
metadataSize: sinon.match.number
};
sinon.assert.match(ctx, Object.assign({}, METADATA_INPUT, EXPECTED_METADATA));
});
});
describe('makeSoftwareServiceRequest()', () => {
before(() => {
const serviceUrl = new url.URL(index.SOFTWARE_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'test',
id: sinon.match.string,
params: {}
}).test(body))
.reply(200, { result: { Success: true } });
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
let res = await index.makeSoftwareServiceRequest({ sessionId: TEST_CTX.sessionId, sharedSecret: TEST_CTX.sharedSecret }, 'test', {});
sinon.assert.match(res, { Success: true });
});
});
describe('makeProducerServiceRequest()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'test',
id: sinon.match.string,
params: {}
}).test(body))
.reply(200, { result: { Success: true } });
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
let res = await index.makeProducerServiceRequest({ sessionId: TEST_CTX.sessionId, sharedSecret: TEST_CTX.sharedSecret }, 'test', {});
sinon.assert.match(res, { Success: true });
});
});
describe('authenticateForSession()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'authenticateForSession',
id: sinon.match.string,
params: { Username: TEST_CTX.username, Password: TEST_CTX.password }
}).test(body))
.reply(200, { result: { SessionId: TEST_CTX.sessionId, SharedSecret: TEST_CTX.sharedSecret } });
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, { result: { Success: false } });
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = {
username: TEST_CTX.username,
password: TEST_CTX.password
};
await index.authenticateForSession(ctx);
sinon.assert.match(ctx, { sessionId: TEST_CTX.sessionId, sharedSecret: TEST_CTX.sharedSecret });
});
it('should reject on failure', async () => {
const ctx = {
username: TEST_CTX.username,
password: 'WRONG_PASSWORD'
};
await assert.rejects(index.authenticateForSession(ctx));
});
});
describe('lookupSoftwareForBundleId()', () => {
before(() => {
const serviceUrl = new url.URL(index.SOFTWARE_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'lookupSoftwareForBundleId',
id: sinon.match.string,
params: {
Application: 'altool',
ApplicationBundleId: 'com.apple.itunes.altool',
BundleId: TEST_CTX.bundleId,
Version: '4.0.1 (1182)'
}
}).test(body))
.reply(200, {
result: {
Success: true,
Attributes: [{ AppleID: TEST_CTX.appleId, Application: TEST_CTX.appName, IconURL: TEST_CTX.appIconUrl }]
}
});
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, {
result: {
Success: false
}
});
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = {
bundleId: TEST_CTX.bundleId
};
await index.lookupSoftwareForBundleId(ctx);
sinon.assert.match(ctx, { appleId: TEST_CTX.appleId, appName: TEST_CTX.appName, appIconUrl: TEST_CTX.appIconUrl });
});
it('should reject on failure', async () => {
const ctx = {
bundleId: 'WRONG_BUNDLE_ID'
};
await assert.rejects(index.lookupSoftwareForBundleId(ctx));
});
});
describe('validateMetadata()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'validateMetadata',
id: sinon.match.string,
params: {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
Files: [
TEST_CTX.fileName,
'metadata.xml'
],
iTMSTransporterMode: 'upload',
MetadataChecksum: TEST_CTX.metadataChecksum,
MetadataCompressed: TEST_CTX.metadataCompressed,
MetadataInfo: {
app_platform: 'ios',
apple_id: TEST_CTX.appleId,
asset_types: [
'bundle'
],
bundle_identifier: TEST_CTX.bundleId,
bundle_short_version_string: TEST_CTX.bundleShortVersion,
bundle_version: TEST_CTX.bundleVersion,
device_id: '',
packageVersion: 'software5.4',
primary_bundle_identifier: ''
},
PackageName: TEST_CTX.packageName,
PackageSize: TEST_CTX.fileSize + TEST_CTX.metadataSize,
Username: TEST_CTX.username,
Version: '2.0.0'
}
}).test(body))
.reply(200, {
result: {
Success: true
}
});
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, {
result: {
Success: false
}
});
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = Object.assign({}, TEST_CTX);
await index.validateMetadata(ctx);
});
it('should reject on failure', async () => {
const ctx = {
};
await assert.rejects(index.validateMetadata(ctx));
});
});
describe('validateAssets()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'validateAssets',
id: sinon.match.string,
params: {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
Files: [
TEST_CTX.fileName,
'metadata.xml'
],
iTMSTransporterMode: 'upload',
MetadataChecksum: TEST_CTX.metadataChecksum,
MetadataCompressed: TEST_CTX.metadataCompressed,
MetadataInfo: {
app_platform: 'ios',
apple_id: TEST_CTX.appleId,
asset_types: [
'bundle'
],
bundle_identifier: TEST_CTX.bundleId,
bundle_short_version_string: TEST_CTX.bundleShortVersion ,
bundle_version: TEST_CTX.bundleVersion,
device_id: '',
packageVersion: 'software5.4',
primary_bundle_identifier: ''
},
PackageName: TEST_CTX.packageName,
PackageSize: TEST_CTX.fileSize + TEST_CTX.metadataSize,
StreamingInfoList: [],
Transport: 'HTTP',
Username: TEST_CTX.username,
Version: '2.0.0'
}
}).test(body))
.reply(200, {
result: {
Success: true,
NewPackageName: 'NEW_PACKAGE_NAME'
}
});
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, {
result: {
Success: false
}
});
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = Object.assign({}, TEST_CTX);
await index.validateAssets(ctx);
sinon.assert.match(ctx, { packageName: 'NEW_PACKAGE_NAME' });
});
it('should reject on failure', async () => {
const ctx = {
};
await assert.rejects(index.validateAssets(ctx));
});
});
describe('clientChecksumCompleted()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'clientChecksumCompleted',
id: sinon.match.string,
params: {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
iTMSTransporterMode: 'upload',
NewPackageName: TEST_CTX.packageName,
Username: TEST_CTX.username,
Version: '2.0.0'
}
}).test(body))
.reply(200, {
result: {
Success: true
}
});
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, {
result: {
Success: false
}
});
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = Object.assign({}, TEST_CTX);
await index.clientChecksumCompleted(ctx);
});
it('should reject on failure', async () => {
const ctx = {
};
await assert.rejects(index.clientChecksumCompleted(ctx));
});
});
describe('createReservation()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'createReservation',
id: sinon.match.string,
params: {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
fileDescriptions: [
{
checksum: TEST_CTX.metadataChecksum,
checksumAlgorithm: 'MD5',
contentType: 'application/xml',
fileName: 'metadata.xml',
fileSize: TEST_CTX.metadataSize
},
{
checksum: TEST_CTX.fileChecksum,
checksumAlgorithm: 'MD5',
contentType: 'application/octet-stream',
fileName: TEST_CTX.fileName,
fileSize: TEST_CTX.fileSize,
uti: 'com.apple.ipa'
}
],
iTMSTransporterMode: 'upload',
NewPackageName: TEST_CTX.packageName,
Username: TEST_CTX.username,
Version: '2.0.0'
}
}).test(body))
.reply(200, {
result: {
Success: true,
Reservations: []
}
});
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, {
result: {
Success: false
}
});
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = Object.assign({}, TEST_CTX);
await index.createReservation(ctx);
});
it('should reject on failure', async () => {
const ctx = {};
await assert.rejects(index.createReservation(ctx));
});
});
describe('executeOperation()', () => {
const TEST_METADATA_OPERATION = {
uri: 'https://example.com/fileupload/metadata',
method: 'PUT',
offset: 0,
headers: {
'Content-Type': 'application/xml'
},
length: TEST_CTX.metadataSize
};
const TEST_BINARY_OPERATION = {
uri: 'https://example.com/fileupload/binary',
method: 'PUT',
offset: 10,
headers: {
'Content-Type': 'application/octet-stream'
},
length: 20
};
const TEST_BINARY_OPERATION_NETWORK_ERROR = {
uri: 'https://example.com/fileupload/error',
method: 'PUT',
offset: 10,
headers: {
'Content-Type': 'application/octet-stream'
},
length: 20
};
before(() => {
const metadataUrl = new url.URL(TEST_METADATA_OPERATION.uri);
nock(metadataUrl.origin)
.matchHeader('Content-Type', TEST_METADATA_OPERATION.headers['Content-Type'])
.intercept(metadataUrl.pathname, TEST_METADATA_OPERATION.method, (body) => body.length === TEST_METADATA_OPERATION.length)
.reply(200);
sinon.stub(utility, 'getFilePart')
.withArgs(TEST_CTX.fileHandle, TEST_BINARY_OPERATION.offset, TEST_BINARY_OPERATION.length)
.resolves(Buffer.alloc(TEST_BINARY_OPERATION.length));
const binaryUrl = new url.URL(TEST_BINARY_OPERATION.uri);
nock(binaryUrl.origin)
.matchHeader('Content-Type', TEST_BINARY_OPERATION.headers['Content-Type'])
.intercept(binaryUrl.pathname, TEST_BINARY_OPERATION.method, (body) => body.length === TEST_BINARY_OPERATION.length)
.reply(200);
nock(binaryUrl.origin)
.intercept(/.*/, TEST_BINARY_OPERATION.method)
.reply(400);
const errorUrl = new url.URL(TEST_BINARY_OPERATION_NETWORK_ERROR.uri);
nock(errorUrl.origin)
.matchHeader('Content-Type', TEST_BINARY_OPERATION_NETWORK_ERROR.headers['Content-Type'])
.intercept(errorUrl.pathname, TEST_BINARY_OPERATION_NETWORK_ERROR.method, (body) => body.length === TEST_BINARY_OPERATION_NETWORK_ERROR.length)
.replyWithError('Network Error');
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request for metadata.xml', async () => {
const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);
ctx.metadataBuffer = Buffer.alloc(ctx.metadataSize);
await index.executeOperation({ ctx, reservation: { file: 'metadata.xml' }, operation: TEST_METADATA_OPERATION });
sinon.assert.match(ctx, { bytesSent: TEST_METADATA_OPERATION.length });
});
it('should make the appropriate HTTP request for binary', async () => {
const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);
await index.executeOperation({ ctx, reservation: { file: TEST_CTX.fileName }, operation: TEST_BINARY_OPERATION });
sinon.assert.match(ctx, { bytesSent: TEST_BINARY_OPERATION.length });
});
it('should do nothing on unknown file', async () => {
const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);
await index.executeOperation({ ctx, reservation: { file: 'unknown' }, operation: {} });
sinon.assert.match(ctx, { bytesSent: 0 });
});
it('should reject on status error', async () => {
const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);
ctx.metadataBuffer = Buffer.alloc(ctx.metadataSize);
const wrongOperation = Object.assign({}, TEST_METADATA_OPERATION, { length: 0 })
await assert.rejects(index.executeOperation({ ctx, reservation: { file: 'metadata.xml' }, operation: wrongOperation }));
});
it('should reject on request error', async () => {
const ctx = Object.assign({ bytesSent: 0 }, TEST_CTX);
await assert.rejects(index.executeOperation({ ctx, reservation: { file: TEST_CTX.fileName }, operation: TEST_BINARY_OPERATION_NETWORK_ERROR }));
});
});
describe('commitReservation()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'commitReservation',
id: sinon.match.string,
params: {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
iTMSTransporterMode: 'upload',
NewPackageName: TEST_CTX.packageName,
reservations: [
'RESERVATION_ID'
],
Username: TEST_CTX.username,
Version: '2.0.0'
}
}).test(body))
.reply(200, {
result: {
Success: true
}
});
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, {
result: {
Success: false
}
});
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = Object.assign({}, TEST_CTX);
await index.commitReservation(ctx, { id: 'RESERVATION_ID' });
});
it('should reject on failure', async () => {
const ctx = Object.assign({}, TEST_CTX);
await assert.rejects(index.commitReservation(ctx, { id: 'WRONG_RESERVATION_ID' }));
});
});
describe('uploadDoneWithArguments()', () => {
before(() => {
const serviceUrl = new url.URL(index.PRODUCER_SERVICE_URL);
nock(serviceUrl.origin)
.post(serviceUrl.pathname, (body) => sinon.match({
jsonrpc: '2.0',
method: 'uploadDoneWithArguments',
id: sinon.match.string,
params: {
Application: 'iTMSTransporter',
BaseVersion: '2.0.0',
FileSizeInfo: {
[TEST_CTX.fileName]: TEST_CTX.fileSize,
"metadata.xml": TEST_CTX.metadataSize
},
ClientChecksumInfo: [
{
CalculatedChecksum: TEST_CTX.fileChecksum,
CalculationTime: 100,
FileLastModified: TEST_CTX.fileModifiedTime,
Filename: TEST_CTX.fileName,
fileSize: TEST_CTX.fileSize
}
],
StatisticsArray: [],
StreamingInfoList: [],
iTMSTransporterMode: 'upload',
PackagePathWithoutBase: null,
NewPackageName: TEST_CTX.packageName,
Transport: 'HTTP',
TransferTime: TEST_CTX.transferTime,
NumberBytesTransferred: TEST_CTX.fileSize + TEST_CTX.metadataSize,
Username: TEST_CTX.username,
Version: '2.0.0'
}
}).test(body))
.reply(200, {
result: {
Success: true
}
});
nock(serviceUrl.origin)
.post(serviceUrl.pathname)
.reply(200, {
result: {
Success: false
}
});
});
after(() => {
sinon.restore();
});
it('should make the appropriate HTTP request', async () => {
const ctx = Object.assign({}, TEST_CTX);
await index.uploadDoneWithArguments(ctx);
});
it('should reject on failure', async () => {
const ctx = {};
await assert.rejects(index.uploadDoneWithArguments(ctx));
});
});
});
================================================
FILE: test/utility.test.js
================================================
const assert = require('assert').strict;
const sinon = require('sinon');
const fs = require('fs');
const stream = require('stream');
const yauzl = require("yauzl");
const zlib = require('zlib');
const axios = require('axios');
const utility = require('../lib/utility');
const TEST_PLIST = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>BUNDLE_IDENTIFIER</string>
<key>CFBundleVersion</key>
<string>BUNDLE_VERSION</string>
<key>CFBundleShortVersionString</key>
<string>BUNDLE_SHORT_VERSION</string>
</dict>
</plist>
`.trim();
const EMPTY_PLIST = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
`.trim();
describe('lib/utility', () => {
describe('generateIDString()', () => {
let clock;
before(() => {
clock = sinon.useFakeTimers({
now: new Date("2020-01-02T03:04:05.678Z"),
shouldAdvanceTime: false,
});
});
after(() => {
clock.restore();
});
it('should correctly format ID based on current time', () => {
assert.equal(utility.generateIDString(), '20200102030405-678');
});
});
describe('makeSessionDigest()', () => {
it('should generate a valid digest string', () => {
assert.equal(utility.makeSessionDigest('SESSION-ID', 'REQUEST_CHECKSUM', 'REQUEST-ID', 'SECRET'), 'af7b0121fe12199cdb5d765b73bd7cb5');
});
});
describe('openFile()', () => {
before(() => {
let stub = sinon.stub(fs, 'open')
stub.withArgs('VALIDPATH').yields(undefined, 1);
stub.withArgs('WRONGPATH').yields(new Error(), undefined);
});
after(() => {
sinon.restore();
});
it('should resolve with file-descriptor on success', async () => {
let fd = await utility.openFile('VALIDPATH');
assert.equal(fd, 1);
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.openFile('WRONGPATH'));
});
});
describe('closeFile()', () => {
before(() => {
let stub = sinon.stub(fs, 'close')
stub.withArgs(1).yields(undefined);
stub.withArgs(0).yields(new Error());
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
await utility.closeFile(1);
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.closeFile(0));
});
});
describe('readFileDataFromZip()', () => {
before(() => {
let fromFdStub = sinon.stub(yauzl, 'fromFd')
let zipFileOK = {
on: () => {},
openReadStream: () => {},
readEntry: () => {}
};
let zipFileOKMock = sinon.mock(zipFileOK);
let okEntry = { fileName: 'Payload/Test.app/Info.plist'};
let okStream = new stream.Readable({
read: function() {
this.push(TEST_PLIST);
this.push(null);
}
});
zipFileOKMock.expects('readEntry').once().returns();
zipFileOKMock.expects('openReadStream').withArgs(okEntry).yields(null, okStream);
zipFileOKMock.expects('on').withArgs('entry').yields(okEntry);
zipFileOKMock.expects('on').withArgs('error').returns();
zipFileOKMock.expects('on').withArgs('end').returns();
fromFdStub.withArgs(0, sinon.match.object)
.yields(null, zipFileOK);
let zipFileReadErr = {
on: () => {},
openReadStream: () => {},
readEntry: () => {}
};
let zipFileReadErrMock = sinon.mock(zipFileReadErr);
zipFileReadErrMock.expects('readEntry').once().returns();
zipFileReadErrMock.expects('openReadStream').withArgs(okEntry).yields(new Error('STREAM_ERR'), null);
zipFileReadErrMock.expects('on').withArgs('entry').yields(okEntry);
zipFileReadErrMock.expects('on').withArgs('error').returns();
zipFileReadErrMock.expects('on').withArgs('end').returns();
fromFdStub.withArgs(1, sinon.match.object)
.yields(null, zipFileReadErr);
let zipFileWrong = {
on: () => {},
openReadStream: () => {},
readEntry: () => {}
};
let zipFileWrongMock = sinon.mock(zipFileWrong);
let wrongEntry = { fileName: 'Payload/Test.app/other.file'};
zipFileWrongMock.expects('readEntry').once().returns();
zipFileWrongMock.expects('openReadStream').never();
zipFileWrongMock.expects('on').withArgs('entry').yields(wrongEntry);
zipFileWrongMock.expects('on').withArgs('error').returns();
zipFileWrongMock.expects('on').withArgs('end').yields();
fromFdStub.withArgs(2, sinon.match.object)
.yields(null, zipFileWrong);
fromFdStub.withArgs(3, sinon.match.object)
.yields(new Error('TEST_ERROR'), null);
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
let data = await utility.readFileDataFromZip(0, /^Payload\/[^/]*.app\/Info\.plist$/);
sinon.assert.match(data, sinon.match.instanceOf(Buffer));
});
it('should throw if unable to open read stream', async () => {
await assert.rejects(utility.readFileDataFromZip(1, /^Payload\/[^/]*.app\/Info\.plist$/), { message: 'STREAM_ERR' });
});
it('should resolve to null if not found', async () => {
let data = await utility.readFileDataFromZip(2, /^Payload\/[^/]*.app\/Info\.plist$/);
sinon.assert.match(data, null);
});
it('should throw if unable to read file', async () => {
await assert.rejects(utility.readFileDataFromZip(3, /^Payload\/[^/]*.app\/Info\.plist$/), { message: 'TEST_ERROR' });
});
});
describe('extractBundleIdAndVersion()', () => {
before(() => {
let readFileDataFromZipStub = sinon.stub(utility, 'readFileDataFromZip');
readFileDataFromZipStub
.withArgs(0, sinon.match.regexp)
.resolves(Buffer.from(TEST_PLIST));
readFileDataFromZipStub
.withArgs(1, sinon.match.regexp)
.resolves(null);
readFileDataFromZipStub
.withArgs(2, sinon.match.regexp)
.resolves(Buffer.from('INVALID'));
readFileDataFromZipStub
.withArgs(3, sinon.match.regexp)
.resolves(Buffer.from(EMPTY_PLIST));
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
let bundleInfo = await utility.extractBundleIdAndVersion(0);
assert.deepEqual(bundleInfo, { bundleId: 'BUNDLE_IDENTIFIER', bundleVersion: 'BUNDLE_VERSION', bundleShortVersion: 'BUNDLE_SHORT_VERSION' });
});
it('should reject with error on failure 1', async () => {
await assert.rejects(utility.extractBundleIdAndVersion(1), { message: 'Info.plist not found' });
});
it('should reject with error on failure 2', async () => {
await assert.rejects(utility.extractBundleIdAndVersion(2), { message: 'Failed to parse Info.plist' });
});
it('should reject with error on failure 3', async () => {
await assert.rejects(utility.extractBundleIdAndVersion(3), { message: 'Bundle info not found in Info.plist' });
});
});
describe('ensureTempDir()', () => {
before(() => {
let stub = sinon.stub(fs.promises, 'mkdir');
stub.withArgs(sinon.match.string, { recursive: true }).resolves(undefined);
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
let res = await utility.ensureTempDir();
sinon.assert.match(res, sinon.match.string);
});
});
describe('downloadTempFile()', () => {
beforeEach(() => {
let readableStream = new stream.PassThrough();
readableStream.end();
let axiosStub = sinon.stub(axios, 'get');
axiosStub.withArgs('http://example.com/app.ipa', { responseType: 'stream' })
.resolves({ data: readableStream, headers: { 'content-length': 1 } });
axiosStub.withArgs('http://example.com/app-no-cl.ipa', { responseType: 'stream' })
.resolves({ data: readableStream, headers: { } });
let ensureTempDirStub = sinon.stub(utility, 'ensureTempDir')
ensureTempDirStub.resolves('PATH');
let writeStream = new stream.Writable();
let createWriteStreamStub = sinon.stub(fs, 'createWriteStream');
createWriteStreamStub.withArgs(sinon.match.string).returns(writeStream);
});
afterEach(() => {
sinon.restore();
});
it('should resolve on success', async () => {
const onProgressCallback = sinon.spy();
let res = await utility.downloadTempFile('http://example.com/app.ipa', onProgressCallback);
sinon.assert.match(res, 'PATH');
sinon.assert.called(onProgressCallback);
});
it('should not call onProgress if content-length unknown', async () => {
const onProgressCallback = sinon.spy();
let res = await utility.downloadTempFile('http://example.com/app-no-cl.ipa', onProgressCallback);
sinon.assert.match(res, 'PATH');
sinon.assert.notCalled(onProgressCallback);
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.downloadTempFile());
});
});
describe('removeTempFile()', () => {
before(() => {
let unlinkStub = sinon.stub(fs.promises, 'unlink');
unlinkStub.withArgs('FILE_PATH').resolves();
unlinkStub.rejects();
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
await utility.removeTempFile('FILE_PATH');
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.removeTempFile());
});
});
describe('getFileStats()', () => {
before(() => {
let stub = sinon.stub(fs, 'fstat')
stub.withArgs(1).yields(undefined, {});
stub.withArgs(0).yields(new Error(), undefined);
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
let stats = await utility.getFileStats(1);
assert.deepEqual(stats, {});
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.getFileStats(0));
});
});
describe('readFile()', () => {
before(() => {
let stub = sinon.stub(fs, 'readFile')
stub.withArgs('VALIDPATH').yields(undefined, 'data');
stub.withArgs('WRONGPATH').yields(new Error(), undefined);
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
let data = await utility.readFile('VALIDPATH');
assert.deepEqual(data, 'data');
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.readFile('WRONGPATH'));
});
});
describe('getFileMD5()', () => {
before(() => {
let stub = sinon.stub(fs, 'createReadStream');
stub.withArgs(sinon.match.string, sinon.match({ fd: 1 })).callsFake(() => {
return new stream.Readable({
read: function() {
this.push('data');
this.push(null);
}
});
});
stub.withArgs(sinon.match.string, sinon.match({ fd: 0 })).callsFake(() => {
return new stream.Readable({
read: function() {
this.emit('error', new Error());
this.push(null);
}
});
});
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
let md5 = await utility.getFileMD5(1);
assert.deepEqual(md5, '8d777f385d3dfec8815d20f7496026dc');
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.getFileMD5(0));
});
});
describe('getFilePart()', () => {
before(() => {
let fsMock = sinon.mock(fs)
fsMock.expects('read').withArgs(1, sinon.match.instanceOf(Buffer), 0, 4, 0).callsFake((fd, buffer, offset, length, position, cb) => {
buffer.write('PART');
cb();
});
fsMock.expects('read').yields(new Error());
});
after(() => {
sinon.restore();
});
it('should resolve on success', async () => {
let part = await utility.getFilePart(1, 0, 4);
assert.deepEqual(part, Buffer.from('PART'));
});
it('should reject with error on failure', async () => {
await assert.rejects(utility.getFilePart(0, 0, 4));
});
});
describe('getStringMD5()', () => {
it('should return correct md5 hash string', () => {
assert.deepEqual(utility.getStringMD5('data'), '8d777f385d3dfec8815d20f7496026dc');
});
});
describe('getStringMD5Buffer()', () => {
it('should return correct md5 hash buffer', () => {
assert.deepEqual(utility.getStringMD5Buffer('data'), Buffer.from('8d777f385d3dfec8815d20f7496026dc', 'hex'));
});
});
describe('bufferToGZBase64()', () => {
it('should return correct md5 hash buffer', async () => {
let gzBase64 = await utility.bufferToGZBase64(Buffer.from('data'));
assert(typeof gzBase64 === 'string');
});
it('should reject with error on failure', async () => {
const zlibMock = sinon.mock(zlib);
zlibMock.expects('gzip')
.withArgs(sinon.match.instanceOf(Buffer))
.once()
.yields(new Error('TEST_ERROR'), null);
await assert.rejects(utility.bufferToGZBase64(Buffer.alloc(0)));
zlibMock.verify();
sinon.restore();
});
});
describe('formatSpeedAndEta()', () => {
it('should correctly format B/s', () => {
assert.deepEqual(utility.formatSpeedAndEta(10, 10, 1000), { eta: '0s', speed: '10 B/s' });
});
it('should correctly format kB/s', () => {
assert.deepEqual(utility.formatSpeedAndEta(10000, 10000, 1000), { eta: '0s', speed: '10 kB/s' });
});
it('should correctly format MB/s', () => {
assert.deepEqual(utility.formatSpeedAndEta(10000000, 10000000, 1000), { eta: '0s', speed: '10 MB/s' });
});
it('should correctly format eta', () => {
assert.deepEqual(utility.formatSpeedAndEta(10, 1000, 1000), { eta: '99s', speed: '10 B/s' });
});
});
});
gitextract_w16i6k4z/
├── .gitattributes
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── assets/
│ └── metadata_template.xml
├── bin/
│ └── cli.js
├── eslint.config.mjs
├── lib/
│ ├── index.js
│ └── utility.js
├── package.json
└── test/
├── index.test.js
└── utility.test.js
SYMBOL INDEX (24 symbols across 4 files)
FILE: bin/cli.js
function formatValue (line 24) | function formatValue(v, options, type) {
function runUpload (line 34) | async function runUpload(ctx) {
function run (line 146) | async function run() {
function stop (line 164) | function stop(signal) {
FILE: lib/index.js
constant SOFTWARE_SERVICE_URL (line 6) | const SOFTWARE_SERVICE_URL = 'https://contentdelivery.itunes.apple.com/W...
constant PRODUCER_SERVICE_URL (line 7) | const PRODUCER_SERVICE_URL = 'https://contentdelivery.itunes.apple.com/W...
constant USER_AGENT (line 8) | const USER_AGENT = 'iTMSTransporter/2.0.0';
constant MAX_BODY_LENGTH (line 10) | const MAX_BODY_LENGTH = 1024 ** 3;
function constructError (line 19) | function constructError(message, response) {
function generateMetadata (line 27) | async function generateMetadata(ctx) {
function makeSoftwareServiceRequest (line 51) | async function makeSoftwareServiceRequest(ctx, method, params) {
function makeProducerServiceRequest (line 85) | async function makeProducerServiceRequest(ctx, method, params) {
function authenticateForSession (line 119) | async function authenticateForSession(ctx) {
function lookupSoftwareForBundleId (line 134) | async function lookupSoftwareForBundleId(ctx) {
function validateMetadata (line 151) | async function validateMetadata(ctx) {
function validateAssets (line 186) | async function validateAssets(ctx) {
function clientChecksumCompleted (line 227) | async function clientChecksumCompleted(ctx) {
function createReservation (line 242) | async function createReservation(ctx) {
function executeOperation (line 276) | async function executeOperation({ ctx, reservation, operation }) {
function commitReservation (line 315) | async function commitReservation(ctx, reservation) {
function uploadDoneWithArguments (line 333) | async function uploadDoneWithArguments(ctx) {
FILE: lib/utility.js
constant INFO_PLIST_FILE_PATTERN (line 14) | const INFO_PLIST_FILE_PATTERN = /^Payload\/[^/]*.app\/Info\.plist$/;
FILE: test/utility.test.js
constant TEST_PLIST (line 11) | const TEST_PLIST = `
constant EMPTY_PLIST (line 26) | const EMPTY_PLIST = `
Condensed preview — 16 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (75K chars).
[
{
"path": ".gitattributes",
"chars": 63,
"preview": "*.js eol=lf\n*.json eol=lf\n*.yml eol=lf\n*.md eol=lf\n*.xml eol=lf"
},
{
"path": ".github/FUNDING.yml",
"chars": 70,
"preview": "# These are supported funding model platforms\n\ngithub: [simonnilsson]\n"
},
{
"path": ".github/workflows/ci.yml",
"chars": 969,
"preview": "name: ci\n\non: [push, pull_request]\n\njobs:\n lint:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v"
},
{
"path": ".github/workflows/release.yml",
"chars": 753,
"preview": "name: release\n\non:\n release:\n types: [published]\n\njobs:\n publish-npm:\n runs-on: ubuntu-latest\n steps:\n -"
},
{
"path": ".gitignore",
"chars": 1853,
"preview": "# General\n.vscode\n.DS_Store\nbuild/\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n#"
},
{
"path": "CHANGELOG.md",
"chars": 3574,
"preview": "# Changelog\nAll notable changes to this project will be documented in this file.\n\n## [Unreleased]\n\n\n## [3.0.3] - 2025-04"
},
{
"path": "LICENSE",
"chars": 1052,
"preview": "Copyright 2020 Simon Nilsson\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this softw"
},
{
"path": "README.md",
"chars": 2473,
"preview": "# ios-uploader\n\n[](https://www.npmjs.org/package/"
},
{
"path": "assets/metadata_template.xml",
"chars": 515,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://apple.com/itunes/importer\" version=\"software5.4\">\n <softw"
},
{
"path": "bin/cli.js",
"chars": 5498,
"preview": "#!/usr/bin/env node\n\nconst { queue } = require('async');\nconst { Command } = require('commander');\nconst cliProgress = r"
},
{
"path": "eslint.config.mjs",
"chars": 658,
"preview": "import js from '@eslint/js';\r\nimport globals from 'globals';\r\nimport stylistic from '@stylistic/eslint-plugin';\r\nimport "
},
{
"path": "lib/index.js",
"chars": 10503,
"preview": "const axios = require('axios');\nconst path = require('path');\n\nconst utility = require('./utility');\n\nconst SOFTWARE_SER"
},
{
"path": "lib/utility.js",
"chars": 5417,
"preview": "const fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst crypto = require('crypto');\ncons"
},
{
"path": "package.json",
"chars": 1622,
"preview": "{\n \"name\": \"ios-uploader\",\n \"version\": \"3.0.3\",\n \"description\": \"Easy to use, cross-platform tool to upload an iOS ap"
},
{
"path": "test/index.test.js",
"chars": 21546,
"preview": "const assert = require('assert').strict;\nconst sinon = require('sinon');\nconst nock = require('nock');\nconst url = requi"
},
{
"path": "test/utility.test.js",
"chars": 14389,
"preview": "const assert = require('assert').strict;\nconst sinon = require('sinon');\nconst fs = require('fs');\nconst stream = requir"
}
]
About this extraction
This page contains the full source code of the simonnilsson/ios-uploader GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 16 files (69.3 KB), approximately 18.2k tokens, and a symbol index with 24 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.