@better-update/cli 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/oxlint.config.ts +6 -0
  3. package/package.json +36 -0
  4. package/src/app-layer.ts +29 -0
  5. package/src/application/build-workflow.ts +222 -0
  6. package/src/application/command-exit.ts +13 -0
  7. package/src/application/login.ts +87 -0
  8. package/src/application/update-promote.ts +88 -0
  9. package/src/application/update-publish.ts +402 -0
  10. package/src/application/update-rollback.ts +275 -0
  11. package/src/commands/analytics/adoption.ts +40 -0
  12. package/src/commands/analytics/channels.ts +35 -0
  13. package/src/commands/analytics/helpers.ts +3 -0
  14. package/src/commands/analytics/index.ts +13 -0
  15. package/src/commands/analytics/platforms.ts +39 -0
  16. package/src/commands/analytics/updates.ts +35 -0
  17. package/src/commands/audit-logs/helpers.ts +3 -0
  18. package/src/commands/audit-logs/index.ts +8 -0
  19. package/src/commands/audit-logs/list.ts +66 -0
  20. package/src/commands/branches.ts +70 -0
  21. package/src/commands/build/android.ts +129 -0
  22. package/src/commands/build/index.ts +63 -0
  23. package/src/commands/build/ios.ts +199 -0
  24. package/src/commands/build/reserve-and-upload.test.ts +263 -0
  25. package/src/commands/build/reserve-and-upload.ts +160 -0
  26. package/src/commands/build/run-step.ts +131 -0
  27. package/src/commands/builds/compatibility-matrix.ts +48 -0
  28. package/src/commands/builds/delete.ts +15 -0
  29. package/src/commands/builds/get.ts +34 -0
  30. package/src/commands/builds/helpers.ts +3 -0
  31. package/src/commands/builds/index.ts +20 -0
  32. package/src/commands/builds/install-link.ts +20 -0
  33. package/src/commands/builds/list.ts +38 -0
  34. package/src/commands/channels/create.ts +37 -0
  35. package/src/commands/channels/delete.ts +15 -0
  36. package/src/commands/channels/helpers.ts +18 -0
  37. package/src/commands/channels/index.ts +24 -0
  38. package/src/commands/channels/list.ts +38 -0
  39. package/src/commands/channels/pause.ts +15 -0
  40. package/src/commands/channels/resume.ts +15 -0
  41. package/src/commands/channels/rollout/complete.ts +17 -0
  42. package/src/commands/channels/rollout/create.ts +36 -0
  43. package/src/commands/channels/rollout/index.ts +11 -0
  44. package/src/commands/channels/rollout/revert.ts +17 -0
  45. package/src/commands/channels/rollout/update.ts +23 -0
  46. package/src/commands/channels/update.ts +32 -0
  47. package/src/commands/credentials/delete.ts +24 -0
  48. package/src/commands/credentials/index.ts +10 -0
  49. package/src/commands/credentials/list.ts +33 -0
  50. package/src/commands/credentials/upload.ts +91 -0
  51. package/src/commands/env/delete.ts +35 -0
  52. package/src/commands/env/export.ts +27 -0
  53. package/src/commands/env/get.ts +25 -0
  54. package/src/commands/env/helpers.ts +13 -0
  55. package/src/commands/env/import.ts +31 -0
  56. package/src/commands/env/index.ts +24 -0
  57. package/src/commands/env/list.ts +44 -0
  58. package/src/commands/env/pull.ts +27 -0
  59. package/src/commands/env/set.ts +42 -0
  60. package/src/commands/fingerprint/compare.ts +25 -0
  61. package/src/commands/fingerprint/generate.ts +18 -0
  62. package/src/commands/fingerprint/index.ts +9 -0
  63. package/src/commands/init.ts +35 -0
  64. package/src/commands/login.ts +13 -0
  65. package/src/commands/logout.ts +12 -0
  66. package/src/commands/projects.ts +84 -0
  67. package/src/commands/status.ts +48 -0
  68. package/src/commands/update/delete.ts +15 -0
  69. package/src/commands/update/helpers.ts +22 -0
  70. package/src/commands/update/index.ts +22 -0
  71. package/src/commands/update/list.ts +60 -0
  72. package/src/commands/update/promote.ts +30 -0
  73. package/src/commands/update/publish.ts +94 -0
  74. package/src/commands/update/rollback.ts +42 -0
  75. package/src/commands/update/rollout/complete.ts +17 -0
  76. package/src/commands/update/rollout/index.ts +10 -0
  77. package/src/commands/update/rollout/revert.ts +17 -0
  78. package/src/commands/update/rollout/set.ts +23 -0
  79. package/src/index.ts +53 -0
  80. package/src/lib/android-keystore.test.ts +114 -0
  81. package/src/lib/android-keystore.ts +76 -0
  82. package/src/lib/android-signing-gradle.test.ts +95 -0
  83. package/src/lib/android-signing-gradle.ts +52 -0
  84. package/src/lib/app-json.ts +81 -0
  85. package/src/lib/apple-auth.test.ts +402 -0
  86. package/src/lib/apple-auth.ts +132 -0
  87. package/src/lib/artifact-finder.test.ts +195 -0
  88. package/src/lib/artifact-finder.ts +122 -0
  89. package/src/lib/browser-login.test.ts +88 -0
  90. package/src/lib/browser-login.ts +193 -0
  91. package/src/lib/build-profile.test.ts +290 -0
  92. package/src/lib/build-profile.ts +234 -0
  93. package/src/lib/cli-schemas.ts +39 -0
  94. package/src/lib/command-errors.ts +60 -0
  95. package/src/lib/credentials-downloader.ts +181 -0
  96. package/src/lib/credentials-manager.ts +354 -0
  97. package/src/lib/env-exporter.test.ts +96 -0
  98. package/src/lib/env-exporter.ts +28 -0
  99. package/src/lib/exit-codes.ts +82 -0
  100. package/src/lib/expo-config.ts +130 -0
  101. package/src/lib/expo-export.test.ts +94 -0
  102. package/src/lib/expo-export.ts +281 -0
  103. package/src/lib/fingerprint.ts +67 -0
  104. package/src/lib/format-error.ts +22 -0
  105. package/src/lib/git-context.ts +56 -0
  106. package/src/lib/gradle-config.ts +126 -0
  107. package/src/lib/ios-export-options.test.ts +98 -0
  108. package/src/lib/ios-export-options.ts +62 -0
  109. package/src/lib/ios-keychain.ts +181 -0
  110. package/src/lib/ios-provisioning.test.ts +115 -0
  111. package/src/lib/ios-provisioning.ts +179 -0
  112. package/src/lib/output.ts +32 -0
  113. package/src/lib/pkcs12.ts +73 -0
  114. package/src/lib/plist.ts +39 -0
  115. package/src/lib/post-build-validation.ts +146 -0
  116. package/src/lib/presigned-upload.test.ts +140 -0
  117. package/src/lib/presigned-upload.ts +35 -0
  118. package/src/lib/record.ts +5 -0
  119. package/src/lib/resolve-named-resource.ts +24 -0
  120. package/src/lib/runtime-version.test.ts +119 -0
  121. package/src/lib/runtime-version.ts +62 -0
  122. package/src/lib/sha256.test.ts +108 -0
  123. package/src/lib/sha256.ts +80 -0
  124. package/src/lib/signed-payloads.test.ts +181 -0
  125. package/src/lib/signed-payloads.ts +164 -0
  126. package/src/lib/string-utils.ts +4 -0
  127. package/src/lib/temp-dir.ts +14 -0
  128. package/src/lib/test-utils.ts +13 -0
  129. package/src/lib/update-platforms.test.ts +45 -0
  130. package/src/lib/update-platforms.ts +19 -0
  131. package/src/lib/xcpretty-formatter.ts +21 -0
  132. package/src/services/api-client.ts +42 -0
  133. package/src/services/apple-session-store.ts +100 -0
  134. package/src/services/auth-store.ts +85 -0
  135. package/src/services/cli-runtime.ts +46 -0
  136. package/src/services/config-store.ts +108 -0
  137. package/src/services/presigned-upload.ts +84 -0
  138. package/src/services/update-asset-uploader.ts +72 -0
  139. package/src/types/keychain.d.ts +22 -0
  140. package/tests/e2e/build.test.ts +270 -0
  141. package/tests/e2e/commands.test.ts +694 -0
  142. package/tests/e2e/ota-lifecycle.test.ts +275 -0
  143. package/tests/e2e/publish.test.ts +150 -0
  144. package/tests/helpers/cli-e2e.ts +426 -0
  145. package/tests/helpers/pty-driver.ts +142 -0
  146. package/tests/interactive/harness/provider-prompt.ts +54 -0
  147. package/tests/interactive/login.test.ts +47 -0
  148. package/tests/interactive/provider-select.test.ts +59 -0
  149. package/tsconfig.json +7 -0
  150. package/vitest.config.ts +38 -0
