@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.
- package/dist/index.js +5319 -0
- package/dist/index.js.map +1 -0
- package/package.json +12 -9
- package/CHANGELOG.md +0 -58
- package/oxlint.config.ts +0 -6
- package/src/app-layer.ts +0 -29
- package/src/application/build-workflow.ts +0 -222
- package/src/application/command-exit.ts +0 -13
- package/src/application/login.ts +0 -87
- package/src/application/update-promote.ts +0 -88
- package/src/application/update-publish.ts +0 -402
- package/src/application/update-rollback.ts +0 -275
- package/src/commands/analytics/adoption.ts +0 -40
- package/src/commands/analytics/channels.ts +0 -35
- package/src/commands/analytics/helpers.ts +0 -3
- package/src/commands/analytics/index.ts +0 -13
- package/src/commands/analytics/platforms.ts +0 -39
- package/src/commands/analytics/updates.ts +0 -35
- package/src/commands/audit-logs/helpers.ts +0 -3
- package/src/commands/audit-logs/index.ts +0 -8
- package/src/commands/audit-logs/list.ts +0 -66
- package/src/commands/branches.ts +0 -70
- package/src/commands/build/android.ts +0 -129
- package/src/commands/build/index.ts +0 -63
- package/src/commands/build/ios.ts +0 -199
- package/src/commands/build/reserve-and-upload.test.ts +0 -263
- package/src/commands/build/reserve-and-upload.ts +0 -160
- package/src/commands/build/run-step.ts +0 -131
- package/src/commands/builds/compatibility-matrix.ts +0 -48
- package/src/commands/builds/delete.ts +0 -15
- package/src/commands/builds/get.ts +0 -34
- package/src/commands/builds/helpers.ts +0 -3
- package/src/commands/builds/index.ts +0 -20
- package/src/commands/builds/install-link.ts +0 -20
- package/src/commands/builds/list.ts +0 -38
- package/src/commands/channels/create.ts +0 -37
- package/src/commands/channels/delete.ts +0 -15
- package/src/commands/channels/helpers.ts +0 -18
- package/src/commands/channels/index.ts +0 -24
- package/src/commands/channels/list.ts +0 -38
- package/src/commands/channels/pause.ts +0 -15
- package/src/commands/channels/resume.ts +0 -15
- package/src/commands/channels/rollout/complete.ts +0 -17
- package/src/commands/channels/rollout/create.ts +0 -36
- package/src/commands/channels/rollout/index.ts +0 -11
- package/src/commands/channels/rollout/revert.ts +0 -17
- package/src/commands/channels/rollout/update.ts +0 -23
- package/src/commands/channels/update.ts +0 -32
- package/src/commands/credentials/delete.ts +0 -24
- package/src/commands/credentials/index.ts +0 -10
- package/src/commands/credentials/list.ts +0 -33
- package/src/commands/credentials/upload.ts +0 -91
- package/src/commands/env/delete.ts +0 -35
- package/src/commands/env/export.ts +0 -27
- package/src/commands/env/get.ts +0 -25
- package/src/commands/env/helpers.ts +0 -13
- package/src/commands/env/import.ts +0 -31
- package/src/commands/env/index.ts +0 -24
- package/src/commands/env/list.ts +0 -44
- package/src/commands/env/pull.ts +0 -27
- package/src/commands/env/set.ts +0 -42
- package/src/commands/fingerprint/compare.ts +0 -25
- package/src/commands/fingerprint/generate.ts +0 -18
- package/src/commands/fingerprint/index.ts +0 -9
- package/src/commands/init.ts +0 -35
- package/src/commands/login.ts +0 -13
- package/src/commands/logout.ts +0 -12
- package/src/commands/projects.ts +0 -84
- package/src/commands/status.ts +0 -48
- package/src/commands/update/delete.ts +0 -15
- package/src/commands/update/helpers.ts +0 -22
- package/src/commands/update/index.ts +0 -22
- package/src/commands/update/list.ts +0 -60
- package/src/commands/update/promote.ts +0 -30
- package/src/commands/update/publish.ts +0 -94
- package/src/commands/update/rollback.ts +0 -42
- package/src/commands/update/rollout/complete.ts +0 -17
- package/src/commands/update/rollout/index.ts +0 -10
- package/src/commands/update/rollout/revert.ts +0 -17
- package/src/commands/update/rollout/set.ts +0 -23
- package/src/index.ts +0 -53
- package/src/lib/android-keystore.test.ts +0 -114
- package/src/lib/android-keystore.ts +0 -76
- package/src/lib/android-signing-gradle.test.ts +0 -95
- package/src/lib/android-signing-gradle.ts +0 -52
- package/src/lib/app-json.ts +0 -81
- package/src/lib/apple-auth.test.ts +0 -402
- package/src/lib/apple-auth.ts +0 -132
- package/src/lib/artifact-finder.test.ts +0 -195
- package/src/lib/artifact-finder.ts +0 -122
- package/src/lib/browser-login.test.ts +0 -88
- package/src/lib/browser-login.ts +0 -193
- package/src/lib/build-profile.test.ts +0 -290
- package/src/lib/build-profile.ts +0 -234
- package/src/lib/cli-schemas.ts +0 -39
- package/src/lib/command-errors.ts +0 -60
- package/src/lib/credentials-downloader.ts +0 -181
- package/src/lib/credentials-manager.ts +0 -354
- package/src/lib/env-exporter.test.ts +0 -96
- package/src/lib/env-exporter.ts +0 -28
- package/src/lib/exit-codes.ts +0 -82
- package/src/lib/expo-config.ts +0 -130
- package/src/lib/expo-export.test.ts +0 -94
- package/src/lib/expo-export.ts +0 -281
- package/src/lib/fingerprint.ts +0 -67
- package/src/lib/format-error.ts +0 -22
- package/src/lib/git-context.ts +0 -56
- package/src/lib/gradle-config.ts +0 -126
- package/src/lib/ios-export-options.test.ts +0 -98
- package/src/lib/ios-export-options.ts +0 -62
- package/src/lib/ios-keychain.ts +0 -181
- package/src/lib/ios-provisioning.test.ts +0 -115
- package/src/lib/ios-provisioning.ts +0 -179
- package/src/lib/output.ts +0 -32
- package/src/lib/pkcs12.ts +0 -73
- package/src/lib/plist.ts +0 -39
- package/src/lib/post-build-validation.ts +0 -146
- package/src/lib/presigned-upload.test.ts +0 -140
- package/src/lib/presigned-upload.ts +0 -35
- package/src/lib/record.ts +0 -5
- package/src/lib/resolve-named-resource.ts +0 -24
- package/src/lib/runtime-version.test.ts +0 -119
- package/src/lib/runtime-version.ts +0 -62
- package/src/lib/sha256.test.ts +0 -108
- package/src/lib/sha256.ts +0 -80
- package/src/lib/signed-payloads.test.ts +0 -181
- package/src/lib/signed-payloads.ts +0 -164
- package/src/lib/string-utils.ts +0 -4
- package/src/lib/temp-dir.ts +0 -14
- package/src/lib/test-utils.ts +0 -13
- package/src/lib/update-platforms.test.ts +0 -45
- package/src/lib/update-platforms.ts +0 -19
- package/src/lib/xcpretty-formatter.ts +0 -21
- package/src/services/api-client.ts +0 -42
- package/src/services/apple-session-store.ts +0 -100
- package/src/services/auth-store.ts +0 -85
- package/src/services/cli-runtime.ts +0 -46
- package/src/services/config-store.ts +0 -108
- package/src/services/presigned-upload.ts +0 -84
- package/src/services/update-asset-uploader.ts +0 -72
- package/src/types/keychain.d.ts +0 -22
- package/tests/e2e/build.test.ts +0 -270
- package/tests/e2e/commands.test.ts +0 -694
- package/tests/e2e/ota-lifecycle.test.ts +0 -275
- package/tests/e2e/publish.test.ts +0 -150
- package/tests/helpers/cli-e2e.ts +0 -426
- package/tests/helpers/pty-driver.ts +0 -142
- package/tests/interactive/harness/provider-prompt.ts +0 -54
- package/tests/interactive/login.test.ts +0 -47
- package/tests/interactive/provider-select.test.ts +0 -59
- package/tsconfig.json +0 -7
- 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
|
-
});
|