@better-update/cli 0.24.1 → 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.1";
37
+ var version = "0.25.0";
37
38
 
38
39
  //#endregion
39
40
  //#region src/lib/interactive-mode.ts
@@ -67,7 +68,7 @@ const cookieSecurity = HttpApiSecurity.apiKey({
67
68
  in: "cookie"
68
69
  });
69
70
  var Authentication = class extends HttpApiMiddleware.Tag()("api/Authentication", {
70
- failure: Unauthorized,
71
+ failure: Schema.Union(Unauthorized, Forbidden),
71
72
  provides: AuthContext,
72
73
  security: {
73
74
  bearer: bearerSecurity,
@@ -130,6 +131,52 @@ const UploadHeaders = Schema.Record({
130
131
  value: Schema.String
131
132
  });
132
133
 
134
+ //#endregion
135
+ //#region ../../packages/api/src/domain/admin.ts
136
+ /**
137
+ * A platform user as seen by a superadmin on the dashboard `/admin` page.
138
+ * `role` is the GLOBAL Better Auth admin-plugin role (e.g. "admin"), distinct
139
+ * from per-organization membership roles. `approved` is the dev-phase gate.
140
+ */
141
+ const AdminUser = Schema.Struct({
142
+ id: Schema.String,
143
+ name: Schema.String,
144
+ email: Schema.String,
145
+ role: Schema.NullOr(Schema.String),
146
+ approved: Schema.Boolean,
147
+ banned: Schema.Boolean,
148
+ createdAt: DateTimeString
149
+ });
150
+ const AdminUserStatus = Schema.Literal("all", "pending", "approved");
151
+ const ListAdminUsersParams = Schema.Struct({
152
+ search: Schema.optional(Schema.String),
153
+ status: Schema.optional(AdminUserStatus),
154
+ ...PaginationParams.fields
155
+ });
156
+
157
+ //#endregion
158
+ //#region ../../packages/api/src/groups/admin.ts
159
+ const userIdParam = HttpApiSchema.param("userId", Schema.String);
160
+ /**
161
+ * Platform administration, restricted to superadmins (Better Auth admin-plugin
162
+ * `role = "admin"`). The dev-phase approval gate lives here: superadmins list
163
+ * users and approve/revoke their access. All endpoints fail `Forbidden` for
164
+ * non-superadmins.
165
+ */
166
+ var AdminGroup = class extends HttpApiGroup.make("admin").add(HttpApiEndpoint.get("listUsers", "/api/admin/users").setUrlParams(ListAdminUsersParams).addSuccess(pageResult(AdminUser)).annotateContext(OpenApi.annotations({
167
+ title: "List users",
168
+ description: "List platform users with approval status (superadmin only)"
169
+ }))).add(HttpApiEndpoint.post("approveUser")`/api/admin/users/${userIdParam}/approve`.addSuccess(AdminUser).annotateContext(OpenApi.annotations({
170
+ title: "Approve user",
171
+ description: "Grant a user access to the app (superadmin only)"
172
+ }))).add(HttpApiEndpoint.post("revokeUser")`/api/admin/users/${userIdParam}/revoke`.addSuccess(AdminUser).annotateContext(OpenApi.annotations({
173
+ title: "Revoke user approval",
174
+ description: "Revoke a user's access to the app (superadmin only)"
175
+ }))).addError(Forbidden).addError(NotFound).annotateContext(OpenApi.annotations({
176
+ title: "Admin",
177
+ description: "Superadmin platform administration"
178
+ })) {};
179
+
133
180
  //#endregion
134
181
  //#region ../../packages/api/src/domain/analytics.ts
135
182
  const PeriodLiteral = Schema.Literal("1d", "7d", "30d", "90d");
@@ -2108,7 +2155,7 @@ var WebhooksGroup = class extends HttpApiGroup.make("webhooks").add(HttpApiEndpo
2108
2155
 
2109
2156
  //#endregion
2110
2157
  //#region ../../packages/api/src/api.ts
2111
- var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(MeGroup).add(WebhooksGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
2158
+ var ManagementApi = class extends HttpApi.make("management-api").add(ProjectsGroup).add(BranchesGroup).add(ChannelsGroup).add(UpdatesGroup).add(AssetsGroup).add(AnalyticsGroup).add(BuildsGroup).add(EnvVarsGroup).add(FingerprintsGroup).add(AuditLogsGroup).add(DevicesGroup).add(AppleTeamsGroup).add(AppleDistributionCertificatesGroup).add(ApplePushKeysGroup).add(AscApiKeysGroup).add(AppleProvisioningProfilesGroup).add(GoogleServiceAccountKeysGroup).add(IosBundleConfigurationsGroup).add(IosAppMetadataGroup).add(SubmissionsGroup).add(AndroidApplicationIdentifiersGroup).add(AndroidUploadKeystoresGroup).add(AndroidBuildCredentialsGroup).add(BuildCredentialsGroup).add(UserEncryptionKeysGroup).add(OrgVaultGroup).add(MeGroup).add(WebhooksGroup).add(AdminGroup).middleware(Authentication).annotateContext(OpenApi.annotations({
2112
2159
  title: "Better Update Management API",
2113
2160
  version: "1.0.0",
2114
2161
  description: "Management API for OTA update publishing, deployment, and analytics"
@@ -2883,6 +2930,81 @@ const UpdateAssetUploaderLive = Layer.effect(UpdateAssetUploader, Effect.gen(fun
2883
2930
  }) };
2884
2931
  }));
2885
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
+
2886
3008
  //#endregion
2887
3009
  //#region src/services/version-check.ts
2888
3010
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/@better-update/cli/latest";
@@ -2933,7 +3055,7 @@ const VersionCheckLive = Layer.effect(VersionCheck, Effect.gen(function* () {
2933
3055
  //#endregion
2934
3056
  //#region src/app-layer.ts
2935
3057
  const CliPlatformLayer = Layer.mergeAll(CliRuntimeLive, NodeContext.layer, FetchHttpClient.layer);
2936
- 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));
2937
3059
  const CliAdapterDependencies = Layer.mergeAll(CliPlatformLayer, CliStoreLayer);
2938
3060
  const ApiClientLayer = ApiClientLive.pipe(Layer.provide(CliAdapterDependencies));
2939
3061
  const AppleAuthLayer = AppleAuthLive.pipe(Layer.provide(CliAdapterDependencies));
@@ -18327,6 +18449,25 @@ const grantRecipient = (args) => Effect.gen(function* () {
18327
18449
  const resolveVaultPassphrase = Effect.gen(function* () {
18328
18450
  return (yield* activeRecipient).source === "file" ? yield* promptPassword("Passphrase to unlock this device's identity:") : void 0;
18329
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));
18330
18471
  /** Look up a registered recipient by its key id or full `SHA256:` fingerprint. */
18331
18472
  const findRecipient = (api, selector) => Effect.gen(function* () {
18332
18473
  const { items } = yield* api.userEncryptionKeys.list();
@@ -18374,11 +18515,15 @@ const openVaultSession = (api, passphrase) => Effect.gen(function* () {
18374
18515
  };
18375
18516
  });
18376
18517
  /**
18377
- * {@link openVaultSession} that first resolves the device passphraseprompting
18378
- * 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.
18379
18521
  */
18380
18522
  const openVaultSessionInteractive = (api) => Effect.gen(function* () {
18381
- return yield* openVaultSession(api, yield* resolveVaultPassphrase);
18523
+ return {
18524
+ orgId: yield* getActiveOrgId(api),
18525
+ vault: yield* unlockVaultKeyInteractive(api)
18526
+ };
18382
18527
  });
18383
18528
  /** Reshape a sealed envelope into the `{ id, …opaque fields }` an upload body carries. */
18384
18529
  const toUploadEnvelope = (envelope) => ({
@@ -26056,13 +26201,11 @@ const currentRecipients = (api) => Effect.gen(function* () {
26056
26201
  //#endregion
26057
26202
  //#region src/commands/credentials/vault-session.ts
26058
26203
  /**
26059
- * Unlock the vault key, prompting for the device passphrase only when the active
26060
- * identity is the on-disk file the CI `BETTER_UPDATE_IDENTITY` env key is raw
26061
- * 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.
26062
26207
  */
26063
- const unlockVaultInteractively = (api) => Effect.gen(function* () {
26064
- return yield* unlockVaultKey(api, yield* resolveVaultPassphrase);
26065
- });
26208
+ const unlockVaultInteractively = (api) => unlockVaultKeyInteractive(api);
26066
26209
  /** Resolve a recipient selector (key id or fingerprint) from a flag, prompting if absent. */
26067
26210
  const resolveSelector = (flag, message) => Effect.gen(function* () {
26068
26211
  if (flag && flag.trim().length > 0) return flag.trim();
@@ -27694,6 +27837,56 @@ const revokeCommand = defineCommand({
27694
27837
  subCommands: { "distribution-certificate": distributionCertificateCommand }
27695
27838
  });
27696
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
+
27697
27890
  //#endregion
27698
27891
  //#region src/commands/credentials/sync/helpers.ts
27699
27892
  const SYNC_EXIT_EXTRAS = {
@@ -28553,6 +28746,9 @@ const credentialsCommand = defineCommand({
28553
28746
  identity: identityCommand,
28554
28747
  access: accessCommand,
28555
28748
  device: deviceCommand,
28749
+ unlock: unlockCommand,
28750
+ lock: lockCommand,
28751
+ status: statusCommand$1,
28556
28752
  list: listCommand$3,
28557
28753
  view: viewCommand$1,
28558
28754
  download: downloadCommand,