@@ -0,0 +1,40 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Console, Effect, Option } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printTable } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleAnalyticsCommandErrors } from "./helpers";
8
+
9
+ const period = Options.choice("period", ["1d", "7d", "30d", "90d"]).pipe(Options.optional);
10
+
11
+ export const adoptionCommand = Command.make("adoption", { period }, (opts) =>
12
+ Effect.gen(function* () {
13
+ const projectId = yield* readProjectId;
14
+ const api = yield* apiClient;
15
+
16
+ const periodFilter = Option.match(opts.period, {
17
+ onNone: () => ({}) as Record<string, string>,
18
+ onSome: (periodValue) => ({ period: periodValue }) as Record<string, string>,
19
+ });
20
+
21
+ const result = yield* api.analytics.adoption({
22
+ urlParams: { projectId, ...periodFilter },
23
+ });
24
+
25
+ if (result.updates.length === 0) {
26
+ yield* Console.log("No adoption data found.");
27
+ return;
28
+ }
29
+
30
+ yield* printTable(
31
+ ["Update ID", "Devices", "First Seen", "Last Seen"],
32
+ result.updates.map((update) => [
33
+ update.updateId,
34
+ String(update.devices),
35
+ update.firstSeen,
36
+ update.lastSeen,
37
+ ]),
38
+ );
39
+ }).pipe(handleAnalyticsCommandErrors),
40
+ );
@@ -0,0 +1,35 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Effect, Option } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printKeyValue } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleAnalyticsCommandErrors } from "./helpers";
8
+
9
+ const channel = Options.text("channel");
10
+ const period = Options.choice("period", ["1d", "7d", "30d", "90d"]).pipe(Options.optional);
11
+
12
+ export const channelsCommand = Command.make("channels", { channel, period }, (opts) =>
13
+ Effect.gen(function* () {
14
+ const projectId = yield* readProjectId;
15
+ const api = yield* apiClient;
16
+
17
+ const periodFilter = Option.match(opts.period, {
18
+ onNone: () => ({}) as Record<string, string>,
19
+ onSome: (periodValue) => ({ period: periodValue }) as Record<string, string>,
20
+ });
21
+
22
+ const result = yield* api.analytics.channels({
23
+ urlParams: { projectId, channel: opts.channel, ...periodFilter },
24
+ });
25
+
26
+ yield* printKeyValue([
27
+ ["Channel", result.channel],
28
+ ["Total Requests", String(result.totalRequests)],
29
+ ["Unique Devices", String(result.uniqueDevices)],
30
+ ["Manifest", String(result.responseTypeDistribution.manifest)],
31
+ ["Directive", String(result.responseTypeDistribution.directive)],
32
+ ["No Update", String(result.responseTypeDistribution.no_update)],
33
+ ]);
34
+ }).pipe(handleAnalyticsCommandErrors),
35
+ );
@@ -0,0 +1,3 @@
1
+ import { makeCommandErrorHandler } from "../../lib/command-errors";
2
+
3
+ export const handleAnalyticsCommandErrors = makeCommandErrorHandler();
@@ -0,0 +1,13 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console } from "effect";
3
+
4
+ import { adoptionCommand } from "./adoption";
5
+ import { channelsCommand } from "./channels";
6
+ import { platformsCommand } from "./platforms";
7
+ import { updatesCommand } from "./updates";
8
+
9
+ export const analyticsCommand = Command.make("analytics", {}, () =>
10
+ Console.log("View deployment analytics. Run with --help for subcommands."),
11
+ ).pipe(
12
+ Command.withSubcommands([adoptionCommand, updatesCommand, channelsCommand, platformsCommand]),
13
+ );
@@ -0,0 +1,39 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Console, Effect, Option } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printTable } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleAnalyticsCommandErrors } from "./helpers";
8
+
9
+ const period = Options.choice("period", ["1d", "7d", "30d", "90d"]).pipe(Options.optional);
10
+
11
+ export const platformsCommand = Command.make("platforms", { period }, (opts) =>
12
+ Effect.gen(function* () {
13
+ const projectId = yield* readProjectId;
14
+ const api = yield* apiClient;
15
+
16
+ const periodFilter = Option.match(opts.period, {
17
+ onNone: () => ({}) as Record<string, string>,
18
+ onSome: (periodValue) => ({ period: periodValue }) as Record<string, string>,
19
+ });
20
+
21
+ const result = yield* api.analytics.platforms({
22
+ urlParams: { projectId, ...periodFilter },
23
+ });
24
+
25
+ if (result.platforms.length === 0) {
26
+ yield* Console.log("No platform data found.");
27
+ return;
28
+ }
29
+
30
+ yield* printTable(
31
+ ["Platform", "Requests", "Devices"],
32
+ result.platforms.map((platform) => [
33
+ platform.platform,
34
+ String(platform.requests),
35
+ String(platform.devices),
36
+ ]),
37
+ );
38
+ }).pipe(handleAnalyticsCommandErrors),
39
+ );
@@ -0,0 +1,35 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Effect, Option } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printKeyValue } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleAnalyticsCommandErrors } from "./helpers";
8
+
9
+ const updateId = Options.text("update-id");
10
+ const period = Options.choice("period", ["1d", "7d", "30d", "90d"]).pipe(Options.optional);
11
+
12
+ export const updatesCommand = Command.make("updates", { updateId, period }, (opts) =>
13
+ Effect.gen(function* () {
14
+ const projectId = yield* readProjectId;
15
+ const api = yield* apiClient;
16
+
17
+ const periodFilter = Option.match(opts.period, {
18
+ onNone: () => ({}) as Record<string, string>,
19
+ onSome: (periodValue) => ({ period: periodValue }) as Record<string, string>,
20
+ });
21
+
22
+ const result = yield* api.analytics.updates({
23
+ urlParams: { projectId, updateId: opts.updateId, ...periodFilter },
24
+ });
25
+
26
+ yield* printKeyValue([
27
+ ["Update ID", result.updateId],
28
+ ["Total Requests", String(result.totalRequests)],
29
+ ["Unique Devices", String(result.uniqueDevices)],
30
+ ["Manifest", String(result.byResponseType.manifest)],
31
+ ["Directive", String(result.byResponseType.directive)],
32
+ ["No Update", String(result.byResponseType.no_update)],
33
+ ]);
34
+ }).pipe(handleAnalyticsCommandErrors),
35
+ );
@@ -0,0 +1,3 @@
1
+ import { makeCommandErrorHandler } from "../../lib/command-errors";
2
+
3
+ export const handleAuditLogCommandErrors = makeCommandErrorHandler();
@@ -0,0 +1,8 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console } from "effect";
3
+
4
+ import { listCommand } from "./list";
5
+
6
+ export const auditLogsCommand = Command.make("audit-logs", {}, () =>
7
+ Console.log("View audit logs. Run with --help for subcommands."),
8
+ ).pipe(Command.withSubcommands([listCommand]));
@@ -0,0 +1,66 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Console, Effect, Option } from "effect";
3
+
4
+ import { printTable } from "../../lib/output";
5
+ import { apiClient } from "../../services/api-client";
6
+ import { handleAuditLogCommandErrors } from "./helpers";
7
+
8
+ const action = Options.text("action").pipe(Options.optional);
9
+ const resourceType = Options.text("resource-type").pipe(Options.optional);
10
+ const actorId = Options.text("actor-id").pipe(Options.optional);
11
+ const from = Options.text("from").pipe(Options.optional);
12
+ const to = Options.text("to").pipe(Options.optional);
13
+
14
+ export const listCommand = Command.make(
15
+ "list",
16
+ { action, resourceType, actorId, from, to },
17
+ (opts) =>
18
+ Effect.gen(function* () {
19
+ const api = yield* apiClient;
20
+
21
+ const filters = {
22
+ ...Option.match(opts.action, {
23
+ onNone: () => ({}),
24
+ onSome: (value) => ({ action: value }),
25
+ }),
26
+ ...Option.match(opts.resourceType, {
27
+ onNone: () => ({}),
28
+ onSome: (value) => ({ resourceType: value }),
29
+ }),
30
+ ...Option.match(opts.actorId, {
31
+ onNone: () => ({}),
32
+ onSome: (value) => ({ actorId: value }),
33
+ }),
34
+ ...Option.match(opts.from, {
35
+ onNone: () => ({}),
36
+ onSome: (value) => ({ from: value }),
37
+ }),
38
+ ...Option.match(opts.to, {
39
+ onNone: () => ({}),
40
+ onSome: (value) => ({ to: value }),
41
+ }),
42
+ } as Record<string, string>;
43
+
44
+ const { items } = yield* api["audit-logs"].list({
45
+ urlParams: { ...filters, page: 1, limit: 100 },
46
+ });
47
+
48
+ if (items.length === 0) {
49
+ yield* Console.log("No audit log entries found.");
50
+ return;
51
+ }
52
+
53
+ yield* printTable(
54
+ ["ID", "Action", "Resource Type", "Resource ID", "Actor", "Source", "Created"],
55
+ items.map((log) => [
56
+ log.id,
57
+ log.action,
58
+ log.resourceType,
59
+ log.resourceId ?? "-",
60
+ log.actorEmail,
61
+ log.source,
62
+ log.createdAt,
63
+ ]),
64
+ );
65
+ }).pipe(handleAuditLogCommandErrors),
66
+ );
@@ -0,0 +1,70 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { readProjectId } from "../lib/app-json";
5
+ import { makeCommandErrorHandler } from "../lib/command-errors";
6
+ import { printKeyValue, printTable } from "../lib/output";
7
+ import { apiClient } from "../services/api-client";
8
+
9
+ const handleErrors = makeCommandErrorHandler();
10
+
11
+ const idArg = Args.text({ name: "id" });
12
+ const nameOption = Options.text("name");
13
+
14
+ const listCommand = Command.make("list", {}, () =>
15
+ Effect.gen(function* () {
16
+ const projectId = yield* readProjectId;
17
+ const api = yield* apiClient;
18
+ const { items } = yield* api.branches.list({
19
+ urlParams: { projectId, page: 1, limit: 1000 },
20
+ });
21
+
22
+ if (items.length === 0) {
23
+ yield* Console.log("No branches found.");
24
+ return;
25
+ }
26
+
27
+ yield* printTable(
28
+ ["ID", "Name", "Created"],
29
+ items.map((branch) => [branch.id, branch.name, branch.createdAt]),
30
+ );
31
+ }).pipe(handleErrors),
32
+ );
33
+
34
+ const createCommand = Command.make("create", { name: nameOption }, (opts) =>
35
+ Effect.gen(function* () {
36
+ const projectId = yield* readProjectId;
37
+ const api = yield* apiClient;
38
+ const branch = yield* api.branches.create({
39
+ payload: { projectId, name: opts.name },
40
+ });
41
+ yield* printKeyValue([
42
+ ["ID", branch.id],
43
+ ["Name", branch.name],
44
+ ["Created", branch.createdAt],
45
+ ]);
46
+ }).pipe(handleErrors),
47
+ );
48
+
49
+ const renameCommand = Command.make("rename", { id: idArg, name: nameOption }, (opts) =>
50
+ Effect.gen(function* () {
51
+ const api = yield* apiClient;
52
+ const branch = yield* api.branches.rename({
53
+ path: { id: opts.id },
54
+ payload: { name: opts.name },
55
+ });
56
+ yield* Console.log(`Branch renamed to "${branch.name}".`);
57
+ }).pipe(handleErrors),
58
+ );
59
+
60
+ const deleteCommand = Command.make("delete", { id: idArg }, (opts) =>
61
+ Effect.gen(function* () {
62
+ const api = yield* apiClient;
63
+ yield* api.branches.delete({ path: { id: opts.id } });
64
+ yield* Console.log(`Branch ${opts.id} deleted.`);
65
+ }).pipe(handleErrors),
66
+ );
67
+
68
+ export const branchesCommand = Command.make("branches", {}, () =>
69
+ Console.log("Manage branches. Run with --help for subcommands."),
70
+ ).pipe(Command.withSubcommands([listCommand, createCommand, renameCommand, deleteCommand]));
@@ -0,0 +1,129 @@
1
+ import path from "node:path";
2
+
3
+ import { Command, FileSystem } from "@effect/platform";
4
+ import { Effect } from "effect";
5
+
6
+ import type { CommandExecutor } from "@effect/platform";
7
+ import type { PlatformError } from "@effect/platform/Error";
8
+
9
+ import { renderSigningGradle } from "../../lib/android-signing-gradle";
10
+ import { findAndroidArtifact } from "../../lib/artifact-finder";
11
+ import { downloadAndroidCredentials } from "../../lib/credentials-downloader";
12
+ import { sha256File } from "../../lib/sha256";
13
+ import { capitalize } from "../../lib/string-utils";
14
+ import { CliRuntime } from "../../services/cli-runtime";
15
+ import { runStep } from "./run-step";
16
+
17
+ import type { AndroidProfile } from "../../lib/build-profile";
18
+ import type {
19
+ ArtifactNotFoundError,
20
+ BuildFailedError,
21
+ MissingCredentialsError,
22
+ } from "../../lib/exit-codes";
23
+ import type { ApiClient } from "../../services/api-client";
24
+
25
+ export interface RunAndroidBuildInput {
26
+ readonly api: ApiClient;
27
+ readonly tempDir: string;
28
+ readonly projectRoot: string;
29
+ readonly androidProfile: AndroidProfile;
30
+ readonly applicationIdentifier: string;
31
+ readonly envVars: Record<string, string>;
32
+ readonly projectId: string;
33
+ }
34
+
35
+ export interface RunAndroidBuildResult {
36
+ readonly artifactPath: string;
37
+ readonly byteSize: number;
38
+ readonly sha256: string;
39
+ }
40
+
41
+ /**
42
+ * Compose the Gradle task name from flavor, format, and buildType.
43
+ *
44
+ * Gradle naming convention: `<verb><Flavor><Variant>`, e.g.
45
+ * - no flavor + apk + release → `assembleRelease`
46
+ * - no flavor + aab + release → `bundleRelease`
47
+ * - flavor=prod + aab + release → `bundleProdRelease`
48
+ * - flavor=prod + apk + debug → `assembleProdDebug`
49
+ */
50
+ const gradleTaskName = (
51
+ format: "apk" | "aab",
52
+ flavor: string | undefined,
53
+ buildType: "debug" | "release",
54
+ ): string => {
55
+ const verb = format === "aab" ? "bundle" : "assemble";
56
+ return flavor
57
+ ? `${verb}${capitalize(flavor)}${capitalize(buildType)}`
58
+ : `${verb}${capitalize(buildType)}`;
59
+ };
60
+
61
+ export const runAndroidBuild = (
62
+ input: RunAndroidBuildInput,
63
+ ): Effect.Effect<
64
+ RunAndroidBuildResult,
65
+ BuildFailedError | MissingCredentialsError | ArtifactNotFoundError | PlatformError,
66
+ CliRuntime | CommandExecutor.CommandExecutor | FileSystem.FileSystem
67
+ > =>
68
+ Effect.gen(function* () {
69
+ const { api, tempDir, projectRoot, androidProfile, applicationIdentifier, envVars, projectId } =
70
+ input;
71
+ const runtime = yield* CliRuntime;
72
+
73
+ // Record build start so artifact-finder can reject stale outputs from
74
+ // Earlier builds that may still live in `android/app/build/outputs/`.
75
+ const buildStartMs = Date.now();
76
+
77
+ const { format } = androidProfile;
78
+ const { flavor } = androidProfile;
79
+ const buildType = androidProfile.buildType ?? "release";
80
+ const androidDir = path.join(projectRoot, "android");
81
+ const commandEnv = yield* runtime.commandEnvironment(envVars);
82
+
83
+ const credentials = yield* downloadAndroidCredentials(api, {
84
+ projectId,
85
+ applicationIdentifier,
86
+ tempDir,
87
+ });
88
+
89
+ yield* runStep(
90
+ Command.make("bunx", "expo", "prebuild", "--platform", "android", "--clean").pipe(
91
+ Command.workingDirectory(projectRoot),
92
+ Command.env(commandEnv),
93
+ ),
94
+ "expo prebuild android",
95
+ );
96
+
97
+ const fs = yield* FileSystem.FileSystem;
98
+ const signingGradlePath = path.join(tempDir, "signing.gradle");
99
+ yield* fs.writeFileString(
100
+ signingGradlePath,
101
+ renderSigningGradle({
102
+ keystorePath: credentials.keystorePath,
103
+ storePassword: credentials.storePassword,
104
+ keyAlias: credentials.keyAlias,
105
+ keyPassword: credentials.keyPassword,
106
+ }),
107
+ );
108
+
109
+ const taskName = gradleTaskName(format, flavor, buildType);
110
+ yield* runStep(
111
+ Command.make("./gradlew", "--init-script", signingGradlePath, `:app:${taskName}`).pipe(
112
+ Command.workingDirectory(androidDir),
113
+ Command.env(commandEnv),
114
+ ),
115
+ "gradlew",
116
+ );
117
+
118
+ const artifactPath = yield* findAndroidArtifact({
119
+ projectRoot,
120
+ format,
121
+ ...(flavor === undefined ? {} : { flavor }),
122
+ buildType,
123
+ minMtimeMs: buildStartMs,
124
+ });
125
+
126
+ const { sha256, byteSize } = yield* sha256File(artifactPath);
127
+
128
+ return { artifactPath, byteSize, sha256 };
129
+ });
@@ -0,0 +1,63 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Effect, Option } from "effect";
3
+
4
+ import type { BadArgument, SystemError } from "@effect/platform/Error";
5
+
6
+ import { runBuildWorkflow } from "../../application/build-workflow";
7
+ import { exitWith } from "../../application/command-exit";
8
+
9
+ import type {
10
+ ArtifactNotFoundError,
11
+ AuthRequiredError,
12
+ BuildFailedError,
13
+ BuildProfileError,
14
+ CompleteError,
15
+ EnvExportError,
16
+ KeychainError,
17
+ MissingCredentialsError,
18
+ PresignedUrlExpiredError,
19
+ ProjectNotLinkedError,
20
+ ProvisioningError,
21
+ ReserveError,
22
+ RuntimeVersionError,
23
+ UploadFailedError,
24
+ } from "../../lib/exit-codes";
25
+
26
+ const platform = Options.choice("platform", ["ios", "android"] as const);
27
+ const profile = Options.text("profile").pipe(Options.withDefault("production"));
28
+ const message = Options.text("message").pipe(Options.optional);
29
+ const noUpload = Options.boolean("no-upload");
30
+ const rawOutput = Options.boolean("raw-output");
31
+
32
+ export const buildCommand = Command.make(
33
+ "build",
34
+ { platform, profile, message, noUpload, rawOutput },
35
+ (opts) =>
36
+ runBuildWorkflow({
37
+ platform: opts.platform,
38
+ profileName: opts.profile,
39
+ message: Option.getOrUndefined(opts.message),
40
+ noUpload: opts.noUpload,
41
+ rawOutput: opts.rawOutput,
42
+ }).pipe(
43
+ Effect.catchTags({
44
+ AuthRequiredError: (err: AuthRequiredError) => exitWith(3, err.message),
45
+ ProjectNotLinkedError: (err: ProjectNotLinkedError) => exitWith(4, err.message),
46
+ BuildProfileError: (err: BuildProfileError) => exitWith(2, err.message),
47
+ RuntimeVersionError: (err: RuntimeVersionError) => exitWith(2, err.message),
48
+ MissingCredentialsError: (err: MissingCredentialsError) =>
49
+ exitWith(5, `${err.message}\n${err.hint}`),
50
+ BuildFailedError: (err: BuildFailedError) => exitWith(6, err.message),
51
+ KeychainError: (err: KeychainError) => exitWith(6, err.message),
52
+ ProvisioningError: (err: ProvisioningError) => exitWith(6, err.message),
53
+ ArtifactNotFoundError: (err: ArtifactNotFoundError) => exitWith(6, err.message),
54
+ ReserveError: (err: ReserveError) => exitWith(7, err.message),
55
+ UploadFailedError: (err: UploadFailedError) => exitWith(7, err.message),
56
+ PresignedUrlExpiredError: (err: PresignedUrlExpiredError) => exitWith(7, err.message),
57
+ CompleteError: (err: CompleteError) => exitWith(7, err.message),
58
+ EnvExportError: (err: EnvExportError) => exitWith(7, err.message),
59
+ SystemError: (err: SystemError) => exitWith(6, `Filesystem error: ${err.message}`),
60
+ BadArgument: (err: BadArgument) => exitWith(6, `Invalid argument: ${err.message}`),
61
+ }),
62
+ ),
63
+ );