@better-update/cli 0.3.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 (150) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/oxlint.config.ts +6 -0
  3. package/package.json +36 -0
  4. package/src/app-layer.ts +29 -0
  5. package/src/application/build-workflow.ts +222 -0
  6. package/src/application/command-exit.ts +13 -0
  7. package/src/application/login.ts +87 -0
  8. package/src/application/update-promote.ts +88 -0
  9. package/src/application/update-publish.ts +402 -0
  10. package/src/application/update-rollback.ts +275 -0
  11. package/src/commands/analytics/adoption.ts +40 -0
  12. package/src/commands/analytics/channels.ts +35 -0
  13. package/src/commands/analytics/helpers.ts +3 -0
  14. package/src/commands/analytics/index.ts +13 -0
  15. package/src/commands/analytics/platforms.ts +39 -0
  16. package/src/commands/analytics/updates.ts +35 -0
  17. package/src/commands/audit-logs/helpers.ts +3 -0
  18. package/src/commands/audit-logs/index.ts +8 -0
  19. package/src/commands/audit-logs/list.ts +66 -0
  20. package/src/commands/branches.ts +70 -0
  21. package/src/commands/build/android.ts +129 -0
  22. package/src/commands/build/index.ts +63 -0
  23. package/src/commands/build/ios.ts +199 -0
  24. package/src/commands/build/reserve-and-upload.test.ts +263 -0
  25. package/src/commands/build/reserve-and-upload.ts +160 -0
  26. package/src/commands/build/run-step.ts +131 -0
  27. package/src/commands/builds/compatibility-matrix.ts +48 -0
  28. package/src/commands/builds/delete.ts +15 -0
  29. package/src/commands/builds/get.ts +34 -0
  30. package/src/commands/builds/helpers.ts +3 -0
  31. package/src/commands/builds/index.ts +20 -0
  32. package/src/commands/builds/install-link.ts +20 -0
  33. package/src/commands/builds/list.ts +38 -0
  34. package/src/commands/channels/create.ts +37 -0
  35. package/src/commands/channels/delete.ts +15 -0
  36. package/src/commands/channels/helpers.ts +18 -0
  37. package/src/commands/channels/index.ts +24 -0
  38. package/src/commands/channels/list.ts +38 -0
  39. package/src/commands/channels/pause.ts +15 -0
  40. package/src/commands/channels/resume.ts +15 -0
  41. package/src/commands/channels/rollout/complete.ts +17 -0
  42. package/src/commands/channels/rollout/create.ts +36 -0
  43. package/src/commands/channels/rollout/index.ts +11 -0
  44. package/src/commands/channels/rollout/revert.ts +17 -0
  45. package/src/commands/channels/rollout/update.ts +23 -0
  46. package/src/commands/channels/update.ts +32 -0
  47. package/src/commands/credentials/delete.ts +24 -0
  48. package/src/commands/credentials/index.ts +10 -0
  49. package/src/commands/credentials/list.ts +33 -0
  50. package/src/commands/credentials/upload.ts +91 -0
  51. package/src/commands/env/delete.ts +35 -0
  52. package/src/commands/env/export.ts +27 -0
  53. package/src/commands/env/get.ts +25 -0
  54. package/src/commands/env/helpers.ts +13 -0
  55. package/src/commands/env/import.ts +31 -0
  56. package/src/commands/env/index.ts +24 -0
  57. package/src/commands/env/list.ts +44 -0
  58. package/src/commands/env/pull.ts +27 -0
  59. package/src/commands/env/set.ts +42 -0
  60. package/src/commands/fingerprint/compare.ts +25 -0
  61. package/src/commands/fingerprint/generate.ts +18 -0
  62. package/src/commands/fingerprint/index.ts +9 -0
  63. package/src/commands/init.ts +35 -0
  64. package/src/commands/login.ts +13 -0
  65. package/src/commands/logout.ts +12 -0
  66. package/src/commands/projects.ts +84 -0
  67. package/src/commands/status.ts +48 -0
  68. package/src/commands/update/delete.ts +15 -0
  69. package/src/commands/update/helpers.ts +22 -0
  70. package/src/commands/update/index.ts +22 -0
  71. package/src/commands/update/list.ts +60 -0
  72. package/src/commands/update/promote.ts +30 -0
  73. package/src/commands/update/publish.ts +94 -0
  74. package/src/commands/update/rollback.ts +42 -0
  75. package/src/commands/update/rollout/complete.ts +17 -0
  76. package/src/commands/update/rollout/index.ts +10 -0
  77. package/src/commands/update/rollout/revert.ts +17 -0
  78. package/src/commands/update/rollout/set.ts +23 -0
  79. package/src/index.ts +53 -0
  80. package/src/lib/android-keystore.test.ts +114 -0
  81. package/src/lib/android-keystore.ts +76 -0
  82. package/src/lib/android-signing-gradle.test.ts +95 -0
  83. package/src/lib/android-signing-gradle.ts +52 -0
  84. package/src/lib/app-json.ts +81 -0
  85. package/src/lib/apple-auth.test.ts +402 -0
  86. package/src/lib/apple-auth.ts +132 -0
  87. package/src/lib/artifact-finder.test.ts +195 -0
  88. package/src/lib/artifact-finder.ts +122 -0
  89. package/src/lib/browser-login.test.ts +88 -0
  90. package/src/lib/browser-login.ts +193 -0
  91. package/src/lib/build-profile.test.ts +290 -0
  92. package/src/lib/build-profile.ts +234 -0
  93. package/src/lib/cli-schemas.ts +39 -0
  94. package/src/lib/command-errors.ts +60 -0
  95. package/src/lib/credentials-downloader.ts +181 -0
  96. package/src/lib/credentials-manager.ts +354 -0
  97. package/src/lib/env-exporter.test.ts +96 -0
  98. package/src/lib/env-exporter.ts +28 -0
  99. package/src/lib/exit-codes.ts +82 -0
  100. package/src/lib/expo-config.ts +130 -0
  101. package/src/lib/expo-export.test.ts +94 -0
  102. package/src/lib/expo-export.ts +281 -0
  103. package/src/lib/fingerprint.ts +67 -0
  104. package/src/lib/format-error.ts +22 -0
  105. package/src/lib/git-context.ts +56 -0
  106. package/src/lib/gradle-config.ts +126 -0
  107. package/src/lib/ios-export-options.test.ts +98 -0
  108. package/src/lib/ios-export-options.ts +62 -0
  109. package/src/lib/ios-keychain.ts +181 -0
  110. package/src/lib/ios-provisioning.test.ts +115 -0
  111. package/src/lib/ios-provisioning.ts +179 -0
  112. package/src/lib/output.ts +32 -0
  113. package/src/lib/pkcs12.ts +73 -0
  114. package/src/lib/plist.ts +39 -0
  115. package/src/lib/post-build-validation.ts +146 -0
  116. package/src/lib/presigned-upload.test.ts +140 -0
  117. package/src/lib/presigned-upload.ts +35 -0
  118. package/src/lib/record.ts +5 -0
  119. package/src/lib/resolve-named-resource.ts +24 -0
  120. package/src/lib/runtime-version.test.ts +119 -0
  121. package/src/lib/runtime-version.ts +62 -0
  122. package/src/lib/sha256.test.ts +108 -0
  123. package/src/lib/sha256.ts +80 -0
  124. package/src/lib/signed-payloads.test.ts +181 -0
  125. package/src/lib/signed-payloads.ts +164 -0
  126. package/src/lib/string-utils.ts +4 -0
  127. package/src/lib/temp-dir.ts +14 -0
  128. package/src/lib/test-utils.ts +13 -0
  129. package/src/lib/update-platforms.test.ts +45 -0
  130. package/src/lib/update-platforms.ts +19 -0
  131. package/src/lib/xcpretty-formatter.ts +21 -0
  132. package/src/services/api-client.ts +42 -0
  133. package/src/services/apple-session-store.ts +100 -0
  134. package/src/services/auth-store.ts +85 -0
  135. package/src/services/cli-runtime.ts +46 -0
  136. package/src/services/config-store.ts +108 -0
  137. package/src/services/presigned-upload.ts +84 -0
  138. package/src/services/update-asset-uploader.ts +72 -0
  139. package/src/types/keychain.d.ts +22 -0
  140. package/tests/e2e/build.test.ts +270 -0
  141. package/tests/e2e/commands.test.ts +694 -0
  142. package/tests/e2e/ota-lifecycle.test.ts +275 -0
  143. package/tests/e2e/publish.test.ts +150 -0
  144. package/tests/helpers/cli-e2e.ts +426 -0
  145. package/tests/helpers/pty-driver.ts +142 -0
  146. package/tests/interactive/harness/provider-prompt.ts +54 -0
  147. package/tests/interactive/login.test.ts +47 -0
  148. package/tests/interactive/provider-select.test.ts +59 -0
  149. package/tsconfig.json +7 -0
  150. package/vitest.config.ts +38 -0
