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.
- package/INFRASTRUCTURE_SETUP.md +59 -0
- package/README.md +56 -0
- package/bin/cli.js +16 -0
- package/lib/flatten-dist.cjs +78 -0
- package/lib/ota-build.cjs +280 -0
- package/lib/ota-config.js +19 -0
- package/lib/ota-deploy.js +284 -0
- package/lib/ota-main.js +197 -0
- package/lib/ota-manager.js +207 -0
- package/lib/ota-security.js +148 -0
- package/lib/ota-version.js +108 -0
- package/lib/verify-dist.cjs +85 -0
- package/package.json +41 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import OTA_CONFIG from './ota-config.js';
|
|
5
|
+
|
|
6
|
+
const rootDir = process.cwd();
|
|
7
|
+
const ENV_PATH = path.join(rootDir, '.env');
|
|
8
|
+
|
|
9
|
+
async function runSecurityAudit() {
|
|
10
|
+
console.log(`\nš”ļø --- OTA PUBLIC TOKEN SECURITY AUDIT ---`);
|
|
11
|
+
console.log(`š Auditing the token that will be embedded in your APK...\n`);
|
|
12
|
+
|
|
13
|
+
if (!fs.existsSync(ENV_PATH)) {
|
|
14
|
+
console.log(`\nā Error: .env file not found in ${rootDir}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const strategy = OTA_CONFIG.strategy;
|
|
19
|
+
const config = OTA_CONFIG[strategy];
|
|
20
|
+
const envContent = fs.readFileSync(ENV_PATH, 'utf-8');
|
|
21
|
+
|
|
22
|
+
const githubPub = envContent.match(/PUBLIC_GITHUB_OTA_PAT=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '');
|
|
23
|
+
const gitlabPub = envContent.match(/PUBLIC_GITLAB_OTA_PAT=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '');
|
|
24
|
+
const pubToken = strategy === 'gitlab' ? gitlabPub : githubPub;
|
|
25
|
+
|
|
26
|
+
if (!pubToken) {
|
|
27
|
+
console.log(`ā ERROR: No public token found in .env for ${strategy}.`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let safetyScore = 100;
|
|
32
|
+
const findings = [];
|
|
33
|
+
|
|
34
|
+
// 1. Audit Token Type
|
|
35
|
+
console.log(`š ļø Audit 1: Token Type Identification...`);
|
|
36
|
+
if (strategy === 'github') {
|
|
37
|
+
if (pubToken.startsWith('github_pat_')) {
|
|
38
|
+
console.log(`ā
Token is 'Fine-grained' (Recommended).`);
|
|
39
|
+
} else {
|
|
40
|
+
console.log(`ā ļø Token is 'Classic' (Higher Risk).`);
|
|
41
|
+
safetyScore -= 30;
|
|
42
|
+
findings.push("Token is a Classic PAT. If leaked, it might expose other repositories.");
|
|
43
|
+
}
|
|
44
|
+
} else if (strategy === 'gitlab') {
|
|
45
|
+
if (pubToken.startsWith('gldt-')) {
|
|
46
|
+
console.log(`ā
Token is a 'Deploy Token' (Very Secure).`);
|
|
47
|
+
} else {
|
|
48
|
+
console.log(`ā ļø Token is a 'Personal Access Token' (Higher Risk).`);
|
|
49
|
+
safetyScore -= 30;
|
|
50
|
+
findings.push("Token is a PAT. Use a Project-specific Deploy Token for better isolation.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. Audit Write Access (Should FAIL)
|
|
55
|
+
console.log(`š ļø Audit 2: Write Access Leak Test...`);
|
|
56
|
+
try {
|
|
57
|
+
const repoUrl = config.repo.replace('https://', `https://${pubToken}@`);
|
|
58
|
+
console.log(` (Simulating unauthorized write attempt...)`);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// Expected to fail if write is tried
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Audit Breadth (Cross-Repo Access)
|
|
64
|
+
console.log(`š ļø Audit 3: Cross-Repository Visibility Test...`);
|
|
65
|
+
try {
|
|
66
|
+
if (strategy === 'github') {
|
|
67
|
+
const cmd = `curl.exe -s -H "Authorization: token ${pubToken}" "https://api.github.com/user/repos"`;
|
|
68
|
+
const response = execSync(cmd).toString().trim();
|
|
69
|
+
|
|
70
|
+
const repos = JSON.parse(response);
|
|
71
|
+
const repoCount = repos.length;
|
|
72
|
+
|
|
73
|
+
if (repoCount > 1) {
|
|
74
|
+
console.log(`šØ CRITICAL: Token can see ${repoCount} repositories! (Should be 1)`);
|
|
75
|
+
safetyScore -= 50;
|
|
76
|
+
findings.push(`Token has access to ${repoCount} repositories. It's too broad!`);
|
|
77
|
+
} else if (repoCount === 1) {
|
|
78
|
+
console.log(`ā
Token is PERFECTLY isolated (Can only see: ${repos[0].full_name}).`);
|
|
79
|
+
} else {
|
|
80
|
+
console.log(`ā
Token is isolated (Cannot even list the repo via API).`);
|
|
81
|
+
}
|
|
82
|
+
} else if (strategy === 'gitlab') {
|
|
83
|
+
const cmd = `curl.exe -s -o NUL -w "%{http_code}" -H "Authorization: Bearer ${pubToken}" "https://gitlab.com/api/v4/projects"`;
|
|
84
|
+
const statusCode = execSync(cmd).toString().trim();
|
|
85
|
+
if (statusCode === "200") {
|
|
86
|
+
console.log(`ā ļø Token might have broad API access.`);
|
|
87
|
+
safetyScore -= 20;
|
|
88
|
+
findings.push("Token has API access. Ensure it's restricted to this project only.");
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`ā
Token is isolated.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.log(`ā
Token is isolated.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 4. Functional Read Test
|
|
98
|
+
console.log(`š ļø Audit 4: Functional Read Test...`);
|
|
99
|
+
try {
|
|
100
|
+
const channel = envContent.match(/PUBLIC_APP_CHANNEL=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '') || 'training';
|
|
101
|
+
const testFile = channel === 'training' ? 'manifest-training.json' : 'manifest.json';
|
|
102
|
+
const activeBranch = config.channels?.[channel]?.branch || config.branch || 'main';
|
|
103
|
+
|
|
104
|
+
let fetchUrl = "";
|
|
105
|
+
|
|
106
|
+
if (strategy === 'github') {
|
|
107
|
+
const repoPath = config.repo.replace('https://github.com/', '').replace(/\/$/, '');
|
|
108
|
+
fetchUrl = `https://api.github.com/repos/${repoPath}/contents/${testFile}?ref=${activeBranch}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const cmd = `curl.exe -s -H "Authorization: Bearer ${pubToken}" -H "Accept: application/vnd.github.v3.raw" "${fetchUrl}"`;
|
|
112
|
+
console.log(` (Attempting fetch: ${fetchUrl})`);
|
|
113
|
+
const content = execSync(cmd).toString().trim();
|
|
114
|
+
|
|
115
|
+
if (content.includes('"version"')) {
|
|
116
|
+
const data = JSON.parse(content);
|
|
117
|
+
console.log(`ā
Read Successful! Current Remote Version: v${data.version}`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(`ā Raw Response: ${content.substring(0, 100)}...`);
|
|
120
|
+
throw new Error("Invalid content");
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.log(`ā ļø Warning: Token is secure but could not read the manifest file.`);
|
|
124
|
+
console.log(` HINT: Make sure 'manifest.json' exists in the repository.`);
|
|
125
|
+
safetyScore -= 10;
|
|
126
|
+
findings.push("Token is secure but functional read test failed. Check if manifest.json exists.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- FINAL REPORT ---
|
|
130
|
+
console.log(`\n------------------------------------`);
|
|
131
|
+
console.log(`š FINAL SECURITY SCORE: ${safetyScore}/100`);
|
|
132
|
+
|
|
133
|
+
if (safetyScore === 100) {
|
|
134
|
+
console.log(`š¢ STATUS: SECURE. Ready for Production.`);
|
|
135
|
+
} else if (safetyScore >= 70) {
|
|
136
|
+
console.log(`š” STATUS: WARNED. Safe but could be improved.`);
|
|
137
|
+
} else {
|
|
138
|
+
console.log(`š“ STATUS: VULNERABLE! DO NOT RELEASE APK.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (findings.length > 0) {
|
|
142
|
+
console.log(`\nš RECOMMENDATIONS:`);
|
|
143
|
+
findings.forEach(f => console.log(` - ${f}`));
|
|
144
|
+
}
|
|
145
|
+
console.log(`------------------------------------\n`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
runSecurityAudit();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import OTA_CONFIG from './ota-config.js';
|
|
5
|
+
|
|
6
|
+
const rootDir = process.cwd();
|
|
7
|
+
const ENV_PATH = path.join(rootDir, '.env');
|
|
8
|
+
|
|
9
|
+
// Helper to construct Raw URL based on provider and branch
|
|
10
|
+
function getRawBaseUrl(repoUrl, strategy, branch = 'main') {
|
|
11
|
+
let base = repoUrl.replace(/\/$/, '');
|
|
12
|
+
if (strategy === 'github') {
|
|
13
|
+
return base.replace('github.com', 'raw.githubusercontent.com') + `/${branch}`;
|
|
14
|
+
} else if (strategy === 'gitlab') {
|
|
15
|
+
return base + `/-/raw/${branch}`;
|
|
16
|
+
}
|
|
17
|
+
return base;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function checkStatus() {
|
|
21
|
+
console.log(`\nš --- OTA VERSION STATUS ---`);
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(ENV_PATH)) {
|
|
24
|
+
console.log(`\nā Error: .env file not found in ${rootDir}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 1. Get Local Version
|
|
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'}`);
|
|
32
|
+
|
|
33
|
+
// 2. Get Remote Version based on Config
|
|
34
|
+
const strategy = OTA_CONFIG.strategy;
|
|
35
|
+
const config = OTA_CONFIG[strategy];
|
|
36
|
+
if (!config || !config.repo) {
|
|
37
|
+
console.log(`\nā Error: Repository not configured for strategy "${strategy}".`);
|
|
38
|
+
console.log(`š” Please run 'npx ota-updates register ${strategy}' to configure.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const channel = envContent.match(/PUBLIC_APP_CHANNEL=(.*)/)?.[1]?.trim().replace(/^"|"$/g, '') || 'training';
|
|
43
|
+
|
|
44
|
+
// Determine branch
|
|
45
|
+
const channelConfig = config.channels?.[channel];
|
|
46
|
+
const activeBranch = channelConfig?.branch || config.branch || 'main';
|
|
47
|
+
|
|
48
|
+
const manifestFile = channel === 'training' ? 'manifest-training.json' : 'manifest.json';
|
|
49
|
+
const rawBaseUrl = getRawBaseUrl(config.repo, strategy, activeBranch);
|
|
50
|
+
const versionUrl = `${rawBaseUrl}/${manifestFile}`;
|
|
51
|
+
|
|
52
|
+
// Get PAT for auth
|
|
53
|
+
const githubPat = envContent.match(/PUBLIC_GITHUB_OTA_PAT=(.*)/)?.[1]?.trim();
|
|
54
|
+
const gitlabPat = envContent.match(/PUBLIC_GITLAB_OTA_PAT=(.*)/)?.[1]?.trim();
|
|
55
|
+
const pat = strategy === 'gitlab' ? gitlabPat : githubPat;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
console.log(`š Checking ${strategy.toUpperCase()} (${channel}) [Branch: ${activeBranch}]...`);
|
|
59
|
+
|
|
60
|
+
let fetchUrl = versionUrl;
|
|
61
|
+
let authHeader = strategy === 'github' ? `Authorization: token ${pat}` : `Authorization: Bearer ${pat}`;
|
|
62
|
+
|
|
63
|
+
if (strategy === 'gitlab') {
|
|
64
|
+
const projectId = '82216532'; // suryabumipermata / one-eighty-run-apps-ota
|
|
65
|
+
fetchUrl = `https://gitlab.com/api/v4/projects/${projectId}/repository/files/${manifestFile}/raw?ref=${activeBranch}`;
|
|
66
|
+
|
|
67
|
+
if (pat && pat.startsWith('gldt-')) {
|
|
68
|
+
authHeader = `Deploy-Token: ${pat}`;
|
|
69
|
+
} else {
|
|
70
|
+
authHeader = `Authorization: Bearer ${pat}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const curlAuth = authHeader && pat ? `-H "${authHeader}"` : '';
|
|
75
|
+
const cmd = `curl.exe -sL -A "Mozilla/5.0" ${curlAuth} "${fetchUrl}"`;
|
|
76
|
+
const result = execSync(cmd).toString().trim();
|
|
77
|
+
|
|
78
|
+
if (!result || result === 'Not Found' || result.includes('404: Not Found') || result.includes('message')) {
|
|
79
|
+
// Check for 404 from API
|
|
80
|
+
if (result.includes('404') || result.includes('File Not Found')) throw new Error('File not found');
|
|
81
|
+
if (result.includes('403') || result.includes('Forbidden')) throw new Error('Auth failed');
|
|
82
|
+
throw new Error('File not found');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const remoteData = JSON.parse(result);
|
|
86
|
+
const remoteVersion = remoteData.version;
|
|
87
|
+
|
|
88
|
+
if (remoteVersion === localVersion) {
|
|
89
|
+
console.log(`ā
Status: UP TO DATE (v${remoteVersion})`);
|
|
90
|
+
} else {
|
|
91
|
+
console.log(`ā ļø Status: OUTDATED!`);
|
|
92
|
+
console.log(`š” Remote Version : v${remoteVersion}`);
|
|
93
|
+
console.log(`š” Run 'npx ota-updates ${channel}' to update.`);
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.message === 'File not found') {
|
|
97
|
+
console.log(`⨠Status: NEW INFRASTRUCTURE (v0.0.0)`);
|
|
98
|
+
console.log(`š” No releases found on server. Ready for initial deployment!`);
|
|
99
|
+
} else {
|
|
100
|
+
console.log(`ā Could not fetch remote manifest.`);
|
|
101
|
+
console.log(`š URL: ${versionUrl}`);
|
|
102
|
+
console.log(`š” HINT: Ensure internet connection is stable or check your PAT.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log(`------------------------------------\n`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
checkStatus();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const distDir = path.join(process.cwd(), 'dist');
|
|
5
|
+
const filesToVerify = ['splash.html', 'index.html', 'home.html'];
|
|
6
|
+
|
|
7
|
+
console.log('š MEMULAI VERIFIKASI OTA ZIP (Pre-Flight Check)...\n');
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(distDir)) {
|
|
10
|
+
console.log(`ā Error: dist/ directory not found in ${process.cwd()}`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let totalErrors = 0;
|
|
15
|
+
|
|
16
|
+
filesToVerify.forEach(filename => {
|
|
17
|
+
const filePath = path.join(distDir, filename);
|
|
18
|
+
if (!fs.existsSync(filePath)) {
|
|
19
|
+
console.log(`ā ļø WARNING: File utama ${filename} tidak ditemukan di dist/`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log(`š Memeriksa: ${filename}`);
|
|
24
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
25
|
+
|
|
26
|
+
// Regex untuk mencari src="..." atau href="..."
|
|
27
|
+
const regex = /(?:src|href)=["']([^"']+)["']/g;
|
|
28
|
+
let match;
|
|
29
|
+
|
|
30
|
+
while ((match = regex.exec(content)) !== null) {
|
|
31
|
+
const assetPath = match[1];
|
|
32
|
+
|
|
33
|
+
// Abaikan link eksternal atau base64
|
|
34
|
+
if (assetPath.startsWith('http') || assetPath.startsWith('data:')) continue;
|
|
35
|
+
|
|
36
|
+
let hasError = false;
|
|
37
|
+
|
|
38
|
+
// CEK 1: Garis Miring Ilegal khusus untuk folder assets
|
|
39
|
+
if (assetPath.startsWith('/assets') || assetPath.startsWith('.//')) {
|
|
40
|
+
console.log(` ā ERROR JALUR: Ditemukan path ilegal -> "${assetPath}"`);
|
|
41
|
+
hasError = true;
|
|
42
|
+
totalErrors++;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Abaikan validasi file fisik untuk navigasi HTML atau anchor SVG
|
|
46
|
+
if (assetPath.endsWith('.html') || assetPath.startsWith('#')) continue;
|
|
47
|
+
|
|
48
|
+
// CEK 2: Apakah filenya benar-benar ada secara fisik?
|
|
49
|
+
// Karena path kita harusnya relatif (misal: assets/file.css),
|
|
50
|
+
// kita gabungkan dengan root folder dist/
|
|
51
|
+
const physicalPath = path.join(distDir, assetPath);
|
|
52
|
+
if (!fs.existsSync(physicalPath)) {
|
|
53
|
+
console.log(` ā ERROR FILE HILANG: File tidak ditemukan di -> "${assetPath}"`);
|
|
54
|
+
hasError = true;
|
|
55
|
+
totalErrors++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log(` ā
${filename} selesai diperiksa.`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Verifikasi isi folder dist/assets/
|
|
62
|
+
const assetsDir = path.join(distDir, 'assets');
|
|
63
|
+
if (fs.existsSync(assetsDir)) {
|
|
64
|
+
console.log('\nš Memeriksa file JS di folder dist/assets/...');
|
|
65
|
+
const jsFiles = fs.readdirSync(assetsDir).filter(f => f.endsWith('.js'));
|
|
66
|
+
|
|
67
|
+
jsFiles.forEach(f => {
|
|
68
|
+
const content = fs.readFileSync(path.join(assetsDir, f), 'utf8');
|
|
69
|
+
if (content.includes('return"/"+e') || content.includes('return "/" + e')) {
|
|
70
|
+
console.log(` ā ERROR VITE PRELOAD: Ditemukan path absolut di file JS -> "${f}"`);
|
|
71
|
+
totalErrors++;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
console.log(' ā
Verifikasi file JS di dist/assets/ selesai.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log('\n------------------------------------');
|
|
78
|
+
if (totalErrors === 0) {
|
|
79
|
+
console.log('š VERIFIKASI SUKSES! Semua path relatif murni dan file fisik terdeteksi.');
|
|
80
|
+
console.log('Bungkusan OTA ini 100% AMAN untuk dikirim.\n');
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`š„ VERIFIKASI GAGAL! Ditemukan ${totalErrors} error pada struktur path atau file.`);
|
|
83
|
+
console.log('š” WAJIB PERBAIKI sebelum mengirim OTA untuk menghindari blank screen di HP user!\n');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ota-manager",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multi-provider OTA update manager for Astro and static web projects.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ota-updates": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"astro",
|
|
15
|
+
"ota",
|
|
16
|
+
"deployment",
|
|
17
|
+
"github",
|
|
18
|
+
"gitlab",
|
|
19
|
+
"automation"
|
|
20
|
+
],
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "First Ryan",
|
|
23
|
+
"email": "firstryan@gmail.com",
|
|
24
|
+
"url": "https://github.com/firstryan"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/firstryan/ota-manager.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/firstryan/ota-manager/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/firstryan/ota-manager#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"archiver": "^8.0.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|