diff --git a/.cursor/commands/code-review.md b/.cursor/commands/code-review.md new file mode 100644 index 000000000..c336c0aa1 --- /dev/null +++ b/.cursor/commands/code-review.md @@ -0,0 +1,122 @@ +--- +name: code-review +description: Automated PR review using comprehensive checklist tailored for Contentstack CLI plugins +--- + +# Code Review Command + +## Usage Patterns + +### Scope-Based Reviews +- `/code-review` - Review all current changes with full checklist +- `/code-review --scope typescript` - Focus on TypeScript configuration and patterns +- `/code-review --scope testing` - Focus on Mocha/Chai/Sinon test patterns +- `/code-review --scope contentstack` - Focus on API integration and CLI patterns +- `/code-review --scope oclif` - Focus on command structure and OCLIF patterns + +### Severity Filtering +- `/code-review --severity critical` - Show only critical issues (security, breaking changes) +- `/code-review --severity high` - Show high and critical issues +- `/code-review --severity all` - Show all issues including suggestions + +### Package-Aware Reviews +- `/code-review --package contentstack-import` - Review changes in specific package +- `/code-review --package-type plugin` - Review plugin packages only +- `/code-review --package-type library` - Review library packages (e.g., variants) + +### File Type Focus +- `/code-review --files commands` - Review command files only +- `/code-review --files tests` - Review test files only +- `/code-review --files modules` - Review import/export modules + +## Comprehensive Review Checklist + +### Monorepo Structure Compliance +- **Package organization**: Proper placement in `packages/` structure +- **pnpm workspace**: Correct `package.json` workspace configuration +- **Build artifacts**: No `lib/` directories committed to version control +- **Dependencies**: Proper use of shared utilities (`@contentstack/cli-command`, `@contentstack/cli-utilities`) + +### TypeScript Standards (Repository-Specific) +- **Configuration compliance**: Follows package-specific TypeScript config +- **Naming conventions**: kebab-case files, PascalCase classes, camelCase functions +- **Type safety**: Appropriate use of strict mode vs relaxed settings per package +- **Import patterns**: ES modules with proper default/named exports +- **Migration strategy**: Proper use of `@ts-ignore` during gradual migration + +### OCLIF Command Patterns (Actual Implementation) +- **Base class usage**: Extends `@contentstack/cli-command` (not `@oclif/core`) +- **Command structure**: Proper `static description`, `examples`, `flags` +- **Topic organization**: Uses `cm` topic structure (`cm:stacks:import`) +- **Error handling**: Uses `handleAndLogError` from utilities +- **Validation**: Early flag validation and user-friendly error messages +- **Service delegation**: Commands orchestrate, services implement business logic + +### Testing Excellence (Mocha/Chai/Sinon Stack) +- **Framework compliance**: Uses Mocha + Chai + Sinon (not Jest) +- **File patterns**: Follows `*.test.ts` naming (or `*.test.js` for bootstrap) +- **Directory structure**: Proper placement in `test/unit/`, `test/lib/`, etc. +- **Mock patterns**: Proper Sinon stubbing of SDK methods +- **Coverage configuration**: Correct nyc setup (watch for `inlcude` typo) +- **Test isolation**: Proper `beforeEach`/`afterEach` with `sinon.restore()` +- **No real API calls**: All external dependencies properly mocked + +### Contentstack API Integration (Real Patterns) +- **SDK usage**: Proper `managementSDKClient` and fluent API chaining +- **Authentication**: Correct `configHandler` and token alias handling +- **Rate limiting compliance**: + - Batch spacing (minimum 1 second between batches) + - 429 retry handling with exponential backoff + - Pagination throttling for variants +- **Error handling**: Proper `handleAndLogError` usage and user-friendly messages +- **Configuration**: Proper regional endpoint and management token handling + +### Import/Export Module Architecture +- **BaseClass extension**: Proper inheritance from import/export BaseClass +- **Batch processing**: Correct use of `makeConcurrentCall` and `logMsgAndWaitIfRequired` +- **Module organization**: Proper entity-specific module structure +- **Configuration handling**: Proper `ModuleClassParams` usage +- **Progress feedback**: Appropriate user feedback during long operations + +### Security and Best Practices +- **Token security**: No API keys or tokens logged or committed +- **Input validation**: Proper validation of user inputs and flags +- **Error exposure**: No sensitive information in error messages +- **File permissions**: Proper handling of file system operations +- **Process management**: Appropriate use of `process.exit(1)` for critical failures + +### Performance Considerations +- **Concurrent processing**: Proper use of `Promise.allSettled` for batch operations +- **Memory management**: Appropriate handling of large datasets +- **Rate limiting**: Compliance with Contentstack API limits (10 req/sec) +- **Batch sizing**: Appropriate batch sizes for different operations +- **Progress tracking**: Efficient progress reporting without performance impact + +### Package-Specific Patterns +- **Plugin vs Library**: Correct `oclif.commands` configuration for plugin packages +- **Command compilation**: Proper build pipeline (`tsc` → `lib/commands` → `oclif manifest`) +- **Dependency management**: Correct use of shared vs package-specific dependencies +- **Test variations**: Handles different test patterns per package (JS vs TS, different structures) + +## Review Execution + +### Automated Checks +1. **Lint compliance**: ESLint and TypeScript compiler checks +2. **Test coverage**: nyc coverage thresholds (where enforced) +3. **Build verification**: Successful compilation to `lib/` directories +4. **Dependency audit**: No security vulnerabilities in dependencies + +### Manual Review Focus Areas +1. **API integration patterns**: Verify proper SDK usage and error handling +2. **Rate limiting implementation**: Check for proper throttling mechanisms +3. **Test quality**: Verify comprehensive mocking and error scenario coverage +4. **Command usability**: Ensure clear help text and examples +5. **Monorepo consistency**: Check for consistent patterns across packages + +### Common Issues to Flag +- **Coverage config typos**: `"inlcude"` instead of `"include"` in `.nycrc.json` +- **Inconsistent TypeScript**: Mixed strict mode usage without migration plan +- **Real API calls in tests**: Any unmocked external dependencies +- **Missing rate limiting**: API calls without proper throttling +- **Build artifacts committed**: Any `lib/` directories in version control +- **Inconsistent error handling**: Not using utilities error handling patterns diff --git a/.cursor/commands/execute-tests.md b/.cursor/commands/execute-tests.md new file mode 100644 index 000000000..7cde58bbd --- /dev/null +++ b/.cursor/commands/execute-tests.md @@ -0,0 +1,107 @@ +--- +name: execute-tests +description: Run tests by scope, file, or module with intelligent filtering for this pnpm monorepo +--- + +# Execute Tests Command + +## Usage Patterns + +### Monorepo-Wide Testing +- `/execute-tests` - Run all tests across all packages +- `/execute-tests --coverage` - Run all tests with nyc coverage report +- `/execute-tests --parallel` - Run package tests in parallel using pnpm + +### Package-Specific Testing +- `/execute-tests packages/contentstack-audit/` - Run tests for specific package +- `/execute-tests packages/contentstack-import/` - Run import package tests +- `/execute-tests packages/contentstack-export/` - Run export package tests +- `/execute-tests contentstack-migration` - Run tests by package name (shorthand) + +### Scope-Based Testing +- `/execute-tests unit` - Run unit tests only (`test/unit/**/*.test.ts`) +- `/execute-tests commands` - Run command tests (`test/commands/**/*.test.ts`) +- `/execute-tests services` - Run service layer tests +- `/execute-tests modules` - Run import/export module tests + +### File Pattern Testing +- `/execute-tests *.test.ts` - Run all TypeScript tests +- `/execute-tests *.test.js` - Run JavaScript tests (bootstrap package) +- `/execute-tests test/unit/services/` - Run tests for specific directory + +### Watch and Development +- `/execute-tests --watch` - Run tests in watch mode with file monitoring +- `/execute-tests --debug` - Run tests with debug output enabled +- `/execute-tests --bail` - Stop on first test failure + +## Intelligent Filtering + +### Repository-Aware Detection +- **Test patterns**: Primarily `*.test.ts`, some `*.test.js` (bootstrap), rare `*.spec.ts` +- **Directory structures**: `test/unit/`, `test/lib/`, `test/seed/`, `test/commands/` +- **Package variations**: Different test layouts per package +- **Build exclusion**: Ignores `lib/` directories (compiled artifacts) + +### Monorepo Integration +- **pnpm workspace support**: Uses `pnpm -r --filter` for package targeting +- **Dependency awareness**: Understands package interdependencies +- **Parallel execution**: Leverages pnpm's parallel capabilities +- **Selective testing**: Can target specific packages or file patterns + +### Framework Detection +- **Mocha configuration**: Respects `.mocharc.json` files per package +- **TypeScript compilation**: Handles `pretest: tsc -p test` scripts +- **Coverage integration**: Works with nyc configuration (`.nycrc.json`) +- **Test helpers**: Detects and includes test initialization files + +## Execution Examples + +### Common Workflows +```bash +# Run all tests with coverage +/execute-tests --coverage + +# Test specific package during development +/execute-tests packages/contentstack-import/ --watch + +# Run only unit tests across all packages +/execute-tests unit + +# Test import/export modules specifically +/execute-tests modules --coverage + +# Debug failing tests in audit package +/execute-tests packages/contentstack-audit/ --debug --bail +``` + +### Package-Specific Commands Generated +```bash +# For contentstack-import package +cd packages/contentstack-import && pnpm test + +# For all packages with coverage +pnpm -r --filter './packages/*' run test:coverage + +# For specific test file +cd packages/contentstack-export && npx mocha test/unit/export/modules/stack.test.ts +``` + +## Configuration Awareness + +### Mocha Integration +- Respects individual package `.mocharc.json` configurations +- Handles TypeScript compilation via `ts-node/register` +- Supports test helpers and initialization files +- Manages timeout settings per package + +### Coverage Integration +- Uses nyc for coverage reporting +- Respects `.nycrc.json` configurations (with typo detection) +- Generates HTML, text, and lcov reports +- Handles TypeScript source mapping + +### pnpm Workspace Features +- Leverages workspace dependency resolution +- Supports filtered execution by package patterns +- Enables parallel test execution across packages +- Respects package-specific scripts and configurations diff --git a/.cursor/rules/contentstack-cli.mdc b/.cursor/rules/contentstack-cli.mdc new file mode 100644 index 000000000..b7ec1b81b --- /dev/null +++ b/.cursor/rules/contentstack-cli.mdc @@ -0,0 +1,165 @@ +--- +description: 'Contentstack CLI specific patterns and API integration' +globs: ['**/import/**/*.ts', '**/export/**/*.ts', '**/modules/**/*.ts', '**/services/**/*.ts', '**/utils/**/*.ts'] +alwaysApply: false +--- + +# Contentstack CLI Standards + +## API Integration + +- Use `@contentstack/cli-utilities` for SDK factory: `managementSDKClient(config)` +- Stack-scoped API access: `stackAPIClient.asset()`, `stackAPIClient.extension()` +- Fluent SDK chaining: `stack.contentType().entry().query().find()` +- Custom HTTP for variants: `apiClient.put/get` with path strings + +## Authentication + +- Use `@contentstack/cli-utilities` for token management +- Management token alias: `configHandler.get('tokens.')` +- OAuth context: `configHandler.get('userUid'|'email'|'oauthOrgUid')` +- Authentication check: `isAuthenticated()` before operations +- Never log API keys or tokens in console or files + +## Rate Limiting - Multiple Mechanisms + +### Batch Spacing (Import/Export) +```typescript +// ✅ GOOD - Ensure minimum 1 second between batches +async logMsgAndWaitIfRequired(processName: string, start: number): Promise { + const end = Date.now(); + const exeTime = end - start; + if (exeTime < 1000) await this.delay(1000 - exeTime); +} +``` + +### 429 Retry (Branches) +```typescript +// ✅ GOOD - Handle 429 with retry +export async function handleErrorMsg(err, retryCallback?: () => Promise) { + if (err?.status === 429 || err?.response?.status === 429) { + await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 sec delay + if (retryCallback) { + return retryCallback(); // Retry the request + } + } +} +``` + +### Variant Pagination Throttle +```typescript +// ✅ GOOD - Throttle variant API requests +if (requestTime < 1000) { + await delay(1000 - requestTime); +} +``` + +## Error Handling + +### Standard Pattern +```typescript +// ✅ GOOD - Use handleAndLogError from utilities +try { + const result = await this.stack.contentType().entry().fetch(); +} catch (error) { + handleAndLogError(error); + this.logAndPrintErrorDetails(error, config); +} +``` + +### User-Friendly Errors +```typescript +// ✅ GOOD - User-facing error display +cliux.print(errorMessage, { color: 'red' }); +// For critical failures +process.exit(1); +``` + +## Module Architecture (Import/Export) + +### BaseClass Pattern +```typescript +// ✅ GOOD - Extend BaseClass for entity modules +export class ContentTypes extends BaseClass { + constructor(params: ModuleClassParams) { + super(params); + // Entity-specific initialization + } + + async import(): Promise { + // Use this.makeConcurrentCall for batching + // Use this.logMsgAndWaitIfRequired for rate limiting + } +} +``` + +### Batch Processing +```typescript +// ✅ GOOD - Concurrent batch processing +const batches = chunk(apiContent, batchSize); +for (const batch of batches) { + const start = Date.now(); + await this.makeConcurrentCall(batch, this.processItem.bind(this)); + await this.logMsgAndWaitIfRequired('Processing', start, batches.length, batchIndex); +} +``` + +## Configuration Patterns + +### Import/Export Config +```typescript +// ✅ GOOD - Use configHandler for management tokens +const config = { + host: configHandler.get('region.cma'), + managementTokenAlias: flags.alias, + stackApiKey: flags['stack-api-key'], + rateLimit: 5, // Default rate limit +}; +``` + +### Regional Configuration +```typescript +// ✅ GOOD - Handle regional endpoints +const defaultConfig = { + host: 'https://api.contentstack.io', + cdn: 'https://cdn.contentstack.io', + // Regional developer hub URLs +}; +``` + +## Testing Patterns + +### SDK Mocking +```typescript +// ✅ GOOD - Mock stack client methods +const mockStackClient = { + fetch: sinon.stub().resolves({ name: 'Test Stack', uid: 'stack-uid' }), + locale: sinon.stub().returns({ + query: sinon.stub().returns({ + find: sinon.stub().resolves({ items: [], count: 0 }), + }), + }), +}; +``` + +### Error Simulation +```typescript +// ✅ GOOD - Test error handling +it('should handle 429 rate limit', async () => { + const error = { status: 429 }; + mockClient.fetch.rejects(error); + // Test retry logic +}); +``` + +## Package-Specific Patterns + +### Plugin vs Library +- **Plugin packages**: Have `oclif.commands` in package.json +- **Library packages** (e.g., variants): No OCLIF commands, consumed by other packages + +### Monorepo Structure +- Commands: `packages/*/src/commands/cm/**/*.ts` +- Modules: `packages/*/src/{import,export,modules}/**/*.ts` +- Utilities: `packages/*/src/utils/**/*.ts` +- Built artifacts: `packages/*/lib/**` (not source) diff --git a/.cursor/rules/dev-workflow.md b/.cursor/rules/dev-workflow.md new file mode 100644 index 000000000..04ac39af6 --- /dev/null +++ b/.cursor/rules/dev-workflow.md @@ -0,0 +1,148 @@ +--- +description: "Core development workflow and TDD patterns - always applied" +globs: ["**/*.ts", "**/*.js", "**/*.json"] +alwaysApply: true +--- + +# Development Workflow + +## Monorepo Structure + +### Package Organization +- **11 plugin packages** under `packages/` +- **pnpm workspaces** with `workspaces: ["packages/*"]` +- **Shared dependencies**: `@contentstack/cli-command`, `@contentstack/cli-utilities` +- **Build artifacts**: `lib/` directory (compiled from `src/`) + +### Development Commands +```bash +# Install dependencies for all packages +pnpm install + +# Run command across all packages +pnpm -r --filter './packages/*' + +# Work on specific package +cd packages/contentstack-import +pnpm test +``` + +## TDD Workflow - MANDATORY + +1. **RED** → Write ONE failing test in `test/unit/**/*.test.ts` +2. **GREEN** → Write minimal code in `src/` to pass +3. **REFACTOR** → Improve code quality while keeping tests green + +### Test-First Examples +```typescript +// ✅ GOOD - Write test first +describe('ImportService', () => { + it('should import content types', async () => { + // Arrange - Set up mocks + mockStackClient.contentType.returns({ + create: sinon.stub().resolves({ uid: 'ct-uid' }) + }); + + // Act - Call the method + const result = await importService.importContentTypes(); + + // Assert - Verify behavior + expect(result.success).to.be.true; + expect(mockStackClient.contentType).to.have.been.called; + }); +}); +``` + +## Critical Rules + +### Testing Standards +- **NO implementation before tests** - Test-driven development only +- **Coverage aspiration**: 80% minimum (not uniformly enforced) +- **Mock all external dependencies** - No real API calls in tests +- **Use Mocha + Chai + Sinon** - Standard testing stack + +### Code Quality +- **TypeScript configuration**: Varies by package (strict mode aspirational) +- **NO test.skip or .only in commits** - Clean test suites only +- **Proper error handling** - Use `handleAndLogError` from utilities + +### Build Process +```bash +# Standard build process +pnpm run build # tsc compilation +pnpm run test # Run test suite +oclif manifest # Generate OCLIF manifest +``` + +## Package-Specific Patterns + +### Plugin Packages +- Have `oclif.commands` in `package.json` +- Commands in `src/commands/cm/**/*.ts` +- Built commands in `lib/commands/` +- Extend `@contentstack/cli-command` + +### Library Packages (e.g., variants) +- No OCLIF commands configuration +- Pure TypeScript libraries +- Consumed by other packages +- `main` points to `lib/index.js` + +## Quick Reference + +For detailed patterns, see skills: +- `@skills/testing` - Mocha, Chai, Sinon patterns and TDD workflow +- `@skills/contentstack-cli` - API integration, rate limiting, authentication +- `@skills/oclif-commands` - Command structure, base classes, validation + +## Development Checklist + +### Before Starting Work +- [ ] Identify target package in `packages/` +- [ ] Check existing tests in `test/unit/` +- [ ] Understand command structure if working on commands +- [ ] Set up proper TypeScript configuration + +### During Development +- [ ] Write failing test first +- [ ] Implement minimal code to pass +- [ ] Mock external dependencies (SDK, file system, etc.) +- [ ] Use proper error handling patterns +- [ ] Follow naming conventions (kebab-case files, PascalCase classes) + +### Before Committing +- [ ] All tests pass: `pnpm test` +- [ ] No `.only` or `.skip` in test files +- [ ] Build succeeds: `pnpm run build` +- [ ] TypeScript compilation clean +- [ ] Proper error handling implemented + +## Common Patterns + +### Service Layer Architecture +```typescript +// ✅ GOOD - Separate concerns +export default class ImportCommand extends Command { + async run(): Promise { + const config = this.buildConfig(); + const service = new ImportService(config); + + try { + await service.execute(); + this.log('Import completed successfully'); + } catch (error) { + handleAndLogError(error); + } + } +} +``` + +### Rate Limiting Compliance +```typescript +// ✅ GOOD - Respect API limits +async processBatch(batch: Item[]): Promise { + const start = Date.now(); + await this.makeConcurrentCall(batch, this.processItem); + await this.logMsgAndWaitIfRequired('Processing', start); +} +``` diff --git a/.cursor/rules/oclif-commands.mdc b/.cursor/rules/oclif-commands.mdc new file mode 100644 index 000000000..ac186ff52 --- /dev/null +++ b/.cursor/rules/oclif-commands.mdc @@ -0,0 +1,219 @@ +--- +description: 'OCLIF command development patterns and CLI best practices' +globs: ['**/commands/**/*.ts'] +alwaysApply: false +--- + +# OCLIF Command Standards + +## Command Structure + +### Standard Command Pattern +```typescript +// ✅ GOOD - Standard command structure +import { Command } from '@contentstack/cli-command'; + +export default class ImportCommand extends Command { + static description = 'Import content from a stack'; + + static examples: string[] = [ + 'csdx cm:stacks:import --stack-api-key --data-dir ', + 'csdx cm:stacks:import --alias --config ', + ]; + + static flags = { + // Define flags using utilities + }; + + async run(): Promise { + // Main command logic + } +} +``` + +## Base Classes Available + +### BaseCommand (Audit Package) +```typescript +// ✅ GOOD - Extend BaseCommand for shared functionality +export abstract class BaseCommand extends Command { + static baseFlags: FlagInput = { + config: Flags.string({ char: 'c', description: 'Config path' }), + 'data-dir': Flags.string({ char: 'd', description: 'Data directory' }), + 'show-console-output': Flags.boolean({ description: 'Show console output' }), + }; + + public async init(): Promise { + await super.init(); + const { args, flags } = await this.parse({ + flags: this.ctor.flags, + baseFlags: (super.ctor as typeof BaseCommand).baseFlags, + // ... + }); + } +} +``` + +### BaseCommand (Export-to-CSV Package) +```typescript +// ✅ GOOD - Lightweight base with command context +export abstract class BaseCommand extends Command { + public commandContext!: CommandContext; + + public async init(): Promise { + await super.init(); + this.commandContext = this.createCommandContext(); + log.debug('Command initialized', this.commandContext); + } + + protected async catch(err: Error & { exitCode?: number }): Promise { + log.debug('Command error caught', { ...this.commandContext, error: err.message }); + return super.catch(err); + } +} +``` + +## Command Patterns + +### Import Commands +- Use `@contentstack/cli-command` Command base +- Parse with `ImportCommand` type for config validation +- Handle authentication via `configHandler` and `isAuthenticated` +- Delegate to service layer modules + +### Direct Extension Pattern +```typescript +// ✅ GOOD - Most packages extend Command directly +export default class BranchMerge extends Command { + static description = 'Merge branches'; + + async run(): Promise { + const { flags } = await this.parse(BranchMerge); + // Command-specific logic + } +} +``` + +## OCLIF Configuration + +### Package.json Setup +```json +{ + "oclif": { + "commands": "./lib/commands", + "bin": "csdx", + "topicSeparator": ":" + } +} +``` + +### Command Topics +- All commands use `cm` topic: `cm:stacks:import`, `cm:branches:merge` +- Built commands live in `lib/commands` (compiled from `src/commands`) +- Optional `csdxConfig.shortCommandName` for abbreviated names + +## Error Handling + +### Standard Error Pattern +```typescript +// ✅ GOOD - Use handleAndLogError from utilities +try { + await this.executeCommand(); +} catch (error) { + handleAndLogError(error); + this.logAndPrintErrorDetails(error, config); +} +``` + +### User-Friendly Messages +```typescript +// ✅ GOOD - Clear user feedback +cliux.print('Operation completed successfully', { color: 'green' }); +cliux.print('Error occurred', { color: 'red' }); + +// For critical failures +process.exit(1); +``` + +## Validation Patterns + +### Early Validation +```typescript +// ✅ GOOD - Validate flags early +async run(): Promise { + const { flags } = await this.parse(MyCommand); + + // Validate required combinations + if (!flags.alias && !flags['stack-api-key']) { + this.error('Either --alias or --stack-api-key is required'); + } + + // Proceed with validated input +} +``` + +### Authentication Check +```typescript +// ✅ GOOD - Check authentication before operations +if (!isAuthenticated()) { + this.error('Please login first using: csdx auth:login'); +} +``` + +## Progress and Logging + +### Progress Feedback +```typescript +// ✅ GOOD - Provide user feedback +this.log('Starting import process...'); +cliux.print('Processing entries...', { color: 'blue' }); + +// Use progress bars for long operations +const progressBar = cliux.progress.start(total); +progressBar.increment(); +progressBar.stop(); +``` + +### Debug Logging +```typescript +// ✅ GOOD - Use structured logging +log.debug('Command initialized', { + command: this.id, + flags: this.flags +}); +``` + +## Command Delegation + +### Service Layer Separation +```typescript +// ✅ GOOD - Commands orchestrate, services implement +async run(): Promise { + const config = this.buildConfig(); + const service = new ImportService(config); + + try { + await service.execute(); + this.log('Import completed successfully'); + } catch (error) { + this.handleError(error); + } +} +``` + +## Testing Commands + +### OCLIF Test Support +```typescript +// ✅ GOOD - Use @oclif/test for command testing +import { test } from '@oclif/test'; + +describe('cm:stacks:import', () => { + test + .stdout() + .command(['cm:stacks:import', '--help']) + .it('shows help', ctx => { + expect(ctx.stdout).to.contain('Import content from a stack'); + }); +}); +``` diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 000000000..7fc3a7c93 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,266 @@ +--- +description: 'Testing patterns and TDD workflow' +globs: ['**/test/**/*.ts', '**/test/**/*.js', '**/__tests__/**/*.ts', '**/*.spec.ts', '**/*.test.ts'] +alwaysApply: true +--- + +# Testing Standards + +## Framework Stack + +### Primary Testing Tools +- **Mocha** - Test runner (used across all packages) +- **Chai** - Assertion library +- **Sinon** - Mocking and stubbing +- **@oclif/test** - Command testing support +- **nyc** - Code coverage + +### Package-Specific Tools +- **nock** - HTTP mocking (migration package) +- **rewire** - Module patching (import package) + +## Test File Patterns + +### Naming Conventions +- **Primary**: `*.test.ts` (dominant pattern) +- **Alternative**: `*.spec.ts` (less common) +- **Bootstrap exception**: `*.test.js` (JavaScript tests) + +### Directory Structure +``` +packages/*/ +├── test/unit/**/*.test.ts # Most packages +├── test/lib/**/*.test.ts # clone package +├── test/seed/**/*.test.ts # seed package +└── test/commands/**/*.test.ts # command-specific tests +``` + +## Mocha Configuration + +### Standard Setup (.mocharc.json) +```json +{ + "require": [ + "test/helpers/init.js", + "ts-node/register", + "source-map-support/register" + ], + "recursive": true, + "timeout": 30000, + "spec": "test/**/*.test.ts" +} +``` + +### TypeScript Compilation +```json +// package.json scripts +{ + "pretest": "tsc -p test", + "test": "nyc --extension .ts mocha" +} +``` + +## Mocking Patterns + +### Sinon SDK Mocking +```typescript +// ✅ GOOD - Mock Contentstack SDK methods +const mockStackClient = { + fetch: sinon.stub().resolves({ + name: 'Test Stack', + uid: 'stack-uid', + org_uid: 'org-uid' + }), + locale: sinon.stub().returns({ + query: sinon.stub().returns({ + find: sinon.stub().resolves({ + items: [{ + uid: 'locale-1', + name: 'English (United States)', + code: 'en-us' + }], + count: 1, + }), + }), + }), +}; +``` + +### Module Stubbing +```typescript +// ✅ GOOD - Stub sibling modules +beforeEach(() => { + sinon.stub(mapModule, 'processEntries').resolves([]); + sinon.stub(configModule, 'getConfig').returns(mockConfig); +}); + +afterEach(() => { + sinon.restore(); +}); +``` + +### HTTP Mocking (Migration) +```typescript +// ✅ GOOD - Use nock for HTTP mocking +import nock from 'nock'; + +beforeEach(() => { + nock('https://api.contentstack.io') + .get('/v3/stacks') + .reply(200, { stacks: [] }); +}); +``` + +## Coverage Configuration + +### NYC Setup (.nycrc.json) +```json +{ + "extension": [".ts"], + "include": ["src/**/*.ts"], + "exclude": ["**/*.test.ts", "**/*.spec.ts"], + "reporter": ["text", "html", "lcov"], + "all": true +} +``` + +### Coverage Targets +- **Team aspiration**: 80% minimum coverage +- **Current enforcement**: Inconsistent across packages +- **Note**: Some packages have `check-coverage: false` +- **Typo alert**: Several `.nycrc.json` files have `"inlcude"` instead of `"include"` + +## Test Structure + +### Standard Test Pattern +```typescript +// ✅ GOOD - Comprehensive test structure +describe('ContentTypes Module', () => { + let mockStackClient: any; + let contentTypes: ContentTypes; + + beforeEach(() => { + mockStackClient = createMockStackClient(); + contentTypes = new ContentTypes({ + stackAPIClient: mockStackClient, + importConfig: mockConfig, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('import()', () => { + it('should import content types successfully', async () => { + // Arrange + mockStackClient.contentType.returns({ + create: sinon.stub().resolves({ uid: 'ct-uid' }) + }); + + // Act + await contentTypes.import(); + + // Assert + expect(mockStackClient.contentType).to.have.been.called; + }); + + it('should handle API errors gracefully', async () => { + // Arrange + const error = new Error('API Error'); + mockStackClient.contentType.throws(error); + + // Act & Assert + await expect(contentTypes.import()).to.be.rejectedWith('API Error'); + }); + }); +}); +``` + +## Command Testing + +### OCLIF Test Pattern +```typescript +// ✅ GOOD - Test commands with @oclif/test +import { test } from '@oclif/test'; + +describe('cm:stacks:import', () => { + test + .stdout() + .command(['cm:stacks:import', '--help']) + .it('shows help message', ctx => { + expect(ctx.stdout).to.contain('Import content from a stack'); + }); + + test + .stderr() + .command(['cm:stacks:import']) + .exit(2) + .it('fails without required flags'); +}); +``` + +## Error Testing + +### Rate Limit Testing +```typescript +// ✅ GOOD - Test rate limiting behavior +it('should handle 429 rate limit errors', async () => { + const rateLimitError = { status: 429 }; + mockClient.fetch.onFirstCall().rejects(rateLimitError); + mockClient.fetch.onSecondCall().resolves(mockResponse); + + const result = await service.fetchWithRetry(); + + expect(mockClient.fetch).to.have.been.calledTwice; + expect(result).to.equal(mockResponse); +}); +``` + +### Authentication Testing +```typescript +// ✅ GOOD - Test authentication scenarios +it('should handle token expiration', async () => { + const authError = { status: 401, message: 'Unauthorized' }; + mockClient.fetch.rejects(authError); + + await expect(service.makeRequest()).to.be.rejectedWith('Unauthorized'); +}); +``` + +## Test Data Management + +### Mock Data Organization +```typescript +// ✅ GOOD - Organize test data +const mockData = { + contentTypes: [ + { uid: 'ct1', title: 'Content Type 1' }, + { uid: 'ct2', title: 'Content Type 2' }, + ], + entries: [ + { uid: 'entry1', title: 'Entry 1', content_type: 'ct1' }, + ], +}; +``` + +### Test Helpers +```typescript +// ✅ GOOD - Create reusable test utilities +export function createMockStackClient() { + return { + fetch: sinon.stub(), + contentType: sinon.stub(), + entry: sinon.stub(), + // ... other methods + }; +} +``` + +## Critical Testing Rules + +- **No real API calls** - Always mock external dependencies +- **Test both success and failure paths** - Cover error scenarios +- **Mock at service boundaries** - Don't mock internal implementation details +- **Use proper cleanup** - Always restore stubs in afterEach +- **Test command validation** - Verify flag validation and error messages diff --git a/.cursor/rules/typescript.mdc b/.cursor/rules/typescript.mdc new file mode 100644 index 000000000..d3ff4774b --- /dev/null +++ b/.cursor/rules/typescript.mdc @@ -0,0 +1,259 @@ +--- +description: 'TypeScript strict mode standards and naming conventions' +globs: ['**/*.ts', '**/*.tsx'] +alwaysApply: false +--- + +# TypeScript Standards + +## Configuration + +### Root Configuration +```json +// tsconfig.json - Baseline configuration +{ + "compilerOptions": { + "strict": true, + "module": "commonjs", + "target": "es2016", + "declaration": true, + "outDir": "lib", + "rootDir": "src" + } +} +``` + +### Package-Level Variations +```json +// Most packages override with: +{ + "compilerOptions": { + "strict": false, // ⚠️ Relaxed for legacy code + "noImplicitAny": true, // ✅ Still enforce type annotations + "target": "es2017", + "allowJs": true // Mixed JS/TS support + } +} +``` + +### Modern Packages (Bootstrap, Variants) +```json +// TypeScript 5.x with stricter settings +{ + "compilerOptions": { + "strict": true, + "target": "es2020", + "moduleResolution": "node16" + } +} +``` + +## Naming Conventions (Actual Usage) + +### Files +- **Primary pattern**: `kebab-case.ts` (`base-class.ts`, `import-config-handler.ts`) +- **Single-word modules**: `stack.ts`, `locales.ts`, `entries.ts` +- **Commands**: Follow OCLIF topic structure (`cm/stacks/import.ts`) + +### Classes +```typescript +// ✅ GOOD - PascalCase for classes +export class ImportCommand extends Command { } +export class BaseClass { } +export class ExportStack { } +export class ContentTypes { } +``` + +### Functions and Methods +```typescript +// ✅ GOOD - camelCase for functions +export async function fetchAllEntries(): Promise { } +async logMsgAndWaitIfRequired(): Promise { } +createCommandContext(): CommandContext { } +``` + +### Constants +```typescript +// ✅ GOOD - SCREAMING_SNAKE_CASE for constants +const DEFAULT_RATE_LIMIT = 5; +const MAX_RETRY_ATTEMPTS = 3; +const API_BASE_URL = 'https://api.contentstack.io'; +``` + +### Interfaces and Types +```typescript +// ✅ GOOD - PascalCase for types +export interface ModuleClassParams { + importConfig: ImportConfig; + stackAPIClient: ManagementStack; +} + +export type ApiOptions = { + host?: string; + timeout?: number; +}; + +export type EnvType = 'development' | 'staging' | 'production'; +``` + +## Import/Export Patterns + +### ES Modules (Preferred) +```typescript +// ✅ GOOD - ES import/export syntax +import { Command } from '@contentstack/cli-command'; +import type { ImportConfig } from '../types'; +import { managementSDKClient } from '@contentstack/cli-utilities'; + +export default class ImportCommand extends Command { } +export { ImportConfig, ApiOptions }; +``` + +### Default Exports +```typescript +// ✅ GOOD - Default export for commands and main classes +export default class ImportCommand extends Command { } +export default class BaseClass { } +``` + +### Named Exports +```typescript +// ✅ GOOD - Named exports for utilities and types +export async function delay(ms: number): Promise { } +export interface ConfigOptions { } +export type ModuleType = 'import' | 'export'; +``` + +## Type Definitions + +### Local Types +```typescript +// ✅ GOOD - Define types close to usage +export interface ImportOptions { + stackApiKey: string; + dataDir: string; + rateLimit?: number; +} + +export type BatchResult = { + success: boolean; + errors: Error[]; + processedCount: number; +}; +``` + +### Type Organization +```typescript +// ✅ GOOD - Organize types in dedicated files +// src/types/index.ts +export interface ImportConfig { } +export interface ExportConfig { } +export type ModuleClassParams = { }; +``` + +## Strict Mode Compliance + +### Function Return Types +```typescript +// ✅ GOOD - Explicit return types +export async function fetchEntries(): Promise { + return await this.stack.entry().query().find(); +} + +export function createConfig(): ImportConfig { + return { + stackApiKey: '', + dataDir: './data', + }; +} +``` + +### Null Safety +```typescript +// ✅ GOOD - Handle null/undefined explicitly +function processEntry(entry: Entry | null): void { + if (!entry) { + throw new Error('Entry is required'); + } + // Process entry safely +} +``` + +### Type Guards +```typescript +// ✅ GOOD - Use type guards for runtime checks +function isImportConfig(config: unknown): config is ImportConfig { + return typeof config === 'object' && + config !== null && + 'stackApiKey' in config; +} +``` + +## Error Handling Types + +### Custom Error Classes +```typescript +// ✅ GOOD - Typed error classes +export class ContentstackApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly cause?: Error + ) { + super(message); + this.name = 'ContentstackApiError'; + } +} +``` + +### Error Union Types +```typescript +// ✅ GOOD - Model expected errors +type ApiResult = { + success: true; + data: T; +} | { + success: false; + error: string; + statusCode: number; +}; +``` + +## Migration Strategy + +### Gradual Strict Mode Adoption +```typescript +// ✅ ACCEPTABLE - Gradual migration approach +// @ts-ignore for legacy code during migration +// TODO: Remove @ts-ignore and fix types +// @ts-ignore +const legacyResult = oldApiCall(); +``` + +### Type Assertions (Use Sparingly) +```typescript +// ⚠️ USE CAREFULLY - Type assertions when necessary +const config = unknownConfig as ImportConfig; + +// ✅ BETTER - Use type guards instead +if (isImportConfig(unknownConfig)) { + const config = unknownConfig; // TypeScript knows the type +} +``` + +## Package-Specific Patterns + +### Command Packages +- Extend `@contentstack/cli-command` types +- Use OCLIF flag types from utilities +- Define command-specific interfaces + +### Library Packages (Variants) +- No OCLIF dependencies +- Pure TypeScript interfaces +- Consumed by other packages + +### Test Files +- Use `any` sparingly for mock objects +- Prefer typed mocks when possible +- Test type safety with TypeScript compiler diff --git a/.talismanrc b/.talismanrc index 4406a0bb9..6650b5fff 100644 --- a/.talismanrc +++ b/.talismanrc @@ -2,5 +2,85 @@ fileignoreconfig: - filename: packages/contentstack-import/src/utils/import-config-handler.ts checksum: 3194f537cee8041f07a7ea91cdc6351c84e400766696d9c3cf80b98f99961f76 - filename: pnpm-lock.yaml - checksum: cea25dedde40bf962d825a088e505113c997ae666a4385d3eec0ae3f9f5d1404 + checksum: 3e5093383a595967f411433e1e5aa84f94bed84f2a6ba8d1af3075e24a6863e3 + - filename: packages/contentstack-export/src/export/modules/environments.ts + checksum: a92c5de7ed8e80f08f911727973a66e0416b4a52265c275d1d25c3095f912811 + - filename: packages/contentstack-import/src/utils/backup-handler.ts + checksum: 9a892b5c4b5aac230fb5969e7f34afdac0b6f96208e64bf9d1195468c935c66c + - filename: packages/contentstack-import/test/unit/utils/backup-handler.test.ts + checksum: 69860727e9b3099d8e1e95db2af17fc8b161684f675477981d27877cd8e1b3bb + - filename: packages/contentstack-export/src/types/default-config.ts + checksum: 5f0b0bb753242356edacb802241ec937a7741647813f9f347837368f08265667 + - filename: packages/contentstack-export/src/types/index.ts + checksum: fa36c236abac338b03bf307102a99f25dddac9afe75b6b34fb82e318e7759799 + - filename: packages/contentstack-asset-management/.eslintrc + checksum: 136f03481c8c59575d2eafd4c78d105119f85fb10fe88e02af8cffaf3eb7c090 + - filename: packages/contentstack-asset-management/src/import/asset-types.ts + checksum: f65307b45623e2d0f17c2b0e26c34f92509850739757a0a9357a48a1c3e2234f + - filename: packages/contentstack-audit/test/unit/logger-config.js + checksum: 493e2e65939325f48d354469f409f1dbf84462adca995ed3a78461e80148d309 + - filename: packages/contentstack-export/test/unit/export/module-exporter.test.ts + checksum: 67b70c93ed679ccb2c61d0c277380676e33c91da8a423f948e81937e5d1d9479 + - filename: packages/contentstack-asset-management/src/types/asset-management-api.ts + checksum: 6629720575ab48371734d9455d591a431604b5afb2c5c682816e1571377a43ab + - filename: packages/contentstack-export/test/unit/export/modules/assets.test.ts + checksum: c4dc86b0973af171a11884e0bff9bb9ce5e41df68906d924588c0bf51b19ae9b + - filename: packages/contentstack-export/test/unit/export/modules/base-class.test.ts + checksum: 893a09567def9768c63310326e3bd35c2570bc436a9b9013147c6d383c949e11 + - filename: packages/contentstack-branches/src/branch/diff-handler.ts + checksum: 3cd4d26a2142cab7cbf2094c9251e028467d17d6a1ed6daf22f21975133805f1 + - filename: packages/contentstack-audit/test/unit/base-command.test.ts + checksum: 4208fae6e7cf1aeeb2b936d119c85cdc40e5e3560c7207e04bb94ba3e0305557 + - filename: packages/contentstack-export/test/unit/export/modules/marketplace-apps.test.ts + checksum: 299b8f60cce1f64be7c20786d6a7c9c370474b97b06d1846114a76a70ec20cf7 + - filename: packages/contentstack-audit/src/modules/assets.ts + checksum: b8b727867f8f6fb52ba18c33d158e1ee7bce9a15153c45becba6f73da16b5fcb + - filename: packages/contentstack-export/src/export/modules/assets.ts + checksum: 1d0ec8a15b35fb71261556e1982f53e7c940ddde49497f64d7a6fd7a7707bae4 + - filename: packages/contentstack-asset-management/src/types/export-types.ts + checksum: 48add19a8466083905e15d6a8a925cd5341fa56cb945f91e411ffee9cd08975b + - filename: packages/contentstack-export/test/unit/export/modules/stack.test.ts + checksum: 79876b8f635037a2d8ba38dac055e7625bf85db6a3cf5729434e6a97e44857d6 + - filename: packages/contentstack-asset-management/src/export/base.ts + checksum: 0fee8bb293b841dcf59ac5c566cb1b1b43a43e27041e7a9cdbee6f0e436c9598 + - filename: packages/contentstack-export/src/config/index.ts + checksum: ae655e25cefff007c4ae4006c67b1529951350d9d2a3d179ef0a80d3da326d5a + - filename: packages/contentstack-branches/src/branch/merge-handler.ts + checksum: 4fd8dba9b723733530b9ba12e81e1d3e5d60b73ac4c082defb10593f257bb133 + - filename: packages/contentstack-asset-management/src/utils/asset-management-api-adapter.ts + checksum: 256ddcfbb10ee4ccfac2ea5c2d733199f8830a78896196d1e965109942b234e8 + - filename: packages/contentstack-import/src/types/default-config.ts + checksum: 1c09acba953cfd7058a3e0d63f0a9bfbb8f28e903538eaa015fdc611402bbd4f + - filename: packages/contentstack-asset-management/src/utils/export-helpers.ts + checksum: 1a0a04d5d86a07307122c5b160d8c3a831f0e17b7a1d2b5aaf16b1a73e231981 + - filename: packages/contentstack-import/src/utils/build-import-spaces-options.ts + checksum: fe0cb6cb5903515982af1e3642f2a19233207d35f13dc205cebeda0aa399f8b5 + - filename: packages/contentstack-branches/src/commands/cm/branches/merge-status.ts + checksum: 6e5b959ddcc5ff68e03c066ea185fcf6c6e57b1819069730340af35aad8a93a8 + - filename: packages/contentstack-asset-management/src/import/spaces.ts + checksum: e607b8a9e42ffab01ba328272cf27106ed2728856ecbe0d6ed791e72d70a27fe + - filename: packages/contentstack-asset-management/src/import/assets.ts + checksum: 20d51c63e9c00783caa3eba9239879e687b34637105c9539be667439e9fa64ab + - filename: packages/contentstack-branches/src/utils/create-branch.ts + checksum: d0613295ee26f7a77d026e40db0a4ab726fabd0a74965f729f1a66d1ef14768f + - filename: packages/contentstack-asset-management/src/import/base.ts + checksum: 0c7ff03a094e9d84710752ee875421e9a21dcc088f56286aa172a96127ad5f8e + - filename: packages/contentstack-asset-management/test/unit/utils/asset-management-api-adapter.test.ts + checksum: ff688f37f40de3f7cbef378ec682ca1167720d902d8d84370464af7feb36c124 + - filename: packages/contentstack-asset-management/src/import/fields.ts + checksum: 09f528b7af3db71f4d939f3768c4760a95c2593c3ee93a7cb568d41f1dbaa71c + - filename: packages/contentstack-asset-management/test/unit/utils/export-helpers.test.ts + checksum: 0e8751163491fc45e7ae3999282d336ae1ab8a9f88e601cbb85b4f44e8db96b8 + - filename: packages/contentstack-export/src/export/modules/stack.ts + checksum: 00774a601a5d2b4a47a91fe5bbb0ea9c93c48fa785ee9887c0d74a6b6ec21296 + - filename: packages/contentstack-audit/test/unit/modules/entries.test.ts + checksum: aaf2e125c5e93ab15364e41559390502a18b83a4b3de5879c02572969381c0a6 + - filename: packages/contentstack-asset-management/test/unit/export/base.test.ts + checksum: 164fc2e5a4337a2739903499b66eecc66a85bb9b50aa2e71079bdd046a195a94 + - filename: skills/code-review/SKILL.md + checksum: 29673e16f6b41fcec7fa236912e7f72b920ed4a3d9a66a89308b4a058b247f3e + - filename: skills/contentstack-cli/SKILL.md + checksum: 36762d43bbacedd0b344f9d4f1179a88e3dbc7e2467341ba42198dcd1bf9e40c + - filename: skills/testing/SKILL.md + checksum: ee1c82f1bb51860cb26fb9f112a53df0127e316fcb22a094034024741251fa3c version: '1.0' diff --git a/packages/contentstack-asset-management/README.md b/packages/contentstack-asset-management/README.md deleted file mode 100644 index 87867c247..000000000 --- a/packages/contentstack-asset-management/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# @contentstack/cli-asset-management - -Asset Management 2.0 API adapter for Contentstack CLI export and import. Used by the export and import plugins when Asset Management (AM 2.0) is enabled. To learn how to export and import content in Contentstack, refer to the [Migration guide](https://www.contentstack.com/docs/developers/cli/migration/). - -[![License](https://img.shields.io/npm/l/@contentstack/cli)](https://github.com/contentstack/cli/blob/main/LICENSE) - - -* [@contentstack/cli-asset-management](#contentstackcli-asset-management) -* [Overview](#overview) -* [Usage](#usage) -* [Exports](#exports) - - -# Overview - -This package provides: - -- **AssetManagementAdapter** – HTTP client for the Asset Management API (spaces, assets, folders, fields, asset types). -- **exportSpaceStructure** – Exports space metadata and full workspace structure (metadata, folders, assets, fields, asset types) for linked workspaces. -- **Types** – `AssetManagementExportOptions`, `LinkedWorkspace`, `IAssetManagementAdapter`, and related types for export/import integration. - -# Usage - -This package is consumed by the export and import plugins. When using the export CLI with the `--asset-management` flag (or when the host app enables AM 2.0), the export plugin calls `exportSpaceStructure` with linked workspaces and options: - -```ts -import { exportSpaceStructure } from '@contentstack/cli-asset-management'; - -await exportSpaceStructure({ - linkedWorkspaces, - exportDir, - branchName: 'main', - assetManagementUrl, - org_uid, - context, - progressManager, - progressProcessName, - updateStatus, - downloadAsset, // optional -}); -``` - -# Exports - -| Export | Description | -|--------|-------------| -| `exportSpaceStructure` | Async function to export space structure for given linked workspaces. | -| `AssetManagementAdapter` | Class to call the Asset Management API (getSpace, getWorkspaceFields, getWorkspaceAssets, etc.). | -| Types from `./types` | `AssetManagementExportOptions`, `ExportSpaceOptions`, `ChunkedJsonWriteOptions`, `LinkedWorkspace`, `SpaceResponse`, `FieldsResponse`, `AssetTypesResponse`, and related API types. | diff --git a/packages/contentstack-asset-management/src/constants/index.ts b/packages/contentstack-asset-management/src/constants/index.ts index 9d6bca636..f7f0ff0c8 100644 --- a/packages/contentstack-asset-management/src/constants/index.ts +++ b/packages/contentstack-asset-management/src/constants/index.ts @@ -35,7 +35,17 @@ export const CHUNK_FILE_SIZE_MB = FALLBACK_AM_CHUNK_FILE_SIZE_MB; export const AM_MAIN_PROCESS_NAME = 'Asset Management 2.0'; /** - * Process names for Asset Management 2.0 export progress (for tick labels). + * Process names for Asset Management 2.0 export/import progress. + * + * In the new per-space layout each entry below corresponds to a single row in + * the multibar: + * - {@link AM_FIELDS} / {@link AM_ASSET_TYPES} are the shared bootstrap rows + * (one execution per org, ahead of per-space work). + * - {@link AM_IMPORT_FIELDS} / {@link AM_IMPORT_ASSET_TYPES} are the import + * equivalents. + * - One additional row per space is added dynamically via + * {@link getSpaceProcessName} and ticks include folders + metadata + asset + * transfer for that space. */ export const PROCESS_NAMES = { AM_SPACE_METADATA: 'Space metadata', @@ -51,6 +61,38 @@ export const PROCESS_NAMES = { AM_IMPORT_ASSETS: 'Import assets', } as const; +/** + * Maximum visual length of a per-space process row label. The CLIProgressManager + * truncates anything over 20 characters; reserve 6 chars for the `Space ` prefix + * so the trailing space uid keeps 14 chars before truncation. + */ +const SPACE_PROCESS_NAME_PREFIX = 'Space '; +const SPACE_PROCESS_NAME_MAX_UID_LEN = 14; + +/** + * Returns the multibar row label for a single AM 2.0 space. + * The label is bounded so CLIProgressManager.formatProcessName doesn't truncate + * it mid-string; the full uid is still used for tick item labels and structured + * logs, only the row label itself is shortened for display. + */ +export function getSpaceProcessName(spaceUid: string): string { + const safeUid = spaceUid ?? ''; + const trimmed = + safeUid.length > SPACE_PROCESS_NAME_MAX_UID_LEN + ? safeUid.substring(0, SPACE_PROCESS_NAME_MAX_UID_LEN) + : safeUid; + return `${SPACE_PROCESS_NAME_PREFIX}${trimmed}`; +} + +/** + * Detects whether a process name belongs to a per-space progress row, used by + * the export/import strategy registries to aggregate counts for the final + * summary across all spaces. + */ +export function isSpaceProcessName(processName: string): boolean { + return typeof processName === 'string' && processName.startsWith(SPACE_PROCESS_NAME_PREFIX); +} + /** * Status messages for each process (exporting, fetching, importing, failed). */ diff --git a/packages/contentstack-asset-management/src/export/asset-types.ts b/packages/contentstack-asset-management/src/export/asset-types.ts index 6223b38d5..bd6c5f17c 100644 --- a/packages/contentstack-asset-management/src/export/asset-types.ts +++ b/packages/contentstack-asset-management/src/export/asset-types.ts @@ -7,6 +7,8 @@ import { getArrayFromResponse } from '../utils/export-helpers'; import { PROCESS_NAMES } from '../constants/index'; export default class ExportAssetTypes extends AssetManagementExportAdapter { + protected processName: string = PROCESS_NAMES.AM_ASSET_TYPES; + constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { super(apiConfig, exportContext); } @@ -24,7 +26,13 @@ export default class ExportAssetTypes extends AssetManagementExportAdapter { } else { log.debug(`Writing ${items.length} shared asset types`, this.exportContext.context); } - await this.writeItemsToChunkedJson(dir, 'asset-types.json', 'asset_types', ['uid', 'title', 'category', 'file_extension'], items); - this.tick(true, PROCESS_NAMES.AM_ASSET_TYPES, null); + await this.writeItemsToChunkedJson( + dir, + 'asset-types.json', + 'asset_types', + ['uid', 'title', 'category', 'file_extension'], + items, + ); + this.tick(true, `asset_types (${items.length})`, null); } } diff --git a/packages/contentstack-asset-management/src/export/assets.ts b/packages/contentstack-asset-management/src/export/assets.ts index acd0f1676..dc8587111 100644 --- a/packages/contentstack-asset-management/src/export/assets.ts +++ b/packages/contentstack-asset-management/src/export/assets.ts @@ -32,11 +32,17 @@ export default class ExportAssets extends AssetManagementExportAdapter { this.getWorkspaceAssets(workspace.space_uid, workspace.uid), ]); + const assetItems = getAssetItems(assetsData); + const downloadableCount = assetItems.filter((asset) => Boolean(asset.url && (asset.uid ?? asset._uid))).length; + // Per-space total: 1 folder write + 1 metadata write + N per-asset downloads. + // The shared module-level total is just a placeholder before this point; update + // it now so the multibar row shows real progress as downloads tick in. + this.progressOrParent?.updateProcessTotal?.(this.processName, 2 + downloadableCount); + await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2)); this.tick(true, `folders: ${workspace.space_uid}`, null); log.debug(`Wrote folders.json for space ${workspace.space_uid}`, this.exportContext.context); - const assetItems = getAssetItems(assetsData); log.debug( assetItems.length === 0 ? `No assets for space ${workspace.space_uid}, wrote empty assets.json` @@ -60,7 +66,7 @@ export default class ExportAssets extends AssetManagementExportAdapter { : `Wrote ${assetItems.length} asset metadata record(s) for space ${workspace.space_uid}`, this.exportContext.context, ); - this.tick(true, `assets: ${workspace.space_uid} (${assetItems.length})`, null); + this.tick(true, `metadata: ${workspace.space_uid} (${assetItems.length})`, null); log.debug(`Starting binary downloads for space ${workspace.space_uid}`, this.exportContext.context); await this.downloadWorkspaceAssets(assetsData, assetsDir, workspace.space_uid); @@ -87,8 +93,6 @@ export default class ExportAssets extends AssetManagementExportAdapter { `Asset downloads: securedAssets=${securedAssets}, concurrency=${this.downloadAssetsBatchConcurrency}`, this.exportContext.context, ); - let lastError: string | null = null; - let allSuccess = true; let downloadOk = 0; let downloadFail = 0; @@ -118,24 +122,25 @@ export default class ExportAssets extends AssetManagementExportAdapter { const filePath = pResolve(assetFolderPath, filename); await writeStreamToFile(nodeStream, filePath); downloadOk += 1; + // Per-asset tick so the per-space progress bar moves in real time. + this.tick(true, `asset: ${filename}`, null); log.debug(`Downloaded asset ${uid} → ${filePath}`, this.exportContext.context); } catch (e) { - allSuccess = false; downloadFail += 1; - lastError = (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].FAILED; + const err = (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].FAILED; + this.tick(false, `asset: ${filename}`, err); log.debug(`Failed to download asset ${uid}: ${e}`, this.exportContext.context); } }); - this.tick(allSuccess, `downloads: ${spaceUid}`, lastError); log.info( - allSuccess + downloadFail === 0 ? `Finished downloading ${downloadOk} asset file(s) for space ${spaceUid}` : `Asset downloads for space ${spaceUid} completed with errors: ${downloadOk} succeeded, ${downloadFail} failed`, this.exportContext.context, ); log.debug( - `Asset downloads finished for space ${spaceUid}: ok=${downloadOk}, failed=${downloadFail}, allSuccess=${allSuccess}`, + `Asset downloads finished for space ${spaceUid}: ok=${downloadOk}, failed=${downloadFail}`, this.exportContext.context, ); } diff --git a/packages/contentstack-asset-management/src/export/base.ts b/packages/contentstack-asset-management/src/export/base.ts index 055d2d3ba..856781653 100644 --- a/packages/contentstack-asset-management/src/export/base.ts +++ b/packages/contentstack-asset-management/src/export/base.ts @@ -18,7 +18,7 @@ export class AssetManagementExportAdapter extends AssetManagementAdapter { protected readonly exportContext: ExportContext; protected progressManager: CLIProgressManager | null = null; protected parentProgressManager: CLIProgressManager | null = null; - protected readonly processName: string = AM_MAIN_PROCESS_NAME; + protected processName: string = AM_MAIN_PROCESS_NAME; constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { super(apiConfig); @@ -30,6 +30,15 @@ export class AssetManagementExportAdapter extends AssetManagementAdapter { this.parentProgressManager = parent; } + /** + * Override the default progress process name for {@link tick}/{@link updateStatus} + * calls. Used by the per-space orchestrator so each module's ticks land on the + * row for the space currently being exported. + */ + public setProcessName(name: string): void { + this.processName = name; + } + protected get progressOrParent(): CLIProgressManager | null { return this.parentProgressManager ?? this.progressManager; } diff --git a/packages/contentstack-asset-management/src/export/fields.ts b/packages/contentstack-asset-management/src/export/fields.ts index fd997e5e5..08e1caa6e 100644 --- a/packages/contentstack-asset-management/src/export/fields.ts +++ b/packages/contentstack-asset-management/src/export/fields.ts @@ -7,6 +7,8 @@ import { getArrayFromResponse } from '../utils/export-helpers'; import { PROCESS_NAMES } from '../constants/index'; export default class ExportFields extends AssetManagementExportAdapter { + protected processName: string = PROCESS_NAMES.AM_FIELDS; + constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { super(apiConfig, exportContext); } @@ -25,6 +27,6 @@ export default class ExportFields extends AssetManagementExportAdapter { log.debug(`Writing ${items.length} shared fields`, this.exportContext.context); } await this.writeItemsToChunkedJson(dir, 'fields.json', 'fields', ['uid', 'title', 'display_type'], items); - this.tick(true, PROCESS_NAMES.AM_FIELDS, null); + this.tick(true, `fields (${items.length})`, null); } } diff --git a/packages/contentstack-asset-management/src/export/spaces.ts b/packages/contentstack-asset-management/src/export/spaces.ts index 24a5a088d..1147cdb1a 100644 --- a/packages/contentstack-asset-management/src/export/spaces.ts +++ b/packages/contentstack-asset-management/src/export/spaces.ts @@ -4,8 +4,7 @@ import { log, CLIProgressManager, configHandler, handleAndLogError } from '@cont import type { AssetManagementExportOptions, AssetManagementAPIConfig } from '../types/asset-management-api'; import type { ExportContext } from '../types/export-types'; -import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; -import { AM_MAIN_PROCESS_NAME, PROCESS_NAMES, PROCESS_STATUS } from '../constants/index'; +import { AM_MAIN_PROCESS_NAME, PROCESS_NAMES, getSpaceProcessName } from '../constants/index'; import ExportAssetTypes from './asset-types'; import ExportFields from './fields'; import ExportWorkspace from './workspaces'; @@ -55,12 +54,18 @@ export class ExportSpaces { await mkdir(spacesRootPath, { recursive: true }); log.debug(`Spaces root path: ${spacesRootPath}`, context); - const totalSteps = 2 + linkedWorkspaces.length * 4; const progress = this.createProgress(); - progress.addProcess(AM_MAIN_PROCESS_NAME, totalSteps); - progress - .startProcess(AM_MAIN_PROCESS_NAME) - .updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_FIELDS].FETCHING, AM_MAIN_PROCESS_NAME); + // Multibar layout: two shared bootstrap rows + one row per space. Per-space + // totals start at 1 and are bumped to (2 + downloadableCount) inside + // ExportAssets.start once we know the asset count for that space. + progress.addProcess(PROCESS_NAMES.AM_FIELDS, 1); + progress.addProcess(PROCESS_NAMES.AM_ASSET_TYPES, 1); + const spaceProcessNames = new Map(); + for (const ws of linkedWorkspaces) { + const spaceProcess = getSpaceProcessName(ws.space_uid); + spaceProcessNames.set(ws.space_uid, spaceProcess); + progress.addProcess(spaceProcess, 1); + } const apiConfig: AssetManagementAPIConfig = { baseURL: assetManagementUrl, @@ -82,39 +87,67 @@ export class ExportSpaces { await mkdir(sharedAssetTypesDir, { recursive: true }); const firstSpaceUid = linkedWorkspaces[0].space_uid; + let bootstrapFailed = false; + let anySpaceFailed = false; try { + progress.startProcess(PROCESS_NAMES.AM_FIELDS); + progress.startProcess(PROCESS_NAMES.AM_ASSET_TYPES); + const exportAssetTypes = new ExportAssetTypes(apiConfig, exportContext); exportAssetTypes.setParentProgressManager(progress); const exportFields = new ExportFields(apiConfig, exportContext); exportFields.setParentProgressManager(progress); - await Promise.all([exportAssetTypes.start(firstSpaceUid), exportFields.start(firstSpaceUid)]); + try { + await Promise.all([exportAssetTypes.start(firstSpaceUid), exportFields.start(firstSpaceUid)]); + progress.completeProcess(PROCESS_NAMES.AM_FIELDS, true); + progress.completeProcess(PROCESS_NAMES.AM_ASSET_TYPES, true); + } catch (bootstrapErr) { + bootstrapFailed = true; + progress.completeProcess(PROCESS_NAMES.AM_FIELDS, false); + progress.completeProcess(PROCESS_NAMES.AM_ASSET_TYPES, false); + throw bootstrapErr; + } for (const ws of linkedWorkspaces) { - progress.updateStatus(`Exporting space: ${ws.space_uid}...`, AM_MAIN_PROCESS_NAME); + const spaceProcess = spaceProcessNames.get(ws.space_uid)!; + progress.startProcess(spaceProcess); log.debug(`Exporting space: ${ws.space_uid}`, context); const spaceDir = pResolve(spacesRootPath, ws.space_uid); try { const exportWorkspace = new ExportWorkspace(apiConfig, exportContext); exportWorkspace.setParentProgressManager(progress); - await exportWorkspace.start(ws, spaceDir, branchName || 'main'); + await exportWorkspace.start(ws, spaceDir, branchName || 'main', spaceProcess); + progress.completeProcess(spaceProcess, true); log.debug(`Exported workspace structure for space ${ws.space_uid}`, context); } catch (err) { + // Per-space failure: mark the row failed and continue with the next + // space so partial export results are preserved (matches import). + anySpaceFailed = true; log.debug(`Failed to export workspace for space ${ws.space_uid}: ${err}`, context); - progress.tick( - false, - `space: ${ws.space_uid}`, - (err as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_SPACE_METADATA].FAILED, - AM_MAIN_PROCESS_NAME, + handleAndLogError( + err, + { ...(context as Record), spaceUid: ws.space_uid }, + `Failed to export space ${ws.space_uid}`, ); - throw err; + progress.completeProcess(spaceProcess, false); } } - progress.completeProcess(AM_MAIN_PROCESS_NAME, true); - log.info('Asset Management export completed successfully', context); + log.info( + anySpaceFailed + ? 'Asset Management export completed with errors in one or more spaces' + : 'Asset Management export completed successfully', + context, + ); log.debug('Asset Management 2.0 export completed', context); } catch (err) { - progress.completeProcess(AM_MAIN_PROCESS_NAME, false); + if (!bootstrapFailed) { + // Mark any spaces that hadn't been processed as failed so the multibar + // doesn't leave dangling pending rows. + for (const [, spaceProcess] of spaceProcessNames) { + progress.completeProcess(spaceProcess, false); + } + } handleAndLogError(err, { ...(context as Record) }, 'Asset Management export failed'); throw err; } diff --git a/packages/contentstack-asset-management/src/export/workspaces.ts b/packages/contentstack-asset-management/src/export/workspaces.ts index c2f5bb4f1..0d45196e1 100644 --- a/packages/contentstack-asset-management/src/export/workspaces.ts +++ b/packages/contentstack-asset-management/src/export/workspaces.ts @@ -6,16 +6,33 @@ import type { AssetManagementAPIConfig, LinkedWorkspace } from '../types/asset-m import type { ExportContext } from '../types/export-types'; import { AssetManagementExportAdapter } from './base'; import ExportAssets from './assets'; -import { PROCESS_NAMES } from '../constants/index'; export default class ExportWorkspace extends AssetManagementExportAdapter { constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) { super(apiConfig, exportContext); } - async start(workspace: LinkedWorkspace, spaceDir: string, branchName: string): Promise { + /** + * Run the export pipeline for a single space. + * + * The optional `spaceProcessName` is the multibar row label that ticks + * (folder write + metadata write + per-asset downloads) should land on. The + * orchestrator passes the per-space row produced by `getSpaceProcessName`; + * if omitted the default {@link processName} (the AM main row) is used so + * direct callers keep working. + */ + async start( + workspace: LinkedWorkspace, + spaceDir: string, + branchName: string, + spaceProcessName?: string, + ): Promise { await this.init(); + if (spaceProcessName) { + this.setProcessName(spaceProcessName); + } + log.debug(`Starting export for AM space ${workspace.space_uid}`, this.exportContext.context); const spaceResponse = await this.getSpace(workspace.space_uid); @@ -35,11 +52,13 @@ export default class ExportWorkspace extends AssetManagementExportAdapter { log.warn(`Could not write ${metadataPath}: ${e}`, this.exportContext.context); throw e; } - this.tick(true, `space: ${workspace.space_uid}`, null); log.debug(`Space metadata written for ${workspace.space_uid}`, this.exportContext.context); const assetsExporter = new ExportAssets(this.apiConfig, this.exportContext); if (this.progressOrParent) assetsExporter.setParentProgressManager(this.progressOrParent); + if (spaceProcessName) { + assetsExporter.setProcessName(spaceProcessName); + } await assetsExporter.start(workspace, spaceDir); log.debug(`Exported workspace structure for space ${workspace.space_uid}`, this.exportContext.context); } diff --git a/packages/contentstack-asset-management/src/import/asset-types.ts b/packages/contentstack-asset-management/src/import/asset-types.ts index 71f5fbbac..0e943edee 100644 --- a/packages/contentstack-asset-management/src/import/asset-types.ts +++ b/packages/contentstack-asset-management/src/import/asset-types.ts @@ -24,6 +24,11 @@ type AssetTypeToCreate = { uid: string; payload: Record }; * 5. Strip read-only/computed keys from the POST body before creating new asset types. */ export default class ImportAssetTypes extends AssetManagementImportAdapter { + protected processName: string = PROCESS_NAMES.AM_IMPORT_ASSET_TYPES; + private successCount = 0; + private failureCount = 0; + private skippedCount = 0; + constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { super(apiConfig, importContext); } @@ -40,15 +45,13 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { if (!existsSync(indexPath)) { log.info('No shared asset types to import (index missing)', this.importContext.context); + this.tick(true, 'asset_types (0)', null); return; } const existingByUid = await this.loadExistingAssetTypesMap(); - this.updateStatus( - PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].IMPORTING, - PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, - ); + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].IMPORTING); await forEachChunkedJsonStore>( dir, @@ -64,6 +67,12 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { await this.importAssetTypesCreates(toCreate); }, ); + + this.tick( + this.failureCount === 0, + `asset_types: ${this.successCount} created, ${this.skippedCount} skipped, ${this.failureCount} failed`, + this.failureCount > 0 ? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].FAILED : null, + ); } /** Org-level asset types keyed by uid for diff; empty map if list API fails. */ @@ -111,7 +120,7 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { this.importContext.context, ); } - this.tick(true, `asset-type: ${uid} (skipped, already exists)`, null, PROCESS_NAMES.AM_IMPORT_ASSET_TYPES); + this.skippedCount += 1; continue; } @@ -125,15 +134,10 @@ export default class ImportAssetTypes extends AssetManagementImportAdapter { await runInBatches(toCreate, this.apiConcurrency, async ({ uid, payload }) => { try { await this.createAssetType(payload as any); - this.tick(true, `asset-type: ${uid}`, null, PROCESS_NAMES.AM_IMPORT_ASSET_TYPES); + this.successCount += 1; log.debug(`Imported asset type: ${uid}`, this.importContext.context); } catch (e) { - this.tick( - false, - `asset-type: ${uid}`, - (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSET_TYPES].FAILED, - PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, - ); + this.failureCount += 1; log.debug(`Failed to import asset type ${uid}: ${e}`, this.importContext.context); } }); diff --git a/packages/contentstack-asset-management/src/import/assets.ts b/packages/contentstack-asset-management/src/import/assets.ts index b69721245..d665353b2 100644 --- a/packages/contentstack-asset-management/src/import/assets.ts +++ b/packages/contentstack-asset-management/src/import/assets.ts @@ -127,58 +127,62 @@ export default class ImportAssets extends AssetManagementImportAdapter { log.debug(`Assets directory: ${assetsDir}`, this.importContext.context); // ----------------------------------------------------------------------- - // 1. Import folders + // 0. Pre-count folders and assets so the per-space progress row knows the + // real total upfront. Each folder/asset is a single tick below. // ----------------------------------------------------------------------- - const folderUidMap: Record = {}; const foldersFileName = this.importContext.foldersFileName ?? 'folders.json'; const foldersFilePath = join(assetsDir, foldersFileName); + const folders = this.readFolders(foldersFilePath, foldersFileName); + const folderCount = folders.length; - if (!existsSync(foldersFilePath)) { - log.debug(`No ${foldersFileName} at ${foldersFilePath}, skipping folder import`, this.importContext.context); - } + const loc = this.resolveAssetsChunkedLocation(spaceDir); + const assetCount = loc ? this.countAssetsInChunkedStore(loc.assetsDir, loc.indexName) : 0; - if (existsSync(foldersFilePath)) { - let foldersData: unknown; - try { - foldersData = JSON.parse(readFileSync(foldersFilePath, 'utf8')); - } catch (e) { - log.warn(`Could not read ${foldersFileName}: ${e}`, this.importContext.context); - } + // Update the per-space row to fold + assets (min 1 so the bar shows + // something even for empty spaces). + this.progressOrParent?.updateProcessTotal?.(this.processName, Math.max(1, folderCount + assetCount)); - if (foldersData) { - log.debug(`Reading folders from ${foldersFilePath}`, this.importContext.context); - const folders = getArrayFromResponse(foldersData, 'folders') as FolderRecord[]; - this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FOLDERS].IMPORTING, PROCESS_NAMES.AM_IMPORT_FOLDERS); - log.debug( - `Importing ${folders.length} folder(s) for space ${newSpaceUid} (concurrency=${this.importFoldersBatchConcurrency})`, - this.importContext.context, - ); - await this.importFolders(newSpaceUid, folders, folderUidMap); - log.debug( - `Folder import phase complete: ${Object.keys(folderUidMap).length} exported folder uid(s) mapped to target`, - this.importContext.context, - ); - log.info( - `Finished importing ${Object.keys(folderUidMap).length} folder(s) for space ${newSpaceUid}`, - this.importContext.context, - ); - } + // ----------------------------------------------------------------------- + // 1. Import folders + // ----------------------------------------------------------------------- + const folderUidMap: Record = {}; + + if (folderCount > 0) { + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FOLDERS].IMPORTING); + log.debug( + `Importing ${folderCount} folder(s) for space ${newSpaceUid} (concurrency=${this.importFoldersBatchConcurrency})`, + this.importContext.context, + ); + await this.importFolders(newSpaceUid, folders, folderUidMap); + log.debug( + `Folder import phase complete: ${Object.keys(folderUidMap).length} exported folder uid(s) mapped to target`, + this.importContext.context, + ); + log.info( + `Finished importing ${Object.keys(folderUidMap).length} folder(s) for space ${newSpaceUid}`, + this.importContext.context, + ); + } else { + log.debug(`No ${foldersFileName} at ${foldersFilePath}, skipping folder import`, this.importContext.context); } // ----------------------------------------------------------------------- // 2. Import assets (chunked on disk — process one chunk file at a time) // ----------------------------------------------------------------------- - const loc = this.resolveAssetsChunkedLocation(spaceDir); if (!loc) { log.info( `No asset metadata index in ${assetsDir}; skipping file uploads for space ${newSpaceUid}`, this.importContext.context, ); log.debug(`No assets.json index found in ${assetsDir}, skipping asset upload`, this.importContext.context); + // Empty space — bump current to total (1) so the row reads 100%. + if (folderCount === 0) { + this.tick(true, `space: ${newSpaceUid} (empty)`, null); + } return { uidMap, urlMap }; } - this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSETS].IMPORTING, PROCESS_NAMES.AM_IMPORT_ASSETS); + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSETS].IMPORTING); log.debug( `Uploading assets for space ${newSpaceUid} from ${loc.assetsDir} (index: ${loc.indexName}, concurrency=${this.uploadAssetsBatchConcurrency})`, this.importContext.context, @@ -205,7 +209,7 @@ export default class ImportAssets extends AssetManagementImportAdapter { if (!existsSync(filePath)) { missingFiles += 1; log.warn(`Asset file not found: ${filePath}, skipping`, this.importContext.context); - this.tick(false, `asset: ${oldUid}`, 'File not found on disk', PROCESS_NAMES.AM_IMPORT_ASSETS); + this.tick(false, `asset: ${oldUid}`, 'File not found on disk'); continue; } @@ -239,16 +243,15 @@ export default class ImportAssets extends AssetManagementImportAdapter { urlMap[asset.url] = created.url; } - this.tick(true, `asset: ${oldUid}`, null, PROCESS_NAMES.AM_IMPORT_ASSETS); + this.tick(true, `asset: ${filename}`, null); uploadOk += 1; log.debug(`Uploaded asset ${oldUid} → ${created.uid} (${filePath})`, this.importContext.context); } catch (e) { uploadFail += 1; this.tick( false, - `asset: ${oldUid}`, + `asset: ${filename}`, (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_ASSETS].FAILED, - PROCESS_NAMES.AM_IMPORT_ASSETS, ); log.debug(`Failed to upload asset ${oldUid}: ${e}`, this.importContext.context); } @@ -271,6 +274,45 @@ export default class ImportAssets extends AssetManagementImportAdapter { return { uidMap, urlMap }; } + /** + * Read folders.json into a list, returning [] when the file is absent or + * unreadable. Side-effects (warnings) match the legacy in-line behaviour so + * callers can rely on the return as a count source. + */ + private readFolders(foldersFilePath: string, foldersFileName: string): FolderRecord[] { + if (!existsSync(foldersFilePath)) { + return []; + } + try { + const data = JSON.parse(readFileSync(foldersFilePath, 'utf8')); + log.debug(`Reading folders from ${foldersFilePath}`, this.importContext.context); + return getArrayFromResponse(data, 'folders') as FolderRecord[]; + } catch (e) { + log.warn(`Could not read ${foldersFileName}: ${e}`, this.importContext.context); + return []; + } + } + + /** + * Sum the asset count across all chunk metadata files for the per-space row + * total. Reads `metadata.json` once (cheap aggregate); avoids streaming the + * full chunk payloads twice. + */ + private countAssetsInChunkedStore(assetsDir: string, indexName: string): number { + try { + const fs = new FsUtility({ basePath: assetsDir, indexFileName: indexName }); + const meta = fs.getPlainMeta(); + let total = 0; + for (const value of Object.values(meta)) { + if (Array.isArray(value)) total += value.length; + } + return total; + } catch (e) { + log.debug(`Could not pre-count assets in ${assetsDir}: ${e}`, this.importContext.context); + return 0; + } + } + /** * Creates folders respecting hierarchy: parents before children. * Uses multiple passes to handle arbitrary depth without requiring sorted input. @@ -317,14 +359,13 @@ export default class ImportAssets extends AssetManagementImportAdapter { parent_uid: isRootParent ? undefined : folderUidMap[parentUid!], }); folderUidMap[folder.uid] = created.uid; - this.tick(true, `folder: ${folder.uid}`, null, PROCESS_NAMES.AM_IMPORT_FOLDERS); + this.tick(true, `folder: ${folder.title}`, null); log.debug(`Created folder ${folder.uid} → ${created.uid}`, this.importContext.context); } catch (e) { this.tick( false, - `folder: ${folder.uid}`, + `folder: ${folder.title}`, (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FOLDERS].FAILED, - PROCESS_NAMES.AM_IMPORT_FOLDERS, ); log.debug(`Failed to create folder ${folder.uid}: ${e}`, this.importContext.context); } diff --git a/packages/contentstack-asset-management/src/import/base.ts b/packages/contentstack-asset-management/src/import/base.ts index ef1d4c0f5..24fca0918 100644 --- a/packages/contentstack-asset-management/src/import/base.ts +++ b/packages/contentstack-asset-management/src/import/base.ts @@ -16,7 +16,7 @@ export class AssetManagementImportAdapter extends AssetManagementAdapter { protected readonly importContext: ImportContext; protected progressManager: CLIProgressManager | null = null; protected parentProgressManager: CLIProgressManager | null = null; - protected readonly processName: string = AM_MAIN_PROCESS_NAME; + protected processName: string = AM_MAIN_PROCESS_NAME; constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { super(apiConfig); @@ -28,6 +28,15 @@ export class AssetManagementImportAdapter extends AssetManagementAdapter { this.parentProgressManager = parent; } + /** + * Override the default progress process name for {@link tick}/{@link updateStatus} + * calls. Used by the per-space orchestrator so each module's ticks land on the + * row for the space currently being imported. + */ + public setProcessName(name: string): void { + this.processName = name; + } + protected get progressOrParent(): CLIProgressManager | null { return this.parentProgressManager ?? this.progressManager; } diff --git a/packages/contentstack-asset-management/src/import/fields.ts b/packages/contentstack-asset-management/src/import/fields.ts index 9785906c2..2bba913f6 100644 --- a/packages/contentstack-asset-management/src/import/fields.ts +++ b/packages/contentstack-asset-management/src/import/fields.ts @@ -24,6 +24,11 @@ type FieldToCreate = { uid: string; payload: Record }; * 5. Strip read-only/computed keys from the POST body before creating new fields. */ export default class ImportFields extends AssetManagementImportAdapter { + protected processName: string = PROCESS_NAMES.AM_IMPORT_FIELDS; + private successCount = 0; + private failureCount = 0; + private skippedCount = 0; + constructor(apiConfig: AssetManagementAPIConfig, importContext: ImportContext) { super(apiConfig, importContext); } @@ -40,12 +45,15 @@ export default class ImportFields extends AssetManagementImportAdapter { if (!existsSync(indexPath)) { log.info('No shared fields to import (index missing)', this.importContext.context); + // Single aggregate tick so the shared row in the multibar still completes + // even when there is nothing to import. + this.tick(true, 'fields (0)', null); return; } const existingByUid = await this.loadExistingFieldsMap(); - this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].IMPORTING, PROCESS_NAMES.AM_IMPORT_FIELDS); + this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].IMPORTING); await forEachChunkedJsonStore>( dir, @@ -61,6 +69,15 @@ export default class ImportFields extends AssetManagementImportAdapter { await this.importFieldsCreates(toCreate); }, ); + + // Aggregate tick at end so the single-row shared bootstrap bar reaches 100% + // regardless of how many chunks/items were processed; the per-field outcome + // is still captured in logs. + this.tick( + this.failureCount === 0, + `fields: ${this.successCount} created, ${this.skippedCount} skipped, ${this.failureCount} failed`, + this.failureCount > 0 ? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].FAILED : null, + ); } /** Org-level fields keyed by uid for diff; empty map if list API fails. */ @@ -105,7 +122,7 @@ export default class ImportFields extends AssetManagementImportAdapter { } else { log.debug(`Field "${uid}" already exists with matching definition, skipping`, this.importContext.context); } - this.tick(true, `field: ${uid} (skipped, already exists)`, null, PROCESS_NAMES.AM_IMPORT_FIELDS); + this.skippedCount += 1; continue; } @@ -119,15 +136,10 @@ export default class ImportFields extends AssetManagementImportAdapter { await runInBatches(toCreate, this.apiConcurrency, async ({ uid, payload }) => { try { await this.createField(payload as any); - this.tick(true, `field: ${uid}`, null, PROCESS_NAMES.AM_IMPORT_FIELDS); + this.successCount += 1; log.debug(`Imported field: ${uid}`, this.importContext.context); } catch (e) { - this.tick( - false, - `field: ${uid}`, - (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_IMPORT_FIELDS].FAILED, - PROCESS_NAMES.AM_IMPORT_FIELDS, - ); + this.failureCount += 1; log.debug(`Failed to import field ${uid}: ${e}`, this.importContext.context); } }); diff --git a/packages/contentstack-asset-management/src/import/spaces.ts b/packages/contentstack-asset-management/src/import/spaces.ts index 6f66d24be..13706ffad 100644 --- a/packages/contentstack-asset-management/src/import/spaces.ts +++ b/packages/contentstack-asset-management/src/import/spaces.ts @@ -10,7 +10,7 @@ import type { ImportSpacesOptions, SpaceMapping, } from '../types/asset-management-api'; -import { AM_MAIN_PROCESS_NAME } from '../constants/index'; +import { AM_MAIN_PROCESS_NAME, PROCESS_NAMES, getSpaceProcessName } from '../constants/index'; import { AssetManagementAdapter } from '../utils/asset-management-api-adapter'; import ImportAssetTypes from './asset-types'; import ImportFields from './fields'; @@ -86,10 +86,18 @@ export class ImportSpaces { log.warn(`Could not read spaces root path ${spacesRootPath}: ${e}`, context); } - const totalSteps = 2 + spaceDirs.length * 2; const progress = this.createProgress(); - progress.addProcess(AM_MAIN_PROCESS_NAME, totalSteps); - progress.startProcess(AM_MAIN_PROCESS_NAME); + // Multibar layout: two shared bootstrap rows + one row per space directory. + // Per-space totals start at 1 and are bumped to (folderCount + assetCount) + // inside ImportAssets.start once we know the counts for that space. + progress.addProcess(PROCESS_NAMES.AM_IMPORT_FIELDS, 1); + progress.addProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, 1); + const spaceProcessNames = new Map(); + for (const spaceUid of spaceDirs) { + const spaceProcess = getSpaceProcessName(spaceUid); + spaceProcessNames.set(spaceUid, spaceProcess); + progress.addProcess(spaceProcess, 1); + } const allUidMap: Record = {}; const allUrlMap: Record = {}; @@ -117,27 +125,40 @@ export class ImportSpaces { log.info('Started Asset Management import', context); // 1. Import shared fields - progress.updateStatus(`Importing shared fields...`, AM_MAIN_PROCESS_NAME); + progress.startProcess(PROCESS_NAMES.AM_IMPORT_FIELDS); const fieldsImporter = new ImportFields(apiConfig, importContext); fieldsImporter.setParentProgressManager(progress); - await fieldsImporter.start(); + try { + await fieldsImporter.start(); + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_FIELDS, true); + } catch (e) { + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_FIELDS, false); + throw e; + } // 2. Import shared asset types - progress.updateStatus('Importing shared asset types...', AM_MAIN_PROCESS_NAME); + progress.startProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES); const assetTypesImporter = new ImportAssetTypes(apiConfig, importContext); assetTypesImporter.setParentProgressManager(progress); - await assetTypesImporter.start(); + try { + await assetTypesImporter.start(); + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, true); + } catch (e) { + progress.completeProcess(PROCESS_NAMES.AM_IMPORT_ASSET_TYPES, false); + throw e; + } // 3. Import each space — continue on failure so partially-imported data is never lost for (const spaceUid of spaceDirs) { const spaceDir = join(spacesRootPath, spaceUid); - progress.updateStatus(`Importing space: ${spaceUid}...`, AM_MAIN_PROCESS_NAME); + const spaceProcess = spaceProcessNames.get(spaceUid)!; + progress.startProcess(spaceProcess); log.debug(`Importing space: ${spaceUid}`, context); try { const workspaceImporter = new ImportWorkspace(apiConfig, importContext); workspaceImporter.setParentProgressManager(progress); - const result = await workspaceImporter.start(spaceUid, spaceDir, existingSpaceUids); + const result = await workspaceImporter.start(spaceUid, spaceDir, existingSpaceUids, spaceProcess); // Newly created spaces get a new uid — add so later iterations in this run see it. existingSpaceUids.add(result.newSpaceUid); @@ -152,17 +173,13 @@ export class ImportSpaces { isDefault: result.isDefault, }); + progress.completeProcess(spaceProcess, true); log.debug(`Imported space ${spaceUid} → ${result.newSpaceUid}`, context); spacesSucceeded += 1; } catch (err) { hasFailures = true; spacesFailed += 1; - progress.tick( - false, - `space: ${spaceUid}`, - (err as Error)?.message ?? 'Failed to import space', - AM_MAIN_PROCESS_NAME, - ); + progress.completeProcess(spaceProcess, false); log.warn(`Failed to import space ${spaceUid}: ${err}`, context); } } @@ -181,14 +198,20 @@ export class ImportSpaces { log.debug('Wrote AM 2.0 mapper files (uid, url, space-uid)', context); } - progress.completeProcess(AM_MAIN_PROCESS_NAME, !hasFailures); log.info( `Asset Management import finished: ${spacesSucceeded} space(s) succeeded, ${spacesFailed} failed, ${spaceDirs.length} attempted.`, context, ); - log.debug('Asset Management 2.0 import completed', context); + log.debug( + `Asset Management 2.0 import completed (hasFailures=${hasFailures})`, + context, + ); } catch (err) { - progress.completeProcess(AM_MAIN_PROCESS_NAME, false); + // Mark any spaces that hadn't been processed as failed so the multibar + // doesn't leave dangling pending rows when the bootstrap phase throws. + for (const [, spaceProcess] of spaceProcessNames) { + progress.completeProcess(spaceProcess, false); + } handleAndLogError(err, { ...(context as Record) }, 'Asset Management import failed'); throw err; } diff --git a/packages/contentstack-asset-management/src/import/workspaces.ts b/packages/contentstack-asset-management/src/import/workspaces.ts index e042b1f3b..d685cc3d2 100644 --- a/packages/contentstack-asset-management/src/import/workspaces.ts +++ b/packages/contentstack-asset-management/src/import/workspaces.ts @@ -5,7 +5,6 @@ import { log } from '@contentstack/cli-utilities'; import type { AssetManagementAPIConfig, ImportContext, SpaceMapping } from '../types/asset-management-api'; import { AssetManagementImportAdapter } from './base'; import ImportAssets from './assets'; -import { PROCESS_NAMES } from '../constants/index'; type WorkspaceResult = SpaceMapping & { uidMap: Record; @@ -23,13 +22,26 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { super(apiConfig, importContext); } + /** + * Run the import pipeline for a single space. + * + * The optional `spaceProcessName` is the multibar row label that ticks + * (folder creates + per-asset uploads) should land on. The orchestrator + * passes the per-space row produced by `getSpaceProcessName`; if omitted the + * default {@link processName} is used so direct callers keep working. + */ async start( oldSpaceUid: string, spaceDir: string, existingSpaceUids: Set = new Set(), + spaceProcessName?: string, ): Promise { await this.init(); + if (spaceProcessName) { + this.setProcessName(spaceProcessName); + } + log.debug(`Starting import for AM space directory ${oldSpaceUid}`, this.importContext.context); // Read exported metadata @@ -48,6 +60,9 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { const assetsImporter = new ImportAssets(this.apiConfig, this.importContext); if (this.progressOrParent) assetsImporter.setParentProgressManager(this.progressOrParent); + if (spaceProcessName) { + assetsImporter.setProcessName(spaceProcessName); + } // Reuse: target org already has a space with the same uid as the export directory. if (existingSpaceUids.has(oldSpaceUid)) { @@ -57,7 +72,9 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { ); const newSpaceUid = oldSpaceUid; const { uidMap, urlMap } = await assetsImporter.buildIdentityMappersFromExport(spaceDir); - this.tick(true, `space: ${oldSpaceUid} → ${newSpaceUid} (reused)`, null, PROCESS_NAMES.AM_SPACE_METADATA); + // Reused spaces do no folder/asset work; tick the per-space row once so it + // completes in the multibar. + this.tick(true, `space: ${oldSpaceUid} → ${newSpaceUid} (reused)`, null); return { oldSpaceUid, newSpaceUid, @@ -75,7 +92,6 @@ export default class ImportWorkspace extends AssetManagementImportAdapter { const newSpaceUid = space.uid; log.debug(`Created space ${newSpaceUid} (old: ${oldSpaceUid})`, this.importContext.context); - this.tick(true, `space: ${oldSpaceUid} → ${newSpaceUid}`, null, PROCESS_NAMES.AM_SPACE_METADATA); const { uidMap, urlMap } = await assetsImporter.start(newSpaceUid, spaceDir); diff --git a/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts index af052e2db..e0fd3bb1b 100644 --- a/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/asset-types.test.ts @@ -3,7 +3,6 @@ import sinon from 'sinon'; import ExportAssetTypes from '../../../src/export/asset-types'; import { AssetManagementExportAdapter } from '../../../src/export/base'; -import { PROCESS_NAMES } from '../../../src/constants/index'; import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; import type { ExportContext } from '../../../src/types/export-types'; @@ -76,13 +75,19 @@ describe('ExportAssetTypes', () => { expect(writeStub.firstCall.args[4]).to.deep.equal([]); }); - it('should tick with success=true, the asset types process name, and null error', async () => { + it('should tick once with the asset_types summary label and null error after writing', async () => { sinon.stub(ExportAssetTypes.prototype, 'getWorkspaceAssetTypes').resolves(assetTypesResponse); const exporter = new ExportAssetTypes(apiConfig, exportContext); await exporter.start(spaceUid); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.firstCall.args).to.deep.equal([true, PROCESS_NAMES.AM_ASSET_TYPES, null]); + expect(tickStub.callCount).to.equal(1); + const [success, label, error] = tickStub.firstCall.args; + expect(success).to.be.true; + // Label format is `asset_types ()` so the shared row carries a count + // summary; exact count comes from the mocked asset-types response. + expect(String(label)).to.match(/^asset_types \(\d+\)$/); + expect(error).to.be.null; }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/assets.test.ts b/packages/contentstack-asset-management/test/unit/export/assets.test.ts index ab6b831d4..2c4ac124e 100644 --- a/packages/contentstack-asset-management/test/unit/export/assets.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/assets.test.ts @@ -106,11 +106,11 @@ describe('ExportAssets', () => { expect(fetchStub.callCount).to.equal(0); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick).to.be.undefined; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(0); }); - it('should tick with success=false and the error message on download failure', async () => { + it('should tick per failed asset with success=false and the error message on download failure', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); fetchStub.rejects(new Error('network failure')); @@ -119,12 +119,16 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.false; - expect(downloadTick!.args[2]).to.equal('network failure'); + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + // Per-asset tick: one failure entry per attempted download. + expect(assetTicks.length).to.be.greaterThan(0); + for (const t of assetTicks) { + expect(t.args[0]).to.be.false; + expect(t.args[2]).to.equal('network failure'); + } }); - it('should tick with success=true and null error on successful downloads', async () => { + it('should tick per asset with success=true and null error on successful downloads', async () => { sinon.stub(ExportAssets.prototype, 'getWorkspaceFolders').resolves(foldersData); sinon.stub(ExportAssets.prototype, 'getWorkspaceAssets').resolves(assetsResponseWithItems); fetchStub.callsFake(async () => makeFetchResponse() as any); @@ -133,9 +137,13 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.true; - expect(downloadTick!.args[2]).to.be.null; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + // One successful tick per asset in the workspace. + expect(assetTicks).to.have.length(assetsResponseWithItems.items.length); + for (const t of assetTicks) { + expect(t.args[0]).to.be.true; + expect(t.args[2]).to.be.null; + } }); it('should skip assets that have neither a url nor a uid', async () => { @@ -168,9 +176,10 @@ describe('ExportAssets', () => { expect(fetchStub.firstCall.args[0]).to.equal('https://cdn.example.com/a.png'); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.true; - expect(downloadTick!.args[2]).to.be.null; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(1); + expect(assetTicks[0].args[0]).to.be.true; + expect(assetTicks[0].args[2]).to.be.null; }); it('should download assets that use file_name, and fall back to "asset" when both names are absent', async () => { @@ -191,8 +200,9 @@ describe('ExportAssets', () => { expect(fetchStub.firstCall.args[0]).to.equal('https://cdn.example.com/a1.pdf'); expect(fetchStub.secondCall.args[0]).to.equal('https://cdn.example.com/a2.bin'); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.true; + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(2); + for (const t of assetTicks) expect(t.args[0]).to.be.true; }); it('should append authtoken to URL when securedAssets is true', async () => { @@ -238,9 +248,10 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.false; - expect(downloadTick!.args[2]).to.include('403'); + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(1); + expect(assetTicks[0].args[0]).to.be.false; + expect(assetTicks[0].args[2]).to.include('403'); }); it('should tick with success=false and "No response body" when body is null', async () => { @@ -254,9 +265,10 @@ describe('ExportAssets', () => { await exporter.start(workspace, spaceDir); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - const downloadTick = tickStub.getCalls().find((c) => String(c.args[1]).startsWith('downloads:')); - expect(downloadTick!.args[0]).to.be.false; - expect(downloadTick!.args[2]).to.equal('No response body'); + const assetTicks = tickStub.getCalls().filter((c) => String(c.args[1]).startsWith('asset:')); + expect(assetTicks).to.have.length(1); + expect(assetTicks[0].args[0]).to.be.false; + expect(assetTicks[0].args[2]).to.equal('No response body'); }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/fields.test.ts b/packages/contentstack-asset-management/test/unit/export/fields.test.ts index a039dcb75..008ceebe3 100644 --- a/packages/contentstack-asset-management/test/unit/export/fields.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/fields.test.ts @@ -3,7 +3,6 @@ import sinon from 'sinon'; import ExportFields from '../../../src/export/fields'; import { AssetManagementExportAdapter } from '../../../src/export/base'; -import { PROCESS_NAMES } from '../../../src/constants/index'; import type { AssetManagementAPIConfig } from '../../../src/types/asset-management-api'; import type { ExportContext } from '../../../src/types/export-types'; @@ -76,13 +75,19 @@ describe('ExportFields', () => { expect(writeStub.firstCall.args[4]).to.deep.equal([]); }); - it('should tick with success=true, the fields process name, and null error', async () => { + it('should tick once with the fields summary label and null error after writing', async () => { sinon.stub(ExportFields.prototype, 'getWorkspaceFields').resolves(fieldsResponse); const exporter = new ExportFields(apiConfig, exportContext); await exporter.start(spaceUid); const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.firstCall.args).to.deep.equal([true, PROCESS_NAMES.AM_FIELDS, null]); + expect(tickStub.callCount).to.equal(1); + const [success, label, error] = tickStub.firstCall.args; + expect(success).to.be.true; + // Label format is `fields ()` so the shared row carries a count + // summary; exact count comes from the mocked fields response. + expect(String(label)).to.match(/^fields \(\d+\)$/); + expect(error).to.be.null; }); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts index 72e0910c9..3228ab8c1 100644 --- a/packages/contentstack-asset-management/test/unit/export/spaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/spaces.test.ts @@ -7,7 +7,7 @@ import ExportAssetTypes from '../../../src/export/asset-types'; import ExportFields from '../../../src/export/fields'; import ExportWorkspace from '../../../src/export/workspaces'; import { AssetManagementExportAdapter } from '../../../src/export/base'; -import { AM_MAIN_PROCESS_NAME } from '../../../src/constants/index'; +import { PROCESS_NAMES, getSpaceProcessName } from '../../../src/constants/index'; import type { AssetManagementExportOptions, LinkedWorkspace } from '../../../src/types/asset-management-api'; @@ -42,8 +42,11 @@ describe('ExportSpaces', () => { sinon.stub(ExportWorkspace.prototype, 'start').resolves(); sinon.stub(ExportWorkspace.prototype, 'setParentProgressManager'); + fakeProgress.addProcess.resetHistory(); fakeProgress.addProcess.returnsThis(); + fakeProgress.startProcess.resetHistory(); fakeProgress.startProcess.returnsThis(); + fakeProgress.updateStatus.resetHistory(); fakeProgress.updateStatus.returnsThis(); fakeProgress.tick.reset(); fakeProgress.completeProcess.reset(); @@ -105,31 +108,46 @@ describe('ExportSpaces', () => { expect(wsStub.secondCall.args[0]).to.deep.include({ uid: 'ws-2', space_uid: 'space-2' }); }); - it('should register and complete the progress process with success', async () => { - const totalSteps = 2 + baseOptions.linkedWorkspaces.length * 4; // 10 + it('should register one shared row per bootstrap phase plus one row per space, and complete each on success', async () => { const exporter = new ExportSpaces(baseOptions); await exporter.start(); - expect(fakeProgress.addProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, totalSteps]); - expect(fakeProgress.startProcess.firstCall.args[0]).to.equal(AM_MAIN_PROCESS_NAME); - expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, true]); + const addProcessCalls = fakeProgress.addProcess.getCalls().map((c) => c.args); + // Shared bootstrap rows + one row per linked workspace. + expect(addProcessCalls).to.deep.equal([ + [PROCESS_NAMES.AM_FIELDS, 1], + [PROCESS_NAMES.AM_ASSET_TYPES, 1], + [getSpaceProcessName('space-1'), 1], + [getSpaceProcessName('space-2'), 1], + ]); + + const completeArgs = fakeProgress.completeProcess.getCalls().map((c) => c.args); + expect(completeArgs).to.deep.include.members([ + [PROCESS_NAMES.AM_FIELDS, true], + [PROCESS_NAMES.AM_ASSET_TYPES, true], + [getSpaceProcessName('space-1'), true], + [getSpaceProcessName('space-2'), true], + ]); }); - it('should mark progress as failed and re-throw when a workspace export errors', async () => { - (ExportWorkspace.prototype.start as sinon.SinonStub).rejects(new Error('workspace-error')); + it('should mark only the failing space row as failed and continue with remaining spaces', async () => { + const wsStub = ExportWorkspace.prototype.start as sinon.SinonStub; + wsStub.onFirstCall().rejects(new Error('workspace-error')); + wsStub.onSecondCall().resolves(); const exporter = new ExportSpaces(baseOptions); - try { - await exporter.start(); - expect.fail('should have thrown'); - } catch (err: any) { - expect(err.message).to.equal('workspace-error'); - } + // Per the plan, per-space failures must NOT abort the orchestrator — + // they're recorded on that space's row and the next space proceeds. + await exporter.start(); - expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, false]); + expect(wsStub.callCount).to.equal(2); + + const completeArgs = fakeProgress.completeProcess.getCalls().map((c) => c.args); + expect(completeArgs).to.deep.include([getSpaceProcessName('space-1'), false]); + expect(completeArgs).to.deep.include([getSpaceProcessName('space-2'), true]); }); - it('should mark progress as failed and re-throw when shared bootstrap export errors', async () => { + it('should mark shared rows as failed and re-throw when shared bootstrap export errors', async () => { (ExportFields.prototype.start as sinon.SinonStub).rejects(new Error('shared-bootstrap-error')); const exporter = new ExportSpaces(baseOptions); @@ -140,7 +158,9 @@ describe('ExportSpaces', () => { expect(err.message).to.equal('shared-bootstrap-error'); } - expect(fakeProgress.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, false]); + const completeArgs = fakeProgress.completeProcess.getCalls().map((c) => c.args); + expect(completeArgs).to.deep.include([PROCESS_NAMES.AM_FIELDS, false]); + expect(completeArgs).to.deep.include([PROCESS_NAMES.AM_ASSET_TYPES, false]); }); it('should use the provided parentProgressManager instead of creating a new one', async () => { @@ -151,16 +171,20 @@ describe('ExportSpaces', () => { tick: sinon.stub(), completeProcess: sinon.stub(), }; - const totalSteps = 2 + baseOptions.linkedWorkspaces.length * 4; const exporter = new ExportSpaces(baseOptions); exporter.setParentProgressManager(fakeParent as any); await exporter.start(); expect((CLIProgressManager.createNested as sinon.SinonStub).callCount).to.equal(0); - expect(fakeParent.addProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, totalSteps]); - expect(fakeParent.startProcess.firstCall.args[0]).to.equal(AM_MAIN_PROCESS_NAME); - expect(fakeParent.completeProcess.firstCall.args).to.deep.equal([AM_MAIN_PROCESS_NAME, true]); + + const addProcessCalls = fakeParent.addProcess.getCalls().map((c) => c.args); + expect(addProcessCalls).to.deep.equal([ + [PROCESS_NAMES.AM_FIELDS, 1], + [PROCESS_NAMES.AM_ASSET_TYPES, 1], + [getSpaceProcessName('space-1'), 1], + [getSpaceProcessName('space-2'), 1], + ]); }); }); diff --git a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts index 0a4503b04..03bdfc6bf 100644 --- a/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts +++ b/packages/contentstack-asset-management/test/unit/export/workspaces.test.ts @@ -55,13 +55,26 @@ describe('ExportWorkspace', () => { expect(getSpaceStub.firstCall.args[0]).to.equal(workspace.space_uid); }); - it('should tick success after writing metadata', async () => { + it('should NOT tick after writing metadata (per-space row is owned by ExportAssets)', async () => { sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); const exporter = new ExportWorkspace(apiConfig, exportContext); await exporter.start(workspace, spaceDir, branchName); + // The per-space progress row's total is folder + metadata + downloads — + // all owned by ExportAssets. The workspace metadata.json write is a + // fixed bootstrap step and intentionally does not consume a tick. const tickStub = (AssetManagementExportAdapter.prototype as any).tick as sinon.SinonStub; - expect(tickStub.firstCall.args).to.deep.equal([true, `space: ${workspace.space_uid}`, null]); + expect(tickStub.callCount).to.equal(0); + }); + + it('should forward spaceProcessName to the assets exporter via setProcessName', async () => { + sinon.stub(ExportWorkspace.prototype, 'getSpace').resolves(spaceResponse); + const setProcessNameStub = sinon.stub(ExportAssets.prototype, 'setProcessName' as any); + + const exporter = new ExportWorkspace(apiConfig, exportContext); + await exporter.start(workspace, spaceDir, branchName, 'Space space-uid-1'); + + expect(setProcessNameStub.firstCall.args[0]).to.equal('Space space-uid-1'); }); it('should delegate to ExportAssets.start with workspace and spaceDir', async () => { diff --git a/packages/contentstack-audit/README.md b/packages/contentstack-audit/README.md index 116024c10..9f2c53d70 100644 --- a/packages/contentstack-audit/README.md +++ b/packages/contentstack-audit/README.md @@ -19,7 +19,7 @@ $ npm install -g @contentstack/cli-audit $ csdx COMMAND running command... $ csdx (--version|-v) -@contentstack/cli-audit/2.0.0-beta.10 darwin-arm64 node-v22.13.1 +@contentstack/cli-audit/2.0.0-beta.6 darwin-arm64 node-v24.13.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND @@ -157,5 +157,5 @@ DESCRIPTION Display help for csdx. ``` -_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.44/src/commands/help.ts)_ +_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_ diff --git a/packages/contentstack-audit/src/modules/assets.ts b/packages/contentstack-audit/src/modules/assets.ts index c6c1a637b..39c357b96 100644 --- a/packages/contentstack-audit/src/modules/assets.ts +++ b/packages/contentstack-audit/src/modules/assets.ts @@ -8,6 +8,20 @@ import values from 'lodash/values'; import { keys } from 'lodash'; import BaseClass from './base-class'; +/** + * Multibar row label for a single space. Bounded to 14 chars after the + * `Space ` prefix so CLIProgressManager.formatProcessName doesn't truncate the + * row mid-string. Mirrors the helper in `@contentstack/cli-asset-management`. + */ +const SPACE_PROCESS_NAME_PREFIX = 'Space '; +const SPACE_PROCESS_NAME_MAX_UID_LEN = 14; +function getSpaceProcessName(spaceUid: string): string { + const safe = spaceUid ?? ''; + const trimmed = + safe.length > SPACE_PROCESS_NAME_MAX_UID_LEN ? safe.substring(0, SPACE_PROCESS_NAME_MAX_UID_LEN) : safe; + return `${SPACE_PROCESS_NAME_PREFIX}${trimmed}`; +} + /* The `Assets` class is responsible for scanning assets, looking for missing environment/locale references, and generating a report in JSON and CSV formats. */ export default class Assets extends BaseClass { @@ -24,6 +38,8 @@ export default class Assets extends BaseClass { public moduleName: keyof typeof auditConfig.moduleConfig; private fixOverwriteConfirmed: boolean | null = null; private resolvedBasePaths: Array<{ path: string; spaceId: string | null }> = []; + /** Map space dir name → the per-space multibar row label, or empty when single-space. */ + private spaceProcessNames: Map = new Map(); constructor({ fix, config, moduleName }: ModuleConstructorParam & CtConstructorParam) { super({ config }); @@ -71,15 +87,32 @@ export default class Assets extends BaseClass { await this.prerequisiteData(); }); - // Create progress manager if we have a total count - if (totalCount && totalCount > 0) { + // Resolve base paths up front so the progress UI can decide between a + // simple single-bar layout (legacy export) and a per-space multibar. + this.resolvedBasePaths = this.resolveAssetBasePaths(); + log.debug(`Resolved ${this.resolvedBasePaths.length} asset base path(s)`, this.config.auditContext); + + const isMultiSpace = + this.resolvedBasePaths.length > 1 || + (this.resolvedBasePaths.length === 1 && this.resolvedBasePaths[0].spaceId !== null); + + if (isMultiSpace) { + const progress = this.createNestedProgress(this.moduleName); + for (const { path, spaceId } of this.resolvedBasePaths) { + // Each space row's total = number of assets in that space; pre-counted + // from the chunked metadata so the bar shows real progress as ticks + // accumulate inside lookForReference. + const rowName = getSpaceProcessName(spaceId ?? 'unknown'); + this.spaceProcessNames.set(spaceId ?? path, rowName); + const spaceTotal = this.countAssetsInChunkedStore(path); + progress.addProcess(rowName, Math.max(1, spaceTotal)); + } + } else if (totalCount && totalCount > 0) { + // Legacy flat layout — single progress bar for the whole asset set. const progress = this.createSimpleProgress(this.moduleName, totalCount); progress.updateStatus('Validating asset references...'); } - this.resolvedBasePaths = this.resolveAssetBasePaths(); - log.debug(`Resolved ${this.resolvedBasePaths.length} asset base path(s)`, this.config.auditContext); - log.debug('Starting asset Reference, Environment and Locale validation', this.config.auditContext); await this.lookForReference(); @@ -250,8 +283,16 @@ export default class Assets extends BaseClass { cliux.print($t(auditMsg.AUDITING_SPACE, { spaceId }), { color: 'cyan' }); } - // Progress bar UX: update status label to reflect the current space - this.progressManager?.updateStatus?.(spaceId ? `Space: ${spaceId}` : 'Scanning assets...'); + // Multi-space layout: start the per-space row and route ticks below to it. + // Single-space (legacy) layout falls back to the existing simple progress + // bar with a status update. + const spaceProcessName = this.spaceProcessNames.get(spaceId ?? spacePath); + if (spaceProcessName) { + this.progressManager?.startProcess?.(spaceProcessName); + this.progressManager?.updateStatus?.(`Space: ${spaceId ?? 'assets'}`, spaceProcessName); + } else { + this.progressManager?.updateStatus?.(spaceId ? `Space: ${spaceId}` : 'Scanning assets...'); + } let fsUtility = new FsUtility({ basePath: spacePath, indexFileName: 'assets.json' }); let indexer = fsUtility.indexFileContent; @@ -332,7 +373,9 @@ export default class Assets extends BaseClass { ); if (this.progressManager) { - this.progressManager.tick(true, `asset: ${assetUid}`, null); + // Route the tick to the per-space row when multi-space, otherwise + // tick the single legacy progress bar (processName arg defaults). + this.progressManager.tick(true, `asset: ${assetUid}`, null, spaceProcessName); } if (this.fix) { @@ -345,6 +388,12 @@ export default class Assets extends BaseClass { await this.writeFixContent(`${spacePath}/${indexer[fileIndex]}`, this.assets); } } + + // Per-space row finished — close it so the multibar shows ✓ Complete + // and the next space (if any) starts cleanly. + if (spaceProcessName) { + this.progressManager?.completeProcess?.(spaceProcessName, true); + } } log.debug( @@ -354,4 +403,30 @@ export default class Assets extends BaseClass { this.config.auditContext, ); } + + /** + * Sum the asset count across all chunk metadata files for a given space's + * `assets/` directory. Used by `run` to seed each per-space progress row's + * total before validation begins. Falls back to walking chunk files if the + * aggregated `metadata.json` is unavailable (older exports). + */ + private countAssetsInChunkedStore(assetsDir: string): number { + try { + const fsUtility = new FsUtility({ basePath: assetsDir, indexFileName: 'assets.json' }); + const meta = fsUtility.getPlainMeta(); + let total = 0; + for (const value of Object.values(meta)) { + if (Array.isArray(value)) total += value.length; + } + if (total > 0) return total; + + // Fallback: count keys across each chunk file (slow path for legacy + // exports without metadata.json). + const indexer = fsUtility.indexFileContent ?? {}; + return Object.keys(indexer).length; + } catch (e) { + log.debug(`Could not pre-count assets in ${assetsDir}: ${e}`, this.config.auditContext); + return 0; + } + } } diff --git a/packages/contentstack-branches/README.md b/packages/contentstack-branches/README.md index 2b6cb6e51..43c780857 100755 --- a/packages/contentstack-branches/README.md +++ b/packages/contentstack-branches/README.md @@ -53,7 +53,6 @@ USAGE * [`csdx cm:branches:delete [-uid ] [-k ]`](#csdx-cmbranchesdelete--uid-value--k-value) * [`csdx cm:branches:diff [--base-branch ] [--compare-branch ] [-k ][--module ] [--format ] [--csv-path ]`](#csdx-cmbranchesdiff---base-branch-value---compare-branch-value--k-value--module-value---format-value---csv-path-value) * [`csdx cm:branches:merge [-k ][--compare-branch ] [--no-revert] [--export-summary-path ] [--use-merge-summary ] [--comment ] [--base-branch ]`](#csdx-cmbranchesmerge--k-value--compare-branch-value---no-revert---export-summary-path-value---use-merge-summary-value---comment-value---base-branch-value) -* [`csdx cm:branches:merge-status -k --merge-uid `](#csdx-cmbranchesmerge-status--k-value---merge-uid-value) ## `csdx cm:branches` @@ -231,27 +230,4 @@ EXAMPLES ``` _See code: [src/commands/cm/branches/merge.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/merge.ts)_ - -## `csdx cm:branches:merge-status -k --merge-uid ` - -Check the status of a branch merge job - -``` -USAGE - $ csdx cm:branches:merge-status -k --merge-uid - -FLAGS - -k, --stack-api-key= (required) Provide your stack API key. - --merge-uid= (required) Merge job UID to check status for. - -DESCRIPTION - Check the status of a branch merge job - -EXAMPLES - $ csdx cm:branches:merge-status -k bltxxxxxxxx --merge-uid merge_abc123 - - $ csdx cm:branches:merge-status --stack-api-key bltxxxxxxxx --merge-uid merge_abc123 -``` - -_See code: [src/commands/cm/branches/merge-status.ts](https://github.com/contentstack/cli/blob/main/packages/contentstack-export/src/commands/cm/branches/merge-status.ts)_ diff --git a/packages/contentstack-clone/README.md b/packages/contentstack-clone/README.md index 4c818981a..f67e967e7 100644 --- a/packages/contentstack-clone/README.md +++ b/packages/contentstack-clone/README.md @@ -16,7 +16,7 @@ $ npm install -g @contentstack/cli-cm-clone $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-clone/2.0.0-beta.17 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-clone/2.0.0-beta.12 darwin-arm64 node-v24.13.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-export/src/utils/progress-strategy-registry.ts b/packages/contentstack-export/src/utils/progress-strategy-registry.ts index b50c1e86b..85a2fdfe2 100644 --- a/packages/contentstack-export/src/utils/progress-strategy-registry.ts +++ b/packages/contentstack-export/src/utils/progress-strategy-registry.ts @@ -1,4 +1,4 @@ -import { AM_MAIN_PROCESS_NAME } from '@contentstack/cli-asset-management'; +import { AM_MAIN_PROCESS_NAME, isSpaceProcessName } from '@contentstack/cli-asset-management'; import { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES } from './constants'; /** * Progress Strategy Registrations for Export Modules @@ -13,6 +13,31 @@ import { DefaultProgressStrategy, } from '@contentstack/cli-utilities'; +/** + * Sum the totals/success/failure counts across every per-space process row in + * the multibar. Used by the AM 2.0 Assets strategy so the final summary reports + * total assets-across-all-spaces instead of the placeholder row. + * + * Returns null when no per-space rows exist, letting the strategy fall back to + * legacy process names. + */ +function aggregateSpaceProcesses( + processes: Map, +): { total: number; success: number; failures: number } | null { + let total = 0; + let success = 0; + let failures = 0; + let found = false; + for (const [name, data] of processes) { + if (!isSpaceProcessName(name)) continue; + found = true; + total += data.total; + success += data.successCount; + failures += data.failureCount; + } + return found ? { total, success, failures } : null; +} + // Wrap all registrations in try-catch to prevent module loading errors try { ProgressStrategyRegistry.register(MODULE_NAMES[MODULE_CONTEXTS.CONTENT_TYPES], new DefaultProgressStrategy()); @@ -31,7 +56,12 @@ try { failures: downloadsProcess.failureCount, }; } - // Asset Management 2.0 path (process name owned by AM package) + // Asset Management 2.0 (per-space layout): sum every "Space *" row so the + // final summary reports total assets-across-all-spaces. Falls through to + // the legacy AM_MAIN/SPACES rows when the per-space layout isn't in use. + const spaceTotals = aggregateSpaceProcesses(processes); + if (spaceTotals) return spaceTotals; + const amProcess = processes.get(AM_MAIN_PROCESS_NAME); if (amProcess) { return { diff --git a/packages/contentstack-import-setup/README.md b/packages/contentstack-import-setup/README.md index 40ed66f01..dfb7b040c 100644 --- a/packages/contentstack-import-setup/README.md +++ b/packages/contentstack-import-setup/README.md @@ -47,7 +47,7 @@ $ npm install -g @contentstack/cli-cm-import-setup $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-import-setup/2.0.0-beta.10 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-import-setup/2.0.0-beta.6 darwin-arm64 node-v24.13.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-import/README.md b/packages/contentstack-import/README.md index 117795d1d..e91aacda9 100644 --- a/packages/contentstack-import/README.md +++ b/packages/contentstack-import/README.md @@ -47,7 +47,7 @@ $ npm install -g @contentstack/cli-cm-import $ csdx COMMAND running command... $ csdx (--version) -@contentstack/cli-cm-import/2.0.0-beta.15 darwin-arm64 node-v22.13.1 +@contentstack/cli-cm-import/2.0.0-beta.11 darwin-arm64 node-v24.13.0 $ csdx --help [COMMAND] USAGE $ csdx COMMAND diff --git a/packages/contentstack-import/src/utils/progress-strategy-registry.ts b/packages/contentstack-import/src/utils/progress-strategy-registry.ts index 5a391317d..0d5137346 100644 --- a/packages/contentstack-import/src/utils/progress-strategy-registry.ts +++ b/packages/contentstack-import/src/utils/progress-strategy-registry.ts @@ -10,8 +10,34 @@ import { CustomProgressStrategy, DefaultProgressStrategy, } from '@contentstack/cli-utilities'; +import { isSpaceProcessName } from '@contentstack/cli-asset-management'; import { MODULE_CONTEXTS, MODULE_NAMES, PROCESS_NAMES } from './constants'; +/** + * Sum the totals/success/failure counts across every per-space process row in + * the multibar. Used by the AM 2.0 Assets strategy so the final import summary + * reports total assets-across-all-spaces instead of the placeholder row. + * + * Returns null when no per-space rows exist, letting the strategy fall back to + * legacy process names. + */ +function aggregateSpaceProcesses( + processes: Map, +): { total: number; success: number; failures: number } | null { + let total = 0; + let success = 0; + let failures = 0; + let found = false; + for (const [name, data] of processes) { + if (!isSpaceProcessName(name)) continue; + found = true; + total += data.total; + success += data.successCount; + failures += data.failureCount; + } + return found ? { total, success, failures } : null; +} + // Wrap all registrations in try-catch to prevent module loading errors try { // Register strategy for Content Types - use Create as primary process @@ -33,6 +59,11 @@ try { }; } + // Asset Management 2.0 (per-space layout): sum every "Space *" row so the + // final summary reports total assets-across-all-spaces. + const spaceTotals = aggregateSpaceProcesses(processes); + if (spaceTotals) return spaceTotals; + return null; // Fall back to default aggregation }), ); diff --git a/skills/README.md b/skills/README.md deleted file mode 100644 index 3257d9d50..000000000 --- a/skills/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Skills – Contentstack CLI plugins - -Source of truth for detailed guidance. Read [AGENTS.md](../AGENTS.md) for the skill index, then open the `SKILL.md` that matches your task. Each folder contains `SKILL.md` with YAML frontmatter (`name`, `description`).