@better-update/cli 0.11.0 → 0.12.1

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.mjs CHANGED
@@ -7,19 +7,20 @@ import { Command, FetchHttpClient, FileSystem, HttpApi, HttpApiClient, HttpApiEn
7
7
  import { NodeContext } from "@effect/platform-node";
8
8
  import path, { join } from "node:path";
9
9
  import process$1 from "node:process";
10
+ import AppleUtils from "@expo/apple-utils";
11
+ import { cancel, confirm, isCancel, multiselect, password, select, text } from "@clack/prompts";
12
+ import { once } from "node:events";
13
+ import { createServer } from "node:http";
10
14
  import { maxBy, uniqBy } from "es-toolkit";
11
15
  import { createHash, randomBytes, randomUUID } from "node:crypto";
12
16
  import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
13
17
  import os from "node:os";
14
18
  import plist from "@expo/plist";
15
19
  import { ExpoRunFormatter } from "@expo/xcpretty";
16
- import { cancel, confirm, isCancel, multiselect, password, select, text } from "@clack/prompts";
17
20
  import forge from "node-forge";
18
21
  import { Buffer as Buffer$1 } from "node:buffer";
19
22
  import { getFormattedSerialNumber, getX509Certificate, parsePKCS12 } from "@expo/pkcs12";
20
23
  import qrcode from "qrcode-terminal";
21
- import { once } from "node:events";
22
- import { createServer } from "node:http";
23
24
  import { fileURLToPath } from "node:url";
24
25
 
25
26
  //#region \0rolldown/runtime.js
@@ -27,7 +28,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
27
28
 
28
29
  //#endregion
29
30
  //#region package.json
30
- var version = "0.11.0";
31
+ var version = "0.12.1";
31
32
 
32
33
  //#endregion
33
34
  //#region src/lib/interactive-mode.ts
@@ -1772,6 +1773,53 @@ const ApiClientLive = Layer.effect(ApiClientService, Effect.gen(function* () {
1772
1773
  }) };
1773
1774
  }));
1774
1775
 
1776
+ //#endregion
1777
+ //#region src/lib/prompts.ts
1778
+ const ensureInteractive = (promptName) => Effect.gen(function* () {
1779
+ if (!(yield* InteractiveMode).allow) return yield* new InteractiveProhibitedError({ message: `Interactive prompt "${promptName}" requested while running non-interactively. Provide the value via a flag, run with --interactive, or unset CI.` });
1780
+ });
1781
+ const handleCancel = (value) => {
1782
+ if (isCancel(value)) {
1783
+ cancel("Operation cancelled.");
1784
+ process.exit(130);
1785
+ }
1786
+ return value;
1787
+ };
1788
+ const promptPassword = (message) => Effect.gen(function* () {
1789
+ yield* ensureInteractive(message);
1790
+ return handleCancel(yield* Effect.promise(async () => password({ message })));
1791
+ });
1792
+ const promptSelect = (message, options) => Effect.gen(function* () {
1793
+ yield* ensureInteractive(message);
1794
+ return handleCancel(yield* Effect.promise(async () => select({
1795
+ message,
1796
+ options: [...options]
1797
+ })));
1798
+ });
1799
+ const promptMultiSelect = (message, options, config) => Effect.gen(function* () {
1800
+ yield* ensureInteractive(message);
1801
+ return handleCancel(yield* Effect.promise(async () => multiselect({
1802
+ message,
1803
+ options: [...options],
1804
+ required: config?.required ?? false
1805
+ })));
1806
+ });
1807
+ const promptText = (message, options) => Effect.gen(function* () {
1808
+ yield* ensureInteractive(message);
1809
+ return handleCancel(yield* Effect.promise(async () => text({
1810
+ message,
1811
+ ...options?.placeholder === void 0 ? {} : { placeholder: options.placeholder },
1812
+ ...options?.defaultValue === void 0 ? {} : { defaultValue: options.defaultValue }
1813
+ })));
1814
+ });
1815
+ const promptConfirm = (message, options) => Effect.gen(function* () {
1816
+ yield* ensureInteractive(message);
1817
+ return handleCancel(yield* Effect.promise(async () => confirm({
1818
+ message,
1819
+ ...options?.initialValue === void 0 ? {} : { initialValue: options.initialValue }
1820
+ })));
1821
+ });
1822
+
1775
1823
  //#endregion
1776
1824
  //#region ../../packages/safe-json/src/index.ts
