@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,694 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
-
|
|
8
|
-
import { setupCliE2E } from "../helpers/cli-e2e";
|
|
9
|
-
|
|
10
|
-
const generateSelfSignedP12 = (password: string, subject: string): Buffer => {
|
|
11
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-p12-"));
|
|
12
|
-
const keyPath = path.join(tmp, "key.pem");
|
|
13
|
-
const certPath = path.join(tmp, "cert.pem");
|
|
14
|
-
const p12Path = path.join(tmp, "cert.p12");
|
|
15
|
-
try {
|
|
16
|
-
execFileSync(
|
|
17
|
-
"openssl",
|
|
18
|
-
[
|
|
19
|
-
"req",
|
|
20
|
-
"-x509",
|
|
21
|
-
"-newkey",
|
|
22
|
-
"rsa:2048",
|
|
23
|
-
"-sha256",
|
|
24
|
-
"-days",
|
|
25
|
-
"365",
|
|
26
|
-
"-nodes",
|
|
27
|
-
"-keyout",
|
|
28
|
-
keyPath,
|
|
29
|
-
"-out",
|
|
30
|
-
certPath,
|
|
31
|
-
"-subj",
|
|
32
|
-
subject,
|
|
33
|
-
],
|
|
34
|
-
{ stdio: "pipe" },
|
|
35
|
-
);
|
|
36
|
-
execFileSync(
|
|
37
|
-
"openssl",
|
|
38
|
-
[
|
|
39
|
-
"pkcs12",
|
|
40
|
-
"-export",
|
|
41
|
-
"-out",
|
|
42
|
-
p12Path,
|
|
43
|
-
"-inkey",
|
|
44
|
-
keyPath,
|
|
45
|
-
"-in",
|
|
46
|
-
certPath,
|
|
47
|
-
"-passout",
|
|
48
|
-
`pass:${password}`,
|
|
49
|
-
"-legacy",
|
|
50
|
-
],
|
|
51
|
-
{ stdio: "pipe" },
|
|
52
|
-
);
|
|
53
|
-
return readFileSync(p12Path);
|
|
54
|
-
} finally {
|
|
55
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const cli = setupCliE2E(".wrangler/state/e2e-cli");
|
|
60
|
-
|
|
61
|
-
const escapeRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
62
|
-
const sqlString = (value: string) => `'${value.replaceAll("'", "''")}'`;
|
|
63
|
-
|
|
64
|
-
const getNodeErrorCode = (error: unknown): string | undefined => {
|
|
65
|
-
if (!(error instanceof Error)) {
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const directCode = (error as NodeJS.ErrnoException).code;
|
|
70
|
-
if (typeof directCode === "string") {
|
|
71
|
-
return directCode;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const { cause } = error as Error & { readonly cause?: unknown };
|
|
75
|
-
if (typeof cause !== "object" || cause === null) {
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const nestedCode = (cause as NodeJS.ErrnoException).code;
|
|
80
|
-
return typeof nestedCode === "string" ? nestedCode : undefined;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const fetchWithRetry = async (url: string, init: RequestInit): Promise<Response> => {
|
|
84
|
-
const maxAttempts = 4;
|
|
85
|
-
|
|
86
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
87
|
-
try {
|
|
88
|
-
return await fetch(url, init);
|
|
89
|
-
} catch (error) {
|
|
90
|
-
const code = getNodeErrorCode(error);
|
|
91
|
-
if (
|
|
92
|
-
!code ||
|
|
93
|
-
!["ECONNRESET", "EPIPE", "UND_ERR_SOCKET"].includes(code) ||
|
|
94
|
-
attempt === maxAttempts
|
|
95
|
-
) {
|
|
96
|
-
throw error;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
await sleep(attempt * 100);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
throw new Error("fetchWithRetry exhausted unexpectedly");
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const seedDestinationChannel = (name: string) => {
|
|
107
|
-
const branchId = `${name}-branch`;
|
|
108
|
-
const channelId = `${name}-channel`;
|
|
109
|
-
cli.seedSql(`
|
|
110
|
-
INSERT INTO "branches" ("id", "project_id", "name", "created_at")
|
|
111
|
-
VALUES (${sqlString(branchId)}, ${sqlString(cli.getProjectId())}, ${sqlString(name)}, '2026-04-14T00:00:00Z');
|
|
112
|
-
|
|
113
|
-
INSERT INTO "channels" (
|
|
114
|
-
"id", "project_id", "name", "branch_id", "branch_mapping_json", "cache_version", "is_paused", "created_at"
|
|
115
|
-
)
|
|
116
|
-
VALUES (
|
|
117
|
-
${sqlString(channelId)},
|
|
118
|
-
${sqlString(cli.getProjectId())},
|
|
119
|
-
${sqlString(name)},
|
|
120
|
-
${sqlString(branchId)},
|
|
121
|
-
NULL,
|
|
122
|
-
0,
|
|
123
|
-
0,
|
|
124
|
-
'2026-04-14T00:00:00Z'
|
|
125
|
-
);
|
|
126
|
-
`);
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
interface PromotableUpdate {
|
|
130
|
-
readonly id: string;
|
|
131
|
-
readonly groupId: string;
|
|
132
|
-
readonly assetHash: string;
|
|
133
|
-
readonly launchAssetKey: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const createPromotableUpdate = async (options?: {
|
|
137
|
-
readonly signed?: {
|
|
138
|
-
readonly manifestId: string;
|
|
139
|
-
readonly createdAt: string;
|
|
140
|
-
readonly signature: string;
|
|
141
|
-
readonly certificateChain: string;
|
|
142
|
-
};
|
|
143
|
-
}): Promise<PromotableUpdate> => {
|
|
144
|
-
const assetBody = Buffer.from("console.log('cli promote source');\n");
|
|
145
|
-
const assetHash = createHash("sha256").update(assetBody).digest("base64url");
|
|
146
|
-
const launchAssetKey = "bundles/ios-launch.js";
|
|
147
|
-
const registerResponse = await cli.postAuthorized("/api/assets/upload", {
|
|
148
|
-
projectId: cli.getProjectId(),
|
|
149
|
-
assets: [{ hash: assetHash, contentType: "application/javascript", fileExt: "js" }],
|
|
150
|
-
});
|
|
151
|
-
expect(registerResponse.status).toBe(201);
|
|
152
|
-
|
|
153
|
-
const registerBody = (await registerResponse.json()) as {
|
|
154
|
-
uploaded: {
|
|
155
|
-
hash: string;
|
|
156
|
-
uploadUrl: string;
|
|
157
|
-
uploadHeaders: Record<string, string>;
|
|
158
|
-
}[];
|
|
159
|
-
deduplicated: string[];
|
|
160
|
-
};
|
|
161
|
-
const upload = registerBody.uploaded.find((asset) => asset.hash === assetHash);
|
|
162
|
-
if (upload) {
|
|
163
|
-
const uploadResponse = await fetchWithRetry(upload.uploadUrl, {
|
|
164
|
-
method: "PUT",
|
|
165
|
-
headers: {
|
|
166
|
-
"content-length": String(assetBody.byteLength),
|
|
167
|
-
...upload.uploadHeaders,
|
|
168
|
-
},
|
|
169
|
-
body: assetBody,
|
|
170
|
-
});
|
|
171
|
-
expect(uploadResponse.status).toBe(200);
|
|
172
|
-
|
|
173
|
-
const finalizeResponse = await cli.postAuthorized(
|
|
174
|
-
`/api/assets/${assetHash}/finalize`,
|
|
175
|
-
undefined,
|
|
176
|
-
);
|
|
177
|
-
expect(finalizeResponse.status).toBe(200);
|
|
178
|
-
} else {
|
|
179
|
-
expect(registerBody.deduplicated).toContain(assetHash);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const manifestBody =
|
|
183
|
-
options?.signed === undefined
|
|
184
|
-
? undefined
|
|
185
|
-
: JSON.stringify({
|
|
186
|
-
id: options.signed.manifestId,
|
|
187
|
-
createdAt: options.signed.createdAt,
|
|
188
|
-
runtimeVersion: "1.0.0",
|
|
189
|
-
launchAsset: { key: launchAssetKey, hash: assetHash },
|
|
190
|
-
assets: [],
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const createUpdateResponse = await cli.postAuthorized("/api/updates", {
|
|
194
|
-
branch: "main",
|
|
195
|
-
slug: "cli-e2e-app",
|
|
196
|
-
runtimeVersion: "1.0.0",
|
|
197
|
-
platform: "ios",
|
|
198
|
-
message: options?.signed ? "CLI signed promotable update" : "CLI promotable update",
|
|
199
|
-
groupId: randomUUID(),
|
|
200
|
-
metadata: {},
|
|
201
|
-
assets: [{ hash: assetHash, key: launchAssetKey, isLaunch: true }],
|
|
202
|
-
...(manifestBody
|
|
203
|
-
? {
|
|
204
|
-
manifestBody,
|
|
205
|
-
signature: options?.signed?.signature,
|
|
206
|
-
certificateChain: options?.signed?.certificateChain,
|
|
207
|
-
}
|
|
208
|
-
: {}),
|
|
209
|
-
});
|
|
210
|
-
expect(createUpdateResponse.status).toBe(201);
|
|
211
|
-
|
|
212
|
-
const update = (await createUpdateResponse.json()) as {
|
|
213
|
-
id: string;
|
|
214
|
-
groupId: string;
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
id: update.id,
|
|
219
|
-
groupId: update.groupId,
|
|
220
|
-
assetHash,
|
|
221
|
-
launchAssetKey,
|
|
222
|
-
};
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
const cliState = {
|
|
226
|
-
rollbackGroupId: "",
|
|
227
|
-
rollbackUpdateId: "",
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
describe("cLI command journey", () => {
|
|
231
|
-
it("links the current Expo app to the existing project", () => {
|
|
232
|
-
const result = cli.runCli("init");
|
|
233
|
-
expect(result.exitCode).toBe(0);
|
|
234
|
-
expect(result.stderr).toBe("");
|
|
235
|
-
expect(result.stdout).toContain("Linking project: CLI E2E App (cli-e2e-app)");
|
|
236
|
-
expect(result.stdout).toContain("Found existing project: CLI E2E App Project");
|
|
237
|
-
expect(result.stdout).toContain("Project linked successfully");
|
|
238
|
-
|
|
239
|
-
const appJson = cli.readAppJson();
|
|
240
|
-
expect(
|
|
241
|
-
(
|
|
242
|
-
((appJson["expo"] as Record<string, unknown>)["extra"] as Record<string, unknown>)[
|
|
243
|
-
"betterUpdate"
|
|
244
|
-
] as Record<string, unknown>
|
|
245
|
-
)["projectId"],
|
|
246
|
-
).toBe(cli.getProjectId());
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("shows project status with credential and build counts", () => {
|
|
250
|
-
const result = cli.runCli("status");
|
|
251
|
-
expect(result.exitCode).toBe(0);
|
|
252
|
-
expect(result.stderr).toBe("");
|
|
253
|
-
expect(result.stdout).toContain("Project");
|
|
254
|
-
expect(result.stdout).toContain("CLI E2E App Project");
|
|
255
|
-
expect(result.stdout).toContain("cli-e2e-app");
|
|
256
|
-
expect(result.stdout).toContain("Credentials");
|
|
257
|
-
expect(result.stdout).toContain("iOS");
|
|
258
|
-
expect(result.stdout).toContain("1");
|
|
259
|
-
expect(result.stdout).toContain("Builds");
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("lists credentials for an empty project", () => {
|
|
263
|
-
const result = cli.runCli("credentials", "list");
|
|
264
|
-
expect(result.exitCode).toBe(0);
|
|
265
|
-
expect(result.stderr).toBe("");
|
|
266
|
-
expect(result.stdout).toContain("No credentials found.");
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it("lists environment variables with masked secret values", () => {
|
|
270
|
-
const result = cli.runCli("env", "list", "--environment", "production");
|
|
271
|
-
expect(result.exitCode).toBe(0);
|
|
272
|
-
expect(result.stderr).toBe("");
|
|
273
|
-
expect(result.stdout).toContain("APP_SECRET");
|
|
274
|
-
expect(result.stdout).toContain("production");
|
|
275
|
-
expect(result.stdout).toContain("secret");
|
|
276
|
-
expect(result.stdout).toContain("••••••");
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it("imports, exports, pulls, sets, and deletes environment variables", () => {
|
|
280
|
-
const envFile = path.join(cli.getProjectDir(), ".env.preview");
|
|
281
|
-
writeFileSync(envFile, "EXPO_PUBLIC_API_URL=https://preview.example.com\nFEATURE_FLAG=true\n");
|
|
282
|
-
|
|
283
|
-
const importResult = cli.runCli("env", "import", envFile, "--environment", "preview");
|
|
284
|
-
expect(importResult.exitCode).toBe(0);
|
|
285
|
-
expect(importResult.stderr).toBe("");
|
|
286
|
-
expect(importResult.stdout).toContain("Imported: 2 created, 0 updated, 0 skipped");
|
|
287
|
-
|
|
288
|
-
const exportResult = cli.runCli("env", "export", "--environment", "preview");
|
|
289
|
-
expect(exportResult.exitCode).toBe(0);
|
|
290
|
-
expect(exportResult.stderr).toBe("");
|
|
291
|
-
expect(exportResult.stdout).toContain("EXPO_PUBLIC_API_URL='https://preview.example.com'");
|
|
292
|
-
expect(exportResult.stdout).toContain("FEATURE_FLAG='true'");
|
|
293
|
-
|
|
294
|
-
const pullResult = cli.runCli("env", "pull", "--environment", "preview");
|
|
295
|
-
expect(pullResult.exitCode).toBe(0);
|
|
296
|
-
expect(pullResult.stderr).toBe("");
|
|
297
|
-
expect(pullResult.stdout).toContain("export EXPO_PUBLIC_API_URL='https://preview.example.com'");
|
|
298
|
-
|
|
299
|
-
const createResult = cli.runCli(
|
|
300
|
-
"env",
|
|
301
|
-
"set",
|
|
302
|
-
"APP_PUBLIC_URL=https://app.example.com",
|
|
303
|
-
"--environment",
|
|
304
|
-
"production",
|
|
305
|
-
);
|
|
306
|
-
expect(createResult.exitCode).toBe(0);
|
|
307
|
-
expect(createResult.stderr).toBe("");
|
|
308
|
-
expect(createResult.stdout).toContain("Created APP_PUBLIC_URL in production");
|
|
309
|
-
|
|
310
|
-
const updateResult = cli.runCli(
|
|
311
|
-
"env",
|
|
312
|
-
"set",
|
|
313
|
-
"APP_PUBLIC_URL=https://app-v2.example.com",
|
|
314
|
-
"--environment",
|
|
315
|
-
"production",
|
|
316
|
-
);
|
|
317
|
-
expect(updateResult.exitCode).toBe(0);
|
|
318
|
-
expect(updateResult.stderr).toBe("");
|
|
319
|
-
expect(updateResult.stdout).toContain("Updated APP_PUBLIC_URL in production");
|
|
320
|
-
|
|
321
|
-
const listResult = cli.runCli("env", "list", "--environment", "production");
|
|
322
|
-
expect(listResult.exitCode).toBe(0);
|
|
323
|
-
expect(listResult.stderr).toBe("");
|
|
324
|
-
expect(listResult.stdout).toContain("APP_PUBLIC_URL");
|
|
325
|
-
expect(listResult.stdout).toContain("https://app-v2.example.com");
|
|
326
|
-
|
|
327
|
-
const deleteResult = cli.runCli(
|
|
328
|
-
"env",
|
|
329
|
-
"delete",
|
|
330
|
-
"APP_PUBLIC_URL",
|
|
331
|
-
"--environment",
|
|
332
|
-
"production",
|
|
333
|
-
);
|
|
334
|
-
expect(deleteResult.exitCode).toBe(0);
|
|
335
|
-
expect(deleteResult.stderr).toBe("");
|
|
336
|
-
expect(deleteResult.stdout).toContain("Deleted APP_PUBLIC_URL from production");
|
|
337
|
-
|
|
338
|
-
const finalList = cli.runCli("env", "list", "--environment", "production");
|
|
339
|
-
expect(finalList.exitCode).toBe(0);
|
|
340
|
-
expect(finalList.stdout).not.toContain("APP_PUBLIC_URL");
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it("lists builds for the linked project", () => {
|
|
344
|
-
const result = cli.runCli("builds", "list");
|
|
345
|
-
expect(result.exitCode).toBe(0);
|
|
346
|
-
expect(result.stderr).toBe("");
|
|
347
|
-
expect(result.stdout).toContain("cli-build-1");
|
|
348
|
-
expect(result.stdout).toContain("ad-hoc");
|
|
349
|
-
expect(result.stdout).toContain("production");
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it("creates a rollback update from the CLI", async () => {
|
|
353
|
-
const commitTime = "2026-04-14T00:00:00.000Z";
|
|
354
|
-
const rollbackResult = cli.runCli(
|
|
355
|
-
"update",
|
|
356
|
-
"rollback",
|
|
357
|
-
"--branch",
|
|
358
|
-
"main",
|
|
359
|
-
"--platform",
|
|
360
|
-
"ios",
|
|
361
|
-
"--commit-time",
|
|
362
|
-
commitTime,
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
expect(rollbackResult.exitCode).toBe(0);
|
|
366
|
-
expect(rollbackResult.stderr).toBe("");
|
|
367
|
-
expect(rollbackResult.stdout).toContain("Created rollback group");
|
|
368
|
-
expect(rollbackResult.stdout).toContain('on branch "main"');
|
|
369
|
-
expect(rollbackResult.stdout).toContain(commitTime);
|
|
370
|
-
|
|
371
|
-
const listResult = cli.runCli("update", "list", "--branch", "main");
|
|
372
|
-
expect(listResult.exitCode).toBe(0);
|
|
373
|
-
expect(listResult.stderr).toBe("");
|
|
374
|
-
expect(listResult.stdout).toContain("Update ID");
|
|
375
|
-
expect(listResult.stdout).toContain("main");
|
|
376
|
-
expect(listResult.stdout).toContain("ios");
|
|
377
|
-
expect(listResult.stdout).toContain("1.0.0");
|
|
378
|
-
expect(listResult.stdout).toContain("yes");
|
|
379
|
-
const rollbackMatch = /^([^\s]+)\s+([^\s]+)\s+main\s+ios\s+1\.0\.0\s+100%\s+yes\s+.+$/m.exec(
|
|
380
|
-
listResult.stdout,
|
|
381
|
-
);
|
|
382
|
-
expect(rollbackMatch).toBeDefined();
|
|
383
|
-
cliState.rollbackUpdateId = rollbackMatch?.[1] ?? "";
|
|
384
|
-
cliState.rollbackGroupId = rollbackMatch?.[2] ?? "";
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
it("creates a signed rollback update from the CLI using pre-signed files", async () => {
|
|
388
|
-
const signedCommitTime = "2026-04-15T00:00:00.000Z";
|
|
389
|
-
const directiveBodyPath = path.join(cli.getProjectDir(), "signed-directive.json");
|
|
390
|
-
const signaturePath = path.join(cli.getProjectDir(), "signed-directive.sig");
|
|
391
|
-
const certificateChainPath = path.join(cli.getProjectDir(), "signed-directive.pem");
|
|
392
|
-
const signedMessage = "Signed rollback via CLI";
|
|
393
|
-
|
|
394
|
-
writeFileSync(
|
|
395
|
-
directiveBodyPath,
|
|
396
|
-
JSON.stringify({
|
|
397
|
-
type: "rollBackToEmbedded",
|
|
398
|
-
parameters: { commitTime: signedCommitTime },
|
|
399
|
-
}),
|
|
400
|
-
);
|
|
401
|
-
writeFileSync(signaturePath, 'sig="signed-cli-test", keyid="main", alg="rsa-v1_5_sha256"\n');
|
|
402
|
-
writeFileSync(
|
|
403
|
-
certificateChainPath,
|
|
404
|
-
"-----BEGIN CERTIFICATE-----\nSIGNED CLI TEST\n-----END CERTIFICATE-----\n",
|
|
405
|
-
);
|
|
406
|
-
|
|
407
|
-
const rollbackResult = cli.runCli(
|
|
408
|
-
"update",
|
|
409
|
-
"rollback",
|
|
410
|
-
"--branch",
|
|
411
|
-
"main",
|
|
412
|
-
"--platform",
|
|
413
|
-
"ios",
|
|
414
|
-
"--message",
|
|
415
|
-
signedMessage,
|
|
416
|
-
"--directive-body-file",
|
|
417
|
-
directiveBodyPath,
|
|
418
|
-
"--signature-file",
|
|
419
|
-
signaturePath,
|
|
420
|
-
"--certificate-chain-file",
|
|
421
|
-
certificateChainPath,
|
|
422
|
-
);
|
|
423
|
-
|
|
424
|
-
expect(rollbackResult.exitCode).toBe(0);
|
|
425
|
-
expect(rollbackResult.stderr).toBe("");
|
|
426
|
-
expect(rollbackResult.stdout).toContain("Created rollback group");
|
|
427
|
-
expect(rollbackResult.stdout).toContain(signedCommitTime);
|
|
428
|
-
|
|
429
|
-
const updatesResponse = await cli.getAuthorized(`/api/updates?projectId=${cli.getProjectId()}`);
|
|
430
|
-
expect(updatesResponse.status).toBe(200);
|
|
431
|
-
const updatesBody = (await updatesResponse.json()) as {
|
|
432
|
-
items: {
|
|
433
|
-
message: string;
|
|
434
|
-
signature: string | null;
|
|
435
|
-
certificateChain: string | null;
|
|
436
|
-
directiveBody: string | null;
|
|
437
|
-
}[];
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
expect(updatesBody.items).toStrictEqual(
|
|
441
|
-
expect.arrayContaining([
|
|
442
|
-
expect.objectContaining({
|
|
443
|
-
message: signedMessage,
|
|
444
|
-
signature: 'sig="signed-cli-test", keyid="main", alg="rsa-v1_5_sha256"',
|
|
445
|
-
certificateChain:
|
|
446
|
-
"-----BEGIN CERTIFICATE-----\nSIGNED CLI TEST\n-----END CERTIFICATE-----",
|
|
447
|
-
directiveBody: JSON.stringify({
|
|
448
|
-
type: "rollBackToEmbedded",
|
|
449
|
-
parameters: { commitTime: signedCommitTime },
|
|
450
|
-
}),
|
|
451
|
-
}),
|
|
452
|
-
]),
|
|
453
|
-
);
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
it("uploads and deletes a distribution certificate", async () => {
|
|
457
|
-
const credentialFile = path.join(cli.getProjectDir(), "cli-uploaded-cert.p12");
|
|
458
|
-
const p12Password = "uploaded-password";
|
|
459
|
-
writeFileSync(
|
|
460
|
-
credentialFile,
|
|
461
|
-
generateSelfSignedP12(p12Password, "/OU=CLIE2ETEAM/CN=Apple Distribution: CLI E2E"),
|
|
462
|
-
);
|
|
463
|
-
|
|
464
|
-
const uploadResult = cli.runCli(
|
|
465
|
-
"credentials",
|
|
466
|
-
"upload",
|
|
467
|
-
"--platform",
|
|
468
|
-
"ios",
|
|
469
|
-
"--type",
|
|
470
|
-
"distribution-certificate",
|
|
471
|
-
"--name",
|
|
472
|
-
"CLI Uploaded Certificate",
|
|
473
|
-
"--file",
|
|
474
|
-
credentialFile,
|
|
475
|
-
"--password",
|
|
476
|
-
p12Password,
|
|
477
|
-
);
|
|
478
|
-
expect(uploadResult.exitCode).toBe(0);
|
|
479
|
-
expect(uploadResult.stderr).toBe("");
|
|
480
|
-
expect(uploadResult.stdout).toContain("Credential uploaded successfully.");
|
|
481
|
-
expect(uploadResult.stdout).toContain("CLI Uploaded Certificate");
|
|
482
|
-
const uploadedCredentialId = /^ID\s+([^\s]+)$/m.exec(uploadResult.stdout)?.[1];
|
|
483
|
-
expect(uploadedCredentialId).toBeDefined();
|
|
484
|
-
|
|
485
|
-
const listAfterUpload = cli.runCli("credentials", "list", "--platform", "ios");
|
|
486
|
-
expect(listAfterUpload.exitCode).toBe(0);
|
|
487
|
-
expect(listAfterUpload.stderr).toBe("");
|
|
488
|
-
expect(listAfterUpload.stdout).toContain(uploadedCredentialId!);
|
|
489
|
-
expect(listAfterUpload.stdout).toContain("distribution-certificate");
|
|
490
|
-
expect(listAfterUpload.stdout).toContain("ios");
|
|
491
|
-
|
|
492
|
-
const deleteResult = cli.runCli(
|
|
493
|
-
"credentials",
|
|
494
|
-
"delete",
|
|
495
|
-
uploadedCredentialId!,
|
|
496
|
-
"--platform",
|
|
497
|
-
"ios",
|
|
498
|
-
"--type",
|
|
499
|
-
"distribution-certificate",
|
|
500
|
-
);
|
|
501
|
-
expect(deleteResult.exitCode).toBe(0);
|
|
502
|
-
expect(deleteResult.stderr).toBe("");
|
|
503
|
-
expect(deleteResult.stdout).toContain(`Credential ${uploadedCredentialId} deleted.`);
|
|
504
|
-
|
|
505
|
-
const listAfterDelete = cli.runCli("credentials", "list", "--platform", "ios");
|
|
506
|
-
expect(listAfterDelete.exitCode).toBe(0);
|
|
507
|
-
expect(listAfterDelete.stderr).toBe("");
|
|
508
|
-
expect(listAfterDelete.stdout).not.toContain(uploadedCredentialId!);
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
it("manages rollout state, promotes an update, and deletes the promoted group", async () => {
|
|
512
|
-
expect(cliState.rollbackUpdateId).not.toBe("");
|
|
513
|
-
expect(cliState.rollbackGroupId).not.toBe("");
|
|
514
|
-
|
|
515
|
-
const setResult = cli.runCli(
|
|
516
|
-
"update",
|
|
517
|
-
"rollout",
|
|
518
|
-
"set",
|
|
519
|
-
cliState.rollbackUpdateId,
|
|
520
|
-
"--percentage",
|
|
521
|
-
"25",
|
|
522
|
-
);
|
|
523
|
-
expect(setResult.exitCode).toBe(0);
|
|
524
|
-
expect(setResult.stderr).toBe("");
|
|
525
|
-
expect(setResult.stdout).toContain(`Updated rollout for ${cliState.rollbackUpdateId} to 25%.`);
|
|
526
|
-
|
|
527
|
-
const listAfterSet = cli.runCli("update", "list", "--branch", "main");
|
|
528
|
-
expect(listAfterSet.exitCode).toBe(0);
|
|
529
|
-
expect(listAfterSet.stderr).toBe("");
|
|
530
|
-
expect(listAfterSet.stdout).toMatch(
|
|
531
|
-
new RegExp(
|
|
532
|
-
`^${escapeRegExp(cliState.rollbackUpdateId)}\\s+${escapeRegExp(cliState.rollbackGroupId)}\\s+main\\s+ios\\s+1\\.0\\.0\\s+25%\\s+yes\\s+.+$`,
|
|
533
|
-
"m",
|
|
534
|
-
),
|
|
535
|
-
);
|
|
536
|
-
|
|
537
|
-
const completeResult = cli.runCli("update", "rollout", "complete", cliState.rollbackUpdateId);
|
|
538
|
-
expect(completeResult.exitCode).toBe(0);
|
|
539
|
-
expect(completeResult.stderr).toBe("");
|
|
540
|
-
expect(completeResult.stdout).toContain(
|
|
541
|
-
`Completed rollout for ${cliState.rollbackUpdateId}. Current rollout is 100%.`,
|
|
542
|
-
);
|
|
543
|
-
|
|
544
|
-
const listAfterComplete = cli.runCli("update", "list", "--branch", "main");
|
|
545
|
-
expect(listAfterComplete.exitCode).toBe(0);
|
|
546
|
-
expect(listAfterComplete.stderr).toBe("");
|
|
547
|
-
expect(listAfterComplete.stdout).toMatch(
|
|
548
|
-
new RegExp(
|
|
549
|
-
`^${escapeRegExp(cliState.rollbackUpdateId)}\\s+${escapeRegExp(cliState.rollbackGroupId)}\\s+main\\s+ios\\s+1\\.0\\.0\\s+100%\\s+yes\\s+.+$`,
|
|
550
|
-
"m",
|
|
551
|
-
),
|
|
552
|
-
);
|
|
553
|
-
|
|
554
|
-
const revertResult = cli.runCli("update", "rollout", "revert", cliState.rollbackUpdateId);
|
|
555
|
-
expect(revertResult.exitCode).toBe(0);
|
|
556
|
-
expect(revertResult.stderr).toBe("");
|
|
557
|
-
expect(revertResult.stdout).toContain(
|
|
558
|
-
`Reverted rollout for ${cliState.rollbackUpdateId}. Current rollout is 0%.`,
|
|
559
|
-
);
|
|
560
|
-
|
|
561
|
-
const listAfterRevert = cli.runCli("update", "list", "--branch", "main");
|
|
562
|
-
expect(listAfterRevert.exitCode).toBe(0);
|
|
563
|
-
expect(listAfterRevert.stderr).toBe("");
|
|
564
|
-
expect(listAfterRevert.stdout).toMatch(
|
|
565
|
-
new RegExp(
|
|
566
|
-
`^${escapeRegExp(cliState.rollbackUpdateId)}\\s+${escapeRegExp(cliState.rollbackGroupId)}\\s+main\\s+ios\\s+1\\.0\\.0\\s+0%\\s+yes\\s+.+$`,
|
|
567
|
-
"m",
|
|
568
|
-
),
|
|
569
|
-
);
|
|
570
|
-
|
|
571
|
-
const targetName = `preview-${Date.now()}`;
|
|
572
|
-
seedDestinationChannel(targetName);
|
|
573
|
-
|
|
574
|
-
const promotableUpdate = await createPromotableUpdate();
|
|
575
|
-
|
|
576
|
-
const promoteResult = cli.runCli(
|
|
577
|
-
"update",
|
|
578
|
-
"promote",
|
|
579
|
-
promotableUpdate.id,
|
|
580
|
-
"--channel",
|
|
581
|
-
targetName,
|
|
582
|
-
);
|
|
583
|
-
expect(promoteResult.exitCode).toBe(0);
|
|
584
|
-
expect(promoteResult.stderr).toBe("");
|
|
585
|
-
expect(promoteResult.stdout).toContain(
|
|
586
|
-
`Promoted update ${promotableUpdate.id} to channel "${targetName}" as update `,
|
|
587
|
-
);
|
|
588
|
-
|
|
589
|
-
const promotedList = cli.runCli("update", "list", "--branch", targetName);
|
|
590
|
-
expect(promotedList.exitCode).toBe(0);
|
|
591
|
-
expect(promotedList.stderr).toBe("");
|
|
592
|
-
const promotedMatch = new RegExp(
|
|
593
|
-
`^([^\\s]+)\\s+([^\\s]+)\\s+${escapeRegExp(targetName)}\\s+ios\\s+1\\.0\\.0\\s+100%\\s+no\\s+.+$`,
|
|
594
|
-
"m",
|
|
595
|
-
).exec(promotedList.stdout);
|
|
596
|
-
expect(promotedMatch).toBeDefined();
|
|
597
|
-
|
|
598
|
-
const promotedGroupId = promotedMatch?.[2] ?? "";
|
|
599
|
-
expect(promotedGroupId).not.toBe("");
|
|
600
|
-
|
|
601
|
-
const deleteResult = cli.runCli("update", "delete", promotedGroupId);
|
|
602
|
-
expect(deleteResult.exitCode).toBe(0);
|
|
603
|
-
expect(deleteResult.stderr).toBe("");
|
|
604
|
-
expect(deleteResult.stdout).toContain(`Deleted 1 update(s) from group ${promotedGroupId}.`);
|
|
605
|
-
|
|
606
|
-
const finalPromotedList = cli.runCli("update", "list", "--branch", targetName);
|
|
607
|
-
expect(finalPromotedList.exitCode).toBe(0);
|
|
608
|
-
expect(finalPromotedList.stderr).toBe("");
|
|
609
|
-
expect(finalPromotedList.stdout).toContain("No updates found.");
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
it("promotes a signed update from the CLI using replacement signed files", async () => {
|
|
613
|
-
const targetName = `signed-preview-${Date.now()}`;
|
|
614
|
-
seedDestinationChannel(targetName);
|
|
615
|
-
|
|
616
|
-
const promotableUpdate = await createPromotableUpdate({
|
|
617
|
-
signed: {
|
|
618
|
-
manifestId: "cli-signed-source-manifest",
|
|
619
|
-
createdAt: "2026-04-14T10:00:00.000Z",
|
|
620
|
-
signature: 'sig="source-signature", keyid="main", alg="rsa-v1_5_sha256"',
|
|
621
|
-
certificateChain: "-----BEGIN CERTIFICATE-----\nSOURCE\n-----END CERTIFICATE-----",
|
|
622
|
-
},
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
const manifestBodyPath = path.join(
|
|
626
|
-
cli.getProjectDir(),
|
|
627
|
-
`signed-promote-manifest-${Date.now()}.json`,
|
|
628
|
-
);
|
|
629
|
-
const signaturePath = path.join(cli.getProjectDir(), `signed-promote-${Date.now()}.sig`);
|
|
630
|
-
const certificateChainPath = path.join(cli.getProjectDir(), `signed-promote-${Date.now()}.pem`);
|
|
631
|
-
const replacementManifestBody = JSON.stringify({
|
|
632
|
-
id: "cli-signed-promoted-manifest",
|
|
633
|
-
createdAt: "2026-04-15T10:00:00.000Z",
|
|
634
|
-
runtimeVersion: "1.0.0",
|
|
635
|
-
launchAsset: {
|
|
636
|
-
key: promotableUpdate.launchAssetKey,
|
|
637
|
-
hash: promotableUpdate.assetHash,
|
|
638
|
-
},
|
|
639
|
-
assets: [],
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
writeFileSync(manifestBodyPath, replacementManifestBody);
|
|
643
|
-
writeFileSync(
|
|
644
|
-
signaturePath,
|
|
645
|
-
'sig="replacement-signature", keyid="main", alg="rsa-v1_5_sha256"\n',
|
|
646
|
-
);
|
|
647
|
-
writeFileSync(
|
|
648
|
-
certificateChainPath,
|
|
649
|
-
"-----BEGIN CERTIFICATE-----\nREPLACEMENT\n-----END CERTIFICATE-----\n",
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
const promoteResult = cli.runCli(
|
|
653
|
-
"update",
|
|
654
|
-
"promote",
|
|
655
|
-
promotableUpdate.id,
|
|
656
|
-
"--channel",
|
|
657
|
-
targetName,
|
|
658
|
-
"--manifest-body-file",
|
|
659
|
-
manifestBodyPath,
|
|
660
|
-
"--signature-file",
|
|
661
|
-
signaturePath,
|
|
662
|
-
"--certificate-chain-file",
|
|
663
|
-
certificateChainPath,
|
|
664
|
-
);
|
|
665
|
-
expect(promoteResult.exitCode).toBe(0);
|
|
666
|
-
expect(promoteResult.stderr).toBe("");
|
|
667
|
-
|
|
668
|
-
const promotedUpdateId = /as update ([^\s.]+)\./.exec(promoteResult.stdout)?.[1];
|
|
669
|
-
expect(promotedUpdateId).toBeDefined();
|
|
670
|
-
|
|
671
|
-
const updatesResponse = await cli.getAuthorized(`/api/updates?projectId=${cli.getProjectId()}`);
|
|
672
|
-
expect(updatesResponse.status).toBe(200);
|
|
673
|
-
const updatesBody = (await updatesResponse.json()) as {
|
|
674
|
-
items: {
|
|
675
|
-
id: string;
|
|
676
|
-
branchId: string;
|
|
677
|
-
signature: string | null;
|
|
678
|
-
certificateChain: string | null;
|
|
679
|
-
manifestBody: string | null;
|
|
680
|
-
}[];
|
|
681
|
-
};
|
|
682
|
-
|
|
683
|
-
expect(updatesBody.items).toStrictEqual(
|
|
684
|
-
expect.arrayContaining([
|
|
685
|
-
expect.objectContaining({
|
|
686
|
-
id: promotedUpdateId,
|
|
687
|
-
signature: 'sig="replacement-signature", keyid="main", alg="rsa-v1_5_sha256"',
|
|
688
|
-
certificateChain: "-----BEGIN CERTIFICATE-----\nREPLACEMENT\n-----END CERTIFICATE-----",
|
|
689
|
-
manifestBody: replacementManifestBody,
|
|
690
|
-
}),
|
|
691
|
-
]),
|
|
692
|
-
);
|
|
693
|
-
});
|
|
694
|
-
});
|