Skip to content

Latest commit

 

History

History
593 lines (486 loc) · 16.7 KB

File metadata and controls

593 lines (486 loc) · 16.7 KB
name contentstack-cli
description Contentstack CLI development patterns, OCLIF commands, API integration, and auth/config workflows. Use for CLI plugins, OCLIF commands, or API integration—including Developer Hub apps (packages/contentstack-apps-cli, app:* commands).

Contentstack CLI Development

OCLIF Command Structure

Plugin Base Command Pattern

import { BaseCommand } from '../../base-command';
import { FlagInput, Flags } from '@contentstack/cli-utilities';
import { cliux, handleAndLogError } from '@contentstack/cli-utilities';

export default class ImportCommand extends BaseCommand<typeof ImportCommand> {
  static id = 'cm:stacks:import';
  static description = 'Import content into a stack';
  
  static flags: FlagInput = {
    'stack-api-key': Flags.string({
      char: 'k',
      description: 'Stack API key',
      required: true,
    }),
    'data-dir': Flags.string({
      char: 'd',
      description: 'Directory with import data',
      required: true,
    }),
    verbose: Flags.boolean({
      char: 'v',
      description: 'Show verbose output',
      default: false
    })
  };
  
  static examples = [
    '$ csdx cm:stacks:import -k <api-key> -d ./data',
    '$ csdx cm:stacks:import -k <api-key> -d ./data --verbose',
  ];

  async run(): Promise<void> {
    try {
      const { flags: parsedFlags } = await this.parse(ImportCommand);
      
      // Validate flags
      if (!parsedFlags['stack-api-key']) {
        this.error('Stack API key is required');
      }
      
      // Delegate to service
      cliux.print('Starting import...', { color: 'blue' });
      const importService = new ImportService(parsedFlags);
      const result = await importService.import();
      
      cliux.success('✅ Import completed successfully');
    } catch (error) {
      handleAndLogError(error, { 
        module: 'import-command',
        stackApiKey: this.flags['stack-api-key']
      });
    }
  }
}

Command Topics and Naming

Commands are organized by topic hierarchy under cm:

  • src/commands/cm/stacks/import.ts → command cm:stacks:import
  • src/commands/cm/stacks/export.ts → command cm:stacks:export
  • src/commands/cm/stacks/audit/index.ts → command cm:stacks:audit
  • src/commands/cm/stacks/audit/fix.ts → command cm:stacks:audit:fix

Flag Validation Patterns

Early Validation

async run(): Promise<void> {
  const { flags } = await this.parse(MyCommand);
  
  // Validate required flags
  if (!flags.region) {
    this.error('--region is required');
  }
  
  // Validate flag values
  const validRegions = ['us', 'eu', 'au'];
  if (!validRegions.includes(flags.region)) {
    this.error(`Region must be one of: ${validRegions.join(', ')}`);
  }
}

Exclusive Flags

static flags: FlagInput = {
  username: flags.string({
    char: 'u',
    exclusive: ['oauth'] // Cannot use with oauth flag
  }),
  oauth: flags.boolean({
    exclusive: ['username', 'password']
  })
};

Dependent Flags

static flags: FlagInput = {
  cma: flags.string({
    dependsOn: ['cda', 'name']
  }),
  cda: flags.string({
    dependsOn: ['cma', 'name']
  })
};

Authentication Commands

Login Command

export default class LoginCommand extends BaseCommand<typeof LoginCommand> {
  static description = 'User sessions login';
  static aliases = ['login'];
  
  static flags: FlagInput = {
    username: flags.string({
      char: 'u',
      description: 'Email address of your Contentstack account',
      exclusive: ['oauth']
    }),
    password: flags.string({
      char: 'p',
      description: 'Password of your Contentstack account',
      exclusive: ['oauth']
    }),
    oauth: flags.boolean({
      description: 'Enable single sign-on (SSO)',
      default: false,
      exclusive: ['username', 'password']
    })
  };

