Просмотр исходного кода

Merge pull request #12 from horanchikk/feat/scripts-big-update

feat(scripts): Крупное обновление работы скриптов
Morozov Vadim 7 месяцев назад
Родитель
Сommit
835beb2132
6 измененных файлов с 400 добавлено и 236 удалено
  1. 7 4
      .gitignore
  2. 25 0
      scripts/config.ts
  3. 29 31
      scripts/download.ts
  4. 0 10
      scripts/fileExists.ts
  5. 217 191
      scripts/mobileBuild.ts
  6. 122 0
      scripts/utils.ts

+ 7 - 4
.gitignore

@@ -23,12 +23,15 @@ logs
 .env.*
 !.env.example
 
-ios
 android
+ios
 
 pnpm-lock.yaml
 
 ./assets/tgz/*
-jdk/*
-sdk/*
-cmdline-tools/*
+
+jdk
+sdk
+cmdline-tools
+
+*.zip

+ 25 - 0
scripts/config.ts

@@ -0,0 +1,25 @@
+export const currentPath = process.cwd()
+export const config = {
+  JAVA_VERSION: '20.0.2',
+  JAVA_DIR: './jdk',
+  get JAVA_BIN() {
+    return `${this.JAVA_DIR}/jdk-${this.JAVA_VERSION}/bin/java`
+  },
+  SDK_DIR: './sdk',
+  CMD_TOOLS_ARCHIVE: 'cmdline-tools.zip',
+  CMD_TOOLS_DIR: 'cmdline-tools',
+  ANDROID_TOOLS: [
+    'build-tools;34.0.0',
+    'build-tools;35.0.0',
+    'platforms;android-34',
+    'platform-tools',
+  ],
+}
+export const envVars: NodeJS.ProcessEnv = {
+  PATH: `${currentPath}/${config.JAVA_DIR.slice(2)}/jdk-${config.JAVA_VERSION}/bin:${
+    process.env.PATH ?? ''
+  }`,
+  JAVA_HOME: `${currentPath}/${config.JAVA_DIR.slice(2)}/jdk-${config.JAVA_VERSION}`,
+  ANDROID_SDK_ROOT: `${currentPath}/${config.SDK_DIR.slice(2)}`,
+  ANDROID_HOME: `${currentPath}/${config.SDK_DIR.slice(2)}`,
+}

+ 29 - 31
scripts/download.ts

@@ -2,13 +2,14 @@ import { createWriteStream } from 'node:fs'
 import { request } from 'node:https'
 import type { IncomingMessage } from 'node:http'
 import { basename } from 'node:path'
+import { pipeline } from 'node:stream/promises'
+import { Transform } from 'node:stream'
 
 export async function downloadFile(task, url: string, path: string): Promise<void> {
-  return new Promise((resolve, reject) => {
-    const fileName = basename(path)
-
-    task.title = `Starting download: ${fileName}`
+  const fileName = basename(path)
+  task.title = `Starting download: ${fileName}`
 
+  await new Promise<void>((resolve, reject) => {
     const req = request(url, (res: IncomingMessage) => {
       if (res.statusCode !== 200) {
         reject(new Error(`Failed to download file. Status code: ${res.statusCode}`))
@@ -18,38 +19,35 @@ export async function downloadFile(task, url: string, path: string): Promise<voi
       const totalSize = Number.parseInt(res.headers['content-length'] || '0', 10)
       let downloadedSize = 0
 
-      const fileStream = createWriteStream(path)
-
-      res.on('data', (chunk) => {
-        downloadedSize += chunk.length
-        if (totalSize) {
-          const progress = Math.round((downloadedSize / totalSize) * 100)
-          const progressBar = '[' + '='.repeat(Math.floor((downloadedSize / totalSize) * 20)) + ' '.repeat(20 - Math.floor((downloadedSize / totalSize) * 20)) + ']'
-          task.title = `Downloading ${fileName}: ${progressBar} ${progress}% (${(downloadedSize / (1024 * 1024)).toFixed(2)} MB / ${(totalSize / (1024 * 1024)).toFixed(2)} MB)`
-        }
-        else {
-          task.title = `Downloading ${fileName}: ${(downloadedSize / (1024 * 1024)).toFixed(2)} MB downloaded`
-        }
+      // Create a transform stream that updates progress
+      const progressStream = new Transform({
+        transform(chunk, encoding, callback) {
+          downloadedSize += chunk.length
+
+          if (totalSize) {
+            const ratio = downloadedSize / totalSize
+            const progress = Math.round(ratio * 100)
+            const progressBarCount = Math.floor(ratio * 20)
+            const progressBar = `[${'='.repeat(progressBarCount)}${' '.repeat(20 - progressBarCount)}]`
+            task.title = `Downloading ${fileName}: ${progressBar} ${progress}% (${(downloadedSize / (1024 * 1024)).toFixed(2)} MB / ${(totalSize / (1024 * 1024)).toFixed(2)} MB)`
+          }
+          else {
+            task.title = `Downloading ${fileName}: ${(downloadedSize / (1024 * 1024)).toFixed(2)} MB downloaded`
+          }
+
+          callback(null, chunk)
+        },
       })
 
-      res.pipe(fileStream)
-
-      fileStream.on('finish', () => {
-        fileStream.close(() => {
-          resolve()
-        })
-      })
-
-      fileStream.on('error', (err) => {
-        fileStream.close()
-        reject(err)
-      })
-    })
+      const fileStream = createWriteStream(path)
 
-    req.on('error', (err) => {
-      reject(err)
+      // Use pipeline to handle piping and errors automatically.
+      pipeline(res, progressStream, fileStream)
+        .then(resolve)
+        .catch(reject)
     })
 
+    req.on('error', reject)
     req.end()
   })
 }

+ 0 - 10
scripts/fileExists.ts

@@ -1,10 +0,0 @@
-import fs from 'node:fs'
-
-export function doesFileExist(filePath: string) {
-  try {
-    return fs.existsSync(filePath)
-  }
-  catch {
-    return false
-  }
-}

+ 217 - 191
scripts/mobileBuild.ts

@@ -1,209 +1,235 @@
-import { exec } from 'node:child_process'
-import { promisify } from 'node:util'
-import path from 'node:path'
-import fs from 'node:fs'
+import os from 'node:os'
 import { Listr } from 'listr2'
-import consola from 'consola'
-import { config } from 'dotenv'
+import { config as initDE } from 'dotenv'
 import { logLogo } from './logo'
 import { downloadFile } from './download'
-import { doesFileExist } from './fileExists'
-
-config()
-
-// yes | sdkmanager --sdk_root=/home/horanchikk/Documents/Sdk --licenses
-// sdkmanager --sdk_root=/home/horanchikk/Documents/Sdk --install "emulator" "build-tools;34.0.0" "build-tools;35.0.0" "platforms;android-34" "platform-tools"
-// export PATH=/home/horanchikk/Documents/jdk-20/bin:$PATH && ionic capacitor run android --list
-
-const execAsync = promisify(exec)
-const currentPath = path.resolve()
-
-async function runCommand(
-  command: string,
-  task?: {
-    output: string
-  },
-  disableStdout: boolean | undefined = false,
-) {
-  try {
-    const { stdout, stderr } = await execAsync(command)
-
-    if (!disableStdout) {
-      if (stderr) {
-        if (task) {
-          task.output = stderr
-        }
-        else { consola.log(stderr) }
-      }
-      else {
-        if (task) {
-          task.output = stdout
-        }
-        else { consola.log(stdout) }
-      }
-    }
-  }
-  catch (error) {
-    if (!disableStdout) {
-      if (task) {
-        task.output = String(error)
-      }
-      else {
-        consola.log(String(error))
-      }
-    }
-  }
-}
+import { config, currentPath } from './config'
+import {
+  fileExistsAsync,
+  removeDir,
+  createDir,
+  runCommand,
+  ensureDownloadedAndExtracted,
+} from './utils'
 
+initDE()
 logLogo()
 
-const ANDROID_DEVICE_ID = process.env.ANDROID_DEVICE_ID
-const tools = ['emulator', 'build-tools;34.0.0', 'build-tools;35.0.0', 'platforms;android-34', 'platform-tools']
-let isSdkInstalled = true
-
-for (const tool of tools) {
-  if (!doesFileExist(`./sdk/${tool.replace(';', '/')}/package.xml`)) {
-    isSdkInstalled = false
-    break
-  }
-}
-
-try {
-  await new Listr(
-    [
-      {
-        title: 'Building nuxt',
-        task: (_, task): Listr =>
-          task.newListr(
-            [
-              {
-                title: 'Removing old build',
-                task: async (_) => {
-                  fs.rmSync('./.nuxt', { recursive: true, force: true })
-                  fs.rmSync('./.output', { recursive: true, force: true })
-                },
+// ----------------------------
+// Main Listr Tasks definition
+// ----------------------------
+await new Listr(
+  [
+    {
+      title: 'Rebuilding Nuxt.js Sources',
+      task: () =>
+        new Listr(
+          [
+            {
+              title: 'Removing old build directories',
+              task: async () => {
+                await Promise.all([removeDir('.nuxt'), removeDir('.output')])
               },
-              {
-                title: 'Re-building nuxt sources',
-                task: async () => {
-                  await runCommand('nuxt generate', task)
-                },
+            },
+            {
+              title: 'Generating static build using Nuxt.js',
+              task: async (_, task) => {
+                await runCommand('nuxt', ['generate'], { task })
               },
-            ],
-          ),
-      },
-      {
-        title: 'Building capacitor',
-        task: (_, task): Listr =>
-          task.newListr(
-            [
-              {
-                title: 'Clearing old mobile builds',
-                task: async (_) => {
-                  fs.rmSync('./android', { recursive: true, force: true })
-                  fs.rmSync('./ios', { recursive: true, force: true })
-                },
+            },
+          ],
+          { concurrent: false },
+        ),
+    },
+    {
+      title: 'Configuring Capacitor Platforms',
+      task: () =>
+        new Listr(
+          [
+            {
+              title: 'Removing outdated mobile platform directories',
+              task: async () => {
+                await Promise.all([removeDir('android'), removeDir('ios')])
               },
-              {
-                title: 'Adding mobile sources',
-                task: async (_, task) => {
-                  await runCommand('npx cap add android', task)
-                  await runCommand('npx cap add ios', task)
-                  await runCommand('npx cap sync', task)
-                },
+            },
+            {
+              title: 'Adding Capacitor mobile platforms',
+              task: async (_, task) => {
+                await runCommand('npx', ['cap', 'add', 'android'], { task })
+                await runCommand('npx', ['cap', 'add', 'ios'], { task })
+                await runCommand('npx', ['cap', 'sync'], { task })
               },
-            ],
-          ),
-      },
-      {
-        title: `Android app`,
-        task: (_, task): Listr =>
-          task.newListr(
-            [
-              {
-                title: 'Download Java SE 20',
-                task: async (_, task) => {
-                  task.title = 'Checking java'
-                  if (!doesFileExist('./jdk/jdk-20/bin/java')) {
-                    await downloadFile(task, 'https://download.java.net/openjdk/jdk20/ri/openjdk-20+36_linux-x64_bin.tar.gz', 'jdk-20.tar.gz')
-                    task.title = 'Unzipping archive'
-                    await runCommand('rm -rf ./jdk && mkdir jdk && tar -xzf jdk-20.tar.gz -C jdk && rm jdk-20.tar.gz')
-                    task.title = 'Java installed'
+            },
+          ],
+          { concurrent: false },
+        ),
+    },
+    {
+      title: 'Assembling Android Application',
+      task: () =>
+        new Listr(
+          [
+            {
+              title: 'Verifying Java SE 20 Installation',
+              task: async (ctx, subTask) => {
+                subTask.title = 'Checking Java installation'
+                if (!(await fileExistsAsync(config.JAVA_BIN))) {
+                  const systemConfig: Partial<
+                    Record<NodeJS.Platform, Partial<Record<NodeJS.Architecture, string>>>
+                  > = {
+                    linux: {
+                      x64: 'https://download.oracle.com/java/20/archive/jdk-20.0.2_linux-x64_bin.tar.gz',
+                      arm64: 'https://download.oracle.com/java/20/archive/jdk-20.0.2_linux-aarch64_bin.tar.gz',
+                    },
+                    // TODO: add support for win32
+                    // win32: {
+                    //   x64: 'https://download.oracle.com/java/20/archive/jdk-20.0.2_windows-x64_bin.zip',
+                    // },
                   }
-                  else {
-                    task.title = 'Java already installed'
+                  const platform = os.platform() as keyof typeof systemConfig
+                  const arch = os.arch()
+                  const url = systemConfig[platform]?.[arch]
+                  if (!url) {
+                    throw new Error(
+                      'Your system platform is not supported for Java installation',
+                    )
                   }
-                },
+                  await createDir(config.JAVA_DIR)
+                  await ensureDownloadedAndExtracted(
+                    config.JAVA_BIN,
+                    url,
+                    `jdk-${config.JAVA_VERSION}.tar.gz`,
+                    [
+                      `rm -rf ${config.JAVA_DIR}/* && mkdir -p ${config.JAVA_DIR}`,
+                      `tar -xzf jdk-${config.JAVA_VERSION}.tar.gz -C ${config.JAVA_DIR}`,
+                      `rm jdk-${config.JAVA_VERSION}.tar.gz`,
+                    ],
+                    { task: subTask },
+                  )
+                  subTask.title = 'Java installed'
+                }
+                else {
+                  subTask.title = 'Java already installed'
+                }
               },
-              {
-                title: 'Download Command Line Tools for Android',
-                task: async (_, task) => {
-                  if (isSdkInstalled) {
-                    task.title = 'Command Line Tools already installed'
-                  }
-                  else {
-                    await downloadFile(task, 'https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip', 'cmdline-tools.zip')
-
-                    task.title = 'Unzipping archive'
-                    await runCommand('rm -rf ./cmdline-tools && unzip cmdline-tools.zip && rm cmdline-tools.zip')
-
-                    task.title = 'Command Line Tools successfully installed'
-                  }
-                },
+            },
+            {
+              title: 'Verifying Android SDK Installation',
+              task: async (ctx, subTask) => {
+                const toolsCheck = config.ANDROID_TOOLS.map(tool =>
+                  fileExistsAsync(`${config.SDK_DIR}/${tool.replace(';', '/')}/package.xml`),
+                )
+                const sdkInstalled = (await Promise.all(toolsCheck)).every(Boolean)
+                // Save the result for later tasks.
+                ctx.isSdkInstalled = sdkInstalled
+                subTask.title = sdkInstalled
+                  ? 'Android SDK is installed'
+                  : 'Android SDK not found, installation required'
               },
-              {
-                title: 'Download additional tools for Android',
-                task: async (_, task) => {
-                  if (isSdkInstalled) {
-                    task.title = 'Additional tools already installed'
-                  }
-                  else {
-                    task.title = 'While you using this application, you accepting all Android licenses'
-
-                    await runCommand(`yes | ./cmdline-tools/bin/sdkmanager --sdk_root=${currentPath}/sdk --licenses`)
-
-                    for (const tool of tools) {
-                      task.title = `Downloading ${tool}`
-                      await runCommand(`./cmdline-tools/bin/sdkmanager --sdk_root=${currentPath}/sdk --install "${tool}"`)
-                    }
-
-                    task.title = 'Additional tools successfully installed'
-                  }
-                },
+            },
+            {
+              title: 'Installing Android Command Line Tools',
+              task: async (ctx, subTask) => {
+                if (ctx.isSdkInstalled) {
+                  subTask.skip('Command Line Tools already installed')
+                  return
+                }
+                if (!(await fileExistsAsync(config.CMD_TOOLS_ARCHIVE))) {
+                  await downloadFile(
+                    subTask,
+                    'https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip',
+                    config.CMD_TOOLS_ARCHIVE,
+                  )
+                }
+                subTask.title = 'Unzipping archive'
+                await runCommand(
+                  'bash',
+                  ['-c', `"rm -rf ${config.CMD_TOOLS_DIR} && unzip ${config.CMD_TOOLS_ARCHIVE}"`],
+                  { task: subTask },
+                )
+                subTask.title = 'Android Command Line Tools installed'
               },
-              {
-                title: `Setup local env ${!ANDROID_DEVICE_ID || ANDROID_DEVICE_ID.length === 0 ? '[Will be skipped]' : ''}`,
-                skip: !ANDROID_DEVICE_ID || ANDROID_DEVICE_ID.length === 0,
-                task: async (_, task) => {
-                  await runCommand(`export PATH=${currentPath}/jdk/jdk-20/bin:$PATH && export ANDROID_SDK_ROOT=${currentPath}/sdk`)
-                  task.title = 'ENV successfully setuped'
-                },
+            },
+            {
+              title: 'Installing Additional Android SDK Components',
+              skip: ctx =>
+                ctx.isSdkInstalled ? 'Additional tools already installed' : false,
+              task: async (ctx, subTask) => {
+                subTask.title = 'Accepting Android licenses'
+                await createDir(config.SDK_DIR)
+                await runCommand('bash', ['-c', `"chmod +x ./${config.CMD_TOOLS_DIR}/bin/sdkmanager"`], {
+                  task: subTask,
+                  disableOutput: true,
+                })
+                await runCommand(
+                  'bash',
+                  [
+                    '-c',
+                    `"yes | ./${config.CMD_TOOLS_DIR}/bin/sdkmanager --sdk_root=${currentPath}/${config.SDK_DIR.slice(2)} --licenses"`,
+                  ],
+                  { task: subTask, disableOutput: true },
+                )
+
+                for (const tool of config.ANDROID_TOOLS) {
+                  subTask.title = `Installing: ${tool}`
+                  await runCommand(
+                    'bash',
+                    [
+                      '-c',
+                      `"./${config.CMD_TOOLS_DIR}/bin/sdkmanager --sdk_root=${currentPath}/${config.SDK_DIR.slice(2)} --install '${tool}'"`,
+                    ],
+                    { task: subTask, disableOutput: true },
+                  )
+                }
+                subTask.title = 'Additional Android SDK components installed'
               },
-              {
-                title: `Build android project ${!ANDROID_DEVICE_ID || ANDROID_DEVICE_ID.length === 0 ? '[Will be skipped]' : ''}`,
-                skip: !ANDROID_DEVICE_ID || ANDROID_DEVICE_ID.length === 0,
-                task: async (_, task) => {
-                  task.title = 'Gradle is building project, please wait'
-                  await runCommand('pnpm --package=@capacitor/cli dlx cap build android', task)
-                  task.title = 'Gradle building is finished'
-                },
+            },
+            {
+              title: 'Compiling Android Project using Gradle',
+              task: async (ctx, subTask) => {
+                subTask.title = 'Gradle is building the project, please wait'
+                await runCommand('bash', ['-c', '"cd android && ./gradlew assembleDebug"'], {
+                  task: subTask,
+                  maxOutputLines: 3,
+                })
+                subTask.title = `APK built at: ${currentPath}/android/app/build/outputs/apk/debug/app-debug.apk`
               },
-              {
-                title: `Run apk ${!ANDROID_DEVICE_ID || ANDROID_DEVICE_ID.length === 0 ? '[Will be skipped]' : ''}`,
-                skip: !ANDROID_DEVICE_ID || ANDROID_DEVICE_ID.length === 0,
-                task: async (_, task) => {
-                  task.title = `Running app on device ${ANDROID_DEVICE_ID}`
-                  await runCommand(`pnpm --package=@capacitor/cli dlx cap run android --no-sync --target ${ANDROID_DEVICE_ID}`, task)
-                  task.title = `Launched at ${ANDROID_DEVICE_ID}`
-                },
+            },
+            {
+              title: `Deploying APK to Connected Android Device ${!process.env.ANDROID_DEVICE_ID ? '[Will be skipped]' : ''}`,
+              skip: () => !process.env.ANDROID_DEVICE_ID,
+              task: async (ctx, subTask) => {
+                const adb = `${config.SDK_DIR}/platform-tools/adb`
+                const launchAppCommand = 'shell monkey -p app.hapticx.procollege -c android.intent.category.LAUNCHER 1'.split(' ')
+
+                subTask.title = 'Checking connected devices'
+                await runCommand(adb, ['devices'], { task: subTask })
+                subTask.title = 'Installing APK on device'
+                await runCommand(
+                  adb,
+                  [
+                    '-s',
+                    process.env.ANDROID_DEVICE_ID,
+                    'install',
+                    './android/app/build/outputs/apk/debug/app-debug.apk',
+                  ],
+                  { task: subTask },
+                )
+                subTask.title = 'Launching the application'
+                await runCommand(adb, ['-s', process.env.ANDROID_DEVICE_ID, ...launchAppCommand])
+                subTask.title = `App launched on device: ${process.env.ANDROID_DEVICE_ID}`
               },
-            ],
-          ),
-      },
-    ],
-    { concurrent: false, rendererOptions: { collapseSubtasks: false } },
-  ).run()
-}
-catch (e) {
-  console.error(e)
-}
+            },
+          ],
+          { concurrent: false },
+        ),
+    },
+  ],
+  {
+    concurrent: false,
+    rendererOptions: {
+      collapseSubtasks: false,
+      formatOutput: 'wrap',
+    },
+  },
+).run()

+ 122 - 0
scripts/utils.ts

@@ -0,0 +1,122 @@
+import fs from 'node:fs/promises'
+import { spawn } from 'node:child_process'
+import { envVars } from './config'
+import { downloadFile } from './download'
+
+export async function fileExistsAsync(filePath: string): Promise<boolean> {
+  try {
+    await fs.access(filePath)
+    return true
+  }
+  catch {
+    return false
+  }
+}
+
+export async function removeDir(dir: string): Promise<void> {
+  await fs.rm(dir, { recursive: true, force: true })
+}
+
+export async function createDir(dirPath: string): Promise<void> {
+  await fs.mkdir(dirPath, { recursive: true })
+}
+
+export async function runCommand(
+  command: string,
+  args: string[] = [],
+  options: {
+    task?: { output?: string }
+    env?: NodeJS.ProcessEnv
+    disableOutput?: boolean
+    maxOutputLines?: number
+  } = { disableOutput: false },
+): Promise<void> {
+  if (!options.env) options.env = envVars
+
+  const maxOutputLines = options.maxOutputLines ?? 10
+
+  return new Promise((resolve, reject) => {
+    const finalEnv = { ...envVars, ...(options.env || {}) }
+
+    let outputLines: string[] = [`$ ${command} ${args.join(' ')}`]
+
+    let remainingData = ''
+
+    const child = spawn(command, args, { env: finalEnv, shell: true })
+
+    const updateOutput = (data: Buffer) => {
+      const text = data.toString()
+
+      const fullText = remainingData + text
+      const lines = fullText.split('\n')
+
+      if (!fullText.endsWith('\n')) {
+        remainingData = lines.pop() ?? ''
+      }
+      else {
+        remainingData = ''
+      }
+
+      outputLines.push(...lines)
+
+      if (outputLines.length > maxOutputLines) {
+        outputLines = outputLines.slice(outputLines.length - maxOutputLines)
+      }
+
+      if (options.task) {
+        options.task.output = outputLines.join('\n')
+      }
+    }
+
+    if (!options.disableOutput) {
+      child.stdout.on('data', updateOutput)
+      child.stderr.on('data', updateOutput)
+    }
+
+    child.on('error', reject)
+
+    child.on('close', (code) => {
+      if (remainingData) {
+        outputLines.push(remainingData)
+      }
+      if (outputLines.length > maxOutputLines) {
+        outputLines = outputLines.slice(outputLines.length - maxOutputLines)
+      }
+      if (options.task) {
+        options.task.output = outputLines.join('\n')
+      }
+      if (code === 0) {
+        resolve()
+      }
+      else {
+        reject(
+          new Error(
+            `Command "${command} ${args.join(' ')}" exited with code ${code}\nOutput:\n${outputLines.join('\n')}\nENV: ${JSON.stringify(options.env)}`,
+          ),
+        )
+      }
+    })
+  })
+}
+
+export async function extractArchive(
+  archiveName: string,
+  extractCommand: string[],
+  options: { task?: { output?: string } } = {},
+) {
+  // TODO: extract zip using library
+  await runCommand('bash', ['-c', `"${extractCommand.join(' && ')}"`], options)
+}
+
+export async function ensureDownloadedAndExtracted(
+  filePath: string,
+  downloadUrl: string,
+  archiveName: string,
+  extractCommand: string[],
+  options: { task?: { output?: string } } = {},
+): Promise<void> {
+  if (!(await fileExistsAsync(filePath))) {
+    await downloadFile(options.task, downloadUrl, archiveName)
+    await extractArchive(archiveName, extractCommand, options)
+  }
+}