ota-manager 1.0.7 → 1.0.14

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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Modern mobile hybrid apps (built with Astro, Vite, or Capacitor) require a robus
17
17
  * šŸ”„ **Flexible CLI Shorthands**: Supports both `npx ota-manager` and `npx ota-updates`. Features lightning-fast shorthands like `npx ota-manager -d training` and `-d live`.
18
18
  * šŸ”€ **Multi-Provider & Multi-Channel Routing**: Built-in support for GitHub and GitLab strategies. Configure independent target repositories and branches for `training` vs `live` environments.
19
19
  * šŸ›”ļø **Size Guardian Protocol**: Pre-flight audit of your `dist/` directory and generated ZIP archive to prevent Zip Bombs (>50MB threshold protection).
20
- * šŸ’„ **Tsar Bomba Path Cleanse (`flatten-dist.cjs`)**: Post-build normalization of absolute asset paths (`/assets/`) to relative paths (`./assets/`) to guarantee flawless Capacitor WebView navigation.
20
+ * āš™ļø **Absolute Path Normalization (`flatten-dist.cjs`)**: Post-build normalization of absolute asset paths (`/assets/`) to relative paths (`./assets/`) to guarantee flawless Capacitor WebView navigation.
21
21
  * šŸ™ˆ **API Route Protection**: Automatically isolates and hides `/src/pages/api` during static export/build to prevent build failures, then restores them seamlessly.
22
22
  * šŸ” **Security Auditor (`ota-security.js`)**: Automated inspection of Personal Access Tokens (PAT) to prevent token leaks or overly broad repository access.
23
23
  * šŸ“” **E2E Connection Simulation**: Built-in `test` command to simulate push and read capabilities against your Git provider before executing actual deployments.
@@ -124,7 +124,7 @@ PUBLIC_OTA_UPDATE_URL=https://raw.githubusercontent.com/your-org/your-ota-repo/m
124
124
  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
125
125
  ā–¼
126
126
  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
127
- │ 3. Build & Tsar Bomba Cleanse: Normalizes /assets/ paths │
127
+ │ 3. Build & Path Normalization: Normalizes /assets/ paths │
128
128
  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
129
129
  ā–¼
130
130
  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
@@ -2,9 +2,12 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
4
  /**
5
- * SAPU JAGAT 4.0 (TSAR BOMBA):
5
+ * ABSOLUTE PATH NORMALIZATION UTILITY:
6
6
  * Menghancurkan SEMUA variasi "/assets/" dan ".//assets/" di seluruh file
7
7
  * tanpa peduli ada tanda kutip atau tidak.
8
+
9
+ Catatan: Mengubah jalur absolut menjadi relatif agar aplikasi Capacitor
10
+ dapat merender aset dengan benar di WebView lokal.
8
11
  */