  async run(): Promise<void> {
    try {
      const managementAPIClient = await managementSDKClient({ 
        host: this.cmaHost,
        skipTokenValidity: true
      });
      
      const { flags: loginFlags } = await this.parse(LoginCommand);
      authHandler.client = managementAPIClient;

      if (loginFlags.oauth) {
        log.debug('Starting OAuth flow', this.contextDetails);
        oauthHandler.host = this.cmaHost;
        await oauthHandler.oauth();
      } else {
        const username = loginFlags.username || await interactive.askUsername();
        const password = loginFlags.password || await interactive.askPassword();
        await authHandler.login(username, password);
      }
      
      cliux.success('✅ Authenticated successfully');
    } catch (error) {
      handleAndLogError(error, this.contextDetails);
    }
  }
}

Logout Command

export default class LogoutCommand extends BaseCommand<typeof LogoutCommand> {
  static description = 'Logout from Contentstack';
  
  async run(): Promise<void> {
    try {
      await authHandler.setConfigData('logout');
      cliux.success('✅ Logged out successfully');
    } catch (error) {
      handleAndLogError(error, this.contextDetails);
    }
  }
}

Token Management

// Add token
export default class TokenAddCommand extends BaseCommand<typeof TokenAddCommand> {
  static description = 'Add authentication token';
  
  static flags: FlagInput = {
    email: flags.string({
      char: 'e',
      description: 'Email address',
      required: true
    }),
    label: flags.string({
      char: 'l',
      description: 'Token label',
      required: false
    })
  };

  async run(): Promise<void> {
    const { flags } = await this.parse(TokenAddCommand);
    // Add token logic
    cliux.success('✅ Token added successfully');
  }
}

Configuration Commands

Config Get Command

export default class ConfigGetCommand extends BaseCommand<typeof ConfigGetCommand> {
  static description = 'Get CLI configuration values';
  
  async run(): Promise<void> {
    try {
      const region = configHandler.get('region');
      cliux.print(`Region: ${region}`);
    } catch (error) {
      handleAndLogError(error, { ...this.contextDetails, module: 'config-get' });
    }
  }
}

Config Set Command

export default class RegionSetCommand extends BaseCommand<typeof RegionSetCommand> {
  static description = 'Set region for CLI';
  
  static args = {
    region: args.string({ description: 'Region name (AWS-NA, AWS-EU, etc.)' })
  };
  
  static examples = [
    '$ csdx config:set:region',
    '$ csdx config:set:region AWS-NA',
    '$ csdx config:set:region --cma <url> --cda <url> --ui-host <url> --name "Custom"'
  ];

  async run(): Promise<void> {
    try {
      const { args, flags } = await this.parse(RegionSetCommand);
      
      let selectedRegion = args.region;
      if (!selectedRegion) {
        selectedRegion = await interactive.askRegions();
      }

      const regionDetails = regionHandler.setRegion(selectedRegion);
      await authHandler.setConfigData('logout'); // Reset auth on region change
      
      cliux.success(`✅ Region set to ${regionDetails.name}`);
      cliux.print(`CMA host: ${regionDetails.cma}`);
      cliux.print(`CDA host: ${regionDetails.cda}`);
    } catch (error) {
      handleAndLogError(error, { ...this.contextDetails, module: 'config-set-region' });
    }
  }
}

Config Remove Command

export default class ProxyRemoveCommand extends BaseCommand<typeof ProxyRemoveCommand> {
  static description = 'Remove proxy configuration';
  
  async run(): Promise<void> {
    try {
      configHandler.remove('proxy');
      cliux.success('✅ Proxy configuration removed');
    } catch (error) {
      handleAndLogError(error, this.contextDetails);
    }
  }
}

API Integration

Using Management SDK Client

import { managementSDKClient } from '@contentstack/cli-utilities';

// Initialize client
const managementClient = await managementSDKClient({ 
  host: this.cmaHost,
  skipTokenValidity: false
});

// Get stack
const stack = managementClient.stack({ api_key: stackApiKey });

// Fetch entry
const entry = await stack.entry(entryUid).fetch();

