@better-update/cli 0.31.0 → 0.32.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.0";
37
+ var version = "0.32.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"
@@ -19357,12 +19388,13 @@ const toAscDevice = (value) => {
19357
19388
  if (!isRecord$1(value)) return null;
19358
19389
  const { id, attributes } = value;
19359
19390
  if (typeof id !== "string" || !isRecord$1(attributes)) return null;
19360
- const { udid, name } = attributes;
19391
+ const { udid, name, deviceClass } = attributes;
19361
19392
  if (typeof udid !== "string" || typeof name !== "string") return null;
19362
19393
  return {
19363
19394
  id,
19364
19395
  udid,
19365
- name
19396
+ name,
19397
+ deviceClass: typeof deviceClass === "string" ? deviceClass : null
19366
19398
  };
19367
19399
  };
19368
19400
  const extractList = (body, map) => {
@@ -19373,6 +19405,18 @@ const extractSingle = (body, map) => {
19373
19405
  if (!isRecord$1(body)) return null;
19374
19406
  return map(body["data"]);
19375
19407
  };
19408
+ /**
19409
+ * App Store Connect paginates list responses (default 200/page) and returns the
19410
+ * absolute URL of the next page under `links.next`. Strip the base so it can be
19411
+ * fed back into `fetchRaw`; return null when there is no further page.
19412
+ */
19413
+ const nextPagePath = (body) => {
19414
+ if (!isRecord$1(body)) return null;
19415
+ const { links } = body;
19416
+ if (!isRecord$1(links) || typeof links["next"] !== "string") return null;
19417
+ const { next } = links;
19418
+ return next.startsWith(API_BASE) ? next.slice(37) : next;
19419
+ };
19376
19420
  const malformed = (resource) => new AscApiError({
19377
19421
  status: 500,
19378
19422
  message: `Malformed ${resource} response`,
@@ -19420,7 +19464,29 @@ const createBundleId = (credentials, params) => withJwt(credentials, (jwt) => Ef
19420
19464
  return resource;
19421
19465
  }));
19422
19466
  const listDevices = (credentials) => withJwt(credentials, (jwt) => Effect.gen(function* () {
19423
- return extractList(yield* fetchRaw(jwt, "/v1/devices?limit=200"), toAscDevice);
19467
+ const devices = [];
19468
+ let path = "/v1/devices?limit=200";
19469
+ while (path !== null) {
19470
+ const body = yield* fetchRaw(jwt, path);
19471
+ devices.push(...extractList(body, toAscDevice));
19472
+ path = nextPagePath(body);
19473
+ }
19474
+ return devices;
19475
+ }));
19476
+ const createDevice = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
19477
+ const resource = extractSingle(yield* fetchRaw(jwt, "/v1/devices", {
19478
+ method: "POST",
19479
+ body: JSON.stringify({ data: {
19480
+ type: "devices",
19481
+ attributes: {
19482
+ name: params.name,
19483
+ udid: params.udid,
19484
+ platform: "IOS"
19485
+ }
19486
+ } })
19487
+ }), toAscDevice);
19488
+ if (resource === null) return yield* malformed("device");
19489
+ return resource;
19424
19490
  }));
