airborne-devkit 0.9.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.
@@ -0,0 +1,321 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { createHash } from "crypto";
4
+ import { createReadStream } from "fs";
5
+ import { promptWithType } from "./prompt.js";
6
+
7
+ const cliToConfigMap = {
8
+ platform: "platform",
9
+ tag: "tag",
10
+ organisation: "organisation",
11
+ namespace: "namespace",
12
+ jsEntryFile: "js_entry_file",
13
+ androidIndex: "android.index_file_path",
14
+ iosIndex: "ios.index_file_path",
15
+ upload: "upload",
16
+ directoryPath: "directory_path",
17
+ bootTimeout: "boot_timeout",
18
+ releaseConfigTimeout: "release_config_timeout",
19
+ };
20
+
21
+ export function normalizeOptions(options = {}) {
22
+ const normalized = {};
23
+
24
+ for (const [key, value] of Object.entries(options)) {
25
+ const mappedKey = cliToConfigMap[key] || key;
26
+ normalized[mappedKey] = value;
27
+ }
28
+
29
+ return normalized;
30
+ }
31
+
32
+ export async function readAirborneConfig(directoryPath) {
33
+ const configPath = path.join(directoryPath, "airborne-config.json");
34
+
35
+ try {
36
+ // Check if file exists
37
+ await fs.promises.access(configPath);
38
+ } catch {
39
+ throw new Error(
40
+ `āŒ Airborne config not found at ${configPath}, try using create-local-release-config`
41
+ );
42
+ }
43
+
44
+ try {
45
+ const configContent = await fs.promises.readFile(configPath, "utf8");
46
+ return JSON.parse(configContent);
47
+ } catch (error) {
48
+ console.error("āŒ Failed to read airborne-config.json:", error.message);
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ export async function writeAirborneConfig(options) {
54
+ try {
55
+ const filledOptions = await fillAirborneConfigOptions(options);
56
+ const config = {
57
+ organisation: filledOptions.organisation,
58
+ namespace: filledOptions.namespace,
59
+ js_entry_file: filledOptions.js_entry_file,
60
+ android: {
61
+ index_file_path: filledOptions.android.index_file_path,
62
+ },
63
+ ios: {
64
+ index_file_path: filledOptions.ios.index_file_path,
65
+ },
66
+ };
67
+ const configPath = path.join(
68
+ filledOptions.directory_path,
69
+ "airborne-config.json"
70
+ );
71
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
72
+ console.log(`āœ… Config written to ${configPath}`);
73
+ } catch (err) {
74
+ console.error("āŒ Failed to create local airborne config:", err.message);
75
+ process.exit(1); // Exit with failure code
76
+ }
77
+ }
78
+
79
+ export async function fillAirborneConfigOptions(options = {}) {
80
+ const questions = [
81
+ {
82
+ key: "organisation",
83
+ question: "\n Please enter the organisation name: ",
84
+ expectedType: "string",
85
+ },
86
+ {
87
+ key: "namespace",
88
+ question: "\n Please enter namespace/application name: ",
89
+ expectedType: "string",
90
+ },
91
+ {
92
+ key: "js_entry_file",
93
+ question: "\n Please enter the JavaScript entry file (e.g., index.js): ",
94
+ expectedType: "string",
95
+ defaultValue: "index.js",
96
+ },
97
+ {
98
+ key: "android.index_file_path",
99
+ question:
100
+ "\n Please enter the Android index file path (default: index.android.bundle): ",
101
+ expectedType: "string",
102
+ defaultValue: "index.android.bundle",
103
+ },
104
+ {
105
+ key: "ios.index_file_path",
106
+ question:
107
+ "\n Please enter the iOS index file path (default: main.jsbundle): ",
108
+ expectedType: "string",
109
+ defaultValue: "main.jsbundle",
110
+ },
111
+ ];
112
+
113
+ const result = { ...options };
114
+ const getNested = (obj, path) =>
115
+ path.split(".").reduce((acc, k) => (acc ? acc[k] : undefined), obj);
116
+
117
+ const setNested = (obj, path, value) => {
118
+ const parts = path.split(".");
119
+ let temp = obj;
120
+ for (let i = 0; i < parts.length - 1; i++) {
121
+ if (!temp[parts[i]]) temp[parts[i]] = {};
122
+ temp = temp[parts[i]];
123
+ }
124
+ temp[parts[parts.length - 1]] = value;
125
+ };
126
+
127
+ for (const { key, question, expectedType, defaultValue } of questions) {
128
+ let value;
129
+
130
+ if (getNested(options, key) !== undefined) {
131
+ value = getNested(options, key);
132
+ } else if (question) {
133
+ value = await promptWithType(question, expectedType, defaultValue);
134
+ }
135
+
136
+ if (value !== undefined) {
137
+ setNested(result, key, value);
138
+ }
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ export async function sha256FileHex(filePath) {
145
+ return new Promise((resolve, reject) => {
146
+ const hash = createHash("sha256");
147
+ const stream = createReadStream(filePath);
148
+
149
+ stream.on("data", (chunk) => hash.update(chunk));
150
+ stream.on("end", () => resolve(hash.digest("hex")));
151
+ stream.on("error", (err) => reject(err));
152
+ });
153
+ }
154
+
155
+ export function hexToBase64(hex) {
156
+ return Buffer.from(hex, "hex").toString("base64");
157
+ }
158
+
159
+ export function stripMetadata(obj) {
160
+ if (Array.isArray(obj)) {
161
+ return obj.map(stripMetadata);
162
+ } else if (obj && typeof obj === "object") {
163
+ const newObj = {};
164
+ for (const key in obj) {
165
+ if (key === "$metadata") continue;
166
+ newObj[key] = stripMetadata(obj[key]);
167
+ }
168
+ return newObj;
169
+ }
170
+ return obj;
171
+ }
172
+
173
+ export function removeToken(text) {
174
+ return (
175
+ text
176
+ // 1. Remove whole `--token <...>` in any usage line
177
+ .replace(/\s*--token\s+<[^>\s]+>/g, "")
178
+
179
+ // 2. Fix cases where we had " \\" at the end of a line
180
+ .replace(/\\\s*\n\s*\n/g, "\n\n")
181
+
182
+ // 3. Remove full parameter definition lines for token
183
+ .replace(/^\s*--token[^\n]*(\n|$)/gm, "")
184
+
185
+ // 4. If leftover text "(required) : Bearer token..." is stuck on another line, drop it
186
+ .replace(/\(required\)\s*:\s*Bearer token[^\n]*/gi, "")
187
+
188
+ // 5. Remove JSON `"token": "..."` entries
189
+ .replace(/"token"\s*:\s*"[^"]*",?\s*\n?/g, "")
190
+
191
+ // 6. Cleanup trailing spaces per line
192
+ .replace(/[ \t]+$/gm, "")
193
+ );
194
+ }
195
+
196
+ export function formatCommand(cmd) {
197
+ cmd.options = cmd.options.filter((opt) => opt.long !== "--token");
198
+ cmd._description = removeToken(cmd._description);
199
+ cmd._description = cmd._description.replace(
200
+ /airborne-core-cli/g,
201
+ "airborne-devkit"
202
+ );
203
+ cmd.listeners("option:token").forEach((listener) => {
204
+ cmd.removeListener("option:token", listener);
205
+ });
206
+ const afterHelpListeners = cmd.listeners("afterHelp");
207
+ cmd.removeAllListeners("afterHelp");
208
+ afterHelpListeners.forEach((fn) => {
209
+ cmd.on("afterHelp", function (...args) {
210
+ const originalWrite = process.stdout.write;
211
+ let output = "";
212
+
213
+ // hijack stdout to capture output
214
+ process.stdout.write = (chunk, ...rest) => {
215
+ output += chunk;
216
+ return true;
217
+ };
218
+
219
+ fn.apply(this, args);
220
+
221
+ // replace airborne_core_Ƅcli with airborne-devkit
222
+ output = output.replace(/airborne-core-cli/g, "airborne-devkit");
223
+
224
+ // restore stdout
225
+ process.stdout.write = originalWrite;
226
+
227
+ // write sanitized output
228
+ process.stdout.write(removeToken(output));
229
+ });
230
+ });
231
+
232
+ cmd.hook("preAction", async (thisCmd) => {
233
+ const token = loadToken(process.cwd());
234
+ if (token?.access_token) {
235
+ thisCmd.setOptionValue("token", token.access_token);
236
+ }
237
+ });
238
+
239
+ return cmd;
240
+ }
241
+
242
+ export async function saveToken(access_token, refresh_token, directory_path) {
243
+ try {
244
+ let tokenPath;
245
+
246
+ if (process.env.CI === "true") {
247
+ tokenPath = path.join("/tmp", "airborne_tokens.json");
248
+ } else {
249
+ if (!directory_path) {
250
+ throw new Error("directory_path is required for non-CI usage.");
251
+ }
252
+
253
+ const airborneDir = path.join(directory_path, ".airborne");
254
+
255
+ // Create .airborne directory if it doesn't exist
256
+ if (!fs.existsSync(airborneDir)) {
257
+ fs.mkdirSync(airborneDir, { recursive: true, mode: 0o700 }); // rwx------
258
+ }
259
+
260
+ tokenPath = path.join(airborneDir, "credentials.json");
261
+ }
262
+
263
+ const data = {
264
+ access_token,
265
+ refresh_token,
266
+ saved_at: new Date().toISOString(),
267
+ };
268
+
269
+ fs.writeFileSync(tokenPath, JSON.stringify(data, null, 2), {
270
+ mode: 0o600, // rw------- permissions
271
+ });
272
+ const gitignorePath = path.join(directory_path, ".gitignore");
273
+ let gitignoreContent = "";
274
+ if (fs.existsSync(gitignorePath)) {
275
+ gitignoreContent = fs.readFileSync(gitignorePath, "utf8");
276
+ }
277
+
278
+ if (!gitignoreContent.includes(".airborne")) {
279
+ gitignoreContent +=
280
+ (gitignoreContent.endsWith("\n") ? "" : "\n") + ".airborne\n";
281
+ fs.writeFileSync(gitignorePath, gitignoreContent, "utf8");
282
+ }
283
+ } catch (err) {
284
+ console.error("āŒ Failed to save tokens:", err.message);
285
+ throw err;
286
+ }
287
+ }
288
+
289
+ export function loadToken(directory_path) {
290
+ try {
291
+ let tokenPath;
292
+
293
+ if (process.env.CI === "true") {
294
+ tokenPath = path.join("/tmp", "airborne_tokens.json");
295
+ } else {
296
+ if (!directory_path) {
297
+ throw new Error("directory_path is required for non-CI usage.");
298
+ }
299
+ tokenPath = path.join(directory_path, ".airborne", "credentials.json");
300
+ }
301
+
302
+ if (fs.existsSync(tokenPath)) {
303
+ return JSON.parse(fs.readFileSync(tokenPath, "utf8"));
304
+ }
305
+
306
+ return null;
307
+ } catch (err) {
308
+ console.error("āŒ Failed to load tokens:", err.message);
309
+ return null;
310
+ }
311
+ }
312
+
313
+ export async function airborneConfigExists(directoryPath) {
314
+ try {
315
+ const configPath = path.join(directoryPath, "airborne-config.json");
316
+ await fs.promises.access(configPath);
317
+ return true;
318
+ } catch (error) {
319
+ return false;
320
+ }
321
+ }
@@ -0,0 +1,380 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { execSync } from "child_process";
4
+ import { hexToBase64, sha256FileHex } from "./common.js";
5
+ import { UploadFileAction, CreateFileAction } from "airborne-core-cli/action";
6
+
7
+ export function readDirectoryRecursive(dirPath, baseDir = dirPath) {
8
+ const items = [];
9
+
10
+ if (!fs.existsSync(dirPath)) {
11
+ return items;
12
+ }
13
+
14
+ const entries = fs.readdirSync(dirPath, {
15
+ withFileTypes: true,
16
+ });
17
+
18
+ for (const entry of entries) {
19
+ const fullPath = path.join(dirPath, entry.name);
20
+ const relativePath = path.relative(baseDir, fullPath);
21
+
22
+ if (entry.isDirectory()) {
23
+ const result = readDirectoryRecursive(fullPath, baseDir);
24
+ result.forEach((resultEntry) => {
25
+ items.push(resultEntry);
26
+ });
27
+ } else {
28
+ items.push({
29
+ name: entry.name,
30
+ type: "file",
31
+ path: relativePath,
32
+ fullPath: fullPath,
33
+ });
34
+ }
35
+ }
36
+
37
+ return items;
38
+ }
39
+
40
+ export async function executeReactNativeBundleCommand(command) {
41
+ console.log("šŸ“¦ Executing command: ", command);
42
+
43
+ try {
44
+ const remotebundleDir = path.join(process.cwd(), "remotebundle");
45
+ if (!fs.existsSync(remotebundleDir)) {
46
+ fs.mkdirSync(remotebundleDir, {
47
+ recursive: true,
48
+ });
49
+ }
50
+
51
+ // Execute the command
52
+ const output = execSync(command, {
53
+ encoding: "utf8",
54
+ cwd: process.cwd(),
55
+ });
56
+
57
+ return {
58
+ success: true,
59
+ output: output,
60
+ command: command,
61
+ };
62
+ } catch (error) {
63
+ console.error("āŒ React Native bundle command failed:", error.message);
64
+
65
+ return {
66
+ success: false,
67
+ error: error.message,
68
+ command: command,
69
+ exitCode: error.status,
70
+ };
71
+ }
72
+ }
73
+ export const uploadFiles = async (filesToUpload, config) => {
74
+ console.log(
75
+ `šŸš€ Starting upload process for ${filesToUpload.length} files...`
76
+ );
77
+
78
+ const results = {
79
+ uploaded: 0,
80
+ existing: 0,
81
+ failed: 0,
82
+ errors: [],
83
+ };
84
+
85
+ try {
86
+ for (let index = 0; index < filesToUpload.length; index++) {
87
+ const fileObj = filesToUpload[index];
88
+ const fileProgress = `[${index + 1}/${filesToUpload.length}]`;
89
+
90
+ try {
91
+ console.log(`${fileProgress} šŸ” Processing ${fileObj.file_path}...`);
92
+
93
+ const storedChecksum = await getMappedChecksum(
94
+ config.directory_path,
95
+ fileObj.file_path,
96
+ config.tag
97
+ );
98
+
99
+ const baseDir = path.isAbsolute(config.directory_path)
100
+ ? config.directory_path
101
+ : path.join(process.cwd(), config.directory_path);
102
+
103
+ const fileFullPath = path.join(
104
+ baseDir,
105
+ config.platform,
106
+ "build",
107
+ "generated",
108
+ "airborne",
109
+ fileObj.file_path
110
+ );
111
+
112
+ if (!fs.existsSync(fileFullPath)) {
113
+ throw new Error(`File not found: ${fileFullPath}`);
114
+ }
115
+
116
+ const checksum = await sha256FileHex(fileFullPath);
117
+
118
+ if (storedChecksum === checksum) {
119
+ console.log(
120
+ `${fileProgress} āœ… File already exists, checksum matches`
121
+ );
122
+ results.existing++;
123
+ continue;
124
+ }
125
+
126
+ console.log(`${fileProgress} ā¬†ļø Uploading file: ${fileObj.file_path}`);
127
+
128
+ const uploadOptions = {
129
+ file: fileFullPath,
130
+ file_path: fileObj.file_path,
131
+ organisation: config.organisation,
132
+ application: config.namespace,
133
+ token: config.token,
134
+ checksum: hexToBase64(checksum),
135
+ tag: config.tag,
136
+ };
137
+
138
+ const uploadOutput = await UploadFileAction(null, uploadOptions);
139
+
140
+ if (!uploadOutput.file_path || !uploadOutput.id) {
141
+ throw new Error("Upload failed, invalid response from server");
142
+ }
143
+
144
+ await createFileMapping(
145
+ config.directory_path,
146
+ uploadOutput.file_path,
147
+ uploadOutput.id,
148
+ uploadOutput.checksum,
149
+ uploadOutput.tag
150
+ );
151
+
152
+ // Check if this was a new upload or existing file returned
153
+ if (uploadOutput.checksum === checksum) {
154
+ console.log(
155
+ `${fileProgress} āœ… Successfully processed ${fileObj.file_path}`
156
+ );
157
+ results.uploaded++;
158
+ } else {
159
+ console.log(`${fileProgress} šŸ”„ File already existed on server`);
160
+ results.existing++;
161
+ }
162
+ } catch (err) {
163
+ console.error(
164
+ `${fileProgress} 🚨 Error processing ${fileObj.file_path}:`,
165
+ err.message
166
+ );
167
+
168
+ results.failed++;
169
+ results.errors.push({ file: fileObj.file_path, error: err.message });
170
+ }
171
+ }
172
+
173
+ console.log("\nšŸ“Š Upload Summary:");
174
+ console.log(`āœ… Uploaded: ${results.uploaded}`);
175
+ console.log(`ā™»ļø Existing: ${results.existing}`);
176
+ console.log(`āŒ Failed: ${results.failed}`);
177
+
178
+ if (results.errors.length > 0) {
179
+ console.log("\nšŸ“‹ Failed files:");
180
+ results.errors.forEach(({ file, error }) => {
181
+ console.log(` • ${file}: ${error}`);
182
+ });
183
+ }
184
+ } catch (err) {
185
+ console.error("\nšŸ’„ Upload process failed:", err.message);
186
+ throw err;
187
+ }
188
+ };
189
+
190
+ export async function createFiles(filesToCreate, config, prefixUrl) {
191
+ console.log(
192
+ `šŸš€ Starting file creation process for ${filesToCreate.length} files...`
193
+ );
194
+
195
+ const results = {
196
+ created: 0,
197
+ existing: 0,
198
+ failed: 0,
199
+ errors: [],
200
+ };
201
+
202
+ try {
203
+ for (let index = 0; index < filesToCreate.length; index++) {
204
+ const fileObj = filesToCreate[index];
205
+ const fileProgress = `[${index + 1}/${filesToCreate.length}]`;
206
+
207
+ try {
208
+ console.log(`${fileProgress} šŸ” Processing ${fileObj.file_path}...`);
209
+
210
+ const storedChecksum = await getMappedChecksum(
211
+ config.directory_path,
212
+ fileObj.file_path,
213
+ config.tag
214
+ );
215
+
216
+ const baseDir = path.isAbsolute(config.directory_path)
217
+ ? config.directory_path
218
+ : path.join(process.cwd(), config.directory_path);
219
+
220
+ const fileFullPath = path.join(
221
+ baseDir,
222
+ config.platform,
223
+ "build",
224
+ "generated",
225
+ "airborne",
226
+ fileObj.file_path
227
+ );
228
+
229
+ if (!fs.existsSync(fileFullPath)) {
230
+ throw new Error(`File not found: ${fileFullPath}`);
231
+ }
232
+
233
+ if (storedChecksum) {
234
+ console.log(`${fileProgress} šŸ” Calculating checksum...`);
235
+ const checksum = await sha256FileHex(fileFullPath);
236
+
237
+ if (storedChecksum === checksum) {
238
+ console.log(
239
+ `${fileProgress} āœ… File already exists, checksum matches`
240
+ );
241
+ results.existing++;
242
+ continue;
243
+ }
244
+ }
245
+
246
+ console.log(
247
+ `${fileProgress} šŸ†• Creating file record for ${fileObj.file_path}...`
248
+ );
249
+
250
+ const fileUrl = prefixUrl + fileObj.file_path;
251
+ console.log(`${fileProgress} šŸ”— File URL: ${fileUrl}`);
252
+
253
+ const createOptions = {
254
+ file_path: fileObj.file_path,
255
+ url: fileUrl,
256
+ organisation: config.organisation,
257
+ application: config.namespace,
258
+ token: config.token,
259
+ tag: config.tag,
260
+ };
261
+
262
+ const output = await CreateFileAction(null, createOptions);
263
+
264
+ if (!output.file_path || !output.id) {
265
+ throw new Error(
266
+ "CreateFileAction failed, invalid response from server"
267
+ );
268
+ }
269
+
270
+ await createFileMapping(
271
+ config.directory_path,
272
+ output.file_path,
273
+ output.id,
274
+ output.checksum,
275
+ output.tag
276
+ );
277
+
278
+ console.log(
279
+ `${fileProgress} āœ… Successfully processed file record for ${fileObj.file_path}`
280
+ );
281
+ results.created++;
282
+ } catch (err) {
283
+ console.error(
284
+ `${fileProgress} 🚨 Error processing ${fileObj.file_path}:`,
285
+ err.message
286
+ );
287
+ results.failed++;
288
+ results.errors.push({ file: fileObj.file_path, error: err.message });
289
+ }
290
+ }
291
+
292
+ console.log("\nšŸ“Š File Creation Summary:");
293
+ console.log(`šŸ†• Created: ${results.created}`);
294
+ console.log(`ā™»ļø Existing: ${results.existing}`);
295
+ console.log(`āŒ Failed: ${results.failed}`);
296
+
297
+ if (results.errors.length > 0) {
298
+ console.log("\nšŸ“‹ Failed files:");
299
+ results.errors.forEach(({ file, error }) => {
300
+ console.log(` • ${file}: ${error}`);
301
+ });
302
+ }
303
+ } catch (err) {
304
+ console.error("\nšŸ’„ File creation process failed:", err.message);
305
+ throw err;
306
+ }
307
+ }
308
+
309
+ export async function createFileMapping(
310
+ directory_path,
311
+ file_path,
312
+ id,
313
+ checksum,
314
+ tag
315
+ ) {
316
+ const airborneDir = path.join(directory_path, ".airborne");
317
+ const mappingFile = path.join(airborneDir, "mappings.json");
318
+
319
+ try {
320
+ // Read existing mappings if file exists
321
+ let mappings = {};
322
+ try {
323
+ const data = await fs.promises.readFile(mappingFile, "utf8");
324
+ mappings = JSON.parse(data);
325
+ } catch (err) {
326
+ if (err.code !== "ENOENT") throw err; // ignore file not found
327
+ }
328
+ if (!tag) {
329
+ tag = "__default__";
330
+ }
331
+
332
+ if (!mappings[tag]) {
333
+ mappings[tag] = {};
334
+ }
335
+
336
+ // Update or insert mapping with checksum
337
+ mappings[tag][file_path] = { id, checksum };
338
+
339
+ // Write updated mappings back
340
+ await fs.promises.writeFile(
341
+ mappingFile,
342
+ JSON.stringify(mappings, null, 2),
343
+ "utf8"
344
+ );
345
+ } catch (error) {
346
+ console.error("āŒ Failed to update file mapping:", error.message);
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ export async function getMappedChecksum(directory_path, file_path, tag) {
352
+ const mappingFile = path.join(directory_path, ".airborne", "mappings.json");
353
+ if (!tag) {
354
+ tag = "__default__";
355
+ }
356
+
357
+ try {
358
+ const data = await fs.promises.readFile(mappingFile, "utf8");
359
+ const mappings = JSON.parse(data);
360
+ return mappings[tag][file_path]?.checksum || null;
361
+ } catch (err) {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ export async function readFileMapping(directory_path, file_path, tag) {
367
+ const mappingFile = path.join(directory_path, ".airborne", "mappings.json");
368
+
369
+ if (!tag) {
370
+ tag = "__default__";
371
+ }
372
+
373
+ try {
374
+ const data = await fs.promises.readFile(mappingFile, "utf8");
375
+ const mappings = JSON.parse(data);
376
+ return mappings[tag][file_path] || null;
377
+ } catch (err) {
378
+ return null;
379
+ }
380
+ }