From f827a9b8afbd0ce41c11d98a4e103b654916fe04 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Fri, 10 Jul 2026 19:37:30 +0530 Subject: [PATCH] NSF sdk examples implemented --- examples/sdk_example/package.json | 5 + .../src/nestedShareFolders/add_nsf_record.ts | 137 ++++++++++++++++ .../src/nestedShareFolders/get_nsf.ts | 19 +-- .../get_nsf_record_details.ts | 40 +++++ .../src/nestedShareFolders/link_nsf.ts | 10 +- .../src/nestedShareFolders/list_nsf.ts | 20 +-- .../src/nestedShareFolders/mkdir_nsf.ts | 58 +++++++ .../src/nestedShareFolders/remove_nsf.ts | 57 +++---- .../src/nestedShareFolders/rmdir_nsf.ts | 118 ++++++++++++++ .../nestedShareFolders/update_nsf_record.ts | 152 ++++++++++++++++++ examples/sdk_example/src/utils/format.ts | 17 ++ .../sdk_example/src/utils/promptCommands.ts | 79 +++++++++ 12 files changed, 654 insertions(+), 58 deletions(-) create mode 100644 examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts create mode 100644 examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts create mode 100644 examples/sdk_example/src/utils/promptCommands.ts diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 65441eca..016577d4 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -33,6 +33,11 @@ "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", "nsf:rm": "ts-node src/nestedShareFolders/remove_nsf.ts", + "nsf:record-details": "ts-node src/nestedShareFolders/get_nsf_record_details.ts", + "nsf:record-update": "ts-node src/nestedShareFolders/update_nsf_record.ts", + "nsf:record-add": "ts-node src/nestedShareFolders/add_nsf_record.ts", + "nsf:mkdir": "ts-node src/nestedShareFolders/mkdir_nsf.ts", + "nsf:rmdir": "ts-node src/nestedShareFolders/rmdir_nsf.ts", "teams:list": "ts-node src/teams/list_teams.ts", "teams:view": "ts-node src/teams/view_team.ts", "teams:add": "ts-node src/teams/add_team.ts", diff --git a/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts new file mode 100644 index 00000000..7327073c --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/add_nsf_record.ts @@ -0,0 +1,137 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + parseNsfFieldSpaceInput, + prompt, + type AddNsfRecordInput, + type AddNsfRecordResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' +import { promptChoice, promptRequiredList, promptYesNo, yesNoPrompt } from '../utils/promptCommands' + +type AddMode = 'single' | 'bulk' | 'multi' + +const MODE_CHOICES: Record = { + '': 'single', + '1': 'single', + '2': 'bulk', + '3': 'multi', + single: 'single', + bulk: 'bulk', + multi: 'multi', +} + +async function promptSharedRecordOptions(): Promise<{ + recordType: string + folder?: string + notes?: string + fieldEntries?: AddNsfRecordInput['fieldEntries'] + customEntries?: AddNsfRecordInput['customEntries'] + hasFileFields?: boolean + force: boolean +}> { + const recordType = (await prompt('Record type (e.g. login, general): ')).trim() + const folder = (await prompt('Folder name or UID (optional, Enter for root): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (field=value, space-separated, optional): ')).trim() + const force = await promptYesNo(yesNoPrompt('Ignore warnings (e.g. attachments)?')) + const parsed = fieldsInput ? parseNsfFieldSpaceInput(fieldsInput) : undefined + + return { + recordType, + folder: folder || undefined, + notes: notes || undefined, + fieldEntries: parsed?.fieldEntries, + customEntries: parsed?.customEntries, + hasFileFields: parsed?.hasFileFields, + force, + } +} + +async function promptOneRecord(shared?: Awaited>): Promise { + const title = (await prompt('Record title: ')).trim() + if (!title) { + throw new Error('Record title is required.') + } + + if (shared) { + return { title, ...shared } + } + + const options = await promptSharedRecordOptions() + return { title, ...options } +} + +function printAddResults(added: AddNsfRecordResult[], revision?: number): void { + logger.info('') + for (const item of added) { + logger.info(`Record: ${item.recordUid}`) + logger.info(` Status: ${item.status}`) + if (item.message) logger.info(` Message: ${item.message}`) + if (item.revision != null) logger.info(` Revision: ${item.revision}`) + logger.info('') + } + if (revision != null) { + logger.info(`Batch revision: ${revision}`) + } + logger.info(`Total: ${added.length} record(s) added`) + logger.info('') +} + +async function addSingleRecord(vault: Awaited>): Promise { + const input = await promptOneRecord() + const result = await withSuppressedLogs(() => vault.addNestedShareRecord(input)) + printAddResults([result], result.revision) +} + +async function addBulkSameTemplate(vault: Awaited>): Promise { + const titles = await promptRequiredList('Record title(s), comma-separated: ', splitCommaSeparated) + const shared = await promptSharedRecordOptions() + + const records = titles.map((title) => ({ title, ...shared })) + const result = await withSuppressedLogs(() => vault.addNestedShareRecords({ records })) + printAddResults(result.added, result.revision) +} + +async function addMultipleRecords(vault: Awaited>): Promise { + const records: AddNsfRecordInput[] = [] + + logger.info('Enter each record (different title, type, folder, or fields allowed).') + while (true) { + logger.info(`--- Record ${records.length + 1} ---`) + records.push(await promptOneRecord()) + if (!(await promptYesNo(yesNoPrompt('Add another record?')))) { + break + } + } + + const result = await withSuppressedLogs(() => vault.addNestedShareRecords({ records })) + printAddResults(result.added, result.revision) +} + +async function addNsfRecord() { + const vault = await login() + + try { + logger.info('Mode: 1) single 2) bulk (same type/folder/fields, multiple titles) 3) multiple (enter each)') + const mode = await promptChoice('Choose [1]: ', MODE_CHOICES) + + if (mode === 'single') { + await addSingleRecord(vault) + } else if (mode === 'bulk') { + await addBulkSameTemplate(vault) + } else { + await addMultipleRecords(vault) + } + } catch (err) { + logger.error(`Record add failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(addNsfRecord) diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts index a997403b..1da71772 100644 --- a/examples/sdk_example/src/nestedShareFolders/get_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf.ts @@ -1,28 +1,25 @@ import { cleanup, extractErrorMessage, + formatNsfDetail, + formatNsfJson, GetNsfFormat, login, logger, - prompt, suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' +import { promptRequired, promptYesNo, yesNoPrompt } from '../utils/promptCommands' async function getNsf() { const vault = await login() try { - const identifier = (await prompt('Record UID, folder UID, or title: ')).trim() - if (!identifier) { - logger.info('No UID or title given.') - return - } + const identifier = await promptRequired('Record UID, folder UID, or title: ', 'No UID or title given.') - const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) - const verbose = isYes(await prompt('Verbose permissions? [y/N]: ')) - const unmask = isYes(await prompt('Unmask secrets? [y/N]: ')) + const asJson = await promptYesNo(yesNoPrompt('Output as JSON?')) + const verbose = await promptYesNo(yesNoPrompt('Verbose permissions?')) + const unmask = await promptYesNo(yesNoPrompt('Unmask secrets?')) const restore = suppressLogs() let result @@ -37,7 +34,7 @@ async function getNsf() { } logger.info('') - const output = asJson ? JSON.stringify(result.view, null, 2) : vault.formatNsfDetail(result, verbose) + const output = asJson ? formatNsfJson(result) : formatNsfDetail(result, verbose) process.stdout.write(`${output}\n`) logger.info('') } catch (err) { diff --git a/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts new file mode 100644 index 00000000..8e8828d1 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/get_nsf_record_details.ts @@ -0,0 +1,40 @@ +import { + cleanup, + extractErrorMessage, + formatNsfRecordDetailsOutput, + GetNsfRecordDetailsFormat, + login, + logger, + prompt, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +async function getNsfRecordDetails() { + const vault = await login() + + try { + const records = splitCommaSeparated(await prompt('Record UID(s) or title(s), comma-separated: ')) + if (records.length === 0) { + logger.info('At least one record is required.') + return + } + + const formatInput = (await prompt('Output format (table/json) [table]: ')).trim().toLowerCase() + const format = + formatInput === 'json' ? GetNsfRecordDetailsFormat.JSON : GetNsfRecordDetailsFormat.Table + + const result = await withSuppressedLogs(() => vault.getNestedShareRecordDetails({ records, format })) + + logger.info('') + logger.info(formatNsfRecordDetailsOutput(result, format)) + logger.info('') + } catch (err) { + logger.error(`Record details failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(getNsfRecordDetails) diff --git a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts index 896ed6bb..63e40124 100644 --- a/examples/sdk_example/src/nestedShareFolders/link_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/link_nsf.ts @@ -3,21 +3,17 @@ import { extractErrorMessage, login, logger, - prompt, suppressLogs, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' +import { promptRequired } from '../utils/promptCommands' async function linkNsf() { const vault = await login() try { - const recordIdentifier = (await prompt('Record UID or title: ')).trim() - const folderIdentifier = (await prompt('Destination folder UID or name: ')).trim() - if (!recordIdentifier || !folderIdentifier) { - logger.info('Both record and folder are required.') - return - } + const recordIdentifier = await promptRequired('Record UID or title: ') + const folderIdentifier = await promptRequired('Destination folder UID or name: ') const restore = suppressLogs() let result diff --git a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts index 20896bbc..735b8597 100644 --- a/examples/sdk_example/src/nestedShareFolders/list_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/list_nsf.ts @@ -2,17 +2,17 @@ import fs from 'fs/promises' import { cleanup, extractErrorMessage, + formatListNsfOutput, ListNsfFormat, login, logger, - prompt, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' +import { promptChoice, promptOptional, promptYesNo, yesNoPrompt } from '../utils/promptCommands' type ListMode = 'all' | 'folders' | 'records' -const MODE_BY_INPUT: Record = { +const LIST_MODE_CHOICES: Record = { '': 'all', '1': 'all', '2': 'folders', @@ -22,19 +22,15 @@ const MODE_BY_INPUT: Record = { records: 'records', } -function parseMode(input: string): ListMode { - return MODE_BY_INPUT[input.trim().toLowerCase()] ?? 'all' -} - async function listNsf() { const vault = await login() try { logger.info('Show: 1) all 2) folders only 3) records only') - const mode = parseMode(await prompt('Choose [1]: ')) - const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) - const asCsv = !asJson && isYes(await prompt('Output as CSV? [y/N]: ')) - const outputPath = (await prompt('Output file path (Enter for stdout): ')).trim() + const mode = await promptChoice('Choose [1]: ', LIST_MODE_CHOICES) + const asJson = await promptYesNo(yesNoPrompt('Output as JSON?')) + const asCsv = !asJson && (await promptYesNo(yesNoPrompt('Output as CSV?'))) + const outputPath = await promptOptional('Output file path (Enter for stdout): ') const format = asJson ? ListNsfFormat.JSON : asCsv ? ListNsfFormat.CSV : ListNsfFormat.Table const rows = vault.listNestedShareFolders({ @@ -47,7 +43,7 @@ async function listNsf() { return } - const output = vault.formatListNsfOutput(rows, format) + const output = formatListNsfOutput(rows, format) if (outputPath && format !== ListNsfFormat.Table) { await fs.writeFile(outputPath, output, 'utf-8') logger.info(`Wrote ${rows.length} row(s) to ${outputPath}`) diff --git a/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts new file mode 100644 index 00000000..c57be31a --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/mkdir_nsf.ts @@ -0,0 +1,58 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_FOLDER_COLORS, + prompt, + type MkdirNsfInput, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +async function runMkdirNsf(vault: Vault): Promise { + const folder = (await prompt('Folder name or path (e.g. Team/Projects/Q1): ')).trim() + if (!folder) { + logger.info('Folder name is required.') + return + } + + logger.info(`Colors: ${NSF_FOLDER_COLORS.join(', ')}`) + const colorInput = (await prompt('Color (optional, leaf only) [none]: ')).trim().toLowerCase() + const color = + colorInput && NSF_FOLDER_COLORS.some((candidate) => candidate === colorInput) + ? (colorInput as MkdirNsfInput['color']) + : undefined + const noInherit = (await prompt('Do not inherit parent permissions? [y/N]: ')).trim().toLowerCase() === 'y' + + const result = await withSuppressedLogs(() => + vault.mkdirNestedShareFolder({ + folder, + color, + noInheritPermissions: noInherit, + }) + ) + + logger.info('') + if (result.message) logger.info(result.message) + logger.info(`Folder UID: ${result.folderUid}`) + logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) + logger.info('') +} + +async function mkdirNsf() { + const vault = await login() + + try { + await runMkdirNsf(vault) + } catch (err) { + logger.error(`Folder create failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(mkdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts index d4ce1c7d..e15aec28 100644 --- a/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts +++ b/examples/sdk_example/src/nestedShareFolders/remove_nsf.ts @@ -1,18 +1,27 @@ import { cleanup, + collectRemoveNsfWarnings, extractErrorMessage, + formatRemoveNsfPreview, login, logger, NsfRemoveOperation, - prompt, suppressLogs, type RemoveNsfRecordInput, type RemoveNsfRecordResult, } from '@keeper-security/keeper-sdk-javascript' import { runExample } from '../utils/runner' -import { isYes } from '../utils/format' +import { splitCommaSeparated } from '../utils/format' +import { + promptChoice, + promptOptional, + promptRequired, + promptRequiredList, + promptYesNo, + yesNoPrompt, +} from '../utils/promptCommands' -const OPERATION_BY_INPUT: Record = { +const OPERATION_CHOICES: Record = { '': NsfRemoveOperation.OwnerTrash, '1': NsfRemoveOperation.OwnerTrash, '2': NsfRemoveOperation.FolderTrash, @@ -22,22 +31,16 @@ const OPERATION_BY_INPUT: Record = { unlink: NsfRemoveOperation.Unlink, } -function parseOperation(input: string): NsfRemoveOperation { - return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveOperation.OwnerTrash -} - -function printPreview(vault: Awaited>, result: RemoveNsfRecordResult): void { +function printPreview(result: RemoveNsfRecordResult): void { if (result.preview.length === 0) return logger.info('') - logger.info(vault.formatRemoveNsfPreview(result.preview)) + logger.info(formatRemoveNsfPreview(result.preview)) logger.info('') } function printPreviewWarnings(result: RemoveNsfRecordResult): void { - for (const item of result.preview) { - for (const warning of item.impact?.warnings ?? []) { - logger.info(`Warning: ${warning}`) - } + for (const warning of collectRemoveNsfWarnings(result.preview)) { + logger.info(`Warning: ${warning}`) } } @@ -57,21 +60,19 @@ async function removeNsf() { const vault = await login() try { - const recordsInput = (await prompt('Record UID(s) or title(s), comma-separated: ')).trim() - const records = recordsInput.split(',').map((value) => value.trim()).filter(Boolean) - if (records.length === 0) { - logger.info('At least one record is required.') - return - } + const records = await promptRequiredList( + 'Record UID(s) or title(s), comma-separated: ', + splitCommaSeparated + ) logger.info('Operation: 1) owner-trash 2) folder-trash 3) unlink') - const operation = parseOperation(await prompt('Choose [1]: ')) + const operation = await promptChoice('Choose [1]: ', OPERATION_CHOICES) const folder = operation === NsfRemoveOperation.Unlink - ? (await prompt('Folder UID or name (required for unlink): ')).trim() - : (await prompt('Folder UID or name (optional): ')).trim() - const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) - const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + ? await promptRequired('Folder UID or name (required for unlink): ') + : await promptOptional('Folder UID or name (optional): ') + const dryRun = await promptYesNo(yesNoPrompt('Dry run (preview only)?')) + const force = dryRun ? false : await promptYesNo(yesNoPrompt('Force confirm without prompt?')) const baseInput: RemoveNsfRecordInput = { records, @@ -81,14 +82,14 @@ async function removeNsf() { if (dryRun) { const result = await removeNestedShareRecords(vault, { ...baseInput, dryRun: true }) - printPreview(vault, result) + printPreview(result) logger.info('[Dry-run] No records were removed.') return } if (force) { const result = await removeNestedShareRecords(vault, { ...baseInput, force: true }) - printPreview(vault, result) + printPreview(result) if (result.confirmed && result.message) { logger.info(result.message) } @@ -96,10 +97,10 @@ async function removeNsf() { } const preview = await removeNestedShareRecords(vault, { ...baseInput, force: false }) - printPreview(vault, preview) + printPreview(preview) printPreviewWarnings(preview) - if (!isYes(await prompt('Do you want to proceed with deletion? [y/n]: '))) { + if (!(await promptYesNo(yesNoPrompt('Do you want to proceed with deletion?')))) { logger.info('Removal cancelled.') return } diff --git a/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts new file mode 100644 index 00000000..641e3418 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/rmdir_nsf.ts @@ -0,0 +1,118 @@ +import { + cleanup, + extractErrorMessage, + formatRemoveNsfFolderPreview, + login, + logger, + NsfRemoveFolderOperation, + prompt, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveFolderOperation.FolderTrash, + '1': NsfRemoveFolderOperation.FolderTrash, + '2': NsfRemoveFolderOperation.DeletePermanent, + 'folder-trash': NsfRemoveFolderOperation.FolderTrash, + 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, +} + +const PERMANENT_DELETE_WARNING_LINES = [ + '*** WARNING ***', + 'delete-permanent is IRREVERSIBLE.', + 'All sub-folders and records inside will be permanently destroyed.', +] as const + +function parseOperation(input: string): NsfRemoveFolderOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash +} + +function logPreview(result: RemoveNsfFolderResult, quiet: boolean): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(formatRemoveNsfFolderPreview(result.preview, result.operation, quiet)) + logger.info('') +} + +function logPreviewWarnings(result: RemoveNsfFolderResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function runRmdirNsf(vault: Vault): Promise { + const folders = splitCommaSeparated(await prompt('Folder UID(s) or name(s), comma-separated: ')) + if (folders.length === 0) { + logger.info('At least one folder is required.') + return + } + + logger.info('Operation: 1) folder-trash 2) delete-permanent') + const operation = parseOperation(await prompt('Choose [1]: ')) + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const quiet = isYes(await prompt('Quiet (summary only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + if (operation === NsfRemoveFolderOperation.DeletePermanent && !force && !dryRun) { + logger.info('') + for (const line of PERMANENT_DELETE_WARNING_LINES) { + logger.info(line) + } + logger.info('') + } + + const baseInput: RemoveNsfFolderInput = { folders, operation, quiet } + + if (dryRun) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, dryRun: true })) + logPreview(result, quiet) + logger.info('[Dry-run] No folders were deleted.') + return + } + + if (force) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + logPreview(result, quiet) + if (result.confirmed && result.message) logger.info(result.message) + return + } + + const preview = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: false })) + logPreview(preview, quiet) + logPreviewWarnings(preview) + + const confirmPrompt = + operation === NsfRemoveFolderOperation.DeletePermanent + ? 'Do you want to permanently delete the folder(s) and all their contents? [y/n]: ' + : 'Do you want to proceed with the folder deletion? [y/n]: ' + + if (!isYes(await prompt(confirmPrompt))) { + logger.info('Removal cancelled.') + return + } + + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + if (result.confirmed && result.message) logger.info(result.message) +} + +async function rmdirNsf() { + const vault = await login() + + try { + await runRmdirNsf(vault) + } catch (err) { + logger.error(`Folder remove failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rmdirNsf) diff --git a/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts new file mode 100644 index 00000000..d5d7fb0f --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/update_nsf_record.ts @@ -0,0 +1,152 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_MAX_RECORD_BATCH, + parseNsfFieldInput, + prompt, + type UpdateNsfRecordItemInput, + type UpdateNsfRecordResultItem, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' +import { promptChoice, promptRequired, promptRequiredList, promptYesNo, yesNoPrompt } from '../utils/promptCommands' + +type UpdateMode = 'single' | 'bulk' | 'multi' + +const MODE_CHOICES: Record = { + '': 'single', + '1': 'single', + '2': 'bulk', + '3': 'multi', + single: 'single', + bulk: 'bulk', + multi: 'multi', +} + +function hasUpdateChanges(item: UpdateNsfRecordItemInput): boolean { + return !!( + item.title?.trim() || + item.recordType?.trim() || + item.notes?.trim() || + item.fieldEntries?.length || + item.customEntries?.length + ) +} + +async function promptRecordChanges(recordLabel = 'Record'): Promise> { + const title = (await prompt('New title (optional): ')).trim() + const recordType = (await prompt('Record type (optional): ')).trim() + const notes = (await prompt('Notes (optional): ')).trim() + const fieldsInput = (await prompt('Fields (type=value, comma-separated, optional): ')).trim() + const parsed = fieldsInput ? parseNsfFieldInput(fieldsInput) : undefined + + const changes: Omit = { + title: title || undefined, + recordType: recordType || undefined, + notes: notes || undefined, + fieldEntries: parsed?.fieldEntries, + customEntries: parsed?.customEntries, + } + + if (!hasUpdateChanges({ record: recordLabel, ...changes })) { + throw new Error('At least one field to update is required (title, type, notes, or fields).') + } + + return changes +} + +function printUpdateResults(updated: UpdateNsfRecordResultItem[]): void { + logger.info('') + for (const item of updated) { + logger.info(`Record: ${item.recordUid}`) + logger.info(` Status: ${item.status}`) + if (item.message) logger.info(` Message: ${item.message}`) + if (item.revision != null) logger.info(` Revision: ${item.revision}`) + logger.info('') + } + logger.info(`Total: ${updated.length} record(s) updated`) + logger.info('') +} + +async function updateSingleRecord(vault: Awaited>): Promise { + const record = await promptRequired('Record UID or title: ') + const changes = await promptRecordChanges(record) + const result = await withSuppressedLogs(() => vault.updateNestedShareRecord({ record, ...changes })) + printUpdateResults([result]) +} + +async function updateBulkSameChanges(vault: Awaited>): Promise { + const records = await promptRequiredList( + 'Record UID(s) or title(s), comma-separated: ', + splitCommaSeparated + ) + + if (records.length > NSF_MAX_RECORD_BATCH) { + logger.error(`Maximum ${NSF_MAX_RECORD_BATCH} records per request.`) + return + } + + logger.info( + `Updating ${records.length} record(s) in one batch. The same title, type, notes, and fields apply to all.` + ) + + const changes = await promptRecordChanges() + const result = await withSuppressedLogs(() => + vault.updateNestedShareRecords({ + records, + ...changes, + }) + ) + printUpdateResults(result.updated) +} + +async function updateMultipleRecords(vault: Awaited>): Promise { + const items: UpdateNsfRecordItemInput[] = [] + + logger.info('Enter each record update (different record and changes allowed).') + while (true) { + logger.info(`--- Record ${items.length + 1} ---`) + const record = await promptRequired('Record UID or title: ') + const changes = await promptRecordChanges(record) + items.push({ record, ...changes }) + + if (items.length >= NSF_MAX_RECORD_BATCH) { + logger.info(`Maximum ${NSF_MAX_RECORD_BATCH} records per request.`) + break + } + if (!(await promptYesNo(yesNoPrompt('Update another record?')))) { + break + } + } + + const result = await withSuppressedLogs(() => vault.updateNestedShareRecords({ records: items })) + printUpdateResults(result.updated) +} + +async function updateNsfRecord() { + const vault = await login() + + try { + logger.info( + 'Mode: 1) single 2) bulk (same changes, multiple records) 3) multiple (enter each)' + ) + const mode = await promptChoice('Choose [1]: ', MODE_CHOICES) + + if (mode === 'single') { + await updateSingleRecord(vault) + } else if (mode === 'bulk') { + await updateBulkSameChanges(vault) + } else { + await updateMultipleRecords(vault) + } + } catch (err) { + logger.error(`Record update failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(updateNsfRecord) diff --git a/examples/sdk_example/src/utils/format.ts b/examples/sdk_example/src/utils/format.ts index b9482be6..9d332b01 100644 --- a/examples/sdk_example/src/utils/format.ts +++ b/examples/sdk_example/src/utils/format.ts @@ -2,10 +2,20 @@ import { EMAIL_LIST_SEPARATOR_PATTERN, EMAIL_PATTERN, isValidEmail, + suppressLogs, } from '@keeper-security/keeper-sdk-javascript' export { EMAIL_PATTERN } +export async function withSuppressedLogs(fn: () => T | Promise): Promise { + const restore = suppressLogs() + try { + return await fn() + } finally { + restore() + } +} + export function padRight(str: string, len: number): string { if (str.length > len) { return len > 1 ? str.substring(0, len - 1) + '\u2026' : str.substring(0, len) @@ -40,6 +50,13 @@ export function isYes(answer: string): boolean { return normalized === 'y' || normalized === 'yes' } +export function splitCommaSeparated(input: string): string[] { + return input + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) +} + export function parseEmails(raw: string): { emails: string[]; invalid: string[] } { const tokens = raw .split(EMAIL_LIST_SEPARATOR_PATTERN) diff --git a/examples/sdk_example/src/utils/promptCommands.ts b/examples/sdk_example/src/utils/promptCommands.ts new file mode 100644 index 00000000..7650731c --- /dev/null +++ b/examples/sdk_example/src/utils/promptCommands.ts @@ -0,0 +1,79 @@ +import { logger, prompt } from '@keeper-security/keeper-sdk-javascript' + +export const YES_NO_PROMPT_SUFFIX = '[y/n]: ' + +const YES_ANSWERS = new Set(['y', 'yes']) +const NO_ANSWERS = new Set(['n', 'no']) + +export function yesNoPrompt(label: string): string { + return `${label} ${YES_NO_PROMPT_SUFFIX}` +} + +export function isYes(answer: string): boolean { + const normalized = answer.trim().toLowerCase() + return YES_ANSWERS.has(normalized) +} + +function parseYesNo(answer: string): boolean | null | undefined { + const normalized = answer.trim().toLowerCase() + if (normalized === '') return undefined + if (YES_ANSWERS.has(normalized)) return true + if (NO_ANSWERS.has(normalized)) return false + return null +} + +export async function promptYesNo(question: string, defaultValue = false): Promise { + while (true) { + const answer = await prompt(question) + const parsed = parseYesNo(answer) + if (parsed === null) { + logger.warn('Invalid input. Enter y, yes, n, or no.') + continue + } + if (parsed === undefined) return defaultValue + return parsed + } +} + +export async function promptChoice( + question: string, + choices: Readonly>, + defaultKey = '' +): Promise { + while (true) { + const raw = await prompt(question) + const key = raw.trim().toLowerCase() + const lookupKey = key === '' ? defaultKey : key + if (lookupKey in choices) { + return choices[lookupKey] + } + + const validKeys = [...new Set(Object.keys(choices).filter((k) => k !== ''))].join(', ') + const defaultHint = defaultKey in choices ? ` (default: ${defaultKey || 'Enter'})` : '' + logger.warn(`Invalid input "${raw.trim()}". Valid choices: ${validKeys}${defaultHint}.`) + } +} + +export async function promptRequired(question: string, message = 'Value is required.'): Promise { + while (true) { + const answer = (await prompt(question)).trim() + if (answer) return answer + logger.warn(message) + } +} + +export async function promptOptional(question: string): Promise { + return (await prompt(question)).trim() +} + +export async function promptRequiredList( + question: string, + parse: (input: string) => string[], + message = 'At least one value is required.' +): Promise { + while (true) { + const items = parse((await prompt(question)).trim()) + if (items.length > 0) return items + logger.warn(message) + } +}