ota-manager 1.0.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,59 @@
1
+ # šŸ›”ļø OTA Infrastructure Setup Guide
2
+
3
+ This guide explains how to properly set up your repository for the **ota-manager** system. To ensure maximum security, we use a **Dual-Token System**.
4
+
5
+ ## šŸ—ļø The Dual-Token System
6
+
7
+ 1. **Developer PAT (Private)**:
8
+ * Used by developers/CI-CD to **Push** new updates.
9
+ * Requires **Write/Code Read & Write** access.
10
+ * Stored locally in `.env` (Never include this in your APK).
11
+ 2. **Public PAT (App-Facing)**:
12
+ * Used by the Mobile App (APK) to **Check & Download** updates.
13
+ * Requires **Read-only** access.
14
+ * Embedded in the App (Safe to be seen by the app).
15
+
16
+ ---
17
+
18
+ ## 🦊 GitLab Setup (Fine-grained Tokens)
19
+
20
+ 1. Go to your **GitLab Profile Settings** > **Access Tokens**.
21
+ 2. Click **Add new token** (Fine-grained token is recommended for specific projects).
22
+ 3. **Basic Info**: Name it `OTA-Public-Access`.
23
+ 4. **Group and project access**: Select **Only specific groups or projects** and find your OTA repository.
24
+ 5. **Permissions (Scopes)**:
25
+ * On the left menu, select **Repository**.
26
+ * For **Developer PAT**: Under **Code**, select **Push** from the dropdown.
27
+ * For **Public PAT**: Under **Code**, select **Read** from the dropdown.
28
+ * *Note*: If you encounter a 403 error during verification, also enable **API** > **Read** access on the left menu.
29
+ 7. Copy the token (starting with `glpat-`) and save it to your `.env` as `PUBLIC_GITLAB_OTA_PAT`.
30
+
31
+ ---
32
+
33
+ ## šŸ™ GitHub Setup (Fine-grained Tokens)
34
+
35
+ 1. Go to your **GitHub Settings** > **Developer settings** > **Personal access tokens** > **Fine-grained tokens**.
36
+ 2. Click **Generate new token**.
37
+ 3. **Repository access**: Select **Only select repositories** and pick your OTA repo.
38
+ 4. **Permissions**:
39
+ * Under **Repository permissions**, find **Contents**.
40
+ * Select **Access: Read-only** for the Public token.
41
+ 5. Click **Generate token**.
42
+ 6. Copy the token (starting with `github_pat_`) and save it to your `.env` as `PUBLIC_GITHUB_OTA_PAT`.
43
+
44
+ ---
45
+
46
+ ## šŸš€ Environment Configuration
47
+
48
+ Once you have your tokens, run the following command to register them:
49
+
50
+ ```bash
51
+ npm run ota-updates register <id>
52
+ ```
53
+
54
+ Replace `<id>` with `github` or `gitlab`. The manager will guide you to input your repository URL and both PATs.
55
+
56
+ ---
57
+
58
+ > [!IMPORTANT]
59
+ > **NEVER** give the Public PAT 'Write' access. This ensures that even if someone extracts the token from your APK, they cannot modify or delete your releases.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # ota-manager
2
+
3
+ Enterprise-grade Over-The-Air (OTA) update manager for Astro and static web projects.
4
+
5
+ ## Features
6
+
7
+ - šŸš€ **Multi-Provider Support**: Switch between GitHub and GitLab seamlessly.
8
+ - šŸ—ļø **Multi-Channel Deployment**: Manage 'training' and 'live' environments independently.
9
+ - šŸ”’ **Secure PAT Management**: Separate Read-only tokens (for APK) and Developer tokens (for deployment).
10
+ - šŸ›”ļø **Pre-deployment Health Check**: Automatic version gap checking and token verification.
11
+ - šŸ“¦ **Smart ZIP Archiving**: POSIX-compliant compression for Android compatibility.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install ota-manager --save-dev
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### 1. Initialize Configuration
22
+ ```bash
23
+ npx ota-updates register gitlab
24
+ ```
25
+ Follow the interactive prompts to set up your repository URL and Access Tokens.
26
+
27
+ ### 2. Check Status
28
+ ```bash
29
+ npx ota-updates status
30
+ ```
31
+
32
+ ### 3. Deploy Update
33
+ ```bash
34
+ npx ota-updates training
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ The manager stores metadata in `ota-config.json` and sensitive tokens in your `.env` file.
40
+
41
+ ```json
42
+ // ota-config.json
43
+ {
44
+ "strategy": "gitlab",
45
+ "configs": {
46
+ "gitlab": {
47
+ "repo": "https://gitlab.com/your-user/your-ota-repo",
48
+ "branch": "main"
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ## License
55
+
56
+ MIT Ā© First Ryan
package/bin/cli.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { fork } from 'child_process';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const mainScript = path.join(__dirname, '../lib/ota-main.js');
10
+
11
+ const args = process.argv.slice(2);
12
+ const child = fork(mainScript, args, { stdio: 'inherit', cwd: process.cwd() });
13
+
14
+ child.on('exit', code => {
15
+ process.exit(code);
16
+ });
@@ -0,0 +1,78 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * SAPU JAGAT 4.0 (TSAR BOMBA):
6
+ * Menghancurkan SEMUA variasi "/assets/" dan ".//assets/" di seluruh file
7
+ * tanpa peduli ada tanda kutip atau tidak.
8
+ */
9
+ function fixPathsInFile(filePath) {
10
+ let content = fs.readFileSync(filePath, 'utf8');
11
+
12
+ // Ganti semua variasi jalur absolut/cacat menjadi relatif murni
13
+ let fixedContent = content;
14
+
15
+ // 1. Bersihkan ".//assets/"
16
+ fixedContent = fixedContent.split('.//assets/').join('assets/');
17
+
18
+ // 2. Bersihkan "/assets/" yang ada di dalam tanda kutip (src, href)
19
+ fixedContent = fixedContent.split('"/assets/').join('"assets/');
20
+ fixedContent = fixedContent.split("'/assets/").join("'assets/");
21
+
22
+ // 3. Bersihkan "/assets/" yang ada di luar tanda kutip (Teks Logo, dll)
23
+ // Contoh: "Logo: /assets/..." -> "Logo: assets/..."
24
+ fixedContent = fixedContent.split(' /assets/').join(' assets/');
25
+ fixedContent = fixedContent.split('>/assets/').join('>assets/');
26
+ fixedContent = fixedContent.split(':/assets/').join(':assets/');
27
+
28
+ // 4. Bersihkan Vite preload helper & dynamic imports yang mengkonstruksi return"/"+e atau return "/" + e
29
+ fixedContent = fixedContent.split('return"/"+e').join('return e');
30
+ fixedContent = fixedContent.split('return "/" + e').join('return e');
31
+ fixedContent = fixedContent.split('return `/${e}`').join('return e');
32
+
33
+ if (content !== fixedContent) {
34
+ fs.writeFileSync(filePath, fixedContent);
35
+ console.log(` šŸ’£ TSAR BOMBA FIX in: ${path.basename(filePath)}`);
36
+ }
37
+ }
38
+
39
+ function flatten(dir, rootDir = dir) {
40
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
41
+
42
+ for (const entry of entries) {
43
+ const fullPath = path.join(dir, entry.name);
44
+ if (entry.isDirectory()) {
45
+ if (entry.name === 'assets') {
46
+ const assetFiles = fs.readdirSync(fullPath);
47
+ assetFiles.forEach(f => {
48
+ if (f.endsWith('.js') || f.endsWith('.css')) {
49
+ fixPathsInFile(path.join(fullPath, f));
50
+ }
51
+ });
52
+ continue;
53
+ }
54
+ flatten(fullPath, rootDir);
55
+
56
+ if (fs.readdirSync(fullPath).length === 0) {
57
+ fs.rmdirSync(fullPath);
58
+ }
59
+ } else if (entry.name.endsWith('.html')) {
60
+ fixPathsInFile(fullPath);
61
+
62
+ if (entry.name === 'index.html' && dir !== rootDir) {
63
+ const folderName = path.basename(dir);
64
+ const targetPath = path.join(rootDir, folderName + '.html');
65
+ fs.renameSync(fullPath, targetPath);
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ const distPath = path.join(process.cwd(), 'dist');
72
+ if (fs.existsSync(distPath)) {
73
+ console.log('šŸš€ SAPU JAGAT 4.0: Launching Tsar Bomba Path Cleanse...');
74
+ flatten(distPath);
75
+ console.log('āœ… ALL PATHS ARE NOW 100% PURE RELATIVE!');
76
+ } else {
77
+ console.log(`āŒ Error: dist/ directory not found in ${process.cwd()}`);
78
+ }
@@ -0,0 +1,280 @@
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();
@@ -0,0 +1,19 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const configPath = path.join(process.cwd(), 'ota-config.json');
5
+
6
+ let data = { strategy: 'github', configs: {} };
7
+ if (fs.existsSync(configPath)) {
8
+ try {
9
+ const rawData = fs.readFileSync(configPath, 'utf-8');
10
+ data = JSON.parse(rawData);
11
+ } catch (e) {
12
+ console.warn('āš ļø Warning: ota-config.json is invalid or corrupted.');
13
+ }
14
+ }
15
+
16
+ export default {
17
+ strategy: data.strategy,
18
+ ...data.configs
19
+ };