@better-update/cli 0.3.0 → 0.3.2

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 (152) hide show
  1. package/dist/index.js +5319 -0
  2. package/dist/index.js.map +1 -0
  3. package/package.json +12 -9
  4. package/CHANGELOG.md +0 -58
  5. package/oxlint.config.ts +0 -6
  6. package/src/app-layer.ts +0 -29
  7. package/src/application/build-workflow.ts +0 -222
  8. package/src/application/command-exit.ts +0 -13
  9. package/src/application/login.ts +0 -87
  10. package/src/application/update-promote.ts +0 -88
  11. package/src/application/update-publish.ts +0 -402
  12. package/src/application/update-rollback.ts +0 -275
  13. package/src/commands/analytics/adoption.ts +0 -40
  14. package/src/commands/analytics/channels.ts +0 -35
  15. package/src/commands/analytics/helpers.ts +0 -3
  16. package/src/commands/analytics/index.ts +0 -13
  17. package/src/commands/analytics/platforms.ts +0 -39
  18. package/src/commands/analytics/updates.ts +0 -35
  19. package/src/commands/audit-logs/helpers.ts +0 -3
  20. package/src/commands/audit-logs/index.ts +0 -8
  21. package/src/commands/audit-logs/list.ts +0 -66
  22. package/src/commands/branches.ts +0 -70
  23. package/src/commands/build/android.ts +0 -129
  24. package/src/commands/build/index.ts +0 -63
  25. package/src/commands/build/ios.ts +0 -199
  26. package/src/commands/build/reserve-and-upload.test.ts +0 -263
  27. package/src/commands/build/reserve-and-upload.ts +0 -160
  28. package/src/commands/build/run-step.ts +0 -131
  29. package/src/commands/builds/compatibility-matrix.ts +0 -48
  30. package/src/commands/builds/delete.ts +0 -15
  31. package/src/commands/builds/get.ts +0 -34
  32. package/src/commands/builds/helpers.ts +0 -3
  33. package/src/commands/builds/index.ts +0 -20
  34. package/src/commands/builds/install-link.ts +0 -20
  35. package/src/commands/builds/list.ts +0 -38
  36. package/src/commands/channels/create.ts +0 -37
  37. package/src/commands/channels/delete.ts +0 -15
  38. package/src/commands/channels/helpers.ts +0 -18
  39. package/src/commands/channels/index.ts +0 -24
  40. package/src/commands/channels/list.ts +0 -38
  41. package/src/commands/channels/pause.ts +0 -15
  42. package/src/commands/channels/resume.ts +0 -15
  43. package/src/commands/channels/rollout/complete.ts +0 -17
  44. package/src/commands/channels/rollout/create.ts +0 -36
  45. package/src/commands/channels/rollout/index.ts +0 -11
  46. package/src/commands/channels/rollout/revert.ts +0 -17
  47. package/src/commands/channels/rollout/update.ts +0 -23
  48. package/src/commands/channels/update.ts +0 -32
  49. package/src/commands/credentials/delete.ts +0 -24
  50. package/src/commands/credentials/index.ts +0 -10
  51. package/src/commands/credentials/list.ts +0 -33
  52. package/src/commands/credentials/upload.ts +0 -91
  53. package/src/commands/env/delete.ts +0 -35
  54. package/src/commands/env/export.ts +0 -27
  55. package/src/commands/env/get.ts +0 -25
  56. package/src/commands/env/helpers.ts +0 -13
  57. package/src/commands/env/import.ts +0 -31
  58. package/src/commands/env/index.ts +0 -24
  59. package/src/commands/env/list.ts +0 -44
  60. package/src/commands/env/pull.ts +0 -27
  61. package/src/commands/env/set.ts +0 -42
  62. package/src/commands/fingerprint/compare.ts +0 -25
  63. package/src/commands/fingerprint/generate.ts +0 -18
  64. package/src/commands/fingerprint/index.ts +0 -9
  65. package/src/commands/init.ts +0 -35
  66. package/src/commands/login.ts +0 -13
  67. package/src/commands/logout.ts +0 -12
  68. package/src/commands/projects.ts +0 -84
  69. package/src/commands/status.ts +0 -48
  70. package/src/commands/update/delete.ts +0 -15
  71. package/src/commands/update/helpers.ts +0 -22
  72. package/src/commands/update/index.ts +0 -22
  73. package/src/commands/update/list.ts +0 -60
  74. package/src/commands/update/promote.ts +0 -30
  75. package/src/commands/update/publish.ts +0 -94
  76. package/src/commands/update/rollback.ts +0 -42
  77. package/src/commands/update/rollout/complete.ts +0 -17
  78. package/src/commands/update/rollout/index.ts +0 -10
  79. package/src/commands/update/rollout/revert.ts +0 -17
  80. package/src/commands/update/rollout/set.ts +0 -23
  81. package/src/index.ts +0 -53
  82. package/src/lib/android-keystore.test.ts +0 -114
  83. package/src/lib/android-keystore.ts +0 -76
  84. package/src/lib/android-signing-gradle.test.ts +0 -95
  85. package/src/lib/android-signing-gradle.ts +0 -52
  86. package/src/lib/app-json.ts +0 -81
  87. package/src/lib/apple-auth.test.ts +0 -402
  88. package/src/lib/apple-auth.ts +0 -132
  89. package/src/lib/artifact-finder.test.ts +0 -195
  90. package/src/lib/artifact-finder.ts +0 -122
  91. package/src/lib/browser-login.test.ts +0 -88
  92. package/src/lib/browser-login.ts +0 -193
  93. package/src/lib/build-profile.test.ts +0 -290
  94. package/src/lib/build-profile.ts +0 -234
  95. package/src/lib/cli-schemas.ts +0 -39
  96. package/src/lib/command-errors.ts +0 -60
  97. package/src/lib/credentials-downloader.ts +0 -181
  98. package/src/lib/credentials-manager.ts +0 -354
  99. package/src/lib/env-exporter.test.ts +0 -96
  100. package/src/lib/env-exporter.ts +0 -28
  101. package/src/lib/exit-codes.ts +0 -82
  102. package/src/lib/expo-config.ts +0 -130
  103. package/src/lib/expo-export.test.ts +0 -94
  104. package/src/lib/expo-export.ts +0 -281
  105. package/src/lib/fingerprint.ts +0 -67
  106. package/src/lib/format-error.ts +0 -22
  107. package/src/lib/git-context.ts +0 -56
  108. package/src/lib/gradle-config.ts +0 -126
  109. package/src/lib/ios-export-options.test.ts +0 -98
  110. package/src/lib/ios-export-options.ts +0 -62
  111. package/src/lib/ios-keychain.ts +0 -181
  112. package/src/lib/ios-provisioning.test.ts +0 -115
  113. package/src/lib/ios-provisioning.ts +0 -179
  114. package/src/lib/output.ts +0 -32
  115. package/src/lib/pkcs12.ts +0 -73
  116. package/src/lib/plist.ts +0 -39
  117. package/src/lib/post-build-validation.ts +0 -146
  118. package/src/lib/presigned-upload.test.ts +0 -140
  119. package/src/lib/presigned-upload.ts +0 -35
  120. package/src/lib/record.ts +0 -5
  121. package/src/lib/resolve-named-resource.ts +0 -24
  122. package/src/lib/runtime-version.test.ts +0 -119
  123. package/src/lib/runtime-version.ts +0 -62
  124. package/src/lib/sha256.test.ts +0 -108
  125. package/src/lib/sha256.ts +0 -80
  126. package/src/lib/signed-payloads.test.ts +0 -181
  127. package/src/lib/signed-payloads.ts +0 -164
  128. package/src/lib/string-utils.ts +0 -4
  129. package/src/lib/temp-dir.ts +0 -14
  130. package/src/lib/test-utils.ts +0 -13
  131. package/src/lib/update-platforms.test.ts +0 -45
  132. package/src/lib/update-platforms.ts +0 -19
  133. package/src/lib/xcpretty-formatter.ts +0 -21
  134. package/src/services/api-client.ts +0 -42
  135. package/src/services/apple-session-store.ts +0 -100
  136. package/src/services/auth-store.ts +0 -85
  137. package/src/services/cli-runtime.ts +0 -46
  138. package/src/services/config-store.ts +0 -108
  139. package/src/services/presigned-upload.ts +0 -84
  140. package/src/services/update-asset-uploader.ts +0 -72
  141. package/src/types/keychain.d.ts +0 -22
  142. package/tests/e2e/build.test.ts +0 -270
  143. package/tests/e2e/commands.test.ts +0 -694
  144. package/tests/e2e/ota-lifecycle.test.ts +0 -275
  145. package/tests/e2e/publish.test.ts +0 -150
  146. package/tests/helpers/cli-e2e.ts +0 -426
  147. package/tests/helpers/pty-driver.ts +0 -142
  148. package/tests/interactive/harness/provider-prompt.ts +0 -54
  149. package/tests/interactive/login.test.ts +0 -47
  150. package/tests/interactive/provider-select.test.ts +0 -59
  151. package/tsconfig.json +0 -7
  152. package/vitest.config.ts +0 -38
