|
1 | 1 | #!/usr/bin/env node |
2 | 2 |
|
3 | | -/** |
4 | | - * Diawi Upload Script |
5 | | - * Uploads iOS IPA files to Diawi for distribution. |
6 | | - * |
7 | | - * Usage: node diawi-upload.mjs <ipa_path> [comment] |
8 | | - * |
9 | | - * Environment variables: |
10 | | - * DIAWI_TOKEN - Required: API token for Diawi authentication |
11 | | - * VERBOSE - Optional: Set to enable verbose logging |
12 | | - * POLL_MAX_COUNT - Optional: Max polling attempts (default: 120) |
13 | | - * POLL_INTERVAL_MS - Optional: Polling interval in ms (default: 500) |
14 | | - */ |
15 | | - |
16 | 3 | import https from 'node:https' |
17 | 4 | import { basename } from 'node:path' |
18 | | -import { createReadStream, statSync } from 'node:fs' |
19 | | -import { randomBytes } from 'node:crypto' |
| 5 | +import { promisify } from 'node:util' |
| 6 | +import { createReadStream } from 'node:fs' |
| 7 | + |
| 8 | +import log from 'npmlog' |
| 9 | +import FormData from 'form-data' |
20 | 10 |
|
21 | 11 | const UPLOAD_URL = 'https://upload.diawi.com/' |
22 | 12 | const STATUS_URL = 'https://upload.diawi.com/status' |
23 | 13 | const DIAWI_TOKEN = process.env.DIAWI_TOKEN |
24 | | -const VERBOSE = process.env.VERBOSE === 'true' || process.env.VERBOSE === '1' |
25 | | -const POLL_MAX_COUNT = parseInt(process.env.POLL_MAX_COUNT || '120', 10) |
26 | | -const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || '500', 10) |
27 | | - |
28 | | -const log = { |
29 | | - info: (prefix, ...args) => console.log(`[INFO] ${prefix}:`, ...args), |
30 | | - verbose: (prefix, ...args) => VERBOSE && console.log(`[VERBOSE] ${prefix}:`, ...args), |
31 | | - warn: (prefix, ...args) => console.warn(`[WARN] ${prefix}:`, ...args), |
32 | | - error: (prefix, ...args) => console.error(`[ERROR] ${prefix}:`, ...args), |
33 | | -} |
| 14 | +const LOG_LEVEL = process.env.VERBOSE ? 'verbose' : 'info' |
| 15 | +const POLL_MAX_COUNT = process.env.POLL_MAX_COUNT ? process.env.POLL_MAX_COUNT : 120 |
| 16 | +const POLL_INTERVAL_MS = process.env.POLL_INTERVAL_MS ? process.env.POLL_INTERVAL_MS : 500 |
34 | 17 |
|
35 | | -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) |
| 18 | +const sleep = (ms) => { |
| 19 | + return new Promise((resolve, reject) => { |
| 20 | + setTimeout(resolve, (ms)) |
| 21 | + }) |
| 22 | +} |
36 | 23 |
|
37 | | -/** |
38 | | - * Perform a GET request |
39 | | - */ |
40 | 24 | const getRequest = async (url) => { |
41 | 25 | return new Promise((resolve, reject) => { |
42 | | - const data = [] |
43 | | - https.get(url, (res) => { |
44 | | - res.on('error', (err) => reject(err)) |
45 | | - res.on('data', (chunk) => data.push(chunk)) |
| 26 | + let data = [] |
| 27 | + https.get(url, res => { |
| 28 | + res.on('error', err => reject(err)) |
| 29 | + res.on('data', chunk => { data.push(chunk) }) |
46 | 30 | res.on('end', () => { |
47 | | - const payload = Buffer.concat(data).toString() |
| 31 | + let payload = Buffer.concat(data).toString() |
48 | 32 | resolve({ |
49 | 33 | code: res.statusCode, |
50 | 34 | message: res.statusMessage, |
51 | 35 | payload: payload, |
52 | 36 | }) |
53 | 37 | }) |
54 | | - }).on('error', reject) |
| 38 | + }) |
55 | 39 | }) |
56 | 40 | } |
57 | 41 |
|
58 | | -/** |
59 | | - * Create multipart form data and upload to Diawi |
60 | | - * Using native Node.js without external dependencies |
61 | | - */ |
62 | 42 | const uploadIpa = async (ipaPath, comment, token) => { |
63 | | - const boundary = `----FormBoundary${randomBytes(16).toString('hex')}` |
64 | | - const fileName = basename(ipaPath) |
65 | | - const fileSize = statSync(ipaPath).size |
| 43 | + let form = new FormData() |
| 44 | + form.append('token', token) |
| 45 | + form.append('file', createReadStream(ipaPath)) |
| 46 | + form.append('comment', comment || basename(ipaPath)) |
66 | 47 |
|
67 | | - log.info('upload', `File: ${fileName}, Size: ${(fileSize / 1024 / 1024).toFixed(2)} MB`) |
| 48 | + const formSubmitPromise = promisify(form.submit.bind(form)) |
68 | 49 |
|
69 | | - return new Promise((resolve, reject) => { |
70 | | - // Build the multipart form data parts |
71 | | - const tokenPart = [ |
72 | | - `--${boundary}`, |
73 | | - 'Content-Disposition: form-data; name="token"', |
74 | | - '', |
75 | | - token, |
76 | | - ].join('\r\n') |
77 | | - |
78 | | - const commentPart = [ |
79 | | - `--${boundary}`, |
80 | | - 'Content-Disposition: form-data; name="comment"', |
81 | | - '', |
82 | | - comment || fileName, |
83 | | - ].join('\r\n') |
84 | | - |
85 | | - const fileHeader = [ |
86 | | - `--${boundary}`, |
87 | | - `Content-Disposition: form-data; name="file"; filename="${fileName}"`, |
88 | | - 'Content-Type: application/octet-stream', |
89 | | - '', |
90 | | - ].join('\r\n') |
91 | | - |
92 | | - const footer = `\r\n--${boundary}--\r\n` |
93 | | - |
94 | | - // Calculate total content length |
95 | | - const headerBuffer = Buffer.from(`${tokenPart}\r\n${commentPart}\r\n${fileHeader}\r\n`) |
96 | | - const footerBuffer = Buffer.from(footer) |
97 | | - const contentLength = headerBuffer.length + fileSize + footerBuffer.length |
98 | | - |
99 | | - const url = new URL(UPLOAD_URL) |
100 | | - const options = { |
101 | | - hostname: url.hostname, |
102 | | - port: 443, |
103 | | - path: url.pathname, |
104 | | - method: 'POST', |
105 | | - headers: { |
106 | | - 'Content-Type': `multipart/form-data; boundary=${boundary}`, |
107 | | - 'Content-Length': contentLength, |
108 | | - }, |
109 | | - } |
110 | | - |
111 | | - const req = https.request(options, (res) => { |
112 | | - let data = '' |
113 | | - res.on('data', (chunk) => { data += chunk }) |
114 | | - res.on('end', () => { |
115 | | - if (res.statusCode !== 200) { |
116 | | - log.error('upload', `Upload failed: ${res.statusCode} ${res.statusMessage}`) |
117 | | - log.error('upload', `Response: ${data}`) |
118 | | - reject(new Error(`Upload failed: ${res.statusCode} ${res.statusMessage}`)) |
119 | | - return |
120 | | - } |
121 | | - try { |
122 | | - const json = JSON.parse(data) |
123 | | - resolve(json.job) |
124 | | - } catch (e) { |
125 | | - reject(new Error(`Failed to parse response: ${data}`)) |
126 | | - } |
127 | | - }) |
128 | | - }) |
129 | | - |
130 | | - req.on('error', (err) => { |
131 | | - log.error('upload', `Request error: ${err.message}`) |
132 | | - reject(err) |
133 | | - }) |
134 | | - |
135 | | - // Write header parts |
136 | | - req.write(headerBuffer) |
| 50 | + const res = await formSubmitPromise(UPLOAD_URL) |
| 51 | + if (res.statusCode != 200) { |
| 52 | + log.error('uploadIpa', 'Upload failed: %d %s', res.statusCode, res.statusMessage) |
| 53 | + process.exit(1) |
| 54 | + } |
137 | 55 |
|
138 | | - // Stream the file |
139 | | - const fileStream = createReadStream(ipaPath) |
140 | | - fileStream.on('data', (chunk) => req.write(chunk)) |
141 | | - fileStream.on('end', () => { |
142 | | - req.write(footerBuffer) |
143 | | - req.end() |
144 | | - }) |
145 | | - fileStream.on('error', (err) => { |
146 | | - log.error('upload', `File read error: ${err.message}`) |
147 | | - reject(err) |
| 56 | + return new Promise((resolve) => { |
| 57 | + const jobId = res.on('data', async (data) => { |
| 58 | + resolve(JSON.parse(data)['job']) |
148 | 59 | }) |
149 | 60 | }) |
150 | 61 | } |
151 | 62 |
|
152 | | -/** |
153 | | - * Check the status of an upload job |
154 | | - */ |
155 | 63 | const checkStatus = async (jobId, token) => { |
156 | | - const params = new URLSearchParams({ token, job: jobId }) |
157 | | - const rval = await getRequest(`${STATUS_URL}?${params.toString()}`) |
158 | | - |
159 | | - if (rval.code !== 200) { |
160 | | - log.error('checkStatus', `Check query failed: ${rval.code} ${rval.message}`) |
161 | | - throw new Error(`Status check failed: ${rval.code} ${rval.message}`) |
| 64 | + let params = new URLSearchParams({ |
| 65 | + token: token, job: jobId, |
| 66 | + }) |
| 67 | + let rval = await getRequest(`${STATUS_URL}?${params.toString()}`) |
| 68 | + if (rval.code != 200) { |
| 69 | + log.error('checkStatus', 'Check query failed: %d %s', rval.code, rval.message) |
| 70 | + process.exit(1) |
162 | 71 | } |
163 | | - |
164 | 72 | return JSON.parse(rval.payload) |
165 | 73 | } |
166 | 74 |
|
167 | | -/** |
168 | | - * Poll for upload completion |
169 | | - */ |
170 | 75 | const pollStatus = async (jobId, token) => { |
171 | 76 | let interval = POLL_INTERVAL_MS |
172 | | - |
173 | 77 | for (let i = 0; i <= POLL_MAX_COUNT; i++) { |
174 | | - const json = await checkStatus(jobId, token) |
175 | | - |
| 78 | + let json = await checkStatus(jobId, token) |
176 | 79 | switch (json.status) { |
177 | 80 | case 2000: |
178 | | - // Success |
179 | 81 | return json |
180 | 82 | case 2001: |
181 | | - // Still processing |
182 | | - log.verbose('pollStatus', `Waiting: ${json.message}`) |
183 | | - break |
| 83 | + log.verbose('pollStatus', 'Waiting: %s', json.message) |
| 84 | + break /* Nothing, just poll again. */ |
184 | 85 | case 4000000: |
185 | | - // Rate limited, back off |
186 | | - log.warn('pollStatus', `Doubling polling interval: ${json.message}`) |
| 86 | + log.warning('pollStatus', 'Doubling polling interval: %s', json.message) |
187 | 87 | interval *= 2 |
188 | 88 | break |
189 | 89 | default: |
190 | 90 | log.error('pollStatus', `Error in status response: ${json.message}`) |
191 | | - throw new Error(`Diawi error: ${json.message}`) |
| 91 | + process.exit(1) |
192 | 92 | } |
193 | | - |
194 | 93 | await sleep(interval) |
195 | 94 | } |
196 | | - |
197 | | - throw new Error(`Failed to poll status after ${POLL_MAX_COUNT} retries`) |
| 95 | + log.error('pollStatus', 'Failed to poll status after %d retries.', POLL_MAX_COUNT) |
| 96 | + process.exit(1) |
198 | 97 | } |
199 | 98 |
|
200 | 99 | const main = async () => { |
201 | 100 | const targetFile = process.argv[2] |
202 | 101 | const comment = process.argv[3] |
| 102 | + log.level = LOG_LEVEL |
203 | 103 |
|
204 | | - if (!DIAWI_TOKEN) { |
| 104 | + if (DIAWI_TOKEN === undefined) { |
205 | 105 | log.error('main', 'No DIAWI_TOKEN env var provided!') |
206 | 106 | process.exit(1) |
207 | 107 | } |
208 | | - |
209 | | - if (!targetFile) { |
| 108 | + if (targetFile === undefined) { |
210 | 109 | log.error('main', 'No file path provided!') |
211 | | - log.error('main', 'Usage: node diawi-upload.mjs <ipa_path> [comment]') |
212 | 110 | process.exit(1) |
213 | 111 | } |
214 | 112 |
|
215 | | - try { |
216 | | - log.info('main', `Uploading: ${targetFile}`) |
217 | | - const jobId = await uploadIpa(targetFile, comment, DIAWI_TOKEN) |
| 113 | + log.info('main', 'Uploading: %s', targetFile) |
| 114 | + let jobId = await uploadIpa(targetFile, comment, DIAWI_TOKEN) |
218 | 115 |
|
219 | | - log.info('main', `Polling upload job status: ${jobId}`) |
220 | | - const uploadMeta = await pollStatus(jobId, DIAWI_TOKEN) |
| 116 | + log.info('main', 'Polling upload job status: %s', jobId) |
| 117 | + let uploadMeta = await pollStatus(jobId, DIAWI_TOKEN) |
221 | 118 |
|
222 | | - // Output the result as JSON (for parsing by CI) |
223 | | - console.log(JSON.stringify(uploadMeta, null, 2)) |
224 | | - |
225 | | - // Also output the direct link for convenience |
226 | | - if (uploadMeta.link) { |
227 | | - log.info('main', `Diawi URL: ${uploadMeta.link}`) |
228 | | - } |
229 | | - } catch (error) { |
230 | | - log.error('main', error.message) |
231 | | - process.exit(1) |
232 | | - } |
| 119 | + console.log(uploadMeta) |
233 | 120 | } |
234 | 121 |
|
235 | 122 | main() |
0 commit comments