@better-update/cli 0.24.2 → 0.25.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
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, spawn, spawnSync } from "node:child_process";
4
4
  import { defineCommand, runMain } from "citty";
5
- import { Console, Context, Data, Deferred, Duration, Effect, Either, Layer, Match, Option, ParseResult, Schedule, Schema } from "effect";
5
+ import { Clock, Console, Context, Data, Deferred, Duration, Effect, Either, Layer, Match, Option, ParseResult, Schedule, Schema } from "effect";
6
6
  import { Command, FetchHttpClient, FileSystem, Headers as Headers$1, HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, HttpClient, HttpClientRequest, OpenApi, Path } from "@effect/platform";
7
7
  import { NodeContext } from "@effect/platform-node";
8
8
  import path from "node:path";
@@ -12,6 +12,7 @@ import { autocomplete, cancel, confirm, isCancel, multiselect, password, select,
12
12
  import { open, readFile, writeFile } from "node:fs/promises";
13
13
  import { X509Certificate, createHash, createSign, createVerify, randomBytes, randomUUID } from "node:crypto";
14
14
  import { accessSync, chmodSync, constants, createReadStream, existsSync, promises, readFileSync, writeFileSync } from "node:fs";
15
+ import { Entry } from "@napi-rs/keyring";
15
16
  import { once } from "node:events";
16
17
  import { createServer } from "node:http";
17
18
  import { maxBy, uniqBy } from "es-toolkit";
@@ -33,7 +34,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
33
34
 
34
35
  //#endregion
35
36
  //#region package.json
36
- var version = "0.24.2";
37
+ var version = "0.25.0";
37
38
 
38
39
  //#endregion
39
40
  //#region src/lib/interactive-mode.ts
@@ -2929,6 +2930,81 @@ const UpdateAssetUploaderLive = Layer.effect(UpdateAssetUploader, Effect.gen(fun
2929
2930
  }) };
2930
2931
  }));
2931
2932
 
