@better-update/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/index.mjs +5190 -0
  2. package/dist/index.mjs.map +1 -0
  3. package/package.json +12 -9
  4. package/CHANGELOG.md +0 -58
  5. package/oxlint.config.ts +0 -6
  6. package/src/app-layer.ts +0 -29
  7. package/src/application/build-workflow.ts +0 -222
  8. package/src/application/command-exit.ts +0 -13
  9. package/src/application/login.ts +0 -87
  10. package/src/application/update-promote.ts +0 -88
  11. package/src/application/update-publish.ts +0 -402
  12. package/src/application/update-rollback.ts +0 -275
  13. package/src/commands/analytics/adoption.ts +0 -40
  14. package/src/commands/analytics/channels.ts +0 -35
  15. package/src/commands/analytics/helpers.ts +0 -3
  16. package/src/commands/analytics/index.ts +0 -13
  17. package/src/commands/analytics/platforms.ts +0 -39
  18. package/src/commands/analytics/updates.ts +0 -35
  19. package/src/commands/audit-logs/helpers.ts +0 -3
  20. package/src/commands/audit-logs/index.ts +0 -8
  21. package/src/commands/audit-logs/list.ts +0 -66
  22. package/src/commands/branches.ts +0 -70
  23. package/src/commands/build/android.ts +0 -129
  24. package/src/commands/build/index.ts +0 -63
  25. package/src/commands/build/ios.ts +0 -199
  26. package/src/commands/build/reserve-and-upload.test.ts +0 -263
  27. package/src/commands/build/reserve-and-upload.ts +0 -160
  28. package/src/commands/build/run-step.ts +0 -131
  29. package/src/commands/builds/compatibility-matrix.ts +0 -48
  30. package/src/commands/builds/delete.ts +0 -15
  31. package/src/commands/builds/get.ts +0 -34
  32. package/src/commands/builds/helpers.ts +0 -3
  33. package/src/commands/builds/index.ts +0 -20
  34. package/src/commands/builds/install-link.ts +0 -20
  35. package/src/commands/builds/list.ts +0 -38
  36. package/src/commands/channels/create.ts +0 -37
  37. package/src/commands/channels/delete.ts +0 -15
  38. package/src/commands/channels/helpers.ts +0 -18
  39. package/src/commands/channels/index.ts +0 -24
  40. package/src/commands/channels/list.ts +0 -38
  41. package/src/commands/channels/pause.ts +0 -15
  42. package/src/commands/channels/resume.ts +0 -15
  43. package/src/commands/channels/rollout/complete.ts +0 -17
  44. package/src/commands/channels/rollout/create.ts +0 -36
  45. package/src/commands/channels/rollout/index.ts +0 -11
  46. package/src/commands/channels/rollout/revert.ts +0 -17
  47. package/src/commands/channels/rollout/update.ts +0 -23
  48. package/src/commands/channels/update.ts +0 -32
  49. package/src/commands/credentials/delete.ts +0 -24
  50. package/src/commands/credentials/index.ts +0 -10
  51. package/src/commands/credentials/list.ts +0 -33
  52. package/src/commands/credentials/upload.ts +0 -91
  53. package/src/commands/env/delete.ts +0 -35
  54. package/src/commands/env/export.ts +0 -27
  55. package/src/commands/env/get.ts +0 -25
  56. package/src/commands/env/helpers.ts +0 -13
  57. package/src/commands/env/import.ts +0 -31
  58. package/src/commands/env/index.ts +0 -24
  59. package/src/commands/env/list.ts +0 -44
  60. package/src/commands/env/pull.ts +0 -27
  61. package/src/commands/env/set.ts +0 -42
  62. package/src/commands/fingerprint/compare.ts +0 -25
  63. package/src/commands/fingerprint/generate.ts +0 -18
  64. package/src/commands/fingerprint/index.ts +0 -9
  65. package/src/commands/init.ts +0 -35
  66. package/src/commands/login.ts +0 -13
  67. package/src/commands/logout.ts +0 -12
  68. package/src/commands/projects.ts +0 -84
  69. package/src/commands/status.ts +0 -48
  70. package/src/commands/update/delete.ts +0 -15
  71. package/src/commands/update/helpers.ts +0 -22
  72. package/src/commands/update/index.ts +0 -22
  73. package/src/commands/update/list.ts +0 -60
  74. package/src/commands/update/promote.ts +0 -30
  75. package/src/commands/update/publish.ts +0 -94
  76. package/src/commands/update/rollback.ts +0 -42
  77. package/src/commands/update/rollout/complete.ts +0 -17
  78. package/src/commands/update/rollout/index.ts +0 -10
  79. package/src/commands/update/rollout/revert.ts +0 -17
  80. package/src/commands/update/rollout/set.ts +0 -23
  81. package/src/index.ts +0 -53
  82. package/src/lib/android-keystore.test.ts +0 -114
  83. package/src/lib/android-keystore.ts +0 -76
  84. package/src/lib/android-signing-gradle.test.ts +0 -95
  85. package/src/lib/android-signing-gradle.ts +0 -52
  86. package/src/lib/app-json.ts +0 -81
  87. package/src/lib/apple-auth.test.ts +0 -402
  88. package/src/lib/apple-auth.ts +0 -132
  89. package/src/lib/artifact-finder.test.ts +0 -195
  90. package/src/lib/artifact-finder.ts +0 -122
  91. package/src/lib/browser-login.test.ts +0 -88
  92. package/src/lib/browser-login.ts +0 -193
  93. package/src/lib/build-profile.test.ts +0 -290
  94. package/src/lib/build-profile.ts +0 -234
  95. package/src/lib/cli-schemas.ts +0 -39
  96. package/src/lib/command-errors.ts +0 -60
  97. package/src/lib/credentials-downloader.ts +0 -181
  98. package/src/lib/credentials-manager.ts +0 -354
  99. package/src/lib/env-exporter.test.ts +0 -96
  100. package/src/lib/env-exporter.ts +0 -28
  101. package/src/lib/exit-codes.ts +0 -82
  102. package/src/lib/expo-config.ts +0 -130
  103. package/src/lib/expo-export.test.ts +0 -94
  104. package/src/lib/expo-export.ts +0 -281
  105. package/src/lib/fingerprint.ts +0 -67
  106. package/src/lib/format-error.ts +0 -22
  107. package/src/lib/git-context.ts +0 -56
  108. package/src/lib/gradle-config.ts +0 -126
  109. package/src/lib/ios-export-options.test.ts +0 -98
  110. package/src/lib/ios-export-options.ts +0 -62
  111. package/src/lib/ios-keychain.ts +0 -181
  112. package/src/lib/ios-provisioning.test.ts +0 -115
  113. package/src/lib/ios-provisioning.ts +0 -179
  114. package/src/lib/output.ts +0 -32
  115. package/src/lib/pkcs12.ts +0 -73
  116. package/src/lib/plist.ts +0 -39
  117. package/src/lib/post-build-validation.ts +0 -146
  118. package/src/lib/presigned-upload.test.ts +0 -140
  119. package/src/lib/presigned-upload.ts +0 -35
  120. package/src/lib/record.ts +0 -5
  121. package/src/lib/resolve-named-resource.ts +0 -24
  122. package/src/lib/runtime-version.test.ts +0 -119
  123. package/src/lib/runtime-version.ts +0 -62
  124. package/src/lib/sha256.test.ts +0 -108
  125. package/src/lib/sha256.ts +0 -80
  126. package/src/lib/signed-payloads.test.ts +0 -181
  127. package/src/lib/signed-payloads.ts +0 -164
  128. package/src/lib/string-utils.ts +0 -4
  129. package/src/lib/temp-dir.ts +0 -14
  130. package/src/lib/test-utils.ts +0 -13
  131. package/src/lib/update-platforms.test.ts +0 -45
  132. package/src/lib/update-platforms.ts +0 -19
  133. package/src/lib/xcpretty-formatter.ts +0 -21
  134. package/src/services/api-client.ts +0 -42
  135. package/src/services/apple-session-store.ts +0 -100
  136. package/src/services/auth-store.ts +0 -85
  137. package/src/services/cli-runtime.ts +0 -46
  138. package/src/services/config-store.ts +0 -108
  139. package/src/services/presigned-upload.ts +0 -84
  140. package/src/services/update-asset-uploader.ts +0 -72
  141. package/src/types/keychain.d.ts +0 -22
  142. package/tests/e2e/build.test.ts +0 -270
  143. package/tests/e2e/commands.test.ts +0 -694
  144. package/tests/e2e/ota-lifecycle.test.ts +0 -275
  145. package/tests/e2e/publish.test.ts +0 -150
  146. package/tests/helpers/cli-e2e.ts +0 -426
  147. package/tests/helpers/pty-driver.ts +0 -142
  148. package/tests/interactive/harness/provider-prompt.ts +0 -54
  149. package/tests/interactive/login.test.ts +0 -47
  150. package/tests/interactive/provider-select.test.ts +0 -59
  151. package/tsconfig.json +0 -7
  152. package/vitest.config.ts +0 -38
