From 83aeb97ad431447b570dbe7acf15c30d15855d2e Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Mon, 24 Aug 2026 16:33:39 -0700 Subject: [PATCH 1/2] repl --- KeeperSdk/package.json | 4 +- KeeperSdk/src/auth/ConsoleLogin.ts | 6 +- KeeperSdk/src/auth/SessionManager.ts | 37 +- KeeperSdk/src/auth/node/FileConfigLoader.ts | 1 + KeeperSdk/src/index.ts | 2 +- examples/repl/package-lock.json | 320 ++++++++++++++++ examples/repl/package.json | 23 ++ examples/repl/scripts/README.md | 46 +++ .../repl/scripts/get_online_controllers.ts | 34 ++ examples/repl/scripts/lock_unlock_user.ts | 62 ++++ .../repl/scripts/send_controller_message.ts | 60 +++ examples/repl/src/commands.ts | 346 ++++++++++++++++++ examples/repl/src/index.ts | 128 +++++++ examples/repl/tsconfig.json | 22 ++ keeperapi/package.json | 2 +- keeperapi/src/auth.ts | 1 + keeperapi/src/restMessages.ts | 5 + 17 files changed, 1089 insertions(+), 10 deletions(-) create mode 100644 examples/repl/package-lock.json create mode 100644 examples/repl/package.json create mode 100644 examples/repl/scripts/README.md create mode 100644 examples/repl/scripts/get_online_controllers.ts create mode 100644 examples/repl/scripts/lock_unlock_user.ts create mode 100644 examples/repl/scripts/send_controller_message.ts create mode 100644 examples/repl/src/commands.ts create mode 100644 examples/repl/src/index.ts create mode 100644 examples/repl/tsconfig.json diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 5c5cf504..47d6cb64 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -1,6 +1,6 @@ { "name": "@keeper-security/keeper-sdk-javascript", - "version": "2.0.0", + "version": "2.1.0", "description": "High-level wrapper for Keeper Security JavaScript SDK", "repository": { "type": "git", @@ -24,7 +24,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "18.1.1", + "@keeper-security/keeperapi": "18.2.0", "@keeper-security/secrets-manager-core": "^17.5.0", "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0", diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index 7818b016..0d57fc57 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -341,7 +341,11 @@ async function syncVault(vault: KeeperVault): Promise { return vault } +export function closePrompt(): void { + getReadlineManager().close() +} + export function cleanup(vault: KeeperVault): void { vault.disconnect() - getReadlineManager().close() + closePrompt() } diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index 7d48e5f7..3770e86f 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -83,6 +83,30 @@ export class SessionManager implements SessionStorage { public createOnDeviceConfig(host: string): (deviceConfig: DeviceConfig) => Promise { return async (deviceConfig: DeviceConfig) => { this.sessionDevices.set(host, { ...deviceConfig }) + await this.persistDeviceConfig(host, deviceConfig) + } + } + + private async persistDeviceConfig(host: string, deviceConfig: DeviceConfig): Promise { + if (!deviceConfig.deviceToken || !deviceConfig.privateKey) return + + const username = this._lastUsername + if (!username) return + + try { + const parsed = await this.configLoader.load() + const config: KeeperJsonConfig = parsed && Object.keys(parsed).length > 0 ? parsed : {} + + config.device_token = Buffer.from(deviceConfig.deviceToken).toString('base64url') + config.private_key = Buffer.from(deviceConfig.privateKey).toString('base64url') + config.user = username + config.server = host + + await this.configLoader.save(config) + this._keeperConfig = null + this._deviceCache = null + } catch (err) { + logger.warn('Failed to persist device config:', extractErrorMessage(err)) } } @@ -135,12 +159,15 @@ export class SessionManager implements SessionStorage { const device = (parsed.devices || []).find( (configDevice) => configDevice.device_token === user.last_device!.device_token ) - if (device?.server_info) { - const serverInfo = device.server_info.find((entry) => entry.server === host) - if (serverInfo) { - serverInfo.clone_code = encodedCloneCode - updated = true + if (device) { + device.server_info = device.server_info || [] + let serverInfo = device.server_info.find((entry) => entry.server === host) + if (!serverInfo) { + serverInfo = { server: host } + device.server_info.push(serverInfo) } + serverInfo.clone_code = encodedCloneCode + updated = true } } diff --git a/KeeperSdk/src/auth/node/FileConfigLoader.ts b/KeeperSdk/src/auth/node/FileConfigLoader.ts index 328fa254..f80f7a0d 100644 --- a/KeeperSdk/src/auth/node/FileConfigLoader.ts +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -29,6 +29,7 @@ export class FileConfigLoader implements ConfigLoader { async save(config: KeeperJsonConfig): Promise { const configPath = path.join(this.configDir, 'config.json') + await fs.mkdir(this.configDir, { recursive: true, mode: 0o700 }) await fs.writeFile(configPath, JSON.stringify(config, null, 2), { mode: 0o600, }) diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 04c92e01..d4398893 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -16,7 +16,7 @@ export type { ConfigurationServerConfig, ConfigurationDeviceConfig, } from './auth/SessionManager' -export { login, cleanup, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' +export { login, cleanup, closePrompt, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' export { connectSdkPlatform, getSdkPlatform, isSdkPlatformConnected } from './platform' export type { SdkPlatform, SdkReadline, SdkRuntime } from './platform' export type { SessionRestoreInput } from './auth/sessionRestore' diff --git a/examples/repl/package-lock.json b/examples/repl/package-lock.json new file mode 100644 index 00000000..a0f1a9ab --- /dev/null +++ b/examples/repl/package-lock.json @@ -0,0 +1,320 @@ +{ + "name": "repl", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "repl", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@keeper-security/keeper-sdk-javascript": "file:../../KeeperSdk", + "@keeper-security/keeperapi": "file:../../keeperapi", + "@types/node": "^20.9.1", + "ts-node": "^10.7.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^4.6.3" + } + }, + "../../keeperapi": { + "name": "@keeper-security/keeperapi", + "version": "18.2.0", + "license": "ISC", + "dependencies": { + "@noble/post-quantum": "^0.5.2", + "asmcrypto.js": "^2.3.2", + "faye-websocket": "^0.11.3", + "form-data": "^4.0.4", + "node-rsa": "^1.0.8", + "protobufjs": "^7.6.1" + }, + "devDependencies": { + "@babel/preset-env": "^7.23.5", + "@babel/preset-typescript": "^7.23.3", + "@rollup/plugin-commonjs": "^22.0.1", + "@rollup/plugin-node-resolve": "^13.3.0", + "@types/jest": "^24.0.15", + "@types/node": "^20.9.1", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.7.0", + "prettier": "^3.8.1", + "protobufjs-cli": "^1.3.1", + "rollup": "^2.79.2", + "rollup-plugin-typescript2": "^0.37.0", + "ts-jest": "^29.1.1", + "ts-node": "^8.10.2", + "typescript": "^4.0.1" + }, + "engines": { + "node": ">=24.13.1" + } + }, + "../../KeeperSdk": { + "name": "@keeper-security/keeper-sdk-javascript", + "version": "2.1.0", + "license": "ISC", + "dependencies": { + "@keeper-security/keeperapi": "18.2.0", + "@keeper-security/secrets-manager-core": "^17.5.0", + "asmcrypto.js": "^2.3.2", + "ts-node": "^10.7.0", + "typescript": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "prettier": "^3.8.1" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@keeper-security/keeper-sdk-javascript": { + "resolved": "../../KeeperSdk", + "link": true + }, + "node_modules/@keeper-security/keeperapi": { + "resolved": "../../keeperapi", + "link": true + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/examples/repl/package.json b/examples/repl/package.json new file mode 100644 index 00000000..620bcc93 --- /dev/null +++ b/examples/repl/package.json @@ -0,0 +1,23 @@ +{ + "name": "repl", + "version": "1.0.0", + "description": "Interactive Keeper Vault REPL - log in once, run commands until you exit", + "author": { + "email": "saldoukhov@keepersecurity.com", + "name": "Sergey Aldoukhov" + }, + "license": "ISC", + "scripts": { + "start": "ts-node src/index.ts", + "types": "tsc --watch", + "types:ci": "tsc" + }, + "dependencies": { + "@keeper-security/keeper-sdk-javascript": "file:../../KeeperSdk", + "@keeper-security/keeperapi": "file:../../keeperapi", + "@types/node": "^20.9.1", + "ts-node": "^10.7.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^4.6.3" + } +} diff --git a/examples/repl/scripts/README.md b/examples/repl/scripts/README.md new file mode 100644 index 00000000..46d918e9 --- /dev/null +++ b/examples/repl/scripts/README.md @@ -0,0 +1,46 @@ +# Scripts + +These are sample scripts showing how to extend the REPL without touching its main code +(`src/index.ts`, `src/commands.ts`). Each one is a small, self-contained example of a task built on +top of the KeeperSdk — a starting point to copy and adapt for your own workflows. + +Run any of them from inside the REPL with the built-in `run` command: + +``` +run scripts/.ts [args...] +``` + +`run` loads the file with `require()` (so it's re-compiled by `ts-node` on the fly — no build step) +and calls its default export with the live, already-authenticated `vault` plus whatever arguments +followed the file path on the command line. + +## Contract + +A script is any `.ts` file with a default export matching: + +```ts +export default async function myScript(vault: KeeperVault, args: string[]): Promise { + // ... +} +``` + +- `vault` is the same `KeeperVault` instance the REPL logged in with — already authenticated and + synced, so there's no separate login/sync step to write. +- `args` is everything typed after the script path, split on whitespace (e.g. + `run scripts/lock_unlock_user.ts unlock alice@example.com` → `args = ['unlock', 'alice@example.com']`). +- Errors thrown from the script are caught by the `run` command and printed with `logger.warn` — + the REPL session keeps going either way, so it's safe to experiment. + +## Samples in this folder + +- **`get_online_controllers.ts`** — calls the PAM router directly (`pamGetOnlineControllersMessage`) + to list connected gateways, independent of the built-in `get_online_controllers` command. +- **`send_controller_message.ts`** — sends a `RouterControllerMessage` to a specific gateway and + pretty-prints its (doubly-JSON-encoded) reply. Shows how to call a lower-level `keeperapi` + `restMessages.ts` helper directly from a script. +- **`lock_unlock_user.ts`** — locks or unlocks one or more enterprise users via + `vault.actionUsers(...)`, reusing the same formatting helpers as the `sdk_example` CLI. + +None of these are REPL commands — they're the "customize without recompiling the tool" escape +hatch. If something you build here turns out to be useful as a permanent command, promote it into +`src/commands.ts` instead. diff --git a/examples/repl/scripts/get_online_controllers.ts b/examples/repl/scripts/get_online_controllers.ts new file mode 100644 index 00000000..0718156d --- /dev/null +++ b/examples/repl/scripts/get_online_controllers.ts @@ -0,0 +1,34 @@ +import type { KeeperVault } from '@keeper-security/keeper-sdk-javascript' +import { + getKeeperRouterBaseUrl, + webSafeUidFromBytes, + formatTimestampMs, + isKeeperRouterConnectionError, + logger, + extractErrorMessage, +} from '@keeper-security/keeper-sdk-javascript' +import { pamGetOnlineControllersMessage } from '@keeper-security/keeperapi' + +export default async function getOnlineControllers(vault: KeeperVault): Promise { + const routerHost = getKeeperRouterBaseUrl(vault.host) + try { + const response = await vault.getAuth().executeRouterRest(pamGetOnlineControllersMessage()) + const controllers = response.controllers ?? [] + if (controllers.length === 0) { + logger.info('No online controllers found.') + return + } + for (const controller of controllers) { + const uid = webSafeUidFromBytes(controller.controllerUid) + logger.info( + ` uid=${uid} ip=${controller.ipAddress || ''} version=${controller.version || ''} connectedOn=${formatTimestampMs(controller.connectedOn)}` + ) + } + } catch (err) { + if (isKeeperRouterConnectionError(err)) { + logger.warn(`Router appears to be down. Router URL [${routerHost}]`) + return + } + logger.warn(extractErrorMessage(err)) + } +} diff --git a/examples/repl/scripts/lock_unlock_user.ts b/examples/repl/scripts/lock_unlock_user.ts new file mode 100644 index 00000000..df58a3a0 --- /dev/null +++ b/examples/repl/scripts/lock_unlock_user.ts @@ -0,0 +1,62 @@ +import type { KeeperVault } from '@keeper-security/keeper-sdk-javascript' +import { + logger, + extractErrorMessage, + suppressLogs, + formatUserActionResult, + renderUserActionAsciiTable, + UserAction, +} from '@keeper-security/keeper-sdk-javascript' + +const ACTIONS: Record = { + lock: UserAction.Lock, + unlock: UserAction.Unlock, +} + +function parseIdentifierList(raw: string): string[] { + const seen = new Set() + const out: string[] = [] + for (const token of raw.split(',')) { + const trimmed = token.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + return out +} + +export default async function lockUnlockUser(vault: KeeperVault, args: string[]): Promise { + const [actionArg, ...rest] = args + const action = actionArg ? ACTIONS[actionArg.toLowerCase()] : undefined + if (!action) { + logger.warn('Usage: run scripts/lock_unlock_user.ts [,...] [@all]') + logger.warn('(@all is only supported for unlock)') + return + } + + const emails = parseIdentifierList(rest.join(',')) + if (emails.length === 0) { + logger.warn('At least one user email, ID, or @all is required.') + return + } + + try { + const restore = suppressLogs() + let result + try { + result = await vault.actionUsers({ emails, action }) + } finally { + restore() + } + + logger.info(renderUserActionAsciiTable(formatUserActionResult(result))) + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, skipped=${result.skipped}, failed=${result.failed})` + ) + } catch (err) { + logger.warn(`lock_unlock_user failed: ${extractErrorMessage(err)}`) + } +} diff --git a/examples/repl/scripts/send_controller_message.ts b/examples/repl/scripts/send_controller_message.ts new file mode 100644 index 00000000..861855ca --- /dev/null +++ b/examples/repl/scripts/send_controller_message.ts @@ -0,0 +1,60 @@ +import type { KeeperVault } from '@keeper-security/keeper-sdk-javascript' +import { logger, extractErrorMessage } from '@keeper-security/keeper-sdk-javascript' +import { PAM, sendControllerMessage, generateUidBytes, normal64Bytes } from '@keeper-security/keeperapi' + +function tryParseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return undefined + } +} + +// The gateway's reply nests its actual response as a JSON-encoded string in an inner "payload" field +// (e.g. { conversationId, payload: "{...}", status, type }) -- unwrap that one level so it doesn't +// print as an escaped string blob. +function parseNestedJson(text: string): unknown { + const parsed = tryParseJson(text) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const inner = (parsed as Record).payload + if (typeof inner === 'string') { + const innerParsed = tryParseJson(inner) + if (innerParsed !== undefined) { + return { ...parsed, payload: innerParsed } + } + } + } + return parsed !== undefined ? parsed : text +} + +export default async function sendControllerMessageScript(vault: KeeperVault, args: string[]): Promise { + const [controllerUidStr, action = 'gateway-info'] = args + if (!controllerUidStr) { + logger.warn('Usage: run scripts/send_controller_message.ts [action]') + return + } + + // GENERAL messages forward { action, inputs } to the gateway untouched; "gateway-info" is a safe, + // read-only command to demo with. controllerUid is required for GENERAL (only ROTATE/CONNECT can omit it). + const payload = Buffer.from(JSON.stringify({ action, inputs: {} }), 'utf8') + + const message = sendControllerMessage({ + messageType: PAM.ControllerMessageType.CMT_GENERAL, + messageUid: generateUidBytes(), + controllerUid: normal64Bytes(controllerUidStr), + streamResponse: false, + payload, + timeout: 10000, + }) + + try { + const response = await vault.getAuth().executeRouterRest(message) + if (!response.payload) { + logger.info('(empty response)') + return + } + logger.info(JSON.stringify(parseNestedJson(response.payload), null, 2)) + } catch (err) { + logger.warn(`send_controller_message failed: ${extractErrorMessage(err)}`) + } +} diff --git a/examples/repl/src/commands.ts b/examples/repl/src/commands.ts new file mode 100644 index 00000000..b4c2201b --- /dev/null +++ b/examples/repl/src/commands.ts @@ -0,0 +1,346 @@ +import * as path from 'path' +import { + KeeperVault, + DRecord, + logger, + extractErrorMessage, + formatRecord, + getRecordTitle, + getRecordType, + getRecordLogin, + getRecordPassword, + getRecordUrl, + getRecordTotpUrl, + getTotpCode, + fetchEnterprisePamControllers, + getKeeperRouterBaseUrl, + webSafeUidFromBytes, + formatTimestampMs, + isKeeperRouterConnectionError, + ResultCodes, +} from '@keeper-security/keeper-sdk-javascript' +import type { ListFolderResult } from '@keeper-security/keeper-sdk-javascript' +import { webSafe64FromBytes, pamGetOnlineControllersMessage } from '@keeper-security/keeperapi' + +export type CommandContext = { + vault: KeeperVault + args: string[] + exit: () => void + history: () => string[] +} + +export type Command = { + usage: string + description: string + run: (ctx: CommandContext) => Promise +} + +const MASKED_VALUE = '********' +const LABEL_WIDTH = 16 + +function formatRow(label: string, value: string): string { + return `${label.padStart(LABEL_WIDTH)}: ${value}` +} + +function displayRecordDetail(record: DRecord): void { + logger.info(`UID: ${record.uid}`) + logger.info(formatRow('Type', getRecordType(record))) + logger.info(formatRow('Title', getRecordTitle(record))) + + const loginVal = getRecordLogin(record) + const password = getRecordPassword(record) + const url = getRecordUrl(record) + + if (loginVal) logger.info(formatRow('login', loginVal)) + if (password) logger.info(formatRow('password', MASKED_VALUE)) + if (url) logger.info(formatRow('url', url)) + + const totpUrl = getRecordTotpUrl(record) + if (totpUrl) { + const code = getTotpCode(totpUrl) + if (code) logger.info(formatRow('Two Factor Code', `${code.code} (valid ${code.secondsRemaining}s)`)) + } +} + +export function splitCommandLine(line: string): string[] { + return line.trim().split(/\s+/).filter(Boolean) +} + +export type ScriptFn = (vault: KeeperVault, args: string[]) => Promise + +async function runScript(vault: KeeperVault, filePath: string, args: string[]): Promise { + const resolved = path.resolve(filePath) + delete require.cache[resolved] + const mod = require(resolved) + const fn: ScriptFn | undefined = typeof mod === 'function' ? mod : mod.default + if (typeof fn !== 'function') { + logger.warn(`Script "${filePath}" must export a default async function(vault, args).`) + return + } + await fn(vault, args) +} + +function printFolderListing(result: ListFolderResult): void { + if (result.folders.length === 0 && result.records.length === 0) { + logger.info('(empty)') + return + } + for (const folder of result.folders) { + logger.info(` ${folder.name}/`) + } + for (const record of result.records) { + logger.info(` ${record.name} [${record.type}] ${record.uid}`) + } +} + +export const commands: Record = { + help: { + usage: 'help', + description: 'List available commands', + run: async () => { + const width = Math.max(...Object.values(commands).map((c) => c.usage.length)) + logger.info('Available commands:') + for (const command of Object.values(commands)) { + logger.info(` ${command.usage.padEnd(width)} ${command.description}`) + } + }, + }, + + whoami: { + usage: 'whoami', + description: 'Show current session info', + run: async ({ vault }) => { + const auth = vault.getAuth() + const summary = vault.getSummary() + logger.info(` Username: ${auth.username}`) + logger.info(` Server: ${vault.host}`) + logger.info(` Folder: ${vault.getWorkingFolderDisplayName()}`) + logger.info(` Records: ${summary.recordCount}`) + logger.info(` Shared Folders: ${summary.sharedFolderCount}`) + logger.info(` Teams: ${summary.teamCount}`) + logger.info(` Folders: ${summary.folderCount}`) + }, + }, + + pwd: { + usage: 'pwd', + description: 'Print current working folder', + run: async ({ vault }) => { + logger.info(vault.getWorkingFolderDisplayName()) + }, + }, + + ls: { + usage: 'ls [path]', + description: 'List contents of the current (or given) folder', + run: async ({ vault, args }) => { + let folderUid = vault.getCurrentFolderUid() + if (args[0]) { + const resolved = await vault.tryResolvePath(args[0]) + if (resolved.remaining) { + logger.warn(`No such folder: ${args[0]}`) + return + } + folderUid = resolved.folderUid + } + const result = await vault.listFolder({ folderUid, showFolders: true, showRecords: true }) + printFolderListing(result) + }, + }, + + cd: { + usage: 'cd [path]', + description: 'Change the current working folder (cd / for vault root)', + run: async ({ vault, args }) => { + const target = args[0] || '/' + try { + const result = await vault.changeDirectory(target) + logger.info(`Working folder: ${result.name}`) + } catch (err) { + logger.warn(`Failed to change directory: ${extractErrorMessage(err)}`) + } + }, + }, + + tree: { + usage: 'tree [path]', + description: 'Show the folder tree from the current (or given) folder', + run: async ({ vault, args }) => { + try { + const ascii = await vault.tree({ folderPath: args[0] || undefined, showRecords: true }) + logger.info(ascii) + } catch (err) { + logger.warn(`Failed to render tree: ${extractErrorMessage(err)}`) + } + }, + }, + + list: { + usage: 'list', + description: 'List every record in the vault', + run: async ({ vault }) => { + const records = vault.getRecords() + if (records.length === 0) { + logger.info('No records found in vault.') + return + } + for (const record of records) { + logger.info(formatRecord(record)) + } + }, + }, + + find: { + usage: 'find ', + description: 'Search records by title, login, or URL', + run: async ({ vault, args }) => { + const criteria = args.join(' ').trim() + if (!criteria) { + logger.warn('Usage: find ') + return + } + const matches = vault.findRecords(criteria) + if (matches.length === 0) { + logger.info('No matching records.') + return + } + for (const record of matches) { + logger.info(` ${getRecordTitle(record)} [${getRecordType(record)}] ${record.uid}`) + } + }, + }, + + get: { + usage: 'get ', + description: 'Show details for a single record', + run: async ({ vault, args }) => { + const target = args.join(' ').trim() + if (!target) { + logger.warn('Usage: get ') + return + } + const record = vault.findRecord(target) + if (!record) { + logger.info(`Record "${target}" not found.`) + return + } + displayRecordDetail(record) + }, + }, + + sync: { + usage: 'sync', + description: 'Re-sync the vault with the server', + run: async ({ vault }) => { + await vault.sync() + const summary = vault.getSummary() + logger.info(`Synced. ${summary.recordCount} records loaded.`) + }, + }, + + get_controllers: { + usage: 'get_controllers', + description: 'Call the PAM router API (pam/get_controllers) and list enterprise gateways', + run: async ({ vault }) => { + try { + const controllers = await fetchEnterprisePamControllers( + vault.getAuth(), + ResultCodes.PAM_GATEWAY_LIST_FAILED + ) + if (controllers.length === 0) { + logger.info('No controllers found.') + return + } + for (const controller of controllers) { + const uid = webSafe64FromBytes(controller.controllerUid ?? new Uint8Array()) + logger.info( + ` ${(controller.controllerName || '(unnamed)').padEnd(24)} uid=${uid} node=${controller.nodeId ?? ''} device=${controller.deviceName || ''}` + ) + } + } catch (err) { + logger.warn(extractErrorMessage(err)) + } + }, + }, + + get_online_controllers: { + usage: 'get_online_controllers', + description: 'Call the PAM router API (loadOnlineControllers) and list connected gateways', + run: async ({ vault }) => { + const routerHost = getKeeperRouterBaseUrl(vault.host) + try { + const response = await vault.getAuth().executeRouterRest(pamGetOnlineControllersMessage()) + const controllers = response.controllers ?? [] + if (controllers.length === 0) { + logger.info('No online controllers found.') + return + } + for (const controller of controllers) { + const uid = webSafeUidFromBytes(controller.controllerUid) + logger.info( + ` uid=${uid} ip=${controller.ipAddress || ''} version=${controller.version || ''} connectedOn=${formatTimestampMs(controller.connectedOn)}` + ) + } + } catch (err) { + if (isKeeperRouterConnectionError(err)) { + logger.warn(`Router appears to be down. Router URL [${routerHost}]`) + return + } + logger.warn(extractErrorMessage(err)) + } + }, + }, + + run: { + usage: 'run [args...]', + description: 'Execute a TypeScript file exporting a default async function(vault, args)', + run: async ({ vault, args }) => { + const [filePath, ...scriptArgs] = args + if (!filePath) { + logger.warn('Usage: run [args...]') + return + } + try { + await runScript(vault, filePath, scriptArgs) + } catch (err) { + logger.warn(`Failed to run script "${filePath}": ${extractErrorMessage(err)}`) + } + }, + }, + + history: { + usage: 'history', + description: 'Show command history', + run: async ({ history }) => { + const entries = history() + if (entries.length === 0) { + logger.info('No history yet.') + return + } + const width = String(entries.length).length + entries.forEach((entry, i) => { + logger.info(` ${String(i + 1).padStart(width)} ${entry}`) + }) + }, + }, + + clear: { + usage: 'clear', + description: 'Clear the screen', + run: async () => { + process.stdout.write('\x1Bc') + }, + }, + + exit: { + usage: 'exit', + description: 'Log out and quit the REPL', + run: async ({ exit }) => exit(), + }, + + quit: { + usage: 'quit', + description: 'Alias for exit', + run: async ({ exit }) => exit(), + }, +} diff --git a/examples/repl/src/index.ts b/examples/repl/src/index.ts new file mode 100644 index 00000000..c5da1079 --- /dev/null +++ b/examples/repl/src/index.ts @@ -0,0 +1,128 @@ +import * as os from 'os' +import * as path from 'path' +import * as fs from 'fs' +import * as readline from 'readline' +import { login, cleanup, closePrompt, logger, extractErrorMessage, SdkDefaults } from '@keeper-security/keeper-sdk-javascript' +import { commands, splitCommandLine } from './commands' + +const HISTORY_FILE = path.join(os.homedir(), SdkDefaults.CONFIG_DIR, 'repl_history') +const MAX_HISTORY = 500 +const HISTORY_EVENT_RE = /^!(\d+)$/ + +// readline keeps history newest-first (index 0 = most recent); the file on disk is oldest-first, bash-style. +function loadHistory(): string[] { + try { + return fs + .readFileSync(HISTORY_FILE, 'utf8') + .split('\n') + .filter(Boolean) + .reverse() + } catch { + return [] + } +} + +function saveHistory(newestFirst: string[]): void { + try { + fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true, mode: 0o700 }) + const oldestFirst = [...newestFirst].reverse().slice(-MAX_HISTORY) + fs.writeFileSync(HISTORY_FILE, oldestFirst.length ? oldestFirst.join('\n') + '\n' : '', { mode: 0o600 }) + } catch (err) { + logger.debug('Failed to save command history:', extractErrorMessage(err)) + } +} + +const NOT_PERSISTED_COMMANDS = new Set(['history', 'exit', 'quit', 'clear']) + +function isNotPersistedCommand(line: string): boolean { + const name = splitCommandLine(line)[0]?.toLowerCase() + return !!name && NOT_PERSISTED_COMMANDS.has(name) +} + +async function main() { + const vault = await login() + closePrompt() + + const initialHistory = loadHistory() + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + history: initialHistory, + historySize: MAX_HISTORY, + }) + + let currentHistory: string[] = initialHistory + rl.on('history', (h) => { + // don't let REPL-control commands clutter history, live or persisted + if (h.length > 0 && isNotPersistedCommand(h[0])) { + h.shift() + } + currentHistory = h + saveHistory(currentHistory) + }) + + let running = true + const exit = () => { + running = false + } + const history = () => [...currentHistory].reverse() + + const shutdown = async () => { + saveHistory(currentHistory) + rl.close() + cleanup(vault) + } + // once we listen for 'SIGINT' ourselves, readline no longer exits the process on Ctrl+C for us + rl.on('SIGINT', () => { + shutdown() + .catch(() => {}) + .finally(() => process.exit(0)) + }) + + logger.info(`Logged in. Type "help" for a list of commands, "exit" to quit.\n`) + + try { + while (running) { + const folder = vault.getWorkingFolderDisplayName() + let line = await new Promise((resolve) => rl.question(`${folder}> `, resolve)) + + const eventMatch = line.trim().match(HISTORY_EVENT_RE) + if (eventMatch) { + const expanded = history()[Number(eventMatch[1]) - 1] + if (!expanded) { + logger.warn(`${line.trim()}: event not found`) + continue + } + logger.info(expanded) + // record the expanded command in history instead of the literal "!n", bash-style + if (currentHistory[0] === line) { + currentHistory[0] = expanded + saveHistory(currentHistory) + } + line = expanded + } + + const [name, ...args] = splitCommandLine(line) + if (!name) continue + + const command = commands[name.toLowerCase()] + if (!command) { + logger.warn(`Unknown command "${name}". Type "help" for a list of commands.`) + continue + } + + try { + await command.run({ vault, args, exit, history }) + } catch (err) { + logger.error(`Error: ${extractErrorMessage(err)}`) + } + } + } finally { + await shutdown() + } +} + +main().catch((err) => { + logger.error('Fatal error:', extractErrorMessage(err)) + process.exitCode = 1 +}) diff --git a/examples/repl/tsconfig.json b/examples/repl/tsconfig.json new file mode 100644 index 00000000..c00f5c28 --- /dev/null +++ b/examples/repl/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2018", + "sourceMap": true, + "noEmit": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@keeper-security/keeper-sdk-javascript": ["../../KeeperSdk/src"], + "@keeper-security/keeper-sdk-javascript/*": ["../../KeeperSdk/src/*"] + } + }, + "exclude": ["node_modules"], + "ts-node": { + "require": ["tsconfig-paths/register"], + "preferTsExts": true + } +} diff --git a/keeperapi/package.json b/keeperapi/package.json index 056dfa81..c7421217 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,7 +1,7 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "18.1.1", + "version": "18.2.0", "browser": "dist/browser/index.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", diff --git a/keeperapi/src/auth.ts b/keeperapi/src/auth.ts index 6723b3b5..a28c5089 100644 --- a/keeperapi/src/auth.ts +++ b/keeperapi/src/auth.ts @@ -634,6 +634,7 @@ export class Auth { wrappedPassword = undefined handleError('auth_failed', loginResponse, e) const error = e as Error + // @ts-ignore if (error.cause?.message === 'No alternate master password found') { return } diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index 1cdd0108..e3e4e7b0 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -1031,6 +1031,11 @@ export const pamGetLeafsMessage = ( export const pamGetOnlineControllersMessage = (): RestOutMessage => createOutMessage('api/user/get_controllers', PAM.PAMOnlineControllers) +export const sendControllerMessage = ( + data: Router.IRouterControllerMessage +): RestMessage => + createMessage(data, 'api/user/send_controller_message', Router.RouterControllerMessage, PAM.ControllerResponse) + export const pamConfigureNetworkGraphMessage = ( data: Router.IPAMNetworkConfigurationRequest ): RestInMessage => From 81aa14fb339c563c7df8d91a6acb8d8681a85061 Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Wed, 26 Aug 2026 10:49:42 -0700 Subject: [PATCH 2/2] fix lock file --- KeeperSdk/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index d5ef0b56..bb45a7b5 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "18.1.1", + "@keeper-security/keeperapi": "18.2.0", "@keeper-security/secrets-manager-core": "^17.5.0", "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0",