@better-update/cli 0.29.0 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.29.0";
37
+ var version = "0.29.2";
38
38
 
39
39
  //#endregion
40
40
  //#region src/lib/interactive-mode.ts
@@ -18926,15 +18926,6 @@ const grantRecipient = (args) => Effect.gen(function* () {
18926
18926
  } });
18927
18927
  });
18928
18928
  /**
18929
- * Resolve the passphrase needed to unlock the active identity before a crypto
18930
- * operation: prompt for it when the identity is the on-disk file, or return
18931
- * `undefined` when the raw `BETTER_UPDATE_IDENTITY` env key is in use (CI). The
18932
- * resolved value is threaded into {@link unlockVaultKey} by the cipher helpers.
18933
- */
18934
- const resolveVaultPassphrase = Effect.gen(function* () {
18935
- return (yield* activeRecipient).source === "file" ? yield* promptPassword("Passphrase to unlock this device's identity:") : void 0;
18936
- });
18937
- /**
18938
18929
  * Unlock the org vault key for an interactive command, reusing a cached vault key
18939
18930
  * from the OS keychain when one is present and unexpired — so the device
18940
18931
  * passphrase is prompted at most once per cache TTL rather than on every command
@@ -18942,6 +18933,12 @@ const resolveVaultPassphrase = Effect.gen(function* () {
18942
18933
  * The CI `BETTER_UPDATE_IDENTITY` key carries no passphrase and is never cached:
18943
18934
  * it skips straight to the raw unwrap. On a cache miss the full unlock runs —
18944
18935
  * prompt, Argon2id, fetch + unwrap — and the result is cached for next time.
18936
+ *
18937
+ * The cached key is the unwrapped vault key, which both unwraps (decrypt/read)
18938
+ * and wraps (encrypt/write) DEKs — so this single entry point backs every vault
18939
+ * operation: download/build-resolve reads, seal-for-upload + generate writes, and
18940
+ * rotation. There is no read-only cache: an unlock makes the next write seamless
18941
+ * too.
18945
18942
  */