// Query entries
const entries = await stack
  .entry()
  .query({ query: { title: 'My Entry' } })
  .find();

// Update entry
const updatedEntry = await stack.entry(entryUid).update({ ...entry });

Error Handling for API Calls

try {
  const stack = client.stack({ api_key: apiKey });
  const entry = await stack.entry(uid).fetch();
} catch (error: any) {
  if (error.status === 401) {
    throw new CLIError('Authentication failed. Please login again.');
  } else if (error.status === 404) {
    throw new CLIError(`Entry with UID "${uid}" not found.`);
  } else if (error.status === 429) {
    throw new CLIError('Rate limited. Please try again later.');
  }
  
  handleAndLogError(error, {
    module: 'entry-service',
    entryUid: uid,
    stackApiKey: apiKey
  });
}

User Input and Interaction

Interactive Prompts

import { interactive } from '../../utils';

// Ask for region selection
const region = await interactive.askRegions();

// Ask for username
const username = await interactive.askUsername();

// Ask for password
const password = await interactive.askPassword();

// Ask custom question
const customResponse = await cliux.prompt('Enter your choice:');

User Feedback

// Success message
cliux.success('✅ Operation completed');

// Error message
cliux.error('❌ Operation failed');

// Info message
cliux.print('Processing...', { color: 'blue' });

// Show data
cliux.table([
  { name: 'Alice', region: 'us', status: 'active' },
  { name: 'Bob', region: 'eu', status: 'inactive' }
]);

Logging Patterns

Structured Logging

log.debug('LoginCommand started', this.contextDetails);
log.debug('Management API client initialized', this.contextDetails);
log.debug('Token parsed', { 
  ...this.contextDetails, 
  flags: loginFlags 
});

try {
  await this.performOperation();
} catch (error) {
  log.debug('Operation failed', {
    ...this.contextDetails,
    error: error.message,
    errorCode: error.code
  });
}

Context Details

The BaseCommand provides contextDetails with:

contextDetails = {
  command: 'auth:login',
  userId: '12345',
  email: 'user@example.com',
  sessionId: 'session-123'
};

Messages (i18n)

Store User Strings

// messages/en.json
{
  "auth": {
    "login": {
      "success": "Authentication successful",
      "failed": "Authentication failed"
    },
    "logout": {
      "success": "Logged out successfully"
    }
  },
  "config": {
    "region": {
      "set": "Region set to {{name}}"
    }
  }
}

Use Message Handler

import { messageHandler } from '@contentstack/cli-utilities';

const message = messageHandler.get(['auth', 'login', 'success']);
cliux.success(message);

Best Practices

Command Organization

export default class MyCommand extends BaseCommand<typeof MyCommand> {
  // 1. Static properties
  static description = '...';
  static examples = [...];
  static flags = {...};
  
  // 2. Instance variables
  private someHelper: Helper;
  
  // 3. run method
  async run(): Promise<void> {
    try {
      const { flags } = await this.parse(MyCommand);
      await this.execute(flags);
    } catch (error) {
      handleAndLogError(error, this.contextDetails);
    }
  }
  
  // 4. Private helper methods
  private async execute(flags: any): Promise<void> {}
  private validate(input: any): void {}
}

Error Messages

  • Be specific about what went wrong
  • Provide actionable feedback
  • Example: "Region must be AWS-NA, AWS-EU, or AWS-AU"
  • Not: "Invalid region"

Progress Indication

cliux.print('🔄 Processing...', { color: 'blue' });
// ... operation ...
cliux.success('✅ Completed successfully');

Apps CLI commands (app:*)

Package: packages/contentstack-apps-cli (@contentstack/apps-cli). SDK, config, HTTP, manifests, GraphQL: framework.

Command layout

Path Command id Purpose
src/commands/app/index.ts app Topic help
src/commands/app/create.ts app:create Create app + optional boilerplate
src/commands/app/get.ts app:get Fetch app details
src/commands/app/update.ts app:update Update from manifest
src/commands/app/delete.ts app:delete Delete from marketplace
src/commands/app/install.ts app:install Install on stack
src/commands/app/reinstall.ts app:reinstall Reinstall on stack
src/commands/app/uninstall.ts app:uninstall Uninstall (factory + strategy)
src/commands/app/deploy.ts app:deploy Deploy (Launch / custom hosting)