@@ -0,0 +1,131 @@
1
+ import process from "node:process";
2
+
3
+ import { Command } from "@effect/platform";
4
+ import { Effect, Fiber, Stream } from "effect";
5
+
6
+ import type { CommandExecutor } from "@effect/platform";
7
+ import type { Scope } from "effect";
8
+
9
+ import { BuildFailedError } from "../../lib/exit-codes";
10
+
11
+ import type { XcodebuildFormatter } from "../../lib/xcpretty-formatter";
12
+
13
+ export const runStep = (
14
+ cmd: Command.Command,
15
+ step: string,
16
+ ): Effect.Effect<void, BuildFailedError, CommandExecutor.CommandExecutor> =>
17
+ Command.exitCode(cmd.pipe(Command.stdout("inherit"), Command.stderr("inherit"))).pipe(
18
+ Effect.mapError(
19
+ (cause) =>
20
+ new BuildFailedError({
21
+ step,
22
+ exitCode: 1,
23
+ message: `${step} failed to spawn: ${String(cause)}`,
24
+ }),
25
+ ),
26
+ Effect.flatMap((code) =>
27
+ code === 0
28
+ ? Effect.void
29
+ : Effect.fail(
30
+ new BuildFailedError({
31
+ step,
32
+ exitCode: code,
33
+ message: `${step} exited with code ${code}`,
34
+ }),
35
+ ),
36
+ ),
37
+ );
38
+
39
+ /**
40
+ * Run a build step with stdout piped through a formatter (e.g., xcpretty).
41
+ * stderr passes through to the terminal directly.
42
+ */
43
+ export const runStepFormatted = (
44
+ cmd: Command.Command,
45
+ step: string,
46
+ formatter: XcodebuildFormatter,
47
+ ): Effect.Effect<void, BuildFailedError, CommandExecutor.CommandExecutor | Scope.Scope> =>
48
+ Effect.gen(function* () {
49
+ const proc = yield* Command.start(
50
+ cmd.pipe(Command.stdout("pipe"), Command.stderr("pipe")),
51
+ ).pipe(
52
+ Effect.mapError(
53
+ (cause) =>
54
+ new BuildFailedError({
55
+ step,
56
+ exitCode: 1,
57
+ message: `${step} failed to spawn: ${String(cause)}`,
58
+ }),
59
+ ),
60
+ );
61
+
62
+ const stdoutFiber = yield* proc.stdout.pipe(
63
+ Stream.decodeText(),
64
+ Stream.splitLines,
65
+ Stream.runForEach((line) => {
66
+ const formatted = formatter.pipe(line);
67
+ return formatted.length > 0
68
+ ? Effect.sync(() => {
69
+ for (const output of formatted) {
70
+ process.stdout.write(`${output}\n`);
71
+ }
72
+ })
73
+ : Effect.void;
74
+ }),
75
+ Effect.mapError(
76
+ (cause) =>
77
+ new BuildFailedError({
78
+ step,
79
+ exitCode: 1,
80
+ message: `${step} stdout stream error: ${String(cause)}`,
81
+ }),
82
+ ),
83
+ Effect.fork,
84
+ );
85
+
86
+ const stderrFiber = yield* proc.stderr.pipe(
87
+ Stream.decodeText(),
88
+ Stream.splitLines,
89
+ Stream.runForEach((line) => Effect.sync(() => process.stderr.write(`${line}\n`))),
90
+ Effect.mapError(
91
+ (cause) =>
92
+ new BuildFailedError({
93
+ step,
94
+ exitCode: 1,
95
+ message: `${step} stderr stream error: ${String(cause)}`,
96
+ }),
97
+ ),
98
+ Effect.fork,
99
+ );
100
+
101
+ // Join fibers concurrently — stream errors are non-fatal, exit code takes precedence.
102
+ yield* Effect.all([Fiber.join(stdoutFiber), Fiber.join(stderrFiber)], {
103
+ concurrency: 2,
104
+ }).pipe(Effect.catchAll(() => Effect.void));
105
+
106
+ const code = yield* proc.exitCode.pipe(
107
+ Effect.mapError(
108
+ (cause) =>
109
+ new BuildFailedError({
110
+ step,
111
+ exitCode: 1,
112
+ message: `${step} exit code error: ${String(cause)}`,
113
+ }),
114
+ ),
115
+ );
116
+
117
+ if (code !== 0) {
118
+ // Print build summary on failure for xcpretty diagnostics
119
+ const summary = formatter.getBuildSummary();
120
+ if (summary) {
121
+ process.stderr.write(`${summary}\n`);
122
+ }
123
+
124
+ return yield* new BuildFailedError({
125
+ step,
126
+ exitCode: code,
127
+ message: `${step} exited with code ${code}`,
128
+ });
129
+ }
130
+ return undefined;
131
+ });
@@ -0,0 +1,48 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printTable } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleBuildsCommandErrors } from "./helpers";
8
+
9
+ export const compatibilityMatrixCommand = Command.make("compatibility-matrix", {}, () =>
10
+ Effect.gen(function* () {
11
+ const projectId = yield* readProjectId;
12
+ const api = yield* apiClient;
13
+ const result = yield* api.builds.compatibilityMatrix({
14
+ urlParams: { projectId },
15
+ });
16
+
17
+ if (result.rows.length === 0 && result.missingRuntimeVersions.length === 0) {
18
+ yield* Console.log("No compatibility data found.");
19
+ return;
20
+ }
21
+
22
+ if (result.rows.length > 0) {
23
+ yield* Console.log("Build-to-Channel Compatibility:");
24
+ yield* printTable(
25
+ ["Build ID", "Platform", "Runtime Version", "Channels"],
26
+ result.rows.map((row) => [
27
+ row.id,
28
+ row.platform,
29
+ row.runtimeVersion ?? "-",
30
+ row.channels.map((channel) => channel.channelName).join(", ") || "-",
31
+ ]),
32
+ );
33
+ }
34
+
35
+ if (result.missingRuntimeVersions.length > 0) {
36
+ yield* Console.log("\nMissing Runtime Versions:");
37
+ yield* printTable(
38
+ ["Channel", "Platform", "Runtime Version", "Updates"],
39
+ result.missingRuntimeVersions.map((missing) => [
40
+ missing.channelName,
41
+ missing.platform,
42
+ missing.runtimeVersion,
43
+ String(missing.updateCount),
44
+ ]),
45
+ );
46
+ }
47
+ }).pipe(handleBuildsCommandErrors),
48
+ );
@@ -0,0 +1,15 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { apiClient } from "../../services/api-client";
5
+ import { handleBuildsCommandErrors } from "./helpers";
6
+
7
+ const id = Args.text({ name: "id" });
8
+
9
+ export const deleteCommand = Command.make("delete", { id }, (opts) =>
10
+ Effect.gen(function* () {
11
+ const api = yield* apiClient;
12
+ yield* api.builds.delete({ path: { id: opts.id } });
13
+ yield* Console.log(`Build ${opts.id} deleted.`);
14
+ }).pipe(handleBuildsCommandErrors),
15
+ );
@@ -0,0 +1,34 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Effect } from "effect";
3
+
4
+ import { printKeyValue } from "../../lib/output";
5
+ import { apiClient } from "../../services/api-client";
6
+ import { handleBuildsCommandErrors } from "./helpers";
7
+
8
+ const id = Args.text({ name: "id" });
9
+
10
+ export const getCommand = Command.make("get", { id }, (opts) =>
11
+ Effect.gen(function* () {
12
+ const api = yield* apiClient;
13
+ const build = yield* api.builds.get({ path: { id: opts.id } });
14
+ yield* printKeyValue([
15
+ ["ID", build.id],
16
+ ["Platform", build.platform],
17
+ ["Profile", build.profile],
18
+ ["Distribution", build.distribution],
19
+ ["Version", build.appVersion ?? "-"],
20
+ ["Build Number", build.buildNumber ?? "-"],
21
+ ["Runtime Version", build.runtimeVersion ?? "-"],
22
+ ["Bundle ID", build.bundleId ?? "-"],
23
+ ["Git Ref", build.gitRef ?? "-"],
24
+ ["Message", build.message ?? "-"],
25
+ [
26
+ "Artifact",
27
+ build.artifact
28
+ ? `${build.artifact.format} (${String(build.artifact.byteSize)} bytes)`
29
+ : "none",
30
+ ],
31
+ ["Created", build.createdAt],
32
+ ]);
33
+ }).pipe(handleBuildsCommandErrors),
34
+ );
@@ -0,0 +1,3 @@
1
+ import { makeCommandErrorHandler } from "../../lib/command-errors";
2
+
3
+ export const handleBuildsCommandErrors = makeCommandErrorHandler();
@@ -0,0 +1,20 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console } from "effect";
3
+
4
+ import { compatibilityMatrixCommand } from "./compatibility-matrix";
5
+ import { deleteCommand } from "./delete";
6
+ import { getCommand } from "./get";
7
+ import { installLinkCommand } from "./install-link";
8
+ import { listCommand } from "./list";
9
+
10
+ export const buildsCommand = Command.make("builds", {}, () =>
11
+ Console.log("Manage builds. Run with --help for subcommands."),
12
+ ).pipe(
13
+ Command.withSubcommands([
14
+ listCommand,
15
+ getCommand,
16
+ deleteCommand,
17
+ installLinkCommand,
18
+ compatibilityMatrixCommand,
19
+ ]),
20
+ );
@@ -0,0 +1,20 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Effect } from "effect";
3
+
4
+ import { printKeyValue } from "../../lib/output";
5
+ import { apiClient } from "../../services/api-client";
6
+ import { handleBuildsCommandErrors } from "./helpers";
7
+
8
+ const id = Args.text({ name: "id" });
9
+
10
+ export const installLinkCommand = Command.make("install-link", { id }, (opts) =>
11
+ Effect.gen(function* () {
12
+ const api = yield* apiClient;
13
+ const result = yield* api.builds.getInstallLink({ path: { id: opts.id } });
14
+ yield* printKeyValue([
15
+ ["Artifact URL", result.artifactUrl],
16
+ ["Install URL", result.installUrl ?? "-"],
17
+ ["Expires", String(result.expires)],
18
+ ]);
19
+ }).pipe(handleBuildsCommandErrors),
20
+ );
@@ -0,0 +1,38 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Effect, Option } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printTable } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleBuildsCommandErrors } from "./helpers";
8
+
9
+ const platform = Options.choice("platform", ["ios", "android"]).pipe(Options.optional);
10
+ const limit = Options.integer("limit").pipe(Options.withDefault(10));
11
+
12
+ export const listCommand = Command.make("list", { platform, limit }, (opts) =>
13
+ Effect.gen(function* () {
14
+ const projectId = yield* readProjectId;
15
+ const api = yield* apiClient;
16
+
17
+ const platformFilter = Option.match(opts.platform, {
18
+ onNone: () => ({}) as Record<string, string>,
19
+ onSome: (platformValue) => ({ platform: platformValue }) as Record<string, string>,
20
+ });
21
+
22
+ const { items } = yield* api.builds.list({
23
+ urlParams: { projectId, ...platformFilter, page: 1, limit: opts.limit },
24
+ });
25
+
26
+ yield* printTable(
27
+ ["ID", "Platform", "Profile", "Distribution", "Version", "Created"],
28
+ items.map((build) => [
29
+ build.id,
30
+ build.platform,
31
+ build.profile,
32
+ build.distribution,
33
+ build.appVersion ?? "-",
34
+ build.createdAt,
35
+ ]),
36
+ );
37
+ }).pipe(handleBuildsCommandErrors),
38
+ );
@@ -0,0 +1,37 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Effect } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printKeyValue } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleChannelCommandErrors, resolveNamedResourceId } from "./helpers";
8
+
9
+ const name = Options.text("name");
10
+ const branch = Options.text("branch");
11
+
12
+ export const createCommand = Command.make("create", { name, branch }, (opts) =>
13
+ Effect.gen(function* () {
14
+ const projectId = yield* readProjectId;
15
+ const api = yield* apiClient;
16
+
17
+ const { items: branches } = yield* api.branches.list({
18
+ urlParams: { projectId, page: 1, limit: 1000 },
19
+ });
20
+ const branchId = yield* resolveNamedResourceId({
21
+ items: branches,
22
+ kind: "Branch",
23
+ name: opts.branch,
24
+ });
25
+
26
+ const channel = yield* api.channels.create({
27
+ payload: { projectId, name: opts.name, branchId },
28
+ });
29
+
30
+ yield* printKeyValue([
31
+ ["ID", channel.id],
32
+ ["Name", channel.name],
33
+ ["Branch", opts.branch],
34
+ ["Created", channel.createdAt],
35
+ ]);
36
+ }).pipe(handleChannelCommandErrors),
37
+ );
@@ -0,0 +1,15 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { apiClient } from "../../services/api-client";
5
+ import { handleChannelCommandErrors } from "./helpers";
6
+
7
+ const id = Args.text({ name: "id" });
8
+
9
+ export const deleteCommand = Command.make("delete", { id }, (opts) =>
10
+ Effect.gen(function* () {
11
+ const api = yield* apiClient;
12
+ yield* api.channels.delete({ path: { id: opts.id } });
13
+ yield* Console.log(`Channel ${opts.id} deleted.`);
14
+ }).pipe(handleChannelCommandErrors),
15
+ );
@@ -0,0 +1,18 @@
1
+ import { Data } from "effect";
2
+
3
+ import { makeCommandErrorHandler } from "../../lib/command-errors";
4
+ import { resolveNamedResourceId as resolveNamedResourceIdBase } from "../../lib/resolve-named-resource";
5
+
6
+ export class ChannelCommandError extends Data.TaggedError("ChannelCommandError")<{
7
+ readonly message: string;
8
+ }> {}
9
+
10
+ export const handleChannelCommandErrors = makeCommandErrorHandler({
11
+ ChannelCommandError: 2,
12
+ });
13
+
14
+ export const resolveNamedResourceId = (params: {
15
+ readonly items: readonly { readonly id: string; readonly name: string }[];
16
+ readonly kind: string;
17
+ readonly name: string;
18
+ }) => resolveNamedResourceIdBase(params, (message) => new ChannelCommandError({ message }));
@@ -0,0 +1,24 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console } from "effect";
3
+
4
+ import { createCommand } from "./create";
5
+ import { deleteCommand } from "./delete";
6
+ import { listCommand } from "./list";
7
+ import { pauseCommand } from "./pause";
8
+ import { resumeCommand } from "./resume";
9
+ import { rolloutCommand } from "./rollout";
10
+ import { updateCommand } from "./update";
11
+
12
+ export const channelsCommand = Command.make("channels", {}, () =>
13
+ Console.log("Manage channels. Run with --help for subcommands."),
14
+ ).pipe(
15
+ Command.withSubcommands([
16
+ listCommand,
17
+ createCommand,
18
+ updateCommand,
19
+ pauseCommand,
20
+ resumeCommand,
21
+ deleteCommand,
22
+ rolloutCommand,
23
+ ]),
24
+ );
@@ -0,0 +1,38 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { printTable } from "../../lib/output";
6
+ import { apiClient } from "../../services/api-client";
7
+ import { handleChannelCommandErrors } from "./helpers";
8
+
9
+ export const listCommand = Command.make("list", {}, () =>
10
+ Effect.gen(function* () {
11
+ const projectId = yield* readProjectId;
12
+ const api = yield* apiClient;
13
+
14
+ const [{ items }, { items: branches }] = yield* Effect.all([
15
+ api.channels.list({ urlParams: { projectId, page: 1, limit: 1000 } }),
16
+ api.branches.list({ urlParams: { projectId, page: 1, limit: 1000 } }),
17
+ ]);
18
+
19
+ if (items.length === 0) {
20
+ yield* Console.log("No channels found.");
21
+ return;
22
+ }
23
+
24
+ const branchNames = new Map(branches.map((branch) => [branch.id, branch.name]));
25
+
26
+ yield* printTable(
27
+ ["ID", "Name", "Branch", "Paused", "Rollout", "Created"],
28
+ items.map((channel) => [
29
+ channel.id,
30
+ channel.name,
31
+ branchNames.get(channel.branchId) ?? channel.branchId,
32
+ channel.isPaused ? "yes" : "no",
33
+ channel.branchMappingJson === null ? "-" : "active",
34
+ channel.createdAt,
35
+ ]),
36
+ );
37
+ }).pipe(handleChannelCommandErrors),
38
+ );
@@ -0,0 +1,15 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { apiClient } from "../../services/api-client";
5
+ import { handleChannelCommandErrors } from "./helpers";
6
+
7
+ const id = Args.text({ name: "id" });
8
+
9
+ export const pauseCommand = Command.make("pause", { id }, (opts) =>
10
+ Effect.gen(function* () {
11
+ const api = yield* apiClient;
12
+ const channel = yield* api.channels.pause({ path: { id: opts.id } });
13
+ yield* Console.log(`Channel "${channel.name}" paused.`);
14
+ }).pipe(handleChannelCommandErrors),
15
+ );
@@ -0,0 +1,15 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { apiClient } from "../../services/api-client";
5
+ import { handleChannelCommandErrors } from "./helpers";
6
+
7
+ const id = Args.text({ name: "id" });
8
+
9
+ export const resumeCommand = Command.make("resume", { id }, (opts) =>
10
+ Effect.gen(function* () {
11
+ const api = yield* apiClient;
12
+ const channel = yield* api.channels.resume({ path: { id: opts.id } });
13
+ yield* Console.log(`Channel "${channel.name}" resumed.`);
14
+ }).pipe(handleChannelCommandErrors),
15
+ );
@@ -0,0 +1,17 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { apiClient } from "../../../services/api-client";
5
+ import { handleChannelCommandErrors } from "../helpers";
6
+
7
+ const channelId = Args.text({ name: "channelId" });
8
+
9
+ export const completeCommand = Command.make("complete", { channelId }, (opts) =>
10
+ Effect.gen(function* () {
11
+ const api = yield* apiClient;
12
+ const channel = yield* api.channels.completeBranchRollout({
13
+ path: { id: opts.channelId },
14
+ });
15
+ yield* Console.log(`Completed rollout on channel "${channel.name}".`);
16
+ }).pipe(handleChannelCommandErrors),
17
+ );
@@ -0,0 +1,36 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { readProjectId } from "../../../lib/app-json";
5
+ import { rolloutPercentageOption } from "../../../lib/cli-schemas";
6
+ import { apiClient } from "../../../services/api-client";
7
+ import { handleChannelCommandErrors, resolveNamedResourceId } from "../helpers";
8
+
9
+ const channelId = Args.text({ name: "channelId" });
10
+ const branch = Options.text("branch");
11
+ const percentage = rolloutPercentageOption("percentage");
12
+
13
+ export const createCommand = Command.make("create", { channelId, branch, percentage }, (opts) =>
14
+ Effect.gen(function* () {
15
+ const projectId = yield* readProjectId;
16
+ const api = yield* apiClient;
17
+
18
+ const { items: branches } = yield* api.branches.list({
19
+ urlParams: { projectId, page: 1, limit: 1000 },
20
+ });
21
+ const newBranchId = yield* resolveNamedResourceId({
22
+ items: branches,
23
+ kind: "Branch",
24
+ name: opts.branch,
25
+ });
26
+
27
+ const channel = yield* api.channels.createBranchRollout({
28
+ path: { id: opts.channelId },
29
+ payload: { newBranchId, percentage: opts.percentage },
30
+ });
31
+
32
+ yield* Console.log(
33
+ `Started rollout on channel "${channel.name}" to branch "${opts.branch}" at ${String(opts.percentage)}%.`,
34
+ );
35
+ }).pipe(handleChannelCommandErrors),
36
+ );
@@ -0,0 +1,11 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console } from "effect";
3
+
4
+ import { completeCommand } from "./complete";
5
+ import { createCommand } from "./create";
6
+ import { revertCommand } from "./revert";
7
+ import { updateCommand } from "./update";
8
+
9
+ export const rolloutCommand = Command.make("rollout", {}, () =>
10
+ Console.log("Manage channel branch rollouts. Run with --help for subcommands."),
11
+ ).pipe(Command.withSubcommands([createCommand, updateCommand, completeCommand, revertCommand]));
@@ -0,0 +1,17 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { apiClient } from "../../../services/api-client";
5
+ import { handleChannelCommandErrors } from "../helpers";
6
+
7
+ const channelId = Args.text({ name: "channelId" });
8
+
9
+ export const revertCommand = Command.make("revert", { channelId }, (opts) =>
10
+ Effect.gen(function* () {
11
+ const api = yield* apiClient;
12
+ const channel = yield* api.channels.revertBranchRollout({
13
+ path: { id: opts.channelId },
14
+ });
15
+ yield* Console.log(`Reverted rollout on channel "${channel.name}".`);
16
+ }).pipe(handleChannelCommandErrors),
17
+ );
@@ -0,0 +1,23 @@
1
+ import { Args, Command } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { rolloutPercentageOption } from "../../../lib/cli-schemas";
5
+ import { apiClient } from "../../../services/api-client";
6
+ import { handleChannelCommandErrors } from "../helpers";
7
+
8
+ const channelId = Args.text({ name: "channelId" });
9
+ const percentage = rolloutPercentageOption("percentage");
10
+
11
+ export const updateCommand = Command.make("update", { channelId, percentage }, (opts) =>
12
+ Effect.gen(function* () {
13
+ const api = yield* apiClient;
14
+ const channel = yield* api.channels.updateBranchRollout({
15
+ path: { id: opts.channelId },
16
+ payload: { percentage: opts.percentage },
17
+ });
18
+
19
+ yield* Console.log(
20
+ `Updated rollout on channel "${channel.name}" to ${String(opts.percentage)}%.`,
21
+ );
22
+ }).pipe(handleChannelCommandErrors),
23
+ );
@@ -0,0 +1,32 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { readProjectId } from "../../lib/app-json";
5
+ import { apiClient } from "../../services/api-client";
6
+ import { handleChannelCommandErrors, resolveNamedResourceId } from "./helpers";
7
+
8
+ const id = Args.text({ name: "id" });
9
+ const branch = Options.text("branch");
10
+
11
+ export const updateCommand = Command.make("update", { id, branch }, (opts) =>
12
+ Effect.gen(function* () {
13
+ const projectId = yield* readProjectId;
14
+ const api = yield* apiClient;
15
+
16
+ const { items: branches } = yield* api.branches.list({
17
+ urlParams: { projectId, page: 1, limit: 1000 },
18
+ });
19
+ const branchId = yield* resolveNamedResourceId({
20
+ items: branches,
21
+ kind: "Branch",
22
+ name: opts.branch,
23
+ });
24
+
25
+ const channel = yield* api.channels.update({
26
+ path: { id: opts.id },
27
+ payload: { branchId },
28
+ });
29
+
30
+ yield* Console.log(`Channel "${channel.name}" relinked to branch "${opts.branch}".`);
31
+ }).pipe(handleChannelCommandErrors),
32
+ );
@@ -0,0 +1,24 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Console, Effect } from "effect";
3
+
4
+ import { deleteCredential } from "../../lib/credentials-manager";
5
+ import { apiClient } from "../../services/api-client";
6
+
7
+ const id = Args.text({ name: "id" });
8
+ const platform = Options.choice("platform", ["ios", "android"] as const);
9
+ const type = Options.choice("type", [
10
+ "distribution-certificate",
11
+ "provisioning-profile",
12
+ "push-key",
13
+ "asc-api-key",
14
+ "keystore",
15
+ "google-service-account-key",
16
+ ] as const);
17
+
18
+ export const deleteCommand = Command.make("delete", { id, platform, type }, (opts) =>
19
+ Effect.gen(function* () {
20
+ const api = yield* apiClient;
21
+ yield* deleteCredential(api, { id: opts.id, platform: opts.platform, type: opts.type });
22
+ yield* Console.log(`Credential ${opts.id} deleted.`);
23
+ }),
24
+ );
@@ -0,0 +1,10 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Console } from "effect";
3
+
4
+ import { deleteCommand } from "./delete";
5
+ import { listCommand } from "./list";
6
+ import { uploadCommand } from "./upload";
7
+
8
+ export const credentialsCommand = Command.make("credentials", {}, () =>
9
+ Console.log("Manage credentials. Run with --help for subcommands."),
10
+ ).pipe(Command.withSubcommands([listCommand, uploadCommand, deleteCommand]));