9
12
  function fixPathsInFile(filePath) {
10
13
  let content = fs.readFileSync(filePath, 'utf8');
@@ -32,7 +35,7 @@ function fixPathsInFile(filePath) {
32
35
 
33
36
  if (content !== fixedContent) {
34
37
  fs.writeFileSync(filePath, fixedContent);
35
- console.log(` šŸ’£ TSAR BOMBA FIX in: ${path.basename(filePath)}`);
38
+ console.log(` āš™ļø PATH NORMALIZATION in: ${path.basename(filePath)}`);
36
39
  }
37
40
  }
38
41
 
@@ -70,7 +73,7 @@ function flatten(dir, rootDir = dir) {
70
73
 
71
74
  const distPath = path.join(process.cwd(), 'dist');
72
75
  if (fs.existsSync(distPath)) {
73
- console.log('šŸš€ SAPU JAGAT 4.0: Launching Tsar Bomba Path Cleanse...');
76
+ console.log('šŸš€ PATH NORMALIZATION: Running absolute path normalization...');
74
77
  flatten(distPath);
75
78
  console.log('āœ… ALL PATHS ARE NOW 100% PURE RELATIVE!');
76
79
  } else {
package/lib/ota-build.cjs CHANGED
@@ -1,280 +1,508 @@
1
- const { execSync } from 'child_process';
2
- const fs = require('fs');
3
- const path = require('path');
4
- const https = require('https');
5
-
6
- const rootDir = process.cwd();
7
- const envPath = path.join(rootDir, '.env');
8
- if (fs.existsSync(envPath)) {
9
- const envContent = fs.readFileSync(envPath, 'utf8');
10
- envContent.split('\n').forEach(line => {
11
- const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
12
- if (match) {
13
- const key = match[1];
14
- let value = match[2] || '';
15
- if (value.length > 0 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
16
- if (value.length > 0 && value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
17
- process.env[key] = value.trim();
18
- }
19
- });
20
- }
21
-
22
- const argTarget = process.argv[2]; // 'ios' or 'android'
23
- const GITHUB_TOKEN = process.env.GITHUB_DEV_PAT;
24
- const REPO = "firstryan-sbr/180spm";
25
- const WORKFLOW_ID = "ios-build.yml";
26
-
27
- async function run() {
28
- if (!argTarget) {
29
- console.log('\nāŒ Error: Please specify target (ios or android)');
30
- console.log('šŸ’” Usage: npx ota-updates build ios\n');
31
- process.exit(1);
32
- }
33
-
34
- syncBranding();
35
-
36
- if (argTarget === 'ios') {
37
- await buildIos();
38
- } else if (argTarget === 'android') {
39
- await buildAndroid();
40
- } else {
41
- console.log(`āŒ Error: Unknown target '${argTarget}'`);
42
- process.exit(1);
43
- }
44
- }
45
-
46
- async function buildIos() {
47
- if (!GITHUB_TOKEN) {
48
- console.log('āŒ Error: GITHUB_DEV_PAT missing in .env');
49
- process.exit(1);
50
- }
51
-
52
- console.log('\nšŸ --- STARTING INTELLIGENT iOS BUILD ---');
53
-
54
- const branch = execSync('git branch --show-current').toString().trim();
55
- console.log(`🌿 Branch: ${branch}`);
56
-
57
- console.log('šŸ” Checking for active builds on GitHub...');
58
- const activeRuns = await githubApi(`/repos/${REPO}/actions/runs?status=in_progress&branch=${branch}&workflow=${WORKFLOW_ID}`);
59
- const queuedRuns = await githubApi(`/repos/${REPO}/actions/runs?status=queued&branch=${branch}&workflow=${WORKFLOW_ID}`);
60
-
61
- let runId = null;
62
- if ((activeRuns.workflow_runs && activeRuns.workflow_runs.length > 0) || (queuedRuns.workflow_runs && queuedRuns.workflow_runs.length > 0)) {
63
- const existing = (activeRuns.workflow_runs && activeRuns.workflow_runs[0]) || (queuedRuns.workflow_runs && queuedRuns.workflow_runs[0]);
64
- runId = existing.id;
65
- console.log(`āš ļø Build already in progress (ID: ${runId}). Joining existing instance...`);
66
- } else {
67
- console.log('šŸ“¦ Checking for changes to push...');
68
- try {
69
- execSync('git add .', { stdio: 'ignore' });
70
- const status = execSync('git status --porcelain').toString();
71
- if (status) {
72
- console.log('šŸ“¤ Pushing changes to GitHub...');
73
- execSync('git commit -m "chore: automated build sync"', { stdio: 'ignore' });
74
- execSync('git push origin development', { stdio: 'ignore' });
75
- console.log('āœ… Changes pushed! Triggering new build...');
76
- await sleep(2000);
77
- } else {
78
- console.log('āœ… No local changes. Checking if we need to trigger manually...');
79
- }
80
- } catch (e) {
81
- console.log('āš ļø Git push skipped (maybe no changes).');
82
- }
83
-
84
- console.log('šŸš€ Triggering GitHub Action...');
85
- try {
86
- await githubApi(`/repos/${REPO}/actions/workflows/${WORKFLOW_ID}/dispatches`, 'POST', {
87
- ref: branch
88
- });
89
- console.log('āœ… Trigger Success! Waiting for run to start...');
90
- } catch (e) {
91
- console.error('āŒ Failed to trigger build:', e.message);
92
- process.exit(1);
93
- }
94
- }
95
-
96
- let attempts = 0;
97
- while (!runId && attempts < 10) {
98
- await sleep(3000);
99
- const runs = await githubApi(`/repos/${REPO}/actions/runs?branch=${branch}&per_page=5`);
100
- if (runs.workflow_runs && runs.workflow_runs.length > 0) {
101
- const latest = runs.workflow_runs[0];
102
- if (latest.status !== 'completed') {
103
- runId = latest.id;
104
- console.log(`šŸ†” Run ID: ${runId}`);
105
- }
106
- }
107
- attempts++;
108
- }
109
-
110
- if (!runId) {
111
- console.log('āŒ Could not find the started run. Please check GitHub Actions web UI.');
112
- process.exit(1);
113
- }
114
-
115
- console.log('ā³ Monitoring Progress (This may take 5-10 minutes)...');
116
- let status = 'queued';
117
- let startTime = Date.now();
118
-
119
- while (status !== 'completed') {
120
- await sleep(5000);
121
- const runData = await githubApi(`/repos/${REPO}/actions/runs/${runId}`);
122
- status = runData.status;
123
- const conclusion = runData.conclusion;
124
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
125
-
126
- process.stdout.write(`\ršŸ”„ Status: [${status.toUpperCase()}] | Time: ${elapsed}s ... `);
127
-
128
- if (status === 'completed') {
129
- console.log('\n');
130
- if (conclusion === 'success') {
131
- console.log('✨ iOS BUILD SUCCESS!');
132
- await downloadArtifact(runId);
133
- } else {
134
- console.log(`āŒ iOS BUILD FAILED (Conclusion: ${conclusion})`);
135
- console.log(`šŸ”— Log: https://github.com/${REPO}/actions/runs/${runId}`);
136
- process.exit(1);
137
- }
138
- }
139
- }
140
- }
141
-
142
- async function downloadArtifact(runId) {
143
- console.log('šŸ“„ Finding artifact to download...');
144
- const artifactsData = await githubApi(`/repos/${REPO}/actions/runs/${runId}/artifacts`);
145
-
146
- if (!artifactsData.artifacts || artifactsData.artifacts.length === 0) {
147
- console.log('āŒ No artifacts found for this run.');
148
- return;
149
- }
150
-
151
- const artifact = artifactsData.artifacts[0];
152
- const targetDir = path.join(rootDir, 'ios', 'release');
153
- if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
154
-
155
- const targetFile = path.join(targetDir, 'ios-build.zip');
156
-
157
- console.log(`šŸ“„ Downloading ${artifact.name} to ${targetFile}...`);
158
-
159
- try {
160
- execSync(`curl.exe -L -H "Authorization: token ${GITHUB_TOKEN}" -o "${targetFile}" "${artifact.archive_download_url}"`, { stdio: 'inherit' });
161
- console.log('\nāœ… Download Complete!');
162
- console.log(`šŸ“¦ Path: ${targetFile}`);
163
- console.log('šŸ’” Unzip and use Sideloadly to install on iPhone.\n');
164
- } catch (e) {
165
- console.error('āŒ Download failed:', e.message);
166
- }
167
- }
168
-
169
- async function buildAndroid() {
170
- console.log('\nšŸ¤– --- STARTING LOCAL ANDROID BUILD ---');
171
- console.log('āš ļø Note: This requires Java JDK and Android Studio/SDK installed.');
172
-
173
- try {
174
- console.log('šŸ—ļø Building Astro & Syncing Capacitor...');
175
-
176
- const apiDir = path.join(rootDir, 'src', 'pages', 'api');
177
- const apiBackupDir = path.join(rootDir, 'src', '_api-backup');
178
- let apiHidden = false;
179
-
180
- if (fs.existsSync(apiDir)) {
181
- console.log('šŸ™ˆ Hiding API routes to allow static build...');
182
- if (fs.existsSync(apiBackupDir)) fs.rmSync(apiBackupDir, { recursive: true, force: true });
183
- fs.renameSync(apiDir, apiBackupDir);
184
- apiHidden = true;
185
- }
186
-
187
- try {
188
- execSync('npm run build', { stdio: 'inherit', cwd: rootDir, env: process.env });
189
- } finally {
190
- if (apiHidden && fs.existsSync(apiBackupDir)) {
191
- console.log('🐵 Restoring API routes...');
192
- fs.renameSync(apiBackupDir, apiDir);
193
- }
194
- }
195
-
196
- execSync('npx cap sync android', { stdio: 'inherit', cwd: rootDir, env: process.env });
197
-
198
- console.log('ā˜• Running Gradle Build (AssembleDebug)...');
199
- const gradleCmd = process.platform === 'win32' ? '.\\gradlew.bat' : './gradlew';
200
- const androidDir = path.join(rootDir, 'android');
201
-
202
- execSync(`${gradleCmd} assembleDebug`, { stdio: 'inherit', cwd: androidDir });
203
-
204
- const apkPath = path.join(androidDir, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
205
- const targetDir = path.join(rootDir, 'android', 'release');
206
- if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
207
-
208
- const finalApk = path.join(targetDir, `180spm-v${process.env.PUBLIC_APP_VERSION || 'debug'}.apk`);
209
- fs.copyFileSync(apkPath, finalApk);
210
-
211
- console.log('\nāœ… ANDROID BUILD SUCCESS!');
212
- console.log(`šŸ“¦ Path: ${finalApk}\n`);
213
- } catch (e) {
214
- console.error('\nāŒ Android Build Failed:', e.message);
215
- process.exit(1);
216
- }
217
- }
218
-
219
- function githubApi(endpoint, method = 'GET', data = null) {
220
- return new Promise((resolve, reject) => {
221
- const options = {
222
- hostname: 'api.github.com',
223
- path: endpoint,
224
- method: method,
225
- headers: {
226
- 'Authorization': `token ${GITHUB_TOKEN}`,
227
- 'User-Agent': 'OTA-Manager-CLI',
228
- 'Accept': 'application/vnd.github.v3+json',
229
- 'Content-Type': 'application/json'
230
- }
231
- };
232
-
233
- const req = https.request(options, (res) => {
234
- let body = '';
235
- res.on('data', (chunk) => body += chunk);
236
- res.on('end', () => {
237
- if (res.statusCode >= 400) {
238
- return reject(new Error(`GitHub API Error: ${res.statusCode} - ${body}`));
239
- }
240
- try {
241
- resolve(body ? JSON.parse(body) : {});
242
- } catch (e) {
243
- resolve({});
244
- }
245
- });
246
- });
247
-
248
- req.on('error', (e) => reject(e));
249
- if (data) req.write(JSON.stringify(data));
250
- req.end();
251
- });
252
- }
253
-
254
- function syncBranding() {
255
- const appName = process.env.PUBLIC_APP_NAME || '180spm';
256
- console.log(`✨ Syncing Branding: [${appName}]`);
257
-
258
- try {
259
- const androidStringsPath = path.join(rootDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml');
260
- if (fs.existsSync(androidStringsPath)) {
261
- let content = fs.readFileSync(androidStringsPath, 'utf8');
262
- content = content.replace(/<string name="app_name">.*?<\/string>/, `<string name="app_name">${appName}<\/string>`);
263
- fs.writeFileSync(androidStringsPath, content);
264
- }
265
-
266
- const iosPlistPath = path.join(rootDir, 'ios', 'App', 'App', 'Info.plist');
267
- if (fs.existsSync(iosPlistPath)) {
268
- let content = fs.readFileSync(iosPlistPath, 'utf8');
269
- content = content.replace(/<key>CFBundleDisplayName<\/key>\s*<string>.*?<\/string>/, `<key>CFBundleDisplayName<\/key>\n\t<string>${appName}<\/string>`);
270
- fs.writeFileSync(iosPlistPath, content);
271
- }
272
- console.log('āœ… Branding synchronized across platforms.');
273
- } catch (e) {
274
- console.error('āš ļø Warning: Failed to sync branding:', e.message);
275
- }
276
- }
277
-
278
- function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
279
-
280
- run();
1
+ const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const https = require('https');
5
+ const readline = require('readline');
6
+ const crypto = require('crypto');
7
+
8
+ function confirm(message) {
9
+ return new Promise((resolve) => {
10
+ const rl = readline.createInterface({
11
+ input: process.stdin,
12
+ output: process.stdout
13
+ });
14
+ rl.question(`\nāš ļø ${message} [y/N]: `, (answer) => {
15
+ rl.close();
16
+ resolve(answer.toLowerCase() === 'y');
17
+ });
18
+ });
19
+ }
20
+
21
+ function getDirHash(dirPath) {
22
+ if (!fs.existsSync(dirPath)) return '';
23
+ const hash = crypto.createHash('sha256');
24
+ const files = fs.readdirSync(dirPath, { recursive: true });
25
+ files.sort().forEach(file => {
26
+ const fullPath = path.join(dirPath, file);
27
+ const stats = fs.statSync(fullPath);
28
+ if (stats.isFile()) {
29
+ hash.update(file);
30
+ hash.update(fs.readFileSync(fullPath));
31
+ }
32
+ });
33
+ return hash.digest('hex');
34
+ }
35
+
36
+ const rootDir = process.cwd();
37
+ const envPath = path.join(rootDir, '.env');
38
+ if (fs.existsSync(envPath)) {
39
+ const envContent = fs.readFileSync(envPath, 'utf8');
40
+ envContent.split('\n').forEach(line => {
41
+ const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
42
+ if (match) {
43
+ const key = match[1];
44
+ let value = match[2] || '';
45
+ if (value.length > 0 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
46
+ if (value.length > 0 && value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
47
+ process.env[key] = value.trim();
48
+ }
49
+ });
50
+ }
51
+
52
+ const rawArgs = process.argv.slice(2).map(a => a.toLowerCase());
53
+ const hasNv = rawArgs.includes('-nv') || rawArgs.includes('--nv') || rawArgs.includes('--no-version') || rawArgs.includes('nover') || rawArgs.includes('no-ver') || rawArgs.includes('-no') || rawArgs.includes('--no') || rawArgs.includes('no') || rawArgs.includes('-norev') || rawArgs.includes('--norev') || rawArgs.includes('norev');
54
+
55
+ const cleanArgs = rawArgs.filter(a => !a.startsWith('-') && a !== 'nover' && a !== 'no-ver' && a !== 'no' && a !== 'norev');
56
+ const rawTarget = cleanArgs[0] || '';
57
+ const rawType = cleanArgs[1] || '';
58
+
59
+ let argTarget = rawTarget;
60
+ let buildType = rawType;
61
+ if (rawTarget === 'apk' || rawTarget === 'aab' || rawTarget === 'bundle') {
62
+ argTarget = 'android';
63
+ if (rawTarget === 'aab' || rawTarget === 'bundle') buildType = 'bundle';
64
+ }
65
+ if (rawTarget === 'ipa') argTarget = 'ios';
66
+ const GITHUB_TOKEN = process.env.GITHUB_DEV_PAT;
67
+ const REPO = "firstryan-sbr/180spm";
68
+ const WORKFLOW_ID = "ios-build.yml";
69
+
70
+ async function run() {
71
+ if (!argTarget) {
72
+ console.log('\nāŒ Error: Please specify target (ios or android)');
73
+ console.log('šŸ’” Usage: npx ota-manager build ios\n');
74
+ process.exit(1);
75
+ }
76
+
77
+ syncBranding();
78
+
79
+ const otaConfigPath = path.join(rootDir, 'ota-config.json');
80
+ let strategy = 'github';
81
+ let repoUrl = '';
82
+ if (fs.existsSync(otaConfigPath)) {
83
+ const otaConfig = JSON.parse(fs.readFileSync(otaConfigPath, 'utf8'));
84
+ strategy = otaConfig.strategy || 'github';
85
+ repoUrl = otaConfig[strategy]?.repo || '';
86
+ }
87
+
88
+ const otaReleasesDir = path.join(rootDir, 'ota-releases');
89
+ if (repoUrl) {
90
+ console.log(`šŸ“‚ Preparing remote OTA repository to synchronize version & hash...`);
91
+ const pat = process.env.GITHUB_DEV_PAT || process.env.GITLAB_DEV_PAT || '';
92
+ const cloneRepo = repoUrl.endsWith('.git') ? repoUrl : repoUrl + '.git';
93
+ const authRepo = cloneRepo.replace('https://', `https://${pat}@`);
94
+ if (fs.existsSync(otaReleasesDir)) fs.rmSync(otaReleasesDir, { recursive: true, force: true });
95
+ try {
96
+ execSync(`git clone --depth 1 ${authRepo} "${otaReleasesDir}"`, { stdio: 'ignore' });
97
+ } catch (e) {
98
+ console.log('āš ļø Could not clone remote repo, falling back to existing manifest if available.');
99
+ }
100
+ }
101
+
102
+ const manifestPath = path.join(otaReleasesDir, 'manifest.json');
103
+ let previousVersion = process.env.PUBLIC_APP_VERSION_ANDROID || '0.1.9.0';
104
+ let previousHash = null;
105
+ if (fs.existsSync(manifestPath)) {
106
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
107
+ previousVersion = manifest.version || manifest.live?.version || previousVersion;
108
+ previousHash = manifest.hash || manifest.live?.hash || null;
109
+ }
110
+
111
+ console.log('šŸ—ļø Building project for Smart Diff Check...');
112
+ const apiDir = path.join(rootDir, 'src', 'pages', 'api');
113
+ const apiBackupDir = path.join(rootDir, 'src', '_api-backup');
114
+ let apiHidden = false;
115
+ if (fs.existsSync(apiDir)) {
116
+ console.log('šŸ™ˆ Hiding API routes (Using Robust Move)...');
117
+ if (fs.existsSync(apiBackupDir)) fs.rmSync(apiBackupDir, { recursive: true, force: true });
118
+ if (process.platform === 'win32') {
119
+ try { execSync(`robocopy "${apiDir}" "${apiBackupDir}" /E /MOVE /NFL /NDL /NJH /NJS`, { stdio: 'ignore' }); } catch (e) { if (fs.existsSync(apiDir)) throw e; }
120
+ } else {
121
+ fs.renameSync(apiDir, apiBackupDir);
122
+ }
123
+ apiHidden = true;
124
+ }
125
+ try {
126
+ execSync('npm run build', { stdio: 'inherit', cwd: rootDir, env: process.env });
127
+ } finally {
128
+ if (apiHidden && fs.existsSync(apiBackupDir)) {
129
+ console.log('🐵 Restoring API routes...');
130
+ if (process.platform === 'win32') {
131
+ try { execSync(`robocopy "${apiBackupDir}" "${apiDir}" /E /MOVE /NFL /NDL /NJH /NJS`, { stdio: 'ignore' }); } catch (e) { if (fs.existsSync(apiBackupDir)) throw e; }
132
+ } else {
133
+ fs.renameSync(apiBackupDir, apiDir);
134
+ }
135
+ }
136
+ }
137
+
138
+ console.log('🧠 Calculating SHA-256 Hash of dist/ artifacts...');
139
+ const distDir = path.join(rootDir, 'dist');
140
+ const currentBuildHash = getDirHash(distDir);
141
+ console.log(`šŸ”‘ Current Build Hash : ${currentBuildHash.substring(0, 16)}...`);
142
+ if (previousHash) console.log(`šŸ”’ Previous Release Hash: ${previousHash.substring(0, 16)}...`);
143
+
144
+ let activeVersion = previousVersion;
145
+ let isChanged = (!previousHash || previousHash !== currentBuildHash);
146
+
147
+ if (hasNv) {
148
+ activeVersion = process.env.PUBLIC_APP_VERSION || process.env.PUBLIC_APP_VERSION_ANDROID || previousVersion;
149
+ console.log(`\nšŸ›”ļø [FLAG -nv DETECTED] Mempertahankan versi lokal saat ini: ${activeVersion} (No version increment)`);
150
+ } else if (!isChanged) {
151
+ console.log(`\nā„¹ļø [SMART DIFF CHECKER] No code changes detected in build artifacts.`);
152
+ console.log(`šŸ“„ Version maintained at: ${previousVersion} (No version increment)`);
153
+
154
+ const ext = (buildType === 'bundle' || buildType === 'aab') ? 'aab' : 'apk';
155
+ const existingPath = argTarget === 'android'
156
+ ? path.join(rootDir, 'android', 'release', `180spm-${previousVersion}.${ext}`)
157
+ : path.join(rootDir, 'ios', 'release', 'ios-build.zip');
158
+
159
+ console.log(`šŸ“¦ ${argTarget.toUpperCase()} file for this version is already available at:\n šŸ‘‰ ${existingPath}`);
160
+
161
+ const proceed = await confirm(`Do you want to force rebuild ${argTarget.toUpperCase()} anyway?`);
162
+ if (!proceed) {
163
+ console.log('\nāŒ Build cancelled. Please use the existing file at the specified path.\n');
164
+ process.exit(0);
165
+ }
166
+ } else {
167
+ const parts = previousVersion.split('.');
168
+ const lastIdx = parts.length - 1;
169
+ const oldPart = parts[lastIdx];
170
+ const oldLen = oldPart.length;
171
+ parts[lastIdx] = (parseInt(oldPart) + 1).toString().padStart(oldLen, '0');
172
+ const nextVersion = parts.join('.');
173
+
174
+ console.log(`\nšŸ” Code changes detected!`);
175
+ console.log(`šŸ“ˆ Version will increment: ${previousVersion} -> ${nextVersion}`);
176
+
177
+ const proceed = await confirm(`Do you want to proceed building ${argTarget.toUpperCase()} with the new version (${nextVersion})?`);
178
+ if (!proceed) {
179
+ console.log('\nāŒ Build cancelled.\n');
180
+ process.exit(0);
181
+ }
182
+
183
+ activeVersion = nextVersion;
184
+ console.log(`šŸ“ Updating .env & manifest to version ${activeVersion}...`);
185
+ const envPath = path.join(rootDir, '.env');
186
+ if (fs.existsSync(envPath)) {
187
+ let envContent = fs.readFileSync(envPath, 'utf8');
188
+ envContent = envContent.replace(/PUBLIC_APP_VERSION_ANDROID=.*/, `PUBLIC_APP_VERSION_ANDROID=${activeVersion}`);
189
+ envContent = envContent.replace(/PUBLIC_APP_VERSION_IOS=.*/, `PUBLIC_APP_VERSION_IOS=${activeVersion}`);
190
+ if (envContent.includes('PUBLIC_APP_VERSION=')) {
191
+ envContent = envContent.replace(/PUBLIC_APP_VERSION=.*/, `PUBLIC_APP_VERSION=${activeVersion}`);
192
+ }
193
+ fs.writeFileSync(envPath, envContent);
194
+ process.env.PUBLIC_APP_VERSION = activeVersion;
195
+ }
196
+ const mainManifestPath = path.join(rootDir, 'src', 'data', 'update-data.json');
197
+ if (fs.existsSync(mainManifestPath)) {
198
+ const manifest = JSON.parse(fs.readFileSync(mainManifestPath, 'utf8'));
199
+ manifest.version = activeVersion;
200
+ fs.writeFileSync(mainManifestPath, JSON.stringify(manifest, null, 2));
201
+ }
202
+ const pkgJsonPath = path.join(rootDir, 'package.json');
203
+ if (fs.existsSync(pkgJsonPath)) {
204
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
205
+ pkgJson.version = activeVersion;
206
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
207
+ }
208
+ }
209
+
210
+ if (argTarget === 'ios') {
211
+ await buildIos();
212
+ } else if (argTarget === 'android') {
213
+ await buildAndroid(activeVersion, buildType);
214
+ } else {
215
+ console.log(`āŒ Error: Unknown target '${argTarget}'`);
216
+ process.exit(1);
217
+ }
218
+ }
219
+
220
+ async function buildIos() {
221
+ if (!GITHUB_TOKEN) {
222
+ console.log('āŒ Error: GITHUB_DEV_PAT missing in .env');
223
+ process.exit(1);
224
+ }
225
+
226
+ const activeWorkflowId = buildType === 'release' ? 'ios-release.yml' : 'ios-build.yml';
227
+ const buildModeName = buildType === 'release' ? 'App Store Release' : 'Free Sideloading';
228
+ console.log(`\nšŸ --- STARTING INTELLIGENT iOS BUILD [${buildModeName.toUpperCase()}] ---`);
229
+
230
+ const branch = execSync('git branch --show-current').toString().trim();
231
+ console.log(`🌿 Branch: ${branch}`);
232
+
233
+ console.log('šŸ” Checking for active builds on GitHub...');
234
+ const activeRuns = await githubApi(`/repos/${REPO}/actions/runs?status=in_progress&branch=${branch}&workflow=${activeWorkflowId}`);
235
+ const queuedRuns = await githubApi(`/repos/${REPO}/actions/runs?status=queued&branch=${branch}&workflow=${activeWorkflowId}`);
236
+
237
+ let runId = null;
238
+ if ((activeRuns.workflow_runs && activeRuns.workflow_runs.length > 0) || (queuedRuns.workflow_runs && queuedRuns.workflow_runs.length > 0)) {
239
+ const existing = (activeRuns.workflow_runs && activeRuns.workflow_runs[0]) || (queuedRuns.workflow_runs && queuedRuns.workflow_runs[0]);
240
+ runId = existing.id;
241
+ console.log(`āš ļø Build already in progress (ID: ${runId}). Joining existing instance...`);
242
+ } else {
243
+ console.log('šŸ“¦ Checking for changes to push...');
244
+ try {
245
+ execSync('git add .', { stdio: 'ignore' });
246
+ const status = execSync('git status --porcelain').toString();
247
+ if (status) {
248
+ console.log('šŸ“¤ Pushing changes to GitHub...');
249
+ execSync('git commit -m "chore: automated build sync"', { stdio: 'ignore' });
250
+ execSync(`git push origin ${branch}`, { stdio: 'ignore' });
251
+ console.log('āœ… Changes pushed!');
252
+ await sleep(2000);
253
+ } else {
254
+ console.log('āœ… No local file changes. Ensuring active branch ref is pushed to remote...');
255
+ execSync(`git push origin ${branch}`, { stdio: 'ignore' });
256
+ }
257
+ } catch (e) {
258
+ console.log('āš ļø Git push skipped or branch already up to date on remote.');
259
+ }
260
+
261
+ console.log('šŸš€ Triggering GitHub Action...');
262
+ try {
263
+ const payload = { ref: branch };
264
+ if (activeWorkflowId === 'ios-build.yml') {
265
+ payload.inputs = {
266
+ channel: process.env.PUBLIC_APP_CHANNEL || 'training',
267
+ api_url: process.env.PUBLIC_API_URL || '',
268
+ github_ota_pat: process.env.PUBLIC_GITHUB_OTA_PAT || '',
269
+ ota_update_url: process.env.PUBLIC_OTA_UPDATE_URL || ''
270
+ };
271
+ }
272
+ await githubApi(`/repos/${REPO}/actions/workflows/${activeWorkflowId}/dispatches`, 'POST', payload);
273
+ console.log('āœ… Trigger Success! Waiting for run to start...');
274
+ } catch (e) {
275
+ console.error('āŒ Failed to trigger build:', e.message);
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ let attempts = 0;
281
+ while (!runId && attempts < 10) {
282
+ await sleep(3000);
283
+ const runs = await githubApi(`/repos/${REPO}/actions/runs?branch=${branch}&per_page=5`);
284
+ if (runs.workflow_runs && runs.workflow_runs.length > 0) {
285
+ const latest = runs.workflow_runs[0];
286
+ if (latest.status !== 'completed') {
287
+ runId = latest.id;
288
+ console.log(`šŸ†” Run ID: ${runId}`);
289
+ }
290
+ }
291
+ attempts++;
292
+ }
293
+
294
+ if (!runId) {
295
+ console.log('āŒ Could not find the started run. Please check GitHub Actions web UI.');
296
+ process.exit(1);
297
+ }
298
+
299
+ console.log('ā³ Monitoring Progress (This may take 5-10 minutes)...');
300
+ let status = 'queued';
301
+ let startTime = Date.now();
302
+
303
+ while (status !== 'completed') {
304
+ await sleep(5000);
305
+ const runData = await githubApi(`/repos/${REPO}/actions/runs/${runId}`);
306
+ status = runData.status;
307
+ const conclusion = runData.conclusion;
308
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
309
+
310
+ process.stdout.write(`\ršŸ”„ Status: [${status.toUpperCase()}] | Time: ${elapsed}s ... `);
311
+
312
+ if (status === 'completed') {
313
+ console.log('\n');
314
+ if (conclusion === 'success') {
315
+ console.log(`✨ iOS BUILD SUCCESS [${buildModeName.toUpperCase()}]!`);
316
+ await downloadArtifact(runId);
317
+ } else {
318
+ console.log(`āŒ iOS BUILD FAILED (Conclusion: ${conclusion})`);
319
+ console.log(`šŸ”— Log: https://github.com/${REPO}/actions/runs/${runId}`);
320
+ process.exit(1);
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ async function downloadArtifact(runId) {
327
+ console.log('šŸ“„ Finding artifact to download...');
328
+ const artifactsData = await githubApi(`/repos/${REPO}/actions/runs/${runId}/artifacts`);
329
+
330
+ if (!artifactsData.artifacts || artifactsData.artifacts.length === 0) {
331
+ console.log('āŒ No artifacts found for this run.');
332
+ return;
333
+ }
334
+
335
+ const artifact = artifactsData.artifacts[0];
336
+ const targetDir = path.join(rootDir, 'ios', 'release');
337
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
338
+
339
+ const zipName = buildType === 'release' ? 'ios-release.zip' : 'ios-build.zip';
340
+ const targetFile = path.join(targetDir, zipName);
341
+
342
+ console.log(`šŸ“„ Downloading ${artifact.name} to ${targetFile}...`);
343
+
344
+ try {
345
+ execSync(`curl.exe -L -H "Authorization: token ${GITHUB_TOKEN}" -o "${targetFile}" "${artifact.archive_download_url}"`, { stdio: 'inherit' });
346
+ console.log('\nāœ… Download Complete!');
347
+ console.log(`šŸ“¦ Path: ${targetFile}`);
348
+ if (buildType === 'release') {
349
+ console.log('šŸ’” Unzip the package to retrieve the signed "180spm-release.ipa".');
350
+ console.log('šŸ’” You can now upload this .ipa file directly to Apple TestFlight or App Store Connect.\n');
351
+ } else {
352
+ console.log('šŸ’” Unzip and use Sideloadly to install on iPhone.\n');
353
+ }
354
+ } catch (e) {
355
+ console.error('āŒ Download failed:', e.message);
356
+ }
357
+ }
358
+
359
+ async function buildAndroid(activeVersion, buildType) {
360
+ console.log('\nšŸ¤– --- STARTING LOCAL ANDROID BUILD ---');
361
+ console.log('āš ļø Note: This requires Java JDK and Android Studio/SDK installed.');
362
+
363
+ try {
364
+ console.log('šŸ”„ Syncing Capacitor Android...');
365
+ execSync('npx cap sync android', { stdio: 'inherit', cwd: rootDir, env: process.env });
366
+
367
+ const isBundle = buildType === 'bundle' || buildType === 'aab';
368
+ const isRelease = isBundle || buildType === 'release';
369
+ const task = isBundle ? 'bundleRelease' : (isRelease ? 'assembleRelease' : 'assembleDebug');
370
+ const modeName = isBundle ? 'App Bundle (Release)' : (isRelease ? 'APK (Release)' : 'APK (Debug)');
371
+
372
+ console.log(`ā˜• Running Gradle Build (${task})...`);
373
+ const gradleCmd = process.platform === 'win32' ? '.\\gradlew.bat' : './gradlew';
374
+ const androidDir = path.join(rootDir, 'android');
375
+
376
+ execSync(`${gradleCmd} ${task}`, { stdio: 'inherit', cwd: androidDir });
377
+
378
+ const targetDir = path.join(rootDir, 'android', 'release');
379
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
380
+
381
+ let sourcePath = '';
382
+ const ext = isBundle ? 'aab' : 'apk';
383
+
384
+ if (isBundle) {
385
+ sourcePath = path.join(androidDir, 'app', 'build', 'outputs', 'bundle', 'release', 'app-release.aab');
386
+ } else if (isRelease) {
387
+ sourcePath = path.join(androidDir, 'app', 'build', 'outputs', 'apk', 'release', 'app-release.apk');
388
+ } else {
389
+ sourcePath = path.join(androidDir, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
390
+ }
391
+
392
+ const finalFile = path.join(targetDir, `180spm-${activeVersion || 'debug'}.${ext}`);
393
+ fs.copyFileSync(sourcePath, finalFile);
394
+
395
+ console.log(`\nāœ… ANDROID BUILD SUCCESS (${modeName})!`);
396
+ console.log(`šŸ“¦ Path: ${finalFile}\n`);
397
+ } catch (e) {
398
+ console.error('\nāŒ Android Build Failed:', e.message);
399
+ process.exit(1);
400
+ }
401
+ }
402
+
403
+ function githubApi(endpoint, method = 'GET', data = null) {
404
+ return new Promise((resolve, reject) => {
405
+ const options = {
406
+ hostname: 'api.github.com',
407
+ path: endpoint,
408
+ method: method,
409
+ headers: {
410
+ 'Authorization': `token ${GITHUB_TOKEN}`,
411
+ 'User-Agent': 'OTA-Manager-CLI',
412
+ 'Accept': 'application/vnd.github.v3+json',
413
+ 'Content-Type': 'application/json'
414
+ }
415
+ };
416
+
417
+ const req = https.request(options, (res) => {
418
+ let body = '';
419
+ res.on('data', (chunk) => body += chunk);
420
+ res.on('end', () => {
421
+ if (res.statusCode >= 400) {
422
+ return reject(new Error(`GitHub API Error: ${res.statusCode} - ${body}`));
423
+ }
424
+ try {
425
+ resolve(body ? JSON.parse(body) : {});
426
+ } catch (e) {
427
+ resolve({});
428
+ }
429
+ });
430
+ });
431
+
432
+ req.on('error', (e) => reject(e));
433
+ if (data) req.write(JSON.stringify(data));
434
+ req.end();
435
+ });
436
+ }
437
+
438
+ function syncBranding() {
439
+ const appName = process.env.PUBLIC_APP_NAME || '180spm';
440
+ const appId = process.env.PUBLIC_APP_ID || 'com.cyclopedia.one_eighty_run';
441
+ const appVer = process.env.PUBLIC_APP_VERSION || process.env.PUBLIC_APP_VERSION_ANDROID || '2.0.00';
442
+ console.log(`✨ Syncing Branding & Version: [${appName} (${appId}) - ${appVer}]`);
443
+
444
+ try {
445
+ const androidStringsPath = path.join(rootDir, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml');
446
+ if (fs.existsSync(androidStringsPath)) {
447
+ let content = fs.readFileSync(androidStringsPath, 'utf8');
448
+ content = content.replace(/<string name="app_name">.*?<\/string>/, `<string name="app_name">${appName}<\/string>`);
449
+ fs.writeFileSync(androidStringsPath, content);
450
+ }
451
+
452
+ const partsStr = appVer.split('.');
453
+ let majorStr = partsStr[0] || '0';
454
+ let minorStr = partsStr[1] || '0';
455
+ let patchStr = partsStr[2] || '00';
456
+ if (patchStr.length === 1) patchStr = '0' + patchStr;
457
+ const versionCodeNum = parseInt(`${majorStr}${minorStr}${patchStr}`, 10) || 1;
458
+
459
+ const buildGradlePath = path.join(rootDir, 'android', 'app', 'build.gradle');
460
+ if (fs.existsSync(buildGradlePath)) {
461
+ let bgContent = fs.readFileSync(buildGradlePath, 'utf8');
462
+ bgContent = bgContent.replace(/applicationId\s+".*?"/, `applicationId "${appId}"`);
463
+ bgContent = bgContent.replace(/versionCode\s+\d+/, `versionCode ${versionCodeNum}`);
464
+ bgContent = bgContent.replace(/versionName\s+".*?"/, `versionName "${appVer}"`);
465
+ fs.writeFileSync(buildGradlePath, bgContent);
466
+ }
467
+
468
+ const iosPlistPath = path.join(rootDir, 'ios', 'App', 'App', 'Info.plist');
469
+ if (fs.existsSync(iosPlistPath)) {
470
+ let content = fs.readFileSync(iosPlistPath, 'utf8');
471
+ content = content.replace(/<key>CFBundleDisplayName<\/key>\s*<string>.*?<\/string>/, `<key>CFBundleDisplayName<\/key>\n\t<string>${appName}<\/string>`);
472
+ fs.writeFileSync(iosPlistPath, content);
473
+ }
474
+
475
+ const pbxprojPath = path.join(rootDir, 'ios', 'App', 'App.xcodeproj', 'project.pbxproj');
476
+ if (fs.existsSync(pbxprojPath)) {
477
+ let pbxContent = fs.readFileSync(pbxprojPath, 'utf8');
478
+ pbxContent = pbxContent.replace(/PRODUCT_BUNDLE_IDENTIFIER = .*?;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${appId};`);
479
+ pbxContent = pbxContent.replace(/CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${versionCodeNum};`);
480
+ pbxContent = pbxContent.replace(/MARKETING_VERSION = .*?;/g, `MARKETING_VERSION = ${appVer};`);
481
+ fs.writeFileSync(pbxprojPath, pbxContent);
482
+ }
483
+
484
+ const iosCapConfig = path.join(rootDir, 'ios', 'App', 'App', 'capacitor.config.json');
485
+ if (fs.existsSync(iosCapConfig)) {
486
+ let capContent = fs.readFileSync(iosCapConfig, 'utf8');
487
+ capContent = capContent.replace(/"appId":\s*".*?"/, `"appId": "${appId}"`);
488
+ capContent = capContent.replace(/"appName":\s*".*?"/, `"appName": "${appName}"`);
489
+ fs.writeFileSync(iosCapConfig, capContent);
490
+ }
491
+
492
+ const androidCapConfig = path.join(rootDir, 'android', 'app', 'src', 'main', 'assets', 'capacitor.config.json');
493
+ if (fs.existsSync(androidCapConfig)) {
494
+ let capContent = fs.readFileSync(androidCapConfig, 'utf8');
495
+ capContent = capContent.replace(/"appId":\s*".*?"/, `"appId": "${appId}"`);
496
+ capContent = capContent.replace(/"appName":\s*".*?"/, `"appName": "${appName}"`);
497
+ fs.writeFileSync(androidCapConfig, capContent);
498
+ }
499
+
500
+ console.log(`āœ… Branding & native versions synchronized (versionCode: ${versionCodeNum}).`);
501
+ } catch (e) {
502
+ console.error('āš ļø Warning: Failed to sync branding/version:', e.message);
503
+ }
504
+ }
505
+
506
+ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
507
+
508
+ run();
package/lib/ota-deploy.js CHANGED
@@ -160,7 +160,7 @@ async function deployOTA() {
160
160
  execSync('npm run build', { stdio: 'inherit', cwd: rootDir });
161
161
 
162
162
  if (fs.existsSync(FLATTEN_SCRIPT)) {
163
- console.log('šŸš€ Running Tsar Bomba Path Cleanse...');
163
+ console.log('šŸš€ Running Absolute Path Normalization...');
164
164
  execSync(`node "${FLATTEN_SCRIPT}"`, { stdio: 'inherit', cwd: rootDir });
165
165
  }
166
166
 
@@ -192,7 +192,9 @@ async function deployOTA() {
192
192
  console.log('šŸ” Changes detected! Auto-incrementing from remote manifest...');
193
193
  const parts = previousVersion.split('.');
194
194
  const lastIdx = parts.length - 1;
195
- parts[lastIdx] = (parseInt(parts[lastIdx]) + 1).toString();
195
+ const oldPart = parts[lastIdx];
196
+ const oldLen = oldPart.length;
197
+ parts[lastIdx] = (parseInt(oldPart) + 1).toString().padStart(oldLen, '0');
196
198
  nextVersion = parts.join('.');
197
199
  console.log(`šŸ“ˆ Auto-increment: ${previousVersion} -> ${nextVersion}`);
198
200
  }
@@ -221,12 +223,90 @@ async function deployOTA() {
221
223
 
222
224
  console.log(`šŸ“ Updating .env to version ${nextVersion} (Android & iOS) and setting OTA target...`);
223
225
  fs.writeFileSync(ENV_PATH, updatedEnv);
226
+
227
+ const envLivePath = path.join(rootDir, '.env-live');
228
+ const envTrainingPath = path.join(rootDir, '.env-training');
229
+ if (argChannel === 'live' && fs.existsSync(envLivePath)) {
230
+ let liveEnv = fs.readFileSync(envLivePath, 'utf-8');
231
+ liveEnv = liveEnv.replace(/PUBLIC_APP_VERSION=.*/, `PUBLIC_APP_VERSION=${nextVersion}`);
232
+ if (liveEnv.includes('PUBLIC_OTA_UPDATE_URL=')) {
233
+ liveEnv = liveEnv.replace(/PUBLIC_OTA_UPDATE_URL=.*/, `PUBLIC_OTA_UPDATE_URL=${activeOtaUrl}`);
234
+ } else {
235
+ liveEnv += `\nPUBLIC_OTA_UPDATE_URL=${activeOtaUrl}`;
236
+ }
237
+ fs.writeFileSync(envLivePath, liveEnv);
238
+ console.log(`šŸ“ Updated .env-live to version ${nextVersion}`);
239
+ }
240
+ if (argChannel === 'training' && fs.existsSync(envTrainingPath)) {
241
+ let trainingEnv = fs.readFileSync(envTrainingPath, 'utf-8');
242
+ trainingEnv = trainingEnv.replace(/PUBLIC_APP_VERSION=.*/, `PUBLIC_APP_VERSION=${nextVersion}`);
243
+ if (trainingEnv.includes('PUBLIC_OTA_UPDATE_URL=')) {
244
+ trainingEnv = trainingEnv.replace(/PUBLIC_OTA_UPDATE_URL=.*/, `PUBLIC_OTA_UPDATE_URL=${activeOtaUrl}`);
245
+ } else {
246
+ trainingEnv += `\nPUBLIC_OTA_UPDATE_URL=${activeOtaUrl}`;
247
+ }
248
+ fs.writeFileSync(envTrainingPath, trainingEnv);
249
+ console.log(`šŸ“ Updated .env-training to version ${nextVersion}`);
250
+ }
224
251
 
252
+ // Sync Native Android & iOS Versions
253
+ try {
254
+ const partsStr = nextVersion.split('.');
255
+ let majorStr = partsStr[0] || '0';
256
+ let minorStr = partsStr[1] || '0';
257
+ let patchStr = partsStr[2] || '00';
258
+ if (patchStr.length === 1) patchStr = '0' + patchStr;
259
+ const versionCodeNum = parseInt(`${majorStr}${minorStr}${patchStr}`, 10) || 1;
260
+
261
+ const buildGradlePath = path.join(rootDir, 'android', 'app', 'build.gradle');
262
+ if (fs.existsSync(buildGradlePath)) {
263
+ let bgContent = fs.readFileSync(buildGradlePath, 'utf8');
264
+ bgContent = bgContent.replace(/versionCode\s+\d+/, `versionCode ${versionCodeNum}`);
265
+ bgContent = bgContent.replace(/versionName\s+".*?"/, `versionName "${nextVersion}"`);
266
+ fs.writeFileSync(buildGradlePath, bgContent);
267
+ }
268
+
269
+ const pbxprojPath = path.join(rootDir, 'ios', 'App', 'App.xcodeproj', 'project.pbxproj');
270
+ if (fs.existsSync(pbxprojPath)) {
271
+ let pbxContent = fs.readFileSync(pbxprojPath, 'utf8');
272
+ pbxContent = pbxContent.replace(/CURRENT_PROJECT_VERSION = \d+;/g, `CURRENT_PROJECT_VERSION = ${versionCodeNum};`);
273
+ pbxContent = pbxContent.replace(/MARKETING_VERSION = .*?;/g, `MARKETING_VERSION = ${nextVersion};`);
274
+ fs.writeFileSync(pbxprojPath, pbxContent);
275
+ }
276
+ console.log(`āœ… Native Android & iOS versions synchronized (versionCode: ${versionCodeNum}).`);
277
+ } catch (err) {
278
+ console.error('āš ļø Warning: Failed to sync native versions:', err.message);
279
+ }
280
+
225
281
  if (fs.existsSync(MAIN_MANIFEST_PATH)) {
226
282
  const manifest = JSON.parse(fs.readFileSync(MAIN_MANIFEST_PATH, 'utf8'));
227
283
  manifest.version = nextVersion;
228
284
  fs.writeFileSync(MAIN_MANIFEST_PATH, JSON.stringify(manifest, null, 2));
229
285
  }
286
+ const pkgJsonPath = path.join(rootDir, 'package.json');
287
+ if (fs.existsSync(pkgJsonPath)) {
288
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
289
+ pkgJson.version = nextVersion;
290
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
291
+ }
292
+
293
+ console.log('šŸ—ļø Rebuilding Astro to bake new version string into JS bundle...');
294
+ hideApi();
295
+ try {
296
+ execSync('npm run build', { stdio: 'inherit', cwd: rootDir });
297
+
298
+ if (fs.existsSync(FLATTEN_SCRIPT)) {
299
+ console.log('šŸš€ Running Absolute Path Normalization...');
300
+ execSync(`node "${FLATTEN_SCRIPT}"`, { stdio: 'inherit', cwd: rootDir });
301
+ }
302
+
303
+ if (fs.existsSync(VERIFY_SCRIPT)) {
304
+ console.log('šŸ” Running Pre-Flight Verification...');
305
+ execSync(`node "${VERIFY_SCRIPT}"`, { stdio: 'inherit', cwd: rootDir });
306
+ }
307
+ } finally {
308
+ showApi();
309
+ }
230
310
 
231
311
  console.log('šŸ›”ļø Size Guardian: Checking dist/ folder size...');
232
312
  const distSizeBytes = getDirSize(DIST_DIR);
@@ -237,7 +317,7 @@ async function deployOTA() {
237
317
  throw new Error(`CRITICAL SIZE VIOLATION: Folder dist/ has bloated to ${distSizeMB.toFixed(2)} MB! Limit is ${MAX_OTA_SIZE_MB} MB. Packaging aborted to prevent ZIP BOMB!`);
238
318
  }
239
319
 
240
- const otaName = `v${nextVersion.replace(/\./g, '_')}.zip`;
320
+ const otaName = `${nextVersion.replace(/\./g, '_')}.zip`;
241
321
  const otaPath = path.join(rootDir, otaName);
242
322
 
243
323
  const isWindows = process.platform === 'win32';
@@ -306,7 +386,7 @@ async function deployToRemote(otaPath, otaName, version, channel, branch, buildH
306
386
  fs.writeFileSync(flatManifestPath, JSON.stringify(manifest[channel], null, 2));
307
387
 
308
388
  execSync(`git add .`, { cwd: OTA_RELEASES_DIR });
309
- execSync(`git commit -m "release: v${version} for ${channel}"`, { cwd: OTA_RELEASES_DIR });
389
+ execSync(`git commit -m "release: ${version} for ${channel}"`, { cwd: OTA_RELEASES_DIR });
310
390
  execSync(`git push origin ${branch}`, { cwd: OTA_RELEASES_DIR });
311
391
 
312
392
  if (fs.existsSync(otaPath)) fs.unlinkSync(otaPath);
package/lib/ota-main.js CHANGED
@@ -23,6 +23,7 @@ const scripts = {
23
23
  verify: path.join(__dirname, 'verify-dist.cjs'),
24
24
  security: path.join(__dirname, 'ota-security.js'),
25
25
  build: path.join(__dirname, 'ota-build.cjs'),
26
+ rollback: path.join(__dirname, 'ota-rollback.js'),
26
27
  };
27
28
 
28
29
  function showHelp() {
@@ -43,9 +44,18 @@ Management Commands:
43
44
  test : Run E2E simulation (Push & Read).
44
45
 
45
46
  Operational Commands:
46
- status : Check local vs remote version.
47
- deploy training : Deploy update to TRAINING channel.
48
- deploy live : Deploy update to LIVE channel.
47
+ status : Check local vs remote version.
48
+ deploy training : Deploy update to TRAINING channel.
49
+ deploy live : Deploy update to LIVE channel.
50
+ rollback <v> [ch] : Rollback / Downgrade ke versi lama (e.g., rollback 2.0.00 training).
51
+
52
+ Native Build Commands:
53
+ build android : Build Android APK Debug (default).
54
+ build android release : Build Android APK Release (assembleRelease).
55
+ build android bundle : Build Android App Bundle (.aab) siap rilis Play Store.
56
+ build ios : Trigger iOS GitHub Actions build & sync branding (Debug/Sideloading).
57
+ build ios release : Trigger iOS App Store Release signed build & sync branding.
58
+ * Gunakan flag 'no', 'norev', 'nover', '-no', atau '-nv' di akhir perintah untuk mencegah kenaikan versi.
49
59
 
50
60
  Active Infrastructure:
51
61
  Strategy : ${OTA_CONFIG.strategy.toUpperCase()}
@@ -125,7 +135,8 @@ async function run() {
125
135
  break;
126
136
 
127
137
  case 'build':
128
- execSync(`node "${scripts.build}" ${subArg || ''}`, { stdio: 'inherit' });
138
+ const allArgs = process.argv.slice(3).join(' ');
139
+ execSync(`node "${scripts.build}" ${allArgs}`, { stdio: 'inherit' });
129
140
  process.exit(0);
130
141
  break;
131
142
 
@@ -136,6 +147,22 @@ async function run() {
136
147
  process.exit(0);
137
148
  break;
138
149
 
150
+ case 'rollback':
151
+ case 'downgrade':
152
+ case 'set-version':
153
+ if (!subArg) {
154
+ console.log('āŒ Error: Mohon tentukan versi tujuan (e.g., npx ota-manager rollback 0.2.0.11 training).');
155
+ process.exit(1);
156
+ }
157
+ const rbChannel = versionArg || 'training';
158
+ if (await confirm(`ARE YOU SURE you want to rollback ${rbChannel.toUpperCase()} to version ${subArg}?`)) {
159
+ execSync(`node "${scripts.rollback}" ${subArg} ${rbChannel}`, { stdio: 'inherit' });
160
+ } else {
161
+ console.log('āŒ Rollback cancelled.');
162
+ }
163
+ process.exit(0);
164
+ break;
165
+
139
166
  case 'training':
140
167
  execSync(`node "${scripts.version}"`, { stdio: 'inherit' });
141
168
  execSync(`node "${scripts.verify}"`, { stdio: 'inherit' });
@@ -0,0 +1,130 @@
1
+ import { execSync } from 'child_process';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import OTA_CONFIG from './ota-config.js';
5
+
6
+ const rootDir = process.cwd();
7
+ const ENV_PATH = path.join(rootDir, '.env');
8
+ const MAIN_MANIFEST_PATH = path.join(rootDir, 'src', 'data', 'update-data.json');
9
+ const OTA_RELEASES_DIR = path.join(rootDir, 'ota-releases');
10
+
11
+ let githubPat = '';
12
+ let gitlabPat = '';
13
+ if (fs.existsSync(ENV_PATH)) {
14
+ const envContent = fs.readFileSync(ENV_PATH, 'utf-8');
15
+ envContent.split('\n').forEach(line => {
16
+ const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
17
+ if (match) {
18
+ if (match[1] === 'GITHUB_DEV_PAT') githubPat = match[2].replace(/['"]/g, '').trim();
19
+ if (match[1] === 'GITLAB_DEV_PAT') gitlabPat = match[2].replace(/['"]/g, '').trim();
20
+ }
21
+ });
22
+ }
23
+
24
+ function getRawBaseUrl(repoUrl, strategy, branch = 'main') {
25
+ let base = repoUrl.replace(/\/$/, '');
26
+ if (strategy === 'github') {
27
+ return base.replace('github.com', 'raw.githubusercontent.com') + `/${branch}`;
28
+ } else if (strategy === 'gitlab') {
29
+ return base + `/-/raw/${branch}`;
30
+ }
31
+ return base;
32
+ }
33
+
34
+ async function rollbackOTA() {
35
+ const targetVersion = process.argv[2];
36
+ const argChannel = process.argv[3] || 'training';
37
+
38
+ if (!targetVersion) {
39
+ console.log('āŒ Error: Mohon tentukan versi tujuan rollback (e.g., npx ota-manager rollback 0.2.0.11 training)');
40
+ process.exit(1);
41
+ }
42
+
43
+ console.log(`\nšŸ”„ --- MEMULAI PROSES ROLLBACK OTA (${argChannel.toUpperCase()}) ---`);
44
+ console.log(`šŸŽÆ Target Versi : v${targetVersion}`);
45
+ console.log(`šŸ”¹ Strategy : ${OTA_CONFIG.strategy.toUpperCase()}`);
46
+
47
+ try {
48
+ const config = OTA_CONFIG[OTA_CONFIG.strategy];
49
+ if (!config || !config.repo) {
50
+ throw new Error(`Repository not configured for strategy "${OTA_CONFIG.strategy}".`);
51
+ }
52
+
53
+ const channelConfig = config.channels?.[argChannel];
54
+ const activeBranch = channelConfig?.branch || config.branch || 'main';
55
+
56
+ console.log(`šŸ“‚ Menyiapkan repositori OTA jarak jauh (Branch: ${activeBranch})...`);
57
+ if (fs.existsSync(OTA_RELEASES_DIR)) {
58
+ fs.rmSync(OTA_RELEASES_DIR, { recursive: true, force: true });
59
+ }
60
+
61
+ const pat = OTA_CONFIG.strategy === 'gitlab' ? gitlabPat : githubPat;
62
+ const cloneRepo = config.repo.endsWith('.git') ? config.repo : config.repo + '.git';
63
+ const authRepo = cloneRepo.replace('https://', `https://${pat}@`);
64
+
65
+ execSync(`git clone --branch ${activeBranch} ${authRepo} "${OTA_RELEASES_DIR}"`, { stdio: 'inherit' });
66
+
67
+ const zipFileName = `v${targetVersion.replace(/\./g, '_')}.zip`;
68
+ const zipFilePath = path.join(OTA_RELEASES_DIR, zipFileName);
69
+
70
+ console.log(`šŸ” Memeriksa ketersediaan bungkusan fisik: ${zipFileName} di server...`);
71
+ if (!fs.existsSync(zipFilePath)) {
72
+ console.log(`\nāŒ ERROR FATAL: File bungkusan ${zipFileName} TIDAK DITEMUKAN di repositori jarak jauh!`);
73
+ console.log(`šŸ’” Rollback dibatalkan karena versi tujuan tidak memiliki bungkusan fisik yang valid.`);
74
+ process.exit(1);
75
+ }
76
+ console.log(`āœ… File bungkusan fisik ${zipFileName} tersedia dan valid!`);
77
+
78
+ const manifestFileName = argChannel === 'training' ? 'manifest-training.json' : 'manifest.json';
79
+ const manifestPath = path.join(OTA_RELEASES_DIR, manifestFileName);
80
+
81
+ const rawBaseUrl = getRawBaseUrl(config.repo, OTA_CONFIG.strategy, activeBranch);
82
+ const activeOtaUrl = `${rawBaseUrl}/${manifestFileName}`;
83
+ const activeZipUrl = `${rawBaseUrl}/${zipFileName}`;
84
+
85
+ console.log(`šŸ“ Memperbarui ${manifestFileName} jarak jauh ke versi ${targetVersion}...`);
86
+ const manifest = {
87
+ version: targetVersion,
88
+ url: activeZipUrl
89
+ };
90
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
91
+
92
+ console.log(`šŸ“ Memperbarui .env lokal ke versi ${targetVersion}...`);
93
+ if (fs.existsSync(ENV_PATH)) {
94
+ let envContent = fs.readFileSync(ENV_PATH, 'utf-8');
95
+ envContent = envContent.replace(/PUBLIC_APP_VERSION_ANDROID=.*/, `PUBLIC_APP_VERSION_ANDROID=${targetVersion}`);
96
+ envContent = envContent.replace(/PUBLIC_APP_VERSION_IOS=.*/, `PUBLIC_APP_VERSION_IOS=${targetVersion}`);
97
+ if (envContent.includes('PUBLIC_APP_VERSION=')) {
98
+ envContent = envContent.replace(/PUBLIC_APP_VERSION=.*/, `PUBLIC_APP_VERSION=${targetVersion}`);
99
+ }
100
+ if (envContent.includes('PUBLIC_OTA_UPDATE_URL=')) {
101
+ envContent = envContent.replace(/PUBLIC_OTA_UPDATE_URL=.*/, `PUBLIC_OTA_UPDATE_URL=${activeOtaUrl}`);
102
+ } else {
103
+ envContent += `\nPUBLIC_OTA_UPDATE_URL=${activeOtaUrl}`;
104
+ }
105
+ fs.writeFileSync(ENV_PATH, envContent);
106
+ }
107
+
108
+ if (fs.existsSync(MAIN_MANIFEST_PATH)) {
109
+ const updateManifest = JSON.parse(fs.readFileSync(MAIN_MANIFEST_PATH, 'utf8'));
110
+ updateManifest.version = targetVersion;
111
+ fs.writeFileSync(MAIN_MANIFEST_PATH, JSON.stringify(updateManifest, null, 2));
112
+ }
113
+
114
+ console.log(`šŸ“¤ Mengirim manifest rollback ke repositori OTA jarak jauh...`);
115
+ execSync('git add .', { cwd: OTA_RELEASES_DIR, stdio: 'ignore' });
116
+ execSync(`git commit -m "chore: rollback OTA manifest to v${targetVersion} (${argChannel})"`, { cwd: OTA_RELEASES_DIR, stdio: 'ignore' });
117
+ execSync(`git push origin ${activeBranch}`, { cwd: OTA_RELEASES_DIR, stdio: 'inherit' });
118
+
119
+ console.log(`\nšŸŽ‰ ROLLBACK SUKSES BERHASIL DIEKSEKUSI!`);
120
+ console.log(`šŸ“„ Versi Aktif Sekarang : v${targetVersion}`);
121
+ console.log(`šŸ”— Channel : ${argChannel}`);
122
+ console.log(`šŸ’” Capgo di HP pengguna akan langsung mendeteksi manifest baru dan melakukan downgrade otomatis ke v${targetVersion}.\n`);
123
+
124
+ } catch (e) {
125
+ console.error(`\nāŒ Rollback Gagal: ${e.message}\n`);
126
+ process.exit(1);
127
+ }
128
+ }
129
+
130
+ rollbackOTA();
@@ -25,10 +25,32 @@ async function checkStatus() {
25
25
  return;
26
26
  }
27
27
 
28
- // 1. Get Local Version
28
+ // 1. Get Local Version & Native Status
29
29
  const envContent = fs.readFileSync(ENV_PATH, 'utf-8');
30
- const localVersion = envContent.match(/PUBLIC_APP_VERSION_ANDROID=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '');
31
- console.log(`šŸ’» Local Version (.env) : v${localVersion || 'unknown'}`);
30
+ const localVersion = envContent.match(/PUBLIC_APP_VERSION_ANDROID=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '') || envContent.match(/PUBLIC_APP_VERSION=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '');
31
+ const appId = envContent.match(/PUBLIC_APP_ID=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '') || 'com.cyclopedia.one_eighty_run';
32
+ console.log(`šŸ’» Local Version (.env) : ${localVersion || 'unknown'}`);
33
+ console.log(`šŸ“¦ App ID / Package Name : ${appId}`);
34
+
35
+ // Cek Android versionCode
36
+ const buildGradlePath = path.join(rootDir, 'android', 'app', 'build.gradle');
37
+ let androidVersionCode = 'unknown';
38
+ if (fs.existsSync(buildGradlePath)) {
39
+ const bgContent = fs.readFileSync(buildGradlePath, 'utf8');
40
+ const match = bgContent.match(/versionCode\s+(\d+)/);
41
+ if (match) androidVersionCode = match[1];
42
+ }
43
+ console.log(`šŸ¤– Android versionCode : ${androidVersionCode}`);
44
+
45
+ // Cek iOS CURRENT_PROJECT_VERSION
46
+ const pbxprojPath = path.join(rootDir, 'ios', 'App', 'App.xcodeproj', 'project.pbxproj');
47
+ let iosProjectVersion = 'unknown';
48
+ if (fs.existsSync(pbxprojPath)) {
49
+ const pbxContent = fs.readFileSync(pbxprojPath, 'utf8');
50
+ const match = pbxContent.match(/CURRENT_PROJECT_VERSION = (\d+);/);
51
+ if (match) iosProjectVersion = match[1];
52
+ }
53
+ console.log(`šŸ iOS Project Version : ${iosProjectVersion}`);
32
54
 
33
55
  // 2. Get Remote Version based on Config
34
56
  const strategy = OTA_CONFIG.strategy;
@@ -83,18 +105,21 @@ async function checkStatus() {
83
105
  }
84
106
 
85
107
  const remoteData = JSON.parse(result);
86
- const remoteVersion = remoteData.version;
108
+ const remoteVersion = remoteData.version || remoteData[channel]?.version;
87
109
 
88
- if (remoteVersion === localVersion) {
89
- console.log(`āœ… Status: UP TO DATE (v${remoteVersion})`);
110
+ if (!remoteVersion) {
111
+ console.log(`✨ Status: CHANNEL "${channel.toUpperCase()}" KOSONG (Belum ada rilis)`);
112
+ console.log(`šŸ’” Jalankan 'npm run ota-manager deploy ${channel}' untuk merilis versi perdana ke channel ini!`);
113
+ } else if (remoteVersion === localVersion) {
114
+ console.log(`āœ… Status: UP TO DATE (${remoteVersion})`);
90
115
  } else {
91
116
  console.log(`āš ļø Status: OUTDATED!`);
92
- console.log(`šŸ“” Remote Version : v${remoteVersion}`);
93
- console.log(`šŸ’” Run 'npx ota-updates ${channel}' to update.`);
117
+ console.log(`šŸ“” Remote Version : ${remoteVersion}`);
118
+ console.log(`šŸ’” Run 'npm run ota-updates ${channel}' to update.`);
94
119
  }
95
120
  } catch (e) {
96
121
  if (e.message === 'File not found') {
97
- console.log(`✨ Status: NEW INFRASTRUCTURE (v0.0.0)`);
122
+ console.log(`✨ Status: NEW INFRASTRUCTURE (0.0.0)`);
98
123
  console.log(`šŸ’” No releases found on server. Ready for initial deployment!`);
99
124
  } else {
100
125
  console.log(`āŒ Could not fetch remote manifest.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ota-manager",
3
- "version": "1.0.7",
3
+ "version": "1.0.14",
4
4
  "description": "Multi-provider OTA update manager for Astro and static web projects.",
5
5
  "type": "module",
6
6
  "main": "index.js",