@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 +162 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
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.
|
|
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
|
|
18424
|
-
*
|
|
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
|
|
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
|
|
26106
|
-
*
|
|
26107
|
-
*
|
|
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) =>
|
|
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,
|