@better-update/cli 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/index.js +5319 -0
  2. package/dist/index.js.map +1 -0
  3. package/package.json +12 -9
  4. package/CHANGELOG.md +0 -58
  5. package/oxlint.config.ts +0 -6
  6. package/src/app-layer.ts +0 -29
  7. package/src/application/build-workflow.ts +0 -222
  8. package/src/application/command-exit.ts +0 -13
  9. package/src/application/login.ts +0 -87
  10. package/src/application/update-promote.ts +0 -88
  11. package/src/application/update-publish.ts +0 -402
  12. package/src/application/update-rollback.ts +0 -275
  13. package/src/commands/analytics/adoption.ts +0 -40
  14. package/src/commands/analytics/channels.ts +0 -35
  15. package/src/commands/analytics/helpers.ts +0 -3
  16. package/src/commands/analytics/index.ts +0 -13
  17. package/src/commands/analytics/platforms.ts +0 -39
  18. package/src/commands/analytics/updates.ts +0 -35
  19. package/src/commands/audit-logs/helpers.ts +0 -3
  20. package/src/commands/audit-logs/index.ts +0 -8
  21. package/src/commands/audit-logs/list.ts +0 -66
  22. package/src/commands/branches.ts +0 -70
  23. package/src/commands/build/android.ts +0 -129
  24. package/src/commands/build/index.ts +0 -63
  25. package/src/commands/build/ios.ts +0 -199
  26. package/src/commands/build/reserve-and-upload.test.ts +0 -263
  27. package/src/commands/build/reserve-and-upload.ts +0 -160
  28. package/src/commands/build/run-step.ts +0 -131
  29. package/src/commands/builds/compatibility-matrix.ts +0 -48
  30. package/src/commands/builds/delete.ts +0 -15
  31. package/src/commands/builds/get.ts +0 -34
  32. package/src/commands/builds/helpers.ts +0 -3
  33. package/src/commands/builds/index.ts +0 -20
  34. package/src/commands/builds/install-link.ts +0 -20
  35. package/src/commands/builds/list.ts +0 -38
  36. package/src/commands/channels/create.ts +0 -37
  37. package/src/commands/channels/delete.ts +0 -15
  38. package/src/commands/channels/helpers.ts +0 -18
  39. package/src/commands/channels/index.ts +0 -24
  40. package/src/commands/channels/list.ts +0 -38
  41. package/src/commands/channels/pause.ts +0 -15
  42. package/src/commands/channels/resume.ts +0 -15
  43. package/src/commands/channels/rollout/complete.ts +0 -17
  44. package/src/commands/channels/rollout/create.ts +0 -36
  45. package/src/commands/channels/rollout/index.ts +0 -11
  46. package/src/commands/channels/rollout/revert.ts +0 -17
  47. package/src/commands/channels/rollout/update.ts +0 -23
  48. package/src/commands/channels/update.ts +0 -32
  49. package/src/commands/credentials/delete.ts +0 -24
  50. package/src/commands/credentials/index.ts +0 -10
  51. package/src/commands/credentials/list.ts +0 -33
  52. package/src/commands/credentials/upload.ts +0 -91
  53. package/src/commands/env/delete.ts +0 -35
  54. package/src/commands/env/export.ts +0 -27
  55. package/src/commands/env/get.ts +0 -25
  56. package/src/commands/env/helpers.ts +0 -13
  57. package/src/commands/env/import.ts +0 -31
  58. package/src/commands/env/index.ts +0 -24
  59. package/src/commands/env/list.ts +0 -44
  60. package/src/commands/env/pull.ts +0 -27
  61. package/src/commands/env/set.ts +0 -42
  62. package/src/commands/fingerprint/compare.ts +0 -25
  63. package/src/commands/fingerprint/generate.ts +0 -18
  64. package/src/commands/fingerprint/index.ts +0 -9
  65. package/src/commands/init.ts +0 -35
  66. package/src/commands/login.ts +0 -13
  67. package/src/commands/logout.ts +0 -12
  68. package/src/commands/projects.ts +0 -84
  69. package/src/commands/status.ts +0 -48
  70. package/src/commands/update/delete.ts +0 -15
  71. package/src/commands/update/helpers.ts +0 -22
  72. package/src/commands/update/index.ts +0 -22
  73. package/src/commands/update/list.ts +0 -60
  74. package/src/commands/update/promote.ts +0 -30
  75. package/src/commands/update/publish.ts +0 -94
  76. package/src/commands/update/rollback.ts +0 -42
  77. package/src/commands/update/rollout/complete.ts +0 -17
  78. package/src/commands/update/rollout/index.ts +0 -10
  79. package/src/commands/update/rollout/revert.ts +0 -17
  80. package/src/commands/update/rollout/set.ts +0 -23
  81. package/src/index.ts +0 -53
  82. package/src/lib/android-keystore.test.ts +0 -114
  83. package/src/lib/android-keystore.ts +0 -76
  84. package/src/lib/android-signing-gradle.test.ts +0 -95
  85. package/src/lib/android-signing-gradle.ts +0 -52
  86. package/src/lib/app-json.ts +0 -81
  87. package/src/lib/apple-auth.test.ts +0 -402
  88. package/src/lib/apple-auth.ts +0 -132
  89. package/src/lib/artifact-finder.test.ts +0 -195
  90. package/src/lib/artifact-finder.ts +0 -122
  91. package/src/lib/browser-login.test.ts +0 -88
  92. package/src/lib/browser-login.ts +0 -193
  93. package/src/lib/build-profile.test.ts +0 -290
  94. package/src/lib/build-profile.ts +0 -234
  95. package/src/lib/cli-schemas.ts +0 -39
  96. package/src/lib/command-errors.ts +0 -60
  97. package/src/lib/credentials-downloader.ts +0 -181
  98. package/src/lib/credentials-manager.ts +0 -354
  99. package/src/lib/env-exporter.test.ts +0 -96
  100. package/src/lib/env-exporter.ts +0 -28
  101. package/src/lib/exit-codes.ts +0 -82
  102. package/src/lib/expo-config.ts +0 -130
  103. package/src/lib/expo-export.test.ts +0 -94
  104. package/src/lib/expo-export.ts +0 -281
  105. package/src/lib/fingerprint.ts +0 -67
  106. package/src/lib/format-error.ts +0 -22
  107. package/src/lib/git-context.ts +0 -56
  108. package/src/lib/gradle-config.ts +0 -126
  109. package/src/lib/ios-export-options.test.ts +0 -98
  110. package/src/lib/ios-export-options.ts +0 -62
  111. package/src/lib/ios-keychain.ts +0 -181
  112. package/src/lib/ios-provisioning.test.ts +0 -115
  113. package/src/lib/ios-provisioning.ts +0 -179
  114. package/src/lib/output.ts +0 -32
  115. package/src/lib/pkcs12.ts +0 -73
  116. package/src/lib/plist.ts +0 -39
  117. package/src/lib/post-build-validation.ts +0 -146
  118. package/src/lib/presigned-upload.test.ts +0 -140
  119. package/src/lib/presigned-upload.ts +0 -35
  120. package/src/lib/record.ts +0 -5
  121. package/src/lib/resolve-named-resource.ts +0 -24
  122. package/src/lib/runtime-version.test.ts +0 -119
  123. package/src/lib/runtime-version.ts +0 -62
  124. package/src/lib/sha256.test.ts +0 -108
  125. package/src/lib/sha256.ts +0 -80
  126. package/src/lib/signed-payloads.test.ts +0 -181
  127. package/src/lib/signed-payloads.ts +0 -164
  128. package/src/lib/string-utils.ts +0 -4
  129. package/src/lib/temp-dir.ts +0 -14
  130. package/src/lib/test-utils.ts +0 -13
  131. package/src/lib/update-platforms.test.ts +0 -45
  132. package/src/lib/update-platforms.ts +0 -19
  133. package/src/lib/xcpretty-formatter.ts +0 -21
  134. package/src/services/api-client.ts +0 -42
  135. package/src/services/apple-session-store.ts +0 -100
  136. package/src/services/auth-store.ts +0 -85
  137. package/src/services/cli-runtime.ts +0 -46
  138. package/src/services/config-store.ts +0 -108
  139. package/src/services/presigned-upload.ts +0 -84
  140. package/src/services/update-asset-uploader.ts +0 -72
  141. package/src/types/keychain.d.ts +0 -22
  142. package/tests/e2e/build.test.ts +0 -270
  143. package/tests/e2e/commands.test.ts +0 -694
  144. package/tests/e2e/ota-lifecycle.test.ts +0 -275
  145. package/tests/e2e/publish.test.ts +0 -150
  146. package/tests/helpers/cli-e2e.ts +0 -426
  147. package/tests/helpers/pty-driver.ts +0 -142
  148. package/tests/interactive/harness/provider-prompt.ts +0 -54
  149. package/tests/interactive/login.test.ts +0 -47
  150. package/tests/interactive/provider-select.test.ts +0 -59
  151. package/tsconfig.json +0 -7
  152. package/vitest.config.ts +0 -38