2933
+ //#endregion
2934
+ //#region src/services/vault-cache.ts
2935
+ /**
2936
+ * "Unlock once, reuse" for the credential vault — the analog of macOS
2937
+ * `security unlock-keychain`. The first vault operation in a session prompts for
2938
+ * the device passphrase, unwraps the vault key, and stows it in the OS keychain
2939
+ * (`@napi-rs/keyring`: macOS Keychain / Windows Credential Manager / Linux
2940
+ * libsecret) with a short TTL; subsequent commands read it back and skip the
2941
+ * prompt + Argon2id derivation entirely until it expires.
2942
+ *
2943
+ * What is cached is the unwrapped **vault key**, never the passphrase or the age
2944
+ * private key — so the blast radius of a leaked keychain entry is one vault
2945
+ * version's credentials, and only until the TTL lapses.
2946
+ */
2947
+ /** How long a cached vault key stays valid before a fresh passphrase is required. */
2948
+ const VAULT_CACHE_TTL_MS = 900 * 1e3;
2949
+ /** Keychain service name; the account is the recipient's public key. */
2950
+ const KEYCHAIN_SERVICE = "better-update-vault";
2951
+ const isCachedVaultEntry = (value) => isRecord(value) && typeof value["vaultKey"] === "string" && typeof value["vaultVersion"] === "number" && typeof value["keyId"] === "string" && typeof value["exp"] === "number";
2952
+ /** Serialize an unlocked vault into a keychain blob, stamping a TTL from `now`. */
2953
+ const encodeCacheEntry = (vault, now, ttlMs = VAULT_CACHE_TTL_MS) => JSON.stringify({
2954
+ vaultKey: toBase64(vault.vaultKey),
2955
+ vaultVersion: vault.vaultVersion,
2956
+ keyId: vault.keyId,
2957
+ exp: now + ttlMs
2958
+ });
2959
+ /**
2960
+ * Parse a keychain blob back into an unlocked vault, or `undefined` when it is
2961
+ * malformed or has expired as of `now` — so an expired entry reads exactly like
2962
+ * a missing one (and is evicted by the caller).
2963
+ */
2964
+ const decodeCacheEntry = (raw, now) => {
2965
+ const parsed = safeJsonParse(raw);
2966
+ if (!isCachedVaultEntry(parsed) || now >= parsed.exp) return;
2967
+ return {
2968
+ vault: {
2969
+ vaultKey: fromBase64(parsed.vaultKey),
2970
+ vaultVersion: parsed.vaultVersion,
2971
+ keyId: parsed.keyId
2972
+ },
2973
+ remainingMs: parsed.exp - now
2974
+ };
2975
+ };
2976
+ var VaultCache = class extends Context.Tag("cli/VaultCache")() {};
2977
+ const VaultCacheLive = Layer.effect(VaultCache, Effect.gen(function* () {
2978
+ const runtime = yield* CliRuntime;
2979
+ const cacheDisabled = Effect.gen(function* () {
2980
+ const flag = yield* runtime.getEnv("BETTER_UPDATE_NO_CACHE");
2981
+ return flag !== void 0 && flag.length > 0 && flag !== "0" && flag !== "false";
2982
+ });
2983
+ const readRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).getPassword()).pipe(Effect.catchAll(() => Effect.succeed(null)));
2984
+ const writeRaw = (publicKey, blob) => Effect.try(() => {
2985
+ new Entry(KEYCHAIN_SERVICE, publicKey).setPassword(blob);
2986
+ }).pipe(Effect.ignore);
2987
+ const deleteRaw = (publicKey) => Effect.try(() => new Entry(KEYCHAIN_SERVICE, publicKey).deletePassword()).pipe(Effect.ignore);
2988
+ return {
2989
+ get: (publicKey) => Effect.gen(function* () {
2990
+ if (yield* cacheDisabled) return;
2991
+ const raw = yield* readRaw(publicKey);
2992
+ if (raw === null) return;
2993
+ const decoded = decodeCacheEntry(raw, yield* Clock.currentTimeMillis);
2994
+ if (decoded === void 0) {
2995
+ yield* deleteRaw(publicKey);
2996
+ return;
2997
+ }
2998
+ return decoded;
2999
+ }),
3000
+ set: (publicKey, vault) => Effect.gen(function* () {
3001
+ if (yield* cacheDisabled) return;
3002
+ yield* writeRaw(publicKey, encodeCacheEntry(vault, yield* Clock.currentTimeMillis));
3003
+ }),
3004
+ clear: (publicKey) => deleteRaw(publicKey)
3005
+ };
3006
+ }));
3007
+
2932
3008
  //#endregion
2933
3009
  //#region src/services/version-check.ts
