@adobe/spacecat-shared-cloud-manager-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## @adobe/spacecat-shared-cloud-manager-client-v1.0.0 (2026-02-21)
2
+
3
+ ### Features
4
+
5
+ * cloud manager client ([#1335](https://github.com/adobe/spacecat-shared/issues/1335)) ([2e4b013](https://github.com/adobe/spacecat-shared/commit/2e4b01359641706d633c16907b8292334581ce39))
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # Spacecat Shared - Cloud Manager Client
2
+
3
+ A JavaScript client for Adobe Cloud Manager repository operations. It supports cloning, pulling, pushing, checking out refs, zipping/unzipping repositories, applying patches, and creating pull requests for both **BYOG (Bring Your Own Git)** and **standard** Cloud Manager repositories.
4
+
5
+ The client is **stateless** with respect to repositories — no repo-specific information is stored on the instance. All repository details (`programId`, `repositoryId`, `imsOrgId`, `repoType`, `repoUrl`) are passed per method call. The only instance-level state is the cached IMS service token (shared across all repos) and generic configuration (CM API base URL, S3 client, git committer identity). This means a single `CloudManagerClient` instance can work across multiple repositories, programs, and IMS orgs within the same session.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @adobe/spacecat-shared-cloud-manager-client
11
+ ```
12
+
13
+ ## Prerequisites
14
+
15
+ This client executes native git commands and requires the **git binary** available at `/opt/bin/git` (e.g. via an AWS Lambda Layer). The Lambda layer must provide:
16
+
17
+ - `git` binary at `/opt/bin/git`
18
+ - Git sub-commands (e.g. `git-remote-https`) at `/opt/libexec/git-core/`
19
+ - Shared libraries (`libcurl`, `libexpat`, etc.) at `/opt/lib/`
20
+
21
+ The client also uses the system `unzip` command (available on AWS Lambda AL2023 runtime) for `unzipRepository`.
22
+
23
+ The following environment variables are set automatically by the client for Lambda layer compatibility:
24
+
25
+ | Variable | Value |
26
+ |----------|-------|
27
+ | `PATH` | `/opt/bin:/usr/local/bin:/usr/bin:/bin` |
28
+ | `GIT_EXEC_PATH` | `/opt/libexec/git-core` |
29
+ | `LD_LIBRARY_PATH` | `/opt/lib:/lib64:/usr/lib64` |
30
+ | `GIT_TERMINAL_PROMPT` | `0` |
31
+
32
+ ## Configuration
33
+
34
+ Create a client from a Helix Universal context (e.g. in a Lambda with the required middleware):
35
+
36
+ ```js
37
+ import CloudManagerClient from '@adobe/spacecat-shared-cloud-manager-client';
38
+
39
+ const client = CloudManagerClient.createFrom(context);
40
+ ```
41
+
42
+ ### Required environment variables
43
+
44
+ | Variable | Description |
45
+ |----------|-------------|
46
+ | `IMS_HOST` | IMS host (e.g. `ims-na1.adobelogin.com`) |
47
+ | `ASO_CM_REPO_SERVICE_IMS_CLIENT_ID` | IMS client ID for the CM Repo Service |
48
+ | `ASO_CM_REPO_SERVICE_IMS_CLIENT_SECRET` | IMS client secret |
49
+ | `ASO_CM_REPO_SERVICE_IMS_CLIENT_CODE` | IMS authorization code |
50
+ | `CM_REPO_URL` | Cloud Manager Repo API base URL (e.g. `https://cm-repo.example.com`) |
51
+ | `ASO_CODE_AUTOFIX_USERNAME` | Git committer username |
52
+ | `ASO_CODE_AUTOFIX_EMAIL` | Git committer email |
53
+
54
+ The context must also provide an S3 client via `context.s3.s3Client` (e.g. using `s3Wrapper`).
55
+
56
+ ### Standard repository credentials (optional)
57
+
58
+ For **standard** (non-BYOG) Cloud Manager repositories, provide credentials via:
59
+
60
+ | Variable | Description |
61
+ |----------|-------------|
62
+ | `CM_STANDARD_REPO_CREDENTIALS` | JSON string mapping **program ID** to basic-auth credentials per program |
63
+
64
+ **Format:** A single JSON object. Each key is a Cloud Manager **program ID** (string). Each value is the HTTP basic-auth credential for that program in the form `"username:accessToken"`.
65
+
66
+ **Example:**
67
+
68
+ ```json
69
+ {
70
+ "12345": "git-user:personal-access-token-or-password",
71
+ "67890": "another-user:another-token"
72
+ }
73
+ ```
74
+
75
+ - If `CM_STANDARD_REPO_CREDENTIALS` is omitted or empty, only BYOG repos can be used (clone/pull/push require IMS Bearer token and `imsOrgId`).
76
+ - The value must be valid JSON; invalid JSON throws at `createFrom()`.
77
+ - For a given **programId**, credentials must exist in this map when using `repoType: 'standard'`; otherwise the client throws at clone/pull/push time.
78
+
79
+ ## Repository types
80
+
81
+ The client supports two authentication modes:
82
+
83
+ | Type | Auth method | When to use |
84
+ |------|-------------|-------------|
85
+ | **BYOG** (Bring Your Own Git) | IMS Bearer token via `http.extraheader` | GitHub, GitLab, Bitbucket, Azure DevOps (or any `repoType` other than `'standard'`) |
86
+ | **Standard** | Base64 Basic auth via `http.extraheader` | Cloud Manager "standard" (managed) repos |
87
+
88
+ Both repo types authenticate via `http.extraheader` — no credentials are ever embedded in URLs. Use the same type for `clone`, `pull`, and `push` for a given repo (same `programId`/`repositoryId`).
89
+
90
+ **Security notes:**
91
+
92
+ - Clone directories are created via `mkdtempSync` under the OS temp directory, producing unique, unpredictable paths safe from symlink attacks and concurrent-run collisions.
93
+ - Patch files are also written into unique temp directories, cleaned up in a `finally` block.
94
+ - Git error output is sanitized before logging — Bearer tokens, Basic auth headers, `x-api-key`, `x-gw-ims-org-id` values, and basic-auth credentials in URLs are all replaced with `[REDACTED]`. Both `stderr`, `stdout`, and `error.message` are sanitized.
95
+ - All git commands run with a 120-second timeout to prevent hung processes from blocking the Lambda.
96
+ - `GIT_ASKPASS` is explicitly cleared to prevent inherited credential helpers from being invoked.
97
+
98
+ ## Usage
99
+
100
+ ### BYOG repositories (e.g. GitHub, GitLab)
101
+
102
+ Clone and push use the IMS token and `imsOrgId`; no `CM_STANDARD_REPO_CREDENTIALS` needed.
103
+
104
+ ```js
105
+ const programId = '12345';
106
+ const repositoryId = '67890';
107
+ const imsOrgId = 'your-ims-org@AdobeOrg';
108
+
109
+ // Clone (optionally checkout a specific ref)
110
+ const clonePath = await client.clone(programId, repositoryId, {
111
+ imsOrgId,
112
+ ref: 'release/5.11', // optional — checks out this ref after clone
113
+ });
114
+
115
+ // Pull latest changes (optional ref checks out the branch before pulling)
116
+ await client.pull(clonePath, programId, repositoryId, { imsOrgId, ref: 'main' });
117
+
118
+ // Checkout a specific ref (standalone, without pull)
119
+ await client.checkout(clonePath, 'main');
120
+
121
+ // Create a branch and apply a patch
122
+ await client.createBranch(clonePath, 'main', 'feature/fix');
123
+
124
+ // Apply a mail-message patch (git am — commit message is embedded in the patch)
125
+ await client.applyPatch(clonePath, 'feature/fix', 's3://bucket/patches/fix.patch');
126
+
127
+ // Apply a plain diff patch (git apply — commitMessage is required)
128
+ await client.applyPatch(clonePath, 'feature/fix', 's3://bucket/patches/fix.diff', {
129
+ commitMessage: 'Apply agent suggestion: fix accessibility issue',
130
+ });
131
+
132
+ // Push (ref is required — specifies the branch to push)
133
+ await client.push(clonePath, programId, repositoryId, { imsOrgId, ref: 'feature/fix' });
134
+
135
+ // Create PR (BYOG only; uses IMS token)
136
+ const pr = await client.createPullRequest(programId, repositoryId, {
137
+ imsOrgId,
138
+ sourceBranch: 'feature/fix',
139
+ destinationBranch: 'main',
140
+ title: 'Fix issue',
141
+ description: 'Automated fix',
142
+ });
143
+ ```
144
+
145
+ ### Standard repositories
146
+
147
+ Use credentials from `CM_STANDARD_REPO_CREDENTIALS` and pass `repoType: 'standard'` and the repo URL.
148
+
149
+ ```js
150
+ const programId = '12345';
151
+ const repositoryId = '67890';
152
+ const imsOrgId = 'your-ims-org@AdobeOrg';
153
+ const repoUrl = 'https://git.cloudmanager.adobe.com/your-org/your-repo.git';
154
+
155
+ const config = {
156
+ imsOrgId,
157
+ repoType: 'standard',
158
+ repoUrl,
159
+ ref: 'main', // optional — checks out this ref after clone
160
+ };
161
+
162
+ // Clone
163
+ const clonePath = await client.clone(programId, repositoryId, config);
164
+
165
+ // Pull latest changes (optional ref checks out the branch before pulling)
166
+ await client.pull(clonePath, programId, repositoryId, { imsOrgId, repoType: 'standard', repoUrl, ref: 'main' });
167
+
168
+ // Create a branch and apply a patch
169
+ await client.createBranch(clonePath, 'main', 'feature/fix');
170
+ await client.applyPatch(clonePath, 'feature/fix', 's3://bucket/patches/fix.patch', {
171
+ commitMessage: 'Apply agent suggestion',
172
+ });
173
+
174
+ // Push (ref is required — specifies the branch to push)
175
+ await client.push(clonePath, programId, repositoryId, {
176
+ imsOrgId, repoType: 'standard', repoUrl, ref: 'feature/fix',
177
+ });
178
+
179
+ // Zip the repository (includes .git history)
180
+ const zipBuffer = await client.zipRepository(clonePath);
181
+
182
+ // Cleanup
183
+ await client.cleanup(clonePath);
184
+ ```
185
+
186
+ *Note*: For Cloud Manager Standard Repositories, pull requests aren't supported, as there's no upstream.
187
+
188
+
189
+ ## API overview
190
+
191
+ - **`clone(programId, repositoryId, config)`** – Clone repo to a unique temp directory. Config: `{ imsOrgId, repoType, repoUrl, ref }`. Optional `ref` checks out a specific branch/tag after clone (failure to checkout does not fail the clone).
192
+ - **`pull(clonePath, programId, repositoryId, config)`** – Pull latest changes into an existing clone. Config: `{ imsOrgId, repoType, repoUrl, ref }`. Optional `ref` checks out the branch before pulling.
193
+ - **`push(clonePath, programId, repositoryId, config)`** – Push a ref to the remote. Config: `{ imsOrgId, repoType, repoUrl, ref }`. The `ref` is **required** and specifies the branch to push.
194
+ - **`checkout(clonePath, ref)`** – Checkout a specific git ref (branch, tag, or SHA) in an existing clone. Unlike the optional checkout in `clone()`, this throws on failure.
195
+ - **`zipRepository(clonePath)`** – Zip the clone (including `.git` history) and return a Buffer.
196
+ - **`unzipRepository(zipBuffer)`** – Extract a ZIP buffer to a new temp directory and return the path. Used for incremental updates (restore a previously-zipped repo from S3, then `pull` with `ref` instead of a full clone). Cleans up on failure.
197
+ - **`createBranch(clonePath, baseBranch, newBranch)`** – Checkout the base branch and create a new branch from it.
198
+ - **`applyPatch(clonePath, branch, s3PatchPath, options?)`** – Download a patch from S3 (`s3://bucket/key` format) and apply it. The patch format is detected automatically from the content, not the file extension. Mail-message patches (content starts with `From `) are applied with `git am`, which creates the commit using the embedded metadata. Plain diffs (content starts with `diff `) are applied with `git apply`, staged with `git add -A`, and committed — `options.commitMessage` is required for this flow. If `commitMessage` is provided with a mail-message patch, it is ignored and a warning is logged. Configures committer identity from `ASO_CODE_AUTOFIX_USERNAME`/`ASO_CODE_AUTOFIX_EMAIL`. Cleans up the temp patch file in a `finally` block.
199
+ - **`cleanup(clonePath)`** – Remove the clone directory. Validates the path starts with the expected temp prefix to prevent accidental deletion.
200
+ - **`createPullRequest(programId, repositoryId, config)`** – Create a PR via the CM Repo API (BYOG only, uses IMS token). Config: `{ imsOrgId, sourceBranch, destinationBranch, title, description }`.
201
+
202
+ ## Exports
203
+
204
+ Repository type constants (for use when passing `repoType` or checking repo type):
205
+
206
+ ```js
207
+ import CloudManagerClient, { CM_REPO_TYPE } from '@adobe/spacecat-shared-cloud-manager-client';
208
+
209
+ // CM_REPO_TYPE.GITHUB → 'github'
210
+ // CM_REPO_TYPE.BITBUCKET → 'bitbucket'
211
+ // CM_REPO_TYPE.GITLAB → 'gitlab'
212
+ // CM_REPO_TYPE.AZURE_DEVOPS → 'azure_devops'
213
+ // CM_REPO_TYPE.STANDARD → 'standard'
214
+ ```
215
+
216
+ ## Testing
217
+
218
+ ```bash
219
+ npm run test
220
+ ```
221
+
222
+ ## Linting
223
+
224
+ ```bash
225
+ npm run lint
226
+ ```
227
+
228
+ ## License
229
+
230
+ Apache-2.0
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@adobe/spacecat-shared-cloud-manager-client",
3
+ "version": "1.0.0",
4
+ "description": "Shared modules of the Spacecat Services - Cloud Manager Client",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.0.0 <25.0.0",
8
+ "npm": ">=10.9.0 <12.0.0"
9
+ },
10
+ "main": "src/index.js",
11
+ "types": "src/index.d.ts",
12
+ "scripts": {
13
+ "test": "c8 mocha",
14
+ "lint": "eslint .",
15
+ "clean": "rm -rf package-lock.json node_modules"
16
+ },
17
+ "mocha": {
18
+ "require": "test/setup-env.js",
19
+ "reporter": "mocha-multi-reporters",
20
+ "reporter-options": "configFile=.mocha-multi.json",
21
+ "spec": "test/**/*.test.js"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/adobe/spacecat-shared.git"
26
+ },
27
+ "author": "",
28
+ "license": "Apache-2.0",
29
+ "bugs": {
30
+ "url": "https://github.com/adobe/spacecat-shared/issues"
31
+ },
32
+ "homepage": "https://github.com/adobe/spacecat-shared#readme",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "@adobe/fetch": "4.2.3",
38
+ "@adobe/spacecat-shared-ims-client": "1.11.6",
39
+ "@adobe/spacecat-shared-utils": "1.81.1",
40
+ "@aws-sdk/client-s3": "3.940.0",
41
+ "adm-zip": "0.5.16"
42
+ },
43
+ "devDependencies": {
44
+ "aws-sdk-client-mock": "4.1.0",
45
+ "chai": "6.2.1",
46
+ "chai-as-promised": "8.0.2",
47
+ "nock": "14.0.10",
48
+ "sinon": "21.0.0",
49
+ "esmock": "2.7.3",
50
+ "sinon-chai": "4.0.1",
51
+ "typescript": "5.9.3"
52
+ }
53
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { UniversalContext } from '@adobe/helix-universal';
14
+
15
+ export declare const CM_REPO_TYPE: Readonly<{
16
+ GITHUB: 'github';
17
+ BITBUCKET: 'bitbucket';
18
+ GITLAB: 'gitlab';
19
+ AZURE_DEVOPS: 'azure_devops';
20
+ STANDARD: 'standard';
21
+ }>;
22
+
23
+ export interface CloneConfig {
24
+ imsOrgId: string;
25
+ repoType: string;
26
+ repoUrl: string;
27
+ ref?: string;
28
+ }
29
+
30
+ export interface PushConfig {
31
+ imsOrgId: string;
32
+ repoType: string;
33
+ repoUrl: string;
34
+ ref: string;
35
+ }
36
+
37
+ export interface PullConfig {
38
+ imsOrgId: string;
39
+ repoType: string;
40
+ repoUrl: string;
41
+ ref?: string;
42
+ }
43
+
44
+ export interface PullRequestConfig {
45
+ imsOrgId: string;
46
+ destinationBranch: string;
47
+ sourceBranch: string;
48
+ title: string;
49
+ description: string;
50
+ }
51
+
52
+ export default class CloudManagerClient {
53
+ static createFrom(context: UniversalContext): CloudManagerClient;
54
+
55
+ clone(programId: string, repositoryId: string, config: CloneConfig): Promise<string>;
56
+ push(clonePath: string, programId: string, repositoryId: string, config: PushConfig): Promise<void>;
57
+ pull(clonePath: string, programId: string, repositoryId: string, config: PullConfig): Promise<void>;
58
+ checkout(clonePath: string, ref: string): Promise<void>;
59
+ unzipRepository(zipBuffer: Buffer): Promise<string>;
60
+ zipRepository(clonePath: string): Promise<Buffer>;
61
+ createBranch(clonePath: string, baseBranch: string, newBranch: string): Promise<void>;
62
+ applyPatch(
63
+ clonePath: string,
64
+ branch: string,
65
+ s3PatchPath: string,
66
+ options?: { commitMessage?: string },
67
+ ): Promise<void>;
68
+ cleanup(clonePath: string): Promise<void>;
69
+ createPullRequest(
70
+ programId: string,
71
+ repositoryId: string,
72
+ config: PullRequestConfig,
73
+ ): Promise<object>;
74
+ }
package/src/index.js ADDED
@@ -0,0 +1,555 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { execFileSync } from 'child_process';
14
+ import {
15
+ existsSync, mkdtempSync, rmSync, statfsSync, writeFileSync,
16
+ } from 'fs';
17
+ import os from 'os';
18
+ import path from 'path';
19
+ import { hasText, tracingFetch as fetch } from '@adobe/spacecat-shared-utils';
20
+ import { ImsClient } from '@adobe/spacecat-shared-ims-client';
21
+ import { GetObjectCommand } from '@aws-sdk/client-s3';
22
+ import AdmZip from 'adm-zip';
23
+
24
+ const GIT_BIN = process.env.GIT_BIN_PATH || '/opt/bin/git';
25
+ const CLONE_DIR_PREFIX = 'cm-repo-';
26
+ const PATCH_FILE_PREFIX = 'cm-patch-';
27
+ const GIT_OPERATION_TIMEOUT_MS = 120_000; // 120s — fail fast before Lambda timeout
28
+
29
+ /**
30
+ * Repository type constants for Cloud Manager integrations.
31
+ * Standard repos use basic auth; all others (BYOG) use Bearer token extraheaders.
32
+ */
33
+ export const CM_REPO_TYPE = Object.freeze({
34
+ GITHUB: 'github',
35
+ BITBUCKET: 'bitbucket',
36
+ GITLAB: 'gitlab',
37
+ AZURE_DEVOPS: 'azure_devops',
38
+ STANDARD: 'standard',
39
+ });
40
+
41
+ // Lambda layer environment: git and its helpers (git-remote-https) live under /opt.
42
+ // Without these, the dynamic linker can't find shared libraries (libcurl, libexpat, …)
43
+ // and git can't locate its sub-commands (git-remote-https for HTTPS transport).
44
+ const GIT_ENV = {
45
+ ...process.env,
46
+ PATH: '/opt/bin:/usr/local/bin:/usr/bin:/bin',
47
+ GIT_EXEC_PATH: '/opt/libexec/git-core',
48
+ LD_LIBRARY_PATH: '/opt/lib:/lib64:/usr/lib64',
49
+ GIT_TERMINAL_PROMPT: '0',
50
+ GIT_ASKPASS: '',
51
+ };
52
+
53
+ /**
54
+ * Parses an S3 URI (s3://bucket/key) into bucket and key.
55
+ * @param {string} s3Path - S3 URI
56
+ * @returns {{ bucket: string, key: string }}
57
+ */
58
+ function parseS3Path(s3Path) {
59
+ let parsed;
60
+ try {
61
+ parsed = new URL(s3Path);
62
+ } catch {
63
+ throw new Error(`Invalid S3 path: ${s3Path}. Expected format: s3://bucket/key`);
64
+ }
65
+ if (parsed.protocol !== 's3:' || !parsed.hostname || !parsed.pathname.slice(1)) {
66
+ throw new Error(`Invalid S3 path: ${s3Path}. Expected format: s3://bucket/key`);
67
+ }
68
+ return { bucket: parsed.hostname, key: parsed.pathname.slice(1) };
69
+ }
70
+
71
+ export default class CloudManagerClient {
72
+ /**
73
+ * Creates a CloudManagerClient from a Universal context.
74
+ * @param {Object} context - Universal function context
75
+ * @returns {CloudManagerClient}
76
+ */
77
+ static createFrom(context) {
78
+ const { log = console } = context;
79
+ const {
80
+ IMS_HOST: imsHost,
81
+ ASO_CM_REPO_SERVICE_IMS_CLIENT_ID: clientId,
82
+ ASO_CM_REPO_SERVICE_IMS_CLIENT_SECRET: clientSecret,
83
+ ASO_CM_REPO_SERVICE_IMS_CLIENT_CODE: clientCode,
84
+ CM_REPO_URL: cmRepoUrl,
85
+ CM_STANDARD_REPO_CREDENTIALS: standardRepoCredentialsRaw,
86
+ ASO_CODE_AUTOFIX_USERNAME: gitUsername,
87
+ ASO_CODE_AUTOFIX_EMAIL: gitUserEmail,
88
+ } = context.env;
89
+
90
+ const s3Client = context.s3?.s3Client;
91
+ if (!s3Client) {
92
+ throw new Error('CloudManagerClient requires S3 client. Ensure s3Wrapper is configured.');
93
+ }
94
+
95
+ if (!hasText(imsHost) || !hasText(clientId) || !hasText(clientSecret) || !hasText(clientCode)) {
96
+ throw new Error('CloudManagerClient requires IMS_HOST, ASO_CM_REPO_SERVICE_IMS_CLIENT_ID,'
97
+ + ' ASO_CM_REPO_SERVICE_IMS_CLIENT_SECRET, and ASO_CM_REPO_SERVICE_IMS_CLIENT_CODE.');
98
+ }
99
+
100
+ if (!hasText(cmRepoUrl)) {
101
+ throw new Error('CloudManagerClient requires CM_REPO_URL.');
102
+ }
103
+
104
+ if (!hasText(gitUsername) || !hasText(gitUserEmail)) {
105
+ throw new Error('CloudManagerClient requires ASO_CODE_AUTOFIX_USERNAME and ASO_CODE_AUTOFIX_EMAIL.');
106
+ }
107
+
108
+ // Optional: credentials for standard (non-BYOG) CM repos.
109
+ // JSON object mapping programId to "username:accessToken".
110
+ let standardRepoCredentials = {};
111
+ if (hasText(standardRepoCredentialsRaw)) {
112
+ try {
113
+ standardRepoCredentials = JSON.parse(standardRepoCredentialsRaw);
114
+ } catch (e) {
115
+ throw new Error('CM_STANDARD_REPO_CREDENTIALS must be valid JSON.');
116
+ }
117
+ }
118
+
119
+ // Create ImsClient for token management (handles caching internally)
120
+ const imsClient = ImsClient.createFrom({
121
+ env: {
122
+ IMS_HOST: imsHost,
123
+ IMS_CLIENT_ID: clientId,
124
+ IMS_CLIENT_CODE: clientCode,
125
+ IMS_CLIENT_SECRET: clientSecret,
126
+ },
127
+ log,
128
+ });
129
+
130
+ return new CloudManagerClient({
131
+ clientId,
132
+ cmRepoUrl,
133
+ standardRepoCredentials,
134
+ gitUsername,
135
+ gitUserEmail,
136
+ }, imsClient, s3Client, log);
137
+ }
138
+
139
+ constructor(config, imsClient, s3Client, log = console) {
140
+ this.config = config;
141
+ this.imsClient = imsClient;
142
+ this.s3Client = s3Client;
143
+ this.log = log;
144
+ }
145
+
146
+ // --- Private helpers ---
147
+
148
+ /**
149
+ * Logs /tmp disk usage (total, used, free) for capacity monitoring.
150
+ * Uses statfsSync — a single syscall with negligible cost.
151
+ * @param {string} operation - The operation that just completed (e.g. 'clone', 'pull')
152
+ */
153
+ #logTmpDiskUsage(operation) {
154
+ const { bsize, blocks, bfree } = statfsSync(os.tmpdir());
155
+ const totalMB = Math.round((bsize * blocks) / (1024 * 1024));
156
+ const freeMB = Math.round((bsize * bfree) / (1024 * 1024));
157
+ const usedMB = totalMB - freeMB;
158
+ this.log.info(`[${operation}] /tmp disk usage: ${usedMB} MB used, ${freeMB} MB free, ${totalMB} MB total`);
159
+ }
160
+
161
+ /**
162
+ * Looks up basic-auth credentials for a standard (non-BYOG) CM repository.
163
+ * @param {string} programId - CM Program ID
164
+ * @returns {string} "username:accessToken"
165
+ */
166
+ #getStandardRepoCredentials(programId) {
167
+ const credentials = this.config.standardRepoCredentials[programId];
168
+ if (!credentials) {
169
+ throw new Error(`No standard repo credentials found for programId: ${programId}. Check CM_STANDARD_REPO_CREDENTIALS.`);
170
+ }
171
+ return credentials;
172
+ }
173
+
174
+ /**
175
+ * Builds the git -c http.extraheader arguments for CM repo auth.
176
+ * @param {string} imsOrgId - IMS Organization ID
177
+ * @returns {Promise<string[]>} Array of git config args
178
+ */
179
+ async #getCMRepoServiceCredentials(imsOrgId) {
180
+ const { access_token: token } = await this.imsClient.getServiceAccessToken();
181
+ const { cmRepoUrl, clientId } = this.config;
182
+
183
+ return [
184
+ '-c', `http.${cmRepoUrl}.extraheader=Authorization: Bearer ${token}`,
185
+ '-c', `http.${cmRepoUrl}.extraheader=x-api-key: ${clientId}`,
186
+ '-c', `http.${cmRepoUrl}.extraheader=x-gw-ims-org-id: ${imsOrgId}`,
187
+ ];
188
+ }
189
+
190
+ /**
191
+ * Constructs the git repository URL for a CM repo.
192
+ * @param {string} programId
193
+ * @param {string} repositoryId
194
+ * @returns {string}
195
+ */
196
+ #getRepoUrl(programId, repositoryId) {
197
+ return `${this.config.cmRepoUrl}/api/program/${programId}/repository/${repositoryId}.git`;
198
+ }
199
+
200
+ /**
201
+ * Executes a git command.
202
+ * @param {string[]} args - Arguments to pass to git
203
+ * @param {Object} [options] - execFileSync options
204
+ * @returns {string} stdout
205
+ */
206
+ #execGit(args, options = {}) {
207
+ try {
208
+ return execFileSync(GIT_BIN, args, {
209
+ encoding: 'utf-8', env: GIT_ENV, timeout: GIT_OPERATION_TIMEOUT_MS, ...options,
210
+ });
211
+ } catch (error) {
212
+ if (error.killed) {
213
+ this.log.error(`Git command timed out after ${GIT_OPERATION_TIMEOUT_MS / 1000}s`);
214
+ throw new Error(`Git command timed out after ${GIT_OPERATION_TIMEOUT_MS / 1000}s`);
215
+ }
216
+ // Sanitize tokens and credentials from all error output sources
217
+ const rawMessage = [error.stderr, error.stdout, error.message]
218
+ .filter(Boolean)
219
+ .join('\n');
220
+ const sanitized = rawMessage
221
+ .replace(/Bearer [^\s"]+/g, 'Bearer [REDACTED]')
222
+ .replace(/:\/\/[^@]+@/g, '://***@')
223
+ .replace(/x-api-key: [^\s"]+/g, 'x-api-key: [REDACTED]')
224
+ .replace(/x-gw-ims-org-id: [^\s"]+/g, 'x-gw-ims-org-id: [REDACTED]')
225
+ .replace(/Authorization: Basic [^\s"]+/g, 'Authorization: Basic [REDACTED]');
226
+ this.log.error(`Git command failed: ${sanitized}`);
227
+ throw new Error(`Git command failed: ${sanitized}`);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Builds authenticated git arguments for a remote command (clone, push, or pull).
233
+ *
234
+ * Both repo types use http.extraheader for authentication:
235
+ * - Standard repos: Basic auth header via extraheader on the repo URL
236
+ * - BYOG repos: Bearer token + API key + IMS org ID via extraheader on the CM Repo URL
237
+ *
238
+ * @param {string} command - The git command ('clone', 'push', or 'pull')
239
+ * @param {string} programId - CM Program ID
240
+ * @param {string} repositoryId - CM Repository ID
241
+ * @param {Object} config - Repository auth configuration
242
+ * @param {string} config.imsOrgId - IMS Organization ID
243
+ * @param {string} config.repoType - Repository type ('standard' or VCS type)
244
+ * @param {string} config.repoUrl - Repository URL
245
+ * @returns {Promise<string[]>} Array of git arguments
246
+ */
247
+ async #buildAuthGitArgs(command, programId, repositoryId, { imsOrgId, repoType, repoUrl } = {}) {
248
+ if (repoType === CM_REPO_TYPE.STANDARD) {
249
+ const credentials = this.#getStandardRepoCredentials(programId);
250
+ const basicAuth = Buffer.from(credentials).toString('base64');
251
+ return [
252
+ '-c', `http.${repoUrl}.extraheader=Authorization: Basic ${basicAuth}`,
253
+ command, repoUrl,
254
+ ];
255
+ }
256
+
257
+ const cmRepoServiceCredentials = await this.#getCMRepoServiceCredentials(imsOrgId);
258
+ const byogRepoUrl = this.#getRepoUrl(programId, repositoryId);
259
+ return [...cmRepoServiceCredentials, command, byogRepoUrl];
260
+ }
261
+
262
+ /**
263
+ * Clones a Cloud Manager repository to /tmp.
264
+ *
265
+ * Both repo types authenticate via http.extraheader (no credentials in the URL):
266
+ * - BYOG repos: Bearer token + API key + IMS org ID via CM Repo URL
267
+ * - Standard repos: Basic auth header via the repo URL
268
+ *
269
+ * If a ref is provided, the clone will be checked out to that ref after cloning.
270
+ * Checkout failures are logged but do not cause the clone to fail, so the caller
271
+ * always gets a usable working copy (on the default branch if checkout fails).
272
+ *
273
+ * @param {string} programId - CM Program ID
274
+ * @param {string} repositoryId - CM Repository ID
275
+ * @param {Object} config - Clone configuration
276
+ * @param {string} config.imsOrgId - IMS Organization ID
277
+ * @param {string} config.repoType - Repository type ('standard' or VCS type)
278
+ * @param {string} config.repoUrl - Repository URL
279
+ * @param {string} [config.ref] - Optional. Git ref to checkout after clone (branch, tag, or SHA)
280
+ * @returns {Promise<string>} The local clone path
281
+ */
282
+ async clone(programId, repositoryId, {
283
+ imsOrgId, repoType, repoUrl, ref,
284
+ } = {}) {
285
+ const clonePath = mkdtempSync(path.join(os.tmpdir(), CLONE_DIR_PREFIX));
286
+
287
+ try {
288
+ this.log.info(`Cloning CM repository: program=${programId}, repo=${repositoryId}, type=${repoType}`);
289
+
290
+ const args = await this.#buildAuthGitArgs('clone', programId, repositoryId, { imsOrgId, repoType, repoUrl });
291
+ this.#execGit([...args, clonePath]);
292
+ this.log.info(`Repository cloned to ${clonePath}`);
293
+ this.#logTmpDiskUsage('clone');
294
+
295
+ // Checkout the requested ref if provided
296
+ if (hasText(ref)) {
297
+ try {
298
+ this.#execGit(['checkout', ref], { cwd: clonePath });
299
+ this.log.info(`Checked out ref '${ref}' in ${clonePath}`);
300
+ } catch (error) {
301
+ this.log.error(`Failed to checkout ref '${ref}': ${error.message}. Continuing with default branch.`);
302
+ }
303
+ }
304
+
305
+ return clonePath;
306
+ } catch (error) {
307
+ rmSync(clonePath, { recursive: true, force: true });
308
+ throw error;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Zips the entire cloned repository including .git history.
314
+ * Downstream services that read the ZIP from S3 need the full git history.
315
+ * @param {string} clonePath - Path to the cloned repository
316
+ * @returns {Promise<Buffer>} ZIP file as a Buffer
317
+ */
318
+ async zipRepository(clonePath) {
319
+ if (!existsSync(clonePath)) {
320
+ throw new Error(`Clone path does not exist: ${clonePath}`);
321
+ }
322
+
323
+ this.log.info(`Zipping repository at ${clonePath}`);
324
+ const zip = new AdmZip();
325
+ zip.addLocalFolder(clonePath);
326
+ return zip.toBuffer();
327
+ }
328
+
329
+ /**
330
+ * Creates a new branch from a base branch.
331
+ * @param {string} clonePath - Path to the cloned repository
332
+ * @param {string} baseBranch - Base branch name
333
+ * @param {string} newBranch - New branch name
334
+ */
335
+ async createBranch(clonePath, baseBranch, newBranch) {
336
+ this.log.info(`Creating branch ${newBranch} from ${baseBranch}`);
337
+ this.#execGit(['checkout', baseBranch], { cwd: clonePath });
338
+ this.#execGit(['checkout', '-b', newBranch], { cwd: clonePath });
339
+ }
340
+
341
+ /**
342
+ * Downloads a patch from S3 and applies it to the given branch.
343
+ * Supports two patch formats, detected automatically from the content:
344
+ * - Mail-message format (starts with "From "): applied via git am, which
345
+ * creates the commit using embedded metadata (author, date, message).
346
+ * - Plain diff format (starts with "diff "): applied via git apply, then
347
+ * staged and committed. A commitMessage option is required for this flow.
348
+ * @param {string} clonePath - Path to the cloned repository
349
+ * @param {string} branch - Branch to apply the patch on
350
+ * @param {string} s3PatchPath - S3 URI of the patch file (s3://bucket/key)
351
+ * @param {object} [options] - Optional settings
352
+ * @param {string} [options.commitMessage] - Commit message for plain diff patches. Required
353
+ * when the patch is a plain diff. Ignored for mail-message patches (git am uses the
354
+ * embedded commit message); a warning is logged if provided with a mail-message patch.
355
+ */
356
+ async applyPatch(clonePath, branch, s3PatchPath, options = {}) {
357
+ const { bucket, key } = parseS3Path(s3PatchPath);
358
+ const patchDir = mkdtempSync(path.join(os.tmpdir(), PATCH_FILE_PREFIX));
359
+ const patchFile = path.join(patchDir, 'applied.patch');
360
+ const { gitUsername, gitUserEmail } = this.config;
361
+
362
+ try {
363
+ // Download patch from S3
364
+ this.log.info(`Downloading patch from s3://${bucket}/${key}`);
365
+ const command = new GetObjectCommand({ Bucket: bucket, Key: key });
366
+ const response = await this.s3Client.send(command);
367
+ const patchContent = await response.Body.transformToString();
368
+ writeFileSync(patchFile, patchContent);
369
+
370
+ // Configure committer identity
371
+ this.#execGit(['config', 'user.name', gitUsername], { cwd: clonePath });
372
+ this.#execGit(['config', 'user.email', gitUserEmail], { cwd: clonePath });
373
+
374
+ // Checkout branch
375
+ this.#execGit(['checkout', branch], { cwd: clonePath });
376
+
377
+ // Detect format from content and apply accordingly
378
+ const isMailMessage = patchContent.startsWith('From ');
379
+ const { commitMessage } = options;
380
+
381
+ if (isMailMessage) {
382
+ // Mail-message format: git am creates the commit using embedded metadata
383
+ if (commitMessage) {
384
+ this.log.warn('commitMessage is ignored for mail-message patches; git am uses the embedded commit message');
385
+ }
386
+ this.#execGit(['am', patchFile], { cwd: clonePath });
387
+ } else {
388
+ // Plain diff format: apply, stage, and commit
389
+ if (!commitMessage) {
390
+ throw new Error('commitMessage is required when applying a plain diff patch');
391
+ }
392
+ this.#execGit(['apply', patchFile], { cwd: clonePath });
393
+ this.#execGit(['add', '-A'], { cwd: clonePath });
394
+ this.#execGit(['commit', '-m', commitMessage], { cwd: clonePath });
395
+ }
396
+
397
+ this.log.info(`Patch applied and committed on branch ${branch}`);
398
+ } finally {
399
+ // Clean up temp patch directory and file
400
+ if (existsSync(patchDir)) {
401
+ rmSync(patchDir, { recursive: true, force: true });
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Pushes the current branch to the remote CM repository.
408
+ * Commits are expected to already exist (e.g. via applyPatch).
409
+ *
410
+ * For BYOG repos: uses Bearer token + API key + IMS org ID via extraheader.
411
+ * For standard repos: uses Basic auth via extraheader.
412
+ *
413
+ * @param {string} clonePath - Path to the cloned repository
414
+ * @param {string} programId - CM Program ID
415
+ * @param {string} repositoryId - CM Repository ID
416
+ * @param {Object} config - Push configuration
417
+ * @param {string} config.imsOrgId - IMS Organization ID
418
+ * @param {string} config.repoType - Repository type ('standard' or VCS type)
419
+ * @param {string} config.repoUrl - Repository URL
420
+ * @param {string} config.ref - Git ref (branch) to push
421
+ */
422
+ async push(clonePath, programId, repositoryId, {
423
+ imsOrgId, repoType, repoUrl, ref,
424
+ } = {}) {
425
+ const pushArgs = await this.#buildAuthGitArgs('push', programId, repositoryId, { imsOrgId, repoType, repoUrl });
426
+ pushArgs.push(ref);
427
+ this.#execGit(pushArgs, { cwd: clonePath });
428
+ this.log.info('Changes pushed successfully');
429
+ this.#logTmpDiskUsage('push');
430
+ }
431
+
432
+ /**
433
+ * Pulls the latest changes from the remote CM repository into an existing clone.
434
+ *
435
+ * For BYOG repos: uses Bearer token + API key + IMS org ID via extraheader.
436
+ * For standard repos: uses Basic auth via extraheader.
437
+ *
438
+ * If a ref is provided, the ref is checked out before pulling so that
439
+ * the pull updates the correct branch.
440
+ *
441
+ * @param {string} clonePath - Path to the cloned repository
442
+ * @param {string} programId - CM Program ID
443
+ * @param {string} repositoryId - CM Repository ID
444
+ * @param {Object} config - Pull configuration
445
+ * @param {string} config.imsOrgId - IMS Organization ID
446
+ * @param {string} config.repoType - Repository type ('standard' or VCS type)
447
+ * @param {string} config.repoUrl - Repository URL
448
+ * @param {string} [config.ref] - Optional. Git ref to checkout before pull (branch, tag, or SHA)
449
+ */
450
+ async pull(clonePath, programId, repositoryId, {
451
+ imsOrgId, repoType, repoUrl, ref,
452
+ } = {}) {
453
+ if (hasText(ref)) {
454
+ this.#execGit(['checkout', ref], { cwd: clonePath });
455
+ this.log.info(`Checked out ref '${ref}' before pull`);
456
+ }
457
+ const pullArgs = await this.#buildAuthGitArgs('pull', programId, repositoryId, { imsOrgId, repoType, repoUrl });
458
+ this.#execGit(pullArgs, { cwd: clonePath });
459
+ this.log.info('Changes pulled successfully');
460
+ this.#logTmpDiskUsage('pull');
461
+ }
462
+
463
+ /**
464
+ * Checks out a specific git ref (branch, tag, or SHA) in an existing repository.
465
+ *
466
+ * @param {string} clonePath - Path to the cloned repository
467
+ * @param {string} ref - Git ref to checkout (branch, tag, or SHA)
468
+ */
469
+ async checkout(clonePath, ref) {
470
+ this.log.info(`Checking out ref '${ref}' in ${clonePath}`);
471
+ this.#execGit(['checkout', ref], { cwd: clonePath });
472
+ }
473
+
474
+ /**
475
+ * Extracts a ZIP buffer into a new unique temp directory.
476
+ * Used to restore a previously-zipped repository from S3
477
+ * for incremental updates (checkout + pull) instead of a full clone.
478
+ *
479
+ * @param {Buffer} zipBuffer - ZIP file content as a Buffer
480
+ * @returns {Promise<string>} Path to the extracted repository
481
+ */
482
+ async unzipRepository(zipBuffer) {
483
+ const extractPath = mkdtempSync(path.join(os.tmpdir(), CLONE_DIR_PREFIX));
484
+
485
+ try {
486
+ const zip = new AdmZip(zipBuffer);
487
+ zip.extractAllTo(extractPath, true);
488
+ this.log.info(`Repository extracted to ${extractPath}`);
489
+ return extractPath;
490
+ } catch (error) {
491
+ rmSync(extractPath, { recursive: true, force: true });
492
+ throw new Error(`Failed to unzip repository: ${error.message}`);
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Removes a cloned repository from the temp directory.
498
+ * @param {string} clonePath - Path to remove
499
+ */
500
+ async cleanup(clonePath) {
501
+ const expectedPrefix = path.join(os.tmpdir(), CLONE_DIR_PREFIX);
502
+ if (!clonePath || !clonePath.startsWith(expectedPrefix)) {
503
+ throw new Error(`Invalid clone path for cleanup: ${clonePath}. Must be a cm-repo temp directory.`);
504
+ }
505
+
506
+ if (existsSync(clonePath)) {
507
+ rmSync(clonePath, { recursive: true, force: true });
508
+ this.log.info(`Cleaned up ${clonePath}`);
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Creates a pull request in a CM repository via the CM Repo REST API.
514
+ * @param {string} programId - CM Program ID
515
+ * @param {string} repositoryId - CM Repository ID
516
+ * @param {Object} config - PR configuration
517
+ * @param {string} config.imsOrgId - IMS Organization ID
518
+ * @param {string} config.destinationBranch - Branch to merge into (base)
519
+ * @param {string} config.sourceBranch - Branch that contains the changes (head)
520
+ * @param {string} config.title - PR title
521
+ * @param {string} config.description - PR description
522
+ * @returns {Promise<Object>}
523
+ */
524
+ async createPullRequest(programId, repositoryId, {
525
+ imsOrgId, destinationBranch, sourceBranch, title, description,
526
+ }) {
527
+ const { access_token: token } = await this.imsClient.getServiceAccessToken();
528
+ const url = `${this.config.cmRepoUrl}/api/program/${programId}/repository/${repositoryId}/pullRequests`;
529
+
530
+ this.log.info(`Creating PR for program=${programId}, repo=${repositoryId}: ${title}`);
531
+
532
+ const response = await fetch(url, {
533
+ method: 'POST',
534
+ headers: {
535
+ 'Content-Type': 'application/json',
536
+ Authorization: `Bearer ${token}`,
537
+ 'x-api-key': this.config.clientId,
538
+ 'x-gw-ims-org-id': imsOrgId,
539
+ },
540
+ body: JSON.stringify({
541
+ title,
542
+ sourceBranch,
543
+ destinationBranch,
544
+ description,
545
+ }),
546
+ });
547
+
548
+ if (!response.ok) {
549
+ const errorText = await response.text();
550
+ throw new Error(`Pull request creation failed: ${response.status} - ${errorText}`);
551
+ }
552
+
553
+ return response.json();
554
+ }
555
+ }