diff --git a/.talismanrc b/.talismanrc index 3db7ed059..ebd1224b7 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,8 +1,4 @@ fileignoreconfig: -- filename: packages/contentstack-bulk-operations/src/services/am-asset-service.ts - checksum: 5f6c0ecba74e27399a7079ca15e65e77ef692697093c9fb1d57213728c4fe985 -- filename: packages/contentstack-bulk-operations/src/utils/asset-uids-from-file.ts - checksum: 580932f192dd3fdd8bb2c55b7a7a78f1694f646ef5c5041f86c75668778f7ecb -- filename: packages/contentstack-bulk-operations/test/unit/utils/asset-uids-from-file.test.ts - checksum: 8123f7a675a0275795b59b15d0f2d5f8f1e57ccbecf3f97249a0dc5a037b9203 +- filename: packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts + checksum: a26defc1f356308c3f46607e68714bfa2de174e58237cb6b949bd43f2494a818 version: '1.0' diff --git a/packages/contentstack-bulk-operations/src/base-am-command.ts b/packages/contentstack-bulk-operations/src/base-am-command.ts new file mode 100644 index 000000000..67ce99cb9 --- /dev/null +++ b/packages/contentstack-bulk-operations/src/base-am-command.ts @@ -0,0 +1,29 @@ +import { Command } from '@contentstack/cli-command'; +import { handleAndLogError } from '@contentstack/cli-utilities'; + +import { fillMissingAmFlags } from './utils'; +import type { AmAssetFlags } from './interfaces'; + +/** + * Thin base command for Asset Management operations. + * Handles flag prompting in init() and exposes typed parsedFlags / loggerContext. + * Deliberately does NOT inherit BaseBulkCommand — AM operations use a different API + * surface with no stack setup, queue managers, or rate limiters. + */ +export abstract class BaseAmCommand extends Command { + protected parsedFlags!: AmAssetFlags; + protected loggerContext!: { module: string }; + + protected async init(): Promise { + await super.init(); + const { flags } = await this.parse(this.constructor as typeof BaseAmCommand); + this.loggerContext = { module: this.id ?? 'cm:stacks:bulk-am-assets' }; + this.parsedFlags = (await fillMissingAmFlags(flags)) as AmAssetFlags; + } + + async catch(error: Error): Promise { + handleAndLogError(error); + } + + abstract run(): Promise; +} diff --git a/packages/contentstack-bulk-operations/src/base-bulk-command.ts b/packages/contentstack-bulk-operations/src/base-bulk-command.ts index e5f0bc62c..370b44a94 100644 --- a/packages/contentstack-bulk-operations/src/base-bulk-command.ts +++ b/packages/contentstack-bulk-operations/src/base-bulk-command.ts @@ -145,15 +145,14 @@ export abstract class BaseBulkCommand extends Command { this.parsedFlags = flags; - const commandName = `cm:stacks:bulk-${this.resourceType === ResourceType.ENTRY ? 'entries' : 'assets'}`; createLogContext( - this.context?.info?.command || commandName, + this.context?.info?.command || this.id, flags['stack-api-key'] || '', flags.alias ? 'Management Token' : 'Basic Auth' ); this.logger = log; - this.loggerContext = { module: commandName }; + this.loggerContext = { module: this.id }; // Check for revert/retry EARLY - all config comes from log file const isRevertOrRetry = flags.revert || flags['retry-failed']; diff --git a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts index f1b0a7bf2..7d1a3118c 100644 --- a/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts +++ b/packages/contentstack-bulk-operations/src/commands/cm/stacks/bulk-am-assets.ts @@ -1,8 +1,8 @@ import chalk from 'chalk'; -import { Command } from '@contentstack/cli-command'; -import { flags, log, createLogContext, handleAndLogError, cliux, FlagInput } from '@contentstack/cli-utilities'; +import { flags, log, createLogContext, cliux, handleAndLogError, FlagInput } from '@contentstack/cli-utilities'; import messages, { $t } from '../../../messages'; +import { BaseAmCommand } from '../../../base-am-command'; import { AmAssetService } from '../../../services'; import { loadAssetUidsFromFile, @@ -18,7 +18,7 @@ type RegionWithOptionalAmUrl = { csAssetsUrl?: string }; /** * AM bulk delete (job) / bulk move — CS Assets API only; asset UIDs come from a JSON file `{ "uids": [...] }`. */ -export default class BulkAmAssets extends Command { +export default class BulkAmAssets extends BaseAmCommand { static description = messages.BULK_AM_ASSETS_DESCRIPTION; static examples = [ @@ -31,15 +31,12 @@ export default class BulkAmAssets extends Command { operation: flags.string({ description: messages.AM_OPERATION_FLAG, options: ['delete', 'move'], - required: true, }), 'space-uid': flags.string({ description: messages.AM_SPACE_UID_FLAG, - required: true, }), 'org-uid': flags.string({ description: messages.AM_ORG_UID_FLAG, - required: true, }), workspace: flags.string({ default: 'main', @@ -47,7 +44,6 @@ export default class BulkAmAssets extends Command { }), 'asset-uids-file': flags.string({ description: messages.AM_ASSET_UIDS_FILE_FLAG, - required: true, }), locale: flags.string({ description: messages.AM_LOCALE_FLAG, @@ -62,7 +58,22 @@ export default class BulkAmAssets extends Command { }), }; - private readonly loggerContext = { module: COMMAND_ID }; + private printAmSummary(op: 'delete' | 'move', opts: { jobId?: string; count?: number; folderUid?: string; notice?: string; error?: string }): void { + if (opts.error) { + log.error($t(messages.AM_OPERATION_FAILED, { operation: op }), this.loggerContext); + log.error(opts.error, this.loggerContext); + } else if (op === 'delete') { + log.success($t(messages.AM_DELETE_SUCCESS), this.loggerContext); + if (opts.jobId) log.info($t(messages.AM_DELETE_JOB_ID, { jobId: opts.jobId }), this.loggerContext); + log.info($t(messages.AM_DELETE_ASYNC_NOTE), this.loggerContext); + } else { + log.success($t(messages.AM_MOVE_SUCCESS), this.loggerContext); + if (opts.count !== undefined && opts.folderUid) { + log.info($t(messages.AM_MOVE_ASSETS_COUNT, { count: opts.count, folderUid: opts.folderUid }), this.loggerContext); + } + } + if (opts.notice) log.info(opts.notice, this.loggerContext); + } private handleAssetUidsFileError(e: LoadAssetUidsError): void { const pathShown = e.filePath; @@ -79,7 +90,7 @@ export default class BulkAmAssets extends Command { async run(): Promise { try { - const { flags: f } = await this.parse(BulkAmAssets); + const f = this.parsedFlags; const amBaseUrl = (this.region as RegionWithOptionalAmUrl).csAssetsUrl?.trim(); if (!amBaseUrl) { @@ -95,26 +106,9 @@ export default class BulkAmAssets extends Command { return; } - const spaceUid = (f['space-uid'] ?? '').trim(); - if (!spaceUid) { - log.error($t(messages.SPACE_UID_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - - const orgUid = (f['org-uid'] ?? '').trim(); - if (!orgUid) { - log.error($t(messages.ORG_UID_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } - - const assetUidsPath = (f['asset-uids-file'] ?? '').trim(); - if (!assetUidsPath) { - log.error($t(messages.AM_ASSET_UIDS_FILE_REQUIRED), this.loggerContext); - process.exitCode = 1; - return; - } + const spaceUid = f['space-uid'].trim(); + const orgUid = f['org-uid'].trim(); + const assetUidsPath = f['asset-uids-file'].trim(); let deleteRows: AmBulkDeleteItem[]; @@ -166,16 +160,17 @@ export default class BulkAmAssets extends Command { log.info($t(messages.AM_DELETING_ASSETS, { count: deleteRows.length, spaceUid }), this.loggerContext); const result = await amService.bulkDelete(spaceUid, workspace, deleteRows); if (!result.success) { - log.error(result.error ?? 'AM bulk delete failed', this.loggerContext); + this.printAmSummary('delete', { error: result.error ?? 'AM bulk delete failed' }); process.exitCode = 1; return; } - if (result.notice) { - log.info($t(messages.AM_OPERATION_NOTICE, { notice: result.notice }), this.loggerContext); - } - if (result.jobId) { - log.info($t(messages.AM_DELETE_SUBMITTED, { jobId: result.jobId }), this.loggerContext); - } + this.printAmSummary('delete', { jobId: result.jobId, notice: result.notice }); + return; + } + + if (f.locale) { + log.error($t(messages.AM_LOCALE_NOT_ALLOWED_FOR_MOVE), this.loggerContext); + process.exitCode = 1; return; } @@ -231,14 +226,11 @@ export default class BulkAmAssets extends Command { ); const result = await amService.bulkMove(spaceUid, workspace, uids, moveFolderUid); if (!result.success) { - log.error(result.error ?? 'AM bulk move failed', this.loggerContext); + this.printAmSummary('move', { error: result.error ?? 'AM bulk move failed' }); process.exitCode = 1; return; } - if (result.notice) { - log.info($t(messages.AM_OPERATION_NOTICE, { notice: result.notice }), this.loggerContext); - } - log.info($t(messages.AM_MOVE_SUBMITTED), this.loggerContext); + this.printAmSummary('move', { count: uids.length, folderUid: moveFolderUid, notice: result.notice }); } catch (error) { handleAndLogError(error); } diff --git a/packages/contentstack-bulk-operations/src/interfaces/index.ts b/packages/contentstack-bulk-operations/src/interfaces/index.ts index b5a18fe71..c13dbc3c4 100644 --- a/packages/contentstack-bulk-operations/src/interfaces/index.ts +++ b/packages/contentstack-bulk-operations/src/interfaces/index.ts @@ -20,6 +20,7 @@ export enum ResourceType { ENTRY = 'entry', ASSET = 'asset', TAXONOMY = 'taxonomy', + AM_ASSET = 'am-asset', } export enum FilterType { @@ -270,6 +271,18 @@ export interface AmBulkOperationResult { error?: string; } +/** Typed flags for the bulk-am-assets command. */ +export interface AmAssetFlags { + operation: string; + 'space-uid': string; + 'org-uid': string; + workspace: string; + 'asset-uids-file': string; + locale?: string; + 'target-folder-uid'?: string; + yes: boolean; +} + export interface BulkJobResult { success: number; failed: number; diff --git a/packages/contentstack-bulk-operations/src/messages/index.ts b/packages/contentstack-bulk-operations/src/messages/index.ts index a285f734e..388247738 100644 --- a/packages/contentstack-bulk-operations/src/messages/index.ts +++ b/packages/contentstack-bulk-operations/src/messages/index.ts @@ -238,10 +238,25 @@ const amBulkAssetsMsg = { AM_WORKSPACE_FLAG: 'AM workspace query parameter (default: main)', AM_ASSET_UIDS_FILE_FLAG: 'Path to UTF-8 JSON file: exactly `{ "uids": ["uid1", "uid2"] }` (non-empty string array, no trimming; large lists: see docs for NODE_OPTIONS)', - AM_LOCALE_FLAG: 'Locale code for bulk delete (single locale per run)', - AM_TARGET_FOLDER_FLAG: 'Destination AM folder UID (required for move)', + AM_LOCALE_FLAG: 'Locale code for bulk delete only (single locale per run). Not applicable for move — move always relocates all locale variants of an asset.', + AM_LOCALE_NOT_ALLOWED_FOR_MOVE: '--locale is not applicable for the move operation. Move always relocates all locale variants of an asset. Remove --locale and try again.', + AM_TARGET_FOLDER_FLAG: 'Destination AM folder UID for bulk move. Use "root" to move assets to the root folder.', AM_INVALID_OPERATION: 'Invalid operation: {operation}. Must be delete or move', AM_CONFIRM_SUMMARY: 'Proceed with AM {operation} on {count} item(s)?', + AM_DELETE_SUCCESS: 'AM bulk delete job submitted successfully!', + AM_DELETE_JOB_ID: 'Job ID: {jobId}', + AM_DELETE_ASYNC_NOTE: 'The job runs asynchronously — check the Asset Management console for status.', + AM_MOVE_SUCCESS: 'AM bulk move completed successfully!', + AM_MOVE_ASSETS_COUNT: '{count} asset(s) moved to folder: {folderUid}', + AM_OPERATION_FAILED: 'AM {operation} failed.', + + // Interactive prompts + AM_SELECT_OPERATION: 'Select AM operation:', + AM_ENTER_SPACE_UID: 'Enter AM space UID:', + AM_ENTER_ORG_UID: 'Enter organization UID:', + AM_ENTER_ASSET_UIDS_FILE: 'Enter path to asset UIDs JSON file (e.g. ./assets.json):', + AM_ENTER_LOCALE: 'Enter locale code for bulk delete (e.g. en-us):', + AM_ENTER_TARGET_FOLDER: 'Enter target folder UID for bulk move (use "root" to move to the root folder):', }; /** diff --git a/packages/contentstack-bulk-operations/src/utils/index.ts b/packages/contentstack-bulk-operations/src/utils/index.ts index 02c334e3e..9f2b6e310 100644 --- a/packages/contentstack-bulk-operations/src/utils/index.ts +++ b/packages/contentstack-bulk-operations/src/utils/index.ts @@ -35,7 +35,7 @@ import { buildBulkModeResult, handleOperationError, } from './command-helpers'; -import { fillMissingFlags } from './interactive'; +import { fillMissingFlags, fillMissingAmFlags } from './interactive'; import { RATE_LIMITER_CONSTANTS, RETRY_STRATEGY_CONSTANTS, @@ -98,6 +98,7 @@ export { buildBulkModeResult, handleOperationError, fillMissingFlags, + fillMissingAmFlags, fetchTaxonomyList, RATE_LIMITER_CONSTANTS, RETRY_STRATEGY_CONSTANTS, diff --git a/packages/contentstack-bulk-operations/src/utils/interactive.ts b/packages/contentstack-bulk-operations/src/utils/interactive.ts index 8794efae6..4bccb0a91 100644 --- a/packages/contentstack-bulk-operations/src/utils/interactive.ts +++ b/packages/contentstack-bulk-operations/src/utils/interactive.ts @@ -223,3 +223,114 @@ export async function fillMissingFlags(flags: any): Promise { return updatedFlags; } + +/** + * Runs a sequence of prompt functions wrapped in the standard interactive mode header/footer. + * Each prompt is a no-op if its condition is already satisfied (value already in flags). + */ +async function runInteractivePrompts(prompts: Array<() => Promise>): Promise { + cliux.print(messages.INTERACTIVE_MODE_START, { color: 'cyan' }); + for (const prompt of prompts) await prompt(); + cliux.print(messages.INTERACTIVE_MODE_COMPLETE, { color: 'green' }); +} + +/** + * Fills in missing flags for the bulk-am-assets command by prompting the user. + * Handles AM-specific required flags including operation-conditional ones + * (locale for delete, target-folder-uid for move). + * Throws in non-TTY environments when required flags are missing. + */ +export async function fillMissingAmFlags(flags: any): Promise { + const f = { ...flags }; + + const needsLocale = f.operation === 'delete' && !f.locale; + const needsFolderUid = f.operation === 'move' && !f['target-folder-uid']; + const needsPrompt = + !f.operation || !f['space-uid'] || !f['org-uid'] || !f['asset-uids-file'] || needsLocale || needsFolderUid; + + if (!needsPrompt) return f; + + // Fail fast in non-interactive environments (CI/CD) rather than hanging on stdin + if (!process.stdin.isTTY) { + const missing = [ + !f.operation && '--operation', + !f['space-uid'] && '--space-uid', + !f['org-uid'] && '--org-uid', + !f['asset-uids-file'] && '--asset-uids-file', + (f.operation === 'delete' && !f.locale) && '--locale', + (f.operation === 'move' && !f['target-folder-uid']) && '--target-folder-uid', + ].filter(Boolean); + throw new Error( + `Missing required flag(s): ${missing.join(', ')}. Provide all required flags when running in a non-interactive environment.` + ); + } + + await runInteractivePrompts([ + async () => { + if (!f.operation) { + f.operation = await cliux.inquire({ + type: 'list', + name: 'operation', + message: messages.AM_SELECT_OPERATION, + choices: [ + { name: 'Delete (AM bulk delete)', value: 'delete' }, + { name: 'Move (AM bulk move)', value: 'move' }, + ], + }); + } + }, + async () => { + if (!f['space-uid']) { + f['space-uid'] = await cliux.inquire({ + type: 'input', + name: 'spaceUid', + message: messages.AM_ENTER_SPACE_UID, + validate: (v: string) => (!v?.trim() ? messages.SPACE_UID_REQUIRED : true), + }); + } + }, + async () => { + if (!f['org-uid']) { + f['org-uid'] = await cliux.inquire({ + type: 'input', + name: 'orgUid', + message: messages.AM_ENTER_ORG_UID, + validate: (v: string) => (!v?.trim() ? messages.ORG_UID_REQUIRED : true), + }); + } + }, + async () => { + if (!f['asset-uids-file']) { + f['asset-uids-file'] = await cliux.inquire({ + type: 'input', + name: 'assetUidsFile', + message: messages.AM_ENTER_ASSET_UIDS_FILE, + validate: (v: string) => (!v?.trim() ? messages.AM_ASSET_UIDS_FILE_REQUIRED : true), + }); + } + }, + // Conditional prompts run after operation is resolved (captured by closure) + async () => { + if (f.operation === 'delete' && !f.locale) { + f.locale = await cliux.inquire({ + type: 'input', + name: 'locale', + message: messages.AM_ENTER_LOCALE, + validate: (v: string) => (!v?.trim() ? messages.AM_LOCALE_REQUIRED : true), + }); + } + }, + async () => { + if (f.operation === 'move' && !f['target-folder-uid']) { + f['target-folder-uid'] = await cliux.inquire({ + type: 'input', + name: 'targetFolderUid', + message: messages.AM_ENTER_TARGET_FOLDER, + validate: (v: string) => (!v?.trim() ? messages.TARGET_FOLDER_REQUIRED : true), + }); + } + }, + ]); + + return f; +} diff --git a/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts b/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts new file mode 100644 index 000000000..0787bfeee --- /dev/null +++ b/packages/contentstack-bulk-operations/test/unit/commands/bulk-am-assets.test.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import BulkAmAssets from '../../../src/commands/cm/stacks/bulk-am-assets'; + +describe('BulkAmAssets command', () => { + let sandbox: sinon.SinonSandbox; + let command: BulkAmAssets; + + const baseDeleteFlags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + workspace: 'main', + yes: true, + }; + + const baseMoveFlags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + workspace: 'main', + yes: true, + }; + + function setRegion(value: object): void { + Object.defineProperty(command, 'region', { value, configurable: true, writable: true }); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + command = new BulkAmAssets([], {} as any); + (command as any).parsedFlags = { ...baseDeleteFlags }; + (command as any).loggerContext = { module: 'cm:stacks:bulk-am-assets' }; + setRegion({}); + }); + + afterEach(() => { + sandbox.restore(); + process.exitCode = undefined; + }); + + describe('AM URL validation', () => { + it('should set exitCode=1 when AM URL is not configured in region', async () => { + setRegion({}); // no csAssetsUrl + + await command.run(); + + expect(process.exitCode).to.equal(1); + }); + }); + + describe('locale not allowed for move', () => { + it('should set exitCode=1 when --locale is passed with --operation move', async () => { + (command as any).parsedFlags = { ...baseMoveFlags, locale: 'en-us' }; + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + + // Stub the file loader to confirm it is NOT reached + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + const loadStub = sandbox.stub(assetUidsModule, 'loadAssetUidsFromFile'); + + await command.run(); + + expect(process.exitCode).to.equal(1); + expect(loadStub.called).to.be.false; // Should have exited before loading files + }); + + it('should NOT set exitCode when --locale is absent for move and API succeeds', async () => { + (command as any).parsedFlags = { ...baseMoveFlags }; + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadAssetUidsFromFile').returns(['uid1', 'uid2']); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.AmAssetService.prototype, 'bulkMove').resolves({ + success: true, + notice: undefined, + }); + + await command.run(); + + expect(process.exitCode).to.not.equal(1); + }); + }); + + describe('delete operation', () => { + beforeEach(() => { + setRegion({ csAssetsUrl: 'https://assets.example.com' }); + }); + + it('should NOT set exitCode on successful delete', async () => { + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadBulkDeleteItemsFromFile').returns([{ uid: 'u1', locale: 'en-us' }]); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.AmAssetService.prototype, 'bulkDelete').resolves({ + success: true, + jobId: 'job-abc-123', + }); + + await command.run(); + + expect(process.exitCode).to.not.equal(1); + }); + + it('should set exitCode=1 on failed delete', async () => { + const assetUidsModule = require('../../../src/utils/asset-uids-from-file'); + sandbox.stub(assetUidsModule, 'loadBulkDeleteItemsFromFile').returns([{ uid: 'u1', locale: 'en-us' }]); + + const amServiceModule = require('../../../src/services/am-asset-service'); + sandbox.stub(amServiceModule.AmAssetService.prototype, 'bulkDelete').resolves({ + success: false, + error: 'API rate limit exceeded', + }); + + await command.run(); + + expect(process.exitCode).to.equal(1); + }); + }); + + describe('BaseAmCommand isolation — no publish/unpublish infrastructure', () => { + it('should not have bulkOperationConfig, queueManager, or managementStack on the instance', () => { + // BulkAmAssets extends BaseAmCommand, NOT BaseBulkCommand. + // None of these publish/unpublish properties should exist. + expect((command as any).bulkOperationConfig).to.be.undefined; + expect((command as any).queueManager).to.be.undefined; + expect((command as any).managementStack).to.be.undefined; + expect((command as any).rateLimiter).to.be.undefined; + }); + }); +}); diff --git a/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts b/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts index 36afee32f..20a5ea9fc 100644 --- a/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts +++ b/packages/contentstack-bulk-operations/test/unit/utils/interactive.test.ts @@ -416,4 +416,226 @@ describe('Interactive Prompts', () => { expect(validValidation).to.be.true; }); }); + + describe('fillMissingAmFlags', () => { + // We need to import fillMissingAmFlags separately + let fillMissingAmFlags: typeof import('../../../src/utils/interactive').fillMissingAmFlags; + let originalIsTTY: boolean | undefined; + + before(async () => { + ({ fillMissingAmFlags } = await import('../../../src/utils/interactive')); + }); + + beforeEach(() => { + originalIsTTY = process.stdin.isTTY; + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + }); + + it('should return flags unchanged when all required flags are provided (delete)', async () => { + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + workspace: 'main', + yes: false, + }; + + const result = await fillMissingAmFlags(flags); + + expect(result).to.deep.equal(flags); + expect(inquireStub.called).to.be.false; + expect(printStub.called).to.be.false; + }); + + it('should return flags unchanged when all required flags are provided (move)', async () => { + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + workspace: 'main', + yes: false, + }; + + const result = await fillMissingAmFlags(flags); + + expect(result).to.deep.equal(flags); + expect(inquireStub.called).to.be.false; + }); + + it('should throw in non-TTY when required base flags are missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { workspace: 'main', yes: false }; + + try { + await fillMissingAmFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--operation'); + expect(error.message).to.include('--space-uid'); + expect(error.message).to.include('--org-uid'); + expect(error.message).to.include('--asset-uids-file'); + expect(error.message).to.include('non-interactive'); + } + }); + + it('should throw in non-TTY and include --locale when operation=delete and locale missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + try { + await fillMissingAmFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--locale'); + } + }); + + it('should throw in non-TTY and include --target-folder-uid when operation=move and folder missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + try { + await fillMissingAmFlags(flags); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('--target-folder-uid'); + } + }); + + it('should prompt for all missing base flags in TTY and show interactive header/footer', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = {}; + + inquireStub.onCall(0).resolves('delete'); // operation + inquireStub.onCall(1).resolves('sp123'); // space-uid + inquireStub.onCall(2).resolves('org456'); // org-uid + inquireStub.onCall(3).resolves('./assets.json'); // asset-uids-file + inquireStub.onCall(4).resolves('en-us'); // locale (delete-conditional) + + const result = await fillMissingAmFlags(flags); + + expect(result.operation).to.equal('delete'); + expect(result['space-uid']).to.equal('sp123'); + expect(result['org-uid']).to.equal('org456'); + expect(result['asset-uids-file']).to.equal('./assets.json'); + expect(result.locale).to.equal('en-us'); + expect(printStub.calledTwice).to.be.true; + }); + + it('should prompt for locale only when operation=delete and locale is missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'delete', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + inquireStub.onCall(0).resolves('en-us'); // locale + + const result = await fillMissingAmFlags(flags); + + expect(result.locale).to.equal('en-us'); + expect(inquireStub.calledOnce).to.be.true; + expect(inquireStub.firstCall.args[0].name).to.equal('locale'); + }); + + it('should prompt for target-folder-uid only when operation=move and folder is missing', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + }; + + inquireStub.onCall(0).resolves('folderABC'); // target-folder-uid + + const result = await fillMissingAmFlags(flags); + + expect(result['target-folder-uid']).to.equal('folderABC'); + expect(inquireStub.calledOnce).to.be.true; + expect(inquireStub.firstCall.args[0].name).to.equal('targetFolderUid'); + }); + + it('should NOT prompt for locale when operation=move', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'move', + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + }; + + const result = await fillMissingAmFlags(flags); + + expect(result.locale).to.be.undefined; + expect(inquireStub.called).to.be.false; + }); + + it('should present delete/move choices for the operation prompt', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + 'space-uid': 'sp123', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + 'target-folder-uid': 'folderABC', + }; + + inquireStub.onCall(0).resolves('move'); // operation + + await fillMissingAmFlags(flags); + + const operationCall = inquireStub.firstCall.args[0]; + expect(operationCall.type).to.equal('list'); + const values = operationCall.choices.map((c: any) => c.value); + expect(values).to.include('delete'); + expect(values).to.include('move'); + }); + + it('should validate that space-uid is not blank', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + + const flags = { + operation: 'delete', + 'org-uid': 'org456', + 'asset-uids-file': './assets.json', + locale: 'en-us', + }; + + inquireStub.onCall(0).resolves('sp123'); + + await fillMissingAmFlags(flags); + + const spaceUidCall = inquireStub.firstCall.args[0]; + expect(spaceUidCall.validate('')).to.not.equal(true); + expect(spaceUidCall.validate('sp123')).to.equal(true); + }); + }); });