2934
3010
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/@better-update/cli/latest";
@@ -2979,7 +3055,7 @@ const VersionCheckLive = Layer.effect(VersionCheck, Effect.gen(function* () {
2979
3055
  //#endregion
2980
3056
  //#region src/app-layer.ts
2981
3057
  const CliPlatformLayer = Layer.mergeAll(CliRuntimeLive, NodeContext.layer, FetchHttpClient.layer);
2982
- const CliStoreLayer = Layer.mergeAll(AuthStoreLive, ConfigStoreLive, AppleSessionStoreLive, IdentityStoreLive).pipe(Layer.provide(CliPlatformLayer));
3058
+ const CliStoreLayer = Layer.mergeAll(AuthStoreLive, ConfigStoreLive, AppleSessionStoreLive, IdentityStoreLive, VaultCacheLive).pipe(Layer.provide(CliPlatformLayer));
2983
3059
  const CliAdapterDependencies = Layer.mergeAll(CliPlatformLayer, CliStoreLayer);
2984
3060
  const ApiClientLayer = ApiClientLive.pipe(Layer.provide(CliAdapterDependencies));
2985
3061
  const AppleAuthLayer = AppleAuthLive.pipe(Layer.provide(CliAdapterDependencies));
@@ -18373,6 +18449,25 @@ const grantRecipient = (args) => Effect.gen(function* () {
18373
18449
  const resolveVaultPassphrase = Effect.gen(function* () {
18374
18450
  return (yield* activeRecipient).source === "file" ? yield* promptPassword("Passphrase to unlock this device's identity:") : void 0;
18375
18451
  });
18452
+ /**
18453
+ * Unlock the org vault key for an interactive command, reusing a cached vault key
18454
+ * from the OS keychain when one is present and unexpired — so the device
18455
+ * passphrase is prompted at most once per cache TTL rather than on every command
18456
+ * (`better-update credentials unlock` / `lock` drive that session explicitly).
18457
+ * The CI `BETTER_UPDATE_IDENTITY` key carries no passphrase and is never cached:
18458
+ * it skips straight to the raw unwrap. On a cache miss the full unlock runs —
18459
+ * prompt, Argon2id, fetch + unwrap — and the result is cached for next time.
18460
+ */
18461
+ const unlockVaultKeyInteractive = (api) => Effect.gen(function* () {
18462
+ const recipient = yield* activeRecipient;
18463
+ if (recipient.source !== "file") return yield* unlockVaultKey(api, void 0);
18464
+ const cache = yield* VaultCache;
18465
+ const cached = yield* cache.get(recipient.publicKey);
18466
+ if (cached !== void 0) return cached.vault;
18467
+ const vault = yield* unlockVaultKey(api, yield* promptPassword("Passphrase to unlock this device's identity:"));
18468
+ yield* cache.set(recipient.publicKey, vault);
18469
+ return vault;
18470
+ }).pipe(Effect.provide(VaultCacheLive));
18376
18471
  /** Look up a registered recipient by its key id or full `SHA256:` fingerprint. */
18377
18472
  const findRecipient = (api, selector) => Effect.gen(function* () {
18378
18473
  const { items } = yield* api.userEncryptionKeys.list();
@@ -18420,11 +18515,15 @@ const openVaultSession = (api, passphrase) => Effect.gen(function* () {
18420
18515
  };
18421
18516
  });
18422
18517
  /**
18423
- * {@link openVaultSession} that first resolves the device passphraseprompting
18424
- * when the active identity is the on-disk file, none for the CI env key.
18518
+ * {@link openVaultSession} that unlocks the vault key interactively reusing the
18519
+ * OS-keychain-cached key when one is live (no prompt), prompting for the device
18520
+ * passphrase only on a cache miss, and none at all for the CI env key.
18425
18521
  */
18426
18522
  const openVaultSessionInteractive = (api) => Effect.gen(function* () {
18427
- return yield* openVaultSession(api, yield* resolveVaultPassphrase);
18523
+ return {
18524
+ orgId: yield* getActiveOrgId(api),
18525
+ vault: yield* unlockVaultKeyInteractive(api)
18526
+ };
18428
18527
  });
18429
18528
  /** Reshape a sealed envelope into the `{ id, …opaque fields }` an upload body carries. */
18430
18529
  const toUploadEnvelope = (envelope) => ({
@@ -26102,13 +26201,11 @@ const currentRecipients = (api) => Effect.gen(function* () {
26102
26201
  //#endregion
26103
26202
  //#region src/commands/credentials/vault-session.ts
26104
26203
  /**
26105
- * Unlock the vault key, prompting for the device passphrase only when the active
26106
- * identity is the on-disk file the CI `BETTER_UPDATE_IDENTITY` env key is raw
26107
- * and needs none.
26204
+ * Unlock the vault key for an interactive command: reuse the OS-keychain-cached
26205
+ * key when live, prompt for the device passphrase only on a cache miss, and none
26206
+ * at all for the CI `BETTER_UPDATE_IDENTITY` env key.
26108
26207
  */
26109
- const unlockVaultInteractively = (api) => Effect.gen(function* () {
26110
- return yield* unlockVaultKey(api, yield* resolveVaultPassphrase);
26111
- });
26208
+ const unlockVaultInteractively = (api) => unlockVaultKeyInteractive(api);
26112
26209
  /** Resolve a recipient selector (key id or fingerprint) from a flag, prompting if absent. */
26113
26210
  const resolveSelector = (flag, message) => Effect.gen(function* () {
26114
26211
  if (flag && flag.trim().length > 0) return flag.trim();
@@ -27740,6 +27837,56 @@ const revokeCommand = defineCommand({
27740
27837
  subCommands: { "distribution-certificate": distributionCertificateCommand }
27741
27838
  });
27742
27839
 
27840
+ //#endregion
27841
+ //#region src/commands/credentials/session.ts
27842
+ /** Whole minutes left, rounded up so "<1 min remaining" still reads as 1. */
27843
+ const remainingMinutes = (remainingMs) => Math.max(1, Math.ceil(remainingMs / 6e4));
27844
+ const unlockCommand = defineCommand({
27845
+ meta: {
27846
+ name: "unlock",
27847
+ description: "Unlock the credential vault and cache the key in your OS keychain, so later commands don't re-prompt"
27848
+ },
27849
+ run: async () => runEffect(Effect.gen(function* () {
27850
+ const recipient = yield* activeRecipient;
27851
+ if (recipient.source !== "file") {
27852
+ yield* printHuman("Active identity is the BETTER_UPDATE_IDENTITY (CI) key — it has no passphrase and isn't cached.");
27853
+ return;
27854
+ }
27855
+ const api = yield* apiClient;
27856
+ const cache = yield* VaultCache;
27857
+ yield* cache.clear(recipient.publicKey);
27858
+ yield* unlockVaultKeyInteractive(api);
27859
+ const cached = yield* cache.get(recipient.publicKey);
27860
+ yield* printHuman(`Vault unlocked${cached === void 0 ? " (no OS keychain available — commands will keep prompting)" : ` for ~${remainingMinutes(cached.remainingMs)} min; run \`better-update credentials lock\` to clear it`}.`);
27861
+ }))
27862
+ });
27863
+ const lockCommand = defineCommand({
27864
+ meta: {
27865
+ name: "lock",
27866
+ description: "Forget the cached vault key — the next credential command will prompt again"
27867
+ },
27868
+ run: async () => runEffect(Effect.gen(function* () {
27869
+ const recipient = yield* activeRecipient;
27870
+ yield* (yield* VaultCache).clear(recipient.publicKey);
27871
+ yield* printHuman("Vault locked — the cached key was cleared from your OS keychain.");
27872
+ }))
27873
+ });
27874
+ const statusCommand$1 = defineCommand({
27875
+ meta: {
27876
+ name: "status",
27877
+ description: "Show whether the vault is currently unlocked (cached) and for how much longer"
27878
+ },
27879
+ run: async () => runEffect(Effect.gen(function* () {
27880
+ const recipient = yield* activeRecipient;
27881
+ if (recipient.source !== "file") {
27882
+ yield* printHuman("Active identity is the BETTER_UPDATE_IDENTITY (CI) key — caching not used.");
27883
+ return;
27884
+ }
27885
+ const cached = yield* (yield* VaultCache).get(recipient.publicKey);
27886
+ yield* printHuman(cached === void 0 ? "Locked — the next credential command will prompt for your passphrase." : `Unlocked — cached vault key expires in ~${remainingMinutes(cached.remainingMs)} min.`);
27887
+ }))
27888
+ });
27889
+
27743
27890
  //#endregion
27744
27891
  //#region src/commands/credentials/sync/helpers.ts
27745
27892
  const SYNC_EXIT_EXTRAS = {
@@ -28599,6 +28746,9 @@ const credentialsCommand = defineCommand({
28599
28746
  identity: identityCommand,
28600
28747
  access: accessCommand,
28601
28748
  device: deviceCommand,
28749
+ unlock: unlockCommand,
28750
+ lock: lockCommand,
28751
+ status: statusCommand$1,
28602
28752
  list: listCommand$3,
28603
28753
  view: viewCommand$1,
28604
28754
  download: downloadCommand,