ant-go 0.1.22

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,516 @@
1
+ /**
2
+ * build.js — `ant build`
3
+ *
4
+ * Flow:
5
+ * 1. Đọc app.json → lấy projectId + project info (bundleId, scheme, xcworkspace, xcodeproj)
6
+ * 2. Lấy Apple credentials (cache hoặc interactive login)
7
+ * 3. POST /builds { projectId } → server trả { jobId, tarUploadUrl, credsUploadUrl }
8
+ * 4. Pack project → upload ios.tar.gz
9
+ * 5. Tạo credentials.json → upload
10
+ * 6. POST /builds/:id/start
11
+ * 7. Poll status
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const os = require('os');
17
+ const https = require('https');
18
+ const http = require('http');
19
+ const { spawn } = require('child_process');
20
+ const chalk = require('chalk');
21
+ const ora = require('ora');
22
+
23
+ const CLI_VERSION = '1.0';
24
+
25
+ const { API_URL, loadConfig } = require('../config');
26
+ const { ensureToken } = require('./auth');
27
+ const { createClient, createBuild, getBuildStatus, fetchUserInfo, uploadAscKey, uploadCapabilities } = require('../api');
28
+ const { ensureAppleCreds } = require('../apple-creds');
29
+ const logger = require('../logger');
30
+ const { t, tError } = require('../i18n');
31
+
32
+ const STEP_LABELS = {
33
+ uploading: { vi: '☁️ CLI đang upload project...', en: '☁️ CLI is uploading project...' },
34
+ pending: { vi: '⏳ Đang chờ build server xử lý...', en: '⏳ Waiting for build server...' },
35
+ initialising: { vi: '🔧 Đang khởi tạo...', en: '🔧 Initialising...' },
36
+ setup_certs: { vi: '🔑 Đang setup certificates...', en: '🔑 Setting up certificates...' },
37
+ bundle_install: { vi: '💎 Đang cài Ruby gems...', en: '💎 Installing Ruby gems...' },
38
+ fastlane_build: { vi: '🏗️ Đang build IPA (Fastlane)...', en: '🏗️ Building IPA (Fastlane)...' },
39
+ };
40
+
41
+ // ── ant.json — build profiles ─────────────────────────────────────────────────
42
+ const DEFAULT_ANT_JSON = {
43
+ build: {
44
+ production: {
45
+ distribution: 'store',
46
+ },
47
+ development: {
48
+ developmentClient: true,
49
+ distribution: 'internal',
50
+ },
51
+ preview: {
52
+ distribution: 'internal',
53
+ },
54
+ },
55
+ };
56
+
57
+ function resolveAntJson(projectRoot, profileName) {
58
+ const antJsonPath = path.join(projectRoot, 'ant.json');
59
+
60
+ if (!fs.existsSync(antJsonPath)) {
61
+ fs.writeFileSync(antJsonPath, JSON.stringify(DEFAULT_ANT_JSON, null, 2));
62
+ console.log('');
63
+ console.log(chalk.cyan(t('antJsonCreated')));
64
+ Object.entries(DEFAULT_ANT_JSON.build).forEach(([name, cfg]) => {
65
+ const tags = [`distribution: ${cfg.distribution}`];
66
+ if (cfg.developmentClient) tags.push('developmentClient: true');
67
+ console.log(` ${chalk.cyan(name.padEnd(12))} ${tags.join(', ')}`);
68
+ });
69
+ console.log('');
70
+ }
71
+
72
+ let antJson;
73
+ try { antJson = JSON.parse(fs.readFileSync(antJsonPath, 'utf8')); }
74
+ catch { logger.error(t('antJsonParseFailed')); process.exit(1); }
75
+
76
+ const profiles = antJson.build || {};
77
+ const profile = profiles[profileName];
78
+
79
+ if (!profile) {
80
+ console.log('');
81
+ console.log(chalk.red(`✖ ${t('antJsonProfileNotFound', profileName)}`));
82
+ console.log('');
83
+ const names = Object.keys(profiles);
84
+ if (names.length > 0) {
85
+ console.log(t('antJsonProfilesAvailable'));
86
+ names.forEach(n => {
87
+ const cfg = profiles[n];
88
+ const tags = [`distribution: ${cfg.distribution || 'store'}`];
89
+ if (cfg.developmentClient) tags.push('developmentClient: true');
90
+ console.log(` ${chalk.cyan(('--profile ' + n).padEnd(24))} ${tags.join(', ')}`);
91
+ });
92
+ console.log('');
93
+ }
94
+ process.exit(1);
95
+ }
96
+
97
+ return {
98
+ distribution: profile.distribution || 'store',
99
+ developmentClient: !!profile.developmentClient,
100
+ };
101
+ }
102
+
103
+ // ── Đọc và kiểm tra app.json ──────────────────────────────────────────────────
104
+ function resolveProjectInfo(projectRoot) {
105
+ const appJsonPath = path.join(projectRoot, 'app.json');
106
+ if (!fs.existsSync(appJsonPath)) {
107
+ logger.error(t('appJsonNotFound', appJsonPath));
108
+ process.exit(1);
109
+ }
110
+
111
+ let appJson;
112
+ try { appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8')); }
113
+ catch { logger.error(t('appJsonParseFailed')); process.exit(1); }
114
+
115
+ appJson.expo ??= {};
116
+ appJson.expo.extra ??= {};
117
+ appJson.expo.extra.ant ??= {};
118
+
119
+ const ant = appJson.expo.extra.ant;
120
+ const projectId = ant.projectId;
121
+
122
+ if (!projectId?.trim()) {
123
+ if (projectId === undefined) {
124
+ ant.projectId = '';
125
+ fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2));
126
+ }
127
+ console.log('');
128
+ console.log(chalk.yellow(t('appJsonNoProjectId')));
129
+ console.log(` ${t('appJsonNoProjectIdHint')}\n`);
130
+ process.exit(0);
131
+ }
132
+
133
+ // Đọc project info từ app.json hoặc ios/ folder
134
+ const iosDir = path.join(projectRoot, 'ios');
135
+ const xcodeproj = ant.xcodeproj || findFile(iosDir, '.xcodeproj');
136
+ const xcworkspace = ant.xcworkspace || findFile(iosDir, '.xcworkspace') || (xcodeproj && xcodeproj.replace('.xcodeproj', '.xcworkspace')) || '';
137
+ const schemeName = ant.schemeName || (xcodeproj && xcodeproj.replace('.xcodeproj', '')) || appJson.expo?.slug || appJson.expo?.name || '';
138
+ const bundleId = ant.bundleId ?? appJson.expo?.ios?.bundleIdentifier ?? '';
139
+
140
+ if (!bundleId) {
141
+ logger.error(t('appJsonNoBundleId'));
142
+ process.exit(1);
143
+ }
144
+
145
+ // buildNumber: đọc từ expo.ios.buildNumber (đồng bộ EAS), nếu không có → null (server auto-increment)
146
+ const rawBN = appJson.expo?.ios?.buildNumber;
147
+ const buildNumber = rawBN != null && /^\d+$/.test(String(rawBN)) ? parseInt(String(rawBN), 10) : null;
148
+
149
+ return { projectId: projectId.trim(), bundleId, schemeName, xcworkspace, xcodeproj, buildNumber };
150
+ }
151
+
152
+ function findFile(dir, ext) {
153
+ if (!fs.existsSync(dir)) return '';
154
+ const found = fs.readdirSync(dir).find(f => f.endsWith(ext));
155
+ return found ?? '';
156
+ }
157
+
158
+ // ── Header box ────────────────────────────────────────────────────────────────
159
+ function printHeader(lines) {
160
+ const width = Math.max(36, ...lines.map(l => l.length + 6));
161
+ const sep = '='.repeat(width);
162
+ const row = text => {
163
+ const pad = width - 6 - text.length;
164
+ return `== ${text}${' '.repeat(Math.max(0, pad))} ==`;
165
+ };
166
+ console.log('');
167
+ console.log(chalk.bold.cyan(sep));
168
+ lines.forEach(l => console.log(chalk.bold.cyan(row(l))));
169
+ console.log(chalk.bold.cyan(sep));
170
+ console.log('');
171
+ }
172
+
173
+
174
+ // ── Pack project (async để spinner có thể quay) ───────────────────────────────
175
+ function packProject(projectRoot, tarFile) {
176
+ // Các thư mục luôn bị loại khỏi archive
177
+ const excludes = [
178
+ '--exclude=./ios',
179
+ '--exclude=./android',
180
+ '--exclude=./.expo',
181
+ '--exclude=./node_modules',
182
+ '--exclude=./.git',
183
+ ];
184
+
185
+ // Đọc .antignore nếu có, mỗi dòng là một pattern thêm vào exclude
186
+ // - Pattern bắt đầu bằng "/" → anchored at root → --exclude=./pattern
187
+ // - Pattern không có "/" đứng đầu → match ở mọi depth → --exclude=pattern
188
+ const antignorePath = path.join(projectRoot, '.antignore');
189
+ if (fs.existsSync(antignorePath)) {
190
+ const lines = fs.readFileSync(antignorePath, 'utf8').split('\n');
191
+ for (const line of lines) {
192
+ const pattern = line.trim();
193
+ if (!pattern || pattern.startsWith('#')) continue;
194
+ if (pattern.startsWith('/')) {
195
+ excludes.push(`--exclude=.${pattern}`); // /foo → ./foo
196
+ } else {
197
+ excludes.push(`--exclude=${pattern}`); // foo or *.ext → match any depth
198
+ }
199
+ }
200
+ }
201
+
202
+ // Convert Windows path (C:\foo) → POSIX path (/c/foo) cho bash trên Windows (Git Bash / MINGW)
203
+ const toPosix = p => p.replace(/^([A-Za-z]):[\\/]/, (_, d) => `/${d.toLowerCase()}/`).replace(/\\/g, '/');
204
+ const tarFilePosix = toPosix(tarFile);
205
+ const projectPosix = toPosix(projectRoot);
206
+
207
+ const cmd = `tar -czf "${tarFilePosix}" ${excludes.join(' ')} -C "${projectPosix}" .`;
208
+ return new Promise((resolve, reject) => {
209
+ const child = spawn('bash', ['-c', cmd], { stdio: 'pipe' });
210
+ let stderr = '';
211
+ child.stderr.on('data', d => { stderr += d.toString(); });
212
+ child.on('close', code => {
213
+ if (code === 0) resolve();
214
+ else reject(new Error(`tar exit ${code}: ${stderr.slice(-300)}`));
215
+ });
216
+ child.on('error', reject);
217
+ });
218
+ }
219
+
220
+ // ── Upload via signed URL ─────────────────────────────────────────────────────
221
+ // Dùng native https để stream pipe thẳng vào socket.
222
+ // Backpressure từ network làm data event chậm lại đúng tốc độ upload thực tế.
223
+ function uploadFile(uploadUrl, filePath, contentType, spinner) {
224
+ return new Promise((resolve, reject) => {
225
+ const fileSize = fs.statSync(filePath).size;
226
+ const totalMB = (fileSize / 1024 / 1024).toFixed(1);
227
+ let uploaded = 0;
228
+
229
+ const url = new URL(uploadUrl);
230
+ const lib = url.protocol === 'https:' ? https : http;
231
+
232
+ const req = lib.request({
233
+ hostname: url.hostname,
234
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
235
+ path: url.pathname + url.search,
236
+ method: 'PUT',
237
+ headers: { 'Content-Type': contentType, 'Content-Length': fileSize },
238
+ }, (res) => {
239
+ res.resume();
240
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve();
241
+ else reject(new Error(`Upload failed: HTTP ${res.statusCode}`));
242
+ });
243
+
244
+ req.on('error', reject);
245
+
246
+ const stream = fs.createReadStream(filePath);
247
+ stream.on('data', chunk => {
248
+ uploaded += chunk.length;
249
+ const pct = Math.round((uploaded / fileSize) * 100);
250
+ const uploadedMB = (uploaded / 1024 / 1024).toFixed(1);
251
+ spinner.text = t('buildUploadProgress', pct, uploadedMB, totalMB);
252
+ });
253
+ stream.on('error', reject);
254
+ stream.pipe(req);
255
+ });
256
+ }
257
+
258
+ // ── Main ──────────────────────────────────────────────────────────────────────
259
+ async function runBuild(options) {
260
+ const authToken = await ensureToken();
261
+
262
+ const platform = (options.platform || '').toLowerCase();
263
+ if (!platform) {
264
+ console.log('');
265
+ console.log(chalk.red(`✖ ${t('buildNoPlatform')}`));
266
+ console.log('');
267
+ console.log(` ${t('buildUsage')} ${chalk.cyan('ant build --platform <platform>')}`);
268
+ console.log('');
269
+ console.log(` ${t('buildPlatformSupported')}`);
270
+ console.log(` ${chalk.cyan('--platform ios')} Build iOS app (.ipa)`);
271
+ console.log(` ${chalk.cyan('--platform android')} Build Android app`);
272
+ console.log('');
273
+ console.log(` ${t('buildExample')}`);
274
+ console.log('');
275
+ process.exit(1);
276
+ }
277
+ if (!['ios', 'android'].includes(platform)) {
278
+ console.log('');
279
+ console.log(chalk.red(`✖ ${t('buildPlatformInvalid', options.platform)}`));
280
+ console.log('');
281
+ console.log(` ${t('buildPlatformOnly')}`);
282
+ console.log('');
283
+ process.exit(1);
284
+ }
285
+ const cfg = loadConfig();
286
+ const client = createClient(API_URL, authToken);
287
+
288
+ // Fetch fresh user info (plan, quota, devices)
289
+ const infoSpinner = ora(t('buildLoadingAccount')).start();
290
+ let userInfo;
291
+ try {
292
+ userInfo = await fetchUserInfo(client);
293
+ const creditsDisplay = userInfo.planCredits === -1
294
+ ? 'Unlimited'
295
+ : `${userInfo.credits.toFixed ? userInfo.credits.toFixed(1) : userInfo.credits}/${userInfo.planCredits}`;
296
+ infoSpinner.succeed(t('buildCreditsRemaining', chalk.cyan(userInfo.plan), chalk.bold(creditsDisplay)));
297
+ } catch (err) {
298
+ infoSpinner.fail(t('buildLoadAccountFailed'));
299
+ logger.error(tError(err.response?.data?.error ?? err.message));
300
+ process.exit(1);
301
+ }
302
+
303
+ if (userInfo.planCredits !== -1 && (userInfo.credits ?? 0) <= 0) {
304
+ console.log('');
305
+ console.log(chalk.red(`✖ ${t('buildOutOfCredits')}`));
306
+ console.log(chalk.gray(` ${t('buildOutOfCreditsHint')}`));
307
+ console.log('');
308
+ process.exit(1);
309
+ }
310
+ if (userInfo.planStatus === 'past_due') {
311
+ console.log(chalk.yellow(`⚠ ${t('buildPastDue')}`));
312
+ console.log(chalk.gray(' https://antgo.work/account/billing'));
313
+ console.log('');
314
+ }
315
+
316
+ const projectRoot = options.project
317
+ ? path.resolve(options.project)
318
+ : cfg.projectRoot ? path.resolve(cfg.projectRoot) : process.cwd();
319
+
320
+ const platformDir = platform === 'android' ? 'android' : 'ios';
321
+ if (!fs.existsSync(path.join(projectRoot, platformDir))) {
322
+ logger.error(t('buildNoPlatformDir', platformDir, projectRoot));
323
+ process.exit(1);
324
+ }
325
+
326
+ // 1. Đọc ant.json + app.json
327
+ const profileName = options.profile || 'production';
328
+ const profileConfig = resolveAntJson(projectRoot, profileName);
329
+ const { distribution, developmentClient } = profileConfig;
330
+
331
+ const autoSubmit = !!options.autoSubmit;
332
+
333
+ if (autoSubmit && distribution !== 'store') {
334
+ console.log('');
335
+ console.log(chalk.red(`✖ ${t('buildAutoSubmitStoreOnly')}`));
336
+ console.log(chalk.gray(` ${t('buildAutoSubmitProfileHint', profileName, distribution)}`));
337
+ console.log('');
338
+ process.exit(1);
339
+ }
340
+
341
+ const projectInfo = resolveProjectInfo(projectRoot);
342
+ const { buildNumber: configBuildNumber } = projectInfo;
343
+ printHeader([
344
+ `Project ID : ${projectInfo.projectId}`,
345
+ `Bundle ID : ${projectInfo.bundleId}`,
346
+ `Profile : ${profileName} (${distribution}${developmentClient ? ', devClient' : ''})`,
347
+ `Build # : ${configBuildNumber != null ? configBuildNumber : 'auto'}`,
348
+ ...(autoSubmit ? ['Auto Submit: TestFlight'] : []),
349
+ ]);
350
+
351
+ // 2. Apple credentials (iOS only)
352
+ let creds;
353
+ if (platform === 'ios') {
354
+ try {
355
+ creds = await ensureAppleCreds(projectInfo, {
356
+ force: !!options.reauth,
357
+ refreshProfile: !!options.refreshProfile,
358
+ distribution,
359
+ profileName,
360
+ userDevices: userInfo.devices,
361
+ apiClient: client,
362
+ projectRoot,
363
+ });
364
+ } catch (err) {
365
+ logger.error(t('buildAppleCredsError') + err.message);
366
+ process.exit(1);
367
+ }
368
+ }
369
+
370
+ // 3. Tạo build job → lấy 2 signed URLs (cùng folder trên Storage)
371
+ const spinner = ora(t('buildCreatingJob')).start();
372
+ let jobId, tarUrl, credsUrl;
373
+ try {
374
+ const res = await createBuild(client, {
375
+ projectId: projectInfo.projectId,
376
+ platform,
377
+ autoSubmit,
378
+ ...(configBuildNumber != null && { buildNumber: configBuildNumber }),
379
+ ...(creds?.teamId && { teamId: creds.teamId }),
380
+ });
381
+ jobId = res.jobId;
382
+ tarUrl = res.tarUrl;
383
+ credsUrl = res.credsUrl;
384
+ const resolvedBN = res.buildNumber;
385
+ spinner.succeed(t('buildJobCreated', chalk.bold(jobId), chalk.cyan(resolvedBN)));
386
+
387
+ // Upload ASC key lên dashboard (best-effort, không block build)
388
+ if (platform === 'ios' && creds?.ascKey && creds?.teamId) {
389
+ const { keyId, issuerId, privateKeyP8 } = creds.ascKey;
390
+ try {
391
+ await uploadAscKey(client, { teamId: creds.teamId, keyId, issuerId, privateKeyP8 });
392
+ console.log(chalk.green(t('buildAscKeySaved')));
393
+ } catch (err) {
394
+ console.log(chalk.yellow(t('buildAscKeyFailed')) + chalk.gray(err.response?.data?.error ?? err.message));
395
+ }
396
+ }
397
+
398
+ // Upload capabilities lên dashboard (best-effort)
399
+ if (platform === 'ios' && creds?.capabilities?.length > 0) {
400
+ try {
401
+ await uploadCapabilities(client, { projectId: projectInfo.projectId, capabilities: creds.capabilities });
402
+ } catch {}
403
+ }
404
+ } catch (err) {
405
+ spinner.fail(t('buildJobFailed'));
406
+ const msg = err.response?.data?.error ?? err.message;
407
+ const status = err.response?.status;
408
+
409
+ if (status === 404 && msg?.includes('không tồn tại')) {
410
+ console.log('');
411
+ console.log(chalk.red(` ✖ ${t('buildProjectNotFound', projectInfo.projectId)}`));
412
+ console.log('');
413
+ console.log(chalk.yellow(` ${t('buildProjectNotFoundHint1')}`));
414
+ console.log(` expo.extra.ant.projectId = "${projectInfo.projectId}"`);
415
+ console.log('');
416
+ console.log(` ${t('buildProjectNotFoundHint2')} ${chalk.cyan('https://antgo.work/account/apps')}`);
417
+ console.log(` ${t('buildProjectNotFoundHint3')}`);
418
+ } else {
419
+ logger.error(tError(msg));
420
+ }
421
+ console.log('');
422
+ process.exit(1);
423
+ }
424
+
425
+ const tmpDir = path.join(os.tmpdir(), `ant-go-${jobId}`);
426
+ fs.mkdirSync(tmpDir, { recursive: true });
427
+
428
+ // 4. Pack + upload tar.gz
429
+ const tarName = platform === 'android' ? 'android.tar.gz' : 'ios.tar.gz';
430
+ const tarFile = path.join(tmpDir, tarName);
431
+ const packSpinner = ora(t('buildPacking')).start();
432
+ try {
433
+ await packProject(projectRoot, tarFile);
434
+ const sizeMB = (fs.statSync(tarFile).size / 1024 / 1024).toFixed(1);
435
+ packSpinner.succeed(t('buildPackDone', sizeMB));
436
+ } catch (err) {
437
+ packSpinner.fail(t('buildPackFailed'));
438
+ logger.error(err.message);
439
+ fs.rmSync(tmpDir, { recursive: true, force: true });
440
+ process.exit(1);
441
+ }
442
+
443
+ const tarSpinner = ora(t('buildUploading', tarName)).start();
444
+ try {
445
+ await uploadFile(tarUrl, tarFile, 'application/gzip', tarSpinner);
446
+ tarSpinner.succeed(t('buildUploadDone', tarName));
447
+ } catch (err) {
448
+ tarSpinner.fail(t('buildUploadFailed', tarName));
449
+ logger.error(err.message);
450
+ process.exit(1);
451
+ }
452
+
453
+ // 5. Tạo + upload credentials.json (iOS only)
454
+ if (platform === 'ios') {
455
+ const credsFile = path.join(tmpDir, 'credentials.json');
456
+ fs.writeFileSync(credsFile, JSON.stringify({
457
+ p12Base64: creds.p12Base64,
458
+ p12Password: creds.p12Password,
459
+ mobileprovisionBase64: creds.mobileprovisionBase64,
460
+ bundleId: creds.bundleId,
461
+ teamId: creds.teamId,
462
+ schemeName: creds.schemeName,
463
+ xcworkspace: creds.xcworkspace,
464
+ xcodeproj: creds.xcodeproj,
465
+ distribution,
466
+ developmentClient,
467
+ ...(autoSubmit && { autoSubmit: true }),
468
+ ...(autoSubmit && creds?.ascKey && {
469
+ ascKeyId: creds.ascKey.keyId,
470
+ ascIssuerId: creds.ascKey.issuerId,
471
+ ascKeyContent: Buffer.from(creds.ascKey.privateKeyP8).toString('base64'),
472
+ }),
473
+ }));
474
+
475
+ const credsSpinner = ora(t('buildUploading', 'credentials.json')).start();
476
+ try {
477
+ await uploadFile(credsUrl, credsFile, 'application/json', credsSpinner);
478
+ credsSpinner.succeed(t('buildUploadDone', 'credentials.json'));
479
+ } catch (err) {
480
+ credsSpinner.fail(t('buildUploadFailed', 'credentials.json'));
481
+ logger.error(err.message);
482
+ process.exit(1);
483
+ } finally {
484
+ fs.rmSync(tmpDir, { recursive: true, force: true });
485
+ }
486
+ } else {
487
+ fs.rmSync(tmpDir, { recursive: true, force: true });
488
+ }
489
+
490
+ // 6. Notify server — verify 2 file đã tồn tại và đánh dấu pending
491
+ const startSpinner = ora(t('buildVerifyingFiles')).start();
492
+ try {
493
+ await client.post(`/api/builds/${jobId}/start`);
494
+ startSpinner.succeed(t('buildVerifyDone'));
495
+ } catch (err) {
496
+ startSpinner.fail(t('buildStartFailed'));
497
+ logger.error(tError(err.response?.data?.error ?? err.message));
498
+ process.exit(1);
499
+ }
500
+
501
+ // Hiện URL và thoát — user vào web xem log, CLI không poll gì thêm
502
+ const appUrl = `${API_URL}/account/app/${encodeURIComponent(projectInfo.schemeName)}/builds/${jobId}`;
503
+ console.log('');
504
+ console.log(chalk.bold('Build submitted to server!'));
505
+ if (autoSubmit) {
506
+ console.log(chalk.gray(' ✈ Auto Submit: on — IPA will be submitted to TestFlight automatically after build completes.'));
507
+ }
508
+ console.log('');
509
+ console.log(' Track progress at:');
510
+ console.log(` ${chalk.cyan.underline(appUrl)}`);
511
+ console.log('');
512
+ process.exit(0);
513
+ }
514
+
515
+ module.exports = { runBuild };
516
+
@@ -0,0 +1,31 @@
1
+ /**
2
+ * configure.js — `ant configure`
3
+ * User chỉ cần set projectRoot (optional, mặc định là cwd khi chạy build)
4
+ * Server URL được hardcode — user không cần biết.
5
+ */
6
+
7
+ const { saveConfig, loadConfig, CONFIG_FILE, API_URL } = require('../config');
8
+ const logger = require('../logger');
9
+ const chalk = require('chalk');
10
+ const { t } = require('../i18n');
11
+
12
+ async function configure(options) {
13
+ const updates = {};
14
+ if (options.project) updates.projectRoot = require('path').resolve(options.project);
15
+
16
+ if (!Object.keys(updates).length) {
17
+ const cfg = loadConfig();
18
+ console.log('');
19
+ console.log(` Config: ${chalk.gray(CONFIG_FILE)}`);
20
+ console.log(` Server: ${chalk.green(API_URL)} ${chalk.gray('(hardcoded)')}`);
21
+ console.log(` ${t('configureProjectLabel')} ${cfg.projectRoot ? chalk.green(cfg.projectRoot) : chalk.gray(t('configureProjectDefault'))}`);
22
+ console.log('');
23
+ console.log(` ${t('configureUsage')} ${chalk.cyan('ant configure --project <path>')}\n`);
24
+ return;
25
+ }
26
+
27
+ saveConfig(updates);
28
+ if (updates.projectRoot) logger.success(`Project: ${chalk.green(updates.projectRoot)}`);
29
+ }
30
+
31
+ module.exports = { configure };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * status.js — `ant status <jobId>`
3
+ */
4
+
5
+ const { API_URL } = require('../config');
6
+ const { createClient, getBuildStatus } = require('../api');
7
+ const { watchBuild } = require('./build');
8
+ const logger = require('../logger');
9
+ const chalk = require('chalk');
10
+ const { t, tError } = require('../i18n');
11
+
12
+ async function checkStatus(jobId, options) {
13
+ const client = createClient(API_URL);
14
+
15
+ let data;
16
+ try {
17
+ data = await getBuildStatus(client, jobId);
18
+ } catch (err) {
19
+ logger.error(tError(err.response?.data?.message ?? err.response?.data?.error ?? err.message));
20
+ process.exit(1);
21
+ }
22
+
23
+ console.log('');
24
+ console.log(` Job ID: ${chalk.bold(jobId)}`);
25
+ console.log(` Status: ${colorStatus(data.status)}`);
26
+ if (data.step) console.log(` Step: ${data.step}`);
27
+ if (data.createdAt) console.log(` Created: ${new Date(data.createdAt).toLocaleString()}`);
28
+ if (data.updatedAt) console.log(` Updated: ${new Date(data.updatedAt).toLocaleString()}`);
29
+ if (data.error) console.log(` Error: ${chalk.red(data.error)}`);
30
+ if (data.ipaUrl) console.log(` IPA: ${chalk.underline(data.ipaUrl)}`);
31
+ if (data.dsymUrl) console.log(` dSYM: ${chalk.underline(data.dsymUrl)}`);
32
+ console.log('');
33
+
34
+ if (data.status === 'pending' || data.status === 'running') {
35
+ logger.log(t('statusRunning') + '\n');
36
+ try { await watchBuild(client, jobId); } catch { process.exit(1); }
37
+ }
38
+ process.exit(0);
39
+ }
40
+
41
+ function colorStatus(s) {
42
+ const u = (s ?? '').toUpperCase();
43
+ if (s === 'success') return chalk.green.bold(u);
44
+ if (s === 'failed') return chalk.red.bold(u);
45
+ if (s === 'running') return chalk.cyan(u);
46
+ if (s === 'pending') return chalk.yellow(u);
47
+ return u || '(UNKNOWN)';
48
+ }
49
+
50
+ module.exports = { checkStatus };
package/src/config.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * config.js — Config phía user: lưu projectRoot (optional) và auth session
3
+ *
4
+ * Server URL được hardcode — user không cần biết / thiết lập.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ const API_URL = process.env.ANT_GO_API_URL || 'https://antgo.work';
12
+
13
+ const CONFIG_DIR = path.join(os.homedir(), '.ant-go');
14
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
15
+
16
+ function loadConfig() {
17
+ if (!fs.existsSync(CONFIG_FILE)) return {};
18
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
19
+ catch { return {}; }
20
+ }
21
+
22
+ function saveConfig(data) {
23
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
24
+ const current = loadConfig();
25
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...data }, null, 2), { mode: 0o600 });
26
+ }
27
+
28
+ // ── Auth helpers ──────────────────────────────────────────────────────────────
29
+
30
+ function getAuth() {
31
+ return loadConfig().auth ?? null;
32
+ }
33
+
34
+ function setAuth(data) {
35
+ saveConfig({ auth: data });
36
+ }
37
+
38
+ function clearAuth() {
39
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
40
+ const current = loadConfig();
41
+ delete current.auth;
42
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(current, null, 2), { mode: 0o600 });
43
+ }
44
+
45
+ function isLoggedIn() {
46
+ const session = getAuth();
47
+ if (!session?.token) return false;
48
+ if (!session.expiresAt) return true;
49
+ return new Date(session.expiresAt) > new Date();
50
+ }
51
+
52
+ function getLang() {
53
+ return loadConfig().lang === 'vi' ? 'vi' : 'en';
54
+ }
55
+
56
+ function setLang(lang) {
57
+ saveConfig({ lang });
58
+ }
59
+
60
+ function isFirstRun() {
61
+ return !loadConfig().firstRunDone;
62
+ }
63
+
64
+ function markFirstRunDone() {
65
+ saveConfig({ firstRunDone: true });
66
+ }
67
+
68
+ module.exports = { API_URL, loadConfig, saveConfig, CONFIG_FILE, getAuth, setAuth, clearAuth, isLoggedIn, getLang, setLang, isFirstRun, markFirstRunDone };