@@ -1,199 +0,0 @@
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
- import type { Scope } from "effect";
9
-
10
- import { findIosArtifact } from "../../lib/artifact-finder";
11
- import { downloadIosCredentials } from "../../lib/credentials-downloader";
12
- import { BuildFailedError } from "../../lib/exit-codes";
13
- import { renderExportOptionsPlist } from "../../lib/ios-export-options";
14
- import { acquireKeychain } from "../../lib/ios-keychain";
15
- import { installProvisioningProfile } from "../../lib/ios-provisioning";
16
- import { validateIosBuild } from "../../lib/post-build-validation";
17
- import { sha256File } from "../../lib/sha256";
18
- import { createXcodebuildFormatter } from "../../lib/xcpretty-formatter";
19
- import { CliRuntime } from "../../services/cli-runtime";
20
- import { runStep, runStepFormatted } from "./run-step";
21
-
22
- import type { IosProfile } from "../../lib/build-profile";
23
- import type {
24
- ArtifactNotFoundError,
25
- KeychainError,
26
- MissingCredentialsError,
27
- ProvisioningError,
28
- } from "../../lib/exit-codes";
29
- import type { ApiClient } from "../../services/api-client";
30
-
31
- export interface RunIosBuildInput {
32
- readonly api: ApiClient;
33
- readonly tempDir: string;
34
- readonly projectRoot: string;
35
- readonly iosProfile: IosProfile;
36
- readonly bundleId: string;
37
- readonly envVars: Record<string, string>;
38
- readonly projectId: string;
39
- readonly rawOutput?: boolean | undefined;
40
- }
41
-
42
- export interface RunIosBuildResult {
43
- readonly artifactPath: string;
44
- readonly byteSize: number;
45
- readonly sha256: string;
46
- }
47
-
48
- const findXcworkspace = (
49
- iosDir: string,
50
- ): Effect.Effect<string, BuildFailedError | PlatformError, FileSystem.FileSystem> =>
51
- Effect.gen(function* () {
52
- const fs = yield* FileSystem.FileSystem;
53
- const entries = yield* fs.readDirectory(iosDir);
54
- const workspace = entries.find((entry) => entry.endsWith(".xcworkspace"));
55
- if (!workspace) {
56
- return yield* new BuildFailedError({
57
- step: "detect xcworkspace",
58
- exitCode: 1,
59
- message: `No .xcworkspace found under ${iosDir}. Did "pod install" run?`,
60
- });
61
- }
62
- return workspace;
63
- });
64
-
65
- export const runIosBuild = (
66
- input: RunIosBuildInput,
67
- ): Effect.Effect<
68
- RunIosBuildResult,
69
- | BuildFailedError
70
- | MissingCredentialsError
71
- | KeychainError
72
- | ProvisioningError
73
- | ArtifactNotFoundError
74
- | PlatformError,
75
- CliRuntime | CommandExecutor.CommandExecutor | FileSystem.FileSystem | Scope.Scope
76
- > =>
77
- // eslint-disable-next-line eslint/max-statements -- ios build orchestration is inherently sequential (prebuild → pod → credentials → archive → exportArchive → find artifact → sha256); splitting further fragments the pipeline without clarifying it
78
- Effect.gen(function* () {
79
- const { api, tempDir, projectRoot, iosProfile, bundleId, envVars, projectId } = input;
80
- const runtime = yield* CliRuntime;
81
-
82
- const iosDir = path.join(projectRoot, "ios");
83
- const { distribution } = iosProfile;
84
- const commandEnv = yield* runtime.commandEnvironment(envVars);
85
-
86
- // 1. Download credentials (p12 + mobileprovision) into tempDir.
87
- const credentials = yield* downloadIosCredentials(api, {
88
- projectId,
89
- bundleIdentifier: bundleId,
90
- distribution,
91
- tempDir,
92
- });
93
-
94
- // 2. Expo prebuild (ios).
95
- yield* runStep(
96
- Command.make("bunx", "expo", "prebuild", "--platform", "ios", "--clean").pipe(
97
- Command.workingDirectory(projectRoot),
98
- Command.env(commandEnv),
99
- ),
100
- "expo prebuild ios",
101
- );
102
-
103
- // 3. pod install.
104
- yield* runStep(
105
- Command.make("pod", "install").pipe(
106
- Command.workingDirectory(iosDir),
107
- Command.env(commandEnv),
108
- ),
109
- "pod install",
110
- );
111
-
112
- // 4. Scoped ephemeral keychain (auto-cleaned on scope close).
113
- const keychain = yield* acquireKeychain({
114
- tempDir,
115
- p12Path: credentials.p12Path,
116
- p12Password: credentials.p12Password,
117
- });
118
-
119
- // 5. Scoped provisioning profile install.
120
- const provisioning = yield* installProvisioningProfile({
121
- profilePath: credentials.profilePath,
122
- });
123
-
124
- // 6. Detect workspace + scheme.
125
- const workspaceFilename = yield* findXcworkspace(iosDir);
126
- const scheme = iosProfile.scheme ?? workspaceFilename.replace(/\.xcworkspace$/, "");
127
- const configuration = iosProfile.buildConfiguration ?? "Release";
128
-
129
- // 7. xcodebuild archive.
130
- const archivePath = path.join(tempDir, "build.xcarchive");
131
- const archiveCmd = Command.make(
132
- "xcodebuild",
133
- "-workspace",
134
- workspaceFilename,
135
- "-scheme",
136
- scheme,
137
- "-configuration",
138
- configuration,
139
- "-archivePath",
140
- archivePath,
141
- "-allowProvisioningUpdates",
142
- "archive",
143
- "CODE_SIGN_STYLE=Manual",
144
- `DEVELOPMENT_TEAM=${provisioning.teamId}`,
145
- `CODE_SIGN_IDENTITY=${keychain.signingIdentity}`,
146
- `PROVISIONING_PROFILE_SPECIFIER=${provisioning.name}`,
147
- ).pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
148
-
149
- const formatter = input.rawOutput ? undefined : createXcodebuildFormatter(projectRoot);
150
- yield* formatter
151
- ? runStepFormatted(archiveCmd, "xcodebuild archive", formatter)
152
- : runStep(archiveCmd, "xcodebuild archive");
153
-
154
- const fs = yield* FileSystem.FileSystem;
155
- const exportOptionsPath = path.join(tempDir, "ExportOptions.plist");
156
- yield* fs.writeFileString(
157
- exportOptionsPath,
158
- renderExportOptionsPlist({
159
- method: distribution,
160
- teamId: provisioning.teamId,
161
- bundleId,
162
- provisioningProfileName: provisioning.name,
163
- }),
164
- );
165
-
166
- // 9. xcodebuild exportArchive.
167
- const exportPath = path.join(tempDir, "export");
168
- const exportCmd = Command.make(
169
- "xcodebuild",
170
- "-exportArchive",
171
- "-archivePath",
172
- archivePath,
173
- "-exportPath",
174
- exportPath,
175
- "-exportOptionsPlist",
176
- exportOptionsPath,
177
- "-allowProvisioningUpdates",
178
- ).pipe(Command.workingDirectory(iosDir), Command.env(commandEnv));
179
-
180
- yield* formatter
181
- ? runStepFormatted(exportCmd, "xcodebuild exportArchive", formatter)
182
- : runStep(exportCmd, "xcodebuild exportArchive");
183
-
184
- // 10. Post-build validation (non-blocking).
185
- yield* validateIosBuild({
186
- archivePath,
187
- expectedBundleId: bundleId,
188
- expectedTeamId: provisioning.teamId,
189
- expectedProfileUuid: provisioning.uuid,
190
- });
191
-
192
- // 11. Locate artifact.
193
- const artifactPath = yield* findIosArtifact({ exportPath });
194
-
195
- // 12. SHA-256 + byte size.
196
- const { sha256, byteSize } = yield* sha256File(artifactPath);
197
-
198
- return { artifactPath, byteSize, sha256 };
199
- });
@@ -1,263 +0,0 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- import { FileSystem, HttpClient, HttpClientResponse } from "@effect/platform";
6
- import { BunFileSystem } from "@effect/platform-bun";
7
- import { it } from "@effect/vitest";
8
- import { Effect, Exit, Layer } from "effect";
9
-
10
- import {
11
- CompleteError,
12
- PresignedUrlExpiredError,
13
- ReserveError,
14
- UploadFailedError,
15
- } from "../../lib/exit-codes";
16
- import { failureError } from "../../lib/test-utils";
17
- import { PresignedUploadClientLive } from "../../services/presigned-upload";
18
- import { reserveAndUpload } from "./reserve-and-upload";
19
-
20
- import type { ApiClient } from "../../services/api-client";
21
-
22
- // ── helpers ───────────────────────────────────────────────────────
23
-
24
- interface ApiStubOptions {
25
- readonly reserve?: (args: { payload: Record<string, unknown> }) => Effect.Effect<
26
- {
27
- id: string;
28
- uploadMode: "single";
29
- uploadUrl: string;
30
- uploadExpiresAt: string;
31
- uploadHeaders: Record<string, string>;
32
- },
33
- unknown
34
- >;
35
- readonly complete?: (args: {
36
- path: { id: string };
37
- payload: { sha256: string; byteSize: number };
38
- }) => Effect.Effect<{ id: string; artifact: unknown }, unknown>;
39
- }
40
-
41
- const makeApi = (opts: ApiStubOptions): ApiClient =>
42
- ({
43
- builds: {
44
- reserve:
45
- opts.reserve ??
46
- (() =>
47
- Effect.succeed({
48
- id: "build_1",
49
- uploadMode: "single" as const,
50
- uploadUrl: "https://example.com/upload",
51
- uploadExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
52
- uploadHeaders: {
53
- "content-type": "application/octet-stream",
54
- "x-amz-checksum-sha256": "checksum",
55
- },
56
- })),
57
- complete:
58
- opts.complete ??
59
- (() =>
60
- Effect.succeed({
61
- id: "build_1",
62
- artifact: {
63
- r2Key: "r2/build_1",
64
- format: "ipa",
65
- contentType: "application/octet-stream",
66
- byteSize: 11,
67
- sha256: "deadbeef",
68
- },
69
- })),
70
- },
71
- }) as unknown as ApiClient;
72
-
73
- const makeHttpClientLayer = (
74
- respond: () => globalThis.Response,
75
- ): Layer.Layer<HttpClient.HttpClient> =>
76
- Layer.succeed(
77
- HttpClient.HttpClient,
78
- HttpClient.make((request) => Effect.sync(() => HttpClientResponse.fromWeb(request, respond()))),
79
- );
80
-
81
- const makePresignedUploadLayer = (
82
- fileSystemLayer: Layer.Layer<FileSystem.FileSystem>,
83
- respond: () => globalThis.Response,
84
- ) =>
85
- PresignedUploadClientLive.pipe(
86
- Layer.provide(Layer.mergeAll(fileSystemLayer, makeHttpClientLayer(respond))),
87
- );
88
-
89
- const okResponse = () => new Response(null, { status: 200 });
90
-
91
- const withTempFile = (bytes: Buffer): { path: string; dispose: () => void } => {
92
- const dir = mkdtempSync(join(tmpdir(), "reserve-test-"));
93
- const filePath = join(dir, "artifact.bin");
94
- writeFileSync(filePath, bytes);
95
- return {
96
- path: filePath,
97
- dispose: () => rmSync(dir, { recursive: true, force: true }),
98
- };
99
- };
100
-
101
- const baseInput = (artifactPath: string) => ({
102
- projectId: "proj_1",
103
- target: {
104
- platform: "ios" as const,
105
- distribution: "app-store" as const,
106
- artifactFormat: "ipa" as const,
107
- },
108
- profileName: "production",
109
- runtimeVersion: "1.2.3",
110
- appVersion: "1.2.0",
111
- buildNumber: "42",
112
- bundleId: "com.example.app",
113
- gitContext: { ref: "main", commit: "abc123", dirty: false },
114
- message: "test build",
115
- artifactPath,
116
- sha256: "deadbeef",
117
- byteSize: 11,
118
- });
119
-
120
- // ── tests ─────────────────────────────────────────────────────────
121
-
122
- describe(reserveAndUpload, () => {
123
- it.effect("happy path: reserves, uploads, completes", () =>
124
- Effect.gen(function* () {
125
- const file = withTempFile(Buffer.from("hello world"));
126
- let reservePayload: Record<string, unknown> | undefined;
127
- let completePath: { id: string } | undefined;
128
-
129
- const api = makeApi({
130
- reserve: ({ payload }) => {
131
- reservePayload = payload;
132
- return Effect.succeed({
133
- id: "build_123",
134
- uploadMode: "single" as const,
135
- uploadUrl: "https://example.com/upload",
136
- uploadExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
137
- uploadHeaders: {
138
- "content-type": "application/octet-stream",
139
- "x-amz-checksum-sha256": "checksum",
140
- },
141
- });
142
- },
143
- complete: ({ path, payload }) => {
144
- completePath = path;
145
- return Effect.succeed({
146
- id: path.id,
147
- artifact: {
148
- r2Key: `r2/${path.id}`,
149
- format: "ipa",
150
- contentType: "application/octet-stream",
151
- byteSize: payload.byteSize,
152
- sha256: payload.sha256,
153
- },
154
- });
155
- },
156
- });
157
-
158
- const result = yield* reserveAndUpload(api, baseInput(file.path)).pipe(
159
- Effect.provide(makePresignedUploadLayer(BunFileSystem.layer, okResponse)),
160
- Effect.ensuring(Effect.sync(file.dispose)),
161
- );
162
-
163
- expect(result.id).toBe("build_123");
164
- expect(result.status).toBe("uploaded");
165
- expect(reservePayload?.["projectId"]).toBe("proj_1");
166
- expect(reservePayload?.["platform"]).toBe("ios");
167
- expect(reservePayload?.["profile"]).toBe("production");
168
- expect(reservePayload?.["distribution"]).toBe("app-store");
169
- expect(reservePayload?.["runtimeVersion"]).toBe("1.2.3");
170
- expect(reservePayload?.["gitRef"]).toBe("main");
171
- expect(reservePayload?.["gitCommit"]).toBe("abc123");
172
- expect(reservePayload?.["bundleId"]).toBe("com.example.app");
173
- expect(reservePayload?.["sha256"]).toBe("deadbeef");
174
- expect(reservePayload?.["byteSize"]).toBe(11);
175
- expect(completePath?.id).toBe("build_123");
176
- }),
177
- );
178
-
179
- it.effect("fails with ReserveError when reserve endpoint fails", () =>
180
- Effect.gen(function* () {
181
- const api = makeApi({
182
- reserve: () => Effect.fail(new Error("server down")),
183
- });
184
- const exit = yield* reserveAndUpload(api, baseInput("/dev/null")).pipe(
185
- Effect.provide(makePresignedUploadLayer(FileSystem.layerNoop({}), okResponse)),
186
- Effect.exit,
187
- );
188
- expect(Exit.isFailure(exit)).toBe(true);
189
- if (Exit.isFailure(exit)) {
190
- const error = failureError(exit);
191
- expect(error).toBeInstanceOf(ReserveError);
192
- }
193
- }),
194
- );
195
-
196
- it.effect("fails with PresignedUrlExpiredError when upload URL expired", () =>
197
- Effect.gen(function* () {
198
- const api = makeApi({
199
- reserve: () =>
200
- Effect.succeed({
201
- id: "build_1",
202
- uploadMode: "single" as const,
203
- uploadUrl: "https://example.com/upload",
204
- uploadExpiresAt: new Date(Date.now() - 1000).toISOString(),
205
- uploadHeaders: {
206
- "content-type": "application/octet-stream",
207
- "x-amz-checksum-sha256": "checksum",
208
- },
209
- }),
210
- });
211
- const exit = yield* reserveAndUpload(api, baseInput("/dev/null")).pipe(
212
- Effect.provide(makePresignedUploadLayer(FileSystem.layerNoop({}), okResponse)),
213
- Effect.exit,
214
- );
215
- expect(Exit.isFailure(exit)).toBe(true);
216
- if (Exit.isFailure(exit)) {
217
- const error = failureError(exit);
218
- expect(error).toBeInstanceOf(PresignedUrlExpiredError);
219
- }
220
- }),
221
- );
222
-
223
- it.effect("fails with UploadFailedError when PUT returns 403", () =>
224
- Effect.gen(function* () {
225
- const file = withTempFile(Buffer.from("hello world"));
226
- const api = makeApi({});
227
- const exit = yield* reserveAndUpload(api, baseInput(file.path)).pipe(
228
- Effect.provide(
229
- makePresignedUploadLayer(
230
- BunFileSystem.layer,
231
- () => new Response("AccessDenied", { status: 403, statusText: "Forbidden" }),
232
- ),
233
- ),
234
- Effect.ensuring(Effect.sync(file.dispose)),
235
- Effect.exit,
236
- );
237
- expect(Exit.isFailure(exit)).toBe(true);
238
- if (Exit.isFailure(exit)) {
239
- const error = failureError(exit);
240
- expect(error).toBeInstanceOf(UploadFailedError);
241
- }
242
- }),
243
- );
244
-
245
- it.effect("fails with CompleteError when complete endpoint fails", () =>
246
- Effect.gen(function* () {
247
- const file = withTempFile(Buffer.from("hello world"));
248
- const api = makeApi({
249
- complete: () => Effect.fail(new Error("db error")),
250
- });
251
- const exit = yield* reserveAndUpload(api, baseInput(file.path)).pipe(
252
- Effect.provide(makePresignedUploadLayer(BunFileSystem.layer, okResponse)),
253
- Effect.ensuring(Effect.sync(file.dispose)),
254
- Effect.exit,
255
- );
256
- expect(Exit.isFailure(exit)).toBe(true);
257
- if (Exit.isFailure(exit)) {
258
- const error = failureError(exit);
259
- expect(error).toBeInstanceOf(CompleteError);
260
- }
261
- }),
262
- );
263
- });
@@ -1,160 +0,0 @@
1
- import { Effect } from "effect";
2
-
3
- import { CompleteError, ReserveError } from "../../lib/exit-codes";
4
- import { formatCause } from "../../lib/format-error";
5
- import { PresignedUploadClient } from "../../services/presigned-upload";
6
-
7
- import type { PresignedUrlExpiredError, UploadFailedError } from "../../lib/exit-codes";
8
- import type { ApiClient } from "../../services/api-client";
9
-
10
- export type BuildTarget =
11
- | {
12
- readonly platform: "ios";
13
- readonly distribution: "app-store" | "ad-hoc" | "development" | "enterprise";
14
- readonly artifactFormat: "ipa";
15
- }
16
- | {
17
- readonly platform: "ios";
18
- readonly distribution: "simulator";
19
- readonly artifactFormat: "tar.gz";
20
- }
21
- | {
22
- readonly platform: "android";
23
- readonly distribution: "play-store";
24
- readonly artifactFormat: "aab";
25
- }
26
- | {
27
- readonly platform: "android";
28
- readonly distribution: "direct";
29
- readonly artifactFormat: "apk";
30
- };
31
-
32
- export interface ReserveAndUploadInput {
33
- readonly target: BuildTarget;
34
- readonly projectId: string;
35
- readonly profileName: string;
36
- readonly runtimeVersion: string;
37
- readonly appVersion?: string;
38
- readonly buildNumber?: string;
39
- readonly bundleId: string;
40
- readonly gitContext: {
41
- readonly ref?: string;
42
- readonly commit?: string;
43
- readonly dirty: boolean;
44
- };
45
- readonly message?: string;
46
- readonly artifactPath: string;
47
- readonly sha256: string;
48
- readonly byteSize: number;
49
- }
50
-
51
- const buildReserveCommon = (input: ReserveAndUploadInput) =>
52
- ({
53
- projectId: input.projectId,
54
- profile: input.profileName,
55
- runtimeVersion: input.runtimeVersion,
56
- bundleId: input.bundleId,
57
- sha256: input.sha256,
58
- byteSize: input.byteSize,
59
- ...(input.appVersion === undefined ? {} : { appVersion: input.appVersion }),
60
- ...(input.buildNumber === undefined ? {} : { buildNumber: input.buildNumber }),
61
- ...(input.gitContext.ref === undefined ? {} : { gitRef: input.gitContext.ref }),
62
- ...(input.gitContext.commit === undefined ? {} : { gitCommit: input.gitContext.commit }),
63
- ...(input.message === undefined ? {} : { message: input.message }),
64
- }) as const;
65
-
66
- const callReserve = (api: ApiClient, input: ReserveAndUploadInput) => {
67
- const common = buildReserveCommon(input);
68
- const { target } = input;
69
- if (target.platform === "ios") {
70
- return target.distribution === "simulator"
71
- ? api.builds.reserve({
72
- payload: {
73
- ...common,
74
- platform: "ios",
75
- distribution: "simulator",
76
- artifactFormat: "tar.gz",
77
- },
78
- })
79
- : api.builds.reserve({
80
- payload: {
81
- ...common,
82
- platform: "ios",
83
- distribution: target.distribution,
84
- artifactFormat: "ipa",
85
- },
86
- });
87
- }
88
- return target.distribution === "play-store"
89
- ? api.builds.reserve({
90
- payload: {
91
- ...common,
92
- platform: "android",
93
- distribution: "play-store",
94
- artifactFormat: "aab",
95
- },
96
- })
97
- : api.builds.reserve({
98
- payload: { ...common, platform: "android", distribution: "direct", artifactFormat: "apk" },
99
- });
100
- };
101
-
102
- export interface ReserveAndUploadResult {
103
- readonly id: string;
104
- readonly status: string;
105
- }
106
-
107
- /**
108
- * Reserve a build record on the server, upload the artifact to the returned
109
- * presigned URL, and finalize the build with its sha256 + byteSize.
110
- */
111
- export const reserveAndUpload = (
112
- api: ApiClient,
113
- input: ReserveAndUploadInput,
114
- ): Effect.Effect<
115
- ReserveAndUploadResult,
116
- ReserveError | UploadFailedError | PresignedUrlExpiredError | CompleteError,
117
- PresignedUploadClient
118
- > =>
119
- Effect.gen(function* () {
120
- const presignedUploadClient = yield* PresignedUploadClient;
121
-
122
- const reserveResult = yield* callReserve(api, input).pipe(
123
- Effect.mapError(
124
- (cause) =>
125
- new ReserveError({
126
- message: `Failed to reserve build: ${formatCause(cause)}`,
127
- }),
128
- ),
129
- );
130
-
131
- yield* presignedUploadClient.putToPresignedUrl({
132
- url: reserveResult.uploadUrl,
133
- filePath: input.artifactPath,
134
- byteSize: input.byteSize,
135
- expiresAt: reserveResult.uploadExpiresAt,
136
- headers: reserveResult.uploadHeaders,
137
- });
138
-
139
- const completed = yield* api.builds
140
- .complete({
141
- path: { id: reserveResult.id },
142
- payload: { sha256: input.sha256, byteSize: input.byteSize },
143
- })
144
- .pipe(
145
- Effect.mapError(
146
- (cause) =>
147
- new CompleteError({
148
- message: `Failed to complete build ${reserveResult.id}: ${formatCause(cause)}`,
149
- }),
150
- ),
151
- );
152
-
153
- if (!completed.artifact) {
154
- return yield* new CompleteError({
155
- message: `Build ${completed.id} completed but server returned no artifact record.`,
156
- });
157
- }
158
-
159
- return { id: completed.id, status: "uploaded" };
160
- });