19425
19491
  const createProvisioningProfile = (credentials, params) => withJwt(credentials, (jwt) => Effect.gen(function* () {
19426
19492
  const relationships = {
@@ -29943,7 +30009,7 @@ const addDeviceCommand = defineCommand({
29943
30009
  },
29944
30010
  "apple-team-id": {
29945
30011
  type: "string",
29946
- description: "Apple team to assign"
30012
+ description: "Internal team Id (UUID from `credentials list`), not the Apple Team Identifier (e.g. 233P57T2L4)"
29947
30013
  },
29948
30014
  invite: {
29949
30015
  type: "boolean",
@@ -30101,7 +30167,7 @@ const listDevicesCommand = defineCommand({
30101
30167
  },
30102
30168
  "apple-team-id": {
30103
30169
  type: "string",
30104
- description: "Filter by Apple team ID"
30170
+ description: "Filter by internal team Id (UUID), not the Apple Team Identifier"
30105
30171
  },
30106
30172
  query: {
30107
30173
  type: "string",
@@ -30143,6 +30209,7 @@ const listDevicesCommand = defineCommand({
30143
30209
  "Class",
30144
30210
  "UDID",
30145
30211
  "Team",
30212
+ "Synced",
30146
30213
  "Enabled"
30147
30214
  ], items.map((device) => [
30148
30215
  device.id,
@@ -30150,6 +30217,7 @@ const listDevicesCommand = defineCommand({
30150
30217
  device.deviceClass,
30151
30218
  device.identifier,
30152
30219
  device.appleTeamId ?? "—",
30220
+ device.appleDevicePortalId === null ? "no" : "yes",
30153
30221
  device.enabled ? "yes" : "no"
30154
30222
  ]));
30155
30223
  return {
@@ -30190,6 +30258,150 @@ const renameDeviceCommand = defineCommand({
30190
30258
  }))
30191
30259
  });
30192
30260
 
30261
+ //#endregion
30262
+ //#region src/commands/devices/sync.ts
30263
+ 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;
30264
+ const APPLE_DEVICE_CLASS = {
30265
+ IPHONE: "IPHONE",
30266
+ IPAD: "IPAD",
30267
+ MAC: "MAC"
30268
+ };
30269
+ const toDeviceClass = (raw) => raw === null ? "UNKNOWN" : APPLE_DEVICE_CLASS[raw] ?? "UNKNOWN";
30270
+ const ascErrorMessage = (error) => error._tag === "AscApiError" ? error.message : `Apple request failed: ${String(error.cause)}`;
30271
+ const LIST_LIMIT = 100;
30272
+ /**
30273
+ * Resolve which ASC key authenticates the sync and which internal team it
30274
+ * targets. Either flag suffices: an ASC key already carries its team, and a team
30275
+ * resolves to the ASC key uploaded for it.
30276
+ */
30277
+ const resolveTarget = (api, args) => Effect.gen(function* () {
30278
+ const keyArg = args["asc-api-key-id"];
30279
+ const teamArg = args["apple-team-id"];
30280
+ const ascKeys = yield* api.ascApiKeys.list();
30281
+ if (keyArg !== void 0) {
30282
+ const match = ascKeys.items.find((key) => key.id === keyArg);
30283
+ if (match === void 0) return yield* new InvalidArgumentError({ message: `ASC API key "${keyArg}" not found in this organization.` });
30284
+ const appleTeamId = teamArg ?? match.appleTeamId;
30285
+ if (!appleTeamId) return yield* new InvalidArgumentError({ message: `ASC API key "${keyArg}" is not linked to an Apple team; pass --apple-team-id <uuid>.` });
30286
+ return {
30287
+ ascApiKeyId: keyArg,
30288
+ appleTeamId
30289
+ };
30290
+ }
30291
+ if (teamArg !== void 0) {
30292
+ const match = ascKeys.items.find((key) => key.appleTeamId === teamArg);
30293
+ 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\`.` });
30294
+ return {
30295
+ ascApiKeyId: match.id,
30296
+ appleTeamId: teamArg
30297
+ };
30298
+ }
30299
+ return yield* new InvalidArgumentError({ message: "Pass --apple-team-id <uuid> or --asc-api-key-id <id> to choose what to sync." });
30300
+ });
30301
+ const listAllLocalDevices = (api, appleTeamId) => Effect.gen(function* () {
30302
+ const items = [];
30303
+ let page = 1;
30304
+ let total = Number.POSITIVE_INFINITY;
30305
+ while (items.length < total) {
30306
+ const result = yield* api.devices.list({ urlParams: {
30307
+ appleTeamId,
30308
+ page,
30309
+ limit: LIST_LIMIT
30310
+ } });
30311
+ ({total} = result);
30312
+ if (result.items.length === 0) break;
30313
+ items.push(...result.items);
30314
+ page += 1;
30315
+ }
30316
+ return items;
30317
+ });
30318
+ const syncDeviceCommand = defineCommand({
30319
+ meta: {
30320
+ name: "sync",
30321
+ description: "Sync devices with Apple App Store Connect: register local-only devices on Apple and import devices already registered there"
30322
+ },
30323
+ args: {
30324
+ "apple-team-id": {
30325
+ type: "string",
30326
+ description: "Internal team Id (UUID) to sync; derived from --asc-api-key-id if omitted"
30327
+ },
30328
+ "asc-api-key-id": {
30329
+ type: "string",
30330
+ description: "ASC API key to authenticate with; derived from --apple-team-id if omitted"
30331
+ },
30332
+ push: {
30333
+ type: "boolean",
30334
+ default: true,
30335
+ description: "Register local-only devices on Apple",
30336
+ negativeDescription: "Skip registering local devices on Apple (--no-push)"
30337
+ },
30338
+ pull: {
30339
+ type: "boolean",
30340
+ default: true,
30341
+ description: "Import Apple-registered devices into better-update",
30342
+ negativeDescription: "Skip importing Apple devices (--no-pull)"
30343
+ }
30344
+ },
30345
+ run: async ({ args }) => runEffect(Effect.gen(function* () {
30346
+ const api = yield* apiClient;
30347
+ const target = yield* resolveTarget(api, args);
30348
+ const creds = yield* fetchAscCredentials(api, target.ascApiKeyId);
30349
+ const ascCreds = {
30350
+ keyId: creds.keyId,
30351
+ issuerId: creds.issuerId,
30352
+ p8Pem: creds.p8Pem
30353
+ };
30354
+ const appleDevices = yield* listDevices(ascCreds);
30355
+ const local = yield* listAllLocalDevices(api, target.appleTeamId);
30356
+ const localUdids = new Set(local.map((device) => device.identifier.toLowerCase()));
30357
+ const pushed = [];
30358
+ const pushFailures = [];
30359
+ if (args.push) {
30360
+ const appleUdids = new Set(appleDevices.map((device) => device.udid.toLowerCase()));
30361
+ const toPush = local.filter((device) => !appleUdids.has(device.identifier.toLowerCase()));
30362
+ for (const device of toPush) {
30363
+ const result = yield* Effect.either(createDevice(ascCreds, {
30364
+ name: device.name,
30365
+ udid: device.identifier
30366
+ }));
30367
+ if (Either.isRight(result)) pushed.push(result.right);
30368
+ else pushFailures.push({
30369
+ identifier: device.identifier,
30370
+ message: ascErrorMessage(result.left)
30371
+ });
30372
+ }
30373
+ }
30374
+ 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) => ({
30375
+ identifier: device.udid,
30376
+ name: device.name.slice(0, 120),
30377
+ deviceClass: toDeviceClass(device.deviceClass),
30378
+ appleDevicePortalId: device.id
30379
+ }));
30380
+ const summary = reconcileEntries.length > 0 ? yield* api.devices.syncDevices({ payload: {
30381
+ appleTeamId: target.appleTeamId,
30382
+ devices: reconcileEntries
30383
+ } }) : {
30384
+ created: 0,
30385
+ linked: 0,
30386
+ unchanged: 0
30387
+ };
30388
+ yield* printHumanKeyValue([
30389
+ ["Apple devices", String(appleDevices.length + pushed.length)],
30390
+ ["Pushed to Apple", String(pushed.length)],
30391
+ ["Imported locally", String(summary.created)],
30392
+ ["Linked (portal id set)", String(summary.linked)],
30393
+ ["Already synced", String(summary.unchanged)]
30394
+ ]);
30395
+ for (const failure of pushFailures) yield* printHuman(`⚠ Could not push ${failure.identifier} to Apple: ${failure.message}`);
30396
+ return {
30397
+ appleTeamId: target.appleTeamId,
30398
+ pushed: pushed.length,
30399
+ ...summary,
30400
+ pushFailures
30401
+ };
30402
+ }), { json: "value" })
30403
+ });
30404
+
30193
30405
  //#endregion
30194
30406
  //#region src/commands/devices/view.ts
30195
30407
  const viewDeviceCommand = defineCommand({
@@ -30230,6 +30442,7 @@ const devicesCommand = defineCommand({
30230
30442
  add: addDeviceCommand,
30231
30443
  list: listDevicesCommand,
30232
30444
  view: viewDeviceCommand,
30445
+ sync: syncDeviceCommand,
30233
30446
  rename: renameDeviceCommand,
30234
30447
  enable: enableDeviceCommand,
30235
30448
  disable: disableDeviceCommand,