@better-update/cli 0.31.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -34,7 +34,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
34
34
 
35
35
  //#endregion
36
36
  //#region package.json
37
- var version = "0.31.1";
37
+ var version = "0.33.0";
38
38
 
39
39
  //#endregion
40
40
  //#region src/lib/interactive-mode.ts
@@ -1297,8 +1297,8 @@ var ChannelsGroup = class extends HttpApiGroup.make("channels").add(HttpApiEndpo
1297
1297
  //#endregion
1298
1298
  //#region ../../packages/api/src/domain/device.ts
1299
1299
  const DeviceClass = Schema.Literal("IPHONE", "IPAD", "MAC", "UNKNOWN");
1300
- const IDENTIFIER_PATTERN = /^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{8}-[A-Fa-f0-9]{16}|[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12})$/u;
1301
- const DeviceIdentifier = Schema.String.pipe(Schema.pattern(IDENTIFIER_PATTERN, { message: () => "Identifier must be an Apple UDID: 40 hex chars, 8-16 hex, or UUID (8-4-4-4-12 hex)" }));
1300
+ const IDENTIFIER_PATTERN$1 = /^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{8}-[A-Fa-f0-9]{16}|[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12})$/u;
1301
+ const DeviceIdentifier = Schema.String.pipe(Schema.pattern(IDENTIFIER_PATTERN$1, { message: () => "Identifier must be an Apple UDID: 40 hex chars, 8-16 hex, or UUID (8-4-4-4-12 hex)" }));
1302
1302
  var Device = class extends Schema.Class("Device")({
1303
1303
  id: Id,
1304
1304
  organizationId: Id,
@@ -1324,6 +1324,34 @@ const UpdateDeviceBody = Schema.Struct({
1324
1324
  enabled: Schema.optional(Schema.Boolean),
1325
1325
  appleTeamId: Schema.optional(Schema.NullOr(Id))
1326
1326
  });
1327
+ /**
1328
+ * One device in an App Store Connect snapshot, as reconciled by `syncDevices`.
1329
+ * `appleDevicePortalId` is the device's id on Apple's portal — its presence is
1330
+ * what the dashboard surfaces as "synced".
1331
+ */
1332
+ const SyncDeviceEntry = Schema.Struct({
1333
+ identifier: DeviceIdentifier,
1334
+ name: Name120,
1335
+ deviceClass: DeviceClass,
1336
+ appleDevicePortalId: Schema.String
1337
+ });
1338
+ /**
1339
+ * Reconcile the org's device roster for one Apple team against a snapshot of
1340
+ * App Store Connect devices: link portal ids onto existing rows and import any
1341
+ * device that only exists on Apple. `appleTeamId` is the internal team Id (UUID).
1342
+ */
1343
+ const SyncDevicesBody = Schema.Struct({
1344
+ appleTeamId: Id,
1345
+ devices: Schema.Array(SyncDeviceEntry)
1346
+ });
1347
+ const SyncDevicesResult = Schema.Struct({
1348
+ /** Devices that existed only on Apple and were imported locally. */
1349
+ created: Schema.Number,
1350
+ /** Local devices that gained (or changed) their Apple portal id. */
1351
+ linked: Schema.Number,
1352
+ /** Local devices already in sync — nothing to do. */
1353
+ unchanged: Schema.Number
1354
+ });
1327
1355
  const DeleteDeviceResult = DeletedResult;
1328
1356
  const DeviceSortColumn = Schema.Literal("name", "createdAt", "deviceClass");
1329
1357
  const DeviceSort = sortParam(DeviceSortColumn);
@@ -1374,6 +1402,9 @@ var DevicesGroup = class extends HttpApiGroup.make("devices").add(HttpApiEndpoin
1374
1402
  }))).add(HttpApiEndpoint.del("delete")`/api/devices/${idParam}`.addSuccess(DeleteDeviceResult).annotateContext(OpenApi.annotations({
1375
1403
  title: "Delete device",
1376
1404
  description: "Remove a registered device from the organization"
1405
+ }))).add(HttpApiEndpoint.post("syncDevices", "/api/devices/sync").setPayload(SyncDevicesBody).addSuccess(SyncDevicesResult).annotateContext(OpenApi.annotations({
1406
+ title: "Sync devices with App Store Connect",
1407
+ description: "Reconcile the org's device roster for one Apple team against an App Store Connect snapshot: link Apple portal ids onto existing devices and import devices that only exist on Apple"
1377
1408
  }))).add(HttpApiEndpoint.post("createRegistrationRequest", "/api/devices/registration-requests").setPayload(CreateRegistrationRequestBody).addSuccess(DeviceRegistrationRequest, { status: 201 }).annotateContext(OpenApi.annotations({
1378
1409
  title: "Create device registration request",
1379
1410
  description: "Generate a URL + QR code for self-service device enrollment via Safari on iOS"
@@ -3047,8 +3078,10 @@ const AppleSessionStoreLive = Layer.effect(AppleSessionStore, Effect.gen(functio
3047
3078
  const defaultAppleUtils = {
3048
3079
  Auth: AppleUtils.Auth,
3049
3080
  Session: AppleUtils.Session,
3081
+ Teams: AppleUtils.Teams,
3050
3082
  CookieFileCache: AppleUtils.CookieFileCache
3051
3083
  };
3084
+ const TEN_CHAR_TEAM_ID = /^[A-Z0-9]{10}$/u;
3052
3085
  var AppleAuth = class extends Context.Tag("cli/AppleAuth")() {};
3053
3086
  const sessionFromAuthState = (state) => ({
3054
3087
  username: state.username,
@@ -3062,11 +3095,19 @@ const sessionFromInfo = (username, info) => ({
3062
3095
  teamName: info.provider.name,
3063
3096
  providerId: info.provider.providerId
3064
3097
  });
3065
- const sessionFromProvider = (username, provider) => ({
3066
- username,
3067
- teamId: provider.publicProviderId,
3068
- teamName: provider.name,
3069
- providerId: provider.providerId
3098
+ /**
3099
+ * Resolve the 10-char Developer Portal Team ID for a selected App Store Connect
3100
+ * provider. Uses the provider's `publicProviderId` directly when it already is a
3101
+ * Team ID; otherwise (UUID providers) looks it up from the Developer Portal team
3102
+ * list by name — the only field both surfaces share. Falls back to the
3103
+ * `publicProviderId` if no match, preserving prior behavior.
3104
+ */
3105
+ const resolvePortalTeamId = (appleUtils, provider) => Effect.gen(function* () {
3106
+ if (TEN_CHAR_TEAM_ID.test(provider.publicProviderId)) return provider.publicProviderId;
3107
+ return (yield* Effect.tryPromise({
3108
+ try: async () => appleUtils.Teams.getTeamsAsync(),
3109
+ catch: (cause) => new AppleAuthError$1({ message: `Failed to list Apple Developer teams: ${formatCause(cause)}` })
3110
+ })).find((team) => team.name === provider.name)?.teamId ?? provider.publicProviderId;
3070
3111
  });
3071
3112
  const restoreFromCookies = (appleUtils, cookies) => Effect.tryPromise({
3072
3113
  try: async () => appleUtils.Auth.loginWithCookiesAsync({ cookies }),
@@ -3081,10 +3122,16 @@ const restoreFromCookies = (appleUtils, cookies) => Effect.tryPromise({
3081
3122
  const resolveSessionTeam = (appleUtils, state) => Effect.gen(function* () {
3082
3123
  const { availableProviders } = state.session;
3083
3124
  const resolution = yield* resolveProvider(appleUtils, availableProviders, state.context.providerId ?? state.session.provider.providerId);
3084
- if (!resolution.switched || resolution.providerId === void 0) return sessionFromAuthState(state);
3085
- const picked = availableProviders.find((provider) => provider.providerId === resolution.providerId);
3086
- if (picked === void 0) return yield* new AppleAuthError$1({ message: `Selected provider ${String(resolution.providerId)} not in available providers list.` });
3087
- return sessionFromProvider(state.username, picked);
3125
+ const switched = resolution.switched && resolution.providerId !== void 0;
3126
+ const provider = switched ? availableProviders.find((entry) => entry.providerId === resolution.providerId) : state.session.provider;
3127
+ if (provider === void 0) return yield* new AppleAuthError$1({ message: `Selected provider ${String(resolution.providerId)} not in available providers list.` });
3128
+ const teamId = (!switched && state.context.teamId !== void 0 && TEN_CHAR_TEAM_ID.test(state.context.teamId) ? state.context.teamId : void 0) ?? (yield* resolvePortalTeamId(appleUtils, provider));
3129
+ return {
3130
+ username: state.username,
3131
+ teamId,
3132
+ teamName: provider.name,
3133
+ providerId: provider.providerId
3134
+ };
3088
3135
  });
3089
3136
  const loginWithCredentials = (appleUtils, credentials) => Effect.tryPromise({
3090
3137
  try: async () => appleUtils.Auth.loginWithUserCredentialsAsync(credentials, { autoResolveProvider: true }),
@@ -19357,12 +19404,13 @@ const toAscDevice = (value) => {
19357
19404
  if (!isRecord$1(value)) return null;
19358
19405
  const { id, attributes } = value;
19359
19406
  if (typeof id !== "string" || !isRecord$1(attributes)) return null;
19360
- const { udid, name } = attributes;
19407
+ const { udid, name, deviceClass } = attributes;
19361
19408
  if (typeof udid !== "string" || typeof name !== "string") return null;
19362
19409
  return {
19363
19410
  id,
19364
19411
  udid,
19365
- name
19412
+ name,
19413
+ deviceClass: typeof deviceClass === "string" ? deviceClass : null
19366
19414
  };
19367
19415
  };
19368
19416
  const extractList = (body, map) => {
@@ -19373,6 +19421,18 @@ const extractSingle = (body, map) => {
19373
19421
  if (!isRecord$1(body)) return null;
19374
19422
  return map(body["data"]);
19375
19423
  };
19424
+ /**
19425
+ * App Store Connect paginates list responses (default 200/page) and returns the
19426
+ * absolute URL of the next page under `links.next`. Strip the base so it can be
19427
+ * fed back into `fetchRaw`; return null when there is no further page.
19428
+ */
19429
+ const nextPagePath = (body) => {
19430
+ if (!isRecord$1(body)) return null;
19431
+ const { links } = body;
19432
+ if (!isRecord$1(links) || typeof links["next"] !== "string") return null;
19433
+ const { next } = links;
19434
+ return next.startsWith(API_BASE) ? next.slice(37) : next;
19435
+ };
19376
19436
  const malformed = (resource) => new AscApiError({
19377
19437
  status: 500,
19378
19438
  message: `Malformed ${resource} response`,
@@ -19420,7 +19480,29 @@ const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Ef
19420
19480
  return resource;
19421
19481
  }));
19422
19482
  const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
19423
- return extractList(yield* fetchRaw(jwt, "/v1/devices?limit=200"), toAscDevice);
19483
+ const devices = [];
19484
+ let path = "/v1/devices?limit=200";
19485
+ while (path !== null) {
19486
+ const body = yield* fetchRaw(jwt, path);
19487
+ devices.push(...extractList(body, toAscDevice));
19488
+ path = nextPagePath(body);
19489
+ }
19490
+ return devices;
19491
+ }));
19492
+ const createDevice = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
19493
+ const resource = extractSingle(yield* fetchRaw(jwt, "/v1/devices", {
19494
+ method: "POST",
19495
+ body: JSON.stringify({ data: {
19496
+ type: "devices",
19497
+ attributes: {
19498
+ name: params.name,
19499
+ udid: params.udid,
19500
+ platform: "IOS"
19501
+ }
19502
+ } })
19503
+ }), toAscDevice);
19504
+ if (resource === null) return yield* malformed("device");
19505
+ return resource;
19424
19506
  }));
19425
19507
  const createProvisioningProfile = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
19426
19508
  const relationships = {
@@ -20689,6 +20771,7 @@ const DISTRIBUTION_TO_CERTIFICATE_TYPE = {
20689
20771
  DEVELOPMENT: AppleUtils.CertificateType.IOS_DEVELOPMENT
20690
20772
  };
20691
20773
  var AppleIdGenerateFailedError = class extends Data.TaggedError("AppleIdGenerateFailedError") {};
20774
+ var ApnsKeyLimitError = class extends Data.TaggedError("ApnsKeyLimitError") {};
20692
20775
  const CERT_LIMIT_PATTERN = /already have a current.*certificate|pending certificate request/iu;
20693
20776
  const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
20694
20777
  const wrap = (step, run) => Effect.tryPromise({
@@ -20833,6 +20916,86 @@ const generateAndUploadProvisioningProfileViaAppleId = (api, input) => Effect.ge
20833
20916
  developerPortalIdentifier: created.developerPortalIdentifier
20834
20917
  };
20835
20918
  });
20919
+ const APNS_SERVICE_ID = "U27F4V844T";
20920
+ const APNS_KEY_LIMIT_PATTERN = /maximum allowed number of .*keys/iu;
20921
+ const wrapKeyCreate = (run) => Effect.tryPromise({
20922
+ try: run,
20923
+ catch: (cause) => {
20924
+ const message = messageOf(cause);
20925
+ return cause instanceof AppleUtils.Keys.MaxKeysCreatedError || APNS_KEY_LIMIT_PATTERN.test(message) ? new ApnsKeyLimitError({ message }) : new AppleIdGenerateFailedError({
20926
+ step: "apple-create-key",
20927
+ message
20928
+ });
20929
+ }
20930
+ });
20931
+ const writeRescueP8 = (keyId, p8Pem) => Effect.gen(function* () {
20932
+ const fs = yield* FileSystem.FileSystem;
20933
+ const filePath = `AuthKey_${keyId}.p8`;
20934
+ yield* fs.writeFileString(filePath, p8Pem, { mode: 384 });
20935
+ return filePath;
20936
+ });
20937
+ const generateAndUploadApnsKeyViaAppleId = (api, input) => Effect.gen(function* () {
20938
+ const ctx = input.context;
20939
+ const key = yield* wrapKeyCreate(async () => AppleUtils.Keys.createKeyAsync(ctx, {
20940
+ name: input.name,
20941
+ isApns: true
20942
+ }));
20943
+ const p8Pem = yield* wrap("apple-download-key", async () => AppleUtils.Keys.downloadKeyAsync(ctx, { id: key.id }));
20944
+ const metadata = {
20945
+ keyId: key.id,
20946
+ appleTeamIdentifier: input.appleTeamIdentifier
20947
+ };
20948
+ return {
20949
+ id: (yield* Effect.gen(function* () {
20950
+ const envelope = yield* sealForUpload({
20951
+ session: yield* openVaultSessionInteractive(api),
20952
+ credentialType: "push-key",
20953
+ metadata,
20954
+ secret: { p8Pem }
20955
+ });
20956
+ return yield* api.applePushKeys.upload({ payload: {
20957
+ ...toUploadEnvelope(envelope),
20958
+ ...metadata
20959
+ } });
20960
+ }).pipe(Effect.catchAll((cause) => Effect.gen(function* () {
20961
+ const rescuePath = yield* writeRescueP8(key.id, p8Pem).pipe(Effect.catchAll(() => Effect.succeed(null)));
20962
+ const where = rescuePath === null ? "could not be saved locally and is now unrecoverable" : `was saved to ${rescuePath} — re-import with \`credentials generate push-key --p8 ${rescuePath} --key-id ${key.id} --apple-team-id ${input.appleTeamIdentifier}\``;
20963
+ return yield* new AppleIdGenerateFailedError({
20964
+ step: "store-apns-key",
20965
+ message: `Created APNs key ${key.id} on Apple but failed to store it (${messageOf(cause)}). The downloaded .p8 ${where}.`
20966
+ });
20967
+ })))).id,
20968
+ keyId: key.id,
20969
+ appleTeamIdentifier: input.appleTeamIdentifier,
20970
+ name: key.name
20971
+ };
20972
+ });
20973
+ const listApnsKeysViaAppleId = (ctx) => Effect.gen(function* () {
20974
+ const keys = yield* wrap("apple-list-keys", async () => AppleUtils.Keys.getKeysAsync(ctx));
20975
+ return (yield* Effect.forEach(keys, (key) => wrap("apple-get-key-info", async () => AppleUtils.Keys.getKeyInfoAsync(ctx, { id: key.id })), { concurrency: 4 })).filter((info) => info.services.some((service) => service.id === APNS_SERVICE_ID)).map((info) => ({
20976
+ developerPortalKeyId: info.id,
20977
+ name: info.name,
20978
+ canRevoke: info.canRevoke
20979
+ }));
20980
+ });
20981
+ const revokeApnsKeyViaAppleId = (ctx, developerPortalKeyId) => wrap("apple-revoke-key", async () => AppleUtils.Keys.revokeKeyAsync(ctx, { id: developerPortalKeyId }));
20982
+ /**
20983
+ * Revoke an APNs key on Apple and (optionally) delete the stored copy. Only keys
20984
+ * still present on the portal are revoked — one already gone upstream is treated
20985
+ * as `revokedOnApple: false` and still deleted locally, so cleanup never wedges.
20986
+ * Shared by the `revoke push-key` command and the interactive wizard.
20987
+ */
20988
+ const revokeLocalApnsKey = (api, input) => Effect.gen(function* () {
20989
+ const present = (yield* listApnsKeysViaAppleId(input.context)).some((entry) => entry.developerPortalKeyId === input.keyId);
20990
+ if (present) yield* revokeApnsKeyViaAppleId(input.context, input.keyId);
20991
+ if (!input.keepLocal) yield* api.applePushKeys.delete({ path: { id: input.pushKeyId } });
20992
+ return {
20993
+ localId: input.pushKeyId,
20994
+ keyId: input.keyId,
20995
+ revokedOnApple: present,
20996
+ deletedLocally: !input.keepLocal
20997
+ };
20998
+ });
20836
20999
 
20837
21000
  //#endregion
20838
21001
  //#region src/lib/ios-bundle-config-upsert.ts
@@ -20905,6 +21068,36 @@ const interactiveAppleIdCertLimitRecover = (ctx) => Effect.gen(function* () {
20905
21068
  yield* Effect.forEach(toRevoke, (id) => revokeDistributionCertViaAppleId(ctx, id), { concurrency: "inherit" });
20906
21069
  yield* Console.log(`Revoked ${toRevoke.length} certificate(s); retrying generation...`);
20907
21070
  });
21071
+ const defaultApnsKeyName = () => `better-update APNs (${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)})`;
21072
+ const apnsKeyLimitRecover = (ctx) => Effect.gen(function* () {
21073
+ yield* Console.log("");
21074
+ yield* Console.log("Apple reports the APNs key limit was hit (max 2 keys per team).");
21075
+ const revocable = (yield* listApnsKeysViaAppleId(ctx)).filter((entry) => entry.canRevoke);
21076
+ if (revocable.length === 0) return yield* new CredentialValidationError({ message: "Apple says the APNs key limit is hit but no revocable keys were returned." });
21077
+ const toRevoke = yield* promptMultiSelect("Select one or more APNs keys to revoke before retrying", revocable.map((entry) => ({
21078
+ value: entry.developerPortalKeyId,
21079
+ label: `${entry.name} (${entry.developerPortalKeyId})`
21080
+ })), { required: true });
21081
+ yield* Effect.forEach(toRevoke, (id) => revokeApnsKeyViaAppleId(ctx, id), { concurrency: "inherit" });
21082
+ yield* Console.log(`Revoked ${toRevoke.length} key(s); retrying creation...`);
21083
+ });
21084
+ /**
21085
+ * Log in with Apple ID, create a fresh APNs `.p8` on the portal, download it, and
21086
+ * upload it end-to-end encrypted — recovering interactively from the key limit.
21087
+ * Returns the stored credential; callers render their own success output. Shared
21088
+ * by the `generate push-key` command and the interactive wizard.
21089
+ */
21090
+ const createApnsKeyViaAppleId = (api, name) => Effect.gen(function* () {
21091
+ const auth = yield* AppleAuth;
21092
+ const session = yield* auth.ensureLoggedIn();
21093
+ const ctx = auth.buildRequestContext(session);
21094
+ const generate = generateAndUploadApnsKeyViaAppleId(api, {
21095
+ context: ctx,
21096
+ appleTeamIdentifier: session.teamId,
21097
+ name
21098
+ });
21099
+ return yield* generate.pipe(Effect.catchTag("ApnsKeyLimitError", () => apnsKeyLimitRecover(ctx).pipe(Effect.flatMap(() => generate))));
21100
+ });
20908
21101
  const generateDistributionCertViaAppleIdInteractive = (api, ctx) => Effect.gen(function* () {
20909
21102
  yield* Console.log("Generating distribution certificate via Apple ID...");
20910
21103
  const generate = generateAndUploadDistributionCertificateViaAppleId(api, { context: ctx });
@@ -26926,6 +27119,39 @@ const revokeIosDistributionCert = (ctx) => Effect.gen(function* () {
26926
27119
  ["Deleted locally", result.deletedLocally ? "yes" : "no (kept)"]
26927
27120
  ]);
26928
27121
  });
27122
+ const revokeIosPushKey = (ctx) => Effect.gen(function* () {
27123
+ const { items } = yield* ctx.api.applePushKeys.list();
27124
+ if (items.length === 0) return yield* new MissingCredentialsError({
27125
+ message: "No APNs push keys in this account.",
27126
+ hint: "Run 'Add a new push key' to create one first."
27127
+ });
27128
+ const localId = yield* promptSelect("Select a push key to revoke", items.map((key) => ({
27129
+ value: key.id,
27130
+ label: `${key.keyId} (team ${key.appleTeamId})`
27131
+ })));
27132
+ const target = items.find((entry) => entry.id === localId);
27133
+ if (target === void 0) return yield* new MissingCredentialsError({
27134
+ message: `Selected push key ${localId} not found.`,
27135
+ hint: "Re-run and pick again."
27136
+ });
27137
+ const keepLocal = yield* promptConfirm("Keep the key in this account after revoking?", { initialValue: false });
27138
+ const auth = yield* AppleAuth;
27139
+ const session = yield* auth.ensureLoggedIn();
27140
+ yield* Console.log("Logging in to Apple and revoking the push key...");
27141
+ const result = yield* revokeLocalApnsKey(ctx.api, {
27142
+ context: auth.buildRequestContext(session),
27143
+ pushKeyId: target.id,
27144
+ keyId: target.keyId,
27145
+ keepLocal
27146
+ });
27147
+ yield* Console.log("Revoke complete.");
27148
+ yield* printKeyValue([
27149
+ ["Local ID", result.localId],
27150
+ ["Key ID", result.keyId],
27151
+ ["Revoked on Apple", result.revokedOnApple ? "yes" : "no (not present on portal)"],
27152
+ ["Deleted locally", result.deletedLocally ? "yes" : "no (kept)"]
27153
+ ]);
27154
+ });
26929
27155
 
26930
27156
  //#endregion
26931
27157
  //#region src/application/credentials-manager-ios.ts
@@ -26990,8 +27216,26 @@ const generateNewIosDistributionCert = (ctx) => Effect.gen(function* () {
26990
27216
  ["Apple team", created.appleTeamIdentifier]
26991
27217
  ]);
26992
27218
  });
27219
+ const promptPushKeyMethod = () => promptSelect("How do you want to provide the APNs auth key?", [{
27220
+ value: "apple-id",
27221
+ label: "Create a new key by logging in with your Apple ID (recommended)"
27222
+ }, {
27223
+ value: "upload",
27224
+ label: "Upload a .p8 you already downloaded from the Apple portal"
27225
+ }]);
26993
27226
  const addIosPushKey = (ctx) => Effect.gen(function* () {
26994
- yield* printHuman("Apple does not expose APNs key creation via API.");
27227
+ if ((yield* promptPushKeyMethod()) === "apple-id") {
27228
+ const created = yield* createApnsKeyViaAppleId(ctx.api, defaultApnsKeyName());
27229
+ yield* Console.log("APNs push key created and registered.");
27230
+ yield* printKeyValue([
27231
+ ["ID", created.id],
27232
+ ["Key ID", created.keyId],
27233
+ ["Apple team", created.appleTeamIdentifier],
27234
+ ["Name", created.name]
27235
+ ]);
27236
+ return;
27237
+ }
27238
+ yield* printHuman("Apple does not expose APNs key creation via the public ASC API.");
26995
27239
  yield* printHuman(`Create one here, download .p8, then return: ${APPLE_PUSH_KEY_PORTAL_URL$1}`);
26996
27240
  const keyId = (yield* promptText("APNs key ID (10 uppercase alphanumeric)")).trim().toUpperCase();
26997
27241
  if (!APPLE_TEN_CHARS.test(keyId)) return yield* new CredentialValidationError({ message: `Push key ID "${keyId}" must be 10 uppercase alphanumeric characters.` });
@@ -27053,7 +27297,12 @@ const setupProjectPushNotifications = (ctx) => Effect.gen(function* () {
27053
27297
  yield* Console.log(`Push notifications set up: key ${pushKeyId} bound to ${config.bundleIdentifier} (${config.distributionType}).`);
27054
27298
  });
27055
27299
  const createNewPushKeyForBundle = (ctx, fallbackTeamId) => Effect.gen(function* () {
27056
- yield* printHuman("Apple does not expose APNs key creation via API.");
27300
+ if ((yield* promptPushKeyMethod()) === "apple-id") {
27301
+ const created = yield* createApnsKeyViaAppleId(ctx.api, defaultApnsKeyName());
27302
+ yield* Console.log(`APNs push key ${created.keyId} created.`);
27303
+ return created.id;
27304
+ }
27305
+ yield* printHuman("Apple does not expose APNs key creation via the public ASC API.");
27057
27306
  yield* printHuman(`Create one here, download .p8, then return: ${APPLE_PUSH_KEY_PORTAL_URL$1}`);
27058
27307
  const rawKeyId = (yield* promptText("APNs key ID (10 uppercase alphanumeric)")).trim().toUpperCase();
27059
27308
  if (!APPLE_TEN_CHARS.test(rawKeyId)) return yield* new CredentialValidationError({ message: `Push key ID "${rawKeyId}" must be 10 uppercase alphanumeric characters.` });
@@ -27140,9 +27389,13 @@ const iosPushKeysMenu = (ctx) => Effect.gen(function* () {
27140
27389
  value: "bind",
27141
27390
  label: "Use an existing push key"
27142
27391
  },
27392
+ {
27393
+ value: "revoke",
27394
+ label: "Revoke a push key (Apple Developer Portal)"
27395
+ },
27143
27396
  {
27144
27397
  value: "remove",
27145
- label: "Remove a push key"
27398
+ label: "Remove a push key (local only)"
27146
27399
  },
27147
27400
  {
27148
27401
  value: BACK,
@@ -27153,6 +27406,7 @@ const iosPushKeysMenu = (ctx) => Effect.gen(function* () {
27153
27406
  if (choice === "setup") yield* safely("set up push notifications", setupProjectPushNotifications(ctx));
27154
27407
  else if (choice === "add") yield* safely("add push key", addIosPushKey(ctx));
27155
27408
  else if (choice === "bind") yield* safely("bind push key", bindIosPushKey(ctx));
27409
+ else if (choice === "revoke") yield* safely("revoke push key", revokeIosPushKey(ctx));
27156
27410
  else if (choice === "remove") yield* safely("remove push key", pickAndDelete(ctx, "push-key", "APNs push key"));
27157
27411
  yield* iosPushKeysMenu(ctx);
27158
27412
  });
@@ -28127,6 +28381,129 @@ const downloadCommand = defineCommand({
28127
28381
  }), { json: "value" })
28128
28382
  });
28129
28383
 
28384
+ //#endregion
28385
+ //#region src/commands/credentials/generate-push-key.ts
28386
+ const PUSH_KEY_EXIT_EXTRAS = {
28387
+ CredentialValidationError: 2,
28388
+ AppleIdGenerateFailedError: 6,
28389
+ ApnsKeyLimitError: 6,
28390
+ AppleAuthError: 4,
28391
+ InteractiveProhibitedError: 4
28392
+ };
28393
+ const APPLE_PUSH_KEY_PORTAL_URL = "https://developer.apple.com/account/resources/authkeys/list";
28394
+ const KEY_ID_PATTERN = /^[A-Z0-9]{10}$/u;
28395
+ const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u;
28396
+ const resolveAppleTeamFromAscKey = (api, ascApiKeyId) => Effect.gen(function* () {
28397
+ if (ascApiKeyId === void 0) return;
28398
+ const teamId = (yield* api.ascApiKeys.list()).items.find((entry) => entry.id === ascApiKeyId)?.appleTeamId;
28399
+ return typeof teamId === "string" ? teamId : void 0;
28400
+ });
28401
+ const validateKeyId = (value) => KEY_ID_PATTERN.test(value) ? Effect.succeed(value) : Effect.fail(new CredentialValidationError({ message: `Push key ID "${value}" must be 10 uppercase alphanumeric characters.` }));
28402
+ const validateAppleTeamId = (value) => APPLE_TEAM_ID_PATTERN.test(value) ? Effect.succeed(value) : Effect.fail(new CredentialValidationError({ message: `Apple Team ID "${value}" must be 10 uppercase alphanumeric characters.` }));
28403
+ const resolvePushKeyInput = (api, args) => Effect.gen(function* () {
28404
+ const derivedTeamId = yield* resolveAppleTeamFromAscKey(api, args["asc-key-id"]);
28405
+ const keyId = yield* validateKeyId((args["key-id"] ?? (yield* promptText("APNs key ID (10 uppercase alphanumeric)"))).trim().toUpperCase());
28406
+ const appleTeamIdentifier = yield* validateAppleTeamId((args["apple-team-id"] ?? derivedTeamId ?? (yield* promptText("Apple Team identifier (10 uppercase alphanumeric)"))).trim().toUpperCase());
28407
+ const p8Path = args.p8 ?? (yield* promptText("Path to the AuthKey_XXXXXXXXXX.p8 file you downloaded"));
28408
+ if (p8Path.trim().length === 0) return yield* new CredentialValidationError({ message: "Missing --p8 path" });
28409
+ return {
28410
+ keyId,
28411
+ appleTeamIdentifier,
28412
+ p8Path,
28413
+ name: args.name ?? keyId
28414
+ };
28415
+ });
28416
+ const resolvePushKeyMethod = (args) => Effect.gen(function* () {
28417
+ if (args.p8 !== void 0 && args.p8.trim().length > 0) return "upload";
28418
+ if (args.method === "upload" || args.method === "apple-id") return args.method;
28419
+ return yield* promptSelect("How do you want to provide the APNs auth key?", [{
28420
+ value: "apple-id",
28421
+ label: "Create a new key by logging in with your Apple ID (recommended)"
28422
+ }, {
28423
+ value: "upload",
28424
+ label: "Upload a .p8 you already downloaded from the Apple portal"
28425
+ }]);
28426
+ });
28427
+ const uploadPushKeyFromFile = (api, args) => Effect.gen(function* () {
28428
+ if (args["skip-portal-hint"] !== true) {
28429
+ yield* printHuman("Apple does not expose APNs key creation via the public ASC API.");
28430
+ yield* printHuman("Create the key here, download the .p8, then come back:");
28431
+ yield* printHuman(` ${APPLE_PUSH_KEY_PORTAL_URL}`);
28432
+ yield* printHuman("");
28433
+ }
28434
+ const resolved = yield* resolvePushKeyInput(api, args);
28435
+ yield* printHuman("Uploading APNs auth key...");
28436
+ const credential = yield* uploadCredential(api, {
28437
+ platform: "ios",
28438
+ type: "push-key",
28439
+ name: resolved.name,
28440
+ filePath: resolved.p8Path,
28441
+ keyId: resolved.keyId,
28442
+ appleTeamIdentifier: resolved.appleTeamIdentifier
28443
+ });
28444
+ yield* printHuman("APNs push key registered.");
28445
+ yield* printHumanKeyValue([
28446
+ ["ID", credential.id],
28447
+ ["Key ID", resolved.keyId],
28448
+ ["Apple team", resolved.appleTeamIdentifier]
28449
+ ]);
28450
+ return credential;
28451
+ });
28452
+ const pushKeyCommand$1 = defineCommand({
28453
+ meta: {
28454
+ name: "push-key",
28455
+ description: "Create an APNs auth key (.p8) by logging in with your Apple ID, or upload one you downloaded; the key is end-to-end encrypted before upload"
28456
+ },
28457
+ args: {
28458
+ method: {
28459
+ type: "enum",
28460
+ options: ["apple-id", "upload"],
28461
+ description: "How to obtain the key: 'apple-id' (create via login) or 'upload' (provide --p8)"
28462
+ },
28463
+ "key-id": {
28464
+ type: "string",
28465
+ description: "APNs key ID — upload only (10 uppercase alphanumeric)"
28466
+ },
28467
+ "apple-team-id": {
28468
+ type: "string",
28469
+ description: "Apple Team identifier — upload only"
28470
+ },
28471
+ p8: {
28472
+ type: "string",
28473
+ description: "Path to the AuthKey_XXXXXXXXXX.p8 file (forces upload)"
28474
+ },
28475
+ "asc-key-id": {
28476
+ type: "string",
28477
+ description: "ASC API key ID to derive --apple-team-id automatically (upload only)"
28478
+ },
28479
+ name: {
28480
+ type: "string",
28481
+ description: "Display name (Apple ID: key name; upload: defaults to key ID)"
28482
+ },
28483
+ "skip-portal-hint": {
28484
+ type: "boolean",
28485
+ description: "Skip the Apple Developer portal URL hint (upload only)"
28486
+ }
28487
+ },
28488
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
28489
+ const api = yield* apiClient;
28490
+ if ((yield* resolvePushKeyMethod(args)) === "upload") return yield* uploadPushKeyFromFile(api, args);
28491
+ yield* printHuman("Creating an APNs auth key via your Apple ID...");
28492
+ const created = yield* createApnsKeyViaAppleId(api, args.name ?? defaultApnsKeyName());
28493
+ yield* printHuman("APNs push key created and registered.");
28494
+ yield* printHumanKeyValue([
28495
+ ["ID", created.id],
28496
+ ["Key ID", created.keyId],
28497
+ ["Apple team", created.appleTeamIdentifier],
28498
+ ["Name", created.name]
28499
+ ]);
28500
+ return created;
28501
+ }), {
28502
+ exits: PUSH_KEY_EXIT_EXTRAS,
28503
+ json: "value"
28504
+ })
28505
+ });
28506
+
28130
28507
  //#endregion
28131
28508
  //#region src/commands/credentials/generate.ts
28132
28509
  const GENERATE_EXIT_EXTRAS = {
@@ -28335,90 +28712,6 @@ const parseDeviceIds = (raw) => {
28335
28712
  const ids = raw.split(",").map((id) => id.trim()).filter((id) => id.length > 0);
28336
28713
  return ids.length === 0 ? void 0 : ids;
28337
28714
  };
28338
- const APPLE_PUSH_KEY_PORTAL_URL = "https://developer.apple.com/account/resources/authkeys/list";
28339
- const KEY_ID_PATTERN = /^[A-Z0-9]{10}$/u;
28340
- const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u;
28341
- const resolveAppleTeamFromAscKey = (api, ascApiKeyId) => Effect.gen(function* () {
28342
- if (ascApiKeyId === void 0) return;
28343
- const teamId = (yield* api.ascApiKeys.list()).items.find((entry) => entry.id === ascApiKeyId)?.appleTeamId;
28344
- return typeof teamId === "string" ? teamId : void 0;
28345
- });
28346
- const validateKeyId = (value) => KEY_ID_PATTERN.test(value) ? Effect.succeed(value) : Effect.fail(new CredentialValidationError({ message: `Push key ID "${value}" must be 10 uppercase alphanumeric characters.` }));
28347
- const validateAppleTeamId = (value) => APPLE_TEAM_ID_PATTERN.test(value) ? Effect.succeed(value) : Effect.fail(new CredentialValidationError({ message: `Apple Team ID "${value}" must be 10 uppercase alphanumeric characters.` }));
28348
- const pushKeyCommand = defineCommand({
28349
- meta: {
28350
- name: "push-key",
28351
- description: "Register an APNs auth key (.p8) — guides you through creating one in the Apple Developer portal, then uploads it"
28352
- },
28353
- args: {
28354
- "key-id": {
28355
- type: "string",
28356
- description: "APNs key ID (10 uppercase alphanumeric)"
28357
- },
28358
- "apple-team-id": {
28359
- type: "string",
28360
- description: "Apple Team identifier"
28361
- },
28362
- p8: {
28363
- type: "string",
28364
- description: "Path to the AuthKey_XXXXXXXXXX.p8 file"
28365
- },
28366
- "asc-key-id": {
28367
- type: "string",
28368
- description: "ASC API key ID to derive --apple-team-id automatically"
28369
- },
28370
- name: {
28371
- type: "string",
28372
- description: "Display name (defaults to the key ID)"
28373
- },
28374
- "skip-portal-hint": {
28375
- type: "boolean",
28376
- description: "Skip the Apple Developer portal URL hint (already created the key)"
28377
- }
28378
- },
28379
- run: async ({ args }) => runEffect(Effect.gen(function* () {
28380
- const api = yield* apiClient;
28381
- if (args["skip-portal-hint"] !== true) {
28382
- yield* printHuman("Apple does not expose APNs key creation via the public ASC API.");
28383
- yield* printHuman("Create the key here, download the .p8, then come back:");
28384
- yield* printHuman(` ${APPLE_PUSH_KEY_PORTAL_URL}`);
28385
- yield* printHuman("");
28386
- }
28387
- const resolved = yield* resolvePushKeyInput(api, args);
28388
- yield* printHuman("Uploading APNs auth key...");
28389
- const credential = yield* uploadCredential(api, {
28390
- platform: "ios",
28391
- type: "push-key",
28392
- name: resolved.name,
28393
- filePath: resolved.p8Path,
28394
- keyId: resolved.keyId,
28395
- appleTeamIdentifier: resolved.appleTeamIdentifier
28396
- });
28397
- yield* printHuman("APNs push key registered.");
28398
- yield* printHumanKeyValue([
28399
- ["ID", credential.id],
28400
- ["Key ID", resolved.keyId],
28401
- ["Apple team", resolved.appleTeamIdentifier]
28402
- ]);
28403
- return credential;
28404
- }), {
28405
- exits: GENERATE_EXIT_EXTRAS,
28406
- json: "value"
28407
- })
28408
- });
28409
- const resolvePushKeyInput = (api, args) => Effect.gen(function* () {
28410
- const derivedTeamId = yield* resolveAppleTeamFromAscKey(api, args["asc-key-id"]);
28411
- const keyId = yield* validateKeyId((args["key-id"] ?? (yield* promptText("APNs key ID (10 uppercase alphanumeric)"))).trim().toUpperCase());
28412
- const appleTeamIdentifier = yield* validateAppleTeamId((args["apple-team-id"] ?? derivedTeamId ?? (yield* promptText("Apple Team identifier (10 uppercase alphanumeric)"))).trim().toUpperCase());
28413
- const p8Path = args.p8 ?? (yield* promptText("Path to the AuthKey_XXXXXXXXXX.p8 file you downloaded"));
28414
- if (p8Path.trim().length === 0) return yield* new CredentialValidationError({ message: "Missing --p8 path" });
28415
- return {
28416
- keyId,
28417
- appleTeamIdentifier,
28418
- p8Path,
28419
- name: args.name ?? keyId
28420
- };
28421
- });
28422
28715
  const GSA_FIREBASE_URL = "https://console.firebase.google.com/project/_/settings/serviceaccounts/adminsdk";
28423
28716
  const GSA_GCP_URL = "https://console.cloud.google.com/iam-admin/serviceaccounts";
28424
28717
  const gsaKeyCommand = defineCommand({
@@ -28485,7 +28778,7 @@ const generateCommand$1 = defineCommand({
28485
28778
  keystore: keystoreCommand,
28486
28779
  "distribution-certificate": distributionCertificateCommand$1,
28487
28780
  "provisioning-profile": provisioningProfileCommand,
28488
- "push-key": pushKeyCommand,
28781
+ "push-key": pushKeyCommand$1,
28489
28782
  "gsa-key": gsaKeyCommand
28490
28783
  }
28491
28784
  });
@@ -28890,7 +29183,10 @@ const resolveType = (raw, available) => Effect.gen(function* () {
28890
29183
  //#region src/commands/credentials/revoke.ts
28891
29184
  const REVOKE_EXIT_EXTRAS = {
28892
29185
  CredentialValidationError: 2,
28893
- GenerateFailedError: 6
29186
+ GenerateFailedError: 6,
29187
+ AppleIdGenerateFailedError: 6,
29188
+ AppleAuthError: 4,
29189
+ InteractiveProhibitedError: 4
28894
29190
  };
28895
29191
  const resolveAscKeyId = (api, raw) => Effect.gen(function* () {
28896
29192
  if (raw !== void 0 && raw.length > 0) return raw;
@@ -28945,12 +29241,74 @@ const distributionCertificateCommand = defineCommand({
28945
29241
  json: "value"
28946
29242
  })
28947
29243
  });
29244
+ const resolvePushKeyTarget = (api, idArg) => Effect.gen(function* () {
29245
+ const { items } = yield* api.applePushKeys.list();
29246
+ if (items.length === 0) return yield* new CredentialValidationError({ message: "No APNs push keys stored. Nothing to revoke." });
29247
+ if (idArg !== void 0 && idArg.length > 0) {
29248
+ const match = items.find((entry) => entry.id === idArg);
29249
+ if (match === void 0) return yield* new CredentialValidationError({ message: `Push key ${idArg} not found.` });
29250
+ return match;
29251
+ }
29252
+ if (items.length === 1) {
29253
+ const [only] = items;
29254
+ if (only !== void 0) return only;
29255
+ }
29256
+ const chosen = yield* promptSelect("Select a push key to revoke", items.map((entry) => ({
29257
+ value: entry.id,
29258
+ label: `${entry.keyId} (team ${entry.appleTeamId})`
29259
+ })));
29260
+ const match = items.find((entry) => entry.id === chosen);
29261
+ if (match === void 0) return yield* new CredentialValidationError({ message: `Selected push key ${chosen} not found after listing.` });
29262
+ return match;
29263
+ });
29264
+ const pushKeyCommand = defineCommand({
29265
+ meta: {
29266
+ name: "push-key",
29267
+ description: "Revoke an APNs auth key on the Apple Developer Portal (via Apple ID login) and delete it from this account"
29268
+ },
29269
+ args: {
29270
+ id: {
29271
+ type: "string",
29272
+ description: "Local push key ID (prompts if omitted)"
29273
+ },
29274
+ "keep-local": {
29275
+ type: "boolean",
29276
+ description: "Revoke on Apple but keep the credential in this account"
29277
+ }
29278
+ },
29279
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
29280
+ const api = yield* apiClient;
29281
+ const target = yield* resolvePushKeyTarget(api, args.id);
29282
+ const auth = yield* AppleAuth;
29283
+ const session = yield* auth.ensureLoggedIn();
29284
+ const result = yield* revokeLocalApnsKey(api, {
29285
+ context: auth.buildRequestContext(session),
29286
+ pushKeyId: target.id,
29287
+ keyId: target.keyId,
29288
+ keepLocal: args["keep-local"] ?? false
29289
+ });
29290
+ yield* printHuman("APNs push key revoke complete.");
29291
+ yield* printHumanKeyValue([
29292
+ ["Local ID", result.localId],
29293
+ ["Key ID", result.keyId],
29294
+ ["Revoked on Apple", result.revokedOnApple ? "yes" : "no (not present on portal)"],
29295
+ ["Deleted locally", result.deletedLocally ? "yes" : "no (--keep-local)"]
29296
+ ]);
29297
+ return result;
29298
+ }), {
29299
+ exits: REVOKE_EXIT_EXTRAS,
29300
+ json: "value"
29301
+ })
29302
+ });
28948
29303
  const revokeCommand = defineCommand({
28949
29304
  meta: {
28950
29305
  name: "revoke",
28951
29306
  description: "Revoke credentials on the upstream provider"
28952
29307
  },
28953
- subCommands: { "distribution-certificate": distributionCertificateCommand }
29308
+ subCommands: {
29309
+ "distribution-certificate": distributionCertificateCommand,
29310
+ "push-key": pushKeyCommand
29311
+ }
28954
29312
  });
28955
29313
 
28956
29314
  //#endregion
@@ -30143,6 +30501,7 @@ const listDevicesCommand = defineCommand({
30143
30501
  "Class",
30144
30502
  "UDID",
30145
30503
  "Team",
30504
+ "Synced",
30146
30505
  "Enabled"
30147
30506
  ], items.map((device) => [
30148
30507
  device.id,
@@ -30150,6 +30509,7 @@ const listDevicesCommand = defineCommand({
30150
30509
  device.deviceClass,
30151
30510
  device.identifier,
30152
30511
  device.appleTeamId ?? "—",
30512
+ device.appleDevicePortalId === null ? "no" : "yes",
30153
30513
  device.enabled ? "yes" : "no"
30154
30514
  ]));
30155
30515
  return {
@@ -30190,6 +30550,150 @@ const renameDeviceCommand = defineCommand({
30190
30550
  }))
30191
30551
  });
30192
30552
 
30553
+ //#endregion
30554
+ //#region src/commands/devices/sync.ts
30555
+ const IDENTIFIER_PATTERN = /^(?:[A-Fa-f0-9]{40}|[A-Fa-f0-9]{8}-[A-Fa-f0-9]{16}|[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12})$/u;
30556
+ const APPLE_DEVICE_CLASS = {
30557
+ IPHONE: "IPHONE",
30558
+ IPAD: "IPAD",
30559
+ MAC: "MAC"
30560
+ };
30561
+ const toDeviceClass = (raw) => raw === null ? "UNKNOWN" : APPLE_DEVICE_CLASS[raw] ?? "UNKNOWN";
30562
+ const ascErrorMessage = (error) => error._tag === "AscApiError" ? error.message : `Apple request failed: ${String(error.cause)}`;
30563
+ const LIST_LIMIT = 100;
30564
+ /**
30565
+ * Resolve which ASC key authenticates the sync and which internal team it
30566
+ * targets. Either flag suffices: an ASC key already carries its team, and a team
30567
+ * resolves to the ASC key uploaded for it.
30568
+ */
30569
+ const resolveTarget = (api, args) => Effect.gen(function* () {
30570
+ const keyArg = args["asc-api-key-id"];
30571
+ const teamArg = args["apple-team-id"];
30572
+ const ascKeys = yield* api.ascApiKeys.list();
30573
+ if (keyArg !== void 0) {
30574
+ const match = ascKeys.items.find((key) => key.id === keyArg);
30575
+ if (match === void 0) return yield* new InvalidArgumentError({ message: `ASC API key "${keyArg}" not found in this organization.` });
30576
+ const appleTeamId = teamArg ?? match.appleTeamId;
30577
+ if (!appleTeamId) return yield* new InvalidArgumentError({ message: `ASC API key "${keyArg}" is not linked to an Apple team; pass --apple-team-id <uuid>.` });
30578
+ return {
30579
+ ascApiKeyId: keyArg,
30580
+ appleTeamId
30581
+ };
30582
+ }
30583
+ if (teamArg !== void 0) {
30584
+ const match = ascKeys.items.find((key) => key.appleTeamId === teamArg);
30585
+ if (match === void 0) return yield* new InvalidArgumentError({ message: `No ASC API key found for team "${teamArg}". Upload one with \`better-update credentials upload-asc-key\`.` });
30586
+ return {
30587
+ ascApiKeyId: match.id,
30588
+ appleTeamId: teamArg
30589
+ };
30590
+ }
30591
+ return yield* new InvalidArgumentError({ message: "Pass --apple-team-id <uuid> or --asc-api-key-id <id> to choose what to sync." });
30592
+ });
30593
+ const listAllLocalDevices = (api, appleTeamId) => Effect.gen(function* () {
30594
+ const items = [];
30595
+ let page = 1;
30596
+ let total = Number.POSITIVE_INFINITY;
30597
+ while (items.length < total) {
30598
+ const result = yield* api.devices.list({ urlParams: {
30599
+ appleTeamId,
30600
+ page,
30601
+ limit: LIST_LIMIT
30602
+ } });
30603
+ ({total} = result);
30604
+ if (result.items.length === 0) break;
30605
+ items.push(...result.items);
30606
+ page += 1;
30607
+ }
30608
+ return items;
30609
+ });
30610
+ const syncDeviceCommand = defineCommand({
30611
+ meta: {
30612
+ name: "sync",
30613
+ description: "Sync devices with Apple App Store Connect: register local-only devices on Apple and import devices already registered there"
30614
+ },
30615
+ args: {
30616
+ "apple-team-id": {
30617
+ type: "string",
30618
+ description: "Internal team Id (UUID) to sync; derived from --asc-api-key-id if omitted"
30619
+ },
30620
+ "asc-api-key-id": {
30621
+ type: "string",
30622
+ description: "ASC API key to authenticate with; derived from --apple-team-id if omitted"
30623
+ },
30624
+ push: {
30625
+ type: "boolean",
30626
+ default: true,
30627
+ description: "Register local-only devices on Apple",
30628
+ negativeDescription: "Skip registering local devices on Apple (--no-push)"
30629
+ },
30630
+ pull: {
30631
+ type: "boolean",
30632
+ default: true,
30633
+ description: "Import Apple-registered devices into better-update",
30634
+ negativeDescription: "Skip importing Apple devices (--no-pull)"
30635
+ }
30636
+ },
30637
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
30638
+ const api = yield* apiClient;
30639
+ const target = yield* resolveTarget(api, args);
30640
+ const creds = yield* fetchAscCredentials(api, target.ascApiKeyId);
30641
+ const ascCreds = {
30642
+ keyId: creds.keyId,
30643
+ issuerId: creds.issuerId,
30644
+ p8Pem: creds.p8Pem
30645
+ };
30646
+ const appleDevices = yield* listDevices(ascCreds);
30647
+ const local = yield* listAllLocalDevices(api, target.appleTeamId);
30648
+ const localUdids = new Set(local.map((device) => device.identifier.toLowerCase()));
30649
+ const pushed = [];
30650
+ const pushFailures = [];
30651
+ if (args.push) {
30652
+ const appleUdids = new Set(appleDevices.map((device) => device.udid.toLowerCase()));
30653
+ const toPush = local.filter((device) => !appleUdids.has(device.identifier.toLowerCase()));
30654
+ for (const device of toPush) {
30655
+ const result = yield* Effect.either(createDevice(ascCreds, {
30656
+ name: device.name,
30657
+ udid: device.identifier
30658
+ }));
30659
+ if (Either.isRight(result)) pushed.push(result.right);
30660
+ else pushFailures.push({
30661
+ identifier: device.identifier,
30662
+ message: ascErrorMessage(result.left)
30663
+ });
30664
+ }
30665
+ }
30666
+ const reconcileEntries = [...appleDevices, ...pushed].filter((device) => args.pull || localUdids.has(device.udid.toLowerCase())).filter((device) => IDENTIFIER_PATTERN.test(device.udid) && device.name.length > 0).map((device) => ({
30667
+ identifier: device.udid,
30668
+ name: device.name.slice(0, 120),
30669
+ deviceClass: toDeviceClass(device.deviceClass),
30670
+ appleDevicePortalId: device.id
30671
+ }));
30672
+ const summary = reconcileEntries.length > 0 ? yield* api.devices.syncDevices({ payload: {
30673
+ appleTeamId: target.appleTeamId,
30674
+ devices: reconcileEntries
30675
+ } }) : {
30676
+ created: 0,
30677
+ linked: 0,
30678
+ unchanged: 0
30679
+ };
30680
+ yield* printHumanKeyValue([
30681
+ ["Apple devices", String(appleDevices.length + pushed.length)],
30682
+ ["Pushed to Apple", String(pushed.length)],
30683
+ ["Imported locally", String(summary.created)],
30684
+ ["Linked (portal id set)", String(summary.linked)],
30685
+ ["Already synced", String(summary.unchanged)]
30686
+ ]);
30687
+ for (const failure of pushFailures) yield* printHuman(`⚠ Could not push ${failure.identifier} to Apple: ${failure.message}`);
30688
+ return {
30689
+ appleTeamId: target.appleTeamId,
30690
+ pushed: pushed.length,
30691
+ ...summary,
30692
+ pushFailures
30693
+ };
30694
+ }), { json: "value" })
30695
+ });
30696
+
30193
30697
  //#endregion
30194
30698
  //#region src/commands/devices/view.ts
30195
30699
  const viewDeviceCommand = defineCommand({
@@ -30230,6 +30734,7 @@ const devicesCommand = defineCommand({
30230
30734
  add: addDeviceCommand,
30231
30735
  list: listDevicesCommand,
30232
30736
  view: viewDeviceCommand,
30737
+ sync: syncDeviceCommand,
30233
30738
  rename: renameDeviceCommand,
30234
30739
  enable: enableDeviceCommand,
30235
30740
  disable: disableDeviceCommand,