1777
1825
  const parseJsonResult = (text) => {
@@ -1797,6 +1845,7 @@ const AppleSessionStoreLive = Layer.effect(AppleSessionStore, Effect.gen(functio
1797
1845
  const homeDirectory = yield* (yield* CliRuntime).homeDirectory;
1798
1846
  const sessionDir = path.join(homeDirectory, ".better-update");
1799
1847
  const sessionFile = path.join(sessionDir, "apple-session.json");
1848
+ const usernameFile = path.join(sessionDir, "apple-username.json");
1800
1849
  return {
1801
1850
  loadSession: Effect.gen(function* () {
1802
1851
  const content = yield* fs.readFileString(sessionFile).pipe(Effect.catchAll(() => Effect.succeed(null)));
@@ -1819,9 +1868,129 @@ const AppleSessionStoreLive = Layer.effect(AppleSessionStore, Effect.gen(functio
1819
1868
  yield* fs.writeFileString(sessionFile, `${JSON.stringify(session, null, 2)}\n`);
1820
1869
  yield* fs.chmod(sessionFile, 384);
1821
1870
  }).pipe(Effect.mapError((cause) => new AppleAuthError$1({ message: `Failed to save Apple session: ${formatCause(cause)}` }))),
1822
- clearSession: fs.remove(sessionFile).pipe(Effect.catchAll(() => Effect.void))
1871
+ clearSession: fs.remove(sessionFile).pipe(Effect.catchAll(() => Effect.void)),
1872
+ loadLastUsername: Effect.gen(function* () {
1873
+ const content = yield* fs.readFileString(usernameFile).pipe(Effect.catchAll(() => Effect.succeed(null)));
1874
+ if (!content) return null;
1875
+ const parsed = safeJsonParse(content);
1876
+ if (!isRecord(parsed) || typeof parsed["username"] !== "string") return null;
1877
+ return parsed["username"];
1878
+ }),
1879
+ saveLastUsername: (username) => Effect.gen(function* () {
1880
+ yield* fs.makeDirectory(sessionDir, { recursive: true });
1881
+ yield* fs.chmod(sessionDir, 448);
1882
+ yield* fs.writeFileString(usernameFile, `${JSON.stringify({ username }, null, 2)}\n`);
1883
+ yield* fs.chmod(usernameFile, 384);
1884
+ }).pipe(Effect.mapError((cause) => new AppleAuthError$1({ message: `Failed to save Apple username: ${formatCause(cause)}` })))
1885
+ };
1886
+ }));
1887
+
1888
+ //#endregion
1889
+ //#region src/services/apple-auth.ts
1890
+ const defaultAppleUtils = {
1891
+ Auth: AppleUtils.Auth,
1892
+ Session: AppleUtils.Session,
1893
+ CookieFileCache: AppleUtils.CookieFileCache
1894
+ };
1895
+ var AppleAuth = class extends Context.Tag("cli/AppleAuth")() {};
1896
+ const sessionFromAuthState = (state) => ({
1897
+ username: state.username,
1898
+ teamId: state.context.teamId ?? state.session.provider.publicProviderId,
1899
+ teamName: state.session.provider.name,
1900
+ providerId: state.context.providerId ?? state.session.provider.providerId
1901
+ });
1902
+ const sessionFromInfo = (username, info) => ({
1903
+ username,
1904
+ teamId: info.provider.publicProviderId,
1905
+ teamName: info.provider.name,
1906
+ providerId: info.provider.providerId
1907
+ });
1908
+ const restoreFromCookies = (appleUtils, cookies, providerId, teamId) => Effect.tryPromise({
1909
+ try: async () => {
1910
+ const input = {
1911
+ cookies,
1912
+ ...providerId === void 0 ? {} : { providerId },
1913
+ ...teamId === void 0 ? {} : { teamId }
1914
+ };
1915
+ return appleUtils.Auth.loginWithCookiesAsync(input);
1916
+ },
1917
+ catch: (cause) => new AppleAuthError$1({ message: `Failed to restore Apple session: ${formatCause(cause)}` })
1918
+ });
1919
+ const loginWithCredentials = (appleUtils, credentials) => Effect.tryPromise({
1920
+ try: async () => appleUtils.Auth.loginWithUserCredentialsAsync(credentials, { autoResolveProvider: true }),
1921
+ catch: (cause) => new AppleAuthError$1({ message: `Apple login failed: ${formatCause(cause)}` })
1922
+ });
1923
+ const readJarCookies = (appleUtils) => appleUtils.CookieFileCache.getCookiesJSON();
1924
+ const promptCredentials = (defaultUsername) => Effect.gen(function* () {
1925
+ const username = yield* promptText("Apple ID", defaultUsername === void 0 ? { placeholder: "you@example.com" } : {
1926
+ defaultValue: defaultUsername,
1927
+ placeholder: defaultUsername
1928
+ });
1929
+ return {
1930
+ username,
1931
+ password: yield* promptPassword(`Password for ${username}`)
1932
+ };
1933
+ });
1934
+ const interactiveLogin = (appleUtils, options, cachedUsername) => Effect.gen(function* () {
1935
+ const store = yield* AppleSessionStore;
1936
+ if (!(yield* InteractiveMode).allow) return yield* new InteractiveProhibitedError({ message: "Apple ID login requires an interactive terminal. Re-run with --interactive or provide an ASC API key (APPLE_ASC_KEY_ID, APPLE_ASC_ISSUER_ID, APPLE_ASC_KEY)." });
1937
+ const defaultUsername = options.username ?? cachedUsername;
1938
+ const { username, password } = yield* promptCredentials(defaultUsername === null ? void 0 : defaultUsername);
1939
+ yield* Effect.logInfo(`Authenticating with Apple as ${username}...`);
1940
+ const state = yield* loginWithCredentials(appleUtils, {
1941
+ username,
1942
+ password
1943
+ });
1944
+ if (state === null) return yield* new AppleAuthError$1({ message: "Apple login returned no session (unexpected)." });
1945
+ const session = sessionFromAuthState(state);
1946
+ yield* store.saveSession({
1947
+ cookies: readJarCookies(appleUtils),
1948
+ username: session.username,
1949
+ teamId: session.teamId,
1950
+ ...session.providerId === void 0 ? {} : { providerId: session.providerId }
1951
+ });
1952
+ yield* store.saveLastUsername(session.username);
1953
+ return session;
1954
+ });
1955
+ const tryRestore = (appleUtils, store) => Effect.gen(function* () {
1956
+ const stored = yield* store.loadSession;
1957
+ if (stored === null) return null;
1958
+ const restored = yield* restoreFromCookies(appleUtils, stored.cookies, stored.providerId, stored.teamId);
1959
+ if (restored === null) return null;
1960
+ return sessionFromAuthState(restored);
1961
+ });
1962
+ const makeAppleAuthLive = (appleUtils = defaultAppleUtils) => Layer.effect(AppleAuth, Effect.gen(function* () {
1963
+ const store = yield* AppleSessionStore;
1964
+ return {
1965
+ ensureLoggedIn: (options = {}) => Effect.gen(function* () {
1966
+ const restored = yield* tryRestore(appleUtils, store).pipe(Effect.catchAll(() => Effect.succeed(null)));
1967
+ if (restored !== null) return restored;
1968
+ return yield* interactiveLogin(appleUtils, options, yield* store.loadLastUsername).pipe(Effect.provideService(AppleSessionStore, store));
1969
+ }),
1970
+ logout: store.clearSession.pipe(Effect.flatMap(() => Effect.tryPromise({
1971
+ try: async () => appleUtils.Auth.logoutAsync(),
1972
+ catch: (cause) => new AppleAuthError$1({ message: formatCause(cause) })
1973
+ }).pipe(Effect.catchAll(() => Effect.void)))),
1974
+ whoami: Effect.gen(function* () {
1975
+ const stored = yield* store.loadSession;
1976
+ if (stored === null) return null;
1977
+ const restored = yield* restoreFromCookies(appleUtils, stored.cookies, stored.providerId, stored.teamId).pipe(Effect.catchAll(() => Effect.succeed(null)));
1978
+ if (restored !== null) return sessionFromAuthState(restored);
1979
+ const info = appleUtils.Session.getAnySessionInfo();
1980
+ return info === null ? {
1981
+ username: stored.username,
1982
+ teamId: stored.teamId,
1983
+ teamName: null,
1984
+ providerId: stored.providerId
1985
+ } : sessionFromInfo(stored.username, info);
1986
+ }),
1987
+ buildRequestContext: (session) => ({
1988
+ teamId: session.teamId,
1989
+ ...session.providerId === void 0 ? {} : { providerId: session.providerId }
1990
+ })
1823
1991
  };
1824
1992
  }));
1993
+ const AppleAuthLive = makeAppleAuthLive();
1825
1994
 
1826
1995
  //#endregion
1827
1996
  //#region src/services/presigned-upload.ts
@@ -1918,10 +2087,11 @@ const CliPlatformLayer = Layer.mergeAll(CliRuntimeLive, NodeContext.layer, Fetch
1918
2087
  const CliStoreLayer = Layer.mergeAll(AuthStoreLive, ConfigStoreLive, AppleSessionStoreLive).pipe(Layer.provide(CliPlatformLayer));
1919
2088
  const CliAdapterDependencies = Layer.mergeAll(CliPlatformLayer, CliStoreLayer);
1920
2089
  const ApiClientLayer = ApiClientLive.pipe(Layer.provide(CliAdapterDependencies));
2090
+ const AppleAuthLayer = AppleAuthLive.pipe(Layer.provide(CliAdapterDependencies));
1921
2091
  const PresignedUploadLayer = PresignedUploadClientLive.pipe(Layer.provide(CliPlatformLayer));
1922
2092
  const UpdateAssetUploaderLayer = UpdateAssetUploaderLive.pipe(Layer.provide(Layer.mergeAll(ApiClientLayer, PresignedUploadLayer)));
1923
2093
  const VersionCheckLayer = VersionCheckLive.pipe(Layer.provide(CliPlatformLayer));
1924
- const makeCliLive = (options) => Layer.mergeAll(CliAdapterDependencies, ApiClientLayer, PresignedUploadLayer, UpdateAssetUploaderLayer, VersionCheckLayer, makeOutputModeLayer(options.json), makeInteractiveModeLayer(options.interactive));
2094
+ const makeCliLive = (options) => Layer.mergeAll(CliAdapterDependencies, ApiClientLayer, AppleAuthLayer, PresignedUploadLayer, UpdateAssetUploaderLayer, VersionCheckLayer, makeOutputModeLayer(options.json), makeInteractiveModeLayer(options.interactive));
1925
2095
  /** Default CLI layer: human-readable, interactive. Override via flags at the entrypoint. */
1926
2096
  const CliLive = makeCliLive({
1927
2097
  json: false,
@@ -1929,63 +2099,264 @@ const CliLive = makeCliLive({
1929
2099
  });
1930
2100
 
1931
2101
  //#endregion
1932
- //#region src/application/command-exit.ts
1933
- const exitWith = (code, message) => Console.error(message).pipe(Effect.zipRight(Effect.gen(function* () {
1934
- yield* (yield* CliRuntime).setExitCode(code);
1935
- })));
2102
+ //#region src/lib/browser-login.ts
2103
+ var BrowserLoginTimeoutError = class extends Data.TaggedError("BrowserLoginTimeoutError") {};
2104
+ var BrowserLoginSessionClosedError = class extends Data.TaggedError("BrowserLoginSessionClosedError") {};
2105
+ const CALLBACK_PAGE = `<!doctype html>
2106
+ <html lang="en">
2107
+ <head>
2108
+ <meta charset="utf-8" />
2109
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2110
+ <title>better-update CLI Login</title>
2111
+ <style>
2112
+ :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
2113
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; }
2114
+ main { max-width: 32rem; line-height: 1.5; }
2115
+ code { font-family: ui-monospace, SFMono-Regular, monospace; }
2116
+ </style>
2117
+ </head>
2118
+ <body>
2119
+ <main>
2120
+ <h1>Completing CLI login...</h1>
2121
+ <p id="message">Finalizing the local session. You can keep this tab open.</p>
2122
+ </main>
2123
+ <script>
2124
+ const message = document.getElementById("message");
2125
+ const render = (text) => {
2126
+ if (message) message.textContent = text;
2127
+ };
1936
2128
 
1937
- //#endregion
1938
- //#region src/lib/command-errors.ts
1939
- const BASE_TAG_MAP = {
1940
- AuthRequiredError: 3,
1941
- ProjectNotLinkedError: 4,
1942
- NotFound: 1,
1943
- Conflict: 1,
1944
- Forbidden: 1,
1945
- BadRequest: 2,
1946
- InvalidArgumentError: 2,
1947
- InteractiveProhibitedError: 2
1948
- };
1949
- const SYSTEM_TAG_MESSAGE = {
1950
- SystemError: (error) => `Filesystem error: ${error.message}`,
1951
- BadArgument: (error) => `Invalid argument: ${error.message}`
2129
+ const params = new URLSearchParams(window.location.hash.slice(1));
2130
+ const token = params.get("token");
2131
+
2132
+ if (!token) {
2133
+ render("Missing token. Return to the CLI and run login again.");
2134
+ } else {
2135
+ fetch("/callback/token", {
2136
+ method: "POST",
2137
+ headers: { "content-type": "application/json" },
2138
+ body: JSON.stringify({ token }),
2139
+ })
2140
+ .then(async (response) => {
2141
+ if (!response.ok) {
2142
+ const body = await response.text();
2143
+ throw new Error(body || "Callback failed");
2144
+ }
2145
+ window.history.replaceState({}, document.title, window.location.pathname);
2146
+ render("CLI login complete. You can close this tab.");
2147
+ setTimeout(() => window.close(), 300);
2148
+ })
2149
+ .catch((error) => {
2150
+ render(error instanceof Error ? error.message : "Callback failed.");
2151
+ });
2152
+ }
2153
+ <\/script>
2154
+ </body>
2155
+ </html>`;
2156
+ const createBrowserLoginSession = (options = {}) => {
2157
+ const tokenDeferred = Effect.runSync(Deferred.make());
2158
+ const waitForToken = Deferred.await(tokenDeferred).pipe(Effect.timeoutFail({
2159
+ duration: options.timeoutMs === void 0 ? Duration.minutes(5) : Duration.millis(options.timeoutMs),
2160
+ onTimeout: () => new BrowserLoginTimeoutError({ message: "Timed out waiting for browser login to complete." })
2161
+ }));
2162
+ const dispose = () => {
2163
+ Effect.runSync(Deferred.fail(tokenDeferred, new BrowserLoginSessionClosedError({ message: "Browser login session closed." })));
2164
+ };
2165
+ return {
2166
+ callbackPath: "/callback",
2167
+ waitForToken,
2168
+ handleRequest: async (request) => {
2169
+ const url = new URL(request.url);
2170
+ if (request.method === "GET" && url.pathname === "/callback") return new Response(CALLBACK_PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
2171
+ if (request.method === "POST" && url.pathname === "/callback/token") try {
2172
+ const body = await request.json();
2173
+ if (!isRecord(body)) return new Response("Invalid callback payload", { status: 400 });
2174
+ const token = typeof body["token"] === "string" ? body["token"].trim() : "";
2175
+ if (token.length === 0) return new Response("Missing token", { status: 400 });
2176
+ Effect.runSync(Deferred.succeed(tokenDeferred, token));
2177
+ return Response.json({ ok: true });
2178
+ } catch {
2179
+ return new Response("Invalid callback payload", { status: 400 });
2180
+ }
2181
+ return new Response("Not found", { status: 404 });
2182
+ },
2183
+ dispose
2184
+ };
1952
2185
  };
1953
- const SYSTEM_TAG_CODE = {
1954
- SystemError: 6,
1955
- BadArgument: 6
2186
+ const readBody = async (req) => {
2187
+ const chunks = [];
2188
+ for await (const chunk of req) chunks.push(chunk);
2189
+ return Buffer.concat(chunks);
1956
2190
  };
1957
- const makeCommandErrorHandler = (extras = {}) => {
1958
- const combined = {
1959
- ...BASE_TAG_MAP,
1960
- ...extras
1961
- };
1962
- const handlers = {};
1963
- for (const [tag, code] of Object.entries(combined)) {
1964
- const systemFormat = SYSTEM_TAG_MESSAGE[tag];
1965
- const resolvedCode = SYSTEM_TAG_CODE[tag] ?? code;
1966
- handlers[tag] = (error) => exitWith(resolvedCode, systemFormat ? systemFormat(error) : error.message);
2191
+ const toFetchRequest = async (req, origin) => {
2192
+ const url = new URL(req.url ?? "/", origin);
2193
+ const method = req.method ?? "GET";
2194
+ const headers = new Headers();
2195
+ for (const [key, value] of Object.entries(req.headers)) {
2196
+ if (value === void 0) continue;
2197
+ if (Array.isArray(value)) for (const entry of value) headers.append(key, entry);
2198
+ else headers.append(key, value);
1967
2199
  }
1968
- return (effect) => {
1969
- return effect.pipe(Effect.catchTags(handlers), Effect.catchAll((cause) => exitWith(1, formatCause(cause))));
2200
+ const init = {
2201
+ method,
2202
+ headers
1970
2203
  };
2204
+ if (method !== "GET" && method !== "HEAD") {
2205
+ const body = await readBody(req);
2206
+ init.body = new Uint8Array(body);
2207
+ }
2208
+ return new Request(url, init);
1971
2209
  };
1972
-
1973
- //#endregion
1974
- //#region src/lib/citty-effect.ts
1975
- let activeCliLayer = CliLive;
1976
- const setActiveCliLayer = (layer) => {
1977
- activeCliLayer = layer;
2210
+ const writeFetchResponse = async (res, response) => {
2211
+ res.statusCode = response.status;
2212
+ response.headers.forEach((value, key) => {
2213
+ res.setHeader(key, value);
2214
+ });
2215
+ const body = await response.arrayBuffer();
2216
+ res.end(Buffer.from(body));
1978
2217
  };
1979
- const runEffect = async (effect, extras = {}) => {
1980
- const provided = makeCommandErrorHandler(extras)(effect).pipe(Effect.provide(activeCliLayer));
1981
- return Effect.runPromise(provided.pipe(Effect.asVoid));
2218
+ const handleIncoming = async (req, res, session) => {
2219
+ try {
2220
+ const request = await toFetchRequest(req, "http://127.0.0.1");
2221
+ await writeFetchResponse(res, await session.handleRequest(request));
2222
+ } catch {
2223
+ res.statusCode = 500;
2224
+ res.end("Local callback failed");
2225
+ }
2226
+ };
2227
+ const createBrowserLoginServer = async (options = {}) => {
2228
+ const session = createBrowserLoginSession(options);
2229
+ const server = createServer((req, res) => {
2230
+ handleIncoming(req, res, session).catch(() => void 0);
2231
+ });
2232
+ server.listen(0, "127.0.0.1");
2233
+ await once(server, "listening");
2234
+ const address = server.address();
2235
+ return {
2236
+ callbackUrl: `http://127.0.0.1:${address !== null && typeof address === "object" ? address.port : 0}${session.callbackPath}`,
2237
+ waitForToken: session.waitForToken,
2238
+ stop: () => {
2239
+ session.dispose();
2240
+ server.close();
2241
+ }
2242
+ };
1982
2243
  };
1983
2244
 
1984
2245
  //#endregion
1985
- //#region src/lib/expo-config.ts
1986
- const loadExpoConfigModule = () => __require("@expo/config");
1987
- const clearDynamicConfigCache = (projectRoot) => {
1988
- const { dynamicConfigPath } = loadExpoConfigModule().getConfigFilePaths(projectRoot);
2246
+ //#region src/application/login.ts
2247
+ const buildOpenBrowserCommand = (platform, url) => {
2248
+ if (platform === "darwin") return Command.make("open", url);
2249
+ if (platform === "win32") return Command.make("cmd", "/c", "start", "", url);
2250
+ return Command.make("xdg-open", url);
2251
+ };
2252
+ const openBrowser = (url) => Effect.gen(function* () {
2253
+ const command = buildOpenBrowserCommand((yield* CliRuntime).platform, url);
2254
+ if (!(yield* Command.exitCode(command).pipe(Effect.map((code) => code === 0), Effect.catchAll(() => Effect.succeed(false))))) yield* Console.log(`Open this URL manually:\n${url}`);
2255
+ });
2256
+ const browserLogin = Effect.scoped(Effect.gen(function* () {
2257
+ const configStore = yield* ConfigStore;
2258
+ const authStore = yield* AuthStore;
2259
+ const webUrl = yield* configStore.getWebUrl;
2260
+ const loginServer = yield* Effect.acquireRelease(Effect.promise(async () => createBrowserLoginServer()), (server) => Effect.sync(server.stop));
2261
+ const loginUrl = `${webUrl}/auth/cli-login?callbackUrl=${encodeURIComponent(loginServer.callbackUrl)}`;
2262
+ yield* Console.log("Opening browser for better-update login...");
2263
+ yield* Console.log("");
2264
+ yield* openBrowser(loginUrl);
2265
+ const token = yield* loginServer.waitForToken;
2266
+ yield* authStore.saveToken(token);
2267
+ yield* Console.log("");
2268
+ yield* Console.log("Logged in successfully. Token saved to ~/.better-update/auth.json");
2269
+ }));
2270
+ const manualLogin = Effect.gen(function* () {
2271
+ yield* Console.log("Log in to better-update with an existing API key");
2272
+ yield* Console.log("Get your API key from the dashboard > API Keys page");
2273
+ yield* Console.log("");
2274
+ const token = yield* promptPassword("Paste your API key (from dashboard > API Keys):");
2275
+ yield* (yield* AuthStore).saveToken(token);
2276
+ yield* Console.log("");
2277
+ yield* Console.log("Logged in successfully. Token saved to ~/.better-update/auth.json");
2278
+ });
2279
+ const runLogin = (options) => Effect.gen(function* () {
2280
+ if (options.manualApiKey) {
2281
+ yield* manualLogin;
2282
+ return;
2283
+ }
2284
+ yield* browserLogin;
2285
+ });
2286
+
2287
+ //#endregion
2288
+ //#region src/application/command-exit.ts
2289
+ const exitWith = (code, message) => Console.error(message).pipe(Effect.zipRight(Effect.gen(function* () {
2290
+ yield* (yield* CliRuntime).setExitCode(code);
2291
+ })));
2292
+
2293
+ //#endregion
2294
+ //#region src/lib/command-errors.ts
2295
+ const BASE_TAG_MAP = {
2296
+ AuthRequiredError: 3,
2297
+ ProjectNotLinkedError: 4,
2298
+ NotFound: 1,
2299
+ Conflict: 1,
2300
+ Forbidden: 1,
2301
+ BadRequest: 2,
2302
+ InvalidArgumentError: 2,
2303
+ InteractiveProhibitedError: 2
2304
+ };
2305
+ const SYSTEM_TAG_MESSAGE = {
2306
+ SystemError: (error) => `Filesystem error: ${error.message}`,
2307
+ BadArgument: (error) => `Invalid argument: ${error.message}`
2308
+ };
2309
+ const SYSTEM_TAG_CODE = {
2310
+ SystemError: 6,
2311
+ BadArgument: 6
2312
+ };
2313
+ const makeCommandErrorHandler = (extras = {}) => {
2314
+ const combined = {
2315
+ ...BASE_TAG_MAP,
2316
+ ...extras
2317
+ };
2318
+ const handlers = {};
2319
+ for (const [tag, code] of Object.entries(combined)) {
2320
+ const systemFormat = SYSTEM_TAG_MESSAGE[tag];
2321
+ const resolvedCode = SYSTEM_TAG_CODE[tag] ?? code;
2322
+ handlers[tag] = (error) => exitWith(resolvedCode, systemFormat ? systemFormat(error) : error.message);
2323
+ }
2324
+ return (effect) => {
2325
+ return effect.pipe(Effect.catchTags(handlers), Effect.catchAll((cause) => exitWith(1, formatCause(cause))));
2326
+ };
2327
+ };
2328
+
2329
+ //#endregion
2330
+ //#region src/lib/citty-effect.ts
2331
+ let activeCliLayer = CliLive;
2332
+ const setActiveCliLayer = (layer) => {
2333
+ activeCliLayer = layer;
2334
+ };
2335
+ const isAuthRequiredError = (error) => typeof error === "object" && error !== null && "_tag" in error && error._tag === "AuthRequiredError";
2336
+ const wrapWithAutoLogin = (effect) => {
2337
+ const attempt = (depth) => effect.pipe(Effect.catchAll((cause) => {
2338
+ if (depth >= 1 || !isAuthRequiredError(cause)) return Effect.fail(cause);
2339
+ return Effect.gen(function* () {
2340
+ if (!(yield* InteractiveMode).allow) return yield* Effect.fail(cause);
2341
+ yield* Console.log("");
2342
+ yield* Console.log("Authentication required.");
2343
+ yield* runLogin({ manualApiKey: false });
2344
+ yield* Console.log("");
2345
+ return yield* attempt(depth + 1);
2346
+ });
2347
+ }));
2348
+ return attempt(0);
2349
+ };
2350
+ const runEffect = async (effect, extras = {}) => {
2351
+ const provided = makeCommandErrorHandler(extras)(wrapWithAutoLogin(effect)).pipe(Effect.provide(activeCliLayer));
2352
+ return Effect.runPromise(provided.pipe(Effect.asVoid));
2353
+ };
2354
+
2355
+ //#endregion
2356
+ //#region src/lib/expo-config.ts
2357
+ const loadExpoConfigModule = () => __require("@expo/config");
2358
+ const clearDynamicConfigCache = (projectRoot) => {
2359
+ const { dynamicConfigPath } = loadExpoConfigModule().getConfigFilePaths(projectRoot);
1989
2360
  if (dynamicConfigPath) delete __require.cache[dynamicConfigPath];
1990
2361
  };
1991
2362
  const applyEnvOverlay = (envVars) => {
@@ -2023,7 +2394,7 @@ const getConfigFilePaths = (projectRoot) => Effect.try({
2023
2394
  });
2024
2395
  const extractProjectId = (config) => Effect.gen(function* () {
2025
2396
  const projectId = config.extra?.betterUpdate?.projectId;
2026
- if (typeof projectId !== "string") return yield* new ProjectNotLinkedError({ message: "Project not linked. Run `better-update link` to connect this project, or set extra.betterUpdate.projectId in your Expo config." });
2397
+ if (typeof projectId !== "string") return yield* new ProjectNotLinkedError({ message: "Project not linked. Run `better-update init` to connect this project, or set extra.betterUpdate.projectId in your Expo config." });
2027
2398
  return projectId;
2028
2399
  });
2029
2400
  const extractSlug = (config) => Effect.gen(function* () {
@@ -2336,6 +2707,73 @@ const analyticsCommand = defineCommand({
2336
2707
  }
2337
2708
  });
2338
2709
 
2710
+ //#endregion
2711
+ //#region src/commands/apple/login.ts
2712
+ const LOGIN_EXIT_EXTRAS = {
2713
+ AppleAuthError: 4,
2714
+ InteractiveProhibitedError: 4
2715
+ };
2716
+ const appleLoginCommand = defineCommand({
2717
+ meta: {
2718
+ name: "login",
2719
+ description: "Log in to your Apple Developer account (used to issue iOS certificates)"
2720
+ },
2721
+ args: { username: {
2722
+ type: "string",
2723
+ description: "Pre-fill the Apple ID prompt (defaults to last-used Apple ID)"
2724
+ } },
2725
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
2726
+ const session = yield* (yield* AppleAuth).ensureLoggedIn(args.username === void 0 ? {} : { username: args.username });
2727
+ yield* Console.log(`Logged in as ${session.username}. Team: ${session.teamName ?? session.teamId} (${session.teamId}).`);
2728
+ }), LOGIN_EXIT_EXTRAS)
2729
+ });
2730
+
2731
+ //#endregion
2732
+ //#region src/commands/apple/logout.ts
2733
+ const appleLogoutCommand = defineCommand({
2734
+ meta: {
2735
+ name: "logout",
2736
+ description: "Clear the cached Apple Developer session (cookies only; ASC API keys unaffected)"
2737
+ },
2738
+ run: async () => runEffect(Effect.gen(function* () {
2739
+ yield* (yield* AppleAuth).logout;
2740
+ yield* Console.log("Cleared Apple Developer session.");
2741
+ }))
2742
+ });
2743
+
2744
+ //#endregion
2745
+ //#region src/commands/apple/whoami.ts
2746
+ const appleWhoamiCommand = defineCommand({
2747
+ meta: {
2748
+ name: "whoami",
2749
+ description: "Show the currently-cached Apple Developer session"
2750
+ },
2751
+ run: async () => runEffect(Effect.gen(function* () {
2752
+ const session = yield* (yield* AppleAuth).whoami;
2753
+ if (session === null) {
2754
+ yield* Console.log("Not logged in to Apple. Run `better-update apple login` to start.");
2755
+ return;
2756
+ }
2757
+ yield* Console.log(`Apple ID: ${session.username}`);
2758
+ yield* Console.log(`Team: ${session.teamName ?? "(unknown)"} (${session.teamId})`);
2759
+ if (session.providerId !== void 0) yield* Console.log(`Provider: ${String(session.providerId)}`);
2760
+ }))
2761
+ });
2762
+
2763
+ //#endregion
2764
+ //#region src/commands/apple/index.ts
2765
+ const appleCommand = defineCommand({
2766
+ meta: {
2767
+ name: "apple",
2768
+ description: "Manage your Apple Developer session (used for issuing iOS credentials)"
2769
+ },
2770
+ subCommands: {
2771
+ login: appleLoginCommand,
2772
+ logout: appleLogoutCommand,
2773
+ whoami: appleWhoamiCommand
2774
+ }
2775
+ });
2776
+
2339
2777
  //#endregion
2340
2778
  //#region src/lib/cli-schemas.ts
2341
2779
  const RolloutPercentage = Schema.Number.pipe(Schema.int(), Schema.between(1, 100)).annotations({
@@ -3603,6 +4041,80 @@ const reserveAndUpload = (api, input) => Effect.gen(function* () {
3603
4041
  };
3604
4042
  });
3605
4043
 
4044
+ //#endregion
4045
+ //#region src/lib/auto-increment.ts
4046
+ const bumpBuildNumber = (current) => Effect.gen(function* () {
4047
+ const raw = current ?? "0";
4048
+ const parsed = Number.parseInt(raw, 10);
4049
+ if (Number.isNaN(parsed)) return yield* new BuildProfileError({ message: `Cannot autoIncrement ios.buildNumber: current value "${raw}" is not a base-10 integer.` });
4050
+ return String(parsed + 1);
4051
+ });
4052
+ const bumpVersionCode = (current) => Effect.gen(function* () {
4053
+ const value = current ?? 0;
4054
+ if (!Number.isInteger(value) || value < 0) return yield* new BuildProfileError({ message: `Cannot autoIncrement android.versionCode: current value ${String(value)} is not a non-negative integer.` });
4055
+ return value + 1;
4056
+ });
4057
+ const SEMVER_PATCH = /^(\d+)\.(\d+)\.(\d+)(.*)$/u;
4058
+ const bumpVersion = (current) => Effect.gen(function* () {
4059
+ if (current === void 0) return yield* new BuildProfileError({ message: "Cannot autoIncrement version: no `version` field set in Expo config." });
4060
+ const match = SEMVER_PATCH.exec(current);
4061
+ if (!match) return yield* new BuildProfileError({ message: `Cannot autoIncrement version: "${current}" is not a semver string like "1.2.3".` });
4062
+ const [, major, minor, patch, suffix] = match;
4063
+ const nextPatch = Number.parseInt(patch ?? "0", 10) + 1;
4064
+ return `${major ?? "0"}.${minor ?? "0"}.${String(nextPatch)}${suffix ?? ""}`;
4065
+ });
4066
+ const computeIosBumps = (config, mode) => Effect.gen(function* () {
4067
+ if (mode === "buildNumber") return { nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber) };
4068
+ return {
4069
+ nextVersion: yield* bumpVersion(config.version),
4070
+ nextBuildNumber: yield* bumpBuildNumber(config.ios?.buildNumber)
4071
+ };
4072
+ });
4073
+ const computeAndroidBumps = (config, mode) => Effect.gen(function* () {
4074
+ if (mode === "versionCode") return { nextVersionCode: yield* bumpVersionCode(config.android?.versionCode) };
4075
+ return {
4076
+ nextVersion: yield* bumpVersion(config.version),
4077
+ nextVersionCode: yield* bumpVersionCode(config.android?.versionCode)
4078
+ };
4079
+ });
4080
+ const buildPatch = (platform, bumps) => {
4081
+ const patch = {};
4082
+ if (bumps.nextVersion !== void 0) patch["version"] = bumps.nextVersion;
4083
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) patch["ios"] = { buildNumber: bumps.nextBuildNumber };
4084
+ if (platform === "android" && bumps.nextVersionCode !== void 0) patch["android"] = { versionCode: bumps.nextVersionCode };
4085
+ return patch;
4086
+ };
4087
+ const describeBumps = (platform, bumps) => {
4088
+ const parts = [];
4089
+ if (bumps.nextVersion !== void 0) parts.push(`version=${bumps.nextVersion}`);
4090
+ if (platform === "ios" && bumps.nextBuildNumber !== void 0) parts.push(`ios.buildNumber=${bumps.nextBuildNumber}`);
4091
+ if (platform === "android" && bumps.nextVersionCode !== void 0) parts.push(`android.versionCode=${String(bumps.nextVersionCode)}`);
4092
+ return parts.join(", ");
4093
+ };
4094
+ const computeBumps = (input) => {
4095
+ if (input.platform === "ios") return input.iosMode === void 0 ? Effect.succeed({}) : computeIosBumps(input.config, input.iosMode);
4096
+ return input.androidMode === void 0 ? Effect.succeed({}) : computeAndroidBumps(input.config, input.androidMode);
4097
+ };
4098
+ const hasAnyBump = (bumps) => bumps.nextVersion !== void 0 || bumps.nextBuildNumber !== void 0 || bumps.nextVersionCode !== void 0;
4099
+ /**
4100
+ * Bump `version` / `ios.buildNumber` / `android.versionCode` per the resolved
4101
+ * autoIncrement mode, persist via `@expo/config.modifyConfigAsync`, and log a
4102
+ * Human-readable summary. No-op when the mode is undefined. Returns the new
4103
+ * Bumped values so callers can refresh their in-memory ExpoConfig.
4104
+ */
4105
+ const applyAutoIncrement = (input) => Effect.gen(function* () {
4106
+ const bumps = yield* computeBumps(input);
4107
+ if (!hasAnyBump(bumps)) return bumps;
4108
+ const patch = buildPatch(input.platform, bumps);
4109
+ const result = yield* writeExpoConfigPatch(input.projectRoot, patch).pipe(Effect.mapError((cause) => new BuildProfileError({ message: `Failed to persist autoIncrement: ${cause.message}` })));
4110
+ if (result.type === "warn" && result.configPath === null) {
4111
+ yield* Console.log(`autoIncrement: dynamic Expo config detected, cannot write back. Update manually: ${describeBumps(input.platform, bumps)}`);
4112
+ return bumps;
4113
+ }
4114
+ yield* Console.log(`autoIncrement: bumped ${describeBumps(input.platform, bumps)}`);
4115
+ return bumps;
4116
+ });
4117
+
3606
4118
  //#endregion
3607
4119
  //#region src/lib/eas-config.ts
3608
4120
  const MAX_EXTENDS_DEPTH = 10;
@@ -3635,6 +4147,21 @@ const asAndroidDistribution = (raw) => {
3635
4147
  const value = asStringValue(raw);
3636
4148
  return value === "play-store" || value === "direct" ? value : void 0;
3637
4149
  };
4150
+ const asIosAutoIncrement = (raw) => {
4151
+ if (typeof raw === "boolean") return raw;
4152
+ const value = asStringValue(raw);
4153
+ return value === "buildNumber" || value === "version" ? value : void 0;
4154
+ };
4155
+ const asAndroidAutoIncrement = (raw) => {
4156
+ if (typeof raw === "boolean") return raw;
4157
+ const value = asStringValue(raw);
4158
+ return value === "versionCode" || value === "version" ? value : void 0;
4159
+ };
4160
+ const asAutoIncrement = (raw) => {
4161
+ if (typeof raw === "boolean") return raw;
4162
+ const value = asStringValue(raw);
4163
+ return value === "buildNumber" || value === "versionCode" || value === "version" ? value : void 0;
4164
+ };
3638
4165
  const asEasDistribution = (raw) => {
3639
4166
  const value = asStringValue(raw);
3640
4167
  return value === "internal" || value === "store" ? value : void 0;
@@ -3651,12 +4178,14 @@ const parseIosProfile = (raw) => {
3651
4178
  const scheme = asStringValue(record["scheme"]);
3652
4179
  const simulator = asBooleanValue(record["simulator"]);
3653
4180
  const enterpriseProvisioning = asEnterpriseProvisioning(record["enterpriseProvisioning"]);
4181
+ const autoIncrement = asIosAutoIncrement(record["autoIncrement"]);
3654
4182
  return {
3655
4183
  ...distribution === void 0 ? {} : { distribution },
3656
4184
  ...buildConfiguration === void 0 ? {} : { buildConfiguration },
3657
4185
  ...scheme === void 0 ? {} : { scheme },
3658
4186
  ...simulator === void 0 ? {} : { simulator },
3659
- ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning }
4187
+ ...enterpriseProvisioning === void 0 ? {} : { enterpriseProvisioning },
4188
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
3660
4189
  };
3661
4190
  };
3662
4191
  const parseAndroidProfile = (raw) => {
@@ -3667,12 +4196,14 @@ const parseAndroidProfile = (raw) => {
3667
4196
  const gradleCommand = asStringValue(record["gradleCommand"]);
3668
4197
  const format = asAndroidFormat(record["format"]);
3669
4198
  const distribution = asAndroidDistribution(record["distribution"]);
4199
+ const autoIncrement = asAndroidAutoIncrement(record["autoIncrement"]);
3670
4200
  return {
3671
4201
  ...buildType === void 0 ? {} : { buildType },
3672
4202
  ...flavor === void 0 ? {} : { flavor },
3673
4203
  ...gradleCommand === void 0 ? {} : { gradleCommand },
3674
4204
  ...format === void 0 ? {} : { format },
3675
- ...distribution === void 0 ? {} : { distribution }
4205
+ ...distribution === void 0 ? {} : { distribution },
4206
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
3676
4207
  };
3677
4208
  };
3678
4209
  const parseBuildProfile = (raw) => {
@@ -3687,6 +4218,7 @@ const parseBuildProfile = (raw) => {
3687
4218
  const ios = parseIosProfile(record["ios"]);
3688
4219
  const android = parseAndroidProfile(record["android"]);
3689
4220
  const credentialsSource = asCredentialsSource(record["credentialsSource"]);
4221
+ const autoIncrement = asAutoIncrement(record["autoIncrement"]);
3690
4222
  return {
3691
4223
  ...extendsName === void 0 ? {} : { extends: extendsName },
3692
4224
  ...developmentClient === void 0 ? {} : { developmentClient },
@@ -3696,7 +4228,8 @@ const parseBuildProfile = (raw) => {
3696
4228
  ...env === void 0 ? {} : { env },
3697
4229
  ...ios === void 0 ? {} : { ios },
3698
4230
  ...android === void 0 ? {} : { android },
3699
- ...credentialsSource === void 0 ? {} : { credentialsSource }
4231
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
4232
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
3700
4233
  };
3701
4234
  };
3702
4235
  const parseEasConfig = (text) => Effect.gen(function* () {
@@ -3764,6 +4297,7 @@ const mergeProfile = (base, overlay) => {
3764
4297
  const channel = overlay.channel ?? base.channel;
3765
4298
  const environment = overlay.environment ?? base.environment;
3766
4299
  const credentialsSource = overlay.credentialsSource ?? base.credentialsSource;
4300
+ const autoIncrement = overlay.autoIncrement ?? base.autoIncrement;
3767
4301
  return {
3768
4302
  ...overlay.extends === void 0 ? {} : { extends: overlay.extends },
3769
4303
  ...developmentClient === void 0 ? {} : { developmentClient },
@@ -3773,7 +4307,8 @@ const mergeProfile = (base, overlay) => {
3773
4307
  ...env === void 0 ? {} : { env },
3774
4308
  ...ios === void 0 ? {} : { ios },
3775
4309
  ...android === void 0 ? {} : { android },
3776
- ...credentialsSource === void 0 ? {} : { credentialsSource }
4310
+ ...credentialsSource === void 0 ? {} : { credentialsSource },
4311
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
3777
4312
  };
3778
4313
  };
3779
4314
  const collectExtendsChain = (profiles, profileName) => Effect.gen(function* () {
@@ -3827,16 +4362,36 @@ const deriveAndroidDistribution = (eas, format) => {
3827
4362
  };
3828
4363
  const hasIosIntent = (eas) => eas.ios !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
3829
4364
  const hasAndroidIntent = (eas) => eas.android !== void 0 || eas.distribution !== void 0 || eas.developmentClient === true;
4365
+ const resolveIosAutoIncrement = (eas) => {
4366
+ const override = eas.ios?.autoIncrement;
4367
+ if (override === false) return;
4368
+ if (override === true) return "buildNumber";
4369
+ if (override === "buildNumber" || override === "version") return override;
4370
+ const top = eas.autoIncrement;
4371
+ if (top === true || top === "buildNumber") return "buildNumber";
4372
+ if (top === "version") return "version";
4373
+ };
4374
+ const resolveAndroidAutoIncrement = (eas) => {
4375
+ const override = eas.android?.autoIncrement;
4376
+ if (override === false) return;
4377
+ if (override === true) return "versionCode";
4378
+ if (override === "versionCode" || override === "version") return override;
4379
+ const top = eas.autoIncrement;
4380
+ if (top === true || top === "versionCode") return "versionCode";
4381
+ if (top === "version") return "version";
4382
+ };
3830
4383
  const toIosProfile = (eas) => {
3831
4384
  if (!hasIosIntent(eas)) return;
3832
4385
  const distribution = deriveIosDistribution(eas);
3833
4386
  if (!distribution) return;
3834
4387
  const ios = eas.ios ?? {};
4388
+ const autoIncrement = resolveIosAutoIncrement(eas);
3835
4389
  return {
3836
4390
  distribution,
3837
4391
  ...ios.buildConfiguration === void 0 ? {} : { buildConfiguration: ios.buildConfiguration },
3838
4392
  ...ios.scheme === void 0 ? {} : { scheme: ios.scheme },
3839
- ...ios.simulator === void 0 ? {} : { simulator: ios.simulator }
4393
+ ...ios.simulator === void 0 ? {} : { simulator: ios.simulator },
4394
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
3840
4395
  };
3841
4396
  };
3842
4397
  const toAndroidProfile = (eas) => {
@@ -3844,12 +4399,15 @@ const toAndroidProfile = (eas) => {
3844
4399
  const format = deriveAndroidFormat(eas);
3845
4400
  if (!format) return;
3846
4401
  const android = eas.android ?? {};
4402
+ const distribution = deriveAndroidDistribution(eas, format);
4403
+ const autoIncrement = resolveAndroidAutoIncrement(eas);
3847
4404
  return {
3848
4405
  format,
3849
- distribution: deriveAndroidDistribution(eas, format),
4406
+ distribution,
3850
4407
  ...android.buildType === void 0 ? {} : { buildType: android.buildType },
3851
4408
  ...android.flavor === void 0 ? {} : { flavor: android.flavor },
3852
- ...android.gradleCommand === void 0 ? {} : { gradleCommand: android.gradleCommand }
4409
+ ...android.gradleCommand === void 0 ? {} : { gradleCommand: android.gradleCommand },
4410
+ ...autoIncrement === void 0 ? {} : { autoIncrement }
3853
4411
  };
3854
4412
  };
3855
4413
  const fromEasProfile = (eas, profileName) => {
@@ -4003,53 +4561,6 @@ const extractGradleConfig = (parsed) => {
4003
4561
  };
4004
4562
  const unquote = (input) => input.startsWith("\"") && input.endsWith("\"") ? input.slice(1, -1) : input;
4005
4563
 
4006
- //#endregion
4007
- //#region src/lib/prompts.ts
4008
- const ensureInteractive = (promptName) => Effect.gen(function* () {
4009
- if (!(yield* InteractiveMode).allow) return yield* new InteractiveProhibitedError({ message: `Interactive prompt "${promptName}" requested while running non-interactively. Provide the value via a flag, run with --interactive, or unset CI.` });
4010
- });
4011
- const handleCancel = (value) => {
4012
- if (isCancel(value)) {
4013
- cancel("Operation cancelled.");
4014
- process.exit(130);
4015
- }
4016
- return value;
4017
- };
4018
- const promptPassword = (message) => Effect.gen(function* () {
4019
- yield* ensureInteractive(message);
4020
- return handleCancel(yield* Effect.promise(async () => password({ message })));
4021
- });
4022
- const promptSelect = (message, options) => Effect.gen(function* () {
4023
- yield* ensureInteractive(message);
4024
- return handleCancel(yield* Effect.promise(async () => select({
4025
- message,
4026
- options: [...options]
4027
- })));
4028
- });
4029
- const promptMultiSelect = (message, options, config) => Effect.gen(function* () {
4030
- yield* ensureInteractive(message);
4031
- return handleCancel(yield* Effect.promise(async () => multiselect({
4032
- message,
4033
- options: [...options],
4034
- required: config?.required ?? false
4035
- })));
4036
- });
4037
- const promptText = (message, options) => Effect.gen(function* () {
4038
- yield* ensureInteractive(message);
4039
- return handleCancel(yield* Effect.promise(async () => text({
4040
- message,
4041
- ...options?.placeholder === void 0 ? {} : { placeholder: options.placeholder },
4042
- ...options?.defaultValue === void 0 ? {} : { defaultValue: options.defaultValue }
4043
- })));
4044
- });
4045
- const promptConfirm = (message, options) => Effect.gen(function* () {
4046
- yield* ensureInteractive(message);
4047
- return handleCancel(yield* Effect.promise(async () => confirm({
4048
- message,
4049
- ...options?.initialValue === void 0 ? {} : { initialValue: options.initialValue }
4050
- })));
4051
- });
4052
-
4053
4564
  //#endregion
4054
4565
  //#region src/lib/platform-detect.ts
4055
4566
  const PLATFORMS = ["ios", "android"];
@@ -4456,6 +4967,38 @@ const parseCert = (certDerBytes) => {
4456
4967
  return forge.pki.certificateFromAsn1(asn1);
4457
4968
  };
4458
4969
  const generatePassword = () => forge.util.encode64(forge.random.getBytesSync(16));
4970
+ const extractCertMetadata = (cert) => Effect.gen(function* () {
4971
+ const appleTeamId = extractTeamId$1(cert);
4972
+ if (appleTeamId === null) return yield* Effect.fail(new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" }));
4973
+ return {
4974
+ serialNumber: cert.serialNumber.toUpperCase(),
4975
+ validFrom: cert.validity.notBefore.toISOString(),
4976
+ validUntil: cert.validity.notAfter.toISOString(),
4977
+ appleTeamId,
4978
+ appleTeamName: stringField(cert, "O"),
4979
+ developerIdIdentifier: stringField(cert, "UID"),
4980
+ commonName: stringField(cert, "CN")
4981
+ };
4982
+ });
4983
+ /**
4984
+ * Parse a PKCS#12 base64 bundle and extract certificate metadata. Used by the
4985
+ * Apple-ID flow which receives a P12 directly from `createCertificateAndP12Async`
4986
+ * and needs metadata before uploading to the better-update server.
4987
+ */
4988
+ const extractMetadataFromP12 = (params) => Effect.gen(function* () {
4989
+ const certBagOid = forge.pki.oids["certBag"];
4990
+ if (certBagOid === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 OID lookup for certBag failed" }));
4991
+ const [first] = yield* Effect.try({
4992
+ try: () => {
4993
+ const p12Der = forge.util.decode64(params.p12Base64);
4994
+ const p12Asn1 = forge.asn1.fromDer(p12Der);
4995
+ return forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, params.password).getBags({ bagType: certBagOid })[certBagOid] ?? [];
4996
+ },
4997
+ catch: (error) => new CertParseError({ message: `Failed to parse PKCS#12 bundle: ${error instanceof Error ? error.message : String(error)}` })
4998
+ });
4999
+ if (first?.cert === void 0) return yield* Effect.fail(new CertParseError({ message: "PKCS#12 bundle does not contain a certificate" }));
5000
+ return yield* extractCertMetadata(first.cert);
5001
+ });
4459
5002
  const buildDistributionCertP12 = (params) => Effect.gen(function* () {
4460
5003
  const result = yield* Effect.try({
4461
5004
  try: () => {
@@ -4473,20 +5016,11 @@ const buildDistributionCertP12 = (params) => Effect.gen(function* () {
4473
5016
  },
4474
5017
  catch: (error) => new CertParseError({ message: `Failed to assemble .p12: ${error instanceof Error ? error.message : String(error)}` })
4475
5018
  });
4476
- const appleTeamId = extractTeamId$1(result.cert);
4477
- if (appleTeamId === null) return yield* Effect.fail(new CertParseError({ message: "Could not extract Apple team identifier from certificate subject" }));
5019
+ const metadata = yield* extractCertMetadata(result.cert);
4478
5020
  return {
4479
5021
  p12Base64: result.p12Base64,
4480
5022
  password: result.password,
4481
- metadata: {
4482
- serialNumber: result.cert.serialNumber.toUpperCase(),
4483
- validFrom: result.cert.validity.notBefore.toISOString(),
4484
- validUntil: result.cert.validity.notAfter.toISOString(),
4485
- appleTeamId,
4486
- appleTeamName: stringField(result.cert, "O"),
4487
- developerIdIdentifier: stringField(result.cert, "UID"),
4488
- commonName: stringField(result.cert, "CN")
4489
- }
5023
+ metadata
4490
5024
  };
4491
5025
  });
4492
5026
 
@@ -4519,7 +5053,7 @@ const generateCertificateSigningRequest = async () => {
4519
5053
 
4520
5054
  //#endregion
4521
5055
  //#region src/lib/credentials-generator.ts
4522
- const DISTRIBUTION_TO_PROFILE_TYPE = {
5056
+ const DISTRIBUTION_TO_PROFILE_TYPE$1 = {
4523
5057
  APP_STORE: "IOS_APP_STORE",
4524
5058
  AD_HOC: "IOS_APP_ADHOC",
4525
5059
  DEVELOPMENT: "IOS_APP_DEVELOPMENT",
@@ -4707,7 +5241,7 @@ const generateAndUploadProvisioningProfile = (api, input) => Effect.gen(function
4707
5241
  }));
4708
5242
  const profileBytes = fromBase64((yield* createProvisioningProfile(ascCreds, {
4709
5243
  profileName: `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`,
4710
- profileType: DISTRIBUTION_TO_PROFILE_TYPE[input.distributionType],
5244
+ profileType: DISTRIBUTION_TO_PROFILE_TYPE$1[input.distributionType],
4711
5245
  bundleIdAscId,
4712
5246
  certificateAscIds: [certAscId],
4713
5247
  deviceAscIds
@@ -4730,93 +5264,181 @@ const generateAndUploadProvisioningProfile = (api, input) => Effect.gen(function
4730
5264
  });
4731
5265
 
4732
5266
  //#endregion
4733
- //#region src/application/credentials-interactive.ts
4734
- const hasTag = (cause) => typeof cause === "object" && cause !== null && "_tag" in cause;
4735
- const isMissingResolveError = (cause) => hasTag(cause) && (cause._tag === "NotFound" || cause._tag === "BadRequest");
4736
- const generateKeystoreInteractive = (api) => Effect.gen(function* () {
4737
- const alias = yield* promptText("Key alias", { placeholder: "upload-key" });
4738
- const storePassword = yield* promptPassword("Keystore password");
4739
- const keyPassword = yield* promptPassword("Key password");
4740
- const commonName = yield* promptText("Common name (CN)", { placeholder: "Your App" });
4741
- const organization = yield* promptText("Organization (O)", { placeholder: "Your Company" });
4742
- yield* Console.log("Generating keystore with keytool...");
4743
- return (yield* generateAndUploadKeystore(api, {
4744
- keyAlias: alias,
4745
- storePassword,
4746
- keyPassword,
4747
- commonName,
4748
- organization
4749
- })).id;
5267
+ //#region src/lib/credentials-generator-apple-id.ts
5268
+ const DISTRIBUTION_TO_PROFILE_TYPE = {
5269
+ APP_STORE: AppleUtils.ProfileType.IOS_APP_STORE,
5270
+ AD_HOC: AppleUtils.ProfileType.IOS_APP_ADHOC,
5271
+ DEVELOPMENT: AppleUtils.ProfileType.IOS_APP_DEVELOPMENT,
5272
+ ENTERPRISE: AppleUtils.ProfileType.IOS_APP_INHOUSE
5273
+ };
5274
+ const DISTRIBUTION_TO_CERTIFICATE_TYPE = {
5275
+ APP_STORE: AppleUtils.CertificateType.IOS_DISTRIBUTION,
5276
+ AD_HOC: AppleUtils.CertificateType.IOS_DISTRIBUTION,
5277
+ ENTERPRISE: AppleUtils.CertificateType.IOS_DISTRIBUTION,
5278
+ DEVELOPMENT: AppleUtils.CertificateType.IOS_DEVELOPMENT
5279
+ };
5280
+ var AppleIdGenerateFailedError = class extends Data.TaggedError("AppleIdGenerateFailedError") {};
5281
+ const wrap = (step, run) => Effect.tryPromise({
5282
+ try: run,
5283
+ catch: (cause) => new AppleIdGenerateFailedError({
5284
+ step,
5285
+ message: cause instanceof Error ? cause.message : String(cause)
5286
+ })
4750
5287
  });
4751
- const pickExistingKeystore = (api) => Effect.gen(function* () {
4752
- const keystores = yield* api.androidUploadKeystores.list();
4753
- if (keystores.items.length === 0) return yield* Effect.fail(new MissingCredentialsError({
4754
- message: "No existing keystores in this organization.",
4755
- hint: "Re-run and choose 'Generate new keystore'."
4756
- }));
4757
- return yield* promptSelect("Select a keystore", keystores.items.map((item) => ({
4758
- value: item.id,
4759
- label: item.keyAlias
5288
+ const generateAndUploadDistributionCertificateViaAppleId = (api, input) => Effect.gen(function* () {
5289
+ const ctx = input.context;
5290
+ const certificateType = input.certificateType === "IOS_DEVELOPMENT" ? AppleUtils.CertificateType.IOS_DEVELOPMENT : AppleUtils.CertificateType.IOS_DISTRIBUTION;
5291
+ const result = yield* wrap("apple-create-certificate", async () => AppleUtils.createCertificateAndP12Async(ctx, { certificateType }));
5292
+ const metadata = yield* extractMetadataFromP12({
5293
+ p12Base64: result.certificateP12,
5294
+ password: result.password
5295
+ }).pipe(Effect.mapError((cause) => new AppleIdGenerateFailedError({
5296
+ step: "parse-p12",
5297
+ message: cause.message
4760
5298
  })));
5299
+ return {
5300
+ id: (yield* api.appleDistributionCertificates.upload({ payload: {
5301
+ p12Base64: result.certificateP12,
5302
+ p12Password: result.password,
5303
+ serialNumber: metadata.serialNumber,
5304
+ appleTeamIdentifier: metadata.appleTeamId,
5305
+ ...metadata.appleTeamName === null ? {} : { appleTeamName: metadata.appleTeamName },
5306
+ ...metadata.developerIdIdentifier === null ? {} : { developerIdIdentifier: metadata.developerIdIdentifier },
5307
+ validFrom: metadata.validFrom,
5308
+ validUntil: metadata.validUntil
5309
+ } })).id,
5310
+ serialNumber: metadata.serialNumber,
5311
+ appleTeamId: metadata.appleTeamId,
5312
+ developerPortalIdentifier: result.certificate.id
5313
+ };
4761
5314
  });
4762
- const resolveAndroidAppId = (api, input) => Effect.gen(function* () {
4763
- const existing = (yield* api.androidApplicationIdentifiers.list({ path: { projectId: input.projectId } })).items.find((item) => item.packageName === input.applicationIdentifier);
4764
- if (existing !== void 0) return existing.id;
4765
- return (yield* api.androidApplicationIdentifiers.create({
4766
- path: { projectId: input.projectId },
4767
- payload: { packageName: input.applicationIdentifier }
4768
- })).id;
4769
- });
4770
- const resolveAndroidKeystoreId = (api, choice) => choice === "generate" ? generateKeystoreInteractive(api) : pickExistingKeystore(api);
4771
- const setupAndroidInteractive = (api, input) => Effect.gen(function* () {
4772
- yield* Console.log("");
4773
- yield* Console.log(`No Android build credentials configured for ${input.applicationIdentifier}.`);
4774
- const appId = yield* resolveAndroidAppId(api, input);
4775
- const choice = yield* promptSelect("How would you like to provide a keystore?", [
4776
- {
4777
- value: "generate",
4778
- label: "Generate new keystore"
4779
- },
4780
- {
4781
- value: "existing",
4782
- label: "Pick an existing keystore"
4783
- },
4784
- {
4785
- value: "abort",
4786
- label: "Abort — I'll configure it in the dashboard"
4787
- }
4788
- ]);
4789
- if (choice === "abort") return yield* Effect.fail(new MissingCredentialsError({
4790
- message: `Build aborted — no keystore bound to ${input.applicationIdentifier}.`,
4791
- hint: "Run `better-update credentials generate keystore` or upload via the dashboard."
5315
+ const findOrCreateBundleId = (ctx, bundleIdentifier) => Effect.gen(function* () {
5316
+ const existing = yield* wrap("apple-find-bundle-id", async () => AppleUtils.BundleId.findAsync(ctx, { identifier: bundleIdentifier }));
5317
+ if (existing !== null) return existing.id;
5318
+ return (yield* wrap("apple-create-bundle-id", async () => AppleUtils.BundleId.createAsync(ctx, {
5319
+ identifier: bundleIdentifier,
5320
+ name: bundleIdentifier,
5321
+ platform: AppleUtils.BundleIdPlatform.IOS
5322
+ }))).id;
5323
+ });
5324
+ const findAscCertificateId = (ctx, serialNumber, certificateType) => Effect.gen(function* () {
5325
+ const certs = yield* wrap("apple-list-certificates", async () => AppleUtils.Certificate.getAsync(ctx, { query: { filter: { certificateType } } }));
5326
+ const upper = serialNumber.toUpperCase();
5327
+ const match = certs.find((entry) => entry.attributes.serialNumber.toUpperCase() === upper);
5328
+ if (match === void 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
5329
+ step: "match-apple-certificate",
5330
+ message: `Distribution certificate ${serialNumber} not present on Apple Developer Portal; upload or re-generate it`
4792
5331
  }));
4793
- const keystoreId = yield* resolveAndroidKeystoreId(api, choice);
4794
- yield* api.androidBuildCredentials.create({
4795
- path: { applicationIdentifierId: appId },
5332
+ return match.id;
5333
+ });
5334
+ const collectIosDeviceIds = (ctx, deviceIds) => Effect.gen(function* () {
5335
+ const devices = yield* wrap("apple-list-devices", async () => AppleUtils.Device.getAllIOSProfileDevicesAsync(ctx));
5336
+ if (deviceIds === void 0) return devices.map((device) => device.id);
5337
+ const allowed = new Set(deviceIds);
5338
+ return devices.filter((device) => allowed.has(device.id)).map((device) => device.id);
5339
+ });
5340
+ const generateAndUploadProvisioningProfileViaAppleId = (api, input) => Effect.gen(function* () {
5341
+ const ctx = input.context;
5342
+ const cert = yield* api.appleDistributionCertificates.list().pipe(Effect.map(({ items }) => items.find((item) => item.id === input.distributionCertificateId)), Effect.flatMap((match) => match === void 0 ? Effect.fail(new AppleIdGenerateFailedError({
5343
+ step: "load-distribution-certificate",
5344
+ message: `Distribution certificate ${input.distributionCertificateId} not found`
5345
+ })) : Effect.succeed(match)));
5346
+ const certificateType = DISTRIBUTION_TO_CERTIFICATE_TYPE[input.distributionType];
5347
+ const [certAscId, bundleIdAscId] = yield* Effect.all([findAscCertificateId(ctx, cert.serialNumber, certificateType), findOrCreateBundleId(ctx, input.bundleIdentifier)], { concurrency: 2 });
5348
+ const useDevices = input.distributionType === "AD_HOC" || input.distributionType === "DEVELOPMENT";
5349
+ const deviceIds = useDevices ? yield* collectIosDeviceIds(ctx, input.deviceIds) : [];
5350
+ if (useDevices && deviceIds.length === 0) return yield* Effect.fail(new AppleIdGenerateFailedError({
5351
+ step: "collect-devices",
5352
+ message: "No registered devices to attach to the provisioning profile"
5353
+ }));
5354
+ const profileName = `${input.bundleIdentifier} ${input.distributionType} ${Date.now()}`;
5355
+ const { profileContent } = (yield* wrap("apple-create-profile", async () => AppleUtils.Profile.createAsync(ctx, {
5356
+ bundleId: bundleIdAscId,
5357
+ certificates: [certAscId],
5358
+ devices: deviceIds,
5359
+ name: profileName,
5360
+ profileType: DISTRIBUTION_TO_PROFILE_TYPE[input.distributionType]
5361
+ }))).attributes;
5362
+ if (profileContent === null) return yield* Effect.fail(new AppleIdGenerateFailedError({
5363
+ step: "extract-profile-content",
5364
+ message: "Apple returned a profile with no content (likely expired/invalid)"
5365
+ }));
5366
+ const profileBytes = fromBase64(profileContent);
5367
+ const rosterHash = useDevices ? computeDeviceRosterHashHex(deviceIds) : void 0;
5368
+ const created = yield* api.appleProvisioningProfiles.upload({ payload: {
5369
+ profileBase64: toBase64(profileBytes),
5370
+ appleDistributionCertificateId: input.distributionCertificateId,
5371
+ isManaged: true,
5372
+ ...rosterHash === void 0 ? {} : { deviceRosterHash: rosterHash }
5373
+ } });
5374
+ return {
5375
+ id: created.id,
5376
+ bundleIdentifier: created.bundleIdentifier,
5377
+ distributionType: created.distributionType,
5378
+ profileName: created.profileName,
5379
+ validUntil: created.validUntil,
5380
+ developerPortalIdentifier: created.developerPortalIdentifier
5381
+ };
5382
+ });
5383
+
5384
+ //#endregion
5385
+ //#region src/application/credentials-interactive-apple-id.ts
5386
+ const chooseIosSetupPath = (api) => Effect.gen(function* () {
5387
+ if (!(yield* api.ascApiKeys.list()).items.some((key) => key.appleTeamId !== null)) return "apple-id";
5388
+ return yield* promptSelect("How would you like to provide your iOS credentials?", [{
5389
+ value: "apple-id",
5390
+ label: "Login with Apple ID (recommended for interactive use)"
5391
+ }, {
5392
+ value: "asc-key",
5393
+ label: "Use an App Store Connect API key"
5394
+ }]);
5395
+ });
5396
+ const setupIosViaAppleId = (api, input) => Effect.gen(function* () {
5397
+ const auth = yield* AppleAuth;
5398
+ const session = yield* auth.ensureLoggedIn();
5399
+ const ctx = auth.buildRequestContext(session);
5400
+ yield* Console.log(`Logged in as ${session.username}. Team: ${session.teamName ?? session.teamId} (${session.teamId}).`);
5401
+ yield* Console.log("Generating distribution certificate via Apple ID...");
5402
+ const cert = yield* generateAndUploadDistributionCertificateViaAppleId(api, { context: ctx });
5403
+ const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
5404
+ yield* Console.log("Generating provisioning profile via Apple ID...");
5405
+ const profile = yield* generateAndUploadProvisioningProfileViaAppleId(api, {
5406
+ context: ctx,
5407
+ distributionCertificateId: cert.id,
5408
+ bundleIdentifier: input.bundleIdentifier,
5409
+ distributionType
5410
+ });
5411
+ yield* api.iosBundleConfigurations.create({
5412
+ path: { projectId: input.projectId },
4796
5413
  payload: {
4797
- name: "Default",
4798
- isDefault: true,
4799
- androidUploadKeystoreId: keystoreId
5414
+ bundleIdentifier: input.bundleIdentifier,
5415
+ distributionType,
5416
+ appleTeamId: cert.appleTeamId,
5417
+ appleDistributionCertificateId: cert.id,
5418
+ appleProvisioningProfileId: profile.id
4800
5419
  }
4801
5420
  });
4802
- yield* Console.log("Android build credentials configured.");
5421
+ yield* Console.log("iOS bundle configuration saved.");
4803
5422
  });
4804
- const ensureAndroidCredentialsAvailable = (api, input) => api.buildCredentials.resolve({
4805
- path: { projectId: input.projectId },
4806
- payload: {
4807
- platform: "android",
4808
- applicationIdentifier: input.applicationIdentifier
4809
- }
4810
- }).pipe(Effect.asVoid);
4811
- const ensureAndroidCredentials = (api, input, options) => ensureAndroidCredentialsAvailable(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
4812
- const mode = yield* InteractiveMode;
4813
- if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
4814
- message: `No Android build credentials for ${input.applicationIdentifier}.`,
4815
- hint: options.freezeCredentials ? "Run `better-update credentials generate` first, or remove --freeze-credentials." : "Run `better-update credentials generate` first, or rerun with --interactive to configure now."
4816
- }));
4817
- yield* setupAndroidInteractive(api, input);
4818
- return yield* ensureAndroidCredentialsAvailable(api, input);
4819
- })));
5423
+ const regenerateProvisioningProfileViaAppleId = (api, input) => Effect.gen(function* () {
5424
+ const auth = yield* AppleAuth;
5425
+ const session = yield* auth.ensureLoggedIn();
5426
+ yield* Console.log("Regenerating provisioning profile via Apple ID...");
5427
+ const created = yield* generateAndUploadProvisioningProfileViaAppleId(api, {
5428
+ context: auth.buildRequestContext(session),
5429
+ distributionCertificateId: input.distributionCertificateId,
5430
+ bundleIdentifier: input.bundleIdentifier,
5431
+ distributionType: input.distributionType
5432
+ });
5433
+ yield* api.iosBundleConfigurations.update({
5434
+ path: { id: input.bundleConfigurationId },
5435
+ payload: { appleProvisioningProfileId: created.id }
5436
+ });
5437
+ return created;
5438
+ });
5439
+
5440
+ //#endregion
5441
+ //#region src/application/credentials-interactive-ios-asc.ts
4820
5442
  const interactiveCertLimitRecover = (api, ascApiKeyId) => Effect.gen(function* () {
4821
5443
  yield* Console.log("");
4822
5444
  yield* Console.log("Apple reports the certificate limit was hit (max 3 distribution certs per team).");
@@ -4919,9 +5541,7 @@ const resolveIosProfileId = (api, input, ctx) => Effect.gen(function* () {
4919
5541
  label: profile.profileName ?? profile.developerPortalIdentifier ?? profile.id
4920
5542
  })));
4921
5543
  });
4922
- const setupIosInteractive = (api, input) => Effect.gen(function* () {
4923
- yield* Console.log("");
4924
- yield* Console.log(`No iOS bundle configuration for ${input.bundleIdentifier} (${input.distribution}).`);
5544
+ const setupIosViaAscKey = (api, input) => Effect.gen(function* () {
4925
5545
  const { certId, cert } = yield* pickIosCertificate(api);
4926
5546
  const ascKeyId = yield* pickIosAscKey(api, cert.appleTeamId);
4927
5547
  const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
@@ -4944,6 +5564,101 @@ const setupIosInteractive = (api, input) => Effect.gen(function* () {
4944
5564
  });
4945
5565
  yield* Console.log("iOS bundle configuration saved.");
4946
5566
  });
5567
+
5568
+ //#endregion
5569
+ //#region src/application/credentials-interactive.ts
5570
+ const hasTag = (cause) => typeof cause === "object" && cause !== null && "_tag" in cause;
5571
+ const isMissingResolveError = (cause) => hasTag(cause) && (cause._tag === "NotFound" || cause._tag === "BadRequest");
5572
+ const generateKeystoreInteractive = (api) => Effect.gen(function* () {
5573
+ const alias = yield* promptText("Key alias", { placeholder: "upload-key" });
5574
+ const storePassword = yield* promptPassword("Keystore password");
5575
+ const keyPassword = yield* promptPassword("Key password");
5576
+ const commonName = yield* promptText("Common name (CN)", { placeholder: "Your App" });
5577
+ const organization = yield* promptText("Organization (O)", { placeholder: "Your Company" });
5578
+ yield* Console.log("Generating keystore with keytool...");
5579
+ return (yield* generateAndUploadKeystore(api, {
5580
+ keyAlias: alias,
5581
+ storePassword,
5582
+ keyPassword,
5583
+ commonName,
5584
+ organization
5585
+ })).id;
5586
+ });
5587
+ const pickExistingKeystore = (api) => Effect.gen(function* () {
5588
+ const keystores = yield* api.androidUploadKeystores.list();
5589
+ if (keystores.items.length === 0) return yield* Effect.fail(new MissingCredentialsError({
5590
+ message: "No existing keystores in this organization.",
5591
+ hint: "Re-run and choose 'Generate new keystore'."
5592
+ }));
5593
+ return yield* promptSelect("Select a keystore", keystores.items.map((item) => ({
5594
+ value: item.id,
5595
+ label: item.keyAlias
5596
+ })));
5597
+ });
5598
+ const resolveAndroidAppId = (api, input) => Effect.gen(function* () {
5599
+ const existing = (yield* api.androidApplicationIdentifiers.list({ path: { projectId: input.projectId } })).items.find((item) => item.packageName === input.applicationIdentifier);
5600
+ if (existing !== void 0) return existing.id;
5601
+ return (yield* api.androidApplicationIdentifiers.create({
5602
+ path: { projectId: input.projectId },
5603
+ payload: { packageName: input.applicationIdentifier }
5604
+ })).id;
5605
+ });
5606
+ const resolveAndroidKeystoreId = (api, choice) => choice === "generate" ? generateKeystoreInteractive(api) : pickExistingKeystore(api);
5607
+ const setupAndroidInteractive = (api, input) => Effect.gen(function* () {
5608
+ yield* Console.log("");
5609
+ yield* Console.log(`No Android build credentials configured for ${input.applicationIdentifier}.`);
5610
+ const appId = yield* resolveAndroidAppId(api, input);
5611
+ const choice = yield* promptSelect("How would you like to provide a keystore?", [
5612
+ {
5613
+ value: "generate",
5614
+ label: "Generate new keystore"
5615
+ },
5616
+ {
5617
+ value: "existing",
5618
+ label: "Pick an existing keystore"
5619
+ },
5620
+ {
5621
+ value: "abort",
5622
+ label: "Abort — I'll configure it in the dashboard"
5623
+ }
5624
+ ]);
5625
+ if (choice === "abort") return yield* Effect.fail(new MissingCredentialsError({
5626
+ message: `Build aborted — no keystore bound to ${input.applicationIdentifier}.`,
5627
+ hint: "Run `better-update credentials generate keystore` or upload via the dashboard."
5628
+ }));
5629
+ const keystoreId = yield* resolveAndroidKeystoreId(api, choice);
5630
+ yield* api.androidBuildCredentials.create({
5631
+ path: { applicationIdentifierId: appId },
5632
+ payload: {
5633
+ name: "Default",
5634
+ isDefault: true,
5635
+ androidUploadKeystoreId: keystoreId
5636
+ }
5637
+ });
5638
+ yield* Console.log("Android build credentials configured.");
5639
+ });
5640
+ const ensureAndroidCredentialsAvailable = (api, input) => api.buildCredentials.resolve({
5641
+ path: { projectId: input.projectId },
5642
+ payload: {
5643
+ platform: "android",
5644
+ applicationIdentifier: input.applicationIdentifier
5645
+ }
5646
+ }).pipe(Effect.asVoid);
5647
+ const ensureAndroidCredentials = (api, input, options) => ensureAndroidCredentialsAvailable(api, input).pipe(Effect.catchIf(isMissingResolveError, () => Effect.gen(function* () {
5648
+ const mode = yield* InteractiveMode;
5649
+ if (options.freezeCredentials || !mode.allow) return yield* Effect.fail(new MissingCredentialsError({
5650
+ message: `No Android build credentials for ${input.applicationIdentifier}.`,
5651
+ hint: options.freezeCredentials ? "Run `better-update credentials generate` first, or remove --freeze-credentials." : "Run `better-update credentials generate` first, or rerun with --interactive to configure now."
5652
+ }));
5653
+ yield* setupAndroidInteractive(api, input);
5654
+ return yield* ensureAndroidCredentialsAvailable(api, input);
5655
+ })));
5656
+ const setupIosInteractive = (api, input) => Effect.gen(function* () {
5657
+ yield* Console.log("");
5658
+ yield* Console.log(`No iOS bundle configuration for ${input.bundleIdentifier} (${input.distribution}).`);
5659
+ if ((yield* chooseIosSetupPath(api)) === "apple-id") return yield* setupIosViaAppleId(api, input);
5660
+ return yield* setupIosViaAscKey(api, input);
5661
+ });
4947
5662
  const resolveIosBuildCredentials = (api, input) => api.buildCredentials.resolve({
4948
5663
  path: { projectId: input.projectId },
4949
5664
  payload: {
@@ -4963,16 +5678,23 @@ const findBoundIosConfig = (api, input) => Effect.gen(function* () {
4963
5678
  });
4964
5679
  const regenerateProvisioningProfile = (api, input) => Effect.gen(function* () {
4965
5680
  const config = yield* findBoundIosConfig(api, input);
4966
- if (config.ascApiKeyId === null || config.appleDistributionCertificateId === null) return yield* new MissingCredentialsError({
4967
- message: "Profile cannot be regenerated: bundle configuration is missing ASC key or distribution certificate",
5681
+ if (config.appleDistributionCertificateId === null) return yield* new MissingCredentialsError({
5682
+ message: "Profile cannot be regenerated: bundle configuration is missing the distribution certificate",
4968
5683
  hint: "Re-bind credentials via `better-update credentials generate` or the dashboard"
4969
5684
  });
5685
+ const distributionType = IOS_DISTRIBUTION_TO_TYPE[input.distribution];
5686
+ if (config.ascApiKeyId === null) return yield* regenerateProvisioningProfileViaAppleId(api, {
5687
+ bundleIdentifier: input.bundleIdentifier,
5688
+ distributionCertificateId: config.appleDistributionCertificateId,
5689
+ distributionType,
5690
+ bundleConfigurationId: config.id
5691
+ });
4970
5692
  yield* Console.log("Regenerating provisioning profile via App Store Connect API...");
4971
5693
  const created = yield* generateAndUploadProvisioningProfile(api, {
4972
5694
  ascApiKeyId: config.ascApiKeyId,
4973
5695
  distributionCertificateId: config.appleDistributionCertificateId,
4974
5696
  bundleIdentifier: input.bundleIdentifier,
4975
- distributionType: IOS_DISTRIBUTION_TO_TYPE[input.distribution]
5697
+ distributionType
4976
5698
  });
4977
5699
  yield* api.iosBundleConfigurations.update({
4978
5700
  path: { id: config.id },
@@ -5076,6 +5798,17 @@ const runAndroidPlatformBuild = (input) => Effect.gen(function* () {
5076
5798
  };
5077
5799
  });
5078
5800
  const runPlatformBuild = (input) => input.platform === "ios" ? runIosPlatformBuild(input) : runAndroidPlatformBuild(input);
5801
+ const resolveProfileName = (projectRoot, requested) => Effect.gen(function* () {
5802
+ const easConfig = yield* readEasJson(projectRoot);
5803
+ const available = Object.keys(easConfig.build ?? {});
5804
+ if (available.includes(requested)) return requested;
5805
+ if (!(yield* InteractiveMode).allow || available.length === 0) return requested;
5806
+ yield* Console.log(`Build profile "${requested}" not found in eas.json.`);
5807
+ return yield* promptSelect("Pick a build profile:", available.map((name) => ({
5808
+ value: name,
5809
+ label: name
5810
+ })));
5811
+ });
5079
5812
  const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
5080
5813
  const api = yield* apiClient;
5081
5814
  const projectRoot = yield* (yield* CliRuntime).cwd;
@@ -5087,11 +5820,18 @@ const runBuildWorkflow = (options) => Effect.scoped(Effect.gen(function* () {
5087
5820
  const baseConfig = yield* readExpoConfig(projectRoot);
5088
5821
  const projectId = yield* extractProjectId(baseConfig);
5089
5822
  const platform = yield* detectPlatform(options.platform, baseConfig);
5090
- const profile = yield* readBuildProfile(projectRoot, options.profileName);
5823
+ const profile = yield* readBuildProfile(projectRoot, yield* resolveProfileName(projectRoot, options.profileName));
5091
5824
  const envVars = yield* pullEnvVars(api, {
5092
5825
  projectId,
5093
5826
  environment: profile.environment
5094
5827
  });
5828
+ yield* applyAutoIncrement({
5829
+ projectRoot,
5830
+ platform,
5831
+ config: yield* readExpoConfig(projectRoot, envVars),
5832
+ ...platform === "ios" && profile.ios?.autoIncrement !== void 0 ? { iosMode: profile.ios.autoIncrement } : {},
5833
+ ...platform === "android" && profile.android?.autoIncrement !== void 0 ? { androidMode: profile.android.autoIncrement } : {}
5834
+ });
5095
5835
  const appMeta = yield* readAppMeta(yield* readExpoConfig(projectRoot, envVars), platform);
5096
5836
  const runtimeVersion = yield* resolveRuntimeVersion({
5097
5837
  raw: appMeta.rawRuntimeVersion,
@@ -10212,25 +10952,59 @@ const exportCommand = defineCommand({
10212
10952
 
10213
10953
  //#endregion
10214
10954
  //#region src/commands/env/get.ts
10955
+ const resolveByKey = (api, key, environment) => Effect.gen(function* () {
10956
+ const env = environment === void 0 ? void 0 : yield* parseSingleEnvironmentArg(environment);
10957
+ const urlParams = {
10958
+ projectId: yield* readProjectId,
10959
+ scope: "all",
10960
+ search: key,
10961
+ ...env === void 0 ? {} : { environments: env }
10962
+ };
10963
+ const { items } = yield* api["env-vars"].list({ urlParams });
10964
+ const matches = items.filter((item) => item.key === key);
10965
+ if (matches.length === 0) return yield* new EnvResourceNotFoundError({ message: `No env var with key "${key}" found${env === void 0 ? "" : ` for environment "${env}"`}.` });
10966
+ if (matches.length > 1) return yield* new EnvResourceNotFoundError({ message: `Multiple env vars match key "${key}". Disambiguate with --environment <${[...new Set(matches.flatMap((entry) => entry.environments))].join(", ")}>.` });
10967
+ return matches[0];
10968
+ });
10969
+ const renderValue$1 = (envVar, includeSensitive) => {
10970
+ if (envVar.visibility === "plaintext") return envVar.value ?? "";
10971
+ if (includeSensitive) return envVar.value ?? "";
10972
+ return "******";
10973
+ };
10215
10974
  const getCommand$1 = defineCommand({
10216
10975
  meta: {
10217
10976
  name: "get",
10218
- description: "Show an environment variable"
10977
+ description: "Show an environment variable by KEY (or --by-id)"
10978
+ },
10979
+ args: {
10980
+ key: {
10981
+ type: "positional",
10982
+ required: true,
10983
+ description: "Env var KEY (uppercase) — or ID when used with --by-id"
10984
+ },
10985
+ environment: {
10986
+ type: "string",
10987
+ description: "Filter by environment when looking up by KEY"
10988
+ },
10989
+ "by-id": {
10990
+ type: "boolean",
10991
+ description: "Treat the argument as an ID instead of KEY"
10992
+ },
10993
+ "include-sensitive": {
10994
+ type: "boolean",
10995
+ description: "Reveal masked sensitive values"
10996
+ }
10219
10997
  },
10220
- args: { id: {
10221
- type: "positional",
10222
- required: true,
10223
- description: "Env var ID"
10224
- } },
10225
10998
  run: async ({ args }) => runEffect(Effect.gen(function* () {
10226
- const envVar = yield* (yield* apiClient)["env-vars"].get({ path: { id: args.id } });
10999
+ const api = yield* apiClient;
11000
+ const envVar = args["by-id"] ? yield* api["env-vars"].get({ path: { id: args.key } }) : yield* resolveByKey(api, args.key, args.environment);
10227
11001
  yield* printKeyValue([
10228
11002
  ["ID", envVar.id],
10229
11003
  ["Key", envVar.key],
10230
11004
  ["Scope", envVar.scope],
10231
11005
  ["Environments", formatEnvironments(envVar.environments)],
10232
11006
  ["Visibility", envVar.visibility],
10233
- ["Value", envVar.visibility === "plaintext" ? envVar.value ?? "" : "******"],
11007
+ ["Value", renderValue$1(envVar, args["include-sensitive"] ?? false)],
10234
11008
  ["Created", envVar.createdAt],
10235
11009
  ["Updated", envVar.updatedAt]
10236
11010
  ]);
@@ -10279,6 +11053,11 @@ const importCommand = defineCommand({
10279
11053
 
10280
11054
  //#endregion
10281
11055
  //#region src/commands/env/list.ts
11056
+ const renderValue = (item, includeSensitive) => {
11057
+ if (item.visibility === "plaintext") return item.value ?? "";
11058
+ if (includeSensitive) return item.value ?? "";
11059
+ return "••••••";
11060
+ };
10282
11061
  const listCommand$2 = defineCommand({
10283
11062
  meta: {
10284
11063
  name: "list",
@@ -10301,6 +11080,10 @@ const listCommand$2 = defineCommand({
10301
11080
  search: {
10302
11081
  type: "string",
10303
11082
  description: "Filter by key substring (case-insensitive)"
11083
+ },
11084
+ "include-sensitive": {
11085
+ type: "boolean",
11086
+ description: "Reveal masked sensitive values (default: masked)"
10304
11087
  }
10305
11088
  },
10306
11089
  run: async ({ args }) => runEffect(Effect.gen(function* () {
@@ -10312,34 +11095,67 @@ const listCommand$2 = defineCommand({
10312
11095
  ...args.environments ? { environments: args.environments } : {},
10313
11096
  ...args.search ? { search: args.search } : {}
10314
11097
  };
11098
+ const result = yield* api["env-vars"].list({ urlParams });
11099
+ const includeSensitive = args["include-sensitive"] ?? false;
10315
11100
  yield* printList([
10316
11101
  "Key",
10317
11102
  "Environments",
10318
11103
  "Scope",
10319
11104
  "Visibility",
10320
11105
  "Value"
10321
- ], (yield* api["env-vars"].list({ urlParams })).items.map((item) => [
11106
+ ], result.items.map((item) => [
10322
11107
  item.key,
10323
11108
  formatEnvironments(item.environments),
10324
11109
  item.overridesGlobal ? `${item.scope} (overrides global)` : item.scope,
10325
11110
  item.visibility,
10326
- item.visibility === "plaintext" ? item.value ?? "" : "••••••"
11111
+ renderValue(item, includeSensitive)
10327
11112
  ]), "No environment variables found.");
10328
11113
  }), envErrorExtras)
10329
11114
  });
10330
11115
 
10331
11116
  //#endregion
10332
11117
  //#region src/commands/env/pull.ts
11118
+ const DEFAULT_PATH = ".env.local";
11119
+ const escapeShellSingleQuoted = (value) => value.replaceAll("'", String.raw`'\''`);
11120
+ const escapeDotenvDoubleQuoted = (value) => `"${value.replaceAll("\\", String.raw`\\`).replaceAll("\"", String.raw`\"`).replaceAll("$", String.raw`\$`).replaceAll("\n", String.raw`\n`).replaceAll("\r", String.raw`\r`)}"`;
11121
+ const printStdout = (items) => Effect.forEach(items, (item) => Console.log(`export ${item.key}='${escapeShellSingleQuoted(item.value)}'`), { discard: true });
11122
+ const writeDotenvFile = (params) => Effect.gen(function* () {
11123
+ const fs = yield* FileSystem.FileSystem;
11124
+ if ((yield* fs.exists(params.targetPath).pipe(Effect.orElseSucceed(() => false))) && !params.force) {
11125
+ if (!(yield* InteractiveMode).allow) return yield* new InvalidArgumentError({ message: `${params.targetPath} already exists. Pass --force to overwrite, or --stdout to print instead.` });
11126
+ if (!(yield* promptConfirm(`Overwrite ${params.targetPath}?`, { initialValue: false }))) {
11127
+ yield* Console.log("Aborted.");
11128
+ return;
11129
+ }
11130
+ }
11131
+ const body = `${params.items.map((item) => `${item.key}=${escapeDotenvDoubleQuoted(item.value)}`).join("\n")}\n`;
11132
+ yield* fs.writeFileString(params.targetPath, body);
11133
+ yield* Console.log(`Wrote ${String(params.items.length)} env vars to ${params.targetPath}`);
11134
+ });
10333
11135
  const pullCommand = defineCommand({
10334
11136
  meta: {
10335
11137
  name: "pull",
10336
- description: "Print env vars in `export KEY='value'` format"
11138
+ description: `Write env vars to a dotenv file (default: ${DEFAULT_PATH}) — or pipe to stdout with --stdout`
11139
+ },
11140
+ args: {
11141
+ environment: {
11142
+ type: "string",
11143
+ default: "production",
11144
+ description: "Target environment (development, preview, production)"
11145
+ },
11146
+ path: {
11147
+ type: "string",
11148
+ description: `Output file path (default: ${DEFAULT_PATH})`
11149
+ },
11150
+ stdout: {
11151
+ type: "boolean",
11152
+ description: "Print `export KEY='value'` lines to stdout instead of writing a file"
11153
+ },
11154
+ force: {
11155
+ type: "boolean",
11156
+ description: "Overwrite the target file without prompting"
11157
+ }
10337
11158
  },
10338
- args: { environment: {
10339
- type: "string",
10340
- default: "production",
10341
- description: "Target environment (development, preview, production)"
10342
- } },
10343
11159
  run: async ({ args }) => runEffect(Effect.gen(function* () {
10344
11160
  const environment = yield* parseSingleEnvironmentArg(args.environment);
10345
11161
  const projectId = yield* readProjectId;
@@ -10347,10 +11163,16 @@ const pullCommand = defineCommand({
10347
11163
  projectId,
10348
11164
  environment
10349
11165
  } });
10350
- for (const item of result.items) {
10351
- const escaped = item.value.replaceAll("'", String.raw`'\''`);
10352
- yield* Console.log(`export ${item.key}='${escaped}'`);
11166
+ if (args.stdout) {
11167
+ yield* printStdout(result.items);
11168
+ return;
10353
11169
  }
11170
+ const cwd = yield* (yield* CliRuntime).cwd;
11171
+ yield* writeDotenvFile({
11172
+ targetPath: path.resolve(cwd, args.path ?? DEFAULT_PATH),
11173
+ items: result.items,
11174
+ force: args.force ?? false
11175
+ });
10354
11176
  }), envErrorExtras)
10355
11177
  });
10356
11178
 
@@ -10663,18 +11485,34 @@ const checkExistingLink = (api, config, localSlug) => Effect.gen(function* () {
10663
11485
  }
10664
11486
  return (yield* promptConfirm("Overwrite local projectId with a fresh link by slug?", { initialValue: false })) ? "mismatch-overwrite" : "mismatch-abort";
10665
11487
  });
11488
+ const writeAndAnnounce = (projectRoot, projectId) => Effect.gen(function* () {
11489
+ const writeResult = yield* writeProjectId(projectRoot, projectId);
11490
+ const target = writeResult.configPath ? path.relative(projectRoot, writeResult.configPath) : "your Expo config";
11491
+ yield* Console.log(`Project linked successfully. ID saved to ${target}.`);
11492
+ if (writeResult.type === "warn" && writeResult.message) yield* Console.log(`Note: ${writeResult.message}`);
11493
+ });
10666
11494
  const initCommand = defineCommand({
10667
11495
  meta: {
10668
11496
  name: "init",
10669
11497
  description: "Link the local Expo project to a better-update project"
10670
11498
  },
10671
- run: async () => runEffect(Effect.gen(function* () {
11499
+ args: { id: {
11500
+ type: "string",
11501
+ description: "Link by explicit project ID (skips slug lookup / project creation)"
11502
+ } },
11503
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
10672
11504
  const projectRoot = yield* (yield* CliRuntime).cwd;
10673
11505
  const config = yield* readExpoConfig(projectRoot);
10674
11506
  const name = config.name ?? config.slug ?? "untitled";
11507
+ const api = yield* apiClient;
11508
+ if (args.id !== void 0 && args.id.length > 0) {
11509
+ const project = yield* api.projects.get({ path: { id: args.id } });
11510
+ yield* Console.log(`Linking project: ${project.name} (${project.id})`);
11511
+ yield* writeAndAnnounce(projectRoot, project.id);
11512
+ return;
11513
+ }
10675
11514
  const slug = yield* extractSlug(config);
10676
11515
  yield* Console.log(`Linking project: ${name} (${slug})`);
10677
- const api = yield* apiClient;
10678
11516
  const linkState = yield* checkExistingLink(api, config, slug);
10679
11517
  if (linkState === "matched" || linkState === "mismatch-abort") return;
10680
11518
  const { items } = yield* api.projects.list({ urlParams: {
@@ -10682,7 +11520,7 @@ const initCommand = defineCommand({
10682
11520
  limit: 100
10683
11521
  } });
10684
11522
  const existing = items.find((project) => project.slug === slug);
10685
- const writeResult = yield* writeProjectId(projectRoot, yield* Effect.gen(function* () {
11523
+ yield* writeAndAnnounce(projectRoot, yield* Effect.gen(function* () {
10686
11524
  if (existing) {
10687
11525
  yield* Console.log(`Found existing project: ${existing.name} (${existing.id})`);
10688
11526
  return existing.id;
@@ -10695,198 +11533,9 @@ const initCommand = defineCommand({
10695
11533
  yield* Console.log(`Created project: ${created.name} (${created.id})`);
10696
11534
  return created.id;
10697
11535
  }));
10698
- const target = writeResult.configPath ? path.relative(projectRoot, writeResult.configPath) : "your Expo config";
10699
- yield* Console.log(`Project linked successfully. ID saved to ${target}.`);
10700
- if (writeResult.type === "warn" && writeResult.message) yield* Console.log(`Note: ${writeResult.message}`);
10701
11536
  }))
10702
11537
  });
10703
11538
 
10704
- //#endregion
10705
- //#region src/lib/browser-login.ts
10706
- var BrowserLoginTimeoutError = class extends Data.TaggedError("BrowserLoginTimeoutError") {};
10707
- var BrowserLoginSessionClosedError = class extends Data.TaggedError("BrowserLoginSessionClosedError") {};
10708
- const CALLBACK_PAGE = `<!doctype html>
10709
- <html lang="en">
10710
- <head>
10711
- <meta charset="utf-8" />
10712
- <meta name="viewport" content="width=device-width, initial-scale=1" />
10713
- <title>better-update CLI Login</title>
10714
- <style>
10715
- :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
10716
- body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; }
10717
- main { max-width: 32rem; line-height: 1.5; }
10718
- code { font-family: ui-monospace, SFMono-Regular, monospace; }
10719
- </style>
10720
- </head>
10721
- <body>
10722
- <main>
10723
- <h1>Completing CLI login...</h1>
10724
- <p id="message">Finalizing the local session. You can keep this tab open.</p>
10725
- </main>
10726
- <script>
10727
- const message = document.getElementById("message");
10728
- const render = (text) => {
10729
- if (message) message.textContent = text;
10730
- };
10731
-
10732
- const params = new URLSearchParams(window.location.hash.slice(1));
10733
- const token = params.get("token");
10734
-
10735
- if (!token) {
10736
- render("Missing token. Return to the CLI and run login again.");
10737
- } else {
10738
- fetch("/callback/token", {
10739
- method: "POST",
10740
- headers: { "content-type": "application/json" },
10741
- body: JSON.stringify({ token }),
10742
- })
10743
- .then(async (response) => {
10744
- if (!response.ok) {
10745
- const body = await response.text();
10746
- throw new Error(body || "Callback failed");
10747
- }
10748
- window.history.replaceState({}, document.title, window.location.pathname);
10749
- render("CLI login complete. You can close this tab.");
10750
- setTimeout(() => window.close(), 300);
10751
- })
10752
- .catch((error) => {
10753
- render(error instanceof Error ? error.message : "Callback failed.");
10754
- });
10755
- }
10756
- <\/script>
10757
- </body>
10758
- </html>`;
10759
- const createBrowserLoginSession = (options = {}) => {
10760
- const tokenDeferred = Effect.runSync(Deferred.make());
10761
- const waitForToken = Deferred.await(tokenDeferred).pipe(Effect.timeoutFail({
10762
- duration: options.timeoutMs === void 0 ? Duration.minutes(5) : Duration.millis(options.timeoutMs),
10763
- onTimeout: () => new BrowserLoginTimeoutError({ message: "Timed out waiting for browser login to complete." })
10764
- }));
10765
- const dispose = () => {
10766
- Effect.runSync(Deferred.fail(tokenDeferred, new BrowserLoginSessionClosedError({ message: "Browser login session closed." })));
10767
- };
10768
- return {
10769
- callbackPath: "/callback",
10770
- waitForToken,
10771
- handleRequest: async (request) => {
10772
- const url = new URL(request.url);
10773
- if (request.method === "GET" && url.pathname === "/callback") return new Response(CALLBACK_PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
10774
- if (request.method === "POST" && url.pathname === "/callback/token") try {
10775
- const body = await request.json();
10776
- if (!isRecord(body)) return new Response("Invalid callback payload", { status: 400 });
10777
- const token = typeof body["token"] === "string" ? body["token"].trim() : "";
10778
- if (token.length === 0) return new Response("Missing token", { status: 400 });
10779
- Effect.runSync(Deferred.succeed(tokenDeferred, token));
10780
- return Response.json({ ok: true });
10781
- } catch {
10782
- return new Response("Invalid callback payload", { status: 400 });
10783
- }
10784
- return new Response("Not found", { status: 404 });
10785
- },
10786
- dispose
10787
- };
10788
- };
10789
- const readBody = async (req) => {
10790
- const chunks = [];
10791
- for await (const chunk of req) chunks.push(chunk);
10792
- return Buffer.concat(chunks);
10793
- };
10794
- const toFetchRequest = async (req, origin) => {
10795
- const url = new URL(req.url ?? "/", origin);
10796
- const method = req.method ?? "GET";
10797
- const headers = new Headers();
10798
- for (const [key, value] of Object.entries(req.headers)) {
10799
- if (value === void 0) continue;
10800
- if (Array.isArray(value)) for (const entry of value) headers.append(key, entry);
10801
- else headers.append(key, value);
10802
- }
10803
- const init = {
10804
- method,
10805
- headers
10806
- };
10807
- if (method !== "GET" && method !== "HEAD") {
10808
- const body = await readBody(req);
10809
- init.body = new Uint8Array(body);
10810
- }
10811
- return new Request(url, init);
10812
- };
10813
- const writeFetchResponse = async (res, response) => {
10814
- res.statusCode = response.status;
10815
- response.headers.forEach((value, key) => {
10816
- res.setHeader(key, value);
10817
- });
10818
- const body = await response.arrayBuffer();
10819
- res.end(Buffer.from(body));
10820
- };
10821
- const handleIncoming = async (req, res, session) => {
10822
- try {
10823
- const request = await toFetchRequest(req, "http://127.0.0.1");
10824
- await writeFetchResponse(res, await session.handleRequest(request));
10825
- } catch {
10826
- res.statusCode = 500;
10827
- res.end("Local callback failed");
10828
- }
10829
- };
10830
- const createBrowserLoginServer = async (options = {}) => {
10831
- const session = createBrowserLoginSession(options);
10832
- const server = createServer((req, res) => {
10833
- handleIncoming(req, res, session).catch(() => void 0);
10834
- });
10835
- server.listen(0, "127.0.0.1");
10836
- await once(server, "listening");
10837
- const address = server.address();
10838
- return {
10839
- callbackUrl: `http://127.0.0.1:${address !== null && typeof address === "object" ? address.port : 0}${session.callbackPath}`,
10840
- waitForToken: session.waitForToken,
10841
- stop: () => {
10842
- session.dispose();
10843
- server.close();
10844
- }
10845
- };
10846
- };
10847
-
10848
- //#endregion
10849
- //#region src/application/login.ts
10850
- const buildOpenBrowserCommand = (platform, url) => {
10851
- if (platform === "darwin") return Command.make("open", url);
10852
- if (platform === "win32") return Command.make("cmd", "/c", "start", "", url);
10853
- return Command.make("xdg-open", url);
10854
- };
10855
- const openBrowser = (url) => Effect.gen(function* () {
10856
- const command = buildOpenBrowserCommand((yield* CliRuntime).platform, url);
10857
- if (!(yield* Command.exitCode(command).pipe(Effect.map((code) => code === 0), Effect.catchAll(() => Effect.succeed(false))))) yield* Console.log(`Open this URL manually:\n${url}`);
10858
- });
10859
- const browserLogin = Effect.scoped(Effect.gen(function* () {
10860
- const configStore = yield* ConfigStore;
10861
- const authStore = yield* AuthStore;
10862
- const webUrl = yield* configStore.getWebUrl;
10863
- const loginServer = yield* Effect.acquireRelease(Effect.promise(async () => createBrowserLoginServer()), (server) => Effect.sync(server.stop));
10864
- const loginUrl = `${webUrl}/auth/cli-login?callbackUrl=${encodeURIComponent(loginServer.callbackUrl)}`;
10865
- yield* Console.log("Opening browser for better-update login...");
10866
- yield* Console.log("");
10867
- yield* openBrowser(loginUrl);
10868
- const token = yield* loginServer.waitForToken;
10869
- yield* authStore.saveToken(token);
10870
- yield* Console.log("");
10871
- yield* Console.log("Logged in successfully. Token saved to ~/.better-update/auth.json");
10872
- }));
10873
- const manualLogin = Effect.gen(function* () {
10874
- yield* Console.log("Log in to better-update with an existing API key");
10875
- yield* Console.log("Get your API key from the dashboard > API Keys page");
10876
- yield* Console.log("");
10877
- const token = yield* promptPassword("Paste your API key (from dashboard > API Keys):");
10878
- yield* (yield* AuthStore).saveToken(token);
10879
- yield* Console.log("");
10880
- yield* Console.log("Logged in successfully. Token saved to ~/.better-update/auth.json");
10881
- });
10882
- const runLogin = (options) => Effect.gen(function* () {
10883
- if (options.manualApiKey) {
10884
- yield* manualLogin;
10885
- return;
10886
- }
10887
- yield* browserLogin;
10888
- });
10889
-
10890
11539
  //#endregion
10891
11540
  //#region src/commands/login.ts
10892
11541
  const loginCommand = defineCommand({
@@ -10908,9 +11557,17 @@ const logoutCommand = defineCommand({
10908
11557
  name: "logout",
10909
11558
  description: "Remove the stored auth token"
10910
11559
  },
10911
- run: async () => runEffect(Effect.gen(function* () {
11560
+ args: { all: {
11561
+ type: "boolean",
11562
+ description: "Also clear cached Apple Developer session (cookies)"
11563
+ } },
11564
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
10912
11565
  yield* (yield* AuthStore).clearToken;
10913
11566
  yield* Console.log("Logged out. Auth token removed.");
11567
+ if (args.all) {
11568
+ yield* (yield* AppleSessionStore).clearSession;
11569
+ yield* Console.log("Cleared Apple Developer session.");
11570
+ }
10914
11571
  }))
10915
11572
  });
10916
11573
 
@@ -11784,6 +12441,86 @@ const resolveChannelToBranch = (client, projectId, channelName) => Effect.gen(fu
11784
12441
  if (!branch) return yield* new UpdatePublishError({ message: `Channel "${channelName}" maps to a branch (${match.branchId}) not in the project's branch list.` });
11785
12442
  return branch.name;
11786
12443
  });
12444
+ const CREATE_NEW_BRANCH_SENTINEL = "__better_update_create_new_branch__";
12445
+ const promptBranchName$1 = Effect.gen(function* () {
12446
+ const trimmed = (yield* promptText("Branch name to create", { placeholder: "e.g. main, staging, release" })).trim();
12447
+ if (trimmed.length === 0) return yield* new UpdatePublishError({ message: "Branch name cannot be empty." });
12448
+ return trimmed;
12449
+ });
12450
+ /**
12451
+ * Interactive branch picker: lists existing branches with a "+ Create new..."
12452
+ * Sentinel option. When the user picks the sentinel (or there are no
12453
+ * Existing branches), prompts for a new branch name.
12454
+ */
12455
+ const promptForBranch = (client, projectId) => Effect.gen(function* () {
12456
+ const branches = yield* drainPages((page) => client.branches.list({ urlParams: {
12457
+ projectId,
12458
+ limit: 100,
12459
+ page
12460
+ } })).pipe(Effect.mapError((cause) => new UpdatePublishError({ message: `Failed to list branches: ${formatCause(cause)}` })));
12461
+ if (branches.length === 0) return yield* promptBranchName$1;
12462
+ const choice = yield* promptSelect("Which branch to publish to?", [...branches.map((branch) => ({
12463
+ value: branch.name,
12464
+ label: branch.name
12465
+ })), {
12466
+ value: CREATE_NEW_BRANCH_SENTINEL,
12467
+ label: "+ Create new branch..."
12468
+ }]);
12469
+ if (choice === CREATE_NEW_BRANCH_SENTINEL) return yield* promptBranchName$1;
12470
+ return choice;
12471
+ });
12472
+ /**
12473
+ * Interactive message prompt with the git commit subject as default.
12474
+ * Returns `undefined` if the user entered an empty value, so the caller
12475
+ * Can fall back to its own default.
12476
+ */
12477
+ const promptForMessage = (commitMessage) => Effect.gen(function* () {
12478
+ const fallback = commitMessage ?? "Publish via better-update CLI";
12479
+ const trimmed = (yield* promptText("Update message", {
12480
+ placeholder: fallback,
12481
+ defaultValue: fallback
12482
+ })).trim();
12483
+ return trimmed.length === 0 ? void 0 : trimmed;
12484
+ });
12485
+ /**
12486
+ * Apply the full branch/message resolution chain in priority order:
12487
+ * Explicit args → git context (when --auto) → channel lookup → env fallback →
12488
+ * Interactive picker. Returns the final values or fails with a helpful error
12489
+ * When everything is exhausted in non-interactive mode.
12490
+ */
12491
+ const resolveBranchAndMessage = (input) => Effect.gen(function* () {
12492
+ let branch = input.branchArg;
12493
+ let message = input.messageArg;
12494
+ if (input.auto) {
12495
+ if (branch === void 0 && input.gitCtx.ref !== void 0) branch = input.gitCtx.ref;
12496
+ if (message === void 0 && input.gitCtx.commitMessage !== void 0) message = input.gitCtx.commitMessage;
12497
+ }
12498
+ if (branch === void 0 && input.channelArg !== void 0) branch = yield* resolveChannelToBranch(input.client, input.projectId, input.channelArg);
12499
+ if (branch === void 0 && input.envBranch !== void 0 && input.envBranch.length > 0) branch = input.envBranch;
12500
+ const interactive = yield* InteractiveMode;
12501
+ if (branch === void 0) {
12502
+ if (!interactive.allow) return yield* new UpdatePublishError({ message: "Missing --branch or --channel. Provide one explicitly, set BETTER_UPDATE_BRANCH, use --auto to infer from git, or run interactively." });
12503
+ branch = yield* promptForBranch(input.client, input.projectId);
12504
+ }
12505
+ if (message === void 0 && interactive.allow && !input.auto) message = yield* promptForMessage(input.gitCtx.commitMessage);
12506
+ return {
12507
+ branch,
12508
+ message
12509
+ };
12510
+ });
12511
+ /**
12512
+ * Show a pre-publish preview and ask for confirmation. Returns `false`
12513
+ * If the user declines so the caller can abort gracefully.
12514
+ */
12515
+ const confirmPublishPreview = (preview) => Effect.gen(function* () {
12516
+ yield* Console.log("");
12517
+ yield* Console.log(`Branch: ${preview.branch}`);
12518
+ yield* Console.log(`Platforms: ${[...preview.platforms].join(", ")}`);
12519
+ yield* Console.log(`Environment: ${preview.environment}`);
12520
+ yield* Console.log(`Message: ${preview.message}`);
12521
+ yield* Console.log("");
12522
+ return yield* promptConfirm("Proceed with publish?", { initialValue: true });
12523
+ });
11787
12524
  const emitMetadataFile = (input) => Effect.gen(function* () {
11788
12525
  const fs = yield* FileSystem.FileSystem;
11789
12526
  yield* fs.makeDirectory(input.dir, { recursive: true }).pipe(Effect.mapError((cause) => new UpdatePublishError({ message: `Failed to prepare metadata directory: ${formatCause(cause)}` })));
@@ -11898,7 +12635,8 @@ const publishPlatform = (params) => Effect.gen(function* () {
11898
12635
  };
11899
12636
  });
11900
12637
  const runUpdatePublish = (options) => Effect.scoped(Effect.gen(function* () {
11901
- const projectRoot = yield* (yield* CliRuntime).cwd;
12638
+ const runtime = yield* CliRuntime;
12639
+ const projectRoot = yield* runtime.cwd;
11902
12640
  const api = yield* apiClient;
11903
12641
  yield* ensureRepoClean({
11904
12642
  projectRoot,
@@ -11919,23 +12657,30 @@ const runUpdatePublish = (options) => Effect.scoped(Effect.gen(function* () {
11919
12657
  envVars: environmentVars
11920
12658
  });
11921
12659
  const tempDir = yield* acquireBuildTempDir.pipe(Effect.mapError((cause) => new UpdatePublishError({ message: `Failed to create a temporary export directory: ${formatCause(cause)}` })));
11922
- let resolvedBranch = options.branch;
11923
- let resolvedMessage = options.message;
11924
- if (options.auto) {
11925
- const gitContext = yield* readGitContext(projectRoot);
11926
- if (!resolvedBranch) {
11927
- if (!gitContext.ref) return yield* new UpdatePublishError({ message: "Cannot infer branch from git. Ensure you are in a git repo with a checked-out branch, or provide --branch explicitly." });
11928
- resolvedBranch = gitContext.ref;
11929
- }
11930
- if (!resolvedMessage && gitContext.commitMessage) resolvedMessage = gitContext.commitMessage;
11931
- }
11932
- if (!resolvedBranch && options.channel !== void 0) resolvedBranch = yield* resolveChannelToBranch(api, projectId, options.channel);
11933
- if (!resolvedBranch) return yield* new UpdatePublishError({ message: "Missing --branch or --channel. Provide one explicitly or use --auto to infer from git." });
12660
+ const gitCtx = yield* readGitContext(projectRoot);
12661
+ const envBranch = (yield* runtime.getEnv("BETTER_UPDATE_BRANCH"))?.trim();
12662
+ const { branch, message: resolvedMessage } = yield* resolveBranchAndMessage({
12663
+ client: api,
12664
+ projectId,
12665
+ branchArg: options.branch,
12666
+ messageArg: options.message,
12667
+ channelArg: options.channel,
12668
+ auto: options.auto,
12669
+ gitCtx,
12670
+ envBranch
12671
+ });
11934
12672
  if (options.skipBundler && options.inputDir === void 0) return yield* new UpdatePublishError({ message: "--skip-bundler requires --input-dir <path> pointing to a pre-bundled export." });
11935
12673
  const sharedExportDir = options.inputDir === void 0 ? void 0 : path.resolve(projectRoot, options.inputDir);
11936
- const branch = resolvedBranch;
11937
12674
  const groupId = randomUUID();
11938
12675
  const message = resolvedMessage ?? "Publish via better-update CLI";
12676
+ if ((yield* InteractiveMode).allow && !options.auto) {
12677
+ if (!(yield* confirmPublishPreview({
12678
+ branch,
12679
+ platforms,
12680
+ message,
12681
+ environment: options.environment
12682
+ }))) return yield* new UpdatePublishError({ message: "Publish cancelled." });
12683
+ }
11939
12684
  const signedPayloads = yield* loadSignedPublishPayloads({
11940
12685
  platforms,
11941
12686
  globalFiles: {
@@ -13203,7 +13948,8 @@ await runMain(defineCommand({
13203
13948
  devices: devicesCommand,
13204
13949
  webhooks: webhooksCommand,
13205
13950
  autocomplete: autocompleteCommand,
13206
- "migrate-config": migrateConfigCommand
13951
+ "migrate-config": migrateConfigCommand,
13952
+ apple: appleCommand
13207
13953
  }
13208
13954
  }));
13209
13955