diff --git a/.bumpy/dashlane-locked-vault.md b/.bumpy/dashlane-locked-vault.md new file mode 100644 index 000000000..b22824f78 --- /dev/null +++ b/.bumpy/dashlane-locked-vault.md @@ -0,0 +1,5 @@ +--- +"@varlock/dashlane-plugin": minor +--- + +dashlane() no longer hangs forever on a locked vault: dcli calls run with stdin closed and a timeout (default 30s, configurable via @initDashlane(timeoutMs=...)). New allowMissing option (per item or in @initDashlane) resolves missing vault entries as empty instead of failing diff --git a/packages/plugins/dashlane/README.md b/packages/plugins/dashlane/README.md index 51afe1d66..27620c972 100644 --- a/packages/plugins/dashlane/README.md +++ b/packages/plugins/dashlane/README.md @@ -178,6 +178,8 @@ Initialize a Dashlane plugin instance. - `id?: string` - Instance identifier for multiple instances (defaults to `_default`) - `autoSync?: boolean` - If `true`, runs `dcli sync` once before the first read (default `false`) - `lockOnExit?: boolean` - Lock the vault on process exit. Defaults to `true` in headless mode, `false` in interactive mode. +- `allowMissing?: boolean` - If `true`, entries that do not exist in the vault resolve as empty instead of failing (default `false`). Only applies to missing entries: a locked vault or timeout still fails. Can be overridden per item. +- `timeoutMs?: number` - Maximum time in milliseconds to wait for each `dcli` call (default `30000`) ### Resolver functions @@ -189,6 +191,9 @@ Fetch a secret from Dashlane via a `dl://` reference. - `dashlane(dlRef)` - Fetch by `dl://` reference - `dashlane(instanceId, dlRef)` - Fetch from a specific instance +**Named parameters:** +- `allowMissing?: boolean` - If `true`, resolves as empty when the entry does not exist in the vault, instead of failing. Overrides the instance-level `allowMissing` setting. A locked vault or timeout still fails. + **Returns:** The resolved secret value as a string. ### Data types @@ -245,6 +250,8 @@ Install the Dashlane CLI following the [installation docs](https://cli.dashlane. - Run `dcli sync` to sync and unlock your vault - If the vault is locked, you may need to enter your master password +The plugin never waits on a locked vault: `dcli` calls run with stdin closed (so interactive prompts fail immediately) and are killed after `timeoutMs` (default 30 seconds) as a backstop. A locked vault always fails the affected items, regardless of `allowMissing` or whether they are optional. + ### Stale secrets If a recently changed secret isn't reflected: diff --git a/packages/plugins/dashlane/src/dashlane-instance.ts b/packages/plugins/dashlane/src/dashlane-instance.ts index 0a38355b4..fdf3fdafd 100644 --- a/packages/plugins/dashlane/src/dashlane-instance.ts +++ b/packages/plugins/dashlane/src/dashlane-instance.ts @@ -1,5 +1,5 @@ -import { spawnSync } from 'node:child_process'; -import { spawnAsync, ExecError } from '@env-spec/utils/exec-helpers'; +import { spawn, spawnSync } from 'node:child_process'; +import { ExecError } from '@env-spec/utils/exec-helpers'; type ErrorCtor = new (msg: string, opts?: { tip?: string }) => Error; @@ -9,6 +9,21 @@ const FIX_INSTALL_TIP = [ ' https://cli.dashlane.com/installation', ].join('\n'); +export const DEFAULT_DCLI_TIMEOUT_MS = 30_000; + +/** dcli output that means the requested entry does not exist in the vault */ +const DCLI_NOT_FOUND_RE = /not found|does not exist|no matching/i; + +/** How long to wait after SIGTERM before hard-killing and giving up on the child */ +const KILL_GRACE_MS = 2000; + +/** Thrown when a dcli call is killed by our timeout (or an external signal) */ +class DcliTimeoutError extends Error { + constructor(detail: string) { + super(`dcli did not complete: ${detail}`); + } +} + export class DashlanePluginInstance { private serviceDeviceKeys?: string; private cache = new Map(); @@ -18,16 +33,25 @@ export class DashlanePluginInstance { private syncPromise?: Promise; private synced = false; private lockAfter = false; + private allowMissing = false; + private timeoutMs = DEFAULT_DCLI_TIMEOUT_MS; constructor( readonly id: string, private ResolutionError: ErrorCtor, ) {} - configure(serviceDeviceKeys?: string, opts?: { autoSync?: boolean; lockOnExit?: boolean }) { + configure(serviceDeviceKeys?: string, opts?: { + autoSync?: boolean; + lockOnExit?: boolean; + allowMissing?: boolean; + timeoutMs?: number; + }) { this.serviceDeviceKeys = serviceDeviceKeys; if (opts?.autoSync !== undefined) this.autoSync = opts.autoSync; this.lockAfter = opts?.lockOnExit ?? !!serviceDeviceKeys; + if (opts?.allowMissing !== undefined) this.allowMissing = opts.allowMissing; + if (opts?.timeoutMs !== undefined) this.timeoutMs = opts.timeoutMs; } /** @internal telemetry: whether this instance auto-syncs the vault before reads */ @@ -44,9 +68,65 @@ export class DashlanePluginInstance { }; } - private get spawnOpts(): { env: Record } | undefined { - const env = this.spawnEnv; - return env ? { env } : undefined; + /** + * Run a dcli subcommand with stdin closed and a hard timeout. + * + * On a locked vault, dcli prompts for the master password and waits on stdin. + * With piped stdio and no timeout that call never returns, hanging the whole + * load. Ignoring stdin makes the prompt hit EOF and fail immediately; the + * timeout is a backstop for any other way dcli can stall. + */ + private execDcli(args: Array): Promise { + return new Promise((resolve, reject) => { + const child = spawn('dcli', args, { + ...this.spawnEnv && { env: this.spawnEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout!.on('data', (data) => { + stdout += data.toString(); + }); + child.stderr!.on('data', (data) => { + stderr += data.toString(); + }); + + let settled = false; + const timers: Array = []; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + for (const t of timers) clearTimeout(t); + fn(); + }; + + // own deadline rather than spawn's `timeout` option: that option only + // sends the kill signal, so a dcli that ignores SIGTERM would still + // leave this promise pending forever + timers.push(setTimeout(() => { + child.kill('SIGTERM'); + timers.push(setTimeout(() => { + // child ignored SIGTERM: hard-kill, release our ends of the pipes, + // and reject without waiting on `exit` + child.kill('SIGKILL'); + child.stdout?.destroy(); + child.stderr?.destroy(); + child.unref(); + settle(() => reject(new DcliTimeoutError(`still running ${KILL_GRACE_MS}ms after SIGTERM`))); + }, KILL_GRACE_MS)); + }, this.timeoutMs)); + + child.on('error', (err) => settle(() => reject(err))); + child.on('exit', (exitCode, signal) => settle(() => { + if (exitCode === 0) { + resolve(stdout); + } else if (signal) { + reject(new DcliTimeoutError(`killed by ${signal}`)); + } else { + reject(new ExecError(exitCode ?? 1, signal, stderr)); + } + })); + }); } async ensureDcliInstalled(): Promise { @@ -57,7 +137,7 @@ export class DashlanePluginInstance { private async doDcliCheck(): Promise { try { - await spawnAsync('dcli', ['--version'], this.spawnOpts); + await this.execDcli(['--version']); this.dcliChecked = true; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { @@ -81,7 +161,7 @@ export class DashlanePluginInstance { private async doSync(): Promise { await this.ensureDcliInstalled(); try { - await spawnAsync('dcli', ['sync'], this.spawnOpts); + await this.execDcli(['sync']); } catch { // Sync failure should not block reads - vault may still have recent data } @@ -109,8 +189,13 @@ export class DashlanePluginInstance { * Read a secret by dl:// reference. * Supports both dl:///field (fast, skips vault decryption) * and dl:///field (slower, requires full vault sync). + * + * With allowMissing (per-call arg, falling back to the instance default), + * a reference that does not exist in the vault resolves to undefined + * instead of throwing. Any other failure (locked vault, timeout, auth) + * still throws. */ - async readReference(dlUri: string): Promise<string> { + async readReference(dlUri: string, opts?: { allowMissing?: boolean }): Promise<string | undefined> { if (!dlUri.startsWith('dl://') || dlUri === 'dl://') { throw new this.ResolutionError(`Invalid Dashlane reference: "${dlUri}"`, { tip: 'References must start with dl:// and include a path — e.g. dashlane("dl://<id>/password")', @@ -125,28 +210,57 @@ export class DashlanePluginInstance { } try { - const result = await spawnAsync('dcli', ['read', dlUri], this.spawnOpts); + const result = await this.execDcli(['read', dlUri]); const value = result.replace(/\n$/, ''); this.cache.set(dlUri, value); return value; } catch (err) { + const allowMissing = opts?.allowMissing ?? this.allowMissing; + if ( + allowMissing + && err instanceof ExecError + && DCLI_NOT_FOUND_RE.test(err.data || err.message) + ) { + return undefined; + } return this.handleDcliError(err, dlUri); } } + private throwLockedVaultError(details?: string): never { + throw new this.ResolutionError( + `Dashlane vault appears locked or not synced${details ? ` (${details})` : ''}`, + { + tip: [ + 'Run `dcli sync` to sync and unlock your vault.', + 'Or set autoSync=true in @initDashlane to sync automatically.', + ].join('\n'), + }, + ); + } + private handleDcliError(err: unknown, context: string): never { + if (err instanceof DcliTimeoutError) { + this.throwLockedVaultError(`dcli did not respond within ${this.timeoutMs}ms`); + } + if (err instanceof ExecError) { const msg = err.data || err.message; - if (msg.match(/not found/i) || msg.match(/does not exist/i) || msg.match(/no matching/i)) { + if (DCLI_NOT_FOUND_RE.test(msg)) { throw new this.ResolutionError(`Entry "${context}" not found in Dashlane vault`, { tip: [ 'Verify the entry exists: dcli password -o json | jq \'.[].title\'', 'Use the entry ID for reliable lookups: dashlane("dl://<id>/password")', + 'Or set allowMissing=true (per item or in @initDashlane) to resolve missing entries as empty', ].join('\n'), }); } + if (msg.match(/locked/i) || msg.match(/sync/i) || msg.match(/master password/i)) { + this.throwLockedVaultError(); + } + if (msg.match(/auth/i) || msg.match(/credential/i) || msg.match(/login/i)) { throw new this.ResolutionError('Dashlane authentication failed', { tip: [ @@ -157,15 +271,6 @@ export class DashlanePluginInstance { }); } - if (msg.match(/locked/i) || msg.match(/sync/i)) { - throw new this.ResolutionError('Dashlane vault appears locked or not synced', { - tip: [ - 'Run `dcli sync` to sync your vault.', - 'Or set autoSync=true in @initDashlane to sync automatically.', - ].join('\n'), - }); - } - throw new this.ResolutionError(`Failed to fetch "${context}" from Dashlane: ${msg}`); } diff --git a/packages/plugins/dashlane/src/dashlane-manager.ts b/packages/plugins/dashlane/src/dashlane-manager.ts index 96a07d893..5c81350d7 100644 --- a/packages/plugins/dashlane/src/dashlane-manager.ts +++ b/packages/plugins/dashlane/src/dashlane-manager.ts @@ -48,12 +48,32 @@ export class DashlaneManager { ? (objArgs.lockOnExit.staticValue === true || objArgs.lockOnExit.staticValue === 'true') : undefined; + if (objArgs?.allowMissing && !objArgs.allowMissing.isStatic) { + throw new SchemaError('Expected allowMissing to be static'); + } + const allowMissing = objArgs?.allowMissing + ? (objArgs.allowMissing.staticValue === true || objArgs.allowMissing.staticValue === 'true') + : undefined; + + if (objArgs?.timeoutMs && !objArgs.timeoutMs.isStatic) { + throw new SchemaError('Expected timeoutMs to be static'); + } + let timeoutMs: number | undefined; + if (objArgs?.timeoutMs) { + timeoutMs = Number(objArgs.timeoutMs.staticValue); + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + throw new SchemaError('Expected timeoutMs to be a positive integer (milliseconds)'); + } + } + this.instances[id] = new DashlanePluginInstance(id, this.errors.ResolutionError); return { id, autoSync, lockOnExit, + allowMissing, + timeoutMs, serviceDeviceKeysResolver: objArgs?.serviceDeviceKeys, }; } @@ -63,11 +83,13 @@ export class DashlaneManager { * Called at resolution time. */ async executeInit({ - id, autoSync, lockOnExit, serviceDeviceKeysResolver, + id, autoSync, lockOnExit, allowMissing, timeoutMs, serviceDeviceKeysResolver, }: { id: string; autoSync?: boolean; lockOnExit?: boolean; + allowMissing?: boolean; + timeoutMs?: number; serviceDeviceKeysResolver?: ArgValue; }) { const serviceDeviceKeys = serviceDeviceKeysResolver @@ -78,7 +100,9 @@ export class DashlaneManager { serviceDeviceKeys && typeof serviceDeviceKeys === 'string' ? serviceDeviceKeys : undefined, - { autoSync, lockOnExit }, + { + autoSync, lockOnExit, allowMissing, timeoutMs, + }, ); } diff --git a/packages/plugins/dashlane/src/plugin.ts b/packages/plugins/dashlane/src/plugin.ts index ab99f9a61..a927369b8 100644 --- a/packages/plugins/dashlane/src/plugin.ts +++ b/packages/plugins/dashlane/src/plugin.ts @@ -36,7 +36,7 @@ plugin.registerResolverFunction({ label: 'Fetch secret from Dashlane by dl:// reference', icon: DASHLANE_ICON, argsSchema: { - type: 'array', + type: 'mixed', arrayMinLength: 1, arrayMaxLength: 2, }, @@ -58,9 +58,11 @@ plugin.registerResolverFunction({ manager.getInstance(instanceId); - return { instanceId, refResolver }; + const allowMissingResolver = this.objArgs?.allowMissing; + + return { instanceId, refResolver, allowMissingResolver }; }, - async resolve({ instanceId, refResolver }) { + async resolve({ instanceId, refResolver, allowMissingResolver }) { manager.registerExitHandler(); const instance = manager.getInstance(instanceId); if (!refResolver) { @@ -70,7 +72,11 @@ plugin.registerResolverFunction({ if (typeof dlUri !== 'string') { throw new SchemaError('Expected dl:// reference to resolve to a string'); } - return await instance.readReference(dlUri); + // per-call allowMissing overrides the instance-level default + const allowMissing = allowMissingResolver + ? !!(await allowMissingResolver.resolve()) + : undefined; + return await instance.readReference(dlUri, { allowMissing }); }, }); diff --git a/packages/plugins/dashlane/test/dashlane.test.ts b/packages/plugins/dashlane/test/dashlane.test.ts index 455146942..cb68abd9f 100644 --- a/packages/plugins/dashlane/test/dashlane.test.ts +++ b/packages/plugins/dashlane/test/dashlane.test.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { - describe, test, beforeAll, afterAll, + describe, test, expect, beforeAll, afterAll, } from 'vitest'; import outdent from 'outdent'; import { pluginTest } from 'varlock/test-helpers'; @@ -35,6 +35,8 @@ afterAll(() => { type DlTestOpts = { /** Map of dl:// references to their resolved values */ dcliResponses?: Record<string, string>; + /** Misbehavior mode for the fake dcli `read` subcommand (see fake-dcli.sh) */ + readBehavior?: 'hang' | 'hang-ignore-sigterm' | 'prompt-stdin' | 'locked'; /** Schema items section (after ---). Required unless fullSchema is provided. */ schema?: string; /** Extra @initDashlane params (e.g., `autoSync=true`) */ @@ -52,6 +54,7 @@ type DlTestOpts = { function dlTest(opts: DlTestOpts) { const { dcliResponses = {}, + readBehavior, schema, initParams = '', headless = false, @@ -61,7 +64,7 @@ function dlTest(opts: DlTestOpts) { return async () => { // Write the dcli config for this test and point the fake script at it - fs.writeFileSync(DCLI_CONFIG_PATH, JSON.stringify({ responses: dcliResponses })); + fs.writeFileSync(DCLI_CONFIG_PATH, JSON.stringify({ responses: dcliResponses, readBehavior })); const origPath = process.env.PATH; process.env.PATH = `${FAKE_BIN_DIR}:${origPath}`; @@ -83,7 +86,7 @@ function dlTest(opts: DlTestOpts) { ${schema} `; - await pluginTest({ + return await pluginTest({ ...rest, schema: fullSchema, ...(headless ? { injectValues: { DL_SERVICE_KEYS: 'dls_test_key_data_1234', ...rest.injectValues } } : {}), @@ -241,4 +244,109 @@ describe('dashlane plugin', () => { expectSchemaError: true, })); }); + + describe('locked vault handling', () => { + // On a locked vault, dcli prompts for the master password and blocks on + // stdin. These tests ensure every dcli call is bounded (stdin closed + + // timeout) and that a locked/unavailable vault always fails the item, + // regardless of required/optional or allowMissing. + + test('master password prompt fails immediately instead of hanging (stdin closed)', dlTest({ + readBehavior: 'prompt-stdin', + schema: 'SECRET=dashlane("dl://abc/password")', + expectValues: { SECRET: Error }, + }), 5000); + + test('hung dcli call fails within the configured timeout', dlTest({ + readBehavior: 'hang', + initParams: 'timeoutMs=500', + schema: 'SECRET=dashlane("dl://abc/password")', + expectValues: { SECRET: Error }, + }), 5000); + + test('dcli that ignores SIGTERM still fails within a bounded time', dlTest({ + readBehavior: 'hang-ignore-sigterm', + initParams: 'timeoutMs=300', + schema: 'SECRET=dashlane("dl://abc/password")', + expectValues: { SECRET: Error }, + }), 5000); + + test('locked vault fails the item even when optional', async () => { + const g = await dlTest({ + readBehavior: 'locked', + schema: outdent` + # @optional + SECRET=dashlane("dl://abc/password") + `, + })(); + const item = g!.configSchema.SECRET; + expect(item.validationState).toBe('error'); + expect(item.errors[0].message).toContain('locked'); + }); + + test('locked vault fails the item even with allowMissing', async () => { + const g = await dlTest({ + readBehavior: 'locked', + schema: outdent` + # @optional + SECRET=dashlane("dl://abc/password", allowMissing=true) + `, + })(); + const item = g!.configSchema.SECRET; + expect(item.validationState).toBe('error'); + expect(item.errors[0].message).toContain('locked'); + }); + + test('invalid timeoutMs value is a schema error', dlTest({ + initParams: 'timeoutMs=soon', + schema: 'SECRET=dashlane("dl://abc/password")', + expectSchemaError: true, + })); + }); + + describe('allowMissing', () => { + test('missing entry resolves empty when allowMissing is set per call', dlTest({ + dcliResponses: { 'dl://abc/password': 'pw1' }, + schema: outdent` + SECRET=dashlane("dl://abc/password") + # @optional + MISSING=dashlane("dl://nope/password", allowMissing=true) + `, + expectValues: { SECRET: 'pw1', MISSING: undefined }, + })); + + test('missing entry resolves empty when allowMissing is set on init', dlTest({ + dcliResponses: { 'dl://abc/password': 'pw1' }, + initParams: 'allowMissing=true', + schema: outdent` + SECRET=dashlane("dl://abc/password") + # @optional + MISSING=dashlane("dl://nope/password") + `, + expectValues: { SECRET: 'pw1', MISSING: undefined }, + })); + + test('per-call allowMissing=false overrides instance default', dlTest({ + dcliResponses: {}, + initParams: 'allowMissing=true', + schema: outdent` + # @optional + MISSING=dashlane("dl://nope/password", allowMissing=false) + `, + expectValues: { MISSING: Error }, + })); + + test('required item with allowMissing still fails as empty', async () => { + const g = await dlTest({ + dcliResponses: {}, + schema: outdent` + # @required + SECRET=dashlane("dl://nope/password", allowMissing=true) + `, + })(); + const item = g!.configSchema.SECRET; + expect(item.validationState).toBe('error'); + expect(item.errors.some((e) => e.name === 'EmptyRequiredValueError')).toBe(true); + }); + }); }); diff --git a/packages/plugins/dashlane/test/fake-dcli.sh b/packages/plugins/dashlane/test/fake-dcli.sh index 5a55fd660..c5647fe29 100755 --- a/packages/plugins/dashlane/test/fake-dcli.sh +++ b/packages/plugins/dashlane/test/fake-dcli.sh @@ -25,6 +25,43 @@ case "$SUBCMD" in ;; read) REF="$1" + + # Optional misbehavior modes (cfg.readBehavior) used by locked-vault tests + BEHAVIOR=$(node -e " + const cfg = JSON.parse(require('fs').readFileSync('$CONFIG_FILE', 'utf-8')); + process.stdout.write(cfg.readBehavior || 'normal'); + ") + case "$BEHAVIOR" in + hang) + # simulate dcli stalling forever (must be killed by the plugin's timeout) + sleep 30 + exit 1 + ;; + hang-ignore-sigterm) + # simulate dcli stalling AND ignoring SIGTERM (must be SIGKILLed, + # and the plugin must not wait on the process actually exiting) + trap '' TERM + sleep 30 + exit 1 + ;; + prompt-stdin) + # simulate dcli prompting for the master password on a locked vault: + # blocks reading stdin, so it hangs forever unless stdin is closed + printf '? Please enter your master password\n' >&2 + if read -r _MASTER_PW; then + echo "unlocked-with-$_MASTER_PW" + exit 0 + else + printf 'Vault is locked\n' >&2 + exit 1 + fi + ;; + locked) + printf 'Vault is locked, please run dcli sync\n' >&2 + exit 1 + ;; + esac + # Use node to look up the reference in the JSON config (portable JSON parsing) node -e " const cfg = JSON.parse(require('fs').readFileSync('$CONFIG_FILE', 'utf-8')); diff --git a/packages/varlock-website/src/content/docs/plugins/dashlane.mdx b/packages/varlock-website/src/content/docs/plugins/dashlane.mdx index cf406d6f3..a50a5dc5a 100644 --- a/packages/varlock-website/src/content/docs/plugins/dashlane.mdx +++ b/packages/varlock-website/src/content/docs/plugins/dashlane.mdx @@ -132,6 +132,8 @@ Initialize a Dashlane plugin instance for `dashlane()` resolver. - `serviceDeviceKeys` (optional): service device keys for headless authentication - `autoSync` (optional): if `true`, runs `dcli sync` once before the first read - `lockOnExit` (optional): lock the vault on process exit. Defaults to `true` in headless mode, `false` in interactive mode. +- `allowMissing` (optional): if `true`, entries that do not exist in the vault resolve as empty instead of failing. Only applies to missing entries: a locked vault or timeout still fails. Can be overridden per item. +- `timeoutMs` (optional): maximum time in milliseconds to wait for each `dcli` call (default `30000`) ```env-spec "@initDashlane" # Interactive (local dev) @@ -174,12 +176,20 @@ Fetch a secret from Dashlane by `dl://` reference. - `instanceId` (optional, if 2 args): instance identifier - `dlReference` (required): `dl://` secret reference URI +**Key/value args:** + +- `allowMissing` (optional): if `true`, resolves as empty when the entry does not exist in the vault, instead of failing. Overrides the instance-level `allowMissing` setting. A locked vault or timeout still fails. + ```env-spec /dashlane\\(.*\\)/ # Default instance DB_PASSWORD=dashlane("dl://abc123/password") # With explicit instance DB_PASSWORD=dashlane(prod, "dl://abc123/password") + +# Tolerate a missing entry (item must not be required) +# @optional +OPTIONAL_TOKEN=dashlane("dl://abc123/token", allowMissing=true) ``` </div> </div> @@ -198,11 +208,14 @@ DB_PASSWORD=dashlane(prod, "dl://abc123/password") ### Entry not found - Verify the entry exists: `dcli password -o json | jq '.[].title'` - Use the entry ID for reliable lookups: `dashlane("dl://<id>/password")` +- Set `allowMissing=true` (per item or in `@initDashlane`) if a missing entry should resolve as empty instead of failing ### Vault locked or not synced - Run `dcli sync` to sync your vault - Set `autoSync=true` in `@initDashlane` to sync automatically before reads +`dcli` calls run with stdin closed and are killed after `timeoutMs` (default 30 seconds), so a locked vault fails fast instead of hanging the load. + ## Resources - [Dashlane CLI documentation](https://cli.dashlane.com/)