From 33a01d43b5888b204f0d302ef42aebdea5c2cbc9 Mon Sep 17 00:00:00 2001
From: Michael Wright <slooxied@gmail.com>
Date: Mon, 2 Nov 2020 09:53:07 +0100
Subject: [PATCH 1/3] feat: ios scenario numbers (#1287)

* added missing documentation

* added scenario-numbers to common flank config

* Added and fixed tests

* Fix PR comments and check if scenario numbers is less than 1024
---
 docs/index.md                                 | 14 +++++
 test_runner/flank.ios.yml                     |  7 +++
 .../src/main/kotlin/ftl/args/AndroidArgs.kt   |  1 -
 .../src/main/kotlin/ftl/args/CommonArgs.kt    |  1 +
 .../main/kotlin/ftl/args/CreateAndroidArgs.kt |  1 -
 .../main/kotlin/ftl/args/CreateCommonArgs.kt  |  1 +
 test_runner/src/main/kotlin/ftl/args/IArgs.kt |  1 +
 .../src/main/kotlin/ftl/args/IosArgs.kt       |  1 +
 .../kotlin/ftl/args/ValidateAndroidArgs.kt    |  3 +-
 .../main/kotlin/ftl/args/ValidateIosArgs.kt   |  8 +++
 .../ftl/config/android/AndroidGcloudConfig.kt | 11 ----
 .../ftl/config/common/CommonGcloudConfig.kt   | 11 ++++
 .../src/test/kotlin/ftl/args/IosArgsTest.kt   | 58 +++++++++++++++++++
 13 files changed, 104 insertions(+), 14 deletions(-)

diff --git a/docs/index.md b/docs/index.md
index b9477409b5..4366640083 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -160,6 +160,13 @@ gcloud:
   #   - gs://bucket/additional.ipa
   #   - path/to/local/ipa/file.ipa
 
+  ## A list of game-loop scenario numbers which will be run as part of the test (default: all scenarios).
+  ## A maximum of 1024 scenarios may be specified in one test matrix, but the maximum number may also be limited by the overall test --timeout setting.
+  # scenario-numbers:
+  #   - 1
+  #   - 2
+  #   - 3
+
   ## The type of iOS test to run. TYPE must be one of: xctest, game-loop. Default: xctest
   # type: xctest
 
@@ -361,6 +368,13 @@ gcloud:
   #   - local/file/path/test1.obb
   #   - local/file/path/test2.obb
 
+  ## A list of game-loop scenario numbers which will be run as part of the test (default: all scenarios).
+  ## A maximum of 1024 scenarios may be specified in one test matrix, but the maximum number may also be limited by the overall test --timeout setting.
+  # scenario-numbers:
+  #   - 1
+  #   - 2
+  #   - 3
+
   ## A list of OBB required filenames. OBB file name must conform to the format as specified by Android e.g. 
   ## [main|patch].0300110.com.example.android.obb which will be installed into <shared-storage>/Android/obb/<package-name>/ on the device.
   # obb-names:
diff --git a/test_runner/flank.ios.yml b/test_runner/flank.ios.yml
index 6c3a2cfb25..5c50560f56 100644
--- a/test_runner/flank.ios.yml
+++ b/test_runner/flank.ios.yml
@@ -91,6 +91,13 @@ gcloud:
   #   - gs://bucket/additional.ipa
   #   - path/to/local/ipa/file.ipa
 
+  ## A list of game-loop scenario numbers which will be run as part of the test (default: all scenarios).
+  ## A maximum of 1024 scenarios may be specified in one test matrix, but the maximum number may also be limited by the overall test --timeout setting.
+  # scenario-numbers:
+  #   - 1
+  #   - 2
+  #   - 3
+
   ## The type of iOS test to run. TYPE must be one of: xctest, game-loop. Default: xctest
   # type: xctest
 
diff --git a/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt b/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt
index 0476fd8395..42c76f6c70 100644
--- a/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt
@@ -15,7 +15,6 @@ data class AndroidArgs(
     val environmentVariables: Map<String, String>, // should not be printed, becuase could contains sensitive informations
     val directoriesToPull: List<String>,
     val grantPermissions: String?,
-    val scenarioNumbers: List<String>,
     val scenarioLabels: List<String>,
     val obbFiles: List<String>,
     val obbNames: List<String>,
diff --git a/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt b/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt
index 94f5110cbe..7cdb512aa4 100644
--- a/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt
@@ -20,6 +20,7 @@ data class CommonArgs(
     override val networkProfile: String?,
     override val otherFiles: Map<String, String>,
     override val type: Type?,
+    override val scenarioNumbers: List<String>,
 
     // flank
     override val project: String,
diff --git a/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt b/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt
index 0b9d3de164..c8520a936c 100644
--- a/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt
@@ -41,6 +41,5 @@ fun createAndroidArgs(
     obfuscateDumpShards = obfuscate,
     obbFiles = gcloud.obbfiles!!,
     obbNames = gcloud.obbnames!!,
-    scenarioNumbers = gcloud.scenarioNumbers!!,
     grantPermissions = gcloud.grantPermissions
 )
diff --git a/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt b/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt
index 5c3665e909..41c0dcb3c1 100644
--- a/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt
@@ -26,6 +26,7 @@ fun CommonConfig.createCommonArgs(
     networkProfile = gcloud.networkProfile,
     clientDetails = gcloud.clientDetails,
     otherFiles = gcloud.otherFiles!!.mapValues { (_, path) -> path.normalizeFilePath() },
+    scenarioNumbers = gcloud.scenarioNumbers!!,
     type = gcloud.type?.toType(),
 
     // flank
diff --git a/test_runner/src/main/kotlin/ftl/args/IArgs.kt b/test_runner/src/main/kotlin/ftl/args/IArgs.kt
index e98c5fa2fe..a046d1bc21 100644
--- a/test_runner/src/main/kotlin/ftl/args/IArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/IArgs.kt
@@ -24,6 +24,7 @@ interface IArgs {
     val resultsHistoryName: String?
     val flakyTestAttempts: Int
     val otherFiles: Map<String, String>
+    val scenarioNumbers: List<String>
     val type: Type? get() = null
 
     // FlankYml
diff --git a/test_runner/src/main/kotlin/ftl/args/IosArgs.kt b/test_runner/src/main/kotlin/ftl/args/IosArgs.kt
index c85b505887..017dee948f 100644
--- a/test_runner/src/main/kotlin/ftl/args/IosArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/IosArgs.kt
@@ -42,6 +42,7 @@ IosArgs
       num-flaky-test-attempts: $flakyTestAttempts
       other-files: ${ArgsToString.mapToString(otherFiles)}
       additional-ipas: ${ArgsToString.listToString(additionalIpas)}
+      scenario-numbers: ${ArgsToString.listToString(scenarioNumbers)}
       type: ${type?.ymlName}
 
     flank:
diff --git a/test_runner/src/main/kotlin/ftl/args/ValidateAndroidArgs.kt b/test_runner/src/main/kotlin/ftl/args/ValidateAndroidArgs.kt
index 15e4f05d4a..ca974c8b22 100644
--- a/test_runner/src/main/kotlin/ftl/args/ValidateAndroidArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/ValidateAndroidArgs.kt
@@ -56,9 +56,10 @@ private fun AndroidArgs.assertLabelContent() {
         else -> obbFiles.forEach { ArgsHelper.assertFileExists(it, " (obb file)") }
     }
 
-    if (scenarioNumbers.isNotEmpty() && (type == null || type != Type.GAMELOOP))
+    if (scenarioNumbers.isNotEmpty() && (type != Type.GAMELOOP))
         throw FlankConfigurationError("Scenario numbers defined but Type is not Game-loop.")
     scenarioNumbers.forEach { it.toIntOrNull() ?: throw FlankConfigurationError("Invalid scenario number provided - $it") }
+    if (scenarioNumbers.size > 1024) throw FlankConfigurationError("There cannot be more than 1024 Scenario numbers")
 }
 
 private const val MAX_OBB_FILES = 2
diff --git a/test_runner/src/main/kotlin/ftl/args/ValidateIosArgs.kt b/test_runner/src/main/kotlin/ftl/args/ValidateIosArgs.kt
index 0f3191f54d..bc0bb848a0 100644
--- a/test_runner/src/main/kotlin/ftl/args/ValidateIosArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/ValidateIosArgs.kt
@@ -15,6 +15,14 @@ fun IosArgs.validate() = apply {
     checkResultsDirUnique()
     assertAdditionalIpas()
     validType()
+    assertGameloop()
+}
+
+fun IosArgs.assertGameloop() {
+    if (scenarioNumbers.isNotEmpty() && (type != Type.GAMELOOP))
+        throw FlankConfigurationError("Scenario numbers defined but Type is not Game-loop.")
+    scenarioNumbers.forEach { it.toIntOrNull() ?: throw FlankConfigurationError("Invalid scenario number provided - $it") }
+    if (scenarioNumbers.size > 1024) throw FlankConfigurationError("There cannot be more than 1024 Scenario numbers")
 }
 
 fun IosArgs.validateRefresh() = apply {
diff --git a/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt b/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt
index e5aa19c973..11c6e32da5 100644
--- a/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt
+++ b/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt
@@ -115,16 +115,6 @@ data class AndroidGcloudConfig @JsonIgnore constructor(
     @set:JsonProperty("directories-to-pull")
     var directoriesToPull: List<String>? by data
 
-    @set:CommandLine.Option(
-        names = ["--scenario-numbers"],
-        split = ",",
-        description = ["A list of game-loop scenario numbers which will be run as part of the test (default: all scenarios). " +
-                "A maximum of 1024 scenarios may be specified in one test matrix, " +
-                "but the maximum number may also be limited by the overall test --timeout setting."]
-    )
-    @set:JsonProperty("scenario-numbers")
-    var scenarioNumbers: List<String>? by data
-
     @set:CommandLine.Option(
         names = ["--scenario-labels"],
         split = ",",
@@ -250,7 +240,6 @@ data class AndroidGcloudConfig @JsonIgnore constructor(
             environmentVariables = emptyMap()
             grantPermissions = FlankDefaults.GRANT_PERMISSIONS_ALL
             directoriesToPull = emptyList()
-            scenarioNumbers = emptyList()
             scenarioLabels = emptyList()
             obbfiles = emptyList()
             obbnames = emptyList()
diff --git a/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt b/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt
index 3afb361ce5..341b8d4a03 100644
--- a/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt
+++ b/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt
@@ -130,6 +130,16 @@ data class CommonGcloudConfig @JsonIgnore constructor(
     @set:JsonProperty("other-files")
     var otherFiles: Map<String, String>? by data
 
+    @set:CommandLine.Option(
+        names = ["--scenario-numbers"],
+        split = ",",
+        description = ["A list of game-loop scenario numbers which will be run as part of the test (default: all scenarios). " +
+                "A maximum of 1024 scenarios may be specified in one test matrix, " +
+                "but the maximum number may also be limited by the overall test --timeout setting."]
+    )
+    @set:JsonProperty("scenario-numbers")
+    var scenarioNumbers: List<String>? by data
+
     @set:CommandLine.Option(
         names = ["--type"],
         description = ["The type of test to run. TYPE must be one of: instrumentation, robo, xctest, game-loop."]
@@ -162,6 +172,7 @@ data class CommonGcloudConfig @JsonIgnore constructor(
             devices = listOf(defaultDevice(android))
             otherFiles = emptyMap()
             type = null
+            scenarioNumbers = emptyList()
         }
     }
 }
diff --git a/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt b/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt
index dd94ebad87..e9460a3ec3 100644
--- a/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt
+++ b/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt
@@ -241,6 +241,7 @@ IosArgs
       additional-ipas: 
         - $testIpa1
         - $testIpa2
+      scenario-numbers: 
       type: xctest
 
     flank:
@@ -301,6 +302,7 @@ IosArgs
       num-flaky-test-attempts: 0
       other-files: 
       additional-ipas: 
+      scenario-numbers: 
       type: xctest
 
     flank:
@@ -1127,6 +1129,62 @@ IosArgs
             assertFalse(systemOutRule.log.contains("WARNING: Google cloud storage result directory should be unique, otherwise results from multiple test matrices will be overwritten or intermingled"))
         }
     }
+
+    @Test
+    fun `should not throw exception if game-loop is provided and nothing else`() {
+        val yaml = """
+        gcloud:
+          test: $testPath
+          xctestrun-file: $testPath
+          results-dir: test
+          type: game-loop
+        """.trimIndent()
+        IosArgs.load(yaml).validate()
+    }
+
+    @Test(expected = FlankConfigurationError::class)
+    fun `should throw exception if game-loop is not provided and scenario numbers are`() {
+        val yaml = """
+        gcloud:
+          test: $testPath
+          xctestrun-file: $testPath
+          results-dir: test
+          scenario-numbers:
+             - 1
+             - 2
+        """.trimIndent()
+        IosArgs.load(yaml).validate()
+    }
+
+    @Test
+    fun `should not throw exception if game-loop is provided and scenario numbers are`() {
+        val yaml = """
+        gcloud:
+          test: $testPath
+          xctestrun-file: $testPath
+          results-dir: test
+          type: game-loop
+          scenario-numbers:
+             - 1
+             - 2
+        """.trimIndent()
+        IosArgs.load(yaml).validate()
+    }
+
+    @Test(expected = FlankConfigurationError::class)
+    fun `should throw exception if invalid scenario numbers are provided`() {
+        val yaml = """
+        gcloud:
+          test: $testPath
+          xctestrun-file: $testPath
+          results-dir: test
+          type: game-loop
+          scenario-numbers:
+             - error1
+             - error2
+        """.trimIndent()
+        IosArgs.load(yaml).validate()
+    }
 }
 
 private fun IosArgs.Companion.load(yamlData: String, cli: IosRunCommand? = null): IosArgs =

From eba5ffecafd3bd26aa61ba784f655d7051f1bccb Mon Sep 17 00:00:00 2001
From: piotradamczyk5 <65554637+piotradamczyk5@users.noreply.github.com>
Date: Mon, 2 Nov 2020 13:31:04 +0100
Subject: [PATCH 2/3] refactor: Rewrite scripts to Kotlin (#1246)

* feat: Change updateFlank script to Kotlin

* create PathHelper

* added buildGo script

* GradleCommand, updateFlank - windows compability

* fix GradleCommand.kt

* added buildFlankScripts.main.kts

* fixed buildGo script

* added testFilters helpers

* Add android paths, initial work of android build command

* Add build of base android apk and tests with copy to directory

* added update library kotlin script

* WIP added first iteration of windows file downloads

* Add duplicated apk names build and doc

* Update android.ops.main.kts

* Added build of multiModule app and cucumber App

* Update android.ops.main.kts

* adding updatingJson and generatingJavaClient

* initial ios

* Update ios.ops.main.kts

* Update ios.ops.main.kts

* Update ios.ops.main.kts

* run pod install working

* Build and copy ios artifacts

* added ios scripts

* added iosScripts

* directories reorganization

* refactor utils

* Update ios.ops.main.kts

* Ios Command in separated file

* ios example change to createCommand

* Move some functions of ios to downloadSoftware main

* refactor utils

* Update ios.ops.main.kts

* Add go build execution to ops

* added shell and bat script for firebase api

* added shell for generate java client

* refactor gradle helper

* Update buildGo.main.kts

* Update buildGo.main.kts

* add scripts for update flank and build flank scripts

* refractor

* added scripts for go

* added directory to scripts

* added script fo unversal framework

* clean up

* Gradle compability with windows

* fix execution

* Update android script

* Update updateFlank.main.kts

* Windows compability for build/update flank

* Update formating

* fast fail on mac only scripts

* Added section to web page

* fix windows scripts

* Add build option for standalone run

* remove unused environment declaration

* Update flank-bash/scripts/android.ops.main.kts

Co-authored-by: piotradamczyk5 <65554637+piotradamczyk5@users.noreply.github.com>

* code formating

* code style changes

* Add check and install command

* Add download software

* rewrite updateBinaries to flankScripts

* rewrite updateBinaries to flankScripts

* rewrite updateBinaries to flankScripts

* Move firebase to shell

* Add custom error on generate java client

* rewrite ios to flankScripts

* Add UpdateApiJsonCommand

* Ops commands initial

* Add android build

* rewrite buildFlank, testFilter and goops to flankScripts

* Change classes to objects

* added ios ops and make some code and documentation changes

* added shell command

* fixed scripts for building flankScripts

* remove standalon flank bash

* fixed native scripts

* fix native scripts and descriptions

* update documentation

* fix scripts

* Add --copy --generate --artifacts options

* update documentation for ops

* Update ops.sh

* Add ios commands

* Add windows batch script

* Add batch script to build android

* Fix build on windows

* Update EarlGrey ops

* Update simple-ios-flank.yml

* fix ops.sh script

* fixed pipe

* Add support for FlankExample build

* Add commands for biuld flankExample and earlGreyExample

* Update README.md

* Update buildFlankScripts.bat

Co-authored-by: Adam <adam.filipowicz92@gmail.com>
Co-authored-by: Michael Wright <Slooxied@gmail.com>
Co-authored-by: adamfilipow92 <64852261+adamfilipow92@users.noreply.github.com>
---
 buildSrc/src/main/kotlin/Dependencies.kt      |   2 +
 buildSrc/src/main/kotlin/Versions.kt          |   2 +
 firebase_apis/generate_java_client.bat        |   2 +
 firebase_apis/generate_java_client.sh         |  34 +----
 firebase_apis/update_api_json.bat             |   2 +
 firebase_apis/update_api_json.sh              |  20 +--
 flank-scripts/README.md                       |  81 ++++++++++++
 flank-scripts/bash/buildFlankScripts.bat      |   1 -
 flank-scripts/bash/flankScripts.bat           |   4 +
 flank-scripts/build.gradle.kts                |   2 +
 .../src/main/kotlin/flank/scripts/Main.kt     |   4 +-
 .../exceptions/FlankScriptsExceptions.kt      |   6 +
 .../kotlin/flank/scripts/shell/BuildFlank.kt  |  35 +++++
 .../flank/scripts/shell/ShellCommand.kt       |  33 +++++
 .../scripts/shell/firebase/FirebaseCommand.kt |  18 +++
 .../firebase/GenerateJavaClientCommand.kt     |  36 ++++++
 .../shell/firebase/UpdateApiJsonCommand.kt    |  31 +++++
 .../flank/scripts/shell/ios/BuildExample.kt   |  57 ++++++++
 .../flank/scripts/shell/ios/BuildFtl.kt       |  47 +++++++
 .../shell/ios/InstallXcPrettyCommand.kt       |  12 ++
 .../scripts/shell/ios/IosBuildCommand.kt      |  10 ++
 .../flank/scripts/shell/ios/LipoHelper.kt     |   6 +
 .../flank/scripts/shell/ios/RunFtlLocal.kt    |  32 +++++
 .../scripts/shell/ios/SetupIosEnvCommand.kt   |  17 +++
 .../scripts/shell/ios/UniversalFramework.kt   |  61 +++++++++
 .../scripts/shell/ops/AndroidOpsCommand.kt    |  26 ++++
 .../flank/scripts/shell/ops/BuildAndroid.kt   | 118 +++++++++++++++++
 .../shell/ops/BuildEarlGreyExampleCommand.kt  |  35 +++++
 .../shell/ops/BuildFlankExampleCommand.kt     |  32 +++++
 .../flank/scripts/shell/ops/BuildIos.kt       |  99 ++++++++++++++
 .../kotlin/flank/scripts/shell/ops/GoOS.kt    |  11 ++
 .../flank/scripts/shell/ops/GoOpsCommand.kt   |  36 ++++++
 .../flank/scripts/shell/ops/OpsCommand.kt     |  18 +++
 .../shell/updatebinaries/UpdateAtomic.kt      |  52 ++++++++
 .../updatebinaries/UpdateBinariesCommand.kt   |  21 +++
 .../shell/updatebinaries/UpdateLlvm.kt        |  78 +++++++++++
 .../shell/updatebinaries/UpdateSwift.kt       |  79 ++++++++++++
 .../scripts/shell/utils/FastFailForWindows.kt |  11 ++
 .../scripts/shell/utils/GradleCommand.kt      |  17 +++
 .../flank/scripts/shell/utils/PathHelper.kt   |  22 ++++
 .../flank/scripts/shell/utils/ShellHelper.kt  |  12 ++
 .../flank/scripts/shell/utils/TestFilters.kt  |  28 ++++
 .../testartifacts/core/DownloadFixtures.kt    |   4 +-
 .../kotlin/flank/scripts/utils/Archive.kt     |  56 ++++++++
 .../kotlin/flank/scripts/utils/Download.kt    |   6 +-
 .../flank/scripts/utils/DownloadSoftware.kt   |  30 +++++
 .../main/kotlin/flank/scripts/utils/Env.kt    |   2 +
 .../flank/scripts/utils/ShellExecute.kt       |  25 +++-
 test_projects/android/build.bat               |   2 +
 test_projects/android/ops.sh                  | 122 +-----------------
 test_projects/gohello/build.bat               |   2 +
 test_projects/gohello/build.sh                |  12 +-
 .../ios/EarlGreyExample/build_example.sh      |  35 +----
 .../ios/EarlGreyExample/build_ftl.sh          |  28 +---
 test_projects/ios/EarlGreyExample/ops.sh      |  67 +---------
 .../ios/EarlGreyExample/run_ftl_local.sh      |  17 +--
 .../EarlGreyExample/universal_framework.sh    |  28 +---
 test_projects/ops.bat                         |  18 +++
 test_projects/ops.sh                          |  20 +--
 test_runner/bash/update_flank.bat             |   9 +-
 test_runner/bash/update_flank.sh              |   8 +-
 61 files changed, 1364 insertions(+), 377 deletions(-)
 create mode 100755 firebase_apis/generate_java_client.bat
 create mode 100755 firebase_apis/update_api_json.bat
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/BuildFlank.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ShellCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/FirebaseCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/GenerateJavaClientCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/UpdateApiJsonCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildExample.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildFtl.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/InstallXcPrettyCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/IosBuildCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/LipoHelper.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/RunFtlLocal.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/SetupIosEnvCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ios/UniversalFramework.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/AndroidOpsCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildAndroid.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildEarlGreyExampleCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildFlankExampleCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildIos.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOS.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOpsCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/ops/OpsCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateAtomic.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateBinariesCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateLlvm.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateSwift.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/utils/FastFailForWindows.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/utils/GradleCommand.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/utils/PathHelper.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/utils/ShellHelper.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/shell/utils/TestFilters.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/utils/Archive.kt
 create mode 100644 flank-scripts/src/main/kotlin/flank/scripts/utils/DownloadSoftware.kt
 create mode 100644 test_projects/android/build.bat
 create mode 100644 test_projects/gohello/build.bat
 create mode 100644 test_projects/ops.bat

diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt
index 38a627e763..0b5dea3838 100644
--- a/buildSrc/src/main/kotlin/Dependencies.kt
+++ b/buildSrc/src/main/kotlin/Dependencies.kt
@@ -54,6 +54,8 @@ object Dependencies {
     const val KOTLIN_SERIALIZATION = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.KOTLIN_SERIALIZATION}"
 
     //region flank-scripts
+    const val ARCHIVE_LIB = "org.rauschig:jarchivelib:${Versions.ARCHIVE_LIB}"
+    const val TUKAANI_XZ = "org.tukaani:xz:${Versions.TUKAANI_XZ}"
     const val CLIKT = "com.github.ajalt:clikt:${Versions.CLIKT}"
     const val JCABI_GITHUB = "com.jcabi:jcabi-github:${Versions.JCABI_GITHUB}"
     const val SLF4J_NOP = "org.slf4j:slf4j-nop:${Versions.SLF4J_NOP}"
diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt
index 837a6222c6..b2dae80325 100644
--- a/buildSrc/src/main/kotlin/Versions.kt
+++ b/buildSrc/src/main/kotlin/Versions.kt
@@ -88,6 +88,8 @@ object Versions {
     const val PROGUARD = "7.0.0"
 
     // ============== flank-scripts ==============
+    const val ARCHIVE_LIB = "1.1.0"
+    const val TUKAANI_XZ = "1.0"
     const val KOTLIN_SERIALIZATION = "1.0.0"
     const val FUEL = "2.3.0"
     const val CLIKT = "2.8.0"
diff --git a/firebase_apis/generate_java_client.bat b/firebase_apis/generate_java_client.bat
new file mode 100755
index 0000000000..7b27596f9d
--- /dev/null
+++ b/firebase_apis/generate_java_client.bat
@@ -0,0 +1,2 @@
+SET DIR=%~dp0
+%DIR%\..\..\flank-scripts\bash\flankScripts.bat shell firebase generateJavaClient
diff --git a/firebase_apis/generate_java_client.sh b/firebase_apis/generate_java_client.sh
index 08cc4b5f61..1ed7797d25 100755
--- a/firebase_apis/generate_java_client.sh
+++ b/firebase_apis/generate_java_client.sh
@@ -1,32 +1,2 @@
-#!/bin/bash
-
-# Note: Must have already installed google-apis-client-generator from the master branch. PIP release will not work!
-
-# git clone https://github.com/google/apis-client-generator.git
-# xcode-select --install
-# brew install python@2
-# export PATH="/usr/local/opt/python@2/libexec/bin:$PATH"
-# pip install --upgrade pip setuptools
-# pip install .
-
-# Generate only the testing library since the others are published officially already.
-
-#  generate_library \
-#     --input=./storage_v1.json \
-#     --language=java \
-#     --output_dir=./storage
-
-rm -rf "./test_api/src/"
-
- generate_library \
-    --input=./json/testing_v1.json \
-    --language=java \
-    --package_path=api/services \
-    --output_dir=./test_api/src/main/java
-
-mv ./test_api/src/main/java/pom.xml ./test_api/pom.xml
-
-#  generate_library \
-#     --input=./toolresults_v1beta3.json \
-#     --language=java \
-#     --output_dir=./apis/toolresults
+DIR=`dirname "$BASH_SOURCE"`
+$DIR/../flank-scripts/bash/flankScripts shell firebase generateJavaClient
diff --git a/firebase_apis/update_api_json.bat b/firebase_apis/update_api_json.bat
new file mode 100755
index 0000000000..1de7925542
--- /dev/null
+++ b/firebase_apis/update_api_json.bat
@@ -0,0 +1,2 @@
+SET DIR=%~dp0
+%DIR%\..\..\flank-scripts\bash\flankScripts.bat shell firebase updateApiJson
diff --git a/firebase_apis/update_api_json.sh b/firebase_apis/update_api_json.sh
index f1635105cc..a07105b5b1 100755
--- a/firebase_apis/update_api_json.sh
+++ b/firebase_apis/update_api_json.sh
@@ -1,18 +1,2 @@
-#!/usr/bin/env bash
-
-# npm -g install sort-json
-
-# Note: API discovery JSON is out of date. Check the gcloud CLI repo for most recent JSON.
-# https://github.com/bootstraponline/gcloud_cli/blob/master/google-cloud-sdk/lib/googlecloudsdk/third_party/apis/testing_v1.json
-
-cd json
-
-TOOL_RESULTS=toolresults_v1beta3.json
-rm "$TOOL_RESULTS"
-curl -o "$TOOL_RESULTS" https://www.googleapis.com/discovery/v1/apis/toolresults/v1beta3/rest
-sort-json "$TOOL_RESULTS"
-
-TESTING=testing_v1.json
-rm "$TESTING"
-curl -o "$TESTING" https://www.googleapis.com/discovery/v1/apis/testing/v1/rest
-sort-json "$TESTING"
+DIR=`dirname "$BASH_SOURCE"`
+$DIR/../flank-scripts/bash/flankScripts shell firebase updateApiJson
diff --git a/flank-scripts/README.md b/flank-scripts/README.md
index 79c736387b..4eaaa3791a 100644
--- a/flank-scripts/README.md
+++ b/flank-scripts/README.md
@@ -163,3 +163,84 @@ All [testArtifacts](../flank-scripts/src/main/kotlin/flank/scripts/testartifacts
   #### `resolve` 
   Automatically prepare local artifacts if needed.
 
+### Shell
+
+To show all available commands for shell use: `flankScripts shell`
+
+Available commands are:
+  - `firebase`               Contains all firebase commands  
+  - `iosBuildExample`        Build example ios app
+  - `iosBuildFtl`            Build ftl ios app
+  - `iosRunFtlLocal`         Run ftl locally ios app
+  - `iosUniversalFramework`  Create Universal Framework
+  - `ops`                    Contains all ops command: android, ios, gp
+  - `updateBinaries`         Update binaries used by Flank
+  - `buildFlank`             Build Flank
+
+#### `firebase` 
+
+Contains tasks related to firebase client generation.  
+These tasks are :
+  - `updateApiJson`      Download file for generating client
+  - `generateJavaClient`   Generates Java client
+
+##### `updateApiJson`
+Download file for generating client
+
+##### `generateJavaClient`
+Generate Java Client from json schema
+
+#### `iosBuildExample` 
+Build example ios app
+
+#### `iosBuildFtl` 
+Build ftl ios app
+
+#### `iosRunFtlLocal` 
+Run ftl locally ios app
+
+| Option      | Description                                                              |
+|-------------|--------------------------------------------------------------------------|
+| --device-id | Device id. Please take it from Xcode -> Window -> Devices and Simulators |
+
+#### `iosUniversalFramework` 
+
+#### `ops` 
+Contains tasks related to building sample apps with tests.  
+These tasks are :
+  - `go`        Build go app with tests
+  - `ios`       Build ios app with tests
+  - `android`   Build android apks with tests
+  
+##### `go`
+Build go app with tests
+
+##### `build_earl_grey_example`
+Build ios earl grey example app with tests
+
+| Option     | Short option | Description              |
+|------------|--------------|--------------------------|
+| --generate | -g           | Make build               |
+| --copy     | -c           | Copy output files to tmp |
+
+##### `build_flank_example`
+Build ios flank example app with tests
+
+| Option     | Short option | Description              |
+|------------|--------------|--------------------------|
+| --generate | -g           | Make build               |
+| --copy     | -c           | Copy output files to tmp |
+
+##### `android`
+Build android apks with tests
+
+| Option     | Short option | Description              |
+|------------|--------------|--------------------------|
+| --generate | -g           | Make build               |
+| --copy     | -c           | Copy output files to tmp |
+
+#### `updateBinaries` 
+Update binaries used by Flank
+
+#### `buildFlank` 
+Build Flank test runner
diff --git a/flank-scripts/bash/buildFlankScripts.bat b/flank-scripts/bash/buildFlankScripts.bat
index 7c701773a2..aeeb65e9c6 100755
--- a/flank-scripts/bash/buildFlankScripts.bat
+++ b/flank-scripts/bash/buildFlankScripts.bat
@@ -1,4 +1,3 @@
-Rem REPLACE with #1246
 SET DIR=%~dp0
 
 SET FLANK_SCRIPTS=%DIR%\..
diff --git a/flank-scripts/bash/flankScripts.bat b/flank-scripts/bash/flankScripts.bat
index 8158f96d59..bed24e6664 100755
--- a/flank-scripts/bash/flankScripts.bat
+++ b/flank-scripts/bash/flankScripts.bat
@@ -1,4 +1,8 @@
 SET DIR=%~dp0
 SET scriptsJar=%DIR%\flankScripts.jar
 
+if not exist "%scriptsJar%" {
+    %DIR%\buildFlankScripts.bat
+}
+
 java -jar "%scriptsJar%" %*
diff --git a/flank-scripts/build.gradle.kts b/flank-scripts/build.gradle.kts
index b4f2c6c6fc..e89b908efe 100644
--- a/flank-scripts/build.gradle.kts
+++ b/flank-scripts/build.gradle.kts
@@ -74,6 +74,8 @@ dependencies {
     implementation(Dependencies.JCABI_GITHUB)
     implementation(Dependencies.SLF4J_NOP)
     implementation(Dependencies.GLASSFISH_JSON)
+    implementation(Dependencies.ARCHIVE_LIB)
+    implementation(Dependencies.TUKAANI_XZ)
 
     detektPlugins(Dependencies.DETEKT_FORMATTING)
 
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/Main.kt b/flank-scripts/src/main/kotlin/flank/scripts/Main.kt
index 082ff2dabd..81dfc4fe8f 100644
--- a/flank-scripts/src/main/kotlin/flank/scripts/Main.kt
+++ b/flank-scripts/src/main/kotlin/flank/scripts/Main.kt
@@ -5,6 +5,7 @@ import com.github.ajalt.clikt.core.subcommands
 import flank.scripts.ci.CiCommand
 import flank.scripts.dependencies.DependenciesCommand
 import flank.scripts.release.ReleaseCommand
+import flank.scripts.shell.ShellCommand
 import flank.scripts.testartifacts.TestArtifactsCommand
 
 class Main : CliktCommand(name = "flankScripts") {
@@ -17,6 +18,7 @@ fun main(args: Array<String>) {
         ReleaseCommand(),
         CiCommand(),
         DependenciesCommand,
-        TestArtifactsCommand()
+        TestArtifactsCommand(),
+        ShellCommand
     ).main(args)
 }
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/exceptions/FlankScriptsExceptions.kt b/flank-scripts/src/main/kotlin/flank/scripts/exceptions/FlankScriptsExceptions.kt
index 3299901d03..817f6e2c1d 100644
--- a/flank-scripts/src/main/kotlin/flank/scripts/exceptions/FlankScriptsExceptions.kt
+++ b/flank-scripts/src/main/kotlin/flank/scripts/exceptions/FlankScriptsExceptions.kt
@@ -16,3 +16,9 @@ class BugsnagException(val body: BugSnagResponse) : FlankScriptsExceptions() {
         return "Error while doing Bugnsag request, because of ${body.errors.joinToString()}"
     }
 }
+
+class ShellCommandException(private val errorMessage: String) : FlankScriptsExceptions() {
+    override fun toString(): String {
+        return "Error while executing shell command, details: $errorMessage"
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/BuildFlank.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/BuildFlank.kt
new file mode 100644
index 0000000000..c8fe356b00
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/BuildFlank.kt
@@ -0,0 +1,35 @@
+package flank.scripts.shell
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.utils.createGradleCommand
+import flank.scripts.shell.utils.rootDirectoryPathString
+import flank.scripts.utils.runCommand
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
+
+object BuildFlankCommand : CliktCommand(name = "buildFlank", help = "Build Flank") {
+    override fun run() {
+        buildFlank()
+    }
+}
+
+private fun buildFlank() {
+    createGradleCommand(
+        workingDir = rootDirectoryPathString,
+        "-p", rootDirectoryPathString, ":test_runner:clean", ":test_runner:assemble", ":test_runner:shadowJar"
+    )
+        .runCommand()
+
+    copyFlankOutputFile()
+}
+
+private fun copyFlankOutputFile() {
+    val flankDirectory = Paths.get(rootDirectoryPathString, "test_runner").toString()
+
+    Files.copy(
+        Paths.get(flankDirectory, "build", "libs", "flank.jar"),
+        Paths.get(flankDirectory, "bash", "flank.jar"),
+        StandardCopyOption.REPLACE_EXISTING
+    )
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ShellCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ShellCommand.kt
new file mode 100644
index 0000000000..69bcaf5ca2
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ShellCommand.kt
@@ -0,0 +1,33 @@
+package flank.scripts.shell
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.core.subcommands
+import flank.scripts.shell.firebase.FirebaseCommand
+import flank.scripts.shell.ios.BuildExampleCommand
+import flank.scripts.shell.ios.BuildFtlCommand
+import flank.scripts.shell.ios.InstallXcPrettyCommand
+import flank.scripts.shell.ios.RunFtlLocalCommand
+import flank.scripts.shell.ios.SetupIosEnvCommand
+import flank.scripts.shell.ios.UniversalFrameworkCommand
+import flank.scripts.shell.ops.OpsCommand
+import flank.scripts.shell.updatebinaries.UpdateBinariesCommand
+
+object ShellCommand : CliktCommand(name = "shell", help = "Task for shell commands") {
+    init {
+        subcommands(
+            FirebaseCommand,
+            BuildExampleCommand,
+            BuildFtlCommand,
+            RunFtlLocalCommand,
+            UniversalFrameworkCommand,
+            OpsCommand,
+            UpdateBinariesCommand,
+            BuildFlankCommand,
+            InstallXcPrettyCommand,
+            SetupIosEnvCommand
+        )
+    }
+
+    @Suppress("EmptyFunctionBlock")
+    override fun run() {}
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/FirebaseCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/FirebaseCommand.kt
new file mode 100644
index 0000000000..80d68d6377
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/FirebaseCommand.kt
@@ -0,0 +1,18 @@
+package flank.scripts.shell.firebase
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.core.subcommands
+
+object FirebaseCommand : CliktCommand(name = "firebase", help = "Contains all firebase commands") {
+
+    init {
+        subcommands(
+            UpdateApiJsonCommand,
+            GenerateJavaClientCommand
+        )
+    }
+
+    @Suppress("EmptyFunctionBlock")
+    override fun run() {
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/GenerateJavaClientCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/GenerateJavaClientCommand.kt
new file mode 100644
index 0000000000..ff7f1ddee5
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/GenerateJavaClientCommand.kt
@@ -0,0 +1,36 @@
+package flank.scripts.shell.firebase
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.exceptions.ShellCommandException
+import flank.scripts.utils.checkIfPipInstalled
+import flank.scripts.utils.installClientGeneratorIfNeeded
+import flank.scripts.utils.runCommand
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
+
+object GenerateJavaClientCommand : CliktCommand(name = "generateJavaClient", help = "Generate Java Client") {
+
+    override fun run() {
+        checkIfPipInstalled()
+        installClientGeneratorIfNeeded()
+        val apiPath = Paths.get("test_api").toString()
+        val outputDirectory = Paths.get(apiPath, "src", "main", "java").toString()
+        val testingJsonInput = Paths.get("json", "testing_v1.json").toString()
+        Paths.get(apiPath, "src").toFile().deleteRecursively()
+
+        val generateLibraryCommand = "generate_library " +
+            "--input=$testingJsonInput " +
+            "--language=java " +
+            "--output_dir=$outputDirectory"
+
+        val result = generateLibraryCommand.runCommand()
+        if (result != 0) throw ShellCommandException("Error when execute generate_library command")
+
+        Files.move(
+            Paths.get(outputDirectory, "pom.xml"),
+            Paths.get(apiPath, "pom.xml"),
+            StandardCopyOption.REPLACE_EXISTING
+        )
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/UpdateApiJsonCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/UpdateApiJsonCommand.kt
new file mode 100644
index 0000000000..831bfa5a79
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/firebase/UpdateApiJsonCommand.kt
@@ -0,0 +1,31 @@
+package flank.scripts.shell.firebase
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.utils.currentPath
+import flank.scripts.utils.downloadFile
+import flank.scripts.utils.downloadSortJsonIfNeeded
+import flank.scripts.utils.runCommand
+import java.nio.file.Paths
+
+object UpdateApiJsonCommand : CliktCommand(name = "updateApiJson", help = "Download file for generating client") {
+    override fun run() {
+        val jsonDirectoryPath = Paths.get(currentPath.toString(), "json")
+        val testingV1Path = Paths.get(jsonDirectoryPath.toString(), "testing_v1.json").toString()
+        val testingV1Beta3Path = Paths.get(jsonDirectoryPath.toString(), "toolresults_v1beta3.json").toString()
+
+        downloadFile(
+            "https://raw.githubusercontent.com/Flank/gcloud_cli/master/google-cloud-sdk/lib/googlecloudsdk/third_party/apis/testing_v1.json",
+            testingV1Path
+        )
+
+        downloadFile(
+            "https://raw.githubusercontent.com/Flank/gcloud_cli/master/google-cloud-sdk/lib/googlecloudsdk/third_party/apis/toolresults_v1beta3.json",
+            testingV1Beta3Path
+        )
+
+        downloadSortJsonIfNeeded()
+
+        "sort-json $testingV1Path".runCommand()
+        "sort-json $testingV1Beta3Path".runCommand()
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildExample.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildExample.kt
new file mode 100644
index 0000000000..bdbf611155
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildExample.kt
@@ -0,0 +1,57 @@
+
+package flank.scripts.shell.ios
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.utils.currentPath
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.shell.utils.pipe
+import flank.scripts.utils.archive
+import flank.scripts.utils.downloadXcPrettyIfNeeded
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
+import java.util.stream.Collectors
+
+object BuildExampleCommand : CliktCommand(name = "iosBuildExample", help = "Build example ios app") {
+    override fun run() {
+        failIfWindows()
+        downloadXcPrettyIfNeeded()
+        buildExample()
+    }
+}
+
+private fun buildExample() {
+    val dataPath: Path = Paths.get(currentPath.toString(), "dd_tmp").apply {
+        toFile().deleteRecursively()
+    }
+
+    val xcodeCommandSwiftTests = createIosBuildCommand(
+        dataPath.toString(),
+        "./EarlGreyExample.xcworkspace",
+        "EarlGreyExampleSwiftTests"
+    )
+    xcodeCommandSwiftTests pipe "xcpretty"
+
+    val xcodeCommandTests = createIosBuildCommand(dataPath.toString(), "./EarlGreyExample.xcworkspace", "EarlGreyExampleTests")
+    xcodeCommandTests pipe "xcpretty"
+
+    copyExampleOutputFiles(dataPath.toString())
+}
+
+private fun copyExampleOutputFiles(dataPath: String) {
+    val archiveFileName = "earlgrey_example.zip"
+    val buildProductPath = Paths.get(dataPath, "Build", "Products")
+
+    Files.walk(Paths.get(""))
+        .filter { it.fileName.toString().endsWith("-iphoneos") || it.fileName.toString().endsWith(".xctestrun") }
+        .map { it.toFile() }
+        .collect(Collectors.toList())
+        .archive(archiveFileName, currentPath.toFile())
+
+    Files.move(
+        Paths.get("", archiveFileName),
+        Paths.get(buildProductPath.toString(), archiveFileName),
+        StandardCopyOption.REPLACE_EXISTING
+    )
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildFtl.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildFtl.kt
new file mode 100644
index 0000000000..8b355490e0
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/BuildFtl.kt
@@ -0,0 +1,47 @@
+package flank.scripts.shell.ios
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.shell.utils.pipe
+import flank.scripts.utils.archive
+import flank.scripts.utils.downloadXcPrettyIfNeeded
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
+import java.util.stream.Collectors
+
+object BuildFtlCommand : CliktCommand(name = "iosBuildFtl", help = "Build ftl ios app") {
+    override fun run() {
+        failIfWindows()
+        downloadXcPrettyIfNeeded()
+        buildFtl()
+    }
+}
+
+private fun buildFtl() {
+    val dataPath = Paths.get("", "dd_tmp").apply {
+        toFile().deleteRecursively()
+    }.toString()
+    val xcodeCommand = createIosBuildCommand(dataPath, "./EarlGreyExample.xcworkspace", "\"EarlGreyExampleSwiftTests\"")
+
+    xcodeCommand pipe "xcpretty"
+    copyFtlOutputFiles(dataPath)
+}
+
+private fun copyFtlOutputFiles(dataPath: String) {
+    val archiveFileName = "earlgrey_example.zip"
+    val buildProductPath = Paths.get(dataPath, "Build", "Products")
+    val currentPath = Paths.get("")
+
+    Files.walk(currentPath)
+        .filter { it.fileName.toString().endsWith("-iphoneos") || it.fileName.toString().endsWith(".xctestrun") }
+        .map { it.toFile() }
+        .collect(Collectors.toList())
+        .archive(archiveFileName, currentPath.toFile())
+
+    Files.move(
+        Paths.get("", archiveFileName),
+        Paths.get(buildProductPath.toString(), archiveFileName),
+        StandardCopyOption.REPLACE_EXISTING
+    )
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/InstallXcPrettyCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/InstallXcPrettyCommand.kt
new file mode 100644
index 0000000000..86247fc3ab
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/InstallXcPrettyCommand.kt
@@ -0,0 +1,12 @@
+package flank.scripts.shell.ios
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.utils.downloadXcPrettyIfNeeded
+
+object InstallXcPrettyCommand : CliktCommand(name = "install_xcpretty", help = "Build ios app with tests") {
+    override fun run() {
+        failIfWindows()
+        downloadXcPrettyIfNeeded()
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/IosBuildCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/IosBuildCommand.kt
new file mode 100644
index 0000000000..947fe99784
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/IosBuildCommand.kt
@@ -0,0 +1,10 @@
+package flank.scripts.shell.ios
+
+fun createIosBuildCommand(buildDir: String, workspace: String, scheme: String, project: String = "") =
+    "xcodebuild build-for-testing" +
+        " -allowProvisioningUpdates" +
+        (if (workspace.isBlank()) "" else " -workspace $workspace") +
+        (if (project.isBlank()) "" else " -project $project") +
+        " -scheme $scheme" +
+        " -derivedDataPath $buildDir" +
+        " -sdk iphoneos"
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/LipoHelper.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/LipoHelper.kt
new file mode 100644
index 0000000000..d442ed3176
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/LipoHelper.kt
@@ -0,0 +1,6 @@
+package flank.scripts.shell.ios
+
+fun createLipoCommand(
+    outputPath: String,
+    vararg files: String
+) = "lipo -create ${files.joinToString(" ")} -output $outputPath"
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/RunFtlLocal.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/RunFtlLocal.kt
new file mode 100644
index 0000000000..ff20f624ab
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/RunFtlLocal.kt
@@ -0,0 +1,32 @@
+package flank.scripts.shell.ios
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.parameters.options.option
+import com.github.ajalt.clikt.parameters.options.required
+import flank.scripts.shell.utils.currentPath
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.utils.runCommand
+import java.nio.file.Path
+import java.nio.file.Paths
+
+object RunFtlLocalCommand : CliktCommand(name = "iosRunFtlLocal", help = "Run ftl locally ios app") {
+
+    private val deviceId by option(help = "Device id. Please take it from Xcode -> Window -> Devices and Simulators")
+        .required()
+
+    override fun run() {
+        failIfWindows()
+        runFtlLocal(deviceId)
+    }
+}
+
+private fun runFtlLocal(deviceId: String) {
+    val dataPath: Path = Paths.get(currentPath.toString(), "dd_tmp", "Build", "Products")
+
+    val xcodeCommand = "xcodebuild test-without-building " +
+        " -xctestrun $dataPath/*.xctestrun " +
+        "-derivedDataPath $dataPath " +
+        "-destination 'id=$deviceId'"
+
+    xcodeCommand.runCommand()
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/SetupIosEnvCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/SetupIosEnvCommand.kt
new file mode 100644
index 0000000000..47eac9ee68
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/SetupIosEnvCommand.kt
@@ -0,0 +1,17 @@
+package flank.scripts.shell.ios
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.ops.EARL_GREY_EXAMPLE
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.shell.utils.iOSTestProjectsPath
+import flank.scripts.utils.downloadCocoaPodsIfNeeded
+import flank.scripts.utils.installPods
+import java.nio.file.Paths
+
+object SetupIosEnvCommand : CliktCommand(name = "setup_ios_env", help = "Build ios app with tests") {
+    override fun run() {
+        failIfWindows()
+        downloadCocoaPodsIfNeeded()
+        installPods(Paths.get(iOSTestProjectsPath, EARL_GREY_EXAMPLE))
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/UniversalFramework.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/UniversalFramework.kt
new file mode 100644
index 0000000000..d96be8649e
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ios/UniversalFramework.kt
@@ -0,0 +1,61 @@
+package flank.scripts.shell.ios
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.utils.currentPath
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.utils.runCommand
+import java.nio.file.Paths
+
+object UniversalFrameworkCommand : CliktCommand(name = "iosUniversalFramework", help = "Create Universal Framework") {
+    override fun run() {
+        failIfWindows()
+        createUniversalFiles()
+    }
+}
+
+private const val APP_FRAMEWORK_FRAMEWORK = "AppFramework.framework"
+private const val APP_FRAMEWORK = "AppFramework"
+
+private fun createUniversalFiles() {
+    val comboPath = Paths.get(currentPath.toString(), "ios-frameworks").toString()
+    val devicePath = Paths.get(comboPath, "Debug-iphoneos").toString()
+    val simPath = Paths.get(comboPath, "Debug-iphonesimulator").toString()
+
+    listOf(
+        "libChannelLib.a",
+        "libCommonLib.a",
+        "libeDistantObject.a",
+        "libTestLib.a",
+        "libUILib.a"
+    ).map { fileName ->
+        createLipoCommand(
+            outputPath = Paths.get(comboPath, fileName).toString(),
+            Paths.get(devicePath, fileName).toString(), Paths.get(simPath, fileName).toString()
+        )
+    }.forEach { command -> command.runCommand() }
+
+    copyAppFrameworkFiles(devicePath, comboPath)
+
+    runDsym(
+        universalFileOutput = Paths.get(comboPath, APP_FRAMEWORK_FRAMEWORK, APP_FRAMEWORK).toString(),
+        comboPath = comboPath,
+        files = arrayOf(
+            Paths.get(devicePath, APP_FRAMEWORK_FRAMEWORK, APP_FRAMEWORK).toString(),
+            Paths.get(simPath, APP_FRAMEWORK_FRAMEWORK, APP_FRAMEWORK).toString()
+        )
+    )
+}
+
+private fun copyAppFrameworkFiles(fromPath: String, toPath: String) {
+    Paths.get(fromPath, APP_FRAMEWORK_FRAMEWORK).toFile()
+        .copyRecursively(Paths.get(toPath, APP_FRAMEWORK_FRAMEWORK).toFile(), overwrite = true)
+}
+
+private fun runDsym(
+    universalFileOutput: String,
+    comboPath: String,
+    files: Array<String>
+) {
+    createLipoCommand(universalFileOutput, *files).runCommand()
+    "dsymutil $universalFileOutput --out ${Paths.get(comboPath, "AppFramework.framework.dSYM")}".runCommand()
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/AndroidOpsCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/AndroidOpsCommand.kt
new file mode 100644
index 0000000000..d837f45723
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/AndroidOpsCommand.kt
@@ -0,0 +1,26 @@
+package flank.scripts.shell.ops
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.parameters.options.flag
+import com.github.ajalt.clikt.parameters.options.multiple
+import com.github.ajalt.clikt.parameters.options.option
+
+object AndroidOpsCommand : CliktCommand(name = "android", help = "Build android apks with tests") {
+
+    private val generate: Boolean by option(help = "Make build").flag("-g", default = true)
+
+    private val copy: Boolean by option(help = "Copy output files to tmp").flag("-c", default = true)
+
+    private val artifacts: List<String> by option().multiple()
+
+    override fun run() {
+        if (generate.not()) return
+        AndroidBuildConfiguration(artifacts, generate, copy).run {
+            buildBaseApk()
+            buildBaseTestApk()
+            buildDuplicatedNamesApks()
+            buildMultiModulesApks()
+            buildCucumberSampleApp()
+        }
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildAndroid.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildAndroid.kt
new file mode 100644
index 0000000000..3bb9c033c5
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildAndroid.kt
@@ -0,0 +1,118 @@
+package flank.scripts.shell.ops
+
+import flank.scripts.shell.utils.androidTestProjectsPath
+import flank.scripts.shell.utils.createGradleCommand
+import flank.scripts.shell.utils.flankFixturesTmpPath
+import flank.scripts.utils.runCommand
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
+
+fun AndroidBuildConfiguration.buildBaseApk() {
+    if (artifacts.canExecute("buildBaseApk").not()) return
+
+    createGradleCommand(
+        workingDir = androidTestProjectsPath,
+        options = listOf("-p", androidTestProjectsPath, "app:assemble")
+    ).runCommand()
+
+    if (copy) copyBaseApk()
+}
+
+private fun copyBaseApk() {
+    val outputDir = Paths.get(flankFixturesTmpPath, "apk", "app-debug.apk")
+
+    if (!outputDir.parent.toFile().exists()) Files.createDirectories(outputDir.parent)
+
+    val assembleDirectory = Paths.get(androidTestProjectsPath, "app", "build", "outputs", "apk", "singleSuccess", "debug", "app-single-success-debug.apk")
+    Files.copy(assembleDirectory, outputDir, StandardCopyOption.REPLACE_EXISTING)
+}
+
+fun AndroidBuildConfiguration.buildBaseTestApk() {
+    if (artifacts.canExecute("buildBaseTestApk").not()) return
+    createGradleCommand(
+        workingDir = androidTestProjectsPath,
+        options = listOf("-p", androidTestProjectsPath, "app:assembleAndroidTest")
+    ).runCommand()
+
+    if (copy) copyBaseTestApk()
+}
+
+private fun copyBaseTestApk() {
+    val assembleDirectory = Paths.get(androidTestProjectsPath, "app", "build", "outputs", "apk", "androidTest")
+    assembleDirectory.toFile().findApks().forEach {
+        Files.copy(it.toPath(), Paths.get(flankFixturesTmpPath, "apk", it.name), StandardCopyOption.REPLACE_EXISTING)
+    }
+}
+
+fun AndroidBuildConfiguration.buildDuplicatedNamesApks() {
+    if (artifacts.canExecute("buildDuplicatedNamesApks").not()) return
+    val modules = (0..3).map { "dir$it" }
+
+    createGradleCommand(
+        workingDir = androidTestProjectsPath,
+        options = listOf("-p", androidTestProjectsPath) + modules.map { "$it:testModule:assembleAndroidTest" }.toList()
+    ).runCommand()
+
+    if (copy) copyDuplicatedNamesApks()
+}
+
+private fun copyDuplicatedNamesApks() {
+    val modules = (0..3).map { "dir$it" }
+    val outputDir = Paths.get(flankFixturesTmpPath, "apk", "duplicated_names")
+    if (!outputDir.toFile().exists()) Files.createDirectories(outputDir)
+
+    modules.map { Paths.get(androidTestProjectsPath, it, "testModule", "build", "outputs", "apk").toFile() }
+        .flatMap { it.findApks().toList() }
+        .forEachIndexed { index, file ->
+            file.copyApkToDirectory(Paths.get(outputDir.toString(), modules[index], file.name))
+        }
+}
+
+private fun File.copyApkToDirectory(output: Path): Path = toPath().let { sourceFile ->
+    if (!output.parent.toFile().exists()) Files.createDirectories(output.parent)
+    Files.copy(sourceFile, output, StandardCopyOption.REPLACE_EXISTING)
+}
+
+fun AndroidBuildConfiguration.buildMultiModulesApks() {
+    if (artifacts.canExecute("buildMultiModulesApks").not()) return
+    createGradleCommand(
+        workingDir = androidTestProjectsPath,
+        options = listOf("-p", androidTestProjectsPath,
+            ":multi-modules:multiapp:assemble") + (1..20).map { ":multi-modules:testModule$it:assembleAndroidTest" }).runCommand()
+
+    if (copy) copyMultiModulesApks()
+}
+
+private fun copyMultiModulesApks() {
+    val outputDir = Paths.get(flankFixturesTmpPath, "apk", "multi-modules").toString()
+    Paths.get(androidTestProjectsPath, "multi-modules").toFile().findApks()
+        .forEach { it.copyApkToDirectory(Paths.get(outputDir, it.name)) }
+}
+
+fun AndroidBuildConfiguration.buildCucumberSampleApp() {
+    if (artifacts.canExecute("buildMultiModulesApks").not()) return
+    createGradleCommand(
+        workingDir = androidTestProjectsPath,
+        options = listOf("-p", androidTestProjectsPath, "cucumber_sample_app:cukeulator:assembleDebug", ":cucumber_sample_app:cukeulator:assembleAndroidTest")
+    ).runCommand()
+
+    if (copy) copyCucumberSampleApp()
+}
+
+private fun copyCucumberSampleApp() {
+    val outputDir = Paths.get(flankFixturesTmpPath, "apk", "cucumber_sample_app").toString()
+    Paths.get(androidTestProjectsPath, "cucumber_sample_app").toFile().findApks().copyApksToPath(outputDir)
+}
+
+private fun File.findApks() = walk().filter { it.extension == "apk" }
+
+private fun Sequence<File>.copyApksToPath(outputDirectory: String) = forEach {
+    it.copyApkToDirectory(Paths.get(outputDirectory, it.name))
+}
+
+private fun List<String>.canExecute(actionName: String) = isEmpty() || any { it.toLowerCase() == actionName.toLowerCase() }
+
+data class AndroidBuildConfiguration(val artifacts: List<String>, val generate: Boolean, val copy: Boolean)
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildEarlGreyExampleCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildEarlGreyExampleCommand.kt
new file mode 100644
index 0000000000..8f92a88303
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildEarlGreyExampleCommand.kt
@@ -0,0 +1,35 @@
+package flank.scripts.shell.ops
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.parameters.options.flag
+import com.github.ajalt.clikt.parameters.options.option
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.shell.utils.iOSTestProjectsPath
+import java.nio.file.Paths
+
+object BuildEarlGreyExampleCommand : CliktCommand(name = "build_earl_grey_example", help = "Build ios earl grey example app with tests") {
+
+    private val generate: Boolean? by option(help = "Make build").flag("-g", default = true)
+
+    private val copy: Boolean? by option(help = "Copy output files to tmp").flag("-c", default = true)
+
+    override fun run() {
+        failIfWindows()
+
+        IosBuildConfiguration(
+            projectPath = Paths.get(iOSTestProjectsPath, EARL_GREY_EXAMPLE).toString(),
+            projectName = EARL_GREY_EXAMPLE,
+            buildConfigurations = listOf(
+                IosTestBuildConfiguration(EARL_GREY_EXAMPLE_SWIFT_TESTS, "swift"),
+                IosTestBuildConfiguration(EARL_GREY_EXAMPLE_TESTS, "objective_c")
+            ),
+            useWorkspace = true,
+            generate = generate ?: true,
+            copy = copy ?: true
+        ).generateIos()
+    }
+}
+
+const val EARL_GREY_EXAMPLE = "EarlGreyExample"
+private const val EARL_GREY_EXAMPLE_TESTS = "EarlGreyExampleTests"
+private const val EARL_GREY_EXAMPLE_SWIFT_TESTS = "EarlGreyExampleSwiftTests"
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildFlankExampleCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildFlankExampleCommand.kt
new file mode 100644
index 0000000000..5cd940f831
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildFlankExampleCommand.kt
@@ -0,0 +1,32 @@
+package flank.scripts.shell.ops
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.parameters.options.flag
+import com.github.ajalt.clikt.parameters.options.option
+import flank.scripts.shell.utils.failIfWindows
+import flank.scripts.shell.utils.iOSTestProjectsPath
+import java.nio.file.Paths
+
+object BuildFlankExampleCommand : CliktCommand(name = "build_flank_example", help = "Build ios flank example app with tests") {
+
+    private val generate: Boolean? by option(help = "Make build").flag("-g", default = true)
+
+    private val copy: Boolean? by option(help = "Copy output files to tmp").flag("-c", default = true)
+
+    override fun run() {
+        failIfWindows()
+
+        IosBuildConfiguration(
+            projectPath = Paths.get(iOSTestProjectsPath, FLANK_EXAMPLE).toString(),
+            projectName = FLANK_EXAMPLE,
+            buildConfigurations = listOf(
+                IosTestBuildConfiguration(FLANK_EXAMPLE, "tests"),
+            ),
+            useWorkspace = false,
+            generate = generate ?: true,
+            copy = copy ?: true
+        ).generateIos()
+    }
+}
+
+private const val FLANK_EXAMPLE = "FlankExample"
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildIos.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildIos.kt
new file mode 100644
index 0000000000..d10e9ccc33
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/BuildIos.kt
@@ -0,0 +1,99 @@
+package flank.scripts.shell.ops
+
+import flank.scripts.shell.ios.createIosBuildCommand
+import flank.scripts.shell.utils.flankFixturesIosTmpPath
+import flank.scripts.shell.utils.pipe
+import flank.scripts.utils.archive
+import flank.scripts.utils.downloadCocoaPodsIfNeeded
+import flank.scripts.utils.downloadXcPrettyIfNeeded
+import flank.scripts.utils.installPods
+import java.io.File
+import java.nio.file.Path
+import java.nio.file.Paths
+
+fun IosBuildConfiguration.generateIos() {
+    downloadCocoaPodsIfNeeded()
+    installPods(Paths.get(projectPath))
+    downloadXcPrettyIfNeeded()
+    if (generate) buildEarlGreyExample()
+}
+
+private fun IosBuildConfiguration.buildEarlGreyExample() = Paths.get(projectPath, "Build")
+    .runBuilds(this)
+    .resolve("Products")
+    .apply { renameXctestFiles().filterFilesToCopy().archiveProject(projectName).copyIosProductFiles(projectName) }
+    .copyTestFiles(this)
+
+private fun Path.runBuilds(configuration: IosBuildConfiguration) = apply {
+    toFile().deleteRecursively()
+    val parent = toFile().parent
+    val workspace =
+        if (configuration.useWorkspace) Paths.get(parent, configuration.workspaceName).toString()
+        else ""
+
+    val project = if (configuration.useWorkspace) ""
+    else Paths.get(parent, "${configuration.projectName}.xcodeproj").toString()
+    configuration.buildConfigurations.forEach {
+        val buildCommand = createIosBuildCommand(
+            parent,
+            workspace,
+            scheme = it.scheme,
+            project,
+        )
+        buildCommand pipe "xcpretty"
+    }
+}
+
+private fun Path.renameXctestFiles() = apply {
+    toFile().walk().filter { it.extension == "xctestrun" }.forEach {
+        it.reduceTestFileName()
+    }
+}
+
+private fun Sequence<File>.archiveProject(projectName: String) = also {
+    it.toList().archive("$projectName.zip", File(flankFixturesIosTmpPath, projectName))
+}
+
+private fun File.reduceTestFileName() =
+    renameTo(toPath().parent.resolve(name.reduceTestFileName()).toFile())
+
+private fun String.reduceTestFileName() =
+    "_.*xctestrun".toRegex().replace(this, ".xctestrun")
+
+private fun Path.filterFilesToCopy() =
+    toFile().walk().filter { it.nameWithoutExtension.endsWith("-iphoneos") || it.extension == "xctestrun" }
+
+private fun Sequence<File>.copyIosProductFiles(projectName: String) = forEach {
+    if (it.isDirectory) it.copyRecursively(Paths.get(flankFixturesIosTmpPath, projectName, it.name).toFile(), overwrite = true)
+    else it.copyTo(Paths.get(flankFixturesIosTmpPath, projectName, it.name).toFile(), overwrite = true)
+}
+
+private fun Path.copyTestFiles(configuration: IosBuildConfiguration) = toString().takeIf { configuration.copy }?.let { productsDirectory ->
+    val appDirectory = Paths.get(productsDirectory, "Debug-iphoneos").toFile().findTestDirectories()
+    appDirectory.forEach {
+        it.walk().filter { it.isFile && it.extension == "" }.forEach { testFile ->
+            configuration.copyTestFile(testFile)
+        }
+    }
+}
+
+private fun IosBuildConfiguration.copyTestFile(
+    fileToCopy: File,
+) =
+    fileToCopy.copyTo(Paths.get(flankFixturesIosTmpPath, projectName, fileToCopy.name).toFile(), true)
+
+private fun File.findTestDirectories() = walk().filter { it.isDirectory && it.name.endsWith(".xctest") }
+
+data class IosBuildConfiguration(
+    val projectPath: String,
+    val projectName: String,
+    val buildConfigurations: List<IosTestBuildConfiguration>,
+    val useWorkspace: Boolean = false,
+    val generate: Boolean = true,
+    val copy: Boolean = true
+)
+
+data class IosTestBuildConfiguration(val scheme: String, val outputDirectoryName: String)
+
+private val IosBuildConfiguration.workspaceName
+    get() = "$projectName.xcworkspace"
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOS.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOS.kt
new file mode 100644
index 0000000000..a68d806cfa
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOS.kt
@@ -0,0 +1,11 @@
+package flank.scripts.shell.ops
+
+enum class GoOS(
+    val goName: String,
+    val directory: String,
+    val extension: String = ""
+) {
+    LINUX("linux", "bin/linux"),
+    MAC("darwin", "bin/mac"),
+    WINDOWS("windows", "bin/win", ".exe"),
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOpsCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOpsCommand.kt
new file mode 100644
index 0000000000..761ca0703e
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/GoOpsCommand.kt
@@ -0,0 +1,36 @@
+package flank.scripts.shell.ops
+
+import com.github.ajalt.clikt.core.CliktCommand
+import flank.scripts.shell.utils.flankFixturesTmpPath
+import flank.scripts.shell.utils.testProjectsPath
+import flank.scripts.utils.runCommand
+import java.nio.file.Path
+import java.nio.file.Paths
+
+object GoOpsCommand : CliktCommand(name = "go", help = "Build go app with tests") {
+    override fun run() {
+        generateGoArtifacts()
+    }
+
+    private fun generateGoArtifacts() {
+        val goHelloBinDirectoryPath = Paths.get(testProjectsPath, "gohello", "bin").apply {
+            toFile().deleteRecursively()
+        }
+        GoOS.values().forEach { createExecutable(it, goHelloBinDirectoryPath) }
+    }
+
+    private fun createExecutable(os: GoOS, goHelloBinDirectoryPath: Path) {
+        Paths.get(goHelloBinDirectoryPath.toString(), *os.directory.split('/').toTypedArray())
+            .toFile()
+            .mkdirs()
+        "go build -o ${os.createOutputPathForBinary()}".runCommand(
+            environmentVariables = mapOf(
+                "GOOS" to os.goName,
+                "GOARCH" to "amd64"
+            )
+        )
+    }
+
+    private fun GoOS.createOutputPathForBinary() =
+        Paths.get(flankFixturesTmpPath, "gohello", directory, "gohello$extension")
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/OpsCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/OpsCommand.kt
new file mode 100644
index 0000000000..c13d24ab81
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/ops/OpsCommand.kt
@@ -0,0 +1,18 @@
+package flank.scripts.shell.ops
+
+import com.github.ajalt.clikt.core.CliktCommand
+import com.github.ajalt.clikt.core.subcommands
+
+object OpsCommand : CliktCommand(name = "ops", help = "Contains all ops command: android, ios, gp") {
+    init {
+        subcommands(
+            AndroidOpsCommand,
+            BuildEarlGreyExampleCommand,
+            BuildFlankExampleCommand,
+            GoOpsCommand
+        )
+    }
+
+    @Suppress("EmptyFunctionBlock")
+    override fun run() {}
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateAtomic.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateAtomic.kt
new file mode 100644
index 0000000000..e4ef86440b
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateAtomic.kt
@@ -0,0 +1,52 @@
+package flank.scripts.shell.updatebinaries
+
+import flank.scripts.utils.downloadFile
+import flank.scripts.utils.extract
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.util.stream.Collectors
+
+private val currentPath = Paths.get("")
+private val atomicPath = Paths.get(currentPath.toString(), "libatomic")
+
+fun updateAtomic() {
+    val atomicDeb = Paths.get(atomicPath.toString(), "libatomic.deb").toFile()
+    val atomicDataTarXz = Paths.get(atomicPath.toString(), "data.tar.xz").toFile()
+
+    if (atomicDeb.exists()) {
+        println("Atomic exists")
+    } else {
+        println("Downloading Atomic...")
+        atomicPath.toFile().mkdirs()
+        downloadFile(
+            srcUrl = "http://mirrors.kernel.org/ubuntu/pool/main/g/gcc-8/libatomic1_8-20180414-1ubuntu2_amd64.deb",
+            destinationPath = atomicDeb.toString()
+        )
+    }
+
+    atomicDeb.extract(atomicPath.toFile(), "ar")
+    atomicDataTarXz.extract(atomicPath.toFile(), "tar", "xz")
+    findAndCopyAtomicLicense()
+    findAndCopyAtomicFiles()
+    atomicPath.toFile().deleteRecursively()
+}
+
+private fun findAndCopyAtomicLicense() {
+    val licenseOutputFile = Paths.get(currentPath.toString(), "libatomic.txt").toFile()
+
+    downloadFile(
+        "http://changelogs.ubuntu.com/changelogs/pool/main/g/gcc-8/gcc-8_8-20180414-1ubuntu2/copyright",
+        licenseOutputFile.toString()
+    )
+}
+
+private fun findAndCopyAtomicFiles() {
+    println("Copying atomic files ...")
+    val list = Files.walk(atomicPath)
+        .filter { it.toString().endsWith("libatomic.so.1") || it.toString().endsWith("libatomic.so.1.2.0") }
+        .collect(Collectors.toList())
+
+    list.forEach {
+        it.toFile().copyTo(Paths.get(currentPath.toString(), it.fileName.toString()).toFile(), true)
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateBinariesCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateBinariesCommand.kt
new file mode 100644
index 0000000000..b42fe638ad
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateBinariesCommand.kt
@@ -0,0 +1,21 @@
+package flank.scripts.shell.updatebinaries
+
+import com.github.ajalt.clikt.core.CliktCommand
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+object UpdateBinariesCommand : CliktCommand(name = "updateBinaries", help = "Update binaries used by Flank") {
+
+    override fun run() {
+        runBlocking {
+            listOf(
+                launch { updateAtomic() },
+                launch { updateLlvm() },
+                launch { updateSwift() }
+            ).joinAll()
+
+            println("Binaries updated")
+        }
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateLlvm.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateLlvm.kt
new file mode 100644
index 0000000000..4e652d8312
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateLlvm.kt
@@ -0,0 +1,78 @@
+package flank.scripts.shell.updatebinaries
+
+import flank.scripts.utils.downloadFile
+import flank.scripts.utils.extract
+import flank.scripts.utils.isWindows
+import java.nio.file.Files
+import java.nio.file.Paths
+
+private val currentPath = Paths.get("")
+private val llvmPath = Paths.get(currentPath.toString(), "llvm")
+
+fun updateLlvm() = if (isWindows) updateLlvmWindows() else updateLlvmNonWindows()
+
+private fun updateLlvmWindows() {
+    println(" Will be available after #1134")
+   /*
+   TODO finish this in #1134
+   val llvmExe = Paths.get(llvmPath.toString(), "LLVM-win64.exe")
+    if (llvmExe.toFile().exists()) {
+        println("LLVM exists")
+    } else {
+        println("Downloading Windows LLVM...")
+        llvmPath.toFile().mkdirs()
+        downloadFile(
+            srcUrl = "https://releases.llvm.org/8.0.0/LLVM-8.0.0-win64.exe",
+            destinationPath = llvmExe.toString()
+        )
+    }
+
+    llvmExe.toFile().extract(llvmPath.toFile(), "zip", "xz")
+    findAndCopyLlvmLicense()
+    findAndCopyLlvmNmFile()
+    llvmPath.toFile().deleteRecursively()*/
+}
+
+private fun updateLlvmNonWindows() {
+    val llvmTarXz = Paths.get(llvmPath.toString(), "llvm.tar.xz")
+
+    if (llvmTarXz.toFile().exists()) {
+        println("LLVM exists")
+    } else {
+        println("Downloading LLVM...")
+        llvmPath.toFile().mkdirs()
+        downloadFile(
+            srcUrl = "http://releases.llvm.org/8.0.0/clang+llvm-8.0.0-x86_64-linux-gnu-ubuntu-16.04.tar.xz",
+            destinationPath = llvmTarXz.toString()
+        )
+    }
+
+    llvmTarXz.toFile().extract(llvmPath.toFile(), "tar", "xz")
+    findAndCopyLlvmLicense()
+    findAndCopyLlvmNmFile()
+    llvmPath.toFile().deleteRecursively()
+}
+
+private fun findAndCopyLlvmLicense() {
+    val licensePathSuffix = Paths.get("include", "llvm", "Support", "LICENSE.TXT").toString()
+    val licenseOutputFile = Paths.get(currentPath.toString(), "llvm.txt").toFile()
+
+    println("Copying license ...")
+    Files.walk(llvmPath)
+        .filter { it.toString().endsWith(licensePathSuffix) }
+        .findFirst()
+        .takeIf { it.isPresent }
+        ?.run { get().toFile().copyTo(licenseOutputFile, overwrite = true) }
+}
+
+private fun findAndCopyLlvmNmFile() {
+    val llvmNmSuffix = Paths.get("bin", "llvm-nm").toString()
+    val llvmNmOutputFile = Paths.get(currentPath.toString(), "nm").toFile()
+
+    println("Copying llvm nm ...")
+    Files.walk(llvmPath)
+        .filter { it.toString().endsWith(llvmNmSuffix) }
+        .findFirst()
+        .takeIf { it.isPresent }
+        ?.run { get().toFile().copyTo(llvmNmOutputFile, overwrite = true) }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateSwift.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateSwift.kt
new file mode 100644
index 0000000000..fe109b2734
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/updatebinaries/UpdateSwift.kt
@@ -0,0 +1,79 @@
+package flank.scripts.shell.updatebinaries
+
+import flank.scripts.utils.downloadFile
+import flank.scripts.utils.extract
+import flank.scripts.utils.isWindows
+import java.nio.file.Files
+import java.nio.file.Paths
+
+private val currentPath = Paths.get("")
+private val swiftPath = Paths.get(currentPath.toString(), "swift")
+
+fun updateSwift() = if (isWindows) updateSwiftWindows() else updateSwiftOther()
+
+private fun updateSwiftWindows() {
+    println(" Will be available after #1134")
+    /*
+    TODO finish this in #1134
+    val swiftExe = Paths.get(swiftPath.toString(), "swift.exe")
+
+     if (swiftExe.toFile().exists()) {
+         println("Swift exists")
+     } else {
+         println("Downloading swift for Windows")
+         swiftPath.toFile().mkdirs()
+         downloadFile(
+             srcUrl = "https://swift.org/builds/swift-5.3-release/windows10/swift-5.3-RELEASE/swift-5.3-RELEASE-windows10.exe",
+             destinationPath = swiftExe.toString()
+         )
+     }
+
+     swiftExe.toFile().extract(swiftPath.toFile(), "zip", "gz")
+     findAndCopySwiftLicense()
+     findAndCopySwiftDemangleFile()
+     swiftPath.toFile().deleteRecursively()*/
+}
+
+private fun updateSwiftOther() {
+    val swiftTarGz = Paths.get(swiftPath.toString(), "swift.tar.gz")
+
+    if (swiftTarGz.toFile().exists()) {
+        println("Swift exists")
+    } else {
+        println("Downloading swift")
+        swiftPath.toFile().mkdirs()
+        downloadFile(
+            srcUrl = "https://swift.org/builds/swift-5.0.1-release/ubuntu1604/swift-5.0.1-RELEASE/swift-5.0.1-RELEASE-ubuntu16.04.tar.gz",
+            destinationPath = swiftTarGz.toString()
+        )
+    }
+
+    swiftTarGz.toFile().extract(swiftPath.toFile(), "tar", "gz")
+    findAndCopySwiftLicense()
+    findAndCopySwiftDemangleFile()
+    swiftPath.toFile().deleteRecursively()
+}
+
+private fun findAndCopySwiftLicense() {
+    val licenseFileSuffix = Paths.get("usr", "share", "swift", "LICENSE.txt").toString()
+    val licenseOutputFile = Paths.get(currentPath.toString(), "swift.txt").toFile()
+
+    println("Copying license ...")
+    Files.walk(swiftPath)
+        .filter { it.toString().endsWith(licenseFileSuffix) }
+        .findFirst()
+        .takeIf { it.isPresent }
+        ?.run { get().toFile().copyTo(licenseOutputFile, overwrite = true) }
+}
+
+private fun findAndCopySwiftDemangleFile() {
+    val switftDemangleFileSuffix = Paths.get("usr", "bin", "swift-demangle").toString()
+    val switftDemangleOutputFile = Paths.get(currentPath.toString(), "swift-demangle").toFile()
+
+    println("Copying swift-demangle ...")
+    Files.walk(swiftPath)
+        .filter { it.toString().endsWith(switftDemangleFileSuffix) }
+        .findFirst()
+        .takeIf { it.isPresent }
+        ?.run { get().toFile().copyTo(switftDemangleOutputFile, overwrite = true) }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/FastFailForWindows.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/FastFailForWindows.kt
new file mode 100644
index 0000000000..2aec5874d0
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/FastFailForWindows.kt
@@ -0,0 +1,11 @@
+package flank.scripts.shell.utils
+
+import flank.scripts.utils.isWindows
+import kotlin.system.exitProcess
+
+fun failIfWindows() {
+    if (isWindows) {
+        println("This script does not work on Windows")
+        exitProcess(1)
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/GradleCommand.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/GradleCommand.kt
new file mode 100644
index 0000000000..c4021fe718
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/GradleCommand.kt
@@ -0,0 +1,17 @@
+package flank.scripts.shell.utils
+
+import flank.scripts.utils.isWindows
+import java.nio.file.Paths
+
+fun createGradleCommand(
+    workingDir: String,
+    vararg options: String
+) = createGradleCommand(workingDir, options.asList())
+
+fun createGradleCommand(
+    workingDir: String,
+    options: List<String>
+) = "${Paths.get(workingDir, gradleExecutable)} ${options.joinToString(" ")}"
+
+private val gradleExecutable: String
+    get() = if (isWindows) "gradlew.bat" else "./gradlew"
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/PathHelper.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/PathHelper.kt
new file mode 100644
index 0000000000..3fe4748e95
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/PathHelper.kt
@@ -0,0 +1,22 @@
+package flank.scripts.shell.utils
+
+import java.nio.file.Files
+import java.nio.file.Paths
+import java.nio.file.Path
+
+val currentPath = Paths.get("")
+val rootDirectoryPath = goToRoot(currentPath)
+val rootDirectoryPathString = rootDirectoryPath.toString()
+
+fun goToRoot(startPath: Path): Path =
+    if (startPath.isRoot()) startPath.toAbsolutePath() else goToRoot(startPath.toAbsolutePath().parent)
+
+fun Path.isRoot() = Files.exists(Paths.get(toString(), "settings.gradle.kts"))
+
+val testProjectsPath = Paths.get(rootDirectoryPathString, "test_projects").toString()
+val androidTestProjectsPath = Paths.get(testProjectsPath, "android").toString()
+val iOSTestProjectsPath = Paths.get(testProjectsPath, "ios").toString()
+val flankFixturesTmpPath =
+    Paths.get(rootDirectoryPathString, "test_runner", "src", "test", "kotlin", "ftl", "fixtures", "tmp").toString()
+val flankFixturesIosTmpPath =
+    Paths.get(flankFixturesTmpPath, "ios").toString()
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/ShellHelper.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/ShellHelper.kt
new file mode 100644
index 0000000000..f086d9c36a
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/ShellHelper.kt
@@ -0,0 +1,12 @@
+package flank.scripts.shell.utils
+
+import flank.scripts.utils.isWindows
+import flank.scripts.utils.runCommand
+
+infix fun String.pipe(command: String) {
+    if (isWindows) {
+        listOf("cmd", "/C", "$this | $command").runCommand()
+    } else {
+        listOf("/bin/bash", "-c", "$this | $command").runCommand()
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/TestFilters.kt b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/TestFilters.kt
new file mode 100644
index 0000000000..7a18faeeea
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/shell/utils/TestFilters.kt
@@ -0,0 +1,28 @@
+package flank.scripts.shell.utils
+
+private const val PACKAGE = "com.example.test_app"
+private const val PACKAGE_FOO = "com.example.test_app.foo"
+private const val PACKAGE_BAR = "com.example.test_app.bar"
+
+private const val ANNOTATION = "$PACKAGE.Annotation"
+
+private const val CLASS = "$PACKAGE.InstrumentedTest"
+private const val CLASS_FOO = "$PACKAGE_FOO.FooInstrumentedTest"
+private const val CLASS_BAR = "$PACKAGE_BAR.BarInstrumentedTest"
+
+private const val METHOD_1 = "$CLASS#test1"
+private const val METHOD_FOO = "$CLASS_FOO#testFoo"
+
+private const val RUNNER = "com.example.test_app.test/androidx.test.runner.AndroidJUnitRunner"
+
+val runAllTestsShellCommand: String
+    get() = "adb shell am instrument -r -w \$@ $RUNNER"
+
+val filterPackageBarClassFooMethod1Command: String
+    get() = "$runAllTestsShellCommand -e package $PACKAGE_BAR -e class $CLASS_FOO -e class $METHOD_1"
+
+val filterAnnotationMethodFooCommand: String
+    get() = "$runAllTestsShellCommand -e annotation $ANNOTATION -e class $METHOD_FOO"
+
+val filterNotPackageFooNotClassBarCommand: String
+    get() = "$runAllTestsShellCommand -e notPackage $PACKAGE_FOO -e notClass $CLASS_BAR"
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/testartifacts/core/DownloadFixtures.kt b/flank-scripts/src/main/kotlin/flank/scripts/testartifacts/core/DownloadFixtures.kt
index 1291654cf6..3186f43c75 100644
--- a/flank-scripts/src/main/kotlin/flank/scripts/testartifacts/core/DownloadFixtures.kt
+++ b/flank-scripts/src/main/kotlin/flank/scripts/testartifacts/core/DownloadFixtures.kt
@@ -2,7 +2,7 @@ package flank.scripts.testartifacts.core
 
 import com.jcabi.github.Release
 import flank.scripts.github.getRelease
-import flank.scripts.utils.download
+import flank.scripts.utils.downloadFile
 import java.io.File
 
 fun Context.downloadFixtures(
@@ -28,7 +28,7 @@ private fun Context.downloadFixtures(
         ).fullName
     ).run {
         if (exists() && overwrite) delete()
-        if (!exists()) download(url, absolutePath).also { println("OK") }
+        if (!exists()) downloadFile(url, absolutePath).also { println("OK") }
         else println("ABORTED (already exists)")
     }
 }
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/utils/Archive.kt b/flank-scripts/src/main/kotlin/flank/scripts/utils/Archive.kt
new file mode 100644
index 0000000000..ab71d3db41
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/utils/Archive.kt
@@ -0,0 +1,56 @@
+package flank.scripts.utils
+
+import org.rauschig.jarchivelib.ArchiveFormat
+import org.rauschig.jarchivelib.ArchiverFactory
+import org.rauschig.jarchivelib.CompressionType
+import java.io.File
+
+fun File.extract(
+    destination: File,
+    archiveFormat: ArchiveFormat = ArchiveFormat.AR,
+    compressFormat: CompressionType? = null
+) {
+    println("Unpacking...$name")
+    val archiver = if (compressFormat != null) {
+        ArchiverFactory.createArchiver(archiveFormat, compressFormat)
+    } else {
+        ArchiverFactory.createArchiver(archiveFormat)
+    }
+    runCatching {
+        archiver.extract(this, destination)
+    }.onFailure {
+        println("There was an error when unpacking $name - $it")
+    }
+}
+
+fun File.extract(
+    destination: File,
+    archiveFormat: String,
+    compressFormat: String? = null
+) {
+    println("Unpacking...$name")
+    val archiver = if (compressFormat != null) {
+        ArchiverFactory.createArchiver(archiveFormat, compressFormat)
+    } else {
+        ArchiverFactory.createArchiver(archiveFormat)
+    }
+    runCatching {
+        archiver.extract(this, destination)
+    }.onFailure {
+        println("There was an error when unpacking $name - $it")
+    }
+}
+
+fun List<File>.archive(
+    destinationFileName: String,
+    destinationDirectory: File,
+    archiveFormat: ArchiveFormat = ArchiveFormat.ZIP
+) {
+    println("Packing...$destinationFileName")
+    val archiver = ArchiverFactory.createArchiver(archiveFormat)
+    runCatching {
+        archiver.create(destinationFileName, destinationDirectory, *toTypedArray())
+    }.onFailure {
+        println("There was an error when packing ${destinationDirectory.absolutePath}${File.separator}$destinationFileName - $it")
+    }
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/utils/Download.kt b/flank-scripts/src/main/kotlin/flank/scripts/utils/Download.kt
index e21c666720..ddc4686644 100644
--- a/flank-scripts/src/main/kotlin/flank/scripts/utils/Download.kt
+++ b/flank-scripts/src/main/kotlin/flank/scripts/utils/Download.kt
@@ -3,11 +3,11 @@ package flank.scripts.utils
 import com.github.kittinunf.fuel.Fuel
 import java.io.File
 
-fun download(
+fun downloadFile(
     srcUrl: String,
-    dstFile: String
+    destinationPath: String
 ) {
     Fuel.download(srcUrl)
-        .fileDestination { _, _ -> File(dstFile) }
+        .fileDestination { _, _ -> File(destinationPath) }
         .responseString()
 }
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/utils/DownloadSoftware.kt b/flank-scripts/src/main/kotlin/flank/scripts/utils/DownloadSoftware.kt
new file mode 100644
index 0000000000..ca67f9a98d
--- /dev/null
+++ b/flank-scripts/src/main/kotlin/flank/scripts/utils/DownloadSoftware.kt
@@ -0,0 +1,30 @@
+package flank.scripts.utils
+
+import java.nio.file.Path
+
+fun downloadXcPrettyIfNeeded() {
+    "xcpretty".checkAndInstallIfNeed("gem install xcpretty")
+}
+
+fun downloadCocoaPodsIfNeeded() {
+    "xcpretty".checkAndInstallIfNeed("gem install cocoapods -v 1.9.3")
+}
+
+fun installPods(path: Path) {
+    "pod install --project-directory=$path --verbose".runCommand()
+}
+
+fun checkIfPipInstalled() {
+    "pip".commandInstalledOr {
+        println("You need pip fot this script. To install it follow https://pip.pypa.io/en/stable/installing/")
+    }
+}
+
+fun downloadSortJsonIfNeeded() {
+    "sort-json".checkAndInstallIfNeed("npm -g install sort-json")
+}
+
+fun installClientGeneratorIfNeeded() {
+    val generateLibraryCheckCommand = (if (isWindows) "where " else "command -v ") + "generate_library"
+    generateLibraryCheckCommand.checkAndInstallIfNeed("pip install google-apis-client-generator")
+}
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/utils/Env.kt b/flank-scripts/src/main/kotlin/flank/scripts/utils/Env.kt
index f865ca96dc..68ec960e1d 100644
--- a/flank-scripts/src/main/kotlin/flank/scripts/utils/Env.kt
+++ b/flank-scripts/src/main/kotlin/flank/scripts/utils/Env.kt
@@ -2,3 +2,5 @@ package flank.scripts.utils
 
 fun getEnv(key: String): String =
     System.getenv(key) ?: throw RuntimeException("Environment variable '$key' not found!")
+
+val isWindows: Boolean = System.getProperty("os.name").startsWith("Windows")
diff --git a/flank-scripts/src/main/kotlin/flank/scripts/utils/ShellExecute.kt b/flank-scripts/src/main/kotlin/flank/scripts/utils/ShellExecute.kt
index b8e1403308..f9e6f953ef 100644
--- a/flank-scripts/src/main/kotlin/flank/scripts/utils/ShellExecute.kt
+++ b/flank-scripts/src/main/kotlin/flank/scripts/utils/ShellExecute.kt
@@ -2,8 +2,12 @@ package flank.scripts.utils
 
 import java.io.File
 
-fun List<String>.runCommand(retryCount: Int = 0, fileForOutput: File? = null) =
-    ProcessBuilder(this).apply {
+fun List<String>.runCommand(
+    retryCount: Int = 0,
+    fileForOutput: File? = null,
+    environmentVariables: Map<String, String> = mapOf()
+) = ProcessBuilder(this).apply {
+        environment().putAll(environmentVariables)
         if (fileForOutput != null) {
             redirectOutput(fileForOutput)
             redirectError(fileForOutput)
@@ -14,8 +18,11 @@ fun List<String>.runCommand(retryCount: Int = 0, fileForOutput: File? = null) =
     }
         .startWithRetry(retryCount)
 
-fun String.runCommand(retryCount: Int = 0, fileForOutput: File? = null) =
-    split(" ").toList().runCommand(retryCount, fileForOutput)
+fun String.runCommand(
+    retryCount: Int = 0,
+    fileForOutput: File? = null,
+    environmentVariables: Map<String, String> = mapOf()
+) = split(" ").toList().runCommand(retryCount, fileForOutput, environmentVariables)
 
 fun String.runForOutput(retryCount: Int = 0): String = File
     .createTempFile(hashCode().toString(), "")
@@ -24,6 +31,16 @@ fun String.runForOutput(retryCount: Int = 0): String = File
         file.readText()
     }
 
+fun String.checkCommandExists() = (if (isWindows) "where " else "command -v ").plus(this).runCommand() == 0
+
+fun String.checkAndInstallIfNeed(installCommand: String) = checkCommandExists().takeUnless { it }?.let {
+    installCommand.runCommand()
+}
+
+fun String.commandInstalledOr(orAction: () -> Unit) = checkCommandExists().takeUnless { it }?.let {
+    orAction()
+}
+
 internal fun ProcessBuilder.startWithRetry(retryCount: Int): Int {
     var retryTries = 0
     var processResponse: Int
diff --git a/test_projects/android/build.bat b/test_projects/android/build.bat
new file mode 100644
index 0000000000..15e97ba4d8
--- /dev/null
+++ b/test_projects/android/build.bat
@@ -0,0 +1,2 @@
+SET DIR=%~dp0
+%DIR%\..\..\flank-scripts\bash\flankScripts.bat shell ops android --copy --generate
diff --git a/test_projects/android/ops.sh b/test_projects/android/ops.sh
index 00636bef56..ce68785836 100755
--- a/test_projects/android/ops.sh
+++ b/test_projects/android/ops.sh
@@ -1,136 +1,26 @@
 #!/usr/bin/env bash
+DIR=`dirname "$BASH_SOURCE"`
 
 function base_app_apk() {
-  local dir=$TEST_PROJECTS_ANDROID
-  local outputDir="$FLANK_FIXTURES_TMP/apk"
-
-  for arg in "$@"; do case "$arg" in
-
-    '--generate' | '-g')
-      "$dir/gradlew" -p "$dir" app:assemble
-      ;;
-
-    '--copy' | '-c')
-      local apkIn="$dir/app/build/outputs/apk/singleSuccess/debug/app-single-success-debug.apk"
-      local apkOut="$outputDir/app-debug.apk"
-
-      mkdir -p "$outputDir"
-      cp "$apkIn" "$apkOut"
-      ;;
-
-    esac done
+  $DIR/../../flank-scripts/bash/flankScripts shell ops android --copy --generate --artifacts=buildBaseApk
 }
 
 # depends on base_app_apk
 function base_test_apks() {
-  local dir=$TEST_PROJECTS_ANDROID
-
-  for arg in "$@"; do case "$arg" in
-
-    '--generate' | '-g')
-      "$dir/gradlew" -p "$dir" app:assembleAndroidTest
-      ;;
-
-    '--copy' | '-c')
-      local outputDir="$FLANK_FIXTURES_TMP/apk"
-
-      mkdir -p "$outputDir"
-      cp "$dir"/app/build/outputs/apk/androidTest/**/debug/*.apk "$outputDir/"
-      ;;
-
-    esac done
+   $DIR/../../flank-scripts/bash/flankScripts shell ops android --copy --generate --artifacts=buildBaseTestApk
 }
 
 # depends on base_app_apk
 function duplicated_names_apks() {
-  local dir=$TEST_PROJECTS_ANDROID
-
-  for arg in "$@"; do case "$arg" in
-
-    '--generate' | '-g')
-      "$dir/gradlew" -p "$dir" \
-        dir0:testModule:assembleAndroidTest \
-        dir1:testModule:assembleAndroidTest \
-        dir2:testModule:assembleAndroidTest \
-        dir3:testModule:assembleAndroidTest
-      ;;
-
-    '--copy' | '-c')
-      local outputDir="$FLANK_FIXTURES_TMP/apk"
-      local testIn="$dir/app/build/outputs/apk/androidTest/**/debug/*.apk"
-
-      mkdir -p "$outputDir"
-      local dir=$(dirname "${BASH_SOURCE[0]-$0}")
-      local outputDir="$FLANK_FIXTURES_TMP/apk/duplicated_names/"
-
-      for index in 0 1 2 3; do
-        moduleName="dir$index"
-        apkDir="$outputDir/$moduleName/"
-        mkdir -p "$apkDir"
-        cp "$dir/$moduleName"/testModule/build/outputs/apk/**/debug/*.apk "$apkDir"
-      done
-      ;;
-
-    esac done
+  $DIR/../../flank-scripts/bash/flankScripts shell ops android --copy --generate --artifacts=buildDuplicatedNamesApks
 }
 
 function multi_module_apks() {
-  local dir=$TEST_PROJECTS_ANDROID
-  local outputDir="$FLANK_FIXTURES_TMP/apk/multi-modules/"
-
-  for arg in "$@"; do case "$arg" in
-
-    '--generate' | '-g')
-      "$dir/gradlew" -p "$dir" \
-        :multi-modules:multiapp:assemble \
-        :multi-modules:testModule1:assembleAndroidTest \
-        :multi-modules:testModule2:assembleAndroidTest \
-        :multi-modules:testModule3:assembleAndroidTest \
-        :multi-modules:testModule4:assembleAndroidTest \
-        :multi-modules:testModule5:assembleAndroidTest \
-        :multi-modules:testModule6:assembleAndroidTest \
-        :multi-modules:testModule7:assembleAndroidTest \
-        :multi-modules:testModule8:assembleAndroidTest \
-        :multi-modules:testModule9:assembleAndroidTest \
-        :multi-modules:testModule10:assembleAndroidTest \
-        :multi-modules:testModule11:assembleAndroidTest \
-        :multi-modules:testModule12:assembleAndroidTest \
-        :multi-modules:testModule13:assembleAndroidTest \
-        :multi-modules:testModule14:assembleAndroidTest \
-        :multi-modules:testModule15:assembleAndroidTest \
-        :multi-modules:testModule16:assembleAndroidTest \
-        :multi-modules:testModule17:assembleAndroidTest \
-        :multi-modules:testModule18:assembleAndroidTest \
-        :multi-modules:testModule19:assembleAndroidTest \
-        :multi-modules:testModule20:assembleAndroidTest
-      ;;
-
-    '--copy' | '-c')
-      mkdir -p "$outputDir"
-      find "$dir/multi-modules" -type f -name "*.apk" -exec cp {} "$outputDir" \;
-      ;;
-
-    esac done
+  $DIR/../../flank-scripts/bash/flankScripts shell ops android --copy --generate --artifacts=buildMultiModulesApks
 }
 
 function cucumber_sample_app() {
-  local dir=$TEST_PROJECTS_ANDROID
-  local outputDir="$FLANK_FIXTURES_TMP/apk/cucumber_sample_app/"
-
-  for arg in "$@"; do case "$arg" in
-
-    '--generate' | '-g')
-      "$dir/gradlew" -p "$dir" \
-        :cucumber_sample_app:cukeulator:assemble \
-        :cucumber_sample_app:cukeulator:assembleAndroidTest
-      ;;
-
-    '--copy' | '-c')
-      mkdir -p "$outputDir"
-      find "$dir/cucumber_sample_app" -type f -name "*.apk" -exec cp {} "$outputDir" \;
-      ;;
-
-    esac done
+  $DIR/../../flank-scripts/bash/flankScripts shell ops android --copy --generate --artifacts=buildCucumberSampleApp
 }
 
 echo "Android test projects ops loaded"
diff --git a/test_projects/gohello/build.bat b/test_projects/gohello/build.bat
new file mode 100644
index 0000000000..2955f93675
--- /dev/null
+++ b/test_projects/gohello/build.bat
@@ -0,0 +1,2 @@
+SET DIR=%~dp0
+%DIR%\..\..\flank-scripts\bash\flankScripts.bat shell ops go
diff --git a/test_projects/gohello/build.sh b/test_projects/gohello/build.sh
index a7e383a006..33510f858f 100755
--- a/test_projects/gohello/build.sh
+++ b/test_projects/gohello/build.sh
@@ -1,10 +1,2 @@
-#!/bin/bash
-
-rm -rf "./bin/"
-mkdir -p bin/win
-mkdir -p bin/linux
-mkdir -p bin/mac
-
-GOOS=windows GOARCH=amd64 go build -o ./bin/win/gohello.exe
-GOOS=linux   GOARCH=amd64 go build -o ./bin/linux/gohello
-GOOS=darwin  GOARCH=amd64 go build -o ./bin/mac/gohello
+DIR=`dirname "$BASH_SOURCE"`
+$DIR/../../flank-scripts/bash/flankScripts shell ops go
diff --git a/test_projects/ios/EarlGreyExample/build_example.sh b/test_projects/ios/EarlGreyExample/build_example.sh
index df03cd26e0..54ce63a89e 100755
--- a/test_projects/ios/EarlGreyExample/build_example.sh
+++ b/test_projects/ios/EarlGreyExample/build_example.sh
@@ -1,33 +1,2 @@
-#!/bin/bash
-
-set -euxo pipefail
-
-if ! [ -x "$(command -v xcpretty)" ]; then
-  gem install xcpretty
-fi
-
-DD="dd_tmp"
-ZIP="earlgrey_example.zip"
-
-rm -rf "$DD"
-
-xcodebuild build-for-testing \
-  -allowProvisioningUpdates \
-  -workspace ./EarlGreyExample.xcworkspace \
-  -scheme "EarlGreyExampleSwiftTests" \
-  -derivedDataPath "$DD" \
-  -sdk iphoneos \
-  | xcpretty
-
-xcodebuild build-for-testing \
-  -allowProvisioningUpdates \
-  -workspace ./EarlGreyExample.xcworkspace \
-  -scheme "EarlGreyExampleTests" \
-  -derivedDataPath "$DD" \
-  -sdk iphoneos \
-  | xcpretty
-
-pushd "$DD/Build/Products"
-zip -r "$ZIP" *-iphoneos *.xctestrun
-popd
-mv "$DD/Build/Products/$ZIP" .
+DIR=`dirname "$BASH_SOURCE"`
+$DIR/../flank-scripts/bash/flankScripts shell iosBuildExample
diff --git a/test_projects/ios/EarlGreyExample/build_ftl.sh b/test_projects/ios/EarlGreyExample/build_ftl.sh
index 36c2dd7a32..f2cdb7f3c3 100755
--- a/test_projects/ios/EarlGreyExample/build_ftl.sh
+++ b/test_projects/ios/EarlGreyExample/build_ftl.sh
@@ -1,26 +1,2 @@
-#!/bin/bash
-
-set -euxo pipefail
-
-if ! [ -x "$(command -v xcpretty)" ]; then
-  gem install xcpretty
-fi
-
-DD="dd_tmp"
-SCHEME="EarlGreyExampleSwiftTests"
-ZIP="ios_earlgrey2.zip"
-
-rm -rf "$DD"
-
-xcodebuild build-for-testing \
-  -allowProvisioningUpdates \
-  -workspace ./EarlGreyExample.xcworkspace \
-  -scheme "$SCHEME" \
-  -derivedDataPath "$DD" \
-  -sdk iphoneos \
-  | xcpretty
-
-pushd "$DD/Build/Products"
-zip -r "$ZIP" *-iphoneos *.xctestrun
-popd
-mv "$DD/Build/Products/$ZIP" .
+DIR=`dirname "$BASH_SOURCE"`
+$DIR/../flank-scripts/bash/flankScripts shell iosBuildFtl
diff --git a/test_projects/ios/EarlGreyExample/ops.sh b/test_projects/ios/EarlGreyExample/ops.sh
index 9ea3d2d552..77f108e8bf 100644
--- a/test_projects/ios/EarlGreyExample/ops.sh
+++ b/test_projects/ios/EarlGreyExample/ops.sh
@@ -1,78 +1,19 @@
 #!/usr/bin/env bash
 
-EARL_GREY_EXAMPLE="$TEST_PROJECTS_IOS/EarlGreyExample"
-
 function setup_ios_env() {
-  if ! [ -x "$(command -v xcpretty)" ]; then
-    gem install cocoapods -v 1.9.3
-  fi
-  (cd "$EARL_GREY_EXAMPLE" && pod install)
+  $DIR/../../flank-scripts/bash/flankScripts shell setup_ios_env
 }
 
 function install_xcpretty() {
-  if ! [ -x "$(command -v xcpretty)" ]; then
-    gem install xcpretty
-  fi
+  $DIR/../../flank-scripts/bash/flankScripts shell install_xcpretty
 }
 
 function universal_framework() {
-  "$EARL_GREY_EXAMPLE/universal_framework.sh"
+   $DIR/../../flank-scripts/bash/flankScripts shell iosUniversalFramework
 }
 
 function earl_grey_example() {
-  local dir=$EARL_GREY_EXAMPLE
-  local buildDir="$dir/build"
-
-  for arg in "$@"; do case "$arg" in
-
-    '--generate' | '-g')
-
-      install_xcpretty
-
-      rm -rf "$buildDir"
-
-      xcodebuild build-for-testing \
-        -allowProvisioningUpdates \
-        -workspace "$dir/EarlGreyExample.xcworkspace" \
-        -scheme "EarlGreyExampleSwiftTests" \
-        -derivedDataPath "$buildDir" \
-        -sdk iphoneos |
-        xcpretty
-
-      xcodebuild build-for-testing \
-        -allowProvisioningUpdates \
-        -workspace "$dir/EarlGreyExample.xcworkspace" \
-        -scheme "EarlGreyExampleTests" \
-        -derivedDataPath "$buildDir" \
-        -sdk iphoneos |
-        xcpretty
-      ;;
-
-    '--copy' | '-c')
-
-      local productsDir="$dir/build/Build/Products"
-
-      # xcodebuild generates .xctestrun files names in format: PROJECTNAME_platform_version_architecture.xctestrun, code below removes "_platform_version_architecture" part
-      mv -f "$productsDir/EarlGreyExampleSwiftTests"*.xctestrun "$productsDir/EarlGreyExampleSwiftTests.xctestrun"
-      mv -f "$productsDir/EarlGreyExampleTests"*.xctestrun "$productsDir/EarlGreyExampleTests.xctestrun"
-
-      mkdir -p "$FLANK_FIXTURES_TMP/ios/earl_grey_example/objc/"
-      mkdir -p "$FLANK_FIXTURES_TMP/ios/earl_grey_example/swift/"
-
-      cp -Rf "$productsDir"/*-iphoneos "$FLANK_FIXTURES_TMP/ios/earl_grey_example/"
-
-      cp "$productsDir"/*.xctestrun "$FLANK_FIXTURES_TMP/ios/earl_grey_example/"
-
-      cp \
-        "$productsDir/Debug-iphoneos/EarlGreyExampleSwift.app/PlugIns/EarlGreyExampleTests.xctest/EarlGreyExampleTests" \
-        "$FLANK_FIXTURES_TMP/ios/earl_grey_example/objc/"
-
-      cp \
-        "$productsDir/Debug-iphoneos/EarlGreyExampleSwift.app/PlugIns/EarlGreyExampleSwiftTests.xctest/EarlGreyExampleSwiftTests" \
-        "$FLANK_FIXTURES_TMP/ios/earl_grey_example/swift/"
-      ;;
-
-    esac done
+  $DIR/../../flank-scripts/bash/flankScripts shell ops ios --copy --generate
 }
 
 echo "iOS EarlGreyExample test projects ops loaded"
diff --git a/test_projects/ios/EarlGreyExample/run_ftl_local.sh b/test_projects/ios/EarlGreyExample/run_ftl_local.sh
index 600117bfb9..c81d9a56e5 100755
--- a/test_projects/ios/EarlGreyExample/run_ftl_local.sh
+++ b/test_projects/ios/EarlGreyExample/run_ftl_local.sh
@@ -1,15 +1,2 @@
-#!/bin/bash
-
-set -euxo pipefail
-
-DD="dd_tmp"
-SCHEME="appUITests"
-ZIP="ios_earlgrey2.zip"
-
-# Firebase test lab runs using -xctestrun
-xcodebuild test-without-building \
-  -xctestrun $DD/Build/Products/*.xctestrun \
-  -derivedDataPath "$DD" \
-  -destination 'id=ADD_YOUR_ID_HERE'
-
-# get device identifier in Xcode -> Window -> Devices and Simulators
+DIR=`dirname "$BASH_SOURCE"`
+$DIR/../flank-scripts/bash/flankScripts shell iosRunFtlLocal
diff --git a/test_projects/ios/EarlGreyExample/universal_framework.sh b/test_projects/ios/EarlGreyExample/universal_framework.sh
index f703753799..cec365d10a 100755
--- a/test_projects/ios/EarlGreyExample/universal_framework.sh
+++ b/test_projects/ios/EarlGreyExample/universal_framework.sh
@@ -1,26 +1,2 @@
-#!/bin/bash
-
-set -euxo pipefail
-
-COMBO="./ios-frameworks"
-DEVICE="$COMBO/Debug-iphoneos"
-SIM="$COMBO/Debug-iphonesimulator"
-
-lipo -create $DEVICE/libChannelLib.a $SIM/libChannelLib.a -output $COMBO/libChannelLib.a
-lipo -create $DEVICE/libCommonLib.a $SIM/libCommonLib.a -output $COMBO/libCommonLib.a
-lipo -create $DEVICE/libeDistantObject.a $SIM/libeDistantObject.a -output $COMBO/libeDistantObject.a
-lipo -create $DEVICE/libTestLib.a $SIM/libTestLib.a -output $COMBO/libTestLib.a
-lipo -create $DEVICE/libUILib.a $SIM/libUILib.a -output $COMBO/libUILib.a
-
-cp -RL $DEVICE/AppFramework.framework $COMBO/AppFramework.framework
-DEVICE_FRAMEWORK="$DEVICE/AppFramework.framework/AppFramework"
-SIM_FRAMEWORK="$SIM/AppFramework.framework/AppFramework"
-UNI_FRAMEWORK="$COMBO/AppFramework.framework/AppFramework"
-
-lipo -create \
-  "$DEVICE_FRAMEWORK" \
-  "$SIM_FRAMEWORK" \
-  -output "$UNI_FRAMEWORK"
-
-dsymutil "$UNI_FRAMEWORK" \
-  --out "$COMBO/AppFramework.framework.dSYM"
+DIR=`dirname "$BASH_SOURCE"`
+$DIR/../flank-scripts/bash/flankScripts shell iosUniversalFramework
diff --git a/test_projects/ops.bat b/test_projects/ops.bat
new file mode 100644
index 0000000000..f1c35124ac
--- /dev/null
+++ b/test_projects/ops.bat
@@ -0,0 +1,18 @@
+@ECHO OFF
+SET ARG=%1
+if "%ARG%" == "android" (
+	CALL ../flank-scripts/bash/flankScripts.bat shell ops android --copy --generate
+)
+
+if "%ARG%" == "ios" (
+    echo iOS Build on windows not supported
+)
+
+if "%ARG%" == "go" (
+	CALL ../flank-scripts/bash/flankScripts.bat shell ops go
+)
+
+if "%ARG%" == "all" (
+    CALL ../flank-scripts/bash/flankScripts.bat shell ops android --copy --generate
+    CALL ../flank-scripts/bash/flankScripts.bat shell ops go
+)
diff --git a/test_projects/ops.sh b/test_projects/ops.sh
index 9a1e7914e0..f9ab388618 100755
--- a/test_projects/ops.sh
+++ b/test_projects/ops.sh
@@ -1,33 +1,27 @@
 #!/usr/bin/env bash
 
-TEST_PROJECTS_ANDROID="$TEST_PROJECTS/android"
-TEST_PROJECTS_IOS="$TEST_PROJECTS/ios"
-
-. "$TEST_PROJECTS_ANDROID/ops.sh"
-. "$TEST_PROJECTS_IOS/EarlGreyExample/ops.sh"
-. "$TEST_PROJECTS_IOS/FlankExample/ops.sh"
+DIR=`dirname "$BASH_SOURCE"`
 
 function update_test_artifacts() {
 
   for arg in "$@"; do case "$arg" in
 
     android)
-      base_app_apk --generate --copy
-      base_test_apks --generate --copy
+      $DIR/../flank-scripts/bash/flankScripts shell ops android --copy --generate
       ;;
 
     ios)
-      setup_ios_env
-      earl_grey_example --generate --copy
-      flank_ios_example --generate --copy
+      $DIR/../flank-scripts/bash/flankScripts shell ops ios --copy --generate
       ;;
 
     go)
-      cp -R "$FLANK_ROOT/test_projects/gohello" "$FLANK_FIXTURES_TMP/"
+      $DIR/../flank-scripts/bash/flankScripts shell ops go --copy --generate
       ;;
 
     all)
-      update_test_artifacts android ios go
+      $DIR/../flank-scripts/bash/flankScripts shell ops android --copy --generate
+      $DIR/../flank-scripts/bash/flankScripts shell ops ios --copy --generate
+      $DIR/../flank-scripts/bash/flankScripts shell ops go --copy --generate
       ;;
 
     esac done
diff --git a/test_runner/bash/update_flank.bat b/test_runner/bash/update_flank.bat
index 6f4d73a8c5..6a5e4d0489 100644
--- a/test_runner/bash/update_flank.bat
+++ b/test_runner/bash/update_flank.bat
@@ -1,7 +1,2 @@
-
-for %%F in (%filename%) do set dirname=%%~dpF
-echo "%dirname%"
-cd ..
-cd ..
-call gradlew.bat clean assemble shadowjar
-cd test_runner\bash
+SET DIR=%~dp0
+%DIR%\..\..\flank-scripts\bash\flankScripts.bat shell buildFlank
diff --git a/test_runner/bash/update_flank.sh b/test_runner/bash/update_flank.sh
index 471ab34ed6..9090fbd33c 100755
--- a/test_runner/bash/update_flank.sh
+++ b/test_runner/bash/update_flank.sh
@@ -1,9 +1,3 @@
-#!/usr/bin/env bash
 
 DIR=`dirname "$BASH_SOURCE"`
-
-FLANK="$DIR/.."
-
-"$FLANK/../gradlew" -p "$FLANK" clean assemble shadowJar
-
-cp "$FLANK"/build/libs/flank.jar "$DIR/flank.jar"
+$DIR/../../flank-scripts/bash/flankScripts shell buildFlank

From 22da7bf441ae880d3a6901f5d2bfad49699dc81b Mon Sep 17 00:00:00 2001
From: adamfilipow92 <64852261+adamfilipow92@users.noreply.github.com>
Date: Mon, 2 Nov 2020 14:17:40 +0100
Subject: [PATCH 3/3] feat: iOS add directories-to-pull  (#1266)

* Moved directories to pull to CommonArgs, added tests

* Fix tests

* Udpate docs

* Update GcIosTestMatrixTest.kt

* fix build issues after conflicting rebase

* Fix pull directory from ios

* update view controller for flank example

* Update IArgs.kt

* Update AndroidGcloudConfig.kt

Co-authored-by: Piotr Adamczyk <piotr.adamczyk@gogoapps.io>
Co-authored-by: piotradamczyk5 <65554637+piotradamczyk5@users.noreply.github.com>
---
 docs/index.md                                 |  6 ++++
 .../EarlGreyExample.xcodeproj/project.pbxproj | 12 ++++----
 .../EarlGreyExampleSwift/ViewController.swift | 20 ++++++++++++-
 .../FlankExample/ViewController.swift         | 19 ++++++++++++
 test_runner/flank.ios.yml                     |  6 ++++
 .../src/main/kotlin/ftl/args/AndroidArgs.kt   |  1 -
 .../src/main/kotlin/ftl/args/CommonArgs.kt    |  1 +
 .../main/kotlin/ftl/args/CreateAndroidArgs.kt |  1 -
 .../main/kotlin/ftl/args/CreateCommonArgs.kt  |  1 +
 test_runner/src/main/kotlin/ftl/args/IArgs.kt |  1 +
 .../src/main/kotlin/ftl/args/IosArgs.kt       |  1 +
 .../ftl/config/android/AndroidGcloudConfig.kt | 14 ---------
 .../ftl/config/common/CommonGcloudConfig.kt   | 16 ++++++++++
 .../src/main/kotlin/ftl/gc/GcIosTestMatrix.kt |  4 +++
 .../src/main/kotlin/ftl/gc/android/Utils.kt   | 23 +++++++-------
 .../src/test/kotlin/ftl/args/IosArgsTest.kt   |  2 ++
 .../test/android/AndroidRunCommandTest.kt     |  4 +--
 .../firebase/test/ios/IosRunCommandTest.kt    |  1 +
 .../test/kotlin/ftl/gc/GcIosTestMatrixTest.kt | 30 ++++++++++++++++++-
 19 files changed, 125 insertions(+), 38 deletions(-)

diff --git a/docs/index.md b/docs/index.md
index 4366640083..2fef138aba 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -145,6 +145,12 @@ gcloud:
   #   version: 12.0
   #   locale: es_ES
   #   orientation: landscape
+  
+  ## A list of paths that will be copied from the device's storage to the designated results bucket after the test
+  ## is complete. These must be absolute paths under /private/var/mobile/Media or /Documents
+  ## of the app under test. If the path is under an app's /Documents, it must be prefixed with the app's bundle id and a colon
+  # directories-to-pull:
+  #   - /private/var/mobile/Media
 
   ## A list of device-path=file-path pairs that specify the paths of the test device and the files you want pushed to the device prior to testing.
   ## Device paths should either be under the Media shared folder (e.g. prefixed with /private/var/mobile/Media) or
diff --git a/test_projects/ios/EarlGreyExample/EarlGreyExample.xcodeproj/project.pbxproj b/test_projects/ios/EarlGreyExample/EarlGreyExample.xcodeproj/project.pbxproj
index 07123bd199..f43ad0c808 100644
--- a/test_projects/ios/EarlGreyExample/EarlGreyExample.xcodeproj/project.pbxproj
+++ b/test_projects/ios/EarlGreyExample/EarlGreyExample.xcodeproj/project.pbxproj
@@ -283,18 +283,18 @@
 				TargetAttributes = {
 					2CB7314C1C29E54A00CF35C1 = {
 						CreatedOnToolsVersion = 7.2;
-						DevelopmentTeam = AD2V26JBWL;
+						DevelopmentTeam = L2UF9MLSM6;
 						LastSwiftMigration = 0800;
 						TestTargetID = 5F5A53781ADE67D500F81DF0;
 					};
 					5F5A53781ADE67D500F81DF0 = {
 						CreatedOnToolsVersion = 6.3;
-						DevelopmentTeam = AD2V26JBWL;
+						DevelopmentTeam = L2UF9MLSM6;
 						LastSwiftMigration = 0800;
 					};
 					5FDE05571B0DAA090037B82F = {
 						CreatedOnToolsVersion = 6.3.2;
-						DevelopmentTeam = AD2V26JBWL;
+						DevelopmentTeam = L2UF9MLSM6;
 						TestTargetID = 5F5A53781ADE67D500F81DF0;
 					};
 				};
@@ -485,7 +485,7 @@
 				CODE_SIGN_IDENTITY = "iPhone Developer";
 				DEBUG_INFORMATION_FORMAT = dwarf;
 				DEFINES_MODULE = NO;
-				DEVELOPMENT_TEAM = AD2V26JBWL;
+				DEVELOPMENT_TEAM = L2UF9MLSM6;
 				ENABLE_TESTABILITY = YES;
 				INFOPLIST_FILE = EarlGreyExampleSwiftTests/Info.plist;
 				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
@@ -619,7 +619,7 @@
 				CODE_SIGN_IDENTITY = "iPhone Developer";
 				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
 				DEFINES_MODULE = YES;
-				DEVELOPMENT_TEAM = AD2V26JBWL;
+				DEVELOPMENT_TEAM = L2UF9MLSM6;
 				EMBEDDED_CONTENT_CONTAINS_SWIFT = YES;
 				INFOPLIST_FILE = "$(SRCROOT)/EarlGreyExample/Info.plist";
 				IPHONEOS_DEPLOYMENT_TARGET = 9.0;
@@ -666,7 +666,7 @@
 				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
 				DEFINES_MODULE = NO;
-				DEVELOPMENT_TEAM = AD2V26JBWL;
+				DEVELOPMENT_TEAM = L2UF9MLSM6;
 				EMBEDDED_CONTENT_CONTAINS_SWIFT = YES;
 				FRAMEWORK_SEARCH_PATHS = "$(inherited)";
 				GCC_PREPROCESSOR_DEFINITIONS = (
diff --git a/test_projects/ios/EarlGreyExample/EarlGreyExampleSwift/ViewController.swift b/test_projects/ios/EarlGreyExample/EarlGreyExampleSwift/ViewController.swift
index 1c7e517b7a..51da069885 100644
--- a/test_projects/ios/EarlGreyExample/EarlGreyExampleSwift/ViewController.swift
+++ b/test_projects/ios/EarlGreyExample/EarlGreyExampleSwift/ViewController.swift
@@ -20,9 +20,27 @@ class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSour
 
   var tableItems = (1...50).map { $0 }
 
+    func getDocumentsDirectory() -> URL {
+        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
+        return paths[0]
+    }
+    
   override func viewDidLoad() {
     super.viewDidLoad()
-
+    
+    let str = "viewLoaded"
+    let outputDirectory = getDocumentsDirectory().appendingPathComponent("output")
+   
+    do {
+        try FileManager.default.createDirectory(atPath: outputDirectory.path, withIntermediateDirectories: true, attributes: nil)
+        
+        let filename = outputDirectory.appendingPathComponent("test.txt")
+        
+        try str.write(to: filename, atomically: true, encoding: String.Encoding.utf8)
+        
+    } catch {
+        print("error on create test file")
+    }
     // Create the send message view to contain one of the two send buttons
     let sendMessageView = SendMessageView(frame: CGRect(x: 0, y: 0, width: 200, height: 200))
     sendMessageView.translatesAutoresizingMaskIntoConstraints = false
diff --git a/test_projects/ios/FlankExample/FlankExample/ViewController.swift b/test_projects/ios/FlankExample/FlankExample/ViewController.swift
index 46087b0f6b..c2e96cf14a 100644
--- a/test_projects/ios/FlankExample/FlankExample/ViewController.swift
+++ b/test_projects/ios/FlankExample/FlankExample/ViewController.swift
@@ -10,9 +10,28 @@ import UIKit
 
 class ViewController: UIViewController {
 
+    func getDocumentsDirectory() -> URL {
+        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
+        return paths[0]
+    }
+
     override func viewDidLoad() {
         super.viewDidLoad()
         // Do any additional setup after loading the view.
+
+
+        let str = "viewLoaded"
+        let outputDirectory = getDocumentsDirectory().appendingPathComponent("output")
+
+        do {
+            try FileManager.default.createDirectory(atPath: outputDirectory.path, withIntermediateDirectories: true, attributes: nil)
+            let filename = outputDirectory.appendingPathComponent("test.txt")
+
+            try str.write(to: filename, atomically: true, encoding: String.Encoding.utf8)
+
+        } catch {
+            print("error on create test file")
+        }
     }
 }
 
diff --git a/test_runner/flank.ios.yml b/test_runner/flank.ios.yml
index 5c50560f56..9b760a40bc 100644
--- a/test_runner/flank.ios.yml
+++ b/test_runner/flank.ios.yml
@@ -77,6 +77,12 @@ gcloud:
   #   locale: es_ES
   #   orientation: landscape
 
+  ## A list of paths that will be copied from the device's storage to the designated results bucket after the test
+  ## is complete. These must be absolute paths under /private/var/mobile/Media or /Documents
+  ## of the app under test. If the path is under an app's /Documents, it must be prefixed with the app's bundle id and a colon
+  # directories-to-pull:
+  #   - /private/var/mobile/Media
+
   ## A list of device-path=file-path pairs that specify the paths of the test device and the files you want pushed to the device prior to testing.
   ## Device paths should either be under the Media shared folder (e.g. prefixed with /private/var/mobile/Media) or
   ## within the documents directory of the filesystem of an app under test (e.g. /Documents). Device paths to app
diff --git a/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt b/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt
index 42c76f6c70..7a0f60eda4 100644
--- a/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/AndroidArgs.kt
@@ -13,7 +13,6 @@ data class AndroidArgs(
     val roboDirectives: List<FlankRoboDirective>,
     val roboScript: String?,
     val environmentVariables: Map<String, String>, // should not be printed, becuase could contains sensitive informations
-    val directoriesToPull: List<String>,
     val grantPermissions: String?,
     val scenarioLabels: List<String>,
     val obbFiles: List<String>,
diff --git a/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt b/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt
index 7cdb512aa4..ee87d6b555 100644
--- a/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/CommonArgs.kt
@@ -20,6 +20,7 @@ data class CommonArgs(
     override val networkProfile: String?,
     override val otherFiles: Map<String, String>,
     override val type: Type?,
+    override val directoriesToPull: List<String>,
     override val scenarioNumbers: List<String>,
 
     // flank
diff --git a/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt b/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt
index c8520a936c..cd5b4b4b8b 100644
--- a/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/CreateAndroidArgs.kt
@@ -23,7 +23,6 @@ fun createAndroidArgs(
     performanceMetrics = gcloud.performanceMetrics!!,
     numUniformShards = gcloud.numUniformShards,
     environmentVariables = gcloud.environmentVariables!!,
-    directoriesToPull = gcloud.directoriesToPull!!,
     autoGoogleLogin = gcloud.autoGoogleLogin!!,
     additionalApks = gcloud.additionalApks!!.map { it.normalizeFilePath() },
     roboScript = gcloud.roboScript?.normalizeFilePath(),
diff --git a/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt b/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt
index 41c0dcb3c1..2393846831 100644
--- a/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/CreateCommonArgs.kt
@@ -25,6 +25,7 @@ fun CommonConfig.createCommonArgs(
     flakyTestAttempts = gcloud.flakyTestAttempts!!,
     networkProfile = gcloud.networkProfile,
     clientDetails = gcloud.clientDetails,
+    directoriesToPull = gcloud.directoriesToPull!!,
     otherFiles = gcloud.otherFiles!!.mapValues { (_, path) -> path.normalizeFilePath() },
     scenarioNumbers = gcloud.scenarioNumbers!!,
     type = gcloud.type?.toType(),
diff --git a/test_runner/src/main/kotlin/ftl/args/IArgs.kt b/test_runner/src/main/kotlin/ftl/args/IArgs.kt
index a046d1bc21..da03e39e2c 100644
--- a/test_runner/src/main/kotlin/ftl/args/IArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/IArgs.kt
@@ -26,6 +26,7 @@ interface IArgs {
     val otherFiles: Map<String, String>
     val scenarioNumbers: List<String>
     val type: Type? get() = null
+    val directoriesToPull: List<String>
 
     // FlankYml
     val maxTestShards: Int
diff --git a/test_runner/src/main/kotlin/ftl/args/IosArgs.kt b/test_runner/src/main/kotlin/ftl/args/IosArgs.kt
index 017dee948f..afc09e2bce 100644
--- a/test_runner/src/main/kotlin/ftl/args/IosArgs.kt
+++ b/test_runner/src/main/kotlin/ftl/args/IosArgs.kt
@@ -40,6 +40,7 @@ IosArgs
       xcode-version: $xcodeVersion
       device:${ArgsToString.objectsToString(devices)}
       num-flaky-test-attempts: $flakyTestAttempts
+      directories-to-pull: ${ArgsToString.listToString(directoriesToPull)}
       other-files: ${ArgsToString.mapToString(otherFiles)}
       additional-ipas: ${ArgsToString.listToString(additionalIpas)}
       scenario-numbers: ${ArgsToString.listToString(scenarioNumbers)}
diff --git a/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt b/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt
index 11c6e32da5..6de48aad1f 100644
--- a/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt
+++ b/test_runner/src/main/kotlin/ftl/config/android/AndroidGcloudConfig.kt
@@ -102,19 +102,6 @@ data class AndroidGcloudConfig @JsonIgnore constructor(
     @set:JsonProperty("grant-permissions")
     var grantPermissions: String? by data
 
-    @set:CommandLine.Option(
-        names = ["--directories-to-pull"],
-        split = ",",
-        description = ["A list of paths that will be copied from the device's " +
-            "storage to the designated results bucket after the test is complete. These must be absolute paths under " +
-            "/sdcard or /data/local/tmp (for example, --directories-to-pull /sdcard/tempDir1,/data/local/tmp/tempDir2). " +
-            "Path names are restricted to the characters a-zA-Z0-9_-./+. The paths /sdcard and /data will be made available " +
-            "and treated as implicit path substitutions. E.g. if /sdcard on a particular device does not map to external " +
-            "storage, the system will replace it with the external storage path prefix for that device."]
-    )
-    @set:JsonProperty("directories-to-pull")
-    var directoriesToPull: List<String>? by data
-
     @set:CommandLine.Option(
         names = ["--scenario-labels"],
         split = ",",
@@ -239,7 +226,6 @@ data class AndroidGcloudConfig @JsonIgnore constructor(
             useOrchestrator = true
             environmentVariables = emptyMap()
             grantPermissions = FlankDefaults.GRANT_PERMISSIONS_ALL
-            directoriesToPull = emptyList()
             scenarioLabels = emptyList()
             obbfiles = emptyList()
             obbnames = emptyList()
diff --git a/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt b/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt
index 341b8d4a03..acce84442e 100644
--- a/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt
+++ b/test_runner/src/main/kotlin/ftl/config/common/CommonGcloudConfig.kt
@@ -120,6 +120,21 @@ data class CommonGcloudConfig @JsonIgnore constructor(
     @set:JsonProperty("num-flaky-test-attempts")
     var flakyTestAttempts: Int? by data
 
+    @set:CommandLine.Option(
+        names = ["--directories-to-pull"],
+        split = ",",
+        description = ["A list of paths that will be copied from the device's " +
+            "storage to the designated results bucket after the test is complete. For Android devices these must be absolute paths under " +
+            "/sdcard or /data/local/tmp (for example, --directories-to-pull /sdcard/tempDir1,/data/local/tmp/tempDir2). " +
+            "Path names are restricted to the characters a-zA-Z0-9_-./+. The paths /sdcard and /data will be made available " +
+            "and treated as implicit path substitutions. E.g. if /sdcard on a particular device does not map to external " +
+            "storage, the system will replace it with the external storage path prefix for that device. " +
+            "For iOS devices these must be absolute paths under /private/var/mobile/Media or /Documents " +
+            "of the app under test. If the path is under an app's /Documents, it must be prefixed with the app's bundle id and a colon"]
+    )
+    @set:JsonProperty("directories-to-pull")
+    var directoriesToPull: List<String>? by data
+
     @set:CommandLine.Option(
         names = ["--other-files"],
         split = ",",
@@ -170,6 +185,7 @@ data class CommonGcloudConfig @JsonIgnore constructor(
             clientDetails = null
             networkProfile = null
             devices = listOf(defaultDevice(android))
+            directoriesToPull = emptyList()
             otherFiles = emptyMap()
             type = null
             scenarioNumbers = emptyList()
diff --git a/test_runner/src/main/kotlin/ftl/gc/GcIosTestMatrix.kt b/test_runner/src/main/kotlin/ftl/gc/GcIosTestMatrix.kt
index 0ae83f4a52..c2a571aa2e 100644
--- a/test_runner/src/main/kotlin/ftl/gc/GcIosTestMatrix.kt
+++ b/test_runner/src/main/kotlin/ftl/gc/GcIosTestMatrix.kt
@@ -16,6 +16,7 @@ import com.google.api.services.testing.model.ToolResultsHistory
 import ftl.args.IosArgs
 import ftl.gc.android.mapGcsPathsToFileReference
 import ftl.gc.android.mapToIosDeviceFiles
+import ftl.gc.android.toIosDeviceFile
 import ftl.ios.Xctestrun
 import ftl.ios.Xctestrun.toByteArray
 import ftl.run.exception.FlankGeneralError
@@ -69,6 +70,7 @@ object GcIosTestMatrix {
             .setNetworkProfile(args.networkProfile)
             .setPushFiles(otherFiles.mapToIosDeviceFiles())
             .setAdditionalIpas(additionalIpasGcsPaths.mapGcsPathsToFileReference())
+            .setPullDirectories(args.directoriesToPull.toIosDeviceFiles())
 
         val testTimeoutSeconds = timeoutToSeconds(args.testTimeout)
 
@@ -98,3 +100,5 @@ object GcIosTestMatrix {
         }
     }
 }
+
+private fun List<String>.toIosDeviceFiles() = map { path -> toIosDeviceFile(path) }
diff --git a/test_runner/src/main/kotlin/ftl/gc/android/Utils.kt b/test_runner/src/main/kotlin/ftl/gc/android/Utils.kt
index 827d3f549f..61e4e59de2 100644
--- a/test_runner/src/main/kotlin/ftl/gc/android/Utils.kt
+++ b/test_runner/src/main/kotlin/ftl/gc/android/Utils.kt
@@ -32,16 +32,15 @@ internal fun Map<String, String>.mapToDeviceObbFiles(obbnames: List<String>): Li
     }
 }
 
-internal fun Map<String, String>.mapToIosDeviceFiles(): List<IosDeviceFile> =
-    map { (testDevicePath, gcsFilePath) ->
-        IosDeviceFile().apply {
-            if (testDevicePath.contains(":")) {
-                val (bundleIdSeparated, pathSeparated) = testDevicePath.split(":")
-                bundleId = bundleIdSeparated
-                devicePath = pathSeparated
-            } else {
-                devicePath = testDevicePath
-            }
-            content = FileReference().setGcsPath(gcsFilePath)
-        }
+internal fun Map<String, String>.mapToIosDeviceFiles(): List<IosDeviceFile> = map { (testDevicePath, gcsFilePath) -> toIosDeviceFile(testDevicePath, gcsFilePath) }
+
+internal fun toIosDeviceFile(testDevicePath: String, gcsFilePath: String = "") = IosDeviceFile().apply {
+    if (testDevicePath.contains(":")) {
+        val (bundleIdSeparated, pathSeparated) = testDevicePath.split(":")
+        bundleId = bundleIdSeparated
+        devicePath = pathSeparated
+    } else {
+        devicePath = testDevicePath
     }
+    content = FileReference().setGcsPath(gcsFilePath)
+}
diff --git a/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt b/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt
index e9460a3ec3..641b97aa81 100644
--- a/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt
+++ b/test_runner/src/test/kotlin/ftl/args/IosArgsTest.kt
@@ -235,6 +235,7 @@ IosArgs
           locale: c
           orientation: default
       num-flaky-test-attempts: 4
+      directories-to-pull: 
       other-files: 
         com.my.app:/Documents/file.txt: local/file.txt
         /private/var/mobile/Media/file.jpg: gs://bucket/file.jpg
@@ -300,6 +301,7 @@ IosArgs
           locale: en
           orientation: portrait
       num-flaky-test-attempts: 0
+      directories-to-pull: 
       other-files: 
       additional-ipas: 
       scenario-numbers: 
diff --git a/test_runner/src/test/kotlin/ftl/cli/firebase/test/android/AndroidRunCommandTest.kt b/test_runner/src/test/kotlin/ftl/cli/firebase/test/android/AndroidRunCommandTest.kt
index a23f0b56d1..14e23f7405 100644
--- a/test_runner/src/test/kotlin/ftl/cli/firebase/test/android/AndroidRunCommandTest.kt
+++ b/test_runner/src/test/kotlin/ftl/cli/firebase/test/android/AndroidRunCommandTest.kt
@@ -80,7 +80,6 @@ class AndroidRunCommandTest {
         assertThat(cmd.config.platform.gcloud.numUniformShards).isNull()
         assertThat(cmd.config.platform.gcloud.testRunnerClass).isNull()
         assertThat(cmd.config.platform.gcloud.environmentVariables).isNull()
-        assertThat(cmd.config.platform.gcloud.directoriesToPull).isNull()
         assertThat(cmd.config.common.gcloud.otherFiles).isNull()
         assertThat(cmd.config.common.gcloud.devices).isNull()
         assertThat(cmd.config.common.gcloud.resultsBucket).isNull()
@@ -98,6 +97,7 @@ class AndroidRunCommandTest {
         assertThat(cmd.config.common.flank.filesToDownload).isNull()
         assertThat(cmd.config.common.gcloud.resultsDir).isNull()
         assertThat(cmd.config.common.gcloud.flakyTestAttempts).isNull()
+        assertThat(cmd.config.common.gcloud.directoriesToPull).isNull()
         assertThat(cmd.config.common.flank.disableSharding).isNull()
         assertThat(cmd.config.common.flank.localResultsDir).isNull()
         assertThat(cmd.config.common.flank.smartFlankDisableUpload).isNull()
@@ -219,7 +219,7 @@ class AndroidRunCommandTest {
         val cmd = AndroidRunCommand()
         CommandLine(cmd).parseArgs("--directories-to-pull=a,b")
 
-        assertThat(cmd.config.platform.gcloud.directoriesToPull).hasSize(2)
+        assertThat(cmd.config.common.gcloud.directoriesToPull).hasSize(2)
     }
 
     @Test
diff --git a/test_runner/src/test/kotlin/ftl/cli/firebase/test/ios/IosRunCommandTest.kt b/test_runner/src/test/kotlin/ftl/cli/firebase/test/ios/IosRunCommandTest.kt
index 264cb75667..f86b076a92 100644
--- a/test_runner/src/test/kotlin/ftl/cli/firebase/test/ios/IosRunCommandTest.kt
+++ b/test_runner/src/test/kotlin/ftl/cli/firebase/test/ios/IosRunCommandTest.kt
@@ -90,6 +90,7 @@ class IosRunCommandTest {
         assertThat(cmd.config.common.gcloud.devices).isNull()
         assertThat(cmd.config.common.gcloud.resultsDir).isNull()
         assertThat(cmd.config.common.gcloud.flakyTestAttempts).isNull()
+        assertThat(cmd.config.common.gcloud.directoriesToPull).isNull()
         assertThat(cmd.config.common.flank.localResultsDir).isNull()
         assertThat(cmd.config.common.flank.smartFlankDisableUpload).isNull()
         assertThat(cmd.config.common.flank.smartFlankGcsPath).isNull()
diff --git a/test_runner/src/test/kotlin/ftl/gc/GcIosTestMatrixTest.kt b/test_runner/src/test/kotlin/ftl/gc/GcIosTestMatrixTest.kt
index 6c52717f9b..e8da56641c 100644
--- a/test_runner/src/test/kotlin/ftl/gc/GcIosTestMatrixTest.kt
+++ b/test_runner/src/test/kotlin/ftl/gc/GcIosTestMatrixTest.kt
@@ -2,10 +2,10 @@ package ftl.gc
 
 import com.dd.plist.NSDictionary
 import com.google.api.services.testing.model.IosDeviceList
-import ftl.shard.Chunk
 import ftl.args.IosArgs
 import ftl.config.FtlConstants.isWindows
 import ftl.ios.FIXTURES_PATH
+import ftl.shard.Chunk
 import ftl.shard.TestMethod
 import ftl.test.util.FlankTestRunner
 import ftl.util.ShardCounter
@@ -165,4 +165,32 @@ class GcIosTestMatrixTest {
         val expected = emptyList<String>()
         assertEquals(expected, iosArgs.additionalIpas)
     }
+
+    @Test
+    fun `should fill directoriesToPull`() {
+        val iosArgs = IosArgs.load(StringReader("""
+            gcloud:
+              test: ./test_runner/src/test/kotlin/ftl/fixtures/tmp/earlgrey_example.zip
+              xctestrun-file: ./test_runner/src/test/kotlin/ftl/fixtures/tmp/EarlGreyExampleSwiftTests_iphoneos13.4-arm64e.xctestrun
+              results-dir: test_dir
+              directories-to-pull:
+                - test/test/test
+        """.trimIndent()))
+
+        val expected = listOf("test/test/test")
+        assertEquals(expected, iosArgs.directoriesToPull)
+    }
+
+    @Test
+    fun `should not fill directoriesToPull`() {
+        val iosArgs = IosArgs.load(StringReader("""
+            gcloud:
+              test: ./test_runner/src/test/kotlin/ftl/fixtures/tmp/earlgrey_example.zip
+              xctestrun-file: ./test_runner/src/test/kotlin/ftl/fixtures/tmp/EarlGreyExampleSwiftTests_iphoneos13.4-arm64e.xctestrun
+              results-dir: test_dir
+        """.trimIndent()))
+
+        val expected = emptyList<String>()
+        assertEquals(expected, iosArgs.directoriesToPull)
+    }
 }