OCLIF topic: app (oclif.topics.app in package.json). Short names in csdxConfig.shortCommandName (e.g. app:createAPCRT).

Base command hierarchy

import { BaseCommand } from "../../base-command";
import { AppCLIBaseCommand } from "../../app-cli-base-command";

// Org-level or SDK-only commands
export default class AppInstall extends BaseCommand<typeof AppInstall> {
  static flags = { /* ... */, ...BaseCommand.baseFlags };
  async run() { /* this.flags, this.managementSdk, this.marketplaceAppSdk ready after init */ }
}

// Commands that read manifest.json from cwd
export default class AppUpdate extends AppCLIBaseCommand {
  async run() {
    // this.manifestData, this.manifestPath set in AppCLIBaseCommand.init
  }
}
  • BaseCommand (src/base-command.ts) — init() parses flags/args, registerConfig, logger, validateRegionAndAuth, SDK init. baseFlags: org, yes. Also: getValPrompt, messages / $t, catch / finally (nonexistent flag → exit 2).
  • AppCLIBaseCommand — After super.init(), getManifestData() from {cwd}/manifest.json.

Always call await super.init() first in init() overrides.

Command responsibilities

  • CLI layer — static flags / examples, prompts (cliux, getValPrompt, src/util/inquirer.ts), this.log, this.error / exit.
  • Business logicsrc/util/, src/factories/, src/strategies/; keep run() small.

Parse and flags

Parse runs inside BaseCommand.init only; do not re-parse in run(). After init, use this.flags and this.args. Inherit BaseCommand.baseFlags when org / --yes apply.

OCLIF hook: src/hooks/init/load-chalk.ts (registered in package.json oclif.hooks.init).

Apps command example

export default class AppGet extends BaseCommand<typeof AppGet> {
  static description = "Get details of an app in developer hub";

  static flags = {
    "app-uid": Flags.string({ description: "App UID" }),
    "app-type": Flags.string({
      options: ["stack", "organization"],
      default: "stack",
    }),
    ...BaseCommand.baseFlags,
  };

  async run(): Promise<void> {
    const { org, "app-uid": appUid } = this.flags;
    // Use this.marketplaceAppSdk / GraphQL / apiRequestHandler — see framework Apps CLI section
    this.log(this.$t(this.messages.APP_FETCH_SUCCESS), "info");
  }
}

External config and deploy

  • --config — JSON merged into sharedConfig (see registerConfig in base-command.ts). Used by create/deploy for Launch project settings.
  • --data-dir — Working directory for boilerplate clone and manifest paths.

Errors

BaseCommand.catch handles NonExistent flag with exit code 2. Prefer actionable messages via messages / $t and existing util error helpers.

Build and test

Command Purpose
pnpm --filter @contentstack/apps-cli run build tsclib/
pnpm --filter @contentstack/apps-cli test Mocha + ESLint (posttest)
pnpm --filter @contentstack/apps-cli run test:unit:report Unit tests with nyc

Testing apps commands

Unit tests under test/unit/commands/app/. Use stubAuthentication from test/unit/helpers/auth-stub-helper.ts and nock Developer Hub hosts from getDeveloperHubUrl(). See testing and dev-workflow.

Tsgen plugin (contentstack-cli-tsgen)

Package: packages/contentstack-cli-tsgen (contentstack-cli-tsgen). Top-level command csdx tsgen (short TSGEN). Requires a delivery token alias (-a / --token-alias).

Full flags, REST vs GraphQL, and library boundaries: typescript-cli-tsgen. Integration tests (Jest): package testing skill. Repo move: TSGEN-MIGRATION.md.

Command Purpose
pnpm --filter contentstack-cli-tsgen run build tsclib/ + OCLIF manifest
pnpm --filter contentstack-cli-tsgen run test:integration Live stack integration tests