@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
package/src/lib/pkcs12.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/* eslint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-member-access, typescript/no-unsafe-call -- @expo/pkcs12 exports declare node-forge cert shapes as `any`; this file is the narrowing boundary that produces the typed P12Info for the rest of the CLI */
|
|
2
|
-
|
|
3
|
-
import { getFormattedSerialNumber, getX509Certificate, parsePKCS12 } from "@expo/pkcs12";
|
|
4
|
-
import { Effect } from "effect";
|
|
5
|
-
|
|
6
|
-
import { CredentialValidationError } from "./exit-codes";
|
|
7
|
-
|
|
8
|
-
export interface P12Info {
|
|
9
|
-
readonly serialNumber: string;
|
|
10
|
-
readonly validFrom: Date | undefined;
|
|
11
|
-
readonly expiresAt: Date | undefined;
|
|
12
|
-
readonly subject: string;
|
|
13
|
-
readonly issuerCN: string | undefined;
|
|
14
|
-
readonly signingIdentity: string;
|
|
15
|
-
readonly teamId: string | undefined;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const APPLE_TEAM_ID_RE = /^[A-Z0-9]{10}$/u;
|
|
19
|
-
|
|
20
|
-
const extractTeamId = (params: {
|
|
21
|
-
readonly signingIdentity: string;
|
|
22
|
-
readonly orgUnit: string | undefined;
|
|
23
|
-
}): string | undefined => {
|
|
24
|
-
if (params.orgUnit && APPLE_TEAM_ID_RE.test(params.orgUnit)) {
|
|
25
|
-
return params.orgUnit;
|
|
26
|
-
}
|
|
27
|
-
const parenMatch = /\(([A-Z0-9]{10})\)\s*$/u.exec(params.signingIdentity);
|
|
28
|
-
return parenMatch?.[1];
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Parse a PKCS#12 (.p12) buffer and extract certificate metadata.
|
|
33
|
-
*/
|
|
34
|
-
export const inspectP12 = (params: {
|
|
35
|
-
readonly data: Buffer;
|
|
36
|
-
readonly password: string;
|
|
37
|
-
}): Effect.Effect<P12Info, CredentialValidationError> =>
|
|
38
|
-
Effect.try({
|
|
39
|
-
try: () => {
|
|
40
|
-
const p12 = parsePKCS12(params.data, params.password);
|
|
41
|
-
const cert = getX509Certificate(p12);
|
|
42
|
-
|
|
43
|
-
const serialNumber = getFormattedSerialNumber(cert) ?? "unknown";
|
|
44
|
-
|
|
45
|
-
const validFrom =
|
|
46
|
-
cert.validity.notBefore instanceof Date ? cert.validity.notBefore : undefined;
|
|
47
|
-
const expiresAt = cert.validity.notAfter instanceof Date ? cert.validity.notAfter : undefined;
|
|
48
|
-
|
|
49
|
-
const subjectParts = cert.subject.attributes.map(
|
|
50
|
-
(attr: { shortName?: string; name: string; value: unknown }) =>
|
|
51
|
-
`${attr.shortName ?? attr.name}=${String(attr.value)}`,
|
|
52
|
-
);
|
|
53
|
-
const subject = subjectParts.join(", ");
|
|
54
|
-
|
|
55
|
-
const issuerCNValue = cert.issuer.getField("CN")?.value;
|
|
56
|
-
const issuerCN = typeof issuerCNValue === "string" ? issuerCNValue : undefined;
|
|
57
|
-
|
|
58
|
-
// Signing identity = Common Name from subject, e.g. "Apple Distribution: Name (TEAMID)"
|
|
59
|
-
const cnValue = cert.subject.getField("CN")?.value;
|
|
60
|
-
const cn = typeof cnValue === "string" ? cnValue : undefined;
|
|
61
|
-
const signingIdentity = cn ?? subject;
|
|
62
|
-
const orgUnitValue = cert.subject.getField("OU")?.value;
|
|
63
|
-
const orgUnit = typeof orgUnitValue === "string" ? orgUnitValue : undefined;
|
|
64
|
-
|
|
65
|
-
const teamId = extractTeamId({ signingIdentity, orgUnit });
|
|
66
|
-
|
|
67
|
-
return { serialNumber, validFrom, expiresAt, subject, issuerCN, signingIdentity, teamId };
|
|
68
|
-
},
|
|
69
|
-
catch: (error) =>
|
|
70
|
-
new CredentialValidationError({
|
|
71
|
-
message: `Failed to parse P12 certificate: ${error instanceof Error ? error.message : String(error)}`,
|
|
72
|
-
}),
|
|
73
|
-
});
|
package/src/lib/plist.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import plist from "@expo/plist";
|
|
2
|
-
|
|
3
|
-
import type { PlistObject } from "@expo/plist";
|
|
4
|
-
// eslint-disable-next-line import-plugin/no-namespace -- bplist-parser typings have no named export; used only as `typeof BplistParser` for the CJS require result
|
|
5
|
-
import type * as BplistParser from "bplist-parser";
|
|
6
|
-
|
|
7
|
-
export type { PlistObject, PlistValue } from "@expo/plist";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Parse an XML plist string into a typed object.
|
|
11
|
-
* Throws on malformed XML — callers should wrap in Effect.try.
|
|
12
|
-
*/
|
|
13
|
-
export const parsePlistXml = (xml: string): PlistObject =>
|
|
14
|
-
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- @expo/plist.parse returns `any`; PlistObject is the library's declared shape for XML plists
|
|
15
|
-
plist.parse(xml) as PlistObject;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Parse a binary plist buffer into a typed object.
|
|
19
|
-
* Uses bplist-parser for Apple's binary plist format.
|
|
20
|
-
*/
|
|
21
|
-
export const parsePlistBinary = (buffer: Buffer): PlistObject => {
|
|
22
|
-
const bplistParser =
|
|
23
|
-
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- CJS require returns `any`; narrow to the package's own typings at the boundary
|
|
24
|
-
require("bplist-parser") as typeof BplistParser;
|
|
25
|
-
// eslint-disable-next-line typescript/no-unsafe-assignment -- bplist-parser typings declare parseBuffer<T>(): T[] with T=any in the shipped .d.ts
|
|
26
|
-
const [result] = bplistParser.parseBuffer(buffer);
|
|
27
|
-
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- bplist-parser typings return `any[]`; PlistObject is the superset shape we consume
|
|
28
|
-
return result as PlistObject;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const BPLIST_MAGIC = Buffer.from("bplist00");
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Auto-detect plist format (binary vs XML) and parse accordingly.
|
|
35
|
-
*/
|
|
36
|
-
export const parsePlist = (data: Buffer): PlistObject =>
|
|
37
|
-
data.subarray(0, 8).equals(BPLIST_MAGIC)
|
|
38
|
-
? parsePlistBinary(data)
|
|
39
|
-
: parsePlistXml(data.toString("utf8"));
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
|
|
3
|
-
import { Command, FileSystem } from "@effect/platform";
|
|
4
|
-
import { Console, Effect } from "effect";
|
|
5
|
-
|
|
6
|
-
import type { CommandExecutor } from "@effect/platform";
|
|
7
|
-
|
|
8
|
-
import { parsePlist, parsePlistXml } from "./plist";
|
|
9
|
-
|
|
10
|
-
export interface IosValidationParams {
|
|
11
|
-
readonly archivePath: string;
|
|
12
|
-
readonly expectedBundleId: string;
|
|
13
|
-
readonly expectedTeamId: string;
|
|
14
|
-
readonly expectedProfileUuid: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ValidationResult {
|
|
18
|
-
readonly passed: boolean;
|
|
19
|
-
readonly warnings: readonly string[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Validate an iOS build after xcodebuild completes. Checks:
|
|
24
|
-
* 1. Bundle ID matches expected value
|
|
25
|
-
* 2. Provisioning profile UUID matches
|
|
26
|
-
* 3. Team ID matches
|
|
27
|
-
*
|
|
28
|
-
* All checks are non-blocking — returns warnings, never fails the build.
|
|
29
|
-
*/
|
|
30
|
-
export const validateIosBuild = (
|
|
31
|
-
params: IosValidationParams,
|
|
32
|
-
): Effect.Effect<
|
|
33
|
-
ValidationResult,
|
|
34
|
-
never,
|
|
35
|
-
CommandExecutor.CommandExecutor | FileSystem.FileSystem
|
|
36
|
-
> =>
|
|
37
|
-
Effect.gen(function* () {
|
|
38
|
-
const appDir = yield* findAppDirectory(params.archivePath).pipe(
|
|
39
|
-
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
if (!appDir) {
|
|
43
|
-
const warnings = ["Could not locate .app bundle in archive — skipping post-build validation"];
|
|
44
|
-
return { passed: false, warnings };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const bundleWarning = yield* checkBundleId(appDir, params.expectedBundleId).pipe(
|
|
48
|
-
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const profileWarnings = yield* checkEmbeddedProfile(
|
|
52
|
-
appDir,
|
|
53
|
-
params.expectedProfileUuid,
|
|
54
|
-
params.expectedTeamId,
|
|
55
|
-
).pipe(Effect.catchAll(() => Effect.succeed([] as readonly string[])));
|
|
56
|
-
|
|
57
|
-
const warnings: readonly string[] = [
|
|
58
|
-
...(bundleWarning ? [bundleWarning] : []),
|
|
59
|
-
...profileWarnings,
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
if (warnings.length > 0) {
|
|
63
|
-
yield* Console.warn("Post-build validation warnings:");
|
|
64
|
-
for (const warning of warnings) {
|
|
65
|
-
yield* Console.warn(` - ${warning}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { passed: warnings.length === 0, warnings };
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// ── helpers ──────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
const findAppDirectory = (
|
|
75
|
-
archivePath: string,
|
|
76
|
-
): Effect.Effect<string, unknown, FileSystem.FileSystem> =>
|
|
77
|
-
Effect.gen(function* () {
|
|
78
|
-
const fs = yield* FileSystem.FileSystem;
|
|
79
|
-
const productsDir = path.join(archivePath, "Products", "Applications");
|
|
80
|
-
const entries = yield* fs.readDirectory(productsDir);
|
|
81
|
-
const appEntry = entries.find((entry) => entry.endsWith(".app"));
|
|
82
|
-
if (!appEntry) {
|
|
83
|
-
return yield* Effect.fail("No .app found");
|
|
84
|
-
}
|
|
85
|
-
return path.join(productsDir, appEntry);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const checkBundleId = (
|
|
89
|
-
appDir: string,
|
|
90
|
-
expectedBundleId: string,
|
|
91
|
-
): Effect.Effect<string | undefined, unknown, FileSystem.FileSystem> =>
|
|
92
|
-
Effect.gen(function* () {
|
|
93
|
-
const fs = yield* FileSystem.FileSystem;
|
|
94
|
-
const plistPath = path.join(appDir, "Info.plist");
|
|
95
|
-
const data = yield* fs.readFile(plistPath);
|
|
96
|
-
const parsed = parsePlist(Buffer.from(data));
|
|
97
|
-
const actualBundleId = parsed["CFBundleIdentifier"];
|
|
98
|
-
|
|
99
|
-
if (typeof actualBundleId === "string" && actualBundleId !== expectedBundleId) {
|
|
100
|
-
return `Bundle ID mismatch: expected "${expectedBundleId}", got "${actualBundleId}"`;
|
|
101
|
-
}
|
|
102
|
-
return undefined;
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const checkEmbeddedProfile = (
|
|
106
|
-
appDir: string,
|
|
107
|
-
expectedUuid: string,
|
|
108
|
-
expectedTeamId: string,
|
|
109
|
-
): Effect.Effect<
|
|
110
|
-
readonly string[],
|
|
111
|
-
unknown,
|
|
112
|
-
CommandExecutor.CommandExecutor | FileSystem.FileSystem
|
|
113
|
-
> =>
|
|
114
|
-
Effect.gen(function* () {
|
|
115
|
-
const warnings: string[] = [];
|
|
116
|
-
const profilePath = path.join(appDir, "embedded.mobileprovision");
|
|
117
|
-
|
|
118
|
-
// Use security cms to decrypt the profile (it's CMS-signed)
|
|
119
|
-
const plistXml = yield* Command.string(
|
|
120
|
-
Command.make("security", "cms", "-D", "-i", profilePath),
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
const parsed = parsePlistXml(plistXml);
|
|
124
|
-
|
|
125
|
-
const actualUuid = parsed["UUID"];
|
|
126
|
-
if (typeof actualUuid === "string" && actualUuid !== expectedUuid) {
|
|
127
|
-
warnings.push(`Profile UUID mismatch: expected "${expectedUuid}", got "${actualUuid}"`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const teamIdentifiers = parsed["TeamIdentifier"];
|
|
131
|
-
if (Array.isArray(teamIdentifiers)) {
|
|
132
|
-
// eslint-disable-next-line typescript/no-unsafe-assignment -- @expo/plist types array entries as any; narrowed via typeof check below
|
|
133
|
-
const [actualTeamId] = teamIdentifiers;
|
|
134
|
-
if (typeof actualTeamId === "string" && actualTeamId !== expectedTeamId) {
|
|
135
|
-
warnings.push(`Team ID mismatch: expected "${expectedTeamId}", got "${actualTeamId}"`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Check expiration
|
|
140
|
-
const expirationDate = parsed["ExpirationDate"];
|
|
141
|
-
if (expirationDate instanceof Date && expirationDate.getTime() < Date.now()) {
|
|
142
|
-
warnings.push(`Embedded provisioning profile expired on ${expirationDate.toISOString()}`);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return warnings;
|
|
146
|
-
});
|
|
@@ -1,140 +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 { HttpClient, HttpClientResponse, FileSystem } 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 { PresignedUploadClientLive } from "../services/presigned-upload";
|
|
11
|
-
import { PresignedUrlExpiredError, UploadFailedError } from "./exit-codes";
|
|
12
|
-
import { putToPresignedUrl } from "./presigned-upload";
|
|
13
|
-
import { failureError } from "./test-utils";
|
|
14
|
-
|
|
15
|
-
// ── helpers ───────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
const makeHttpClientLayer = (
|
|
18
|
-
respond: () => globalThis.Response,
|
|
19
|
-
): Layer.Layer<HttpClient.HttpClient> =>
|
|
20
|
-
Layer.succeed(
|
|
21
|
-
HttpClient.HttpClient,
|
|
22
|
-
HttpClient.make((request) => Effect.sync(() => HttpClientResponse.fromWeb(request, respond()))),
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
// In-memory noop filesystem for the "expired" branch where the file is never opened.
|
|
26
|
-
const noopFsLayer: Layer.Layer<FileSystem.FileSystem> = FileSystem.layerNoop({});
|
|
27
|
-
|
|
28
|
-
const makePresignedUploadLayer = (
|
|
29
|
-
fileSystemLayer: Layer.Layer<FileSystem.FileSystem>,
|
|
30
|
-
respond: () => globalThis.Response,
|
|
31
|
-
) =>
|
|
32
|
-
PresignedUploadClientLive.pipe(
|
|
33
|
-
Layer.provide(Layer.mergeAll(fileSystemLayer, makeHttpClientLayer(respond))),
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
const withTempFile = (bytes: Buffer): { path: string; dispose: () => void } => {
|
|
37
|
-
const dir = mkdtempSync(join(tmpdir(), "presigned-test-"));
|
|
38
|
-
const filePath = join(dir, "artifact.bin");
|
|
39
|
-
writeFileSync(filePath, bytes);
|
|
40
|
-
return {
|
|
41
|
-
path: filePath,
|
|
42
|
-
dispose: () => rmSync(dir, { recursive: true, force: true }),
|
|
43
|
-
};
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const futureExpiry = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
47
|
-
const pastExpiry = () => new Date(Date.now() - 1000).toISOString();
|
|
48
|
-
|
|
49
|
-
// ── tests ─────────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
describe(putToPresignedUrl, () => {
|
|
52
|
-
it.effect("fails with PresignedUrlExpiredError when expiry is in the past", () =>
|
|
53
|
-
Effect.gen(function* () {
|
|
54
|
-
const exit = yield* putToPresignedUrl({
|
|
55
|
-
url: "https://example.com/upload",
|
|
56
|
-
filePath: "/dev/null",
|
|
57
|
-
byteSize: 0,
|
|
58
|
-
expiresAt: pastExpiry(),
|
|
59
|
-
}).pipe(
|
|
60
|
-
Effect.provide(
|
|
61
|
-
makePresignedUploadLayer(noopFsLayer, () => new Response(null, { status: 200 })),
|
|
62
|
-
),
|
|
63
|
-
Effect.exit,
|
|
64
|
-
);
|
|
65
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
66
|
-
if (Exit.isFailure(exit)) {
|
|
67
|
-
const error = failureError(exit);
|
|
68
|
-
expect(error).toBeInstanceOf(PresignedUrlExpiredError);
|
|
69
|
-
}
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
it.effect("fails with PresignedUrlExpiredError within 30s safety margin", () =>
|
|
74
|
-
Effect.gen(function* () {
|
|
75
|
-
const inTenSeconds = new Date(Date.now() + 10_000).toISOString();
|
|
76
|
-
const exit = yield* putToPresignedUrl({
|
|
77
|
-
url: "https://example.com/upload",
|
|
78
|
-
filePath: "/dev/null",
|
|
79
|
-
byteSize: 0,
|
|
80
|
-
expiresAt: inTenSeconds,
|
|
81
|
-
}).pipe(
|
|
82
|
-
Effect.provide(
|
|
83
|
-
makePresignedUploadLayer(noopFsLayer, () => new Response(null, { status: 200 })),
|
|
84
|
-
),
|
|
85
|
-
Effect.exit,
|
|
86
|
-
);
|
|
87
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
88
|
-
if (Exit.isFailure(exit)) {
|
|
89
|
-
const error = failureError(exit);
|
|
90
|
-
expect(error).toBeInstanceOf(PresignedUrlExpiredError);
|
|
91
|
-
}
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
it.effect("succeeds on 2xx response", () =>
|
|
96
|
-
Effect.gen(function* () {
|
|
97
|
-
const file = withTempFile(Buffer.from("hello world"));
|
|
98
|
-
const exit = yield* putToPresignedUrl({
|
|
99
|
-
url: "https://example.com/upload",
|
|
100
|
-
filePath: file.path,
|
|
101
|
-
byteSize: 11,
|
|
102
|
-
expiresAt: futureExpiry(),
|
|
103
|
-
}).pipe(
|
|
104
|
-
Effect.provide(
|
|
105
|
-
makePresignedUploadLayer(BunFileSystem.layer, () => new Response(null, { status: 200 })),
|
|
106
|
-
),
|
|
107
|
-
Effect.ensuring(Effect.sync(file.dispose)),
|
|
108
|
-
Effect.exit,
|
|
109
|
-
);
|
|
110
|
-
expect(Exit.isSuccess(exit)).toBe(true);
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
it.effect("fails with UploadFailedError on 403 response", () =>
|
|
115
|
-
Effect.gen(function* () {
|
|
116
|
-
const file = withTempFile(Buffer.from("hello world"));
|
|
117
|
-
const exit = yield* putToPresignedUrl({
|
|
118
|
-
url: "https://example.com/upload",
|
|
119
|
-
filePath: file.path,
|
|
120
|
-
byteSize: 11,
|
|
121
|
-
expiresAt: futureExpiry(),
|
|
122
|
-
}).pipe(
|
|
123
|
-
Effect.provide(
|
|
124
|
-
makePresignedUploadLayer(
|
|
125
|
-
BunFileSystem.layer,
|
|
126
|
-
() => new Response("AccessDenied", { status: 403, statusText: "Forbidden" }),
|
|
127
|
-
),
|
|
128
|
-
),
|
|
129
|
-
Effect.ensuring(Effect.sync(file.dispose)),
|
|
130
|
-
Effect.exit,
|
|
131
|
-
);
|
|
132
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
133
|
-
if (Exit.isFailure(exit)) {
|
|
134
|
-
const error = failureError(exit);
|
|
135
|
-
expect(error).toBeInstanceOf(UploadFailedError);
|
|
136
|
-
expect((error as UploadFailedError).message).toContain("403");
|
|
137
|
-
}
|
|
138
|
-
}),
|
|
139
|
-
);
|
|
140
|
-
});
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
|
|
3
|
-
import { PresignedUploadClient } from "../services/presigned-upload";
|
|
4
|
-
|
|
5
|
-
import type { PresignedUrlExpiredError, UploadFailedError } from "./exit-codes";
|
|
6
|
-
|
|
7
|
-
export interface PutToPresignedUrlOptions {
|
|
8
|
-
readonly url: string;
|
|
9
|
-
readonly filePath: string;
|
|
10
|
-
readonly byteSize: number;
|
|
11
|
-
readonly expiresAt: string;
|
|
12
|
-
readonly headers?: Record<string, string>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export const putToPresignedUrl = ({
|
|
16
|
-
url,
|
|
17
|
-
filePath,
|
|
18
|
-
byteSize,
|
|
19
|
-
expiresAt,
|
|
20
|
-
headers,
|
|
21
|
-
}: PutToPresignedUrlOptions): Effect.Effect<
|
|
22
|
-
void,
|
|
23
|
-
PresignedUrlExpiredError | UploadFailedError,
|
|
24
|
-
PresignedUploadClient
|
|
25
|
-
> =>
|
|
26
|
-
Effect.gen(function* () {
|
|
27
|
-
const presignedUploadClient = yield* PresignedUploadClient;
|
|
28
|
-
yield* presignedUploadClient.putToPresignedUrl({
|
|
29
|
-
url,
|
|
30
|
-
filePath,
|
|
31
|
-
byteSize,
|
|
32
|
-
expiresAt,
|
|
33
|
-
...(headers === undefined ? {} : { headers }),
|
|
34
|
-
});
|
|
35
|
-
});
|
package/src/lib/record.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
2
|
-
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
|
-
|
|
4
|
-
export const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
5
|
-
isRecord(value) ? value : undefined;
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
|
|
3
|
-
interface NamedResource {
|
|
4
|
-
readonly id: string;
|
|
5
|
-
readonly name: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export const resolveNamedResourceId = <Err>(
|
|
9
|
-
params: {
|
|
10
|
-
readonly items: readonly NamedResource[];
|
|
11
|
-
readonly kind: string;
|
|
12
|
-
readonly name: string;
|
|
13
|
-
},
|
|
14
|
-
makeError: (message: string) => Err,
|
|
15
|
-
): Effect.Effect<string, Err> =>
|
|
16
|
-
Effect.gen(function* () {
|
|
17
|
-
const match = params.items.find((item) => item.name === params.name);
|
|
18
|
-
if (match === undefined) {
|
|
19
|
-
return yield* Effect.fail(
|
|
20
|
-
makeError(`${params.kind} "${params.name}" not found in the linked project.`),
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
return match.id;
|
|
24
|
-
});
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { CommandExecutor } from "@effect/platform";
|
|
2
|
-
import { it } from "@effect/vitest";
|
|
3
|
-
import { Effect, Exit } from "effect";
|
|
4
|
-
|
|
5
|
-
import { RuntimeVersionError } from "./exit-codes";
|
|
6
|
-
import { resolveRuntimeVersion } from "./runtime-version";
|
|
7
|
-
import { failureError } from "./test-utils";
|
|
8
|
-
|
|
9
|
-
// ── helpers ───────────────────────────────────────────────────────
|
|
10
|
-
|
|
11
|
-
const makeStubExecutor = (stdout: string): CommandExecutor.CommandExecutor =>
|
|
12
|
-
({
|
|
13
|
-
[CommandExecutor.TypeId]: CommandExecutor.TypeId,
|
|
14
|
-
string: () => Effect.succeed(stdout),
|
|
15
|
-
}) as unknown as CommandExecutor.CommandExecutor;
|
|
16
|
-
|
|
17
|
-
const provideStubExecutor = (stdout: string) =>
|
|
18
|
-
Effect.provideService(CommandExecutor.CommandExecutor, makeStubExecutor(stdout));
|
|
19
|
-
|
|
20
|
-
// ── tests ─────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
describe(resolveRuntimeVersion, () => {
|
|
23
|
-
it.effect("returns literal string as-is", () =>
|
|
24
|
-
Effect.gen(function* () {
|
|
25
|
-
const result = yield* resolveRuntimeVersion({
|
|
26
|
-
raw: "1.2.3",
|
|
27
|
-
appVersion: "9.9.9",
|
|
28
|
-
projectRoot: ".",
|
|
29
|
-
}).pipe(provideStubExecutor(""));
|
|
30
|
-
expect(result).toBe("1.2.3");
|
|
31
|
-
}),
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
it.effect('resolves {policy:"appVersion"} to appVersion', () =>
|
|
35
|
-
Effect.gen(function* () {
|
|
36
|
-
const result = yield* resolveRuntimeVersion({
|
|
37
|
-
raw: { policy: "appVersion" },
|
|
38
|
-
appVersion: "2.5.0",
|
|
39
|
-
projectRoot: ".",
|
|
40
|
-
}).pipe(provideStubExecutor(""));
|
|
41
|
-
expect(result).toBe("2.5.0");
|
|
42
|
-
}),
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
it.effect('fails when policy "appVersion" has no expo.version', () =>
|
|
46
|
-
Effect.gen(function* () {
|
|
47
|
-
const exit = yield* resolveRuntimeVersion({
|
|
48
|
-
raw: { policy: "appVersion" },
|
|
49
|
-
appVersion: undefined,
|
|
50
|
-
projectRoot: ".",
|
|
51
|
-
}).pipe(provideStubExecutor(""), Effect.exit);
|
|
52
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
53
|
-
}),
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
it.effect('resolves {policy:"fingerprint"} via CommandExecutor JSON hash', () =>
|
|
57
|
-
Effect.gen(function* () {
|
|
58
|
-
const result = yield* resolveRuntimeVersion({
|
|
59
|
-
raw: { policy: "fingerprint" },
|
|
60
|
-
appVersion: undefined,
|
|
61
|
-
projectRoot: ".",
|
|
62
|
-
}).pipe(provideStubExecutor('{"hash":"abc123","sources":[]}'));
|
|
63
|
-
expect(result).toBe("abc123");
|
|
64
|
-
}),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
it.effect("fails when fingerprint stdout is not JSON", () =>
|
|
68
|
-
Effect.gen(function* () {
|
|
69
|
-
const exit = yield* resolveRuntimeVersion({
|
|
70
|
-
raw: { policy: "fingerprint" },
|
|
71
|
-
appVersion: undefined,
|
|
72
|
-
projectRoot: ".",
|
|
73
|
-
}).pipe(provideStubExecutor("not-json"), Effect.exit);
|
|
74
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
75
|
-
if (Exit.isFailure(exit)) {
|
|
76
|
-
const error = failureError(exit);
|
|
77
|
-
expect(error).toBeInstanceOf(RuntimeVersionError);
|
|
78
|
-
}
|
|
79
|
-
}),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
it.effect("fails when fingerprint JSON has no hash field", () =>
|
|
83
|
-
Effect.gen(function* () {
|
|
84
|
-
const exit = yield* resolveRuntimeVersion({
|
|
85
|
-
raw: { policy: "fingerprint" },
|
|
86
|
-
appVersion: undefined,
|
|
87
|
-
projectRoot: ".",
|
|
88
|
-
}).pipe(provideStubExecutor('{"sources":[]}'), Effect.exit);
|
|
89
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
90
|
-
}),
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
it.effect('fails with guidance on policy "nativeVersion"', () =>
|
|
94
|
-
Effect.gen(function* () {
|
|
95
|
-
const exit = yield* resolveRuntimeVersion({
|
|
96
|
-
raw: { policy: "nativeVersion" },
|
|
97
|
-
appVersion: undefined,
|
|
98
|
-
projectRoot: ".",
|
|
99
|
-
}).pipe(provideStubExecutor(""), Effect.exit);
|
|
100
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
101
|
-
if (Exit.isFailure(exit)) {
|
|
102
|
-
const error = failureError(exit);
|
|
103
|
-
expect(error).toBeInstanceOf(RuntimeVersionError);
|
|
104
|
-
expect(error!.message).toContain("nativeVersion");
|
|
105
|
-
}
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
it.effect("fails when runtimeVersion is missing entirely", () =>
|
|
110
|
-
Effect.gen(function* () {
|
|
111
|
-
const exit = yield* resolveRuntimeVersion({
|
|
112
|
-
raw: undefined,
|
|
113
|
-
appVersion: "1.0.0",
|
|
114
|
-
projectRoot: ".",
|
|
115
|
-
}).pipe(provideStubExecutor(""), Effect.exit);
|
|
116
|
-
expect(Exit.isFailure(exit)).toBe(true);
|
|
117
|
-
}),
|
|
118
|
-
);
|
|
119
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
2
|
-
|
|
3
|
-
import type { CommandExecutor } from "@effect/platform";
|
|
4
|
-
|
|
5
|
-
import { RuntimeVersionError } from "./exit-codes";
|
|
6
|
-
import { runFingerprintFull } from "./fingerprint";
|
|
7
|
-
|
|
8
|
-
import type { RawRuntimeVersion } from "./build-profile";
|
|
9
|
-
|
|
10
|
-
export interface ResolveRuntimeVersionOptions {
|
|
11
|
-
readonly raw: RawRuntimeVersion | undefined;
|
|
12
|
-
readonly appVersion: string | undefined;
|
|
13
|
-
readonly projectRoot: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const resolveRuntimeVersion = ({
|
|
17
|
-
raw,
|
|
18
|
-
appVersion,
|
|
19
|
-
projectRoot,
|
|
20
|
-
}: ResolveRuntimeVersionOptions): Effect.Effect<
|
|
21
|
-
string,
|
|
22
|
-
RuntimeVersionError,
|
|
23
|
-
CommandExecutor.CommandExecutor
|
|
24
|
-
> =>
|
|
25
|
-
Effect.gen(function* () {
|
|
26
|
-
if (typeof raw === "string") {
|
|
27
|
-
return raw;
|
|
28
|
-
}
|
|
29
|
-
if (raw === undefined) {
|
|
30
|
-
return yield* new RuntimeVersionError({
|
|
31
|
-
message: "No runtimeVersion configured in expo section of app.json.",
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const { policy } = raw;
|
|
36
|
-
if (policy === "appVersion") {
|
|
37
|
-
if (appVersion === undefined) {
|
|
38
|
-
return yield* new RuntimeVersionError({
|
|
39
|
-
message: 'runtimeVersion policy is "appVersion" but expo.version is missing in app.json.',
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
return appVersion;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (policy === "fingerprint") {
|
|
46
|
-
return yield* runFingerprintFull(projectRoot).pipe(
|
|
47
|
-
Effect.map((result) => result.hash),
|
|
48
|
-
Effect.mapError((cause) => new RuntimeVersionError({ message: cause.message })),
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (policy === "nativeVersion") {
|
|
53
|
-
return yield* new RuntimeVersionError({
|
|
54
|
-
message:
|
|
55
|
-
'runtimeVersion policy "nativeVersion" is not supported. Set a static runtimeVersion string in app.json.',
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return yield* new RuntimeVersionError({
|
|
60
|
-
message: `Unsupported runtimeVersion policy "${policy}". Use a static string, "appVersion", or "fingerprint".`,
|
|
61
|
-
});
|
|
62
|
-
});
|