diff --git a/jest.config.js b/jest.config.js index 7a425f4..e5d8be5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,15 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['**/src/tests/**/*.ts'], - testPathIgnorePatterns: ['/node_modules/', '/dist/', '/src/index.ts'], + // Default: unit tests only (exclude integration tests) + testMatch: ['**/src/tests/**/*.test.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/', '/src/index.ts', 'integration\\.test\\.ts'], + setupFilesAfterEnv: ['/src/tests/setup.ts'], + // Map TypeScript path aliases to actual paths + moduleNameMapper: { + '^core/(.*)$': '/src/core/$1', + '^core$': '/src/core', + '^lib/(.*)$': '/src/lib/$1', + '^types/(.*)$': '/src/types/$1', + }, }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5c64474..7a9be0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,23 @@ { "name": "@agility/cli", - "version": "1.0.0-beta.9.16", + "version": "1.0.0-beta.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agility/cli", - "version": "1.0.0-beta.9.16", + "version": "1.0.0-beta.13", "license": "ISC", "dependencies": { "@agility/content-fetch": "^2.0.10", "@agility/content-sync": "^1.2.0", - "@agility/management-sdk": "^0.1.35", + "@agility/management-sdk": "^0.1.38", "ansi-colors": "^4.1.3", "blessed": "^0.1.81", "blessed-contrib": "^4.11.0", "cli-progress": "^3.11.2", "date-fns": "^4.1.0", + "form-data": "^4.0.5", "fuzzy": "^0.1.3", "inquirer": "^8.0.0", "inquirer-checkbox-plus-prompt": "^1.4.2", @@ -82,7 +83,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.14.0" } @@ -101,9 +101,9 @@ } }, "node_modules/@agility/management-sdk": { - "version": "0.1.35", - "resolved": "https://registry.npmjs.org/@agility/management-sdk/-/management-sdk-0.1.35.tgz", - "integrity": "sha512-js4EYPm6FQtmao0kDT3y6w3Azh+PsHaGOMpjEdt/thtDm+T1szeZ0CRlONlpkN6qi4bNlWIA6/SeCoqhpOkVaA==", + "version": "0.1.38", + "resolved": "https://registry.npmjs.org/@agility/management-sdk/-/management-sdk-0.1.38.tgz", + "integrity": "sha512-g6/hNgCjf+uzcJbkPaxeWqwaAid50tMnRW0KYQ4J/fGuJqtcZG0et0X3m76Ev3yUqnkpIO/+C5zMIQMghN3/oQ==", "license": "MIT", "dependencies": { "axios": "^0.27.2" @@ -154,7 +154,6 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1463,7 +1462,6 @@ "integrity": "sha512-hcxGs9TfQGghOM8atpRT+bBMUX7V8WosdYt98bQ59wUToJck55eCOlemJ+0FpOZOQw5ff7LSi9+IO56KvYEFyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1952,7 +1950,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3020,9 +3017,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3397,7 +3394,6 @@ "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", "license": "MIT", - "peer": true, "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", @@ -4465,7 +4461,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6103,7 +6098,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -7596,7 +7590,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7704,7 +7697,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 9c533ef..2d10907 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agility/cli", - "version": "1.0.0-beta.12", + "version": "1.0.0-beta.13", "description": "Agility CLI for working with your content. (Public Beta)", "repository": { "type": "git", @@ -17,6 +17,8 @@ "postbuild": "chmod +x dist/index.js", "refresh": "rm -rf ./node_modules ./package-lock.json && npm install", "test": "jest", + "test:unit": "jest", + "test:integration": "jest --testMatch=\"**/*.integration.test.ts\" --testPathIgnorePatterns=\"/node_modules/|/dist/|/src/index.ts\"", "debug": "node --inspect-brk -r ts-node/register src/index.ts" }, "keywords": [ @@ -45,12 +47,13 @@ "dependencies": { "@agility/content-fetch": "^2.0.10", "@agility/content-sync": "^1.2.0", - "@agility/management-sdk": "^0.1.35", + "@agility/management-sdk": "^0.1.38", "ansi-colors": "^4.1.3", "blessed": "^0.1.81", "blessed-contrib": "^4.11.0", "cli-progress": "^3.11.2", "date-fns": "^4.1.0", + "form-data": "^4.0.5", "fuzzy": "^0.1.3", "inquirer": "^8.0.0", "inquirer-checkbox-plus-prompt": "^1.4.2", @@ -76,4 +79,4 @@ "ts-node": "^10.9.2", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/src/core/auth.ts b/src/core/auth.ts index fab976a..9d60d92 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -119,16 +119,33 @@ export class Auth { async logout() { const env = this.getEnv(); - const key = this.getEnvKey(env); + const auth0Key = this.getEnvKey(env); + const patKey = `cli-pat-token:${env}`; + + let removedAny = false; + try { - const removed = await keytar.deletePassword(SERVICE_NAME, key); - if (removed) { - console.log(`Logged out from ${env} environment.`); + // Remove Auth0 token + const removedAuth0 = await keytar.deletePassword(SERVICE_NAME, auth0Key); + if (removedAuth0) { + console.log(`āœ“ Removed Auth0 token for ${env} environment.`); + removedAny = true; + } + + // Remove PAT token + const removedPAT = await keytar.deletePassword(SERVICE_NAME, patKey); + if (removedPAT) { + console.log(`āœ“ Removed Personal Access Token for ${env} environment.`); + removedAny = true; + } + + if (removedAny) { + console.log(ansiColors.green(`\nšŸ”“ Successfully logged out from ${env} environment.`)); } else { - console.log(`No token found in ${env} environment.`); + console.log(ansiColors.yellow(`No tokens found in ${env} environment.`)); } } catch (err) { - console.error(`āŒ Failed to delete token:`, err); + console.error(`āŒ Failed to delete tokens:`, err); } exit(); } @@ -871,13 +888,6 @@ export class Auth { async validateCommand(commandType: "pull" | "sync" | "clean" | "interactive" | "push"): Promise { const missingFields: string[] = []; - // Validate that --publish flag is only used with sync command - if (state.publish && commandType !== "sync") { - console.log(ansiColors.red(`\nāŒ The --publish flag is only available for sync commands.`)); - console.log(ansiColors.yellow(`šŸ’” Use: agility sync --sourceGuid="source" --targetGuid="target" --publish`)); - return false; - } - // Check command-specific requirements switch (commandType) { case "pull": diff --git a/src/core/batch-workflows.ts b/src/core/batch-workflows.ts new file mode 100644 index 0000000..69dde20 --- /dev/null +++ b/src/core/batch-workflows.ts @@ -0,0 +1,139 @@ +/** + * Batch Workflows Core Service + * + * Core batch workflow operations using the SDK's + * BatchWorkflowContent and BatchWorkflowPages methods. + * + * Supports: Publish, Unpublish, Approve, Decline, RequestApproval + */ + +import { state, getApiClient } from './state'; +import ansiColors from 'ansi-colors'; +import { WorkflowOperationType, BatchWorkflowResult } from '../types'; +import { getOperationName } from '../lib/workflows/workflow-helpers'; + +// Re-export types for convenience +export { WorkflowOperationType, BatchWorkflowResult }; + +// Re-export helpers from workflows folder +export { getOperationName, getOperationVerb, getOperationIcon } from '../lib/workflows/workflow-helpers'; +export { parseWorkflowOptions, parseOperationType } from '../lib/workflows/workflow-options'; + +/** + * Batch size for processing - prevents API throttling + */ +const BATCH_SIZE = 250; + +/** + * Extract detailed error message from various error formats + */ +function extractErrorDetails(error: any): string { + // Check for nested error structures (common in SDK exceptions) + if (error.innerError) { + return extractErrorDetails(error.innerError); + } + + // Check for response data from API + if (error.response?.data) { + if (typeof error.response.data === 'string') { + return error.response.data; + } + if (error.response.data.message) { + return error.response.data.message; + } + if (error.response.data.error) { + return error.response.data.error; + } + return JSON.stringify(error.response.data); + } + + // Check for status code + if (error.response?.status) { + return `HTTP ${error.response.status}: ${error.response.statusText || 'Unknown error'}`; + } + + // Check for message property + if (error.message) { + return error.message; + } + + // Fallback + return String(error) || 'Unknown workflow error'; +} + +/** + * Item type for batch workflow operations + */ +export type BatchItemType = 'content' | 'pages'; + +/** + * Unified batch workflow operation for content items or pages + * + * @param ids - Array of IDs to process + * @param locale - Target locale + * @param operation - Workflow operation type + * @param type - Item type: 'content' or 'pages' + * @returns Promise with batch result + */ +export async function batchWorkflow( + ids: number[], + locale: string, + operation: WorkflowOperationType, + type: BatchItemType +): Promise { + const label = type === 'content' ? 'content items' : 'pages'; + + try { + const apiClient = getApiClient(); + const targetGuid = state.targetGuid; + + if (!apiClient) { + throw new Error('API client not available in state'); + } + if (!targetGuid || targetGuid.length === 0) { + throw new Error('Target GUID not available in state'); + } + if (!locale) { + throw new Error('Locale not available in state'); + } + if (!ids || ids.length === 0) { + throw new Error(`${label} IDs array is empty`); + } + + // const operationName = getOperationName(operation); + + // Log the attempt for debugging + // if (state.verbose) { + // console.log(ansiColors.gray(`${operationName}ing ${ids.length} ${label} to ${targetGuid[0]} (${locale})...`)); + // } + + // Call appropriate SDK method based on type + const processedIds = type === 'content' + ? await apiClient.contentMethods.batchWorkflowContent(ids, targetGuid[0], locale, operation, false) + : await apiClient.pageMethods.batchWorkflowPages(ids, targetGuid[0], locale, operation, false); + + return { + success: true, + processedIds, + failedCount: 0 + }; + } catch (error: any) { + return { + success: false, + processedIds: [], + failedCount: ids.length, + error: extractErrorDetails(error) + }; + } +} + +/** + * Create batches of items for processing + */ +export function createBatches(items: T[], batchSize: number = BATCH_SIZE): T[][] { + const batches: T[][] = []; + for (let i = 0; i < items.length; i += batchSize) { + batches.push(items.slice(i, i + batchSize)); + } + return batches; +} diff --git a/src/core/index.ts b/src/core/index.ts index 4b64c15..4845c7c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -15,6 +15,42 @@ export { normalizeProcessArgs, normalizeArgv } from './arg-normalizer'; // Publishing service export { PublishService, type PublishResult, type PublishOptions } from './publish'; +// Workflow operation standalone module +export { WorkflowOperation } from '../lib/workflows'; + +// Batch workflows service - core batch operations +export { + batchWorkflow, + type BatchItemType, + createBatches +} from './batch-workflows'; + +// Workflow module - orchestration, options, helpers +export { + workflowOrchestrator, + parseWorkflowOptions, + parseOperationType, + getOperationName, + getOperationVerb, + getOperationIcon +} from '../lib/workflows'; + +// Re-export all workflow types from central types folder +export { + WorkflowOperationType, + BatchWorkflowResult, + WorkflowOrchestratorResult, + WorkflowOptions, + WorkflowOperationResult, + ContentMapping, + PageMapping, + MappingReadResult, + MappingUpdateResult, + ItemState, + SourceItemData, + PublishStatusResult +} from '../types'; + // Content and data services export { content } from './content'; export { assets } from './assets'; @@ -22,4 +58,4 @@ export { fileOperations } from './fileOperations'; export { getApiClient } from './state'; // File system integration -// Note: store-interface-filesystem uses module.exports, import directly if needed +// Note: store-interface-filesystem uses module.exports, import directly if needed diff --git a/src/core/pull.ts b/src/core/pull.ts index 361ba54..d35c793 100644 --- a/src/core/pull.ts +++ b/src/core/pull.ts @@ -3,6 +3,7 @@ import * as fs from "fs"; import { getState, initializeLogger, finalizeLogger, getLogger } from "./state"; import ansiColors from "ansi-colors"; import { markPullStart, clearTimestamps } from "../lib/incremental"; +import { waitForFetchApiSync } from "../lib/shared/get-fetch-api-status"; import { Downloader } from "../lib/downloaders/orchestrate-downloaders"; @@ -65,6 +66,20 @@ export class Pull { const totalStartTime = Date.now(); try { + // Wait for Fetch API sync to complete before pulling (only for standalone pull operations) + // This ensures we're pulling the latest data from the CDN + // Skip when called from push - the refresh-mappings workflow handles this separately + if (!fromPush) { + for (const guid of allGuids) { + try { + await waitForFetchApiSync(guid, 'fetch', false); + } catch (error: any) { + // Log warning but don't fail the pull - the API might not support this endpoint yet + console.log(ansiColors.yellow(`āš ļø Could not check Fetch API status for ${guid}: ${error.message}`)); + } + } + } + // Execute concurrent downloads for all GUIDs, locales and channels (sitemaps) const results = await this.downloader.instanceOrchestrator(fromPush); @@ -84,21 +99,20 @@ export class Pull { const success = totalFailed === 0; - // Use the orchestrator summary function to handle all completion logic - const logger = getLogger(); - if (logger) { - // Collect log file paths - const logFilePaths = results - .map(res => res.logFilePath) - .filter(path => path); - - logger.orchestratorSummary(results, totalElapsedTime, success, logFilePaths); - } - - finalizeLogger(); // Finalize global logger if it exists - - // Only exit if not called from push operation + // Only show completion summary and finalize logger for standalone pull operations + // When called from push/sync, the parent operation handles its own summary if (!fromPush) { + const logger = getLogger(); + if (logger) { + // Collect log file paths + const logFilePaths = results + .map(res => res.logFilePath) + .filter(path => path); + + logger.orchestratorSummary(results, totalElapsedTime, success, logFilePaths); + } + + finalizeLogger(); // Finalize global logger if it exists process.exit(success ? 0 : 1); } diff --git a/src/core/push.ts b/src/core/push.ts index 1b8ad6b..6e12a30 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -1,6 +1,6 @@ import * as path from "path"; import * as fs from "fs"; -import { getState, initializeLogger, finalizeLogger, getLogger, state } from "./state"; +import { getState, initializeLogger, finalizeLogger, getLogger, state, setState } from "./state"; import ansiColors from "ansi-colors"; import { markPushStart, clearTimestamps } from "../lib/incremental"; @@ -16,7 +16,7 @@ export class Push { } async pushInstances(fromSync: boolean = false): Promise<{ success: boolean; results: any[]; elapsedTime: number }> { - const { isSync, sourceGuid, targetGuid, models, modelsWithDeps } = state; + const { isSync, sourceGuid, targetGuid, models, modelsWithDeps, autoPublish } = state; // Initialize logger for push operation // Determine if this is a sync operation by checking if both source and target GUIDs exist @@ -107,6 +107,11 @@ export class Push { } finalizeLogger(); // Finalize global logger if it exists + + // Auto-publish if enabled and sync was successful + if (isSync && autoPublish && success) { + await this.executeAutoPublish(results, autoPublish); + } // Only exit if not called from another operation @@ -127,6 +132,61 @@ export class Push { } } + /** + * Execute auto-publish after sync completes + */ + private async executeAutoPublish(results: PushResults[], autoPublishMode: string): Promise { + // Collect all publishable IDs from sync results + const allContentIds: number[] = []; + const allPageIds: number[] = []; + + for (const result of results) { + if (result.publishableContentIds && result.publishableContentIds.length > 0) { + allContentIds.push(...result.publishableContentIds); + } + if (result.publishablePageIds && result.publishablePageIds.length > 0) { + allPageIds.push(...result.publishablePageIds); + } + } + + // Determine what to publish based on mode + const publishContent = autoPublishMode === 'content' || autoPublishMode === 'both'; + const publishPages = autoPublishMode === 'pages' || autoPublishMode === 'both'; + + const contentIdsToPublish = publishContent ? allContentIds : []; + const pageIdsToPublish = publishPages ? allPageIds : []; + + // Check if there's anything to publish + if (contentIdsToPublish.length === 0 && pageIdsToPublish.length === 0) { + console.log(ansiColors.yellow('\nāš ļø Auto-publish: No items to publish from sync operation')); + return; + } + + console.log(ansiColors.cyan('\n' + '═'.repeat(50))); + console.log(ansiColors.cyan('šŸš€ AUTO-PUBLISH')); + console.log(ansiColors.cyan('═'.repeat(50))); + console.log(ansiColors.gray(`Mode: ${autoPublishMode}`)); + console.log(ansiColors.gray(`Content items to publish: ${contentIdsToPublish.length}`)); + console.log(ansiColors.gray(`Pages to publish: ${pageIdsToPublish.length}`)); + + try { + // Set explicit IDs in state for the workflow operation + setState({ + explicitContentIDs: contentIdsToPublish, + explicitPageIDs: pageIdsToPublish, + operationType: 'publish' + }); + + // Import and execute workflow operation + const { WorkflowOperation } = await import('../lib/workflows'); + const workflowOp = new WorkflowOperation(); + await workflowOp.executeFromMappings(); + + } catch (error: any) { + console.error(ansiColors.red(`\nāŒ Auto-publish failed: ${error.message}`)); + } + } + private async handleResetFlag(guid: string): Promise { const state = getState(); const guidFolderPath = path.join(process.cwd(), state.rootPath, guid); diff --git a/src/core/state.ts b/src/core/state.ts index 45dc6af..8adb934 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -7,6 +7,7 @@ import * as mgmtApi from '@agility/management-sdk'; import fs from 'fs'; import path from 'path'; import { Logs, OperationType, EntityType } from './logs'; +import { Options } from '@agility/management-sdk'; export interface State { // Environment modes @@ -45,8 +46,14 @@ export interface State { reset: boolean; update: boolean; - // Publishing control - publish: boolean; + // Workflow operation control + operationType?: string; // Workflow operation: publish, unpublish, approve, decline, requestApproval + dryRun: boolean; // Preview mode - show what would be processed without executing + autoPublish: string; // Auto-publish after sync: 'content', 'pages', 'both', or '' (disabled) + + // Explicit ID overrides (bypass mappings lookup) + explicitContentIDs: number[]; // Target content IDs to process directly + explicitPageIDs: number[]; // Target page IDs to process directly // Model-specific models: string; @@ -127,9 +134,12 @@ export const state: State = { force: false, reset: false, update: true, + dryRun: false, + autoPublish: '', // Empty string = disabled - // Publishing control - publish: false, + // Explicit ID overrides (bypass mappings lookup) + explicitContentIDs: [], + explicitPageIDs: [], // Model-specific models: "", @@ -230,8 +240,23 @@ export function setState(argv: any) { if (argv.reset !== undefined) state.reset = argv.reset; if (argv.update !== undefined) state.update = argv.update; - // Publishing control - if (argv.publish !== undefined) state.publish = argv.publish; + // Workflow operation control + if (argv.operationType !== undefined) state.operationType = argv.operationType; + if (argv.dryRun !== undefined) state.dryRun = argv.dryRun; + + // Explicit ID overrides - parse comma-separated strings into number arrays + if (argv.contentIDs !== undefined && argv.contentIDs !== "") { + state.explicitContentIDs = String(argv.contentIDs) + .split(',') + .map((id: string) => parseInt(id.trim(), 10)) + .filter((id: number) => !isNaN(id) && id > 0); + } + if (argv.pageIDs !== undefined && argv.pageIDs !== "") { + state.explicitPageIDs = String(argv.pageIDs) + .split(',') + .map((id: string) => parseInt(id.trim(), 10)) + .filter((id: number) => !isNaN(id) && id > 0); + } // Model-specific if (argv.models !== undefined) state.models = argv.models; @@ -450,8 +475,13 @@ export function resetState() { state.reset = false; state.update = true; - // Publishing control - state.publish = false; + // Workflow operation control + state.operationType = undefined; + state.dryRun = false; + + // Explicit ID overrides + state.explicitContentIDs = []; + state.explicitPageIDs = []; // Model-specific state.models = ""; @@ -497,9 +527,15 @@ export function getApiClient(): mgmtApi.ApiClient { // Create new client using current auth state if (!state.mgmtApiOptions) { - throw new Error('Management API options not initialized. Call auth.init() first.'); + // throw new Error('Management API options not initialized. Call auth.init() first.'); } + if(!state.mgmtApiOptions && !state.token) { + throw new Error('Management API options not initialized. Call auth.init() first.'); + } else if (!state.mgmtApiOptions && state.token) { + state.mgmtApiOptions = new Options(); + state.mgmtApiOptions.token = state.token; + } // Create and cache the client state.cachedApiClient = new mgmtApi.ApiClient(state.mgmtApiOptions); return state.cachedApiClient; diff --git a/src/core/system-args.ts b/src/core/system-args.ts index 359c3b3..c9c2f7a 100644 --- a/src/core/system-args.ts +++ b/src/core/system-args.ts @@ -127,6 +127,29 @@ export const systemArgs = { type: "boolean" as const, default: false, }, + dryRun: { + describe: "Dry run mode: show what items would be processed without executing the operation. Useful for previewing workflow operations.", + demandOption: false, + type: "boolean" as const, + alias: ["dry-run", "dryrun", "DryRun", "DRY_RUN"], + default: false, + }, + + // **Explicit ID Override for Workflow Operations** + contentIDs: { + describe: "Comma-separated list of target content IDs to process. Bypasses mappings lookup when provided (e.g., --contentIDs=121,1221,345).", + demandOption: false, + alias: ["content-ids", "contentIds", "ContentIDs", "CONTENTIDS"], + type: "string" as const, + default: "", + }, + pageIDs: { + describe: "Comma-separated list of target page IDs to process. Bypasses mappings lookup when provided (e.g., --pageIDs=12,11,45).", + demandOption: false, + alias: ["page-ids", "pageIds", "PageIDs", "PAGEIDS"], + type: "string" as const, + default: "", + }, // Instance identification args sourceGuid: { @@ -167,12 +190,20 @@ export const systemArgs = { default: false }, - // Publishing args - publish: { - describe: "For sync commands only: automatically publish synced content items and pages after successful sync operation. Enables batch publishing for streamlined deployment workflow. Default: false.", - type: "boolean" as const, - alias: ["publish", "Publish", "PUBLISH"], - default: false + // Auto-publish after sync + autoPublish: { + describe: "Automatically publish content and/or pages after sync completes. Options: 'content' (publish only content), 'pages' (publish only pages), 'both' (publish content and pages). Default: both when flag is provided.", + demandOption: false, + alias: ["auto-publish", "autoPublish", "AutoPublish", "AUTO_PUBLISH"], + type: "string" as const, + coerce: (value: string | boolean) => { + // Handle --autoPublish without value (defaults to 'both') + if (value === true || value === '') return 'both'; + if (value === false) return ''; + const lower = String(value).toLowerCase(); + if (['content', 'pages', 'both'].includes(lower)) return lower; + return 'both'; // Default to 'both' for any other value + } }, }; @@ -190,11 +221,12 @@ export interface SystemArgs { sync?: boolean; clean?: boolean; generate?: boolean; - publish?: boolean; + operationType?: string; // Workflow operation: publish, unpublish, approve, decline, requestApproval test?: boolean; + dryRun?: boolean; // Preview mode - show what would be processed without executing verbose?: boolean; overwrite?: boolean; - force?: boolean; // New: Override target safety conflicts + force?: boolean; // Override target safety conflicts update?: boolean; legacyFolders?: boolean; elements?: string; @@ -205,4 +237,6 @@ export interface SystemArgs { channel?: string; preview?: boolean; rootPath?: string; + contentIDs?: string; // Explicit content IDs (bypasses mappings) + pageIDs?: string; // Explicit page IDs (bypasses mappings) } diff --git a/src/index.ts b/src/index.ts index 656f8da..1d19e85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ inquirer.registerPrompt("search-list", searchList); import { Auth, state, setState, resetState, primeFromEnv, systemArgs, normalizeProcessArgs, normalizeArgv } from "./core"; import { Pull } from "./core/pull"; import { Push } from "./core/push"; +import { WorkflowOperation } from "./lib/workflows"; import { initializeLogger, getLogger, finalizeLogger, finalizeAllGuidLoggers } from "./core/state"; @@ -38,9 +39,10 @@ yargs.command({ describe: "Default command - shows available commands", handler: function () { console.log(colors.cyan("\nAvailable commands:")); - console.log(colors.white(" pull - Pull your Agility instance locally")); - console.log(colors.white(" push - Push your instance to a target instance")); - console.log(colors.white(" sync - Sync your instance (alias for push with updates enabled)")); + console.log(colors.white(" pull - Pull your Agility instance locally")); + console.log(colors.white(" push - Push your instance to a target instance")); + console.log(colors.white(" sync - Sync your instance (alias for push with updates enabled)")); + console.log(colors.white(" workflowOperation - Perform workflow operations (publish, unpublish, approve, decline)")); console.log(colors.white("\nFor more information, use: --help")); console.log(""); }, @@ -206,6 +208,106 @@ yargs.command({ } }) +// Workflow operation command - performs workflow operations on content/pages from existing mappings +yargs.command({ + command: "workflows", + aliases: ["workflow"], + describe: "Perform workflow operations (publish, unpublish, approve, decline, requestApproval) on content and pages from existing mappings.", + builder: { + sourceGuid: { + describe: "Source instance GUID (from the original sync).", + demandOption: true, + type: "string", + }, + targetGuid: { + describe: "Target instance GUID to perform workflow operation on.", + demandOption: true, + type: "string", + }, + list: { + describe: "List available mapping pairs instead of running operation.", + type: "boolean", + default: false, + }, + // Workflow operation type for batch workflow operations + operationType: { + describe: "Workflow operation to perform: publish, unpublish, approve, decline, or requestApproval. Used with workflowOperation command.", + type: "string" as const, + alias: ["operation-type", "operationType", "OperationType", "OPERATION_TYPE", "op","type"], + choices: ["publish", "unpublish", "approve", "decline", "requestApproval"], + // default: "publish", + coerce: (value: string) => { + if (!value) return "publish"; + const lower = String(value).toLowerCase(); + // Normalize various input formats + switch (lower) { + case "publish": + case "pub": + return "publish"; + case "unpublish": + case "unpub": + return "unpublish"; + case "approve": + case "app": + return "approve"; + case "decline": + case "dec": + return "decline"; + case "requestapproval": + case "request-approval": + case "request_approval": + case "req": + return "requestApproval"; + default: + return "publish"; + } + } + }, + // System args (commonly repeated across commands) + ...systemArgs + }, + handler: async function (argv) { + resetState(); // Clear any previous command state + + // Normalize argv to handle rich text editor character conversions + argv = normalizeArgv(argv); + + // Prime state from .env file before applying command line args + const envPriming = primeFromEnv(); + if (envPriming.hasEnvFile && envPriming.primedValues.length > 0) { + console.log(colors.cyan(`šŸ“„ Found .env file, primed: ${envPriming.primedValues.join(', ')}`)); + } + + setState(argv); + + // If --list flag, just list available mappings + if (argv.list) { + const workflowOp = new WorkflowOperation(); + workflowOp.listMappings(); + return; + } + + auth = new Auth(); + const isAuthorized = await auth.init(); + if (!isAuthorized) { + return; + } + + // Validate command requirements + const isValidCommand = await auth.validateCommand('push'); + if (!isValidCommand) { + return; + } + + const workflowOp = new WorkflowOperation(); + const result = await workflowOp.executeFromMappings(); + + if (!result.success) { + process.exit(1); + } + } +}) + // Normalize process.argv to handle rich text editor character conversions // (e.g., em dashes, curly quotes from Word/Notepad) normalizeProcessArgs(); diff --git a/src/lib/mappers/content-item-mapper.ts b/src/lib/mappers/content-item-mapper.ts index 853fb1b..a949d0d 100644 --- a/src/lib/mappers/content-item-mapper.ts +++ b/src/lib/mappers/content-item-mapper.ts @@ -138,5 +138,33 @@ export class ContentItemMapper { return targetContentItem.properties.versionID > mapping.targetVersionID; } + /** + * Update only the target versionID in a mapping (used after publishing) + * Does NOT update sourceVersionID - that should only change during sync operations + * + * @returns Object with success status and old/new version IDs + */ + updateTargetVersionID(targetContentID: number, newVersionID: number): { + success: boolean; + oldVersionID?: number; + newVersionID?: number; + } { + const mapping = this.getContentItemMappingByContentID(targetContentID, 'target'); + if (!mapping) return { success: false }; + + const oldVersionID = mapping.targetVersionID; + + // Only update if version actually changed + if (oldVersionID !== newVersionID) { + mapping.targetVersionID = newVersionID; + this.saveMapping(); + } + + return { + success: true, + oldVersionID, + newVersionID + }; + } } \ No newline at end of file diff --git a/src/lib/mappers/mapping-reader.ts b/src/lib/mappers/mapping-reader.ts new file mode 100644 index 0000000..fc92f36 --- /dev/null +++ b/src/lib/mappers/mapping-reader.ts @@ -0,0 +1,143 @@ +/** + * Mapping Reader Utility + * + * Reads content and page mappings from the file system to extract target IDs + * for workflow operations. Uses fileOperations for consistent filesystem access. + */ + +import { fileOperations } from '../../core'; +import { state } from '../../core/state'; +import { ContentMapping, PageMapping, MappingReadResult } from '../../types'; + +// Re-export types for convenience +export { ContentMapping, PageMapping, MappingReadResult }; + +/** + * Read all mappings for a source/target GUID pair across all locales + * Uses fileOperations for consistent filesystem access + */ +export function readMappingsForGuidPair( + sourceGuid: string, + targetGuid: string, + locales: string[] +): MappingReadResult { + const result: MappingReadResult = { + contentIds: [], + pageIds: [], + contentMappings: [], + pageMappings: [], + errors: [] + }; + + for (const locale of locales) { + // Use fileOperations for consistent access + const fileOps = new fileOperations(targetGuid, locale); + + // Read content mappings using fileOperations getMappingFile + const contentMappings = fileOps.getMappingFile('item', sourceGuid, targetGuid, locale); + if (contentMappings && contentMappings.length > 0) { + result.contentMappings.push(...contentMappings as ContentMapping[]); + result.contentIds.push(...contentMappings.map((m: ContentMapping) => m.targetContentID)); + } + + // Read page mappings using fileOperations getMappingFile + const pageMappings = fileOps.getMappingFile('page', sourceGuid, targetGuid, locale); + if (pageMappings && pageMappings.length > 0) { + result.pageMappings.push(...pageMappings as PageMapping[]); + result.pageIds.push(...pageMappings.map((m: PageMapping) => m.targetPageID)); + } + } + + // Deduplicate IDs (same content/page might appear in multiple locales) + result.contentIds = Array.from(new Set(result.contentIds)); + result.pageIds = Array.from(new Set(result.pageIds)); + + return result; +} + +/** + * List available mapping directories to discover source/target pairs + * Uses fileOperations for consistent filesystem access + */ +export function listAvailableMappingPairs(): Array<{ sourceGuid: string; targetGuid: string; locales: string[] }> { + const fileOps = new fileOperations('', ''); + const mappingsDir = fileOps.getMappingFilePath('', ''); + + // Get the root mappings folder from state + const rootMappingsPath = `${state.rootPath}/mappings`; + + if (!fileOps.fileExists(rootMappingsPath)) { + return []; + } + + const pairs: Array<{ sourceGuid: string; targetGuid: string; locales: string[] }> = []; + + try { + const dirs = fileOps.getFolderContents(rootMappingsPath); + + for (const dir of dirs) { + // Directory format: {sourceGuid}-{targetGuid} + const fullPath = `${rootMappingsPath}/${dir}`; + + // Find locales in this directory + const locales: string[] = []; + try { + const contents = fileOps.getFolderContents(fullPath); + + for (const item of contents) { + // Check if it looks like a locale (e.g., en-us, es-us) + if (/^[a-z]{2}-[a-z]{2}$/i.test(item)) { + locales.push(item); + } + } + } catch (e) { + // Skip directories we can't read + continue; + } + + if (locales.length > 0) { + // Parse the directory name to extract source and target GUIDs + // Format: {sourceGuid}-{targetGuid} where GUIDs are like "c39c63bd-us2" + const guidPattern = /^([a-zA-Z0-9]+-[a-zA-Z0-9]+)-([a-zA-Z0-9]+-[a-zA-Z0-9]+)$/; + const match = dir.match(guidPattern); + + if (match) { + pairs.push({ + sourceGuid: match[1], + targetGuid: match[2], + locales + }); + } + } + } + } catch (error: any) { + console.error(`Error listing mapping directories: ${error.message}`); + } + + return pairs; +} + +/** + * Get mapping summary for display + */ +export function getMappingSummary( + sourceGuid: string, + targetGuid: string, + locales: string[] +): { totalContent: number; totalPages: number; localesFound: string[] } { + const result = readMappingsForGuidPair(sourceGuid, targetGuid, locales); + const fileOps = new fileOperations('', ''); + + const localesFound = locales.filter(locale => { + const ops = new fileOperations(targetGuid, locale); + const contentMappings = ops.getMappingFile('item', sourceGuid, targetGuid, locale); + const pageMappings = ops.getMappingFile('page', sourceGuid, targetGuid, locale); + return (contentMappings && contentMappings.length > 0) || (pageMappings && pageMappings.length > 0); + }); + + return { + totalContent: result.contentIds.length, + totalPages: result.pageIds.length, + localesFound + }; +} diff --git a/src/lib/mappers/mapping-version-updater.ts b/src/lib/mappers/mapping-version-updater.ts new file mode 100644 index 0000000..3f187be --- /dev/null +++ b/src/lib/mappers/mapping-version-updater.ts @@ -0,0 +1,309 @@ +/** + * Mapping Version Updater + * + * After publishing, updates the mappings with the new versionIDs + * by reading the refreshed data from the filesystem using fileOperations. + */ + +import { fileOperations } from '../../core'; +import { getLogger } from '../../core/state'; +import { ContentItemMapper } from './content-item-mapper'; +import { PageMapper } from './page-mapper'; +import { getContentItemsFromFileSystem } from '../getters/filesystem/get-content-items'; +import { getPagesFromFileSystem } from '../getters/filesystem/get-pages'; +import ansiColors from 'ansi-colors'; +import { MappingUpdateResult } from '../../types'; + +// Re-export type for convenience +export { MappingUpdateResult }; + +/** + * Version change detail for logging + */ +export interface VersionChangeDetail { + id: number; + oldVersion: number; + newVersion: number; + changed: boolean; + name?: string; // Content title/name or page title + refName?: string; // Content referenceName or page path + modelName?: string; // Content model (definitionName) +} + +/** + * Helper to log to both logger and capture lines + */ +function logLine(line: string, logLines: string[]): void { + const logger = getLogger(); + if (logger) { + logger.info(line); + } else { + console.log(line); + } + logLines.push(line); +} + +/** + * Update content item mappings with new targetVersionID after publishing + * Only updates targetVersionID - sourceVersionID should only change during sync operations + */ +export async function updateContentMappingsAfterPublish( + publishedContentIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string +): Promise<{ updated: number; errors: string[]; changes: VersionChangeDetail[] }> { + const errors: string[] = []; + const changes: VersionChangeDetail[] = []; + let updated = 0; + + // Deduplicate IDs - API may return duplicates for nested content + const uniqueContentIds = Array.from(new Set(publishedContentIds)); + + if (uniqueContentIds.length === 0) { + return { updated: 0, errors: [], changes: [] }; + } + + try { + // Create file operations for target (we only need target data for versionID) + const targetFileOps = new fileOperations(targetGuid, locale); + + // Load content items from target filesystem (refreshed after pull) + const targetContentItems = getContentItemsFromFileSystem(targetFileOps); + + // Create content item mapper + const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); + + // Create lookup map for quick access + const targetContentMap = new Map( + targetContentItems.map(item => [item.contentID, item]) + ); + + // Update targetVersionID for each published content item + for (const targetContentId of uniqueContentIds) { + const targetItem = targetContentMap.get(targetContentId); + if (!targetItem) { + errors.push(`Target content item ${targetContentId} not found in filesystem`); + continue; + } + + // Update only the target versionID in the mapping + const result = contentMapper.updateTargetVersionID( + targetContentId, + targetItem.properties.versionID + ); + + if (result.success) { + updated++; + // Track all version updates with display info + changes.push({ + id: targetContentId, + oldVersion: result.oldVersionID!, + newVersion: result.newVersionID!, + changed: result.oldVersionID !== result.newVersionID, + name: targetItem.fields?.title || targetItem.fields?.name || `Item ${targetContentId}`, + refName: targetItem.properties?.referenceName, + modelName: targetItem.properties?.definitionName + }); + } else { + errors.push(`No mapping found for target content ID ${targetContentId}`); + } + } + + return { updated, errors, changes }; + } catch (error: any) { + errors.push(`Content mapping update failed: ${error.message}`); + return { updated, errors, changes: [] }; + } +} + +/** + * Update page mappings with new targetVersionID after publishing + * Only updates targetVersionID - sourceVersionID should only change during sync operations + */ +export async function updatePageMappingsAfterPublish( + publishedPageIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string +): Promise<{ updated: number; errors: string[]; changes: VersionChangeDetail[] }> { + const errors: string[] = []; + const changes: VersionChangeDetail[] = []; + let updated = 0; + + // Deduplicate IDs - API may return duplicates + const uniquePageIds = Array.from(new Set(publishedPageIds)); + + if (uniquePageIds.length === 0) { + return { updated: 0, errors: [], changes: [] }; + } + + try { + // Create file operations for target (we only need target data for versionID) + const targetFileOps = new fileOperations(targetGuid, locale); + + // Load pages from target filesystem (refreshed after pull) + const targetPages = getPagesFromFileSystem(targetFileOps); + + // Create page mapper + const pageMapper = new PageMapper(sourceGuid, targetGuid, locale); + + // Create lookup map for quick access + const targetPageMap = new Map( + targetPages.map(page => [page.pageID, page]) + ); + + // Update targetVersionID for each published page + for (const targetPageId of uniquePageIds) { + const targetPage = targetPageMap.get(targetPageId); + if (!targetPage) { + errors.push(`Target page ${targetPageId} not found in filesystem`); + continue; + } + + // Update only the target versionID in the mapping + const result = pageMapper.updateTargetVersionID( + targetPageId, + targetPage.properties.versionID + ); + + if (result.success) { + updated++; + // Track all version updates with display info + changes.push({ + id: targetPageId, + oldVersion: result.oldVersionID!, + newVersion: result.newVersionID!, + changed: result.oldVersionID !== result.newVersionID, + name: targetPage.title || targetPage.name || `Page ${targetPageId}`, + refName: targetPage.name ? `/${targetPage.name}` : undefined + }); + } else { + errors.push(`No mapping found for target page ID ${targetPageId}`); + } + } + + return { updated, errors, changes }; + } catch (error: any) { + errors.push(`Page mapping update failed: ${error.message}`); + return { updated, errors, changes: [] }; + } +} + +/** + * Format version change for display + * Format: ā— [guid][locale] content ID: {id} - Name (Type) v1565 → v1593 mapping updated + */ +function formatVersionChange( + change: VersionChangeDetail, + entityType: string, + targetGuid: string, + locale: string +): string { + const symbol = change.changed ? ansiColors.green('ā—') : ansiColors.yellow('ā—‹'); + const guidDisplay = change.changed ? ansiColors.green(`[${targetGuid}]`) : ansiColors.yellow(`[${targetGuid}]`); + const localeDisplay = ansiColors.gray(`[${locale}]`); + const entityDisplay = ansiColors.white(entityType); + const idDisplay = ansiColors.cyan.underline(String(change.id)); + const nameDisplay = ansiColors.white(change.name || ''); + + // Build the type display (model name for content, path for pages) + let typeDisplay = ''; + if (change.modelName) { + typeDisplay = ansiColors.gray(` (${change.modelName})`); + } else if (change.refName) { + typeDisplay = ansiColors.gray(` (${change.refName})`); + } + + if (change.changed) { + const versionDisplay = ansiColors.gray(`v${change.oldVersion} → v${change.newVersion}`); + const action = ansiColors.green('mapping updated'); + // Format: ā— [guid][locale] content ID: {id} - Name (Type) v1565 → v1593 mapping updated + return `${symbol} ${guidDisplay}${localeDisplay} ${entityDisplay} ID: ${idDisplay} - ${nameDisplay}${typeDisplay} ${versionDisplay} ${action}`; + } else { + const versionDisplay = ansiColors.gray(`v${change.newVersion}`); + return `${symbol} ${guidDisplay}${localeDisplay} ${entityDisplay} ID: ${idDisplay} - ${nameDisplay}${typeDisplay} ${versionDisplay} ${ansiColors.gray('unchanged')}`; + } +} + +/** + * Display version changes with summary and full details + * Returns formatted lines for logging + */ +function displayVersionChanges( + label: string, + entityType: string, + changes: VersionChangeDetail[], + totalUpdated: number, + targetGuid: string, + locale: string, + logLines: string[] +): void { + if (changes.length === 0) return; + + // Show all items using the logger + changes.forEach(change => { + const line = formatVersionChange(change, entityType, targetGuid, locale); + logLine(line, logLines); + }); +} + +/** + * Update all mappings after publishing + * Returns result and log lines for the logger + */ +export async function updateMappingsAfterPublish( + publishedContentIds: number[], + publishedPageIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string +): Promise<{ result: MappingUpdateResult; logLines: string[] }> { + const logLines: string[] = []; + + logLine(ansiColors.cyan('\nUpdating mappings with new version IDs...'), logLines); + + const result: MappingUpdateResult = { + contentMappingsUpdated: 0, + pageMappingsUpdated: 0, + errors: [] + }; + + // Update content mappings + if (publishedContentIds.length > 0) { + const contentResult = await updateContentMappingsAfterPublish( + publishedContentIds, + sourceGuid, + targetGuid, + locale + ); + result.contentMappingsUpdated = contentResult.updated; + result.errors.push(...contentResult.errors); + + displayVersionChanges('content item', 'content', contentResult.changes, contentResult.updated, targetGuid, locale, logLines); + } + + // Update page mappings + if (publishedPageIds.length > 0) { + const pageResult = await updatePageMappingsAfterPublish( + publishedPageIds, + sourceGuid, + targetGuid, + locale + ); + result.pageMappingsUpdated = pageResult.updated; + result.errors.push(...pageResult.errors); + + displayVersionChanges('page', 'page', pageResult.changes, pageResult.updated, targetGuid, locale, logLines); + } + + // Summary line + logLine(ansiColors.green(`āœ“ Mappings updated: ${result.contentMappingsUpdated} content, ${result.pageMappingsUpdated} pages`), logLines); + + // Report any errors + if (result.errors.length > 0) { + logLine(ansiColors.yellow(` āš ļø ${result.errors.length} mapping update errors (see logs)`), logLines); + } + + return { result, logLines }; +} diff --git a/src/lib/mappers/page-mapper.ts b/src/lib/mappers/page-mapper.ts index fdfd0d1..e36a612 100644 --- a/src/lib/mappers/page-mapper.ts +++ b/src/lib/mappers/page-mapper.ts @@ -128,5 +128,33 @@ export class PageMapper { return targetPage.properties.versionID > mapping.targetVersionID; } + /** + * Update only the target versionID in a mapping (used after publishing) + * Does NOT update sourceVersionID - that should only change during sync operations + * + * @returns Object with success status and old/new version IDs + */ + updateTargetVersionID(targetPageID: number, newVersionID: number): { + success: boolean; + oldVersionID?: number; + newVersionID?: number; + } { + const mapping = this.getPageMappingByPageID(targetPageID, 'target'); + if (!mapping) return { success: false }; + + const oldVersionID = mapping.targetVersionID; + + // Only update if version actually changed + if (oldVersionID !== newVersionID) { + mapping.targetVersionID = newVersionID; + this.saveMapping(); + } + + return { + success: true, + oldVersionID, + newVersionID + }; + } } \ No newline at end of file diff --git a/src/lib/publishers/index.ts b/src/lib/publishers/index.ts index db07779..507bd86 100644 --- a/src/lib/publishers/index.ts +++ b/src/lib/publishers/index.ts @@ -3,10 +3,63 @@ * * This module provides simple publisher functions that mirror the SDK patterns exactly. * These functions are lightweight wrappers around the Management SDK publishing methods. + * + * NOTE: Batch workflow operations have been consolidated into src/core/batch-workflows.ts + * The exports below are re-exported from their new locations. */ // Simple publisher functions - mirror SDK patterns export { publishContentItem } from './content-item-publisher'; export { publishPage } from './page-publisher'; export { publishContentList } from './content-list-publisher'; -export { publishBatch } from './batch-publisher'; \ No newline at end of file +export { publishBatch } from './batch-publisher'; + +// Re-export from consolidated batch-workflows service in core +export { + batchWorkflow, + type BatchItemType, + createBatches +} from '../../core/batch-workflows'; + +// Re-export workflow module +export { + workflowOrchestrator, + parseWorkflowOptions, + getOperationName +} from '../workflows'; + +// Re-export all workflow types from central types folder +export { + WorkflowOperationType, + BatchWorkflowResult, + WorkflowOrchestratorResult, + WorkflowOptions, + ContentMapping, + PageMapping, + MappingReadResult, + MappingUpdateResult, + ItemState, + SourceItemData, + PublishStatusResult +} from '../../types'; + +// Re-export mapping utilities from mappers (moved from publishers) +export { + updateMappingsAfterPublish, + updateContentMappingsAfterPublish, + updatePageMappingsAfterPublish +} from '../mappers/mapping-version-updater'; + +export { + readMappingsForGuidPair, + listAvailableMappingPairs, + getMappingSummary +} from '../mappers/mapping-reader'; + +// Re-export source publish status checker functions from shared (moved from publishers) +export { + checkSourcePublishStatus, + filterPublishedContent, + filterPublishedPages, + isPublished +} from '../shared/source-publish-status-checker'; \ No newline at end of file diff --git a/src/lib/pushers/content-pusher/util/types.ts b/src/lib/pushers/content-pusher/util/types.ts index 1e7c317..f91f234 100644 --- a/src/lib/pushers/content-pusher/util/types.ts +++ b/src/lib/pushers/content-pusher/util/types.ts @@ -26,7 +26,7 @@ export interface BatchProcessingResult { skippedCount: number; // Number of items skipped due to existing content successfulItems: BatchSuccessItem[]; failedItems: BatchFailedItem[]; - publishableIds: number[]; // Target content IDs for auto-publishing + publishableIds: number[]; // Target content IDs for workflow operations } /** diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 384a9bd..6913f87 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -46,14 +46,17 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // TODO: we only care about the field count if the target model has NO fields and the source model has fields - // special case for the default RichTextArea model - const defaultRichTextArea = model.referenceName === 'RichTextArea' && !mapping && targetModel; - if(defaultRichTextArea){ - // force create the mapping for the default RichTextArea model + // Handle models that exist in target but have no mapping + // This ensures downstream containers can find their model mappings + const existsInTargetWithoutMapping = !mapping && targetModel; + if (existsInTargetWithoutMapping) { + // Create the mapping for existing target models (ensures containers can reference them) referenceMapper.addMapping(model, targetModel); + // Add to skip list since model already exists and is up to date + shouldSkip.push(model); + continue; // Skip remaining conditions - mapping is now created, no further action needed } - if ((!mapping && !targetModel)) { shouldCreateStub.push(model); } @@ -64,7 +67,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp shouldUpdateFields.push(model); } // if the mapping exists, and the target has changed, we need to skip the model, not safe to update - if ((mapping && hasTargetChanged) || defaultRichTextArea) { + if (mapping && hasTargetChanged) { shouldSkip.push(model); } // if the mapping exists, and the source and target have not changed, we need to skip the model diff --git a/src/lib/pushers/orchestrate-pushers.ts b/src/lib/pushers/orchestrate-pushers.ts index a849ca4..6293073 100644 --- a/src/lib/pushers/orchestrate-pushers.ts +++ b/src/lib/pushers/orchestrate-pushers.ts @@ -300,7 +300,7 @@ export class Pushers { totalSkipped += pusherResult.skipped || 0; totalFailures += pusherResult.failed || 0; - // Collect publishable IDs for auto-publishing + // Collect publishable IDs for workflow operations if (pusherResult.publishableIds && pusherResult.publishableIds.length > 0) { if (config.elements.includes("Content")) { publishableContentIds.push(...pusherResult.publishableIds); diff --git a/src/lib/pushers/page-pusher/push-pages.ts b/src/lib/pushers/page-pusher/push-pages.ts index 468c9ce..148b3f7 100644 --- a/src/lib/pushers/page-pusher/push-pages.ts +++ b/src/lib/pushers/page-pusher/push-pages.ts @@ -36,7 +36,7 @@ export async function pushPages( let failed = 0; let skipped = 0; // No duplicates to skip since API prevents true duplicates at same hierarchy level let status: "success" | "error" = "success"; - let publishableIds: number[] = []; // Track target page IDs for auto-publishing + let publishableIds: number[] = []; // Track target page IDs for workflow operations //loop all the channels diff --git a/src/lib/pushers/template-pusher.ts b/src/lib/pushers/template-pusher.ts index 8ef39c8..1a324d2 100644 --- a/src/lib/pushers/template-pusher.ts +++ b/src/lib/pushers/template-pusher.ts @@ -49,13 +49,26 @@ export async function pushTemplates( const { sourceGuid, targetGuid } = state; const referenceMapper = new TemplateMapper(sourceGuid[0], targetGuid[0]); - const existingMapping = referenceMapper.getTemplateMapping(template, "source"); + let existingMapping = referenceMapper.getTemplateMapping(template, "source"); let targetTemplate = targetData.find(targetTemplate => targetTemplate.pageTemplateID === existingMapping?.targetPageTemplateID) || null; if (!targetTemplate) { // Try to get the template via the mapper targetTemplate = referenceMapper.getMappedEntity(existingMapping, "target"); } + // Handle templates that exist in target but have no mapping (match by name) + // This ensures downstream pages can find their template mappings + if (!existingMapping && !targetTemplate) { + targetTemplate = targetData.find(t => t.pageTemplateName === template.pageTemplateName) || null; + if (targetTemplate) { + // Create the mapping for existing target template + referenceMapper.addMapping(template, targetTemplate); + logger.template.skipped(template, "exists in target, mapping created", targetGuid[0]); + skipped++; + processedCount++; + continue; // Skip to next template - mapping is now created + } + } const isTargetSafe = existingMapping !== null && referenceMapper.hasTargetChanged(targetTemplate); const hasSourceChanges = existingMapping !== null && referenceMapper.hasSourceChanged(template); @@ -86,12 +99,12 @@ export async function pushTemplates( if (def.contentDefinitionID) { const modelMappers = new ModelMapper(sourceGuid[0], targetGuid[0]); - const modelMapping = modelMappers.getModelMappingByID(def.contentDefinitionID, 'target'); + const modelMapping = modelMappers.getModelMappingByID(def.contentDefinitionID, 'source'); if (modelMapping?.targetID) mappedDef.contentDefinitionID = modelMapping.targetID; } if (def.itemContainerID) { const containerMappers = new ContainerMapper(sourceGuid[0], targetGuid[0]); - const containerMapping = containerMappers.getContainerMappingByContentViewID(def.itemContainerID, 'target'); + const containerMapping = containerMappers.getContainerMappingByContentViewID(def.itemContainerID, 'source'); if (containerMapping?.targetContentViewID) mappedDef.itemContainerID = containerMapping.targetContentViewID; } // if (def.publishContentItemID) { diff --git a/src/lib/shared/get-fetch-api-status.ts b/src/lib/shared/get-fetch-api-status.ts new file mode 100644 index 0000000..88957a9 --- /dev/null +++ b/src/lib/shared/get-fetch-api-status.ts @@ -0,0 +1,88 @@ +/** + * Fetch API Status Checker + * + * Checks if the Fetch API CDN sync is complete for an instance. + * Used before pull operations and after publishing to ensure + * changes have propagated to the CDN. + */ + +import * as mgmtApi from '@agility/management-sdk'; +import { getApiClient } from '../../core/state'; +import ansiColors from 'ansi-colors'; + +export type FetchApiSyncMode = 'fetch' | 'preview'; + +export interface FetchApiStatus { + timestamp?: string; + completionTime?: string; + errorMessage?: string; + inProgress: boolean; + itemsAffected: number; + lastContentVersionID: number; + lastDeletedContentVersionID: number; + lastDeletedPageVersionID: number; + leaseID?: string; + maxChangeDate?: string; + maxContentModelDate?: string; + pushType: number; + startTime?: string; + websiteName?: string; +} + +/** + * Get the Fetch API sync status for an instance + * + * @param guid - The instance GUID + * @param mode - Sync mode: 'fetch' (live) or 'preview'. Defaults to 'fetch'. + * @param waitForCompletion - If true, polls until sync is complete. Defaults to false. + * @returns The sync status + */ +export async function getFetchApiStatus( + guid: string, + mode: FetchApiSyncMode = 'fetch', + waitForCompletion: boolean = false +): Promise { + const apiClient = getApiClient(); + return apiClient.instanceMethods.getFetchApiStatus(guid, mode, waitForCompletion); +} + +/** + * Wait for Fetch API sync to complete with progress messaging + * Returns log lines for capturing in logger + * + * @param guid - The instance GUID + * @param mode - Sync mode: 'fetch' (live) or 'preview'. Defaults to 'fetch'. + * @param silent - If true, suppresses console output. Defaults to false. + * @returns Object containing final status and log lines + */ +export async function waitForFetchApiSync( + guid: string, + mode: FetchApiSyncMode = 'fetch', + silent: boolean = false +): Promise<{ status: FetchApiStatus; logLines: string[] }> { + const logLines: string[] = []; + + // First check if sync is in progress + const initialStatus = await getFetchApiStatus(guid, mode, false); + + if (!initialStatus.inProgress) { + return { status: initialStatus, logLines }; + } + + // Sync is in progress, wait for completion + const waitingMsg = ansiColors.gray(`Waiting for Fetch API sync to complete...`); + logLines.push(waitingMsg); + if (!silent) { + console.log(waitingMsg); + } + + const finalStatus = await getFetchApiStatus(guid, mode, true); + + const completeMsg = ansiColors.green(`āœ“ Fetch API sync complete \n`); + logLines.push(completeMsg); + if (!silent) { + console.log(completeMsg); + } + + return { status: finalStatus, logLines }; +} diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts index f547df6..30a1a8a 100644 --- a/src/lib/shared/index.ts +++ b/src/lib/shared/index.ts @@ -11,6 +11,29 @@ export function prettyException(error: any): string { return error.message || er export function logBatchError(error: any, context: string): void { console.error("Batch Error:", error); } export { pollBatchUntilComplete, extractBatchResults } from "../pushers/batch-polling"; +// Source publish status checker - checks source instance publish status +export { + checkSourcePublishStatus, + filterPublishedContent, + filterPublishedPages, + isPublished +} from './source-publish-status-checker'; + +// Fetch API status checker - checks if CDN sync is complete +export { + getFetchApiStatus, + waitForFetchApiSync, + type FetchApiStatus, + type FetchApiSyncMode +} from './get-fetch-api-status'; + +// Re-export types from central types folder +export { + ItemState, + type SourceItemData, + type PublishStatusResult +} from '../../types'; + // Version utility import * as fs from 'fs'; import * as path from 'path'; diff --git a/src/lib/shared/source-publish-status-checker.ts b/src/lib/shared/source-publish-status-checker.ts new file mode 100644 index 0000000..de91b70 --- /dev/null +++ b/src/lib/shared/source-publish-status-checker.ts @@ -0,0 +1,153 @@ +/** + * Source Publish Status Checker + * + * Reads source instance files from the agility-files folder to determine + * which items are published in the source instance. This allows workflow operations + * to only process items in the target that match the source publish state. + * Uses fileOperations for consistent filesystem access. + */ + +import { fileOperations } from '../../core'; +import { + ContentMapping, + PageMapping, + ItemState, + SourceItemData, + PublishStatusResult +} from '../../types'; + +// Re-export types for convenience +export { ItemState, SourceItemData, PublishStatusResult }; + +/** + * Check if an item is published based on its state + */ +export function isPublished(itemState: number): boolean { + return itemState === ItemState.Published; +} + +/** + * Read source item data using fileOperations + */ +function readSourceItem(fileOps: fileOperations, type: 'item' | 'page', id: number): SourceItemData | null { + try { + const data = fileOps.readJsonFile(`${type}/${id}.json`); + return data as SourceItemData | null; + } catch (error: any) { + return null; + } +} + +/** + * Filter content mappings to only include items that are published in the source + */ +export function filterPublishedContent( + contentMappings: ContentMapping[], + sourceGuid: string, + locales: string[] +): PublishStatusResult { + const result: PublishStatusResult = { + publishedContentIds: [], + unpublishedContentIds: [], + publishedPageIds: [], + unpublishedPageIds: [], + errors: [] + }; + + for (const mapping of contentMappings) { + let found = false; + let isItemPublished = false; + + // Try each locale to find the source item + for (const locale of locales) { + const fileOps = new fileOperations(sourceGuid, locale); + const sourceItem = readSourceItem(fileOps, 'item', mapping.sourceContentID); + + if (sourceItem && sourceItem.properties) { + found = true; + isItemPublished = isPublished(sourceItem.properties.state); + break; + } + } + + if (!found) { + result.errors.push(`Source content item ${mapping.sourceContentID} not found in local files`); + // Default to publishing if source not found (preserve existing behavior) + result.publishedContentIds.push(mapping.targetContentID); + } else if (isItemPublished) { + result.publishedContentIds.push(mapping.targetContentID); + } else { + result.unpublishedContentIds.push(mapping.targetContentID); + } + } + + return result; +} + +/** + * Filter page mappings to only include pages that are published in the source + */ +export function filterPublishedPages( + pageMappings: PageMapping[], + sourceGuid: string, + locales: string[] +): PublishStatusResult { + const result: PublishStatusResult = { + publishedContentIds: [], + unpublishedContentIds: [], + publishedPageIds: [], + unpublishedPageIds: [], + errors: [] + }; + + for (const mapping of pageMappings) { + let found = false; + let isItemPublished = false; + + // Try each locale to find the source page + for (const locale of locales) { + const fileOps = new fileOperations(sourceGuid, locale); + const sourceItem = readSourceItem(fileOps, 'page', mapping.sourcePageID); + + if (sourceItem && sourceItem.properties) { + found = true; + isItemPublished = isPublished(sourceItem.properties.state); + break; + } + } + + if (!found) { + result.errors.push(`Source page ${mapping.sourcePageID} not found in local files`); + // Default to publishing if source not found (preserve existing behavior) + result.publishedPageIds.push(mapping.targetPageID); + } else if (isItemPublished) { + result.publishedPageIds.push(mapping.targetPageID); + } else { + result.unpublishedPageIds.push(mapping.targetPageID); + } + } + + return result; +} + +/** + * Check publish status for all content and page mappings + * Returns filtered lists of target IDs that should be published + */ +export function checkSourcePublishStatus( + contentMappings: ContentMapping[], + pageMappings: PageMapping[], + sourceGuid: string, + locales: string[] +): PublishStatusResult { + const contentResult = filterPublishedContent(contentMappings, sourceGuid, locales); + const pageResult = filterPublishedPages(pageMappings, sourceGuid, locales); + + return { + publishedContentIds: contentResult.publishedContentIds, + unpublishedContentIds: contentResult.unpublishedContentIds, + publishedPageIds: pageResult.publishedPageIds, + unpublishedPageIds: pageResult.unpublishedPageIds, + errors: [...contentResult.errors, ...pageResult.errors] + }; +} diff --git a/src/lib/workflows/index.ts b/src/lib/workflows/index.ts new file mode 100644 index 0000000..676ef01 --- /dev/null +++ b/src/lib/workflows/index.ts @@ -0,0 +1,24 @@ +/** + * Workflows Module + * + * Central exports for all workflow-related functionality. + */ + +// Core workflow operation class +export { WorkflowOperation, WorkflowOperationResult } from './workflow-operation'; + +// Workflow orchestrator +export { workflowOrchestrator } from './workflow-orchestrator'; + +// Batch processing +export { processBatches, type BatchProcessingResult } from './process-batches'; + +// Workflow options parsing +export { parseWorkflowOptions, parseOperationType } from './workflow-options'; + +// Workflow helpers (operation names, verbs, icons) +export { getOperationName, getOperationVerb, getOperationIcon } from './workflow-helpers'; + +// Mapping utilities +export { listMappings } from './list-mappings'; +export { refreshAndUpdateMappings } from './refresh-mappings'; diff --git a/src/lib/workflows/list-mappings.ts b/src/lib/workflows/list-mappings.ts new file mode 100644 index 0000000..d701d94 --- /dev/null +++ b/src/lib/workflows/list-mappings.ts @@ -0,0 +1,38 @@ +/** + * List Mappings + * + * Display available mapping pairs for workflow operations. + */ + +import ansiColors from 'ansi-colors'; +import { listAvailableMappingPairs, getMappingSummary } from '../mappers/mapping-reader'; + +/** + * List available mapping pairs for workflow operations + */ +export function listMappings(): void { + console.log(ansiColors.cyan('\n' + '═'.repeat(50))); + console.log(ansiColors.cyan('šŸ“‹ AVAILABLE MAPPINGS')); + console.log(ansiColors.cyan('═'.repeat(50))); + + const pairs = listAvailableMappingPairs(); + + if (pairs.length === 0) { + console.log(ansiColors.yellow('\nNo mappings found.')); + console.log(ansiColors.gray('Run a sync operation first to create mappings.')); + return; + } + + for (const pair of pairs) { + const summary = getMappingSummary(pair.sourceGuid, pair.targetGuid, pair.locales); + + console.log(ansiColors.white(`\n${pair.sourceGuid} → ${pair.targetGuid}`)); + console.log(ansiColors.gray(`Locales: ${pair.locales.join(', ')}`)); + console.log(ansiColors.gray(`Content items: ${summary.totalContent}`)); + console.log(ansiColors.gray(`Pages: ${summary.totalPages}`)); + } + + console.log(ansiColors.cyan('\n' + '─'.repeat(50))); + console.log(ansiColors.gray('To run a workflow operation:')); + console.log(ansiColors.white(' node dist/index.js workflows --sourceGuid --targetGuid --type publish')); +} diff --git a/src/lib/workflows/process-batches.ts b/src/lib/workflows/process-batches.ts new file mode 100644 index 0000000..b742c25 --- /dev/null +++ b/src/lib/workflows/process-batches.ts @@ -0,0 +1,237 @@ +/** + * Process Batches + * + * Processes items in batches with progress reporting and error handling. + */ + +import ansiColors from 'ansi-colors'; +import { batchWorkflow, createBatches, type BatchItemType } from '../../core/batch-workflows'; +import { getOperationName, getOperationVerb } from './workflow-helpers'; +import { WorkflowOperationType } from '../../types'; +import { state, fileOperations } from '../../core'; +import { getLogger } from '../../core/state'; +import { getContentItemsFromFileSystem } from '../getters/filesystem/get-content-items'; +import { getPagesFromFileSystem } from '../getters/filesystem/get-pages'; + +/** + * Item info for display purposes + */ +interface ItemDisplayInfo { + id: number; + name: string; + type?: string; +} + +/** + * Get content item display info from filesystem + */ +function getContentDisplayInfo(ids: number[], targetGuid: string, locale: string): Map { + const displayMap = new Map(); + + try { + const fileOps = new fileOperations(targetGuid, locale); + const contentItems = getContentItemsFromFileSystem(fileOps); + + for (const item of contentItems) { + if (ids.includes(item.contentID)) { + // Try to get a display name from fields.title, properties.referenceName, or definitionName + const displayName = item.fields?.title + || item.fields?.name + || item.properties?.referenceName + || `Item ${item.contentID}`; + const modelName = item.properties?.definitionName || ''; + + displayMap.set(item.contentID, { + id: item.contentID, + name: displayName, + type: modelName + }); + } + } + } catch (error) { + // Silently fail - we'll just show IDs without names + } + + return displayMap; +} + +/** + * Get page display info from filesystem + */ +function getPageDisplayInfo(ids: number[], targetGuid: string, locale: string): Map { + const displayMap = new Map(); + + try { + const fileOps = new fileOperations(targetGuid, locale); + const pages = getPagesFromFileSystem(fileOps); + + for (const page of pages) { + if (ids.includes(page.pageID)) { + // Use title, name, or pageID as display + const displayName = page.title || page.name || `Page ${page.pageID}`; + const pagePath = page.name ? `/${page.name}` : ''; + + displayMap.set(page.pageID, { + id: page.pageID, + name: displayName, + type: pagePath + }); + } + } + } catch (error) { + // Silently fail - we'll just show IDs without names + } + + return displayMap; +} + +/** + * Helper to log to both console (via logger) and capture lines + */ +function logLine(line: string, logLines: string[]): void { + const logger = getLogger(); + if (logger) { + logger.info(line); + } else { + console.log(line); + } + logLines.push(line); +} + +/** + * Display all items being processed (no truncation) + * Format: ā— [guid][locale] content ID: {id} - Name (Type) - publishing + */ +function displayItemBreakdown( + ids: number[], + type: BatchItemType, + targetGuid: string, + locale: string, + operationName: string, + displayMap: Map, + logLines: string[] +): void { + const entityType = type === 'content' ? 'content' : 'page'; + + // Show ALL items - no truncation + for (const id of ids) { + const info = displayMap.get(id); + const guidDisplay = ansiColors.green(`[${targetGuid}]`); + const localeDisplay = ansiColors.gray(`[${locale}]`); + const symbol = ansiColors.green('ā—'); + + let line: string; + if (info) { + const typeDisplay = info.type ? ansiColors.gray(` (${info.type})`) : ''; + // Format: ā— [guid][locale] content ID: {id} - Name (Type) - publishing + line = `${symbol} ${guidDisplay}${localeDisplay} ${ansiColors.white(entityType)} ID: ${ansiColors.cyan.underline(String(id))} - ${ansiColors.white(info.name)}${typeDisplay} - ${ansiColors.gray(operationName.toLowerCase())}`; + } else { + line = `${symbol} ${guidDisplay}${localeDisplay} ${ansiColors.white(entityType)} ID: ${ansiColors.cyan.underline(String(id))} - ${ansiColors.gray(operationName.toLowerCase())}`; + } + logLine(line, logLines); + } +} + +/** + * Batch processing result + */ +export interface BatchProcessingResult { + total: number; + processed: number; + failed: number; + batches: number; + processedIds: number[]; + logLines: string[]; +} + +/** + * Process batches for a specific item type (content or pages) + */ +export async function processBatches( + ids: number[], + type: BatchItemType, + locale: string, + operation: WorkflowOperationType, + errors: string[] +): Promise { + const logLines: string[] = []; + const results: BatchProcessingResult = { + total: ids.length, + processed: 0, + failed: 0, + batches: 0, + processedIds: [], + logLines: [] + }; + + if (ids.length === 0) return results; + + const label = type === 'content' ? 'Content' : 'Page'; + const operationName = getOperationName(operation); + const operationVerb = getOperationVerb(operation); + + logLine(ansiColors.cyan(`\n${operationName}ing ${ids.length} ${label.toLowerCase()} items...`), logLines); + + // Get item display info and show breakdown (ALL items, no truncation) + const targetGuid = state.targetGuid?.[0]; + if (targetGuid) { + const displayMap = type === 'content' + ? getContentDisplayInfo(ids, targetGuid, locale) + : getPageDisplayInfo(ids, targetGuid, locale); + + if (displayMap.size > 0) { + displayItemBreakdown(ids, type, targetGuid, locale, operationName, displayMap, logLines); + } + } + + const batches = createBatches(ids); + results.batches = batches.length; + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + const batchNum = i + 1; + const progress = Math.round((batchNum / batches.length) * 100); + + logLine(ansiColors.gray(`[${progress}%] ${label} batch ${batchNum}/${batches.length}: ${operationName}ing ${batch.length} items...`), logLines); + + try { + const batchResult = await batchWorkflow(batch, locale, operation, type); + + if (batchResult.success) { + results.processed += batchResult.processedIds.length; + results.processedIds.push(...batchResult.processedIds); + } else { + results.failed += batch.length; + errors.push(`${label} batch ${batchNum}: ${batchResult.error}`); + } + } catch (error: any) { + results.failed += batch.length; + errors.push(`${label} batch ${batchNum}: ${error.message}`); + } + + // Small delay between batches to prevent throttling + if (i < batches.length - 1) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + // Display count - clarify when API processes more items than requested (nested content) + let summaryLine: string; + if (results.processed > results.total) { + // API processed additional nested items + summaryLine = ansiColors.green(`āœ“ ${label} ${operationVerb}: ${results.processed} items (${results.total} requested + ${results.processed - results.total} nested)`); + } else if (results.processed === results.total) { + summaryLine = ansiColors.green(`āœ“ ${label} ${operationVerb}: ${results.processed} items`); + } else { + // Some items failed or were skipped + summaryLine = ansiColors.green(`āœ“ ${label} ${operationVerb}: ${results.processed}/${results.total} items`); + } + logLine(summaryLine, logLines); + + if (results.failed > 0) { + logLine(ansiColors.red(`āœ— ${results.failed} ${label.toLowerCase()} items failed`), logLines); + } + + results.logLines = logLines; + return results; +} diff --git a/src/lib/workflows/refresh-mappings.ts b/src/lib/workflows/refresh-mappings.ts new file mode 100644 index 0000000..3256908 --- /dev/null +++ b/src/lib/workflows/refresh-mappings.ts @@ -0,0 +1,173 @@ +/** + * Refresh Mappings + * + * Refresh target instance data and update mappings after publishing. + */ + +import ansiColors from 'ansi-colors'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Pull } from '../../core/pull'; +import { getAllApiKeys, getState } from '../../core/state'; +import { updateMappingsAfterPublish } from '../mappers/mapping-version-updater'; +import { waitForFetchApiSync } from '../shared/get-fetch-api-status'; +import { generateLogHeader } from '../shared'; + +/** + * Check if we have valid API keys for the target GUID + */ +function hasValidTargetKeys(targetGuid: string): boolean { + const apiKeys = getAllApiKeys(); + return apiKeys.some(key => key.guid === targetGuid); +} + +/** + * Write log lines to a file + */ +function writeLogFile(logLines: string[], targetGuid: string, locale: string): string | null { + try { + const state = getState(); + const logDir = path.join(process.cwd(), state.rootPath, targetGuid, 'logs'); + + // Create logs directory if it doesn't exist + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFileName = `publish-${locale}-${timestamp}.log`; + const logFilePath = path.join(logDir, logFileName); + + // Add header + const header = generateLogHeader('Publish', { + 'Target GUID': targetGuid, + 'Locale': locale + }); + + // Strip ANSI colors for file output + const stripAnsi = (str: string) => str.replace(/\x1B\[[0-9;]*[mK]/g, ''); + const cleanLines = logLines.map(line => stripAnsi(line)); + + const content = header + cleanLines.join('\n') + '\n'; + fs.writeFileSync(logFilePath, content, 'utf8'); + + return logFilePath; + } catch (error) { + return null; + } +} + +/** + * Refresh target instance data and update mappings with new versionIDs after publishing + * + * @param publishedContentIds - Content IDs that were published + * @param publishedPageIds - Page IDs that were published + * @param sourceGuid - Source instance GUID + * @param targetGuid - Target instance GUID + * @param locale - Locale code + * @param publishLogLines - Log lines from the publish operation to include in log file + */ +export async function refreshAndUpdateMappings( + publishedContentIds: number[], + publishedPageIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string, + publishLogLines: string[] = [] +): Promise { + // Start with publish log lines if provided + const logLines: string[] = [...publishLogLines]; + + const headerLine = ansiColors.cyan('\nRefreshing target instance data...'); + logLines.push(headerLine); + console.log(headerLine); + + // Check if we have API keys for the target - if not, key fetch failed earlier + if (!hasValidTargetKeys(targetGuid)) { + const warnLine = ansiColors.yellow(` āš ļø No API keys available for target ${targetGuid} - skipping refresh and mapping updates`); + const infoLine1 = ansiColors.gray(' This typically indicates an API connection issue (503, timeout, etc.)'); + const infoLine2 = ansiColors.gray(' Mappings will be updated on next successful sync'); + logLines.push(warnLine, infoLine1, infoLine2); + console.log(warnLine); + console.log(infoLine1); + console.log(infoLine2); + + // Still write log file even if we can't refresh + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + console.log(ansiColors.gray(`\nšŸ“„ Log file: ${logFilePath}`)); + } + return; + } + + try { + // Wait for Fetch API sync to complete before refreshing + // This ensures we're pulling the latest published data from the CDN + try { + const syncResult = await waitForFetchApiSync(targetGuid, 'fetch', false); + logLines.push(...syncResult.logLines); + } catch (error: any) { + const warnLine = ansiColors.yellow(` āš ļø Could not check Fetch API status: ${error.message}`); + logLines.push(warnLine); + console.log(warnLine); + // Continue with refresh anyway - the status check is best-effort + } + + const pull = new Pull(); + + // Run an incremental pull on the target instance + const pullResult = await pull.pullInstances(true); + + // Check if the pull was successful before updating mappings + if (!pullResult.success) { + const warnLine = ansiColors.yellow(' āš ļø Target refresh failed - skipping mapping version updates'); + const infoLine = ansiColors.gray(' Run a manual pull to refresh data and update mappings'); + logLines.push(warnLine, infoLine); + console.log(warnLine); + console.log(infoLine); + + // Still write log file on failure + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + console.log(ansiColors.gray(`\nšŸ“„ Log file: ${logFilePath}`)); + } + return; + } + + const successLine = ansiColors.green('āœ“ Target instance data refreshed'); + logLines.push(successLine); + console.log(successLine); + + // Update the mappings with the new versionIDs + const mappingResult = await updateMappingsAfterPublish( + publishedContentIds, + publishedPageIds, + sourceGuid, + targetGuid, + locale + ); + + // Add mapping update log lines + logLines.push(...mappingResult.logLines); + + // Write log file + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + const logPathLine = ansiColors.gray(`\nšŸ“„ Log file: ${logFilePath}`); + console.log(logPathLine); + } + + } catch (error: any) { + const errorLine = ansiColors.yellow(` āš ļø Warning: Could not refresh/update mappings after publish: ${error.message}`); + const infoLine = ansiColors.gray(' Mappings may be stale until next sync'); + logLines.push(errorLine, infoLine); + console.error(errorLine); + console.log(infoLine); + + // Still write log file on error + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + console.log(ansiColors.gray(`\nšŸ“„ Log file: ${logFilePath}`)); + } + } +} diff --git a/src/lib/workflows/workflow-helpers.ts b/src/lib/workflows/workflow-helpers.ts new file mode 100644 index 0000000..fd8000f --- /dev/null +++ b/src/lib/workflows/workflow-helpers.ts @@ -0,0 +1,67 @@ +/** + * Workflow Helper Functions + * + * Utility functions for workflow operations - operation names, verbs, icons. + */ + +import { WorkflowOperationType } from '../../types'; + +/** + * Get human-readable operation name + */ +export function getOperationName(operation: WorkflowOperationType): string { + switch (operation) { + case WorkflowOperationType.Publish: + return 'publish'; + case WorkflowOperationType.Unpublish: + return 'unpublish'; + case WorkflowOperationType.Approve: + return 'approve'; + case WorkflowOperationType.Decline: + return 'decline'; + case WorkflowOperationType.RequestApproval: + return 'request approval'; + default: + return 'process'; + } +} + +/** + * Get operation verb for logging (past tense) + */ +export function getOperationVerb(operation: WorkflowOperationType): string { + switch (operation) { + case WorkflowOperationType.Publish: + return 'published'; + case WorkflowOperationType.Unpublish: + return 'unpublished'; + case WorkflowOperationType.Approve: + return 'approved'; + case WorkflowOperationType.Decline: + return 'declined'; + case WorkflowOperationType.RequestApproval: + return 'submitted for approval'; + default: + return 'processed'; + } +} + +/** + * Get operation icon for logging + */ +export function getOperationIcon(operation: WorkflowOperationType): string { + switch (operation) { + case WorkflowOperationType.Publish: + return 'šŸ“¤'; + case WorkflowOperationType.Unpublish: + return 'šŸ“„'; + case WorkflowOperationType.Approve: + return 'āœ…'; + case WorkflowOperationType.Decline: + return 'āŒ'; + case WorkflowOperationType.RequestApproval: + return 'šŸ“'; + default: + return 'āš™ļø'; + } +} diff --git a/src/lib/workflows/workflow-operation.ts b/src/lib/workflows/workflow-operation.ts new file mode 100644 index 0000000..d942288 --- /dev/null +++ b/src/lib/workflows/workflow-operation.ts @@ -0,0 +1,282 @@ +/** + * Workflow Operation Core Module + * + * Standalone module that reads mappings from the filesystem and performs + * workflow operations (publish, unpublish, approve, decline, requestApproval) + * on content and pages in the target instance. + */ + +import ansiColors from 'ansi-colors'; +import { state, initializeLogger, finalizeLogger, getLogger } from '../../core/state'; +import { readMappingsForGuidPair, getMappingSummary } from '../mappers/mapping-reader'; +import { parseWorkflowOptions, parseOperationType } from './workflow-options'; +import { getOperationName } from './workflow-helpers'; +import { workflowOrchestrator } from './workflow-orchestrator'; +import { listMappings } from './list-mappings'; +import { refreshAndUpdateMappings } from './refresh-mappings'; +import { checkSourcePublishStatus } from '../shared/source-publish-status-checker'; +import { WorkflowOperationResult, WorkflowOperationType } from '../../types'; + +// Re-export type for convenience +export { WorkflowOperationResult }; + +export class WorkflowOperation { + /** + * Execute workflow operation from mapping files + */ + async executeFromMappings(): Promise { + const startTime = Date.now(); + + // Initialize logger + initializeLogger('push'); + const logger = getLogger(); + + // Get operation type from state + const operationType = parseOperationType(state.operationType); + const operationName = getOperationName(operationType); + + const result: WorkflowOperationResult = { + success: true, + contentProcessed: 0, + contentFailed: 0, + pagesProcessed: 0, + pagesFailed: 0, + elapsedTime: 0, + errors: [], + operation: operationName + }; + + try { + const { sourceGuid, targetGuid, locale: locales } = state; + + // Validate required parameters + if (!sourceGuid || sourceGuid.length === 0) { + throw new Error('Source GUID is required. Use --sourceGuid flag.'); + } + if (!targetGuid || targetGuid.length === 0) { + throw new Error('Target GUID is required. Use --targetGuid flag.'); + } + if (!locales || locales.length === 0) { + throw new Error('At least one locale is required. Use --locale flag.'); + } + + const source = sourceGuid[0]; + const target = targetGuid[0]; + const primaryLocale = locales[0]; + + console.log(ansiColors.cyan('\n' + '═'.repeat(50))); + console.log(ansiColors.cyan(`šŸ“¦ WORKFLOW OPERATION: ${operationName.toUpperCase()}`)); + console.log(ansiColors.cyan('═'.repeat(50))); + console.log(ansiColors.gray(`Source: ${source}`)); + console.log(ansiColors.gray(`Target: ${target}`)); + console.log(ansiColors.gray(`Locales: ${locales.join(', ')}`)); + console.log(ansiColors.gray(`Operation: ${operationName}`)); + + // Get mapping summary + const summary = getMappingSummary(source, target, locales); + // Check if explicit IDs are provided (bypasses mappings lookup) + const hasExplicitContentIDs = state.explicitContentIDs && state.explicitContentIDs.length > 0; + const hasExplicitPageIDs = state.explicitPageIDs && state.explicitPageIDs.length > 0; + const useExplicitIDs = hasExplicitContentIDs || hasExplicitPageIDs; + + // Parse workflow options - process both content and pages by default + const options = parseWorkflowOptions(true, primaryLocale); + if (!options) { + throw new Error('Failed to parse workflow options'); + } + + // Override with the actual operation type from state + options.operation = operationType; + + let contentIds: number[]; + let pageIds: number[]; + + if (useExplicitIDs) { + // Explicit IDs mode - bypass mappings lookup + console.log(ansiColors.cyan('\nšŸ”§ Using explicit IDs (bypassing mappings lookup)')); + + contentIds = hasExplicitContentIDs ? state.explicitContentIDs : []; + pageIds = hasExplicitPageIDs ? state.explicitPageIDs : []; + + console.log(ansiColors.gray(` Explicit content IDs: ${contentIds.length > 0 ? contentIds.join(', ') : '(none)'}`)); + console.log(ansiColors.gray(` Explicit page IDs: ${pageIds.length > 0 ? pageIds.join(', ') : '(none)'}`)); + + if (contentIds.length === 0 && pageIds.length === 0) { + console.log(ansiColors.yellow('\nāš ļø No valid IDs provided.')); + result.elapsedTime = Date.now() - startTime; + return result; + } + } else { + // Standard mode - use mappings files + console.log(ansiColors.gray(`\nMapping Summary:`)); + console.log(ansiColors.gray(`Content items: ${summary.totalContent}`)); + console.log(ansiColors.gray(`Pages: ${summary.totalPages}`)); + console.log(ansiColors.gray(`Locales with data: ${summary.localesFound.join(', ') || 'none'}`)); + + if (summary.totalContent === 0 && summary.totalPages === 0) { + console.log(ansiColors.yellow('\nāš ļø No mappings found to process.')); + console.log(ansiColors.gray(' Run a sync operation first to create mappings, or use --contentIDs/--pageIDs to specify IDs directly.')); + result.elapsedTime = Date.now() - startTime; + return result; + } + + // Read mappings + const mappingResult = readMappingsForGuidPair(source, target, locales); + + if (mappingResult.errors.length > 0) { + console.log(ansiColors.yellow('\nWarnings during mapping read:')); + mappingResult.errors.forEach(err => console.log(ansiColors.yellow(` - ${err}`))); + } + + // For publish operations, check source publish status to filter only published items + contentIds = mappingResult.contentIds; + pageIds = mappingResult.pageIds; + + if (operationType === WorkflowOperationType.Publish) { + console.log(ansiColors.cyan('\nChecking source instance publish status...')); + const publishStatus = checkSourcePublishStatus( + mappingResult.contentMappings, + mappingResult.pageMappings, + source, + locales + ); + + // Report status check warnings + if (publishStatus.errors.length > 0) { + console.log(ansiColors.yellow(`${publishStatus.errors.length} items not found in source files (will be included)`)); + } + + // Report filtering results + const totalContentMapped = mappingResult.contentIds.length; + const totalPagesMapped = mappingResult.pageIds.length; + const contentPublishedInSource = publishStatus.publishedContentIds.length; + const pagesPublishedInSource = publishStatus.publishedPageIds.length; + const contentSkipped = publishStatus.unpublishedContentIds.length; + const pagesSkipped = publishStatus.unpublishedPageIds.length; + + console.log(ansiColors.gray(`Content: ${contentPublishedInSource}/${totalContentMapped} published in source (${contentSkipped} staging/unpublished skipped)`)); + console.log(ansiColors.gray(`Pages: ${pagesPublishedInSource}/${totalPagesMapped} published in source (${pagesSkipped} staging/unpublished skipped)`)); + + // Filter IDs based on publish mode AND source publish status + contentIds = options.processContent ? publishStatus.publishedContentIds : []; + pageIds = options.processPages ? publishStatus.publishedPageIds : []; + } else { + // For non-publish operations, use all mapped IDs + contentIds = options.processContent ? mappingResult.contentIds : []; + pageIds = options.processPages ? mappingResult.pageIds : []; + } + } + + const modeDescription = options.processContent && options.processPages + ? 'content and pages' + : options.processContent + ? 'content only' + : 'pages only'; + + console.log(ansiColors.cyan(`\n${operationName.charAt(0).toUpperCase() + operationName.slice(1)}ing ${modeDescription}...`)); + console.log(ansiColors.gray(`Content items to ${operationName}: ${contentIds.length}`)); + console.log(ansiColors.gray(`Pages to ${operationName}: ${pageIds.length}`)); + + // DRY RUN: Show preview and exit without executing + if (state.dryRun) { + console.log(ansiColors.yellow('\n' + '═'.repeat(50))); + console.log(ansiColors.yellow(`šŸ” DRY RUN PREVIEW - ${operationName.toUpperCase()}`)); + console.log(ansiColors.yellow('═'.repeat(50))); + console.log(ansiColors.gray('\nThe following items would be processed:')); + + if (contentIds.length > 0) { + console.log(ansiColors.cyan(`\nšŸ“„ Content Items (${contentIds.length}):`)); + const displayContentIds = contentIds.slice(0, 20); + displayContentIds.forEach(id => console.log(ansiColors.white(` • ID: ${id}`))); + if (contentIds.length > 20) { + console.log(ansiColors.gray(` ... and ${contentIds.length - 20} more content items`)); + } + } + + if (pageIds.length > 0) { + console.log(ansiColors.cyan(`\nšŸ“‘ Pages (${pageIds.length}):`)); + const displayPageIds = pageIds.slice(0, 20); + displayPageIds.forEach(id => console.log(ansiColors.white(` • ID: ${id}`))); + if (pageIds.length > 20) { + console.log(ansiColors.gray(` ... and ${pageIds.length - 20} more pages`)); + } + } + + console.log(ansiColors.yellow('\n' + '─'.repeat(50))); + console.log(ansiColors.yellow('āš ļø DRY RUN COMPLETE - No changes were made')); + console.log(ansiColors.gray(`Remove --dryRun flag to execute the ${operationName} operation`)); + console.log(ansiColors.yellow('─'.repeat(50))); + + result.contentProcessed = contentIds.length; + result.pagesProcessed = pageIds.length; + result.elapsedTime = Date.now() - startTime; + finalizeLogger(); + return result; + } + + // Execute workflow operation + console.log(ansiColors.cyan('\n' + '─'.repeat(50))); + console.log(ansiColors.cyan(`šŸš€ ${operationName.toUpperCase()} PHASE (${modeDescription})`)); + console.log(ansiColors.cyan('─'.repeat(50))); + + const workflowResult = await workflowOrchestrator(contentIds, pageIds, options); + + // Update results + result.contentProcessed = workflowResult.contentResults.processed; + result.contentFailed = workflowResult.contentResults.failed; + result.pagesProcessed = workflowResult.pageResults.processed; + result.pagesFailed = workflowResult.pageResults.failed; + result.success = workflowResult.success; + result.errors = workflowResult.errors; + + // If items were published, refresh target instance data and update mappings + if (operationType === WorkflowOperationType.Publish && + (workflowResult.contentResults.processed > 0 || workflowResult.pageResults.processed > 0)) { + await refreshAndUpdateMappings( + workflowResult.contentResults.processedIds, + workflowResult.pageResults.processedIds, + source, + target, + primaryLocale, + workflowResult.logLines // Pass publish log lines to include in log file + ); + } + + // Final summary + result.elapsedTime = Date.now() - startTime; + const totalProcessed = result.contentProcessed + result.pagesProcessed; + const totalFailed = result.contentFailed + result.pagesFailed; + const totalSeconds = Math.floor(result.elapsedTime / 1000); + + console.log(ansiColors.cyan('\n' + '═'.repeat(50))); + console.log(ansiColors.cyan(`šŸ“Š ${operationName.toUpperCase()} COMPLETE`)); + console.log(ansiColors.cyan('═'.repeat(50))); + console.log(ansiColors.green(`āœ“ Processed: ${totalProcessed} items`)); + if (totalFailed > 0) { + console.log(ansiColors.red(`āœ— Failed: ${totalFailed} items`)); + } + console.log(ansiColors.gray(`Total time: ${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`)); + + if (result.errors.length > 0) { + console.log(ansiColors.yellow('\nErrors encountered:')); + result.errors.forEach(err => console.log(ansiColors.red(` - ${err}`))); + } + + } catch (error: any) { + result.success = false; + result.errors.push(error.message); + console.error(ansiColors.red(`\nāŒ Workflow operation failed: ${error.message}`)); + } + + finalizeLogger(); + result.elapsedTime = Date.now() - startTime; + return result; + } + + /** + * List available mapping pairs for workflow operations + */ + listMappings(): void { + listMappings(); + } +} diff --git a/src/lib/workflows/workflow-options.ts b/src/lib/workflows/workflow-options.ts new file mode 100644 index 0000000..e8903c3 --- /dev/null +++ b/src/lib/workflows/workflow-options.ts @@ -0,0 +1,89 @@ +/** + * Workflow Options Parsing + * + * Functions for parsing and converting workflow operation options. + */ + +import { WorkflowOperationType, WorkflowOptions } from '../../types'; + +/** + * Convert string operation type to WorkflowOperationType enum + */ +export function parseOperationType(operationType: string | undefined): WorkflowOperationType { + if (!operationType) return WorkflowOperationType.Publish; + + switch (operationType.toLowerCase()) { + case 'publish': + return WorkflowOperationType.Publish; + case 'unpublish': + return WorkflowOperationType.Unpublish; + case 'approve': + return WorkflowOperationType.Approve; + case 'decline': + return WorkflowOperationType.Decline; + case 'requestapproval': + case 'request-approval': + case 'request_approval': + return WorkflowOperationType.RequestApproval; + default: + return WorkflowOperationType.Publish; + } +} + +/** + * Parse workflow options from state/command args + */ +export function parseWorkflowOptions( + operationType: string | boolean | WorkflowOperationType, + locale: string +): WorkflowOptions | null { + if (!operationType) return null; + + // Default operation is Publish + let operation = WorkflowOperationType.Publish; + let processContent = true; + let processPages = true; + + // Handle string operation types + if (typeof operationType === 'string') { + const value = operationType.toLowerCase(); + + // Check for operation type + switch (value) { + case 'publish': + operation = WorkflowOperationType.Publish; + break; + case 'unpublish': + operation = WorkflowOperationType.Unpublish; + break; + case 'approve': + operation = WorkflowOperationType.Approve; + break; + case 'decline': + operation = WorkflowOperationType.Decline; + break; + case 'requestapproval': + case 'request-approval': + case 'request_approval': + operation = WorkflowOperationType.RequestApproval; + break; + case 'content': + processPages = false; + break; + case 'pages': + processContent = false; + break; + case 'true': + // Default behavior - process both + break; + default: + // Unrecognized value - default to publish both + break; + } + } else if (typeof operationType === 'number') { + // Direct WorkflowOperationType enum value + operation = operationType; + } + + return { processContent, processPages, locale, operation }; +} diff --git a/src/lib/workflows/workflow-orchestrator.ts b/src/lib/workflows/workflow-orchestrator.ts new file mode 100644 index 0000000..19c3ed0 --- /dev/null +++ b/src/lib/workflows/workflow-orchestrator.ts @@ -0,0 +1,76 @@ +/** + * Workflow Orchestrator + * + * Orchestrates batch workflow operations for content items and pages. + */ + +import ansiColors from 'ansi-colors'; +import { processBatches } from './process-batches'; +import { getOperationName, getOperationVerb } from './workflow-helpers'; +import { WorkflowOrchestratorResult, WorkflowOptions } from '../../types'; + +/** + * Helper to log to both console and capture lines + */ +function logLine(line: string, logLines: string[]): void { + console.log(line); + logLines.push(line); +} + +/** + * Orchestrate workflow operations for content items and pages + * Processes items in batches and reports progress + */ +export async function workflowOrchestrator( + contentIds: number[], + pageIds: number[], + options: WorkflowOptions +): Promise { + const errors: string[] = []; + const logLines: string[] = []; + const { locale, processContent, processPages, operation } = options; + const operationName = getOperationName(operation); + const operationVerb = getOperationVerb(operation); + + // Process content and pages + const contentResults = processContent + ? await processBatches(contentIds, 'content', locale, operation, errors) + : { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }; + + // Collect content log lines + logLines.push(...contentResults.logLines); + + const pageResults = processPages + ? await processBatches(pageIds, 'pages', locale, operation, errors) + : { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }; + + // Collect page log lines + logLines.push(...pageResults.logLines); + + // Summary + const totalProcessed = contentResults.processed + pageResults.processed; + const totalFailed = contentResults.failed + pageResults.failed; + const totalRequested = contentResults.total + pageResults.total; + const totalNested = totalProcessed > totalRequested ? totalProcessed - totalRequested : 0; + + if (totalRequested > 0) { + if (totalNested > 0) { + logLine(ansiColors.cyan(`\nWorkflow summary: ${totalProcessed} items ${operationVerb} (${totalRequested} requested + ${totalNested} nested)`), logLines); + } else { + logLine(ansiColors.cyan(`\nWorkflow summary: ${totalProcessed}/${totalRequested} items ${operationVerb} successfully`), logLines); + } + if (totalFailed > 0) { + logLine(ansiColors.yellow(` ${totalFailed} items failed`), logLines); + } + } else { + logLine(ansiColors.gray(`\nNo items to ${operationName}`), logLines); + } + + return { + success: errors.length === 0, + contentResults, + pageResults, + errors, + logLines + }; +} diff --git a/src/tests/setup.ts b/src/tests/setup.ts new file mode 100644 index 0000000..a1749eb --- /dev/null +++ b/src/tests/setup.ts @@ -0,0 +1,20 @@ +/** + * Jest setup file - loads environment variables for testing + * + * Test env file location: src/tests/.env.test + * Copy .env.test.example to .env.test and fill in your test credentials + */ +import dotenv from 'dotenv'; +import path from 'path'; + +// Load .env.test from the tests folder (not project root) +const envPath = path.resolve(__dirname, '.env'); +const result = dotenv.config({ path: envPath }); + +if (result.error) { + console.warn(` +āš ļø Test environment file not found: ${envPath} + Copy src/tests/.env.test.example to src/tests/.env + and fill in your test credentials. +`); +} diff --git a/src/tests/shared/fetch-api-status.integration.test.ts b/src/tests/shared/fetch-api-status.integration.test.ts new file mode 100644 index 0000000..51a66f6 --- /dev/null +++ b/src/tests/shared/fetch-api-status.integration.test.ts @@ -0,0 +1,120 @@ +/** + * Integration tests for Fetch API Status helper + * + * Tests the getFetchApiStatus and waitForFetchApiSync functions + * against a real Agility CMS instance. + * + * Setup: + * 1. Copy src/tests/env.test.example to src/tests/.env + * 2. Fill in your test credentials + * + * Required env vars in src/tests/.env: + * - AGILITY_TOKEN - Valid authentication token + * - AGILITY_GUID or AGILITY_TARGET_GUID - Instance GUID to check + * + * Run with: npm run test:integration + */ + +// Disable SSL certificate verification for local development +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +import { getFetchApiStatus, waitForFetchApiSync, FetchApiStatus } from '../../lib/shared/get-fetch-api-status'; +import { state } from '../../core/state'; +import * as mgmtApi from '@agility/management-sdk'; +import { primeFromEnv } from '../../core/state'; + +describe('Fetch API Status - Integration Tests', () => { + let testGuid: string; + + beforeAll(async () => { + // Prime state from .env + primeFromEnv(); + + // Get required environment variables + const token = process.env.AGILITY_TOKEN2 || process.env.AGILITY_TOKEN; + testGuid = process.env.AGILITY_GUID || process.env.AGILITY_TARGET_GUID || ''; + const baseUrl = process.env.AGILITY_BASE_URL || process.env.BASE_URL; + + if (!token) { + throw new Error('AGILITY_TOKEN is required in .env for integration tests'); + } + if (!testGuid) { + throw new Error('AGILITY_GUID or AGILITY_TARGET_GUID is required in .env for integration tests'); + } + + // Initialize API client with real credentials + const options: mgmtApi.Options = { + token: token, + baseUrl: baseUrl, + refresh_token: null, + duration: 3000, + retryCount: 500, + }; + + const apiClient = new mgmtApi.ApiClient(options); + + // Set state for the helper functions + state.mgmtApiOptions = options; + state.cachedApiClient = apiClient; + }); + + describe('getFetchApiStatus', () => { + it('should return sync status for fetch mode', async () => { + const status = await getFetchApiStatus(testGuid, 'fetch', false); + + expect(status).toBeDefined(); + expect(typeof status.inProgress).toBe('boolean'); + expect(typeof status.lastContentVersionID).toBe('number'); + expect(typeof status.pushType).toBe('number'); + + // pushType should be 2 for fetch mode + expect(status.pushType).toBe(2); + + }, 30000); + + it('should return sync status for preview mode', async () => { + const status = await getFetchApiStatus(testGuid, 'preview', false); + + expect(status).toBeDefined(); + expect(typeof status.inProgress).toBe('boolean'); + expect(typeof status.lastContentVersionID).toBe('number'); + expect(typeof status.pushType).toBe('number'); + + // pushType should be 1 for preview mode + expect(status.pushType).toBe(1); + + }, 30000); + }); + + describe('waitForFetchApiSync', () => { + it('should wait for sync to complete (or return immediately if not syncing)', async () => { + const startTime = Date.now(); + const result = await waitForFetchApiSync(testGuid, 'fetch', true); + const elapsed = Date.now() - startTime; + + expect(result).toBeDefined(); + expect(result.status).toBeDefined(); + expect(result.status.inProgress).toBe(false); + expect(Array.isArray(result.logLines)).toBe(true); + + }, 120000); // 2 minute timeout for waiting + }); + + describe('error handling', () => { + it('should handle invalid GUID gracefully', async () => { + // Temporarily override the state with invalid credentials + const originalClient = state.cachedApiClient; + + try { + // This should throw or return an error + await getFetchApiStatus('invalid-guid-xxx', 'fetch', false); + // If it doesn't throw, that's also acceptable (API might return a default) + } catch (error: any) { + expect(error).toBeDefined(); + } + + // Restore original client + state.cachedApiClient = originalClient; + }, 30000); + }); +}); diff --git a/src/tests/workflows/batch-workflows.integration.test.ts b/src/tests/workflows/batch-workflows.integration.test.ts new file mode 100644 index 0000000..de4ad04 --- /dev/null +++ b/src/tests/workflows/batch-workflows.integration.test.ts @@ -0,0 +1,155 @@ +/** + * Integration tests for batch workflow operations + * Tests workflow operations (publish, unpublish, etc.) on content and pages using a REAL API client + * + * Setup: + * 1. Copy src/tests/env.test.example to src/tests/.env.test + * 2. Fill in your test credentials + * + * Required env vars in src/tests/.env.test: + * - AGILITY_TOKEN - Valid authentication token + * - AGILITY_TARGET_GUID or AGILITY_GUID - Target instance GUID + * - AGILITY_LOCALE or AGILITY_LOCALES - Locale(s) for testing + * - CONTENTIDS_TO_BATCH_PUBLISH - Comma-separated content IDs + * - PAGES_TO_BATCH_PUBLISH - Comma-separated page IDs + * + * Run with: npm test -- --testPathPattern="integration" + */ + +// Disable SSL certificate verification for local development +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +import { batchWorkflow } from '../../core/batch-workflows'; +import { WorkflowOperationType } from '@agility/management-sdk'; +import { state } from '../../core/state'; +import * as mgmtApi from '@agility/management-sdk'; +import { primeFromEnv } from '../../core/state'; + +// Helper function to parse comma-separated IDs from environment variable +function parseIDs(envVar: string | undefined, fallback: number[]): number[] { + if (!envVar) return fallback; + return envVar + .split(',') + .map(id => parseInt(id.trim(), 10)) + .filter(id => !isNaN(id)); +} + +// Get test data from environment variables +const TEST_CONTENT_IDS = parseIDs(process.env.CONTENTIDS_TO_BATCH_PUBLISH, []); +const TEST_PAGE_IDS = parseIDs(process.env.PAGES_TO_BATCH_PUBLISH, []); +const TEST_LOCALE = process.env.AGILITY_LOCALE || process.env.AGILITY_LOCALES?.split(',')[0] || 'en-us'; +const BASE_URL = process.env.AGILITY_BASE_URL || process.env.BASE_URL || 'https://api.agilitycms.com'; + +describe('Batch Workflow Operations - Integration Tests', () => { + let apiClient: mgmtApi.ApiClient; + + beforeAll(async () => { + // Prime state from .env + primeFromEnv(); + + // Get required environment variables + const token = process.env.AGILITY_TOKEN2 || process.env.AGILITY_TOKEN; + const targetGuid = process.env.AGILITY_TARGET_GUID || process.env.AGILITY_GUID; + + if (!token) { + throw new Error('AGILITY_TOKEN is required in .env for integration tests'); + } + if (!targetGuid) { + throw new Error('AGILITY_TARGET_GUID or AGILITY_GUID is required in .env for integration tests'); + } + + // Initialize API client with real credentials + const options: mgmtApi.Options = { + token: token, + baseUrl: BASE_URL, + refresh_token: null, + duration: 3000, + retryCount: 500, + }; + + apiClient = new mgmtApi.ApiClient(options); + + // Set state for the workflow functions + state.targetGuid = [targetGuid]; + state.mgmtApiOptions = options; + state.cachedApiClient = apiClient; + }); + + // ============================================================================ + // Content Workflow Operations + // ============================================================================ + + describe('Content Workflow Operations', () => { + beforeAll(() => { + if (TEST_CONTENT_IDS.length === 0) { + console.warn('CONTENTIDS_TO_BATCH_PUBLISH not set - content tests will be skipped'); + } + }); + + it('should run publish workflow operation on content items', async () => { + if (TEST_CONTENT_IDS.length === 0) return; + + const result = await batchWorkflow(TEST_CONTENT_IDS, TEST_LOCALE, WorkflowOperationType.Publish, 'content'); + expect(result.success).toBe(true); + expect(result.processedIds).toBeDefined(); + expect(result.processedIds.length).toBeGreaterThan(0); + expect(result.error).toBeUndefined(); + }, 30000); + + it('should run unpublish workflow operation on content items', async () => { + if (TEST_CONTENT_IDS.length === 0) return; + + const result = await batchWorkflow(TEST_CONTENT_IDS, TEST_LOCALE, WorkflowOperationType.Unpublish, 'content'); + expect(result.success).toBe(true); + expect(result.processedIds).toBeDefined(); + expect(result.processedIds.length).toBeGreaterThan(0); + expect(result.error).toBeUndefined(); + }, 30000); + + it('should handle workflow operation on invalid content IDs gracefully', async () => { + const invalidContentIDs = [999999, 999998]; + const result = await batchWorkflow(invalidContentIDs, TEST_LOCALE, WorkflowOperationType.Publish, 'content'); + expect(result).toBeDefined(); + expect(result.success !== undefined).toBe(true); + }, 30000); + }); + + // ============================================================================ + // Page Workflow Operations + // ============================================================================ + + describe('Page Workflow Operations', () => { + beforeAll(() => { + if (TEST_PAGE_IDS.length === 0) { + console.warn('PAGES_TO_BATCH_PUBLISH not set - page tests will be skipped'); + } + }); + + it('should run publish workflow operation on pages', async () => { + if (TEST_PAGE_IDS.length === 0) return; + + const result = await batchWorkflow(TEST_PAGE_IDS, TEST_LOCALE, WorkflowOperationType.Publish, 'pages'); + expect(result.success).toBe(true); + expect(result.processedIds).toBeDefined(); + expect(result.processedIds.length).toBeGreaterThan(0); + expect(result.error).toBeUndefined(); + }, 30000); + + it('should run unpublish workflow operation on pages', async () => { + if (TEST_PAGE_IDS.length === 0) return; + + const result = await batchWorkflow(TEST_PAGE_IDS, TEST_LOCALE, WorkflowOperationType.Unpublish, 'pages'); + expect(result.success).toBe(true); + expect(result.processedIds).toBeDefined(); + expect(result.processedIds.length).toBeGreaterThan(0); + expect(result.error).toBeUndefined(); + }, 30000); + + it('should handle workflow operation on invalid page IDs gracefully', async () => { + const invalidPageIDs = [999999, 999998]; + const result = await batchWorkflow(invalidPageIDs, TEST_LOCALE, WorkflowOperationType.Publish, 'pages'); + expect(result).toBeDefined(); + expect(result.success !== undefined).toBe(true); + }, 30000); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 64e2680..19dc096 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,4 +12,7 @@ export * from './cliToken'; // Note: comparisonResult.ts doesn't export anything, skipping // ReferenceMapperV2 types -export * from './referenceMapperV2'; \ No newline at end of file +export * from './referenceMapperV2'; + +// Workflow types (batch workflows, mappings, publish status) +export * from './workflows'; \ No newline at end of file diff --git a/src/types/sourceData.ts b/src/types/sourceData.ts index cf5ccf0..229a379 100644 --- a/src/types/sourceData.ts +++ b/src/types/sourceData.ts @@ -35,7 +35,7 @@ export interface PusherResult { failed: number; skipped: number; status: 'success' | 'error'; - publishableIds?: number[]; // Optional: target instance IDs for auto-publishing (content items and pages only) + publishableIds?: number[]; // Optional: target instance IDs for workflow operations (content items and pages only) } /** diff --git a/src/types/workflows.ts b/src/types/workflows.ts new file mode 100644 index 0000000..cc6785b --- /dev/null +++ b/src/types/workflows.ts @@ -0,0 +1,164 @@ +/** + * Workflow Types + * + * Type definitions for batch workflow operations, mappings, and publish status checking. + */ + +import { WorkflowOperationType } from '@agility/management-sdk'; + +// Re-export WorkflowOperationType for convenience +export { WorkflowOperationType }; + +// ============================================================================ +// Batch Workflow Types +// ============================================================================ + +/** + * Result from a batch workflow operation + */ +export interface BatchWorkflowResult { + success: boolean; + processedIds: number[]; + failedCount: number; + error?: string; +} + +/** + * Combined result from workflow orchestration + */ +export interface WorkflowOrchestratorResult { + success: boolean; + contentResults: { + total: number; + processed: number; + failed: number; + batches: number; + processedIds: number[]; + }; + pageResults: { + total: number; + processed: number; + failed: number; + batches: number; + processedIds: number[]; + }; + errors: string[]; + logLines: string[]; +} + +/** + * Options for workflow operations + */ +export interface WorkflowOptions { + processContent: boolean; + processPages: boolean; + locale: string; + operation: WorkflowOperationType; +} + +/** + * Result from a workflow operation command + */ +export interface WorkflowOperationResult { + success: boolean; + contentProcessed: number; + contentFailed: number; + pagesProcessed: number; + pagesFailed: number; + elapsedTime: number; + errors: string[]; + operation: string; +} + +// ============================================================================ +// Mapping Types +// ============================================================================ + +/** + * Content item mapping between source and target instances + */ +export interface ContentMapping { + sourceGuid: string; + targetGuid: string; + sourceContentID: number; + targetContentID: number; + sourceVersionID: number; + targetVersionID: number; +} + +/** + * Page mapping between source and target instances + */ +export interface PageMapping { + sourceGuid: string; + targetGuid: string; + sourcePageID: number; + targetPageID: number; + sourceVersionID: number; + targetVersionID: number; + sourcePageTemplateName: string | null; + targetPageTemplateName: string | null; +} + +/** + * Result from reading mappings + */ +export interface MappingReadResult { + contentIds: number[]; + pageIds: number[]; + contentMappings: ContentMapping[]; + pageMappings: PageMapping[]; + errors: string[]; +} + +/** + * Result from updating mappings after publishing + */ +export interface MappingUpdateResult { + contentMappingsUpdated: number; + pageMappingsUpdated: number; + errors: string[]; +} + +// ============================================================================ +// Publish Status Types +// ============================================================================ + +/** + * Item state values from the Agility CMS ItemState enum + */ +export enum ItemState { + New = -1, + None = 0, + Staging = 1, + Published = 2, + Deleted = 3, + Approved = 4, + AwaitingApproval = 5, + Declined = 6, + Unpublished = 7 +} + +/** + * Source item data structure for publish status checking + */ +export interface SourceItemData { + contentID?: number; + pageID?: number; + properties: { + state: number; + modified: string; + versionID: number; + }; +} + +/** + * Result from checking publish status of source items + */ +export interface PublishStatusResult { + publishedContentIds: number[]; + unpublishedContentIds: number[]; + publishedPageIds: number[]; + unpublishedPageIds: number[]; + errors: string[]; +} diff --git a/yarn.lock b/yarn.lock index c2bffc9..2a70273 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24,10 +24,10 @@ dotenv "^8.2.0" proper-lockfile "^4.1.2" -"@agility/management-sdk@^0.1.33": - version "0.1.33" - resolved "https://registry.npmjs.org/@agility/management-sdk/-/management-sdk-0.1.33.tgz" - integrity sha512-+lxk49pi4nPJO+jZLECYGEp1U30HApwtoRGZSNGkboOAsWkCIzP4URcZPLgOExRt5iiXjKSCLxMcryR44q5h2g== +"@agility/management-sdk@^0.1.38": + version "0.1.38" + resolved "https://registry.npmjs.org/@agility/management-sdk/-/management-sdk-0.1.38.tgz" + integrity sha512-g6/hNgCjf+uzcJbkPaxeWqwaAid50tMnRW0KYQ4J/fGuJqtcZG0et0X3m76Ev3yUqnkpIO/+C5zMIQMghN3/oQ== dependencies: axios "^0.27.2" @@ -1699,10 +1699,10 @@ follow-redirects@^1.14.0, follow-redirects@^1.14.9: resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== -form-data@^4.0.0: - version "4.0.3" - resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz" - integrity sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA== +form-data@^4.0.0, form-data@^4.0.5: + version "4.0.5" + resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" @@ -1720,11 +1720,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2: - version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"