From 198c97f21c3a13b6cd7c6a798bb469061b33f069 Mon Sep 17 00:00:00 2001 From: denniswo Date: Tue, 11 Aug 2026 10:10:31 +0200 Subject: [PATCH 1/4] SP-1173: mark the directories the export commands write A directory has no single file to rename, so a classified export keeps its tree and gains the cover sheet inside a prefixed directory. The cover decision helper now takes a writer callback, which lets the three directory commands reuse the same branch the file writers use. extractExportedZipWithNestedZipsToDir resolves its target against the working directory like its sibling extract helpers, so the cover sheet lands next to the tree it belongs to. Includes-AI-Code: true Co-authored-by: Cursor --- .../branch-export-import.command.service.ts | 10 ++-- .../single-package-export.service.ts | 7 ++- src/commands/t2tc/t2tc-package.service.ts | 8 ++- src/core/utils/cui-file-service.ts | 55 ++++++++++++------- src/core/utils/file-service.ts | 13 +++-- 5 files changed, 58 insertions(+), 35 deletions(-) diff --git a/src/commands/configuration-management/branch/branch-export-import.command.service.ts b/src/commands/configuration-management/branch/branch-export-import.command.service.ts index 768fd43c..b66ef699 100644 --- a/src/commands/configuration-management/branch/branch-export-import.command.service.ts +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -162,10 +162,12 @@ export class BranchExportImportCommandService { fs.rmSync(zipPath, { force: true }); } } - const targetDir = resolve(process.cwd(), packageKey); - fs.rmSync(targetDir, { recursive: true, force: true }); - fs.cpSync(sourceDir, targetDir, { recursive: true }); - return `Successful export. Exported directory: ${packageKey}`; + const directoryName = await this.cuiFileService.writeDirectoryWithGivenName(name => { + const targetDir = resolve(process.cwd(), name); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); + }, packageKey); + return `Successful export. Exported directory: ${directoryName}`; } private prepareLocalWorkingDir(file: string | undefined, directory: string | undefined): string { diff --git a/src/commands/configuration-management/single-package-export.service.ts b/src/commands/configuration-management/single-package-export.service.ts index ad98ff52..b0f0fd2a 100644 --- a/src/commands/configuration-management/single-package-export.service.ts +++ b/src/commands/configuration-management/single-package-export.service.ts @@ -32,8 +32,11 @@ export class SinglePackageExportService { return; } - fileService.extractZipBufferToDirectory(packageData, packageKey); - logger.info(`Successful export. Exported directory: ${packageKey}`); + const directoryName = await this.cuiFileService.writeDirectoryWithGivenName( + targetDir => fileService.extractZipBufferToDirectory(packageData, targetDir), + packageKey + ); + logger.info(`Successful export. Exported directory: ${directoryName}`); } private async exportToGitBranch(packageData: Buffer, gitBranch: string): Promise { diff --git a/src/commands/t2tc/t2tc-package.service.ts b/src/commands/t2tc/t2tc-package.service.ts index 426455b1..35301ede 100644 --- a/src/commands/t2tc/t2tc-package.service.ts +++ b/src/commands/t2tc/t2tc-package.service.ts @@ -245,9 +245,11 @@ export class T2tcPackageService { private async downloadZip(exportedZip: AdmZip, unzip: boolean): Promise { if (unzip) { const fileDownloadedMessage = "Successful download. Downloaded directory: "; - const targetDirectoryName = `export_${uuidv4()}`; - fileService.extractExportedZipWithNestedZipsToDir(exportedZip, targetDirectoryName); - logger.info(fileDownloadedMessage + targetDirectoryName); + const directoryName = await this.cuiFileService.writeDirectoryWithGivenName( + targetDir => fileService.extractExportedZipWithNestedZipsToDir(exportedZip, targetDir), + `export_${uuidv4()}` + ); + logger.info(fileDownloadedMessage + directoryName); } else { const fileDownloadedMessage = "File downloaded successfully. New filename: "; const filename = await this.cuiFileService.writeZipToFileWithGivenName(exportedZip.toBuffer(), `export_${uuidv4()}.zip`); diff --git a/src/core/utils/cui-file-service.ts b/src/core/utils/cui-file-service.ts index cfc739c0..7a9b01f1 100644 --- a/src/core/utils/cui-file-service.ts +++ b/src/core/utils/cui-file-service.ts @@ -20,46 +20,61 @@ export class CuiFileService { } public async writeToFileWithGivenName(data: string, filename: string): Promise { - return this.writeWithCoverHandling(data, filename, cover => - this.writeClassifiedArchive(filename, data, cover) + return this.writeWithCoverHandling( + name => fileService.writeToFileWithGivenName(data, name), + filename, + cover => this.writeClassifiedArchive(filename, data, cover) ); } public async writeZipToFileWithGivenName(zipData: Buffer, filename: string): Promise { - return this.writeWithCoverHandling(zipData, filename, cover => { - const zip = new AdmZip(zipData); - this.addCoverPage(zip, cover); + return this.writeWithCoverHandling( + name => fileService.writeBufferToFileWithGivenName(zipData, name), + filename, + cover => { + const zip = new AdmZip(zipData); + this.addCoverPage(zip, cover); + + return this.writeArchive(zip, filename); + } + ); + } + + public async writeDirectoryWithGivenName(write: (targetDir: string) => void, directoryName: string): Promise { + return this.writeWithCoverHandling(write, directoryName, cover => { + const coverSheet = this.decodeCoverPage(cover); + const classifiedName = this.prefixFileName(directoryName, CuiFileService.CLASSIFIED_PREFIX); - return this.writeArchive(zip, filename); + write(classifiedName); + fileService.writeBufferToFileWithGivenName( + coverSheet, + path.join(classifiedName, CuiFileService.COVER_SHEET_FILE_NAME) + ); + + return classifiedName; }); } private async writeWithCoverHandling( - payload: string | Buffer, + write: (name: string) => void, filename: string, - onClassified: (cover: CuiPdfCoverResponse) => Promise | string + onClassified: (cover: CuiPdfCoverResponse) => string ): Promise { const cover = await this.cuiApi.getCuiPdfCover(); if (!cover) { - return this.writePayload(payload, filename); + write(filename); + return filename; } if (!this.isClassified(cover)) { - return this.writePayload(payload, this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX)); - } + const unclassifiedName = this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX); + write(unclassifiedName); - return onClassified(cover); - } - - private writePayload(payload: string | Buffer, filename: string): string { - if (Buffer.isBuffer(payload)) { - fileService.writeBufferToFileWithGivenName(payload, filename); - } else { - fileService.writeToFileWithGivenName(payload, filename); + return unclassifiedName; } - return filename; + return onClassified(cover); } private writeClassifiedArchive(filename: string, data: string, cover: CuiPdfCoverResponse): string { diff --git a/src/core/utils/file-service.ts b/src/core/utils/file-service.ts index 40619c14..875402b4 100644 --- a/src/core/utils/file-service.ts +++ b/src/core/utils/file-service.ts @@ -57,12 +57,13 @@ export class FileService { } public extractExportedZipWithNestedZipsToDir(zipFile: AdmZip, targetDir: string): string { - this.mkdirRecursive(targetDir); - zipFile.extractAllTo(targetDir, true, true); + const targetPath = path.resolve(process.cwd(), targetDir); + this.mkdirRecursive(targetPath); + zipFile.extractAllTo(targetPath, true, true); - const files = fs.readdirSync(targetDir); + const files = fs.readdirSync(targetPath); for (const file of files) { - const innerZipPath = path.join(targetDir, file); + const innerZipPath = path.join(targetPath, file); if (file.endsWith(".zip")) { const nestedZip = new AdmZip(innerZipPath); const nestedDir = innerZipPath.replace(/\.zip$/, ""); @@ -72,8 +73,8 @@ export class FileService { fs.rmSync(innerZipPath); // Optionally remove the inner zip } } - this.restrictFilePermissions(targetDir); - return targetDir; + this.restrictFilePermissions(targetPath); + return targetPath; } public isDirectory(sourcePath: string): boolean { From 8c8b4f6875b65ddc6abd19366b74cf4a2e1cec54 Mon Sep 17 00:00:00 2001 From: denniswo Date: Tue, 11 Aug 2026 10:10:31 +0200 Subject: [PATCH 2/4] SP-1173: cover CUI marking of directory exports Unit cases for the three cover outcomes plus the fail-closed path, and one command case per call site asserting the prefixed directory holds the original tree next to the cover sheet. Includes-AI-Code: true Co-authored-by: Cursor --- .../cui-marking-directory-exports.spec.ts | 120 ++++++++++++++++++ tests/core/utils/cui-file-service.spec.ts | 57 ++++++++- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 tests/commands/cui-marking-directory-exports.spec.ts diff --git a/tests/commands/cui-marking-directory-exports.spec.ts b/tests/commands/cui-marking-directory-exports.spec.ts new file mode 100644 index 00000000..abedef92 --- /dev/null +++ b/tests/commands/cui-marking-directory-exports.spec.ts @@ -0,0 +1,120 @@ +import { resolve } from "node:path"; +import * as fs from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import * as os from "node:os"; +import AdmZip = require("adm-zip"); +import { mockAxiosGet, mockAxiosGetWithStatus, mockAxiosPost } from "../utls/http-requests-mock"; +import { testContext } from "../utls/test-context"; +import { loggingTestTransport } from "../jest.setup"; +import { FileService } from "../../src/core/utils/file-service"; +import { CuiFileService } from "../../src/core/utils/cui-file-service"; +import { ConfigUtils } from "../utls/config-utils"; +import { SinglePackageExportService } from "../../src/commands/configuration-management/single-package-export.service"; +import { BranchExportImportCommandService } from "../../src/commands/configuration-management/branch/branch-export-import.command.service"; +import { T2tcCommandService } from "../../src/commands/t2tc/t2tc-command.service"; +import { PackageManifestTransport } from "../../src/commands/configuration-management/interfaces/package-export.interfaces"; + +const COVER_URL = "https://myTeam.celonis.cloud/api/team/cui-settings/cui-pdf-cover"; +const PDF_BYTES = Buffer.from("%PDF-1.4 cover sheet"); + +const EXPORT_MESSAGE = "Successful export. Exported directory: "; +const DOWNLOAD_MESSAGE = "Successful download. Downloaded directory: "; +const BRANCH = "feature-a"; + +function markAsClassified(): void { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" }, + }); +} + +function loggedDirectoryName(prefix: string): string { + const message = loggingTestTransport.logMessages.map(entry => entry.message).find(entry => entry.includes(prefix)); + return message.split(prefix)[1]; +} + +function markedDirectory(prefix: string, expectedName: string): string { + const directoryName = loggedDirectoryName(prefix); + expect(directoryName).toEqual(`${CuiFileService.CLASSIFIED_PREFIX}${expectedName}`); + + const coverSheet = readFileSync(resolve(process.cwd(), directoryName, CuiFileService.COVER_SHEET_FILE_NAME)); + expect(coverSheet.equals(PDF_BYTES)).toBe(true); + + return directoryName; +} + +function exists(...segments: string[]): boolean { + return existsSync(resolve(process.cwd(), ...segments)); +} + +function buildPackageZip(packageKey: string): Buffer { + const zip = new AdmZip(); + zip.addFile("package.json", Buffer.from(JSON.stringify({ key: packageKey, name: "My Package" }))); + zip.addFile("nodes/node-1.json", Buffer.from(JSON.stringify({ key: "node-1", type: "VIEW" }))); + return zip.toBuffer(); +} + +function seedPackageDir(packageKey: string): string { + const dir = fs.mkdtempSync(resolve(os.tmpdir(), "cui-dir-test-")); + fs.mkdirSync(resolve(dir, "nodes")); + fs.writeFileSync(resolve(dir, "package.json"), JSON.stringify({ key: packageKey, name: "My Package" })); + fs.writeFileSync(resolve(dir, "nodes", "root.json"), JSON.stringify({ key: "root", type: "FOLDER" })); + return dir; +} + +describe("CUI marking of directory exports", () => { + + beforeEach(() => { + markAsClassified(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("Should mark the directory of config package export", async () => { + const packageKey = "pkg-export"; + mockAxiosGet(`https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/export-file`, buildPackageZip(packageKey)); + + await new SinglePackageExportService(testContext).exportPackage(packageKey, false, null); + + const directoryName = markedDirectory(EXPORT_MESSAGE, packageKey); + expect(exists(directoryName, "package.json")).toBe(true); + expect(exists(directoryName, "nodes", "node-1.json")).toBe(true); + expect(exists(packageKey)).toBe(false); + }); + + it("Should mark the directory of config branch export", async () => { + const packageKey = "pkg-branch"; + const branchPackageKey = `${packageKey}@${BRANCH}`; + mockAxiosGet(`https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${branchPackageKey}/export-file`, buildPackageZip(branchPackageKey)); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(seedPackageDir(branchPackageKey)); + + await new BranchExportImportCommandService(testContext).exportBranch(packageKey, BRANCH, {}); + + const directoryName = markedDirectory(EXPORT_MESSAGE, packageKey); + expect(exists(directoryName, "nodes", "root.json")).toBe(true); + + const exported = JSON.parse(readFileSync(resolve(process.cwd(), directoryName, "package.json"), "utf-8")); + expect(exported.key).toEqual(packageKey); + expect(exists(packageKey)).toBe(false); + }); + + it("Should mark the directory of t2tc package export --unzip", async () => { + const manifest: PackageManifestTransport[] = [ConfigUtils.buildManifestForKeyAndFlavor("key-1", "TEST")]; + mockAxiosGet( + "https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key-1&withDependencies=false", + ConfigUtils.buildBatchExportZip(manifest, []).toBuffer() + ); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", []); + + await new T2tcCommandService(testContext).batchExportPackages(["key-1"], undefined, false, null, true); + + const directoryName = loggedDirectoryName(DOWNLOAD_MESSAGE); + expect(directoryName.startsWith(`${CuiFileService.CLASSIFIED_PREFIX}export_`)).toBe(true); + expect(exists(directoryName, "manifest.json")).toBe(true); + + const coverSheet = readFileSync(resolve(process.cwd(), directoryName, CuiFileService.COVER_SHEET_FILE_NAME)); + expect(coverSheet.equals(PDF_BYTES)).toBe(true); + }); +}); diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts index f47401bb..f05250c4 100644 --- a/tests/core/utils/cui-file-service.spec.ts +++ b/tests/core/utils/cui-file-service.spec.ts @@ -1,4 +1,4 @@ -import { accessSync, readFileSync } from "node:fs"; +import { accessSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import AdmZip = require("adm-zip"); import { CuiFileService } from "../../../src/core/utils/cui-file-service"; @@ -164,4 +164,59 @@ describe("CuiFileService", () => { .rejects.toThrow("CUI marking applies but the response contained no cover page."); }); }); + + describe("when the artifact is a directory", () => { + const writeTree = (targetDir: string): void => { + mkdirSync(resolve(process.cwd(), targetDir, "nodes"), { recursive: true }); + writeFileSync(resolve(process.cwd(), targetDir, "package.json"), PAYLOAD); + writeFileSync(resolve(process.cwd(), targetDir, "nodes", "node-1.json"), PAYLOAD); + }; + + const exists = (...segments: string[]): boolean => existsSync(resolve(process.cwd(), ...segments)); + + it("Should keep the original directory name when no marking applies", async () => { + mockAxiosGetWithStatus(COVER_URL, 204, ""); + + const directoryName = await cuiFileService.writeDirectoryWithGivenName(writeTree, "unmarked-export"); + + expect(directoryName).toEqual("unmarked-export"); + expect(exists(directoryName, "package.json")).toBe(true); + expect(exists(directoryName, CuiFileService.COVER_SHEET_FILE_NAME)).toBe(false); + }); + + it("Should only prefix the directory when the content is unclassified", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([])); + + const directoryName = await cuiFileService.writeDirectoryWithGivenName(writeTree, "plain-export"); + + expect(directoryName).toEqual("Unclassified - plain-export"); + expect(exists(directoryName, "nodes", "node-1.json")).toBe(true); + expect(exists(directoryName, CuiFileService.COVER_SHEET_FILE_NAME)).toBe(false); + expect(exists("plain-export")).toBe(false); + }); + + it("Should prefix the directory and write the cover sheet into it", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([{ code: "PRVCY", name: "Privacy" }])); + + const directoryName = await cuiFileService.writeDirectoryWithGivenName(writeTree, "classified-export"); + + expect(directoryName).toEqual("CUI - classified-export"); + expect(exists(directoryName, "package.json")).toBe(true); + expect(exists(directoryName, "nodes", "node-1.json")).toBe(true); + const coverSheet = readFileSync(resolve(process.cwd(), directoryName, CuiFileService.COVER_SHEET_FILE_NAME)); + expect(coverSheet.equals(PDF_BYTES)).toBe(true); + expect(exists("classified-export")).toBe(false); + }); + + it("Should fail without writing anything when no cover page was returned", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + }); + + await expect(cuiFileService.writeDirectoryWithGivenName(writeTree, "broken-export")) + .rejects.toThrow("CUI marking applies but the response contained no cover page."); + expect(exists("broken-export")).toBe(false); + expect(exists("CUI - broken-export")).toBe(false); + }); + }); }); From c102e000bb1bbc4d94c3c75f4c8bb88f134e1bcc Mon Sep 17 00:00:00 2001 From: denniswo Date: Tue, 11 Aug 2026 12:00:54 +0200 Subject: [PATCH 3/4] SP-1173: type the BaseManager pull methods as Promise pull and pullFile resolve with nothing, so Promise claimed more than they deliver. Every caller awaits and discards the result, and no subclass overrides either method. Includes-AI-Code: true Co-authored-by: Cursor --- src/core/http/http-shared/base.manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index 1bcdc3f4..2f4cb6a3 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -14,7 +14,7 @@ export abstract class BaseManager { this.cuiFileService = new CuiFileService(context); } - public async pull(): Promise { + public async pull(): Promise { try { const data = await this.httpClient().get(this.getConfig().pullUrl); const filename = await this.writeToFile(data); @@ -25,7 +25,7 @@ export abstract class BaseManager { } } - public async pullFile(): Promise { + public async pullFile(): Promise { try { const data = await this.httpClient().downloadFile(this.getConfig().pullUrl); const filename = await this.writeStreamToFile(data); From 500ee83cdc3ba42cb0c85290e4fa71e379774fa8 Mon Sep 17 00:00:00 2001 From: denniswo Date: Tue, 11 Aug 2026 15:57:48 +0200 Subject: [PATCH 4/4] SP-1173: document CUI marking outcomes for local disk writes Internal overview of cover-response classification and per-trigger examples for unclassified and classified artifacts. Includes-AI-Code: true Co-authored-by: Cursor --- docs/cui-marking.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/cui-marking.md diff --git a/docs/cui-marking.md b/docs/cui-marking.md new file mode 100644 index 00000000..5c71903b --- /dev/null +++ b/docs/cui-marking.md @@ -0,0 +1,31 @@ +# CUI marking + +When Content CLI writes a user-facing file or directory to disk, it asks the team’s CUI settings whether marking applies. The answer decides the filename (or directory name) and whether a cover sheet is included. + +Commands that only print to the console, profile and log files, and exports that go straight to a Git branch never mark anything on disk. + +## Classification: cover response → artifact + +Example: `list packages --json` would otherwise write `packages.json`. + +| Cover response | Meaning | Outcome | +|---|---|---| +| **403** | Feature flag disabled | `packages.json` | +| **204** | Team has CUI disabled | `packages.json` | +| **200**, no categories | Marking applies, unclassified | `Unclassified - packages.json` | +| **200**, with categories | Marking applies, classified | `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` | +| Unexpected response | Fail closed | Nothing written; the command errors | + +**403** and **204** both leave the artifact unmarked. They are not the same as **200 with no categories**, which still renames it to `Unclassified - …`. + +## Scope: how the write is triggered + +| Trigger | Commands | Example (unclassified) | Example (classified) | +|---|---|---|---| +| `--json` listings and reports | `list spaces`, `list packages`, `list assets` / `assignments` / `data-pools`, `config *`, `t2tc package list` / `diff`, `deployment *`, `asset-registry *` | `list packages --json` → `Unclassified - packages.json` | `list packages --json` → `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` | +| `-o, --outputToJsonFile` reports | `analyze` / `import action-flows`, `export data-pool`, `import data-pools`, `t2tc package import` report | `export data-pool -o` → `Unclassified - .json` | `export data-pool -o` → `CUI - .zip` containing the JSON and `CUI_Cover_Sheet.pdf` | +| Artifact is already an archive | `config package export --zip`, `config branch export --zip`, `t2tc package export`, `export action-flows`, `pull package` | `config package export --zip` → `Unclassified - my-package.zip` | `config package export --zip` → `CUI - my-package.zip` with `CUI_Cover_Sheet.pdf` inside the archive | +| Single non-archive export | `pull asset` / `skill` / `data-pool` / `view-bookmarks` / `bookmarks`, `export bookmarks` | `pull asset` → `Unclassified - asset_.yml` | `pull asset` → `CUI - asset_.zip` containing the YAML and `CUI_Cover_Sheet.pdf` | +| Output is a directory | `config package export`, `config branch export`, `t2tc package export --unzip` | `config package export` → `Unclassified - my-package/` | `config package export` → `CUI - my-package/` with `CUI_Cover_Sheet.pdf` inside | +| `--gitBranch` variants | `config package export`, `config branch export`, `t2tc package export` | Out of scope | Out of scope | +| No output flag | Console-only listings, profile / git-profile / log files | Out of scope | Out of scope |