@@ -1,426 +0,0 @@
1
- import { execSync, spawnSync } from "node:child_process";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { setTimeout as sleep } from "node:timers/promises";
6
-
7
- import { applyProcessEnv, createServerE2EEnvironment } from "../../../server/tests/helpers/e2e-env";
8
-
9
- import type { unstable_startWorker } from "../../../server/node_modules/wrangler";
10
-
11
- const CLI_DIR = path.resolve(import.meta.dirname, "../..");
12
- const SERVER_DIR = path.resolve(import.meta.dirname, "../../../server");
13
-
14
- export interface SetupCliE2EOptions {
15
- /** Use an existing directory as the CLI project root instead of creating a temp dir. */
16
- readonly projectDir?: string;
17
- /** Custom app.json template. ScopeKey and project name are derived from expo.owner/slug/name. */
18
- readonly appJsonTemplate?: Record<string, unknown>;
19
- }
20
-
21
- const defaultAppJsonTemplate = {
22
- expo: {
23
- name: "CLI E2E App",
24
- slug: "cli-e2e-app",
25
- owner: "cli-e2e",
26
- version: "1.0.0",
27
- runtimeVersion: "1.0.0",
28
- ios: {
29
- bundleIdentifier: "com.example.cli",
30
- buildNumber: "1",
31
- },
32
- android: {
33
- package: "com.example.cli",
34
- versionCode: 1,
35
- },
36
- extra: {
37
- betterUpdate: {
38
- profiles: {
39
- production: {
40
- environment: "production",
41
- ios: { distribution: "ad-hoc" },
42
- android: { distribution: "direct", format: "apk" },
43
- },
44
- },
45
- },
46
- },
47
- },
48
- };
49
-
50
- const parseCookies = (response: Response): string => {
51
- const setCookie = response.headers.getSetCookie();
52
- return setCookie
53
- .map((cookie) => cookie.split(";")[0])
54
- .filter(Boolean)
55
- .join("; ");
56
- };
57
-
58
- const sqlString = (value: string) => `'${value.replaceAll("'", "''")}'`;
59
-
60
- const getNodeErrorCode = (error: unknown): string | undefined => {
61
- if (!(error instanceof Error)) {
62
- return undefined;
63
- }
64
-
65
- const directCode = (error as NodeJS.ErrnoException).code;
66
- if (typeof directCode === "string") {
67
- return directCode;
68
- }
69
-
70
- const { cause } = error as Error & { readonly cause?: unknown };
71
- if (typeof cause !== "object" || cause === null) {
72
- return undefined;
73
- }
74
-
75
- const nestedCode = (cause as NodeJS.ErrnoException).code;
76
- return typeof nestedCode === "string" ? nestedCode : undefined;
77
- };
78
-
79
- const isRetryableFetchError = (error: unknown) => {
80
- const code = getNodeErrorCode(error);
81
- return code !== undefined && ["ECONNRESET", "EPIPE", "UND_ERR_SOCKET"].includes(code);
82
- };
83
-
84
- export interface CliCommandResult {
85
- readonly stdout: string;
86
- readonly stderr: string;
87
- readonly exitCode: number;
88
- }
89
-
90
- export interface CliE2EContext {
91
- readonly getBaseUrl: () => string;
92
- readonly getProjectDir: () => string;
93
- readonly getProjectId: () => string;
94
- readonly readAppJson: () => Record<string, unknown>;
95
- readonly runCli: (...args: readonly string[]) => CliCommandResult;
96
- readonly seedSql: (sql: string) => void;
97
- readonly post: (
98
- path: string,
99
- body: unknown,
100
- headers?: Record<string, string>,
101
- ) => Promise<Response>;
102
- readonly get: (path: string, headers?: Record<string, string>) => Promise<Response>;
103
- readonly getAuthorized: (path: string, headers?: Record<string, string>) => Promise<Response>;
104
- readonly postAuthorized: (
105
- path: string,
106
- body: unknown,
107
- headers?: Record<string, string>,
108
- ) => Promise<Response>;
109
- readonly patchAuthorized: (
110
- path: string,
111
- body: unknown,
112
- headers?: Record<string, string>,
113
- ) => Promise<Response>;
114
- readonly deleteAuthorized: (
115
- path: string,
116
- body: unknown,
117
- headers?: Record<string, string>,
118
- ) => Promise<Response>;
119
- }
120
-
121
- export const setupCliE2E = (persistDir: string, options?: SetupCliE2EOptions): CliE2EContext => {
122
- const template = options?.appJsonTemplate ?? defaultAppJsonTemplate;
123
- const expoConfig = (template as { expo?: Record<string, unknown> }).expo ?? {};
124
- const slugRaw = expoConfig["slug"];
125
- const slug = typeof slugRaw === "string" ? slugRaw : "cli-e2e-app";
126
- const nameRaw = expoConfig["name"];
127
- const projectName = `${typeof nameRaw === "string" ? nameRaw : "E2E"} Project`;
128
- const useExternalProjectDir = options?.projectDir !== undefined;
129
-
130
- const state = {
131
- worker: null as Awaited<ReturnType<typeof unstable_startWorker>> | null,
132
- baseUrl: "",
133
- cookies: "",
134
- organizationId: "",
135
- projectId: "",
136
- apiKey: "",
137
- projectDir: "",
138
- homeDir: "",
139
- restoreProcessEnv: undefined as (() => void) | undefined,
140
- originalAppJson: undefined as string | undefined,
141
- };
142
-
143
- const persistPath = path.resolve(SERVER_DIR, persistDir);
144
- const persistArg = path.relative(SERVER_DIR, persistPath) || ".";
145
- const seedFileId = persistDir.replaceAll(/[^a-zA-Z0-9]+/gu, "-");
146
- const seedFile = path.resolve(SERVER_DIR, `.wrangler/seed-${seedFileId}.sql`);
147
-
148
- const post = async (requestPath: string, body: unknown, headers?: Record<string, string>) =>
149
- requestWithRetry(async () =>
150
- fetch(`${state.baseUrl}${requestPath}`, {
151
- method: "POST",
152
- headers: { "content-type": "application/json", ...headers },
153
- body: JSON.stringify(body),
154
- }),
155
- );
156
-
157
- const get = async (requestPath: string, headers?: Record<string, string>) =>
158
- requestWithRetry(async () =>
159
- fetch(`${state.baseUrl}${requestPath}`, headers ? { headers } : {}),
160
- );
161
-
162
- const patch = async (requestPath: string, body: unknown, headers?: Record<string, string>) =>
163
- requestWithRetry(async () =>
164
- fetch(`${state.baseUrl}${requestPath}`, {
165
- method: "PATCH",
166
- headers: { "content-type": "application/json", ...headers },
167
- body: JSON.stringify(body),
168
- }),
169
- );
170
-
171
- const del = async (requestPath: string, body: unknown, headers?: Record<string, string>) =>
172
- requestWithRetry(async () =>
173
- fetch(`${state.baseUrl}${requestPath}`, {
174
- method: "DELETE",
175
- headers: { "content-type": "application/json", ...headers },
176
- body: JSON.stringify(body),
177
- }),
178
- );
179
-
180
- const seedSql = (sql: string) => {
181
- writeFileSync(seedFile, sql);
182
- try {
183
- execSync(
184
- `bunx wrangler d1 execute DB --local --persist-to ${persistArg} --file ${seedFile}`,
185
- {
186
- cwd: SERVER_DIR,
187
- stdio: "pipe",
188
- },
189
- );
190
- } finally {
191
- rmSync(seedFile, { force: true });
192
- }
193
- };
194
-
195
- const requestWithRetry = async (run: () => Promise<Response>): Promise<Response> => {
196
- const maxAttempts = 4;
197
-
198
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
199
- try {
200
- return await run();
201
- } catch (error) {
202
- if (!isRetryableFetchError(error) || attempt === maxAttempts) {
203
- throw error;
204
- }
205
-
206
- await sleep(attempt * 100);
207
- }
208
- }
209
-
210
- throw new Error("requestWithRetry exhausted unexpectedly");
211
- };
212
-
213
- const writeAppJson = () => {
214
- writeFileSync(
215
- path.join(state.projectDir, "app.json"),
216
- `${JSON.stringify(template, null, 2)}\n`,
217
- );
218
- };
219
-
220
- const runCli = (...args: readonly string[]): CliCommandResult => {
221
- const result = spawnSync("bun", [path.resolve(CLI_DIR, "src/index.ts"), ...args], {
222
- cwd: state.projectDir,
223
- env: {
224
- ...process.env,
225
- HOME: state.homeDir,
226
- BETTER_UPDATE_URL: state.baseUrl,
227
- BETTER_UPDATE_TOKEN: state.apiKey,
228
- FORCE_COLOR: "0",
229
- NO_COLOR: "1",
230
- },
231
- encoding: "utf8",
232
- });
233
-
234
- return {
235
- stdout: result.stdout,
236
- stderr: result.stderr,
237
- exitCode: result.status ?? 1,
238
- };
239
- };
240
-
241
- beforeAll(async () => {
242
- rmSync(persistPath, { recursive: true, force: true });
243
- const e2eEnv = createServerE2EEnvironment({ projectRoot: SERVER_DIR });
244
- state.restoreProcessEnv = applyProcessEnv(e2eEnv.processOverrides);
245
-
246
- execSync(`bunx wrangler d1 migrations apply DB --local --persist-to ${persistArg}`, {
247
- cwd: SERVER_DIR,
248
- env: e2eEnv.wranglerEnv,
249
- stdio: "pipe",
250
- });
251
-
252
- const originalCwd = process.cwd();
253
- process.chdir(SERVER_DIR);
254
- try {
255
- const { unstable_startWorker } = await import("../../../server/node_modules/wrangler");
256
- state.worker = await unstable_startWorker({
257
- config: path.resolve(SERVER_DIR, "wrangler.jsonc"),
258
- envFiles: [],
259
- bindings: e2eEnv.workerBindings,
260
- build: { nodejsCompatMode: "v2" },
261
- dev: {
262
- server: { port: 0 },
263
- inspector: false,
264
- logLevel: "error",
265
- persist: persistPath,
266
- },
267
- });
268
- } finally {
269
- process.chdir(originalCwd);
270
- }
271
-
272
- const url = await state.worker.url;
273
- state.baseUrl = url.href.replace(/\/$/, "");
274
-
275
- state.homeDir = mkdtempSync(path.join(os.tmpdir(), "better-update-cli-home-"));
276
-
277
- if (useExternalProjectDir) {
278
- state.projectDir = options.projectDir!;
279
- const appJsonPath = path.join(state.projectDir, "app.json");
280
- if (existsSync(appJsonPath)) {
281
- state.originalAppJson = readFileSync(appJsonPath, "utf8");
282
- }
283
- } else {
284
- state.projectDir = mkdtempSync(path.join(os.tmpdir(), "better-update-cli-project-"));
285
- }
286
- writeAppJson();
287
-
288
- const signUpResponse = await post("/api/auth/sign-up/email", {
289
- name: "CLI E2E User",
290
- email: "cli-e2e@example.com",
291
- password: "SecureP@ss123",
292
- });
293
- expect(signUpResponse.status).toBe(200);
294
- state.cookies = parseCookies(signUpResponse);
295
-
296
- const createOrgResponse = await post(
297
- "/api/auth/organization/create",
298
- { name: "CLI Org", slug: "cli-org" },
299
- { cookie: state.cookies },
300
- );
301
- expect(createOrgResponse.status).toBe(200);
302
- const createOrgBody = await createOrgResponse.json();
303
- state.organizationId = createOrgBody.id;
304
- state.cookies = parseCookies(createOrgResponse) || state.cookies;
305
-
306
- const setActiveResponse = await post(
307
- "/api/auth/organization/set-active",
308
- { organizationId: state.organizationId },
309
- { cookie: state.cookies },
310
- );
311
- expect(setActiveResponse.status).toBe(200);
312
- state.cookies = parseCookies(setActiveResponse) || state.cookies;
313
-
314
- const createProjectResponse = await post(
315
- "/api/projects",
316
- { name: projectName, slug },
317
- { cookie: state.cookies },
318
- );
319
- expect(createProjectResponse.status).toBe(201);
320
- const createProjectBody = await createProjectResponse.json();
321
- state.projectId = createProjectBody.id;
322
-
323
- const createKeyResponse = await post(
324
- "/api/auth/api-key/create",
325
- { name: "cli-e2e-key", organizationId: state.organizationId },
326
- { cookie: state.cookies },
327
- );
328
- expect(createKeyResponse.status).toBe(200);
329
- const createKeyBody = await createKeyResponse.json();
330
- state.apiKey = createKeyBody.key;
331
-
332
- const createBranchResponse = await post(
333
- "/api/branches",
334
- { projectId: state.projectId, name: "main" },
335
- { cookie: state.cookies },
336
- );
337
- expect(createBranchResponse.status).toBe(201);
338
-
339
- const createEnvVarResponse = await post(
340
- "/api/env-vars",
341
- {
342
- projectId: state.projectId,
343
- environment: "production",
344
- key: "APP_SECRET",
345
- value: "super-secret",
346
- visibility: "secret",
347
- },
348
- { cookie: state.cookies },
349
- );
350
- expect(createEnvVarResponse.status).toBe(201);
351
-
352
- seedSql(`
353
- INSERT INTO "builds" (
354
- "id", "project_id", "platform", "profile", "distribution", "runtime_version",
355
- "app_version", "build_number", "bundle_id", "git_ref", "git_commit",
356
- "message", "metadata_json", "created_at"
357
- )
358
- VALUES (
359
- 'cli-build-1',
360
- ${sqlString(state.projectId)},
361
- 'ios',
362
- 'production',
363
- 'ad-hoc',
364
- '1.0.0',
365
- '1.0.0',
366
- '1',
367
- 'com.example.cli',
368
- 'main',
369
- 'abcdef1',
370
- 'CLI seeded build',
371
- '{}',
372
- '2024-04-01T00:00:00Z'
373
- );
374
-
375
- INSERT INTO "build_artifacts" (
376
- "build_id", "r2_key", "format", "content_type", "byte_size", "sha256", "created_at"
377
- )
378
- VALUES (
379
- 'cli-build-1',
380
- 'builds/${state.organizationId}/${state.projectId}/cli-build-1.ipa',
381
- 'ipa',
382
- 'application/octet-stream',
383
- 1024,
384
- 'cli-build-sha',
385
- '2024-04-01T00:00:00Z'
386
- );
387
- `);
388
- });
389
-
390
- afterAll(async () => {
391
- await state.worker?.dispose();
392
- state.restoreProcessEnv?.();
393
- rmSync(persistPath, { recursive: true, force: true });
394
- if (useExternalProjectDir) {
395
- if (state.originalAppJson !== undefined) {
396
- writeFileSync(path.join(state.projectDir, "app.json"), state.originalAppJson);
397
- }
398
- } else {
399
- rmSync(state.projectDir, { recursive: true, force: true });
400
- }
401
- rmSync(state.homeDir, { recursive: true, force: true });
402
- });
403
-
404
- return {
405
- getBaseUrl: () => state.baseUrl,
406
- getProjectDir: () => state.projectDir,
407
- getProjectId: () => state.projectId,
408
- readAppJson: () =>
409
- JSON.parse(readFileSync(path.join(state.projectDir, "app.json"), "utf8")) as Record<
410
- string,
411
- unknown
412
- >,
413
- runCli,
414
- seedSql,
415
- post,
416
- get,
417
- getAuthorized: async (requestPath, headers) =>
418
- get(requestPath, { authorization: `Bearer ${state.apiKey}`, ...headers }),
419
- postAuthorized: async (requestPath, body, headers) =>
420
- post(requestPath, body, { authorization: `Bearer ${state.apiKey}`, ...headers }),
421
- patchAuthorized: async (requestPath, body, headers) =>
422
- patch(requestPath, body, { authorization: `Bearer ${state.apiKey}`, ...headers }),
423
- deleteAuthorized: async (requestPath, body, headers) =>
424
- del(requestPath, body, { authorization: `Bearer ${state.apiKey}`, ...headers }),
425
- };
426
- };
@@ -1,142 +0,0 @@
1
- import { setTimeout as sleep } from "node:timers/promises";
2
-
3
- import { spawn } from "node-pty";
4
-
5
- import type { IPty } from "node-pty";
6
-
7
- export interface PtySpawnOptions {
8
- readonly cwd?: string;
9
- readonly env?: NodeJS.ProcessEnv;
10
- readonly cols?: number;
11
- readonly rows?: number;
12
- }
13
-
14
- export interface PtyDriver {
15
- readonly output: () => string;
16
- readonly stripped: () => string;
17
- readonly expect: (
18
- pattern: string | RegExp,
19
- options?: { readonly timeoutMs?: number },
20
- ) => Promise<void>;
21
- readonly send: (text: string) => void;
22
- readonly enter: () => void;
23
- readonly down: (count?: number) => void;
24
- readonly up: (count?: number) => void;
25
- readonly waitExit: (options?: { readonly timeoutMs?: number }) => Promise<number>;
26
- readonly kill: () => void;
27
- }
28
-
29
- const ANSI_REGEX =
30
- // eslint-disable-next-line no-control-regex -- stripping ANSI escapes
31
- /[\u001B\u009B][[()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[\dA-ORZcf-ntqry=><]/g;
32
-
33
- export const stripAnsi = (input: string) => input.replace(ANSI_REGEX, "");
34
-
35
- export const spawnPty = (
36
- command: string,
37
- args: readonly string[],
38
- options?: PtySpawnOptions,
39
- ): PtyDriver => {
40
- const ptyProcess: IPty = spawn(command, [...args], {
41
- cols: options?.cols ?? 120,
42
- rows: options?.rows ?? 40,
43
- cwd: options?.cwd ?? process.cwd(),
44
- env: { ...process.env, ...options?.env } as Record<string, string>,
45
- name: "xterm-256color",
46
- });
47
-
48
- let buffer = "";
49
- let exitCode: number | null = null;
50
- const waiters: (() => void)[] = [];
51
-
52
- const drainWaiters = () => {
53
- const pending = waiters.splice(0);
54
- for (const waiter of pending) {
55
- waiter();
56
- }
57
- };
58
-
59
- ptyProcess.onData((chunk) => {
60
- buffer += chunk;
61
- drainWaiters();
62
- });
63
-
64
- ptyProcess.onExit(({ exitCode: code }) => {
65
- exitCode = code;
66
- drainWaiters();
67
- });
68
-
69
- const waitFor = async (timeoutMs: number, predicate: () => boolean) =>
70
- new Promise<void>((resolve, reject) => {
71
- if (predicate()) {
72
- resolve();
73
- return;
74
- }
75
- let settled = false;
76
- const tick = () => {
77
- if (settled) {
78
- return;
79
- }
80
- if (predicate()) {
81
- settled = true;
82
- clearTimeout(timer);
83
- resolve();
84
- } else if (exitCode === null) {
85
- waiters.push(tick);
86
- } else {
87
- settled = true;
88
- clearTimeout(timer);
89
- reject(
90
- new Error(
91
- `pty exited (code=${exitCode}) before predicate matched. Buffer:\n${stripAnsi(buffer)}`,
92
- ),
93
- );
94
- }
95
- };
96
- const timer = setTimeout(() => {
97
- if (settled) {
98
- return;
99
- }
100
- settled = true;
101
- reject(new Error(`pty wait timed out after ${timeoutMs}ms. Buffer:\n${stripAnsi(buffer)}`));
102
- }, timeoutMs);
103
- waiters.push(tick);
104
- });
105
-
106
- return {
107
- output: () => buffer,
108
- stripped: () => stripAnsi(buffer),
109
- expect: async (pattern, { timeoutMs = 5000 } = {}) => {
110
- const matcher =
111
- typeof pattern === "string"
112
- ? (text: string) => text.includes(pattern)
113
- : (text: string) => pattern.test(text);
114
- await waitFor(timeoutMs, () => matcher(stripAnsi(buffer)));
115
- },
116
- send: (text) => {
117
- ptyProcess.write(text);
118
- },
119
- enter: () => {
120
- ptyProcess.write("\r");
121
- },
122
- down: (count = 1) => {
123
- for (let index = 0; index < count; index += 1) {
124
- ptyProcess.write("\u001B[B");
125
- }
126
- },
127
- up: (count = 1) => {
128
- for (let index = 0; index < count; index += 1) {
129
- ptyProcess.write("\u001B[A");
130
- }
131
- },
132
- waitExit: async ({ timeoutMs = 10_000 } = {}) => {
133
- await waitFor(timeoutMs, () => exitCode !== null);
134
- // Give the pty a beat to flush any trailing bytes.
135
- await sleep(10);
136
- return exitCode ?? -1;
137
- },
138
- kill: () => {
139
- ptyProcess.kill();
140
- },
141
- };
142
- };
@@ -1,54 +0,0 @@
1
- #!/usr/bin/env bun
2
- import process from "node:process";
3
-
4
- import { BunContext, BunRuntime } from "@effect/platform-bun";
5
- import { Effect, Layer } from "effect";
6
-
7
- import type { Session } from "@expo/apple-utils";
8
- // eslint-disable-next-line import-plugin/no-namespace -- harness casts a stub as `typeof AppleUtils` (whole module shape) to satisfy resolveProvider's injected module param
9
- import type * as AppleUtils from "@expo/apple-utils";
10
-
11
- import { resolveProvider } from "../../../src/lib/apple-auth";
12
- import { CliRuntimeLive } from "../../../src/services/cli-runtime";
13
-
14
- // Force the prompt branch: ignore any APPLE_PROVIDER_ID from the host shell.
15
- delete process.env["APPLE_PROVIDER_ID"];
16
-
17
- const fakeAppleUtils = {
18
- Session: {
19
- setSessionProviderIdAsync: async (_id: number) => null,
20
- },
21
- } as unknown as typeof AppleUtils;
22
-
23
- const providers: readonly Session.SessionProvider[] = [
24
- {
25
- providerId: 10,
26
- publicProviderId: "pub-10",
27
- name: "Org Alpha",
28
- contentTypes: ["SOFTWARE"],
29
- subType: "ORGANIZATION",
30
- },
31
- {
32
- providerId: 20,
33
- publicProviderId: "pub-20",
34
- name: "Org Beta",
35
- contentTypes: ["SOFTWARE"],
36
- subType: "ORGANIZATION",
37
- },
38
- {
39
- providerId: 30,
40
- publicProviderId: "pub-30",
41
- name: "Org Gamma",
42
- contentTypes: ["SOFTWARE"],
43
- subType: "ORGANIZATION",
44
- },
45
- ];
46
-
47
- const program = Effect.gen(function* () {
48
- const result = yield* resolveProvider(fakeAppleUtils, providers, undefined, undefined);
49
- // Distinctive marker so the PTY test can extract the JSON past any rendered prompt text.
50
- // eslint-disable-next-line eslint/no-console -- interactive PTY harness prints a parseable marker to stdout; Effect.Console adds formatting that breaks the parser
51
- console.log(`RESULT=${JSON.stringify(result)}`);
52
- });
53
-
54
- program.pipe(Effect.provide(Layer.mergeAll(BunContext.layer, CliRuntimeLive)), BunRuntime.runMain);
@@ -1,47 +0,0 @@
1
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
-
5
- import { spawnPty } from "../helpers/pty-driver";
6
-
7
- const CLI_ENTRY = path.resolve(import.meta.dirname, "../../src/index.ts");
8
-
9
- describe("login --api-key (interactive PoC)", () => {
10
- let homeDir: string;
11
-
12
- beforeEach(() => {
13
- homeDir = mkdtempSync(path.join(os.tmpdir(), "better-update-pty-home-"));
14
- });
15
-
16
- afterEach(() => {
17
- rmSync(homeDir, { recursive: true, force: true });
18
- });
19
-
20
- it("prompts for API key, stores token on enter", async () => {
21
- const driver = spawnPty("bun", [CLI_ENTRY, "login", "--api-key"], {
22
- env: {
23
- HOME: homeDir,
24
- FORCE_COLOR: "0",
25
- NO_COLOR: "1",
26
- // Layer construction currently yields apiClient eagerly via UpdateAssetUploaderLive,
27
- // So every CLI invocation resolves a token. Pass a placeholder — login still writes
28
- // The prompted token to auth.json, which is what we assert.
29
- BETTER_UPDATE_TOKEN: "startup-placeholder",
30
- },
31
- });
32
-
33
- await driver.expect(/Paste your API key/, { timeoutMs: 15_000 });
34
-
35
- driver.send("pk_test_abc123");
36
- driver.enter();
37
-
38
- await driver.expect("Logged in successfully", { timeoutMs: 10_000 });
39
- const code = await driver.waitExit({ timeoutMs: 5000 });
40
- expect(code).toBe(0);
41
-
42
- const authJson = JSON.parse(
43
- readFileSync(path.join(homeDir, ".better-update/auth.json"), "utf8"),
44
- ) as { token: string };
45
- expect(authJson.token).toBe("pk_test_abc123");
46
- });
47
- });