Skip to content

Commit 8410ace

Browse files
Merge pull request #7 from contentstack/feat/DX-4444
feat: add asset management package for AM2.0 support
2 parents 00534b9 + bd80e5a commit 8410ace

60 files changed

Lines changed: 11689 additions & 11157 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.talismanrc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
fileignoreconfig:
2-
- filename: package-lock.json
3-
checksum: 45100667793fc7dfaae3e24787871257e7f29e06df69ba10ec05b358d59ff15d
42
- filename: pnpm-lock.yaml
5-
checksum: 87d001c32b1d7f9df30a289c277e0ea13cfd8a0e2e5fa5118956ff4183683e5c
3+
checksum: 69c9fefd1240e00e7efa17658a53292444de3eecc70fb93c719b3b92a8cac0f0
64
- filename: .husky/pre-commit
75
checksum: 7a12030ddfea18d6f85edc25f1721fb2009df00fdd42bab66b05de25ab3e32b2
6+
- filename: packages/contentstack-migration/src/commands/cm/stacks/migration.ts
7+
checksum: 8690833f285db085aa1431d4a708c243e2bf5b4ed366c5c15e2daf66eb24c19e
88
version: '1.0'

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@
1010
},
1111
"private": true,
1212
"scripts": {
13-
"clean": "pnpm -r --filter './packages/*' run clean",
13+
"clean:packages": "pnpm -r --filter './packages/*' run clean",
1414
"build": "pnpm -r --filter './packages/*' run build",
1515
"test": "pnpm -r --filter './packages/*' run test",
1616
"prepack": "pnpm -r --filter './packages/*' run prepack",
1717
"bootstrap": "pnpm install",
1818
"clean:modules": "rm -rf node_modules packages/**/node_modules",
1919
"clean:lock": "rm -f pnpm-lock.yaml",
20-
"clean:all": "pnpm store prune && rm -rf node_modules && pnpm run clean",
20+
"clean:all": "pnpm store prune && rm -rf node_modules && pnpm run clean:packages",
2121
"setup": "pnpm run clean:all && pnpm run bootstrap && pnpm run build",
22-
"prepare": "npx husky && chmod +x .husky/pre-commit"
22+
"prepare": "npx husky && chmod +x .husky/pre-commit",
23+
"update:lockfile": "pnpm install --lockfile-only"
2324
},
2425
"license": "MIT",
2526
"packageManager": "pnpm@10.28.0",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"env": {
3+
"node": true
4+
},
5+
"parser": "@typescript-eslint/parser",
6+
"parserOptions": {
7+
"project": "tsconfig.json",
8+
"sourceType": "module"
9+
},
10+
"extends": [
11+
"oclif-typescript",
12+
"plugin:@typescript-eslint/recommended"
13+
],
14+
"rules": {
15+
"@typescript-eslint/no-unused-vars": [
16+
"error",
17+
{
18+
"args": "none"
19+
}
20+
],
21+
"@typescript-eslint/prefer-namespace-keyword": "error",
22+
"@typescript-eslint/quotes": [
23+
"error",
24+
"single",
25+
{
26+
"avoidEscape": true,
27+
"allowTemplateLiterals": true
28+
}
29+
],
30+
"semi": "off",
31+
"@typescript-eslint/type-annotation-spacing": "error",
32+
"@typescript-eslint/no-redeclare": "off",
33+
"eqeqeq": [
34+
"error",
35+
"smart"
36+
],
37+
"id-match": "error",
38+
"no-eval": "error",
39+
"no-var": "error",
40+
"quotes": "off",
41+
"indent": "off",
42+
"camelcase": "off",
43+
"comma-dangle": "off",
44+
"arrow-parens": "off",
45+
"operator-linebreak": "off",
46+
"object-curly-spacing": "off",
47+
"node/no-missing-import": "off",
48+
"padding-line-between-statements": "off",
49+
"@typescript-eslint/ban-ts-ignore": "off",
50+
"unicorn/no-abusive-eslint-disable": "off",
51+
"unicorn/consistent-function-scoping": "off",
52+
"@typescript-eslint/no-use-before-define": "off"
53+
}
54+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
lib/
2+
node_modules/
3+
*.tsbuildinfo
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# @contentstack/cli-asset-management
2+
3+
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/).
4+
5+
[![License](https://img.shields.io/npm/l/@contentstack/cli)](https://github.com/contentstack/cli/blob/main/LICENSE)
6+
7+
<!-- toc -->
8+
* [@contentstack/cli-asset-management](#contentstackcli-asset-management)
9+
* [Overview](#overview)
10+
* [Usage](#usage)
11+
* [Exports](#exports)
12+
<!-- tocstop -->
13+
14+
# Overview
15+
16+
This package provides:
17+
18+
- **AssetManagementAdapter** – HTTP client for the Asset Management API (spaces, assets, folders, fields, asset types).
19+
- **exportSpaceStructure** – Exports space metadata and full workspace structure (metadata, folders, assets, fields, asset types) for linked workspaces.
20+
- **Types**`AssetManagementExportOptions`, `LinkedWorkspace`, `IAssetManagementAdapter`, and related types for export/import integration.
21+
22+
# Usage
23+
24+
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:
25+
26+
```ts
27+
import { exportSpaceStructure } from '@contentstack/cli-asset-management';
28+
29+
await exportSpaceStructure({
30+
linkedWorkspaces,
31+
exportDir,
32+
branchName: 'main',
33+
assetManagementUrl,
34+
org_uid,
35+
context,
36+
progressManager,
37+
progressProcessName,
38+
updateStatus,
39+
downloadAsset, // optional
40+
});
41+
```
42+
43+
# Exports
44+
45+
| Export | Description |
46+
|--------|-------------|
47+
| `exportSpaceStructure` | Async function to export space structure for given linked workspaces. |
48+
| `AssetManagementAdapter` | Class to call the Asset Management API (getSpace, getWorkspaceFields, getWorkspaceAssets, etc.). |
49+
| Types from `./types` | `AssetManagementExportOptions`, `ExportSpaceOptions`, `ChunkedJsonWriteOptions`, `LinkedWorkspace`, `SpaceResponse`, `FieldsResponse`, `AssetTypesResponse`, and related API types. |
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"name": "@contentstack/cli-asset-management",
3+
"version": "1.0.0",
4+
"description": "Asset Management 2.0 API adapter for export and import",
5+
"main": "lib/index.js",
6+
"types": "lib/index.d.ts",
7+
"files": [
8+
"lib",
9+
"oclif.manifest.json"
10+
],
11+
"scripts": {
12+
"build": "pnpm compile",
13+
"clean": "rm -rf ./lib ./node_modules tsconfig.build.tsbuildinfo",
14+
"compile": "tsc -b tsconfig.json",
15+
"postpack": "rm -f oclif.manifest.json",
16+
"prepack": "pnpm compile && oclif manifest && oclif readme",
17+
"version": "oclif readme && git add README.md",
18+
"lint": "eslint src/**/*.ts",
19+
"format": "eslint src/**/*.ts --fix",
20+
"test": "nyc --extension .ts mocha --require ts-node/register --forbid-only \"test/**/*.test.ts\"",
21+
"posttest": "npm run lint",
22+
"test:unit": "mocha --require ts-node/register --forbid-only \"test/unit/**/*.test.ts\"",
23+
"test:unit:report": "nyc --extension .ts mocha --require ts-node/register --forbid-only \"test/unit/**/*.test.ts\""
24+
},
25+
"keywords": [
26+
"contentstack",
27+
"asset-management",
28+
"cli"
29+
],
30+
"license": "MIT",
31+
"dependencies": {
32+
"@contentstack/cli-utilities": "~2.0.0-beta"
33+
},
34+
"oclif": {
35+
"commands": "./lib/commands",
36+
"bin": "csdx",
37+
"devPlugins": [
38+
"@oclif/plugin-help"
39+
],
40+
"repositoryPrefix": "<%- repo %>/blob/main/packages/contentstack-asset-management/<%- commandPath %>"
41+
},
42+
"devDependencies": {
43+
"@types/chai": "^4.3.11",
44+
"@types/mocha": "^10.0.6",
45+
"@types/node": "^20.17.50",
46+
"@types/sinon": "^17.0.2",
47+
"chai": "^4.4.1",
48+
"eslint": "^8.57.1",
49+
"eslint-config-oclif": "^6.0.68",
50+
"mocha": "^10.8.2",
51+
"nyc": "^15.1.0",
52+
"oclif": "^4.17.46",
53+
"sinon": "^17.0.1",
54+
"source-map-support": "^0.5.21",
55+
"ts-node": "^10.9.2",
56+
"typescript": "^5.8.3"
57+
}
58+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export const BATCH_SIZE = 50;
2+
export const CHUNK_FILE_SIZE_MB = 1;
3+
4+
/**
5+
* Main process name for Asset Management 2.0 export (single progress bar).
6+
* Use this when adding/starting the process and for all ticks.
7+
*/
8+
export const AM_MAIN_PROCESS_NAME = 'Asset Management 2.0';
9+
10+
/**
11+
* Process names for Asset Management 2.0 export progress (for tick labels).
12+
*/
13+
export const PROCESS_NAMES = {
14+
AM_SPACE_METADATA: 'Space metadata',
15+
AM_FOLDERS: 'Folders',
16+
AM_ASSETS: 'Assets',
17+
AM_FIELDS: 'Fields',
18+
AM_ASSET_TYPES: 'Asset types',
19+
AM_DOWNLOADS: 'Asset downloads',
20+
} as const;
21+
22+
/**
23+
* Status messages for each process (exporting, fetching, failed).
24+
*/
25+
export const PROCESS_STATUS = {
26+
[PROCESS_NAMES.AM_SPACE_METADATA]: {
27+
EXPORTING: 'Exporting space metadata...',
28+
FAILED: 'Failed to export space metadata.',
29+
},
30+
[PROCESS_NAMES.AM_FOLDERS]: {
31+
FETCHING: 'Fetching folders...',
32+
FAILED: 'Failed to fetch folders.',
33+
},
34+
[PROCESS_NAMES.AM_ASSETS]: {
35+
FETCHING: 'Fetching assets...',
36+
FAILED: 'Failed to fetch assets.',
37+
},
38+
[PROCESS_NAMES.AM_FIELDS]: {
39+
FETCHING: 'Fetching fields...',
40+
FAILED: 'Failed to fetch fields.',
41+
},
42+
[PROCESS_NAMES.AM_ASSET_TYPES]: {
43+
FETCHING: 'Fetching asset types...',
44+
FAILED: 'Failed to fetch asset types.',
45+
},
46+
[PROCESS_NAMES.AM_DOWNLOADS]: {
47+
DOWNLOADING: 'Downloading asset files...',
48+
FAILED: 'Failed to download assets.',
49+
},
50+
} as const;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { log } from '@contentstack/cli-utilities';
2+
3+
import type { AssetManagementAPIConfig } from '../types/asset-management-api';
4+
import type { ExportContext } from '../types/export-types';
5+
import { AssetManagementExportAdapter } from './base';
6+
import { getArrayFromResponse } from '../utils/export-helpers';
7+
import { PROCESS_NAMES } from '../constants/index';
8+
9+
export default class ExportAssetTypes extends AssetManagementExportAdapter {
10+
constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) {
11+
super(apiConfig, exportContext);
12+
}
13+
14+
async start(spaceUid: string): Promise<void> {
15+
await this.init();
16+
const assetTypesData = await this.getWorkspaceAssetTypes(spaceUid);
17+
const items = getArrayFromResponse(assetTypesData, 'asset_types');
18+
const dir = this.getAssetTypesDir();
19+
log.debug(
20+
items.length === 0
21+
? 'No asset types, wrote empty asset-types'
22+
: `Writing ${items.length} shared asset types`,
23+
this.exportContext.context,
24+
);
25+
await this.writeItemsToChunkedJson(dir, 'asset-types.json', 'asset_types', ['uid', 'title', 'category', 'file_extension'], items);
26+
this.tick(true, PROCESS_NAMES.AM_ASSET_TYPES, null);
27+
}
28+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { resolve as pResolve } from 'node:path';
2+
import { Readable } from 'node:stream';
3+
import { mkdir, writeFile } from 'node:fs/promises';
4+
import { configHandler, log } from '@contentstack/cli-utilities';
5+
6+
import type { AssetManagementAPIConfig, LinkedWorkspace } from '../types/asset-management-api';
7+
import type { ExportContext } from '../types/export-types';
8+
import { AssetManagementExportAdapter } from './base';
9+
import { getAssetItems, writeStreamToFile } from '../utils/export-helpers';
10+
import { PROCESS_NAMES, PROCESS_STATUS } from '../constants/index';
11+
12+
export default class ExportAssets extends AssetManagementExportAdapter {
13+
constructor(apiConfig: AssetManagementAPIConfig, exportContext: ExportContext) {
14+
super(apiConfig, exportContext);
15+
}
16+
17+
async start(workspace: LinkedWorkspace, spaceDir: string): Promise<void> {
18+
await this.init();
19+
const assetsDir = pResolve(spaceDir, 'assets');
20+
await mkdir(assetsDir, { recursive: true });
21+
log.debug(`Fetching folders and assets for space ${workspace.space_uid}`, this.exportContext.context);
22+
23+
const [folders, assetsData] = await Promise.all([
24+
this.getWorkspaceFolders(workspace.space_uid),
25+
this.getWorkspaceAssets(workspace.space_uid),
26+
]);
27+
28+
await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2));
29+
this.tick(true, `folders: ${workspace.space_uid}`, null);
30+
log.debug(`Wrote folders.json for space ${workspace.space_uid}`, this.exportContext.context);
31+
32+
const assetItems = getAssetItems(assetsData);
33+
log.debug(
34+
assetItems.length === 0
35+
? `No assets for space ${workspace.space_uid}, wrote empty assets.json`
36+
: `Writing ${assetItems.length} assets metadata for space ${workspace.space_uid}`,
37+
this.exportContext.context,
38+
);
39+
await this.writeItemsToChunkedJson(
40+
assetsDir,
41+
'assets.json',
42+
'assets',
43+
['uid', 'url', 'filename', 'file_name', 'parent_uid'],
44+
assetItems,
45+
);
46+
this.tick(true, `assets: ${workspace.space_uid} (${assetItems.length})`, null);
47+
48+
await this.downloadWorkspaceAssets(assetsData, assetsDir, workspace.space_uid);
49+
}
50+
51+
private async downloadWorkspaceAssets(
52+
assetsData: unknown,
53+
assetsDir: string,
54+
spaceUid: string,
55+
): Promise<void> {
56+
const items = getAssetItems(assetsData);
57+
if (items.length === 0) {
58+
log.debug('No assets to download', this.exportContext.context);
59+
return;
60+
}
61+
62+
this.updateStatus(PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].DOWNLOADING);
63+
log.debug(`Downloading ${items.length} asset file(s) for space ${spaceUid}...`, this.exportContext.context);
64+
const filesDir = pResolve(assetsDir, 'files');
65+
await mkdir(filesDir, { recursive: true });
66+
67+
const securedAssets = this.exportContext.securedAssets ?? false;
68+
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
69+
let lastError: string | null = null;
70+
let allSuccess = true;
71+
72+
for (const asset of items) {
73+
const uid = asset.uid ?? asset._uid;
74+
const url = asset.url;
75+
const filename = asset.filename ?? asset.file_name ?? 'asset';
76+
if (!url || !uid) continue;
77+
try {
78+
const separator = url.includes('?') ? '&' : '?';
79+
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
80+
const response = await fetch(downloadUrl);
81+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
82+
const body = response.body;
83+
if (!body) throw new Error('No response body');
84+
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
85+
const assetFolderPath = pResolve(filesDir, uid);
86+
await mkdir(assetFolderPath, { recursive: true });
87+
const filePath = pResolve(assetFolderPath, filename);
88+
await writeStreamToFile(nodeStream, filePath);
89+
log.debug(`Downloaded asset ${uid}`, this.exportContext.context);
90+
} catch (e) {
91+
allSuccess = false;
92+
lastError = (e as Error)?.message ?? PROCESS_STATUS[PROCESS_NAMES.AM_DOWNLOADS].FAILED;
93+
log.debug(`Failed to download asset ${uid}: ${e}`, this.exportContext.context);
94+
}
95+
}
96+
97+
this.tick(allSuccess, `downloads: ${spaceUid}`, lastError);
98+
log.debug('Asset downloads completed', this.exportContext.context);
99+
}
100+
}

0 commit comments

Comments
 (0)