Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion docs/user-guide/config-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Where `-f` is the shorthand for `--file`. You can also point at an unzipped dire
content-cli config package import -p <sourceProfile> -d <package directory path>
```

`--file` and `--directory` are mutually exclusive — provide exactly one.
`--file`, `--directory`, and `--gitBranch` are mutually exclusive — provide exactly one. See [Git Integration for Import](#git-integration-for-import) for the `--gitBranch` workflow.

This command requires **edit permission** on the target package, or **create** permission when the package does not yet exist (see [Permissions](#permissions)).

Expand Down Expand Up @@ -168,6 +168,19 @@ content-cli config package import -p <sourceProfile> -f <file path> --json
info: File downloaded successfully. New filename: 9560f81f-f746-4117-83ee-dd1f614ad624.json
```

#### Git Integration for Import

Instead of `--file` / `--directory`, you can pull the package straight from a Git branch with the following **Git options**:

- `--gitProfile <gitProfileName>` – the Git profile to use for the Git operations. If omitted, the default profile is used.
- `--gitBranch <branchName>` – the branch in the Git repository the package is pulled from and imported.

The CLI clones the configured repository at the given branch, expects the branch contents to be the flat [package layout](#package-zip-format), zips them, and imports the package. `--gitBranch` cannot be combined with `--file` or `--directory`.

```bash
content-cli config package import -p <sourceProfile> --gitProfile myGitProfile --gitBranch feature-branch
```

### Export a Package

`config package export` is the counterpart of [`config package import`](#import-a-package): it exports a single package's **staging (draft) version** in the package format. As with the `config nodes` commands, "staging" refers to the current draft state of the package — the unpublished working version, not a published package version. The output uses the same flat [package format](#package-zip-format) that `config package import` consumes, so a package can be exported from one team and imported into another without any conversion. Unlike [`t2tc package export`](./t2tc-commands.md#export-packages) — which produces a multi-package **batch artifact** — this command exports exactly one package on its own.
Expand Down Expand Up @@ -211,6 +224,23 @@ my-package/

`variables.json` is omitted when the package has no variable assignments.

#### Git Integration for Export

Instead of writing to the current working directory, you can push the exported package straight to a Git branch with the following **Git options**:

- `--gitProfile <gitProfileName>` – the Git profile to use for the Git operations. If omitted, the default profile is used.
- `--gitBranch <branchName>` – the branch in the Git repository the exported package is pushed to.

The package is pushed in its unzipped [package layout](#package-layout), so `--zip` has no effect when `--gitBranch` is used.

```bash
content-cli config package export -p <sourceProfile> --packageKey <packageKey> --gitProfile myGitProfile --gitBranch feature-branch
```

```bash
info: Successfully exported package to branch: feature-branch
```

## Validate Package Configurations (`config package validate`)

> **Renamed.** This command moved from `config validate` to `config package validate`. The old `config validate` still works but prints a deprecation notice; switch to `config package validate`.
Expand Down
18 changes: 14 additions & 4 deletions src/commands/configuration-management/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,21 @@ class Module extends IModule {
.description("Commands for working with a single package.");

packageCommand.command("import")
.description("Import a package from a zip file or directory. Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.")
.description("Import a package from a zip file, directory, or Git branch. Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.")
.option("-f, --file <file>", "Package zip file (relative path)")
.option("-d, --directory <directory>", "Package directory (relative path)")
.option("--overwrite", "Flag to allow overwriting an existing package with the same key")
.option("--json", "Return the response as a JSON file")
.option("--gitProfile <gitProfile>", "Git profile which you want to use for the Git operations")
.option("--gitBranch <gitBranch>", "Git branch from which you want to pull the package and import")
.action(this.importSinglePackage);

packageCommand.command("export")
.description("Export a single package's staging (draft) version to an unzipped directory (or a single zip with --zip). Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.")
.description("Export a single package's staging (draft) version to an unzipped directory (or a single zip with --zip, or to a Git branch with --gitBranch). Uses the package format, which is not interchangeable with the 't2tc package export' / 't2tc package import' batch archive.")
.requiredOption("--packageKey <packageKey>", "Key of the package to export")
.option("--zip", "Export the package as a single <packageKey>.zip file instead of an unzipped <packageKey> directory", false)
.option("--gitProfile <gitProfile>", "Git profile which you want to use for the Git operations")
.option("--gitBranch <gitBranch>", "Git branch in which you want to push the exported package")
.action(this.exportSinglePackage);

packageCommand.command("validate")
Expand Down Expand Up @@ -307,11 +311,17 @@ class Module extends IModule {
}

private async importSinglePackage(context: Context, command: Command, options: OptionValues): Promise<void> {
await new SinglePackageImportService(context).importPackage(options.file, options.directory, options.overwrite, options.json);
if (options.gitProfile && !options.gitBranch) {
throw new Error("Please specify a branch using --gitBranch when using a Git profile.");
}
await new SinglePackageImportService(context).importPackage(options.file, options.directory, options.overwrite, options.json, options.gitBranch);
}

private async exportSinglePackage(context: Context, command: Command, options: OptionValues): Promise<void> {
await new SinglePackageExportService(context).exportPackage(options.packageKey, options.zip);
if (options.gitProfile && !options.gitBranch) {
throw new Error("Please specify a branch using --gitBranch when using a Git profile.");
}
await new SinglePackageExportService(context).exportPackage(options.packageKey, options.zip, options.gitBranch);
}

private async diffPackages(context: Context, command: Command, options: OptionValues): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import * as fs from "node:fs";
import { Context } from "../../core/command/cli-context";
import { fileService, FileService } from "../../core/utils/file-service";
import { logger } from "../../core/utils/logger";
import { GitService } from "../../core/git-profile/git/git.service";
import { SinglePackageExportApi } from "./api/single-package-export-api";

export class SinglePackageExportService {

private readonly singlePackageExportApi: SinglePackageExportApi;
private readonly gitService: GitService;

constructor(context: Context) {
this.singlePackageExportApi = new SinglePackageExportApi(context);
this.gitService = new GitService(context);
}

public async exportPackage(packageKey: string, zip: boolean): Promise<void> {
public async exportPackage(packageKey: string, zip: boolean, gitBranch: string): Promise<void> {
const packageData = await this.singlePackageExportApi.exportPackage(packageKey);

if (gitBranch) {
await this.exportToGitBranch(packageData, gitBranch);
return;
}

if (zip) {
const fileName = `${packageKey}.zip`;
fileService.writeBufferToFileWithGivenName(packageData, fileName);
Expand All @@ -24,4 +33,14 @@ export class SinglePackageExportService {
fileService.extractZipBufferToDirectory(packageData, packageKey);
logger.info(`Successful export. Exported directory: ${packageKey}`);
}

private async exportToGitBranch(packageData: Buffer, gitBranch: string): Promise<void> {
const extractedDirectory = fileService.extractZipBufferToTempDirectory(packageData);
try {
await this.gitService.pushToBranch(extractedDirectory, gitBranch);
logger.info("Successfully exported package to branch: " + gitBranch);
} finally {
fs.rmSync(extractedDirectory, { recursive: true, force: true });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as fs from "node:fs";
import { Context } from "../../core/command/cli-context";
import { fileService, FileService } from "../../core/utils/file-service";
import { logger } from "../../core/utils/logger";
import { GitService } from "../../core/git-profile/git/git.service";
import { SinglePackageImportApi } from "./api/single-package-import-api";
import { SinglePackageImportResult } from "./interfaces/single-package-import.interfaces";

Expand All @@ -14,22 +15,44 @@ export class SinglePackageImportService {
private static readonly MAX_UNCOMPRESSED_ZIP_SIZE = 4 * 1024 * 1024 * 1024;

private readonly singlePackageImportApi: SinglePackageImportApi;
private readonly gitService: GitService;

constructor(context: Context) {
this.singlePackageImportApi = new SinglePackageImportApi(context);
this.gitService = new GitService(context);
}

public async importPackage(file: string, directory: string, overwrite: boolean, jsonResponse: boolean): Promise<void> {
const resolvedSource = this.resolveSource(file, directory);
public async importPackage(file: string, directory: string, overwrite: boolean, jsonResponse: boolean, gitBranch: string): Promise<void> {
if ((file || directory) && gitBranch) {
throw new Error("You cannot use --file or --directory together with --gitBranch. Only one import source can be defined.");
}
if (!file && !directory && !gitBranch) {
throw new Error("You must provide a --file, a --directory, or a --gitBranch option to import a package.");
}

let gitTempDir: string | undefined;
try {
const packageZip = new AdmZip(resolvedSource.zipPath);
const formData = this.buildBodyForImport(packageZip, resolvedSource.zipPath);
const result = await this.singlePackageImportApi.importPackage(formData, overwrite);
this.outputResult(result, jsonResponse);
let resolvedSource: { zipPath: string; isTemporary: boolean };
if (gitBranch) {
gitTempDir = await this.gitService.pullFromBranch(gitBranch);
resolvedSource = { zipPath: fileService.zipDirectoryAsSinglePackage(gitTempDir), isTemporary: true };
} else {
resolvedSource = this.resolveSource(file, directory);
}

try {
const packageZip = new AdmZip(resolvedSource.zipPath);
const formData = this.buildBodyForImport(packageZip, resolvedSource.zipPath);
const result = await this.singlePackageImportApi.importPackage(formData, overwrite);
this.outputResult(result, jsonResponse);
} finally {
if (resolvedSource.isTemporary) {
fs.rmSync(resolvedSource.zipPath);
}
}
} finally {
if (resolvedSource.isTemporary) {
fs.rmSync(resolvedSource.zipPath);
if (gitTempDir) {
fs.rmSync(gitTempDir, { recursive: true, force: true });
}
}
}
Expand All @@ -38,9 +61,6 @@ export class SinglePackageImportService {
if (file && directory) {
throw new Error("You cannot use both --file and --directory options at the same time. Only one import source can be defined.");
}
if (!file && !directory) {
throw new Error("You must provide either a --file or a --directory option to import a package.");
}
if (file) {
if (fileService.isDirectory(file)) {
throw new Error("The --file option accepts only zip files.");
Expand Down
8 changes: 8 additions & 0 deletions src/core/utils/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export class FileService {
this.restrictFilePermissions(targetPath);
}

public extractZipBufferToTempDirectory(data: Buffer): string {
const tempDir = path.join(os.tmpdir(), `content-cli-${uuidv4()}`);
fs.mkdirSync(tempDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS });
new AdmZip(data).extractAllTo(tempDir, true, true);
this.restrictFilePermissions(tempDir);
return tempDir;
}

public async readFileToJson<T>(fileName: string): Promise<T> {
const fileContent = this.readFile(fileName);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import * as path from "path";
import * as fs from "node:fs";
import AdmZip = require("adm-zip");
import { mockAxiosGet, mockAxiosGetError, mockedAxiosInstance } from "../../utls/http-requests-mock";
import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service";
import { testContext } from "../../utls/test-context";
import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup";
import { FileService } from "../../../src/core/utils/file-service";
import { GitService } from "../../../src/core/git-profile/git/git.service";

const PACKAGE_KEY = "pkg-1";
const EXPORT_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/export-file`;
Expand Down Expand Up @@ -37,7 +39,7 @@ describe("Single package export", () => {

const extractSpy = jest.spyOn(FileService.prototype, "extractZipBufferToDirectory").mockImplementation(() => undefined);

await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false);
await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false, null);

expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything());
expect(extractSpy).toHaveBeenCalledTimes(1);
Expand All @@ -52,7 +54,7 @@ describe("Single package export", () => {
const packageData = buildSinglePackageZip();
mockAxiosGet(EXPORT_URL, packageData);

await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true);
await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true, null);

expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything());

Expand All @@ -67,9 +69,46 @@ describe("Single package export", () => {
mockAxiosGetError(EXPORT_URL, 404, { errorCode: "PACKAGE_NOT_FOUND" });

await expect(
new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false)
new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false, null)
).rejects.toThrow(`Problem exporting package ${PACKAGE_KEY}`);

expect(mockWriteFileSync).not.toHaveBeenCalled();
});

it("Should push the exported package to a Git branch when --gitBranch is set", async () => {
const packageData = buildSinglePackageZip();
mockAxiosGet(EXPORT_URL, packageData);

const extractedDirectory = "/tmp/content-cli-export-temp";
const extractTempSpy = jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(extractedDirectory);
const extractDirSpy = jest.spyOn(FileService.prototype, "extractZipBufferToDirectory").mockImplementation(() => undefined);
const pushToBranchSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue();

await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, false, "my-branch");

expect(mockedAxiosInstance.get).toHaveBeenCalledWith(EXPORT_URL, expect.anything());
expect(extractTempSpy).toHaveBeenCalledTimes(1);
expect((extractTempSpy.mock.calls[0][0] as Buffer).equals(packageData)).toBe(true);
expect(pushToBranchSpy).toHaveBeenCalledWith(extractedDirectory, "my-branch");
expect(fs.rmSync).toHaveBeenCalledWith(extractedDirectory, { recursive: true, force: true });
expect(loggingTestTransport.logMessages[0].message).toContain("Successfully exported package to branch: my-branch");
expect(extractDirSpy).not.toHaveBeenCalled();
expect(mockWriteFileSync).not.toHaveBeenCalled();
});

it("Should push to the Git branch unzipped even when --zip is also set", async () => {
const packageData = buildSinglePackageZip();
mockAxiosGet(EXPORT_URL, packageData);

const extractedDirectory = "/tmp/content-cli-export-temp";
const extractTempSpy = jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(extractedDirectory);
const pushToBranchSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue();

await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true, "my-branch");

expect(extractTempSpy).toHaveBeenCalledTimes(1);
expect(pushToBranchSpy).toHaveBeenCalledWith(extractedDirectory, "my-branch");
expect(mockWriteFileSync).not.toHaveBeenCalled();
expect(loggingTestTransport.logMessages[0].message).toContain("Successfully exported package to branch: my-branch");
});
});
Loading
Loading