@@ -1,95 +0,0 @@
1
- import { renderSigningGradle } from "./android-signing-gradle";
2
-
3
- describe(renderSigningGradle, () => {
4
- it("renders standard release signing config", () => {
5
- const script = renderSigningGradle({
6
- keystorePath: "/tmp/release.keystore",
7
- storePassword: "store-pass",
8
- keyAlias: "my-key",
9
- keyPassword: "key-pass",
10
- });
11
- expect(script).toMatchInlineSnapshot(`
12
- "allprojects {
13
- afterEvaluate { project ->
14
- if (project.plugins.hasPlugin('com.android.application')) {
15
- project.android {
16
- signingConfigs {
17
- release {
18
- storeFile file('/tmp/release.keystore')
19
- storePassword 'store-pass'
20
- keyAlias 'my-key'
21
- keyPassword 'key-pass'
22
- }
23
- }
24
- buildTypes {
25
- release {
26
- signingConfig signingConfigs.release
27
- }
28
- }
29
- }
30
- }
31
- }
32
- }
33
- "
34
- `);
35
- });
36
-
37
- it("references com.android.application plugin check + afterEvaluate", () => {
38
- const script = renderSigningGradle({
39
- keystorePath: "/k",
40
- storePassword: "s",
41
- keyAlias: "a",
42
- keyPassword: "k",
43
- });
44
- expect(script).toContain("allprojects {");
45
- expect(script).toContain("afterEvaluate { project ->");
46
- expect(script).toContain("project.plugins.hasPlugin('com.android.application')");
47
- expect(script).toContain("signingConfigs {");
48
- expect(script).toContain("buildTypes {");
49
- expect(script).toContain("signingConfig signingConfigs.release");
50
- });
51
-
52
- it("escapes single quotes in passwords", () => {
53
- const script = renderSigningGradle({
54
- keystorePath: "/k",
55
- storePassword: "can't",
56
- keyAlias: "won't",
57
- keyPassword: "pass'word",
58
- });
59
- expect(script).toContain(String.raw`storePassword 'can\'t'`);
60
- expect(script).toContain(String.raw`keyAlias 'won\'t'`);
61
- expect(script).toContain(String.raw`keyPassword 'pass\'word'`);
62
- });
63
-
64
- it("escapes backslashes in path", () => {
65
- const script = renderSigningGradle({
66
- keystorePath: "C:\\keys\\release.keystore",
67
- storePassword: "s",
68
- keyAlias: "a",
69
- keyPassword: "k",
70
- });
71
- expect(script).toContain(String.raw`storeFile file('C:\\keys\\release.keystore')`);
72
- });
73
-
74
- it("escapes combined backslash and quote", () => {
75
- const script = renderSigningGradle({
76
- keystorePath: "/k",
77
- storePassword: "a\\'b",
78
- keyAlias: "x",
79
- keyPassword: "y",
80
- });
81
- // \\ → \\\\ and ' → \\'
82
- expect(script).toContain(String.raw`storePassword 'a\\\'b'`);
83
- });
84
-
85
- it("escapes `$` in passwords to prevent Groovy interpolation", () => {
86
- const script = renderSigningGradle({
87
- keystorePath: "/k",
88
- storePassword: "p@ss$word",
89
- keyAlias: "a",
90
- keyPassword: "$secret",
91
- });
92
- expect(script).toContain(String.raw`storePassword 'p@ss\$word'`);
93
- expect(script).toContain(String.raw`keyPassword '\$secret'`);
94
- });
95
- });
@@ -1,52 +0,0 @@
1
- export interface RenderSigningGradleInput {
2
- readonly keystorePath: string;
3
- readonly storePassword: string;
4
- readonly keyAlias: string;
5
- readonly keyPassword: string;
6
- }
7
-
8
- /**
9
- * Escape a Groovy single-quoted string literal: backslashes, single quotes,
10
- * and `$` (to prevent string interpolation on Groovy double-quoted strings,
11
- * though we use single quotes everywhere for safety).
12
- */
13
- const escapeGroovySingleQuoted = (value: string): string =>
14
- value
15
- .replaceAll("\\", String.raw`\\`)
16
- .replaceAll("'", String.raw`\'`)
17
- .replaceAll("$", String.raw`\$`);
18
-
19
- /**
20
- * Render a Gradle init script that injects a `release` signing config into
21
- * every Android application module after evaluation. This is passed to
22
- * `./gradlew --init-script <path>` so the keystore never has to live in the
23
- * project tree.
24
- */
25
- export const renderSigningGradle = ({
26
- keystorePath,
27
- storePassword,
28
- keyAlias,
29
- keyPassword,
30
- }: RenderSigningGradleInput): string =>
31
- `allprojects {
32
- afterEvaluate { project ->
33
- if (project.plugins.hasPlugin('com.android.application')) {
34
- project.android {
35
- signingConfigs {
36
- release {
37
- storeFile file('${escapeGroovySingleQuoted(keystorePath)}')
38
- storePassword '${escapeGroovySingleQuoted(storePassword)}'
39
- keyAlias '${escapeGroovySingleQuoted(keyAlias)}'
40
- keyPassword '${escapeGroovySingleQuoted(keyPassword)}'
41
- }
42
- }
43
- buildTypes {
44
- release {
45
- signingConfig signingConfigs.release
46
- }
47
- }
48
- }
49
- }
50
- }
51
- }
52
- `;
@@ -1,81 +0,0 @@
1
- import { FileSystem } from "@effect/platform";
2
- import { Effect } from "effect";
3
-
4
- import { ProjectNotLinkedError } from "./exit-codes";
5
- import { formatCause } from "./format-error";
6
- import { asRecord, isRecord } from "./record";
7
-
8
- export const readAppJson = Effect.gen(function* () {
9
- const fs = yield* FileSystem.FileSystem;
10
- const content = yield* fs
11
- .readFileString("./app.json")
12
- .pipe(
13
- Effect.mapError(
14
- () => new ProjectNotLinkedError({ message: "app.json not found in current directory" }),
15
- ),
16
- );
17
- const parsed = yield* Effect.try({
18
- try: (): unknown => JSON.parse(content),
19
- catch: () => new ProjectNotLinkedError({ message: "app.json contains malformed JSON" }),
20
- });
21
- if (!isRecord(parsed)) {
22
- return yield* new ProjectNotLinkedError({ message: "app.json must be a JSON object" });
23
- }
24
- return parsed;
25
- });
26
-
27
- export const readProjectId = Effect.gen(function* () {
28
- const appJson = yield* readAppJson;
29
- const expo = asRecord(appJson["expo"]);
30
- const extra = asRecord(expo?.["extra"]);
31
- const betterUpdate = asRecord(extra?.["betterUpdate"]);
32
- const projectId = betterUpdate?.["projectId"];
33
-
34
- if (typeof projectId !== "string") {
35
- return yield* new ProjectNotLinkedError({
36
- message:
37
- "Project not linked. Run `better-update link` to connect this project, or set expo.extra.betterUpdate.projectId in app.json.",
38
- });
39
- }
40
-
41
- return projectId;
42
- });
43
-
44
- export const readSlug = Effect.gen(function* () {
45
- const appJson = yield* readAppJson;
46
- const expo = asRecord(appJson["expo"]);
47
- const slug = expo?.["slug"];
48
-
49
- if (typeof slug !== "string") {
50
- return yield* new ProjectNotLinkedError({
51
- message: "Missing expo.slug in app.json. Required to identify the project.",
52
- });
53
- }
54
-
55
- return slug;
56
- });
57
-
58
- export const writeProjectId = (id: string) =>
59
- Effect.gen(function* () {
60
- const fs = yield* FileSystem.FileSystem;
61
- const appJson = yield* readAppJson;
62
-
63
- const expo = asRecord(appJson["expo"]) ?? {};
64
- const extra = asRecord(expo["extra"]) ?? {};
65
- const betterUpdate = asRecord(extra["betterUpdate"]) ?? {};
66
-
67
- betterUpdate["projectId"] = id;
68
- extra["betterUpdate"] = betterUpdate;
69
- expo["extra"] = extra;
70
- appJson["expo"] = expo;
71
-
72
- yield* fs.writeFileString("./app.json", `${JSON.stringify(appJson, null, 2)}\n`);
73
- }).pipe(
74
- Effect.mapError((cause) =>
75
- cause instanceof ProjectNotLinkedError
76
- ? cause
77
- : new ProjectNotLinkedError({
78
- message: `Failed to write project ID to app.json: ${formatCause(cause)}`,
79
- }),
80
- ),
81
- );
@@ -1,402 +0,0 @@
1
- import { Terminal } from "@effect/platform";
2
- import { it } from "@effect/vitest";
3
- import { Effect, Exit, Layer, Mailbox, Option } from "effect";
4
-
5
- import type { Session } from "@expo/apple-utils";
6
- // eslint-disable-next-line import-plugin/no-namespace -- stub factory typed as `typeof AppleUtils` (whole module shape); no named type covers the full module
7
- import type * as AppleUtils from "@expo/apple-utils";
8
-
9
- import { CliRuntime } from "../services/cli-runtime";
10
- import { parseProviderId, resolveProvider } from "./apple-auth";
11
- import { AppleAuthError } from "./exit-codes";
12
-
13
- // ── helpers ──────────────────────────────────────────────────────
14
-
15
- const provider = (
16
- providerId: number,
17
- name = `Provider ${providerId}`,
18
- subType = "ORGANIZATION",
19
- ): Session.SessionProvider => ({
20
- providerId,
21
- publicProviderId: `pub-${providerId}`,
22
- name,
23
- contentTypes: ["SOFTWARE"],
24
- subType,
25
- });
26
-
27
- const makeAppleUtilsStub = (setProviderSpy?: (id: number) => Promise<unknown>) =>
28
- ({
29
- Session: {
30
- setSessionProviderIdAsync: async (id: number) =>
31
- setProviderSpy?.(id) ?? Promise.resolve(null),
32
- },
33
- }) as unknown as typeof AppleUtils;
34
-
35
- const makeCliRuntimeLayer = (env: Readonly<Record<string, string | undefined>> = {}) =>
36
- Layer.succeed(CliRuntime, {
37
- argv: [],
38
- platform: "linux" as NodeJS.Platform,
39
- cwd: Effect.succeed("/"),
40
- getEnv: (name: string) => Effect.succeed(env[name]),
41
- homeDirectory: Effect.succeed("/"),
42
- userName: Effect.succeed("test"),
43
- commandEnvironment: () => Effect.succeed({}),
44
- setExitCode: () => Effect.void,
45
- });
46
-
47
- // Stub Terminal — none of the non-prompt branches read from it.
48
- const terminalStubLayer = Layer.succeed(Terminal.Terminal, {
49
- columns: Effect.succeed(80),
50
- rows: Effect.succeed(24),
51
- isTTY: Effect.succeed(false),
52
- readInput: Effect.dieMessage("readInput not used in tests") as never,
53
- readLine: Effect.dieMessage("readLine not used in tests") as never,
54
- display: () => Effect.void,
55
- });
56
-
57
- const provideTestServices = (env: Readonly<Record<string, string | undefined>> = {}) =>
58
- Layer.mergeAll(makeCliRuntimeLayer(env), terminalStubLayer);
59
-
60
- // ── parseProviderId ──────────────────────────────────────────────
61
-
62
- describe(parseProviderId, () => {
63
- it.effect("accepts a positive integer string", () =>
64
- Effect.gen(function* () {
65
- const result = yield* parseProviderId("118573544");
66
- expect(result).toBe(118_573_544);
67
- }),
68
- );
69
-
70
- it.effect("accepts zero", () =>
71
- Effect.gen(function* () {
72
- const result = yield* parseProviderId("0");
73
- expect(result).toBe(0);
74
- }),
75
- );
76
-
77
- it.effect("rejects a non-numeric string with AppleAuthError", () =>
78
- Effect.gen(function* () {
79
- const exit = yield* Effect.exit(parseProviderId("abc"));
80
- expect(Exit.isFailure(exit)).toBe(true);
81
- if (Exit.isFailure(exit)) {
82
- const err = exit.cause._tag === "Fail" ? exit.cause.error : null;
83
- expect(err).toBeInstanceOf(AppleAuthError);
84
- expect(err!.message).toContain("APPLE_PROVIDER_ID");
85
- }
86
- }),
87
- );
88
-
89
- it.effect("rejects a decimal value", () =>
90
- Effect.gen(function* () {
91
- const exit = yield* Effect.exit(parseProviderId("1.5"));
92
- expect(Exit.isFailure(exit)).toBe(true);
93
- }),
94
- );
95
-
96
- it.effect("rejects an empty string", () =>
97
- Effect.gen(function* () {
98
- // Number("") === 0, which IS an integer — guard at call site (readEnvProviderId)
99
- // Skips empty strings. Document that parseProviderId treats "" as 0.
100
- const result = yield* parseProviderId("");
101
- expect(result).toBe(0);
102
- }),
103
- );
104
- });
105
-
106
- // ── resolveProvider ──────────────────────────────────────────────
107
-
108
- describe(resolveProvider, () => {
109
- it.effect("uses APPLE_PROVIDER_ID env when set, switching when it differs from current", () =>
110
- Effect.gen(function* () {
111
- const calls: number[] = [];
112
- const appleUtils = makeAppleUtilsStub(async (id) => {
113
- calls.push(id);
114
- return null;
115
- });
116
-
117
- const result = yield* resolveProvider(appleUtils, [provider(1), provider(2)], 1, undefined);
118
-
119
- expect(result).toStrictEqual({ providerId: 2, switched: true });
120
- expect(calls).toStrictEqual([2]);
121
- }).pipe(Effect.provide(provideTestServices({ APPLE_PROVIDER_ID: "2" }))),
122
- );
123
-
124
- it.effect("env match against current provider does not trigger switch", () =>
125
- Effect.gen(function* () {
126
- const calls: number[] = [];
127
- const appleUtils = makeAppleUtilsStub(async (id) => {
128
- calls.push(id);
129
- return null;
130
- });
131
-
132
- const result = yield* resolveProvider(appleUtils, [provider(1), provider(2)], 1, undefined);
133
-
134
- expect(result).toStrictEqual({ providerId: 1, switched: false });
135
- expect(calls).toStrictEqual([]);
136
- }).pipe(Effect.provide(provideTestServices({ APPLE_PROVIDER_ID: "1" }))),
137
- );
138
-
139
- it.effect("invalid env value fails with AppleAuthError", () =>
140
- Effect.gen(function* () {
141
- const appleUtils = makeAppleUtilsStub();
142
- const exit = yield* Effect.exit(resolveProvider(appleUtils, [provider(1)], 1, undefined));
143
- expect(Exit.isFailure(exit)).toBe(true);
144
- }).pipe(Effect.provide(provideTestServices({ APPLE_PROVIDER_ID: "not-a-number" }))),
145
- );
146
-
147
- it.effect("uses cached provider when still available", () =>
148
- Effect.gen(function* () {
149
- const calls: number[] = [];
150
- const appleUtils = makeAppleUtilsStub(async (id) => {
151
- calls.push(id);
152
- return null;
153
- });
154
-
155
- const result = yield* resolveProvider(
156
- appleUtils,
157
- [provider(1), provider(2), provider(3)],
158
- 1,
159
- 3,
160
- );
161
-
162
- expect(result).toStrictEqual({ providerId: 3, switched: true });
163
- expect(calls).toStrictEqual([3]);
164
- }).pipe(Effect.provide(provideTestServices())),
165
- );
166
-
167
- it.effect("ignores stale cached provider and falls through to single available", () =>
168
- Effect.gen(function* () {
169
- const calls: number[] = [];
170
- const appleUtils = makeAppleUtilsStub(async (id) => {
171
- calls.push(id);
172
- return null;
173
- });
174
-
175
- // Cached provider 99 no longer in availableProviders → fall through.
176
- const result = yield* resolveProvider(appleUtils, [provider(7)], 7, 99);
177
-
178
- expect(result).toStrictEqual({ providerId: 7, switched: false });
179
- expect(calls).toStrictEqual([]);
180
- }).pipe(Effect.provide(provideTestServices())),
181
- );
182
-
183
- it.effect("returns currentProviderId when availableProviders is empty", () =>
184
- Effect.gen(function* () {
185
- const appleUtils = makeAppleUtilsStub();
186
-
187
- const result = yield* resolveProvider(appleUtils, [], 5, undefined);
188
-
189
- expect(result).toStrictEqual({ providerId: 5, switched: false });
190
- }).pipe(Effect.provide(provideTestServices())),
191
- );
192
-
193
- it.effect("returns undefined when no providers and no current id", () =>
194
- Effect.gen(function* () {
195
- const appleUtils = makeAppleUtilsStub();
196
-
197
- const result = yield* resolveProvider(appleUtils, [], undefined, undefined);
198
-
199
- expect(result).toStrictEqual({ providerId: undefined, switched: false });
200
- }).pipe(Effect.provide(provideTestServices())),
201
- );
202
-
203
- it.effect("single available provider applies through applyChoice", () =>
204
- Effect.gen(function* () {
205
- const calls: number[] = [];
206
- const appleUtils = makeAppleUtilsStub(async (id) => {
207
- calls.push(id);
208
- return null;
209
- });
210
-
211
- const result = yield* resolveProvider(appleUtils, [provider(42)], undefined, undefined);
212
-
213
- expect(result).toStrictEqual({ providerId: 42, switched: true });
214
- expect(calls).toStrictEqual([42]);
215
- }).pipe(Effect.provide(provideTestServices())),
216
- );
217
-
218
- it.effect(
219
- "multi-provider with no env, no cache, autoresolved current → preserves without prompt",
220
- () =>
221
- Effect.gen(function* () {
222
- const calls: number[] = [];
223
- const appleUtils = makeAppleUtilsStub(async (id) => {
224
- calls.push(id);
225
- return null;
226
- });
227
-
228
- // CurrentProviderId is set (apple-utils auto-resolved). No prompt — CI-safe.
229
- const result = yield* resolveProvider(
230
- appleUtils,
231
- [provider(1), provider(2), provider(3)],
232
- 2,
233
- undefined,
234
- );
235
-
236
- expect(result).toStrictEqual({ providerId: 2, switched: false });
237
- expect(calls).toStrictEqual([]);
238
- }).pipe(Effect.provide(provideTestServices())),
239
- );
240
-
241
- it.effect("env value takes precedence over cached pick", () =>
242
- Effect.gen(function* () {
243
- const calls: number[] = [];
244
- const appleUtils = makeAppleUtilsStub(async (id) => {
245
- calls.push(id);
246
- return null;
247
- });
248
-
249
- const result = yield* resolveProvider(
250
- appleUtils,
251
- [provider(1), provider(2), provider(3)],
252
- 1,
253
- 3,
254
- );
255
-
256
- expect(result).toStrictEqual({ providerId: 2, switched: true });
257
- expect(calls).toStrictEqual([2]);
258
- }).pipe(Effect.provide(provideTestServices({ APPLE_PROVIDER_ID: "2" }))),
259
- );
260
-
261
- it.effect("propagates AppleAuthError when setSessionProviderIdAsync rejects", () =>
262
- Effect.gen(function* () {
263
- const appleUtils = makeAppleUtilsStub(async () => {
264
- throw new Error("provider not accessible");
265
- });
266
-
267
- const exit = yield* Effect.exit(
268
- resolveProvider(appleUtils, [provider(1), provider(2)], 1, undefined),
269
- );
270
-
271
- expect(Exit.isFailure(exit)).toBe(true);
272
- if (Exit.isFailure(exit)) {
273
- const err = exit.cause._tag === "Fail" ? exit.cause.error : null;
274
- expect(err).toBeInstanceOf(AppleAuthError);
275
- expect((err as AppleAuthError).message).toContain("Failed to switch");
276
- }
277
- }).pipe(Effect.provide(provideTestServices({ APPLE_PROVIDER_ID: "2" }))),
278
- );
279
- });
280
-
281
- // ── resolveProvider (prompt branch via scripted Terminal) ────────
282
-
283
- interface KeyEvent {
284
- readonly name: string;
285
- readonly input?: string;
286
- readonly ctrl?: boolean;
287
- readonly meta?: boolean;
288
- readonly shift?: boolean;
289
- }
290
-
291
- const toUserInput = (event: KeyEvent): Terminal.UserInput => ({
292
- input: event.input ? Option.some(event.input) : Option.none(),
293
- key: {
294
- name: event.name,
295
- ctrl: event.ctrl ?? false,
296
- meta: event.meta ?? false,
297
- shift: event.shift ?? false,
298
- },
299
- });
300
-
301
- /**
302
- * Build a Terminal layer backed by a pre-filled Mailbox of scripted keystrokes.
303
- * `display` output is captured into `displayed` for optional assertions.
304
- */
305
- const makeScriptedTerminalLayer = (events: readonly KeyEvent[], displayed: string[]) =>
306
- Layer.effect(
307
- Terminal.Terminal,
308
- Effect.gen(function* () {
309
- const mailbox = yield* Mailbox.make<Terminal.UserInput>();
310
- yield* mailbox.offerAll(events.map(toUserInput));
311
- return {
312
- columns: Effect.succeed(80),
313
- rows: Effect.succeed(24),
314
- isTTY: Effect.succeed(true),
315
- readInput: Effect.succeed(mailbox),
316
- readLine: Effect.dieMessage("readLine not used in prompt tests") as never,
317
- display: (text: string) =>
318
- Effect.sync(() => {
319
- displayed.push(text);
320
- }),
321
- };
322
- }),
323
- );
324
-
325
- const provideScriptedPrompt = (
326
- events: readonly KeyEvent[],
327
- displayed: string[],
328
- env: Readonly<Record<string, string | undefined>> = {},
329
- ) => Layer.mergeAll(makeCliRuntimeLayer(env), makeScriptedTerminalLayer(events, displayed));
330
-
331
- describe("resolveProvider (prompt branch)", () => {
332
- it.effect("prompts when multi-provider + no env + no cache + no auto-current", () =>
333
- Effect.gen(function* () {
334
- const displayed: string[] = [];
335
- const calls: number[] = [];
336
- const appleUtils = makeAppleUtilsStub(async (id) => {
337
- calls.push(id);
338
- return null;
339
- });
340
-
341
- const result = yield* resolveProvider(
342
- appleUtils,
343
- [provider(10, "Org A"), provider(20, "Org B"), provider(30, "Org C")],
344
- undefined,
345
- undefined,
346
- ).pipe(
347
- Effect.provide(
348
- provideScriptedPrompt(
349
- [{ name: "down" }, { name: "down" }, { name: "return" }],
350
- displayed,
351
- ),
352
- ),
353
- );
354
-
355
- expect(result).toStrictEqual({ providerId: 30, switched: true });
356
- expect(calls).toStrictEqual([30]);
357
- const allDisplay = displayed.join("");
358
- expect(allDisplay).toContain("Select App Store Connect provider");
359
- expect(allDisplay).toContain("Org C");
360
- }),
361
- );
362
-
363
- it.effect("enter on first item picks it without arrow keys", () =>
364
- Effect.gen(function* () {
365
- const displayed: string[] = [];
366
- const calls: number[] = [];
367
- const appleUtils = makeAppleUtilsStub(async (id) => {
368
- calls.push(id);
369
- return null;
370
- });
371
-
372
- const result = yield* resolveProvider(
373
- appleUtils,
374
- [provider(1), provider(2)],
375
- undefined,
376
- undefined,
377
- ).pipe(Effect.provide(provideScriptedPrompt([{ name: "return" }], displayed)));
378
-
379
- expect(result).toStrictEqual({ providerId: 1, switched: true });
380
- expect(calls).toStrictEqual([1]);
381
- }),
382
- );
383
-
384
- it.effect("up-arrow wraps around from top to bottom", () =>
385
- Effect.gen(function* () {
386
- const displayed: string[] = [];
387
- const appleUtils = makeAppleUtilsStub(async () => null);
388
-
389
- const result = yield* resolveProvider(
390
- appleUtils,
391
- [provider(1), provider(2), provider(3)],
392
- undefined,
393
- undefined,
394
- ).pipe(
395
- Effect.provide(provideScriptedPrompt([{ name: "up" }, { name: "return" }], displayed)),
396
- );
397
-
398
- expect(result.providerId).toBe(3);
399
- expect(result.switched).toBe(true);
400
- }),
401
- );
402
- });