18946
18943
  const unlockVaultKeyInteractive = (api) => Effect.gen(function* () {
18947
18944
  const recipient = yield* activeRecipient;
@@ -18953,6 +18950,17 @@ const unlockVaultKeyInteractive = (api) => Effect.gen(function* () {
18953
18950
  yield* cache.set(recipient.publicKey, vault);
18954
18951
  return vault;
18955
18952
  }).pipe(Effect.provide(VaultCacheLive));
18953
+ /**
18954
+ * Forget the active recipient's cached vault key. Called after a rotation re-keys
18955
+ * the vault: the cached key + version are now stale, so leaving them would make
18956
+ * the next seal upload a key/version the server CAS-rejects (and the next decrypt
18957
+ * fail integrity). Clearing forces a fresh unlock at the new version next time —
18958
+ * which also correctly locks out a device that just revoked its own access.
18959
+ */
18960
+ const forgetCachedVaultKey = Effect.gen(function* () {
18961
+ const recipient = yield* activeRecipient;
18962
+ yield* (yield* VaultCache).clear(recipient.publicKey);
18963
+ }).pipe(Effect.provide(VaultCacheLive));
18956
18964
  /** Look up a registered recipient by its key id or full `SHA256:` fingerprint. */
18957
18965
  const findRecipient = (api, selector) => Effect.gen(function* () {
18958
18966
  const { items } = yield* api.userEncryptionKeys.list();
@@ -18992,13 +19000,6 @@ const getActiveOrgId = (api) => Effect.gen(function* () {
18992
19000
  if (me.activeOrganization === null) return yield* new IdentityError({ message: "No active organization for this token." });
18993
19001
  return me.activeOrganization.id;
18994
19002
  });
18995
- /** Resolve the active org id and unlock this device's vault key — the once-per-command I/O. */
18996
- const openVaultSession = (api, passphrase) => Effect.gen(function* () {
18997
- return {
18998
- orgId: yield* getActiveOrgId(api),
18999
- vault: yield* unlockVaultKey(api, passphrase)
19000
- };
19001
- });
19002
19003
  /**
19003
19004
  * {@link openVaultSession} that unlocks the vault key interactively — reusing the
19004
19005
  * OS-keychain-cached key when one is live (no prompt), prompting for the device
@@ -19129,6 +19130,43 @@ const openFromDownload = (args) => {
19129
19130
  //#region src/lib/android-keystore.ts
19130
19131
  const DEFAULT_KEYSTORE_VALIDITY_DAYS = 1e4;
19131
19132
  const renderDistinguishedName = (params) => `CN=${params.commonName}, O=${params.organization}`;
19133
+ const FINGERPRINT_PATTERNS = {
19134
+ md5: /MD5:\s*(?<value>[0-9A-F:]+)/iu,
19135
+ sha1: /SHA-?1:\s*(?<value>[0-9A-F:]+)/iu,
19136
+ sha256: /SHA-?256:\s*(?<value>[0-9A-F:]+)/iu
19137
+ };
19138
+ /**
19139
+ * Parse certificate fingerprints out of `keytool -list -v` output. The fingerprint
19140
+ * labels (`MD5:`, `SHA1:`, `SHA256:`) are stable across keytool locales — only the
19141
+ * surrounding prose is translated — so label-anchored regexes are robust. MD5 is
19142
+ * absent on modern JDKs (dropped from `-v` output); that field stays `undefined`.
19143
+ * keytool already emits the canonical uppercase, colon-separated form the dashboard
19144
+ * displays verbatim, so no normalization is needed.
19145
+ */
19146
+ const parseKeystoreFingerprints = (output) => ({
19147
+ md5: output.match(FINGERPRINT_PATTERNS.md5)?.groups?.["value"],
19148
+ sha1: output.match(FINGERPRINT_PATTERNS.sha1)?.groups?.["value"],
19149
+ sha256: output.match(FINGERPRINT_PATTERNS.sha256)?.groups?.["value"]
19150
+ });
19151
+ /**
19152
+ * Run `keytool -list -v` against an on-disk keystore and extract its certificate
19153
+ * fingerprints. Only the store password is required to read a certificate. Used at
19154
+ * upload/generate time to populate the public, server-visible fingerprint metadata
19155
+ * the dashboard renders.
19156
+ */
19157
+ const extractKeystoreFingerprints = (params) => Command.string(Command.make("keytool", "-list", "-v", "-keystore", params.keystorePath, "-alias", params.keyAlias, "-storepass", params.storePassword).pipe(Command.env({ LC_ALL: "C" }))).pipe(Effect.mapError((cause) => new BuildFailedError({
19158
+ step: "extract keystore fingerprints",
19159
+ exitCode: 1,
19160
+ message: `keytool -list failed to run (is the JDK installed?): ${String(cause)}`
19161
+ })), Effect.flatMap((output) => {
19162
+ const fingerprints = parseKeystoreFingerprints(output);
19163
+ if (fingerprints.sha1 === void 0 && fingerprints.sha256 === void 0) return Effect.fail(new BuildFailedError({
19164
+ step: "extract keystore fingerprints",
19165
+ exitCode: 1,
19166
+ message: "keytool produced no SHA-1/SHA-256 fingerprints — verify the key alias and keystore password"
19167
+ }));
19168
+ return Effect.succeed(fingerprints);
19169
+ }));
19132
19170
  const generateAndroidKeystore = (input) => Command.exitCode(Command.make("keytool", "-genkeypair", "-v", "-storetype", "JKS", "-keystore", input.outputPath, "-alias", input.keyAlias, "-keyalg", "RSA", "-keysize", "2048", "-validity", String(input.validityDays ?? DEFAULT_KEYSTORE_VALIDITY_DAYS), "-storepass", input.storePassword, "-keypass", input.keyPassword, "-dname", renderDistinguishedName({
19133
19171
  commonName: input.commonName,
19134
19172
  organization: input.organization
@@ -19610,8 +19648,18 @@ const generateAndUploadKeystore = (api, input) => Effect.scoped(Effect.gen(funct
19610
19648
  ...compact({ validityDays: input.validityDays })
19611
19649
  });
19612
19650
  const bytes = yield* fs.readFile(keystorePath);
19613
- const session = yield* openVaultSession(api, input.passphrase ?? (yield* resolveVaultPassphrase));
19614
- const metadata = { keyAlias: input.keyAlias };
19651
+ const fingerprints = yield* extractKeystoreFingerprints({
19652
+ keystorePath,
19653
+ keyAlias: input.keyAlias,
19654
+ storePassword: input.storePassword
19655
+ });
19656
+ const session = yield* openVaultSessionInteractive(api);
19657
+ const metadata = compact({
19658
+ keyAlias: input.keyAlias,
19659
+ md5Fingerprint: fingerprints.md5,
19660
+ sha1Fingerprint: fingerprints.sha1,
19661
+ sha256Fingerprint: fingerprints.sha256
19662
+ });
19615
19663
  const envelope = yield* sealForUpload({
19616
19664
  session,
19617
19665
  credentialType: "keystore",
@@ -19661,7 +19709,7 @@ const generateAndUploadDistributionCertificate = (api, input) => Effect.gen(func
19661
19709
  step: "p12-build",
19662
19710
  message: cause.message
19663
19711
  })));
19664
- const session = yield* openVaultSession(api, input.passphrase ?? (yield* resolveVaultPassphrase));
19712
+ const session = yield* openVaultSessionInteractive(api);
19665
19713
  const metadata = {
19666
19714
  serialNumber: bundle.metadata.serialNumber,
19667
19715
  appleTeamIdentifier: bundle.metadata.appleTeamId,
@@ -20667,7 +20715,7 @@ const generateAndUploadDistributionCertificateViaAppleId = (api, input) => Effec
20667
20715
  step: "parse-p12",
20668
20716
  message: cause.message
20669
20717
  })));
20670
- const session = yield* openVaultSession(api, yield* resolveVaultPassphrase);
20718
+ const session = yield* openVaultSessionInteractive(api);
20671
20719
  const envelopeMetadata = {
20672
20720
  serialNumber: metadata.serialNumber,
20673
20721
  appleTeamIdentifier: metadata.appleTeamId,
@@ -21065,14 +21113,12 @@ const isMissingResolveError = (cause) => hasTag(cause) && (cause._tag === "NotFo
21065
21113
  const randomKeystoreSecret = () => randomBytes(24).toString("base64url");
21066
21114
  const generateKeystoreAuto = (api, applicationIdentifier) => Effect.gen(function* () {
21067
21115
  yield* Console.log("Generating a new Android Keystore...");
21068
- const passphrase = yield* resolveVaultPassphrase;
21069
21116
  return (yield* generateAndUploadKeystore(api, {
21070
21117
  keyAlias: "upload",
21071
21118
  storePassword: randomKeystoreSecret(),
21072
21119
  keyPassword: randomKeystoreSecret(),
21073
21120
  commonName: applicationIdentifier,
21074
- organization: "better-update",
21075
- ...compact({ passphrase })
21121
+ organization: "better-update"
21076
21122
  })).id;
21077
21123
  });
21078
21124
  const generateKeystoreInteractive = (api) => Effect.gen(function* () {
@@ -21081,15 +21127,13 @@ const generateKeystoreInteractive = (api) => Effect.gen(function* () {
21081
21127
  const keyPassword = yield* promptPassword("Key password");
21082
21128
  const commonName = yield* promptText("Common name (CN)", { placeholder: "Your App" });
21083
21129
  const organization = yield* promptText("Organization (O)", { placeholder: "Your Company" });
21084
- const passphrase = yield* resolveVaultPassphrase;
21085
21130
  yield* Console.log("Generating keystore with keytool...");
21086
21131
  return (yield* generateAndUploadKeystore(api, {
21087
21132
  keyAlias: alias,
21088
21133
  storePassword,
21089
21134
  keyPassword,
21090
21135
  commonName,
21091
- organization,
21092
- ...compact({ passphrase })
21136
+ organization
21093
21137
  })).id;
21094
21138
  });
21095
21139
  const pickExistingKeystore = (api) => Effect.gen(function* () {
@@ -21112,6 +21156,25 @@ const resolveAndroidAppId = (api, input) => Effect.gen(function* () {
21112
21156
  })).id;
21113
21157
  });
21114
21158
  const resolveAndroidKeystoreId = (api, choice) => choice === "generate" ? generateKeystoreInteractive(api) : pickExistingKeystore(api);
21159
+ const bindAndroidKeystore = (api, appId, keystoreId) => Effect.gen(function* () {
21160
+ const existing = yield* api.androidBuildCredentials.list({ path: { applicationIdentifierId: appId } });
21161
+ const target = existing.items.find((group) => group.isDefault) ?? existing.items.at(0);
21162
+ if (target === void 0) {
21163
+ yield* api.androidBuildCredentials.create({
21164
+ path: { applicationIdentifierId: appId },
21165
+ payload: {
21166
+ name: "Default",
21167
+ isDefault: true,
21168
+ androidUploadKeystoreId: keystoreId
21169
+ }
21170
+ });
21171
+ return;
21172
+ }
21173
+ yield* api.androidBuildCredentials.update({
21174
+ path: { id: target.id },
21175
+ payload: { androidUploadKeystoreId: keystoreId }
21176
+ });
21177
+ });
21115
21178
  const setupAndroidInteractive = (api, input) => Effect.gen(function* () {
21116
21179
  yield* Console.log("");
21117
21180
  yield* Console.log(`No Android build credentials configured for ${input.applicationIdentifier}.`);
@@ -21134,15 +21197,7 @@ const setupAndroidInteractive = (api, input) => Effect.gen(function* () {
21134
21197
  message: `Build aborted — no keystore bound to ${input.applicationIdentifier}.`,
21135
21198
  hint: "Run `better-update credentials generate keystore` or upload via the dashboard."
21136
21199
  });
21137
- const keystoreId = yield* choice === "generate" ? generateKeystoreAuto(api, input.applicationIdentifier) : pickExistingKeystore(api);
21138
- yield* api.androidBuildCredentials.create({
21139
- path: { applicationIdentifierId: appId },
21140
- payload: {
21141
- name: "Default",
21142
- isDefault: true,
21143
- androidUploadKeystoreId: keystoreId
21144
- }
21145
- });
21200
+ yield* bindAndroidKeystore(api, appId, yield* choice === "generate" ? generateKeystoreAuto(api, input.applicationIdentifier) : pickExistingKeystore(api));
21146
21201
  yield* Console.log("Android build credentials configured.");
21147
21202
  });
21148
21203
  const ensureAndroidCredentialsAvailable = (api, input) => api.buildCredentials.resolve({
@@ -25896,8 +25951,9 @@ const detectFormat = (bytes) => {
25896
25951
  /**
25897
25952
  * Validate keystore bytes + alias/passwords locally before sealing. Mirrors the
25898
25953
  * old server check: magic-byte format detection + required-field validation.
25899
- * Fingerprints are not derived from the bytes (keytool never surfaced them here
25900
- * either) they remain optional, user-supplied metadata.
25954
+ * Fingerprints cannot be derived from the raw bytes; they are extracted separately
25955
+ * via keytool (`extractKeystoreFingerprints` in ./android-keystore) at upload/generate
25956
+ * time and attached to the public metadata.
25901
25957
  */
25902
25958
  const validateAndroidKeystore = (params) => Effect.gen(function* () {
25903
25959
  if (params.bytes.byteLength < 16) return yield* new CredentialValidationError({ message: "Keystore file too small" });
@@ -26049,7 +26105,7 @@ const uploadIosDistributionCertificate = (api, input, bytes) => Effect.gen(funct
26049
26105
  validUntil: info.expiresAt.toISOString()
26050
26106
  };
26051
26107
  const envelope = yield* sealForUpload({
26052
- session: yield* openVaultSession(api, input.passphrase),
26108
+ session: yield* openVaultSessionInteractive(api),
26053
26109
  credentialType: "distribution-certificate",
26054
26110
  metadata,
26055
26111
  secret: {
@@ -26075,7 +26131,7 @@ const uploadIosPushKey = (api, input, bytes) => Effect.gen(function* () {
26075
26131
  appleTeamIdentifier: input.appleTeamIdentifier
26076
26132
  };
26077
26133
  const envelope = yield* sealForUpload({
26078
- session: yield* openVaultSession(api, input.passphrase),
26134
+ session: yield* openVaultSessionInteractive(api),
26079
26135
  credentialType: "push-key",
26080
26136
  metadata,
26081
26137
  secret: { p8Pem: toUtf8(bytes) }
@@ -26100,7 +26156,7 @@ const uploadIosAscApiKey = (api, input, bytes) => Effect.gen(function* () {
26100
26156
  appleTeamIdentifier: input.appleTeamIdentifier
26101
26157
  });
26102
26158
  const envelope = yield* sealForUpload({
26103
- session: yield* openVaultSession(api, input.passphrase),
26159
+ session: yield* openVaultSessionInteractive(api),
26104
26160
  credentialType: "asc-api-key",
26105
26161
  metadata,
26106
26162
  secret: { p8Pem: toUtf8(bytes) }
@@ -26127,14 +26183,25 @@ const uploadAndroidKeystore = (api, input, bytes) => Effect.gen(function* () {
26127
26183
  if (input.password === void 0) return yield* missing("password");
26128
26184
  if (!input.keyAlias) return yield* missing("key-alias");
26129
26185
  if (!input.keyPassword) return yield* missing("key-password");
26130
- const metadata = { keyAlias: (yield* validateAndroidKeystore({
26186
+ const parsed = yield* validateAndroidKeystore({
26131
26187
  bytes,
26132
26188
  keyAlias: input.keyAlias,
26133
26189
  keystorePassword: input.password,
26134
26190
  keyPassword: input.keyPassword
26135
- })).keyAlias };
26191
+ });
26192
+ const fingerprints = yield* extractKeystoreFingerprints({
26193
+ keystorePath: input.filePath,
26194
+ keyAlias: parsed.keyAlias,
26195
+ storePassword: input.password
26196
+ });
26197
+ const metadata = compact({
26198
+ keyAlias: parsed.keyAlias,
26199
+ md5Fingerprint: fingerprints.md5,
26200
+ sha1Fingerprint: fingerprints.sha1,
26201
+ sha256Fingerprint: fingerprints.sha256
26202
+ });
26136
26203
  const envelope = yield* sealForUpload({
26137
- session: yield* openVaultSession(api, input.passphrase),
26204
+ session: yield* openVaultSessionInteractive(api),
26138
26205
  credentialType: "keystore",
26139
26206
  metadata,
26140
26207
  secret: {
@@ -26162,7 +26229,7 @@ const uploadAndroidGoogleServiceAccountKey = (api, input, bytes) => Effect.gen(f
26162
26229
  googleProjectId: parsed.googleProjectId
26163
26230
  };
26164
26231
  const envelope = yield* sealForUpload({
26165
- session: yield* openVaultSession(api, input.passphrase),
26232
+ session: yield* openVaultSessionInteractive(api),
26166
26233
  credentialType: "google-service-account-key",
26167
26234
  metadata,
26168
26235
  secret: { json }
@@ -26191,10 +26258,7 @@ const uploadCredential = (api, input) => Effect.gen(function* () {
26191
26258
  const hasKey = (candidate) => Object.hasOwn(uploadHandlers, candidate);
26192
26259
  const handler = hasKey(key) ? uploadHandlers[key] : void 0;
26193
26260
  if (!handler) return yield* new CredentialValidationError({ message: `Unsupported credential combination: platform=${input.platform} type=${input.type}` });
26194
- return yield* handler(api, input.type === "provisioning-profile" || input.passphrase !== void 0 ? input : {
26195
- ...input,
26196
- ...compact({ passphrase: yield* resolveVaultPassphrase })
26197
- }, bytes);
26261
+ return yield* handler(api, input, bytes);
26198
26262
  });
26199
26263
  const deleteCredential = (api, input) => {
26200
26264
  const path = { id: input.id };
@@ -27185,10 +27249,14 @@ const runCredentialsManager = Effect.gen(function* () {
27185
27249
  * one, re-wrap the new vault key to each recipient, then submit the rotation
27186
27250
  * atomically (the server CAS-guards on the current version and requires a
27187
27251
  * recovery recipient in the set). Drops every recipient not in `recipients`.
27252
+ *
27253
+ * Unlocks via the cache-aware path (reusing a live `credentials unlock` session),
27254
+ * then drops that cached key once the re-key lands — it is now stale, so the next
27255
+ * operation must re-unlock at the new version.
27188
27256
  */
27189
27257
  const rotateVaultTo = (args) => Effect.gen(function* () {
27190
27258
  const orgId = yield* getActiveOrgId(args.api);
27191
- const current = yield* unlockVaultKey(args.api, args.passphrase);
27259
+ const current = yield* unlockVaultKeyInteractive(args.api);
27192
27260
  const newVaultKey = generateVaultKey();
27193
27261
  const newVersion = current.vaultVersion + 1;
27194
27262
  const { deks } = yield* args.api.orgVault.listCredentialDeks();
@@ -27226,11 +27294,13 @@ const rotateVaultTo = (args) => Effect.gen(function* () {
27226
27294
  recipient: recipient.publicKey
27227
27295
  }))
27228
27296
  })), { concurrency: "unbounded" });
27229
- return yield* args.api.orgVault.rotate({ payload: {
27297
+ const rotated = yield* args.api.orgVault.rotate({ payload: {
27230
27298
  fromVersion: current.vaultVersion,
27231
27299
  recipientWraps,
27232
27300
  credentialDeks
27233
27301
  } });
27302
+ yield* forgetCachedVaultKey;
27303
+ return rotated;
27234
27304
  });
27235
27305
  /** The encryption keys currently holding the vault key, joined with their public keys. */
27236
27306
  const currentRecipients = (api) => Effect.gen(function* () {
@@ -27365,7 +27435,6 @@ const rotateCommand = defineCommand({
27365
27435
  yield* confirmRecipients(recipients, args.yes === true);
27366
27436
  const rotated = yield* rotateVaultTo({
27367
27437
  api,
27368
- passphrase: yield* resolveVaultPassphrase,
27369
27438
  recipients: recipients.map(toRotationRecipient)
27370
27439
  });
27371
27440
  yield* printHuman(`Rotated the vault to version ${String(rotated.vaultVersion)} (${String(recipients.length)} recipients).`);
@@ -27401,7 +27470,6 @@ const revokeCommand$1 = defineCommand({
27401
27470
  yield* confirmRecipients(surviving, args.yes === true);
27402
27471
  const rotated = yield* rotateVaultTo({
27403
27472
  api,
27404
- passphrase: yield* resolveVaultPassphrase,
27405
27473
  recipients: surviving.map(toRotationRecipient)
27406
27474
  });
27407
27475
  yield* printHuman(`Revoked ${target.label} and rotated the vault to version ${String(rotated.vaultVersion)}.`);
@@ -27487,7 +27555,6 @@ const recoveryCommand = defineCommand({
27487
27555
  yield* confirmRecipients(surviving, args.yes === true);
27488
27556
  const rotated = yield* rotateVaultTo({
27489
27557
  api,
27490
- passphrase: yield* resolveVaultPassphrase,
27491
27558
  recipients: [...surviving.map(toRotationRecipient), {
27492
27559
  userEncryptionKeyId: registered.id,
27493
27560
  publicKey: newRecovery.publicKey