Skip to content

Commit d5c9125

Browse files
authored
feat: add b2c mrt env var push command (#308)
* feat: add b2c mrt env var push command Reads a local .env file, diffs against remote MRT env vars, shows a summary, prompts for confirmation (skippable with --yes), and pushes changed variables one at a time with per-variable success/failure reporting. New files: - src/commands/mrt/env/var/push.ts — oclif command extending MrtCommand - src/utils/mrt/env-var-diff.ts — pure diff utilities (filterByPrefix, computeEnvVarDiff, formatEnvVarDiffSummary) - test/commands/mrt/env/var/push.test.ts — 8 mocha tests - test/utils/mrt/env-var-diff.test.ts — 19 mocha tests Adds dotenv dependency for .env file parsing. * Fix code formatting * fix: resolve lint errors in mrt env var push command * refactor: simplify mrt env var push command - Inline intermediate allLocalVars variable - Hoist getMrtAuth() and mrtOrigin outside parallel loop to avoid redundant instantiation per variable * feat: replace dotenv with util.parseEnv and use batch setEnvVars in push command Resolves two TODOs: swaps dotenv.parse for Node's native util.parseEnv (Node >= 22), and replaces individual setEnvVar calls with a single batch setEnvVars request, falling back to per-variable calls with error reporting if the batch fails. * refactor: eliminate batchSucceeded flag and extract baseParams in push command * Remove dotenv dependency * fix: silence ux.stdout in push command tests Stub ux.stdout in beforeEach instead of command.log, which was a no-op since the command outputs via ux.stdout rather than this.log. * changeset: add mrt env var push
1 parent abad5e4 commit d5c9125

5 files changed

Lines changed: 823 additions & 0 deletions

File tree

.changeset/mrt-env-var-push.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@salesforce/b2c-cli': minor
3+
---
4+
5+
Add `b2c mrt env var push` command. Reads a local `.env` file, computes a diff against the current remote MRT environment variables, and pushes new or changed variables in a single batch request (with per-variable fallback). Supports `--file`, `--exclude-prefix`, and `--yes` flags.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import {readFileSync} from 'node:fs';
7+
import {resolve} from 'node:path';
8+
import {parseEnv} from 'node:util';
9+
import {Flags, ux} from '@oclif/core';
10+
import {confirm} from '@inquirer/prompts';
11+
import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli';
12+
import {listEnvVars, setEnvVar, setEnvVars} from '@salesforce/b2c-tooling-sdk/operations/mrt';
13+
import {t, withDocs} from '../../../../i18n/index.js';
14+
import {filterByPrefix, computeEnvVarDiff, formatEnvVarDiffSummary} from '../../../../utils/mrt/env-var-diff.js';
15+
16+
/**
17+
* Push local .env file variables to an MRT project environment.
18+
*
19+
* Environment variables read:
20+
* MRT_PROJECT (optional) - MRT project slug, overridden by --project flag
21+
* MRT_ENVIRONMENT (optional) - MRT environment, overridden by --environment flag
22+
*/
23+
export default class MrtEnvVarPush extends MrtCommand<typeof MrtEnvVarPush> {
24+
static description = withDocs(
25+
t('commands.mrt.env.var.push.description', 'Push local .env file variables to a Managed Runtime environment'),
26+
'/cli/mrt.html#b2c-mrt-env-var-push',
27+
);
28+
29+
static enableJsonFlag = true;
30+
31+
static examples = [
32+
'<%= config.bin %> <%= command.id %> --project acme-storefront --environment production',
33+
'<%= config.bin %> <%= command.id %> -p my-project -e staging --yes',
34+
'<%= config.bin %> <%= command.id %> --file config/.env --exclude-prefix INTERNAL_ -p my-project -e staging',
35+
];
36+
37+
static flags = {
38+
...MrtCommand.baseFlags,
39+
file: Flags.string({
40+
char: 'f',
41+
description: t('commands.mrt.env.var.push.fileFlag', 'Path to the .env file to push'),
42+
default: '.env',
43+
}),
44+
'exclude-prefix': Flags.string({
45+
description: t(
46+
'commands.mrt.env.var.push.excludePrefixFlag',
47+
'Exclude variables whose keys start with this prefix (repeatable)',
48+
),
49+
multiple: true,
50+
default: ['MRT_'],
51+
}),
52+
yes: Flags.boolean({
53+
char: 'y',
54+
description: t('commands.mrt.env.var.push.yesFlag', 'Skip confirmation prompt'),
55+
default: false,
56+
}),
57+
};
58+
59+
protected operations = {
60+
listEnvVars,
61+
setEnvVar,
62+
setEnvVars,
63+
readEnvFile: (path: string): string => readFileSync(path, 'utf8'),
64+
};
65+
66+
async run(): Promise<{pushed: number; failed: number; skipped: number}> {
67+
const {flags} = await this.parse(MrtEnvVarPush);
68+
const envFilePath = resolve(flags.file);
69+
70+
// Step 1: Read and parse .env file
71+
let rawContent: string;
72+
try {
73+
rawContent = this.operations.readEnvFile(envFilePath);
74+
} catch (error_) {
75+
const error = error_ as NodeJS.ErrnoException;
76+
if (error.code === 'ENOENT') {
77+
this.error(t('commands.mrt.env.var.push.fileNotFound', '.env file not found: {{path}}', {path: envFilePath}));
78+
}
79+
throw error_;
80+
}
81+
82+
const parsed = parseEnv(rawContent);
83+
84+
// Step 2: Filter out excluded prefixes
85+
const localVars = filterByPrefix(
86+
new Map(Object.entries(parsed).filter((e): e is [string, string] => e[1] !== undefined)),
87+
flags['exclude-prefix'],
88+
);
89+
90+
// Step 3: Validate MRT target
91+
const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values;
92+
93+
if (!project) {
94+
this.error(
95+
t(
96+
'commands.mrt.env.var.push.missingProject',
97+
'MRT project is required. Provide --project flag, set MRT_PROJECT, or set mrtProject in dw.json.',
98+
),
99+
);
100+
}
101+
if (!environment) {
102+
this.error(
103+
t(
104+
'commands.mrt.env.var.push.missingEnvironment',
105+
'MRT environment is required. Provide --environment flag, set MRT_ENVIRONMENT, or set mrtEnvironment in dw.json.',
106+
),
107+
);
108+
}
109+
110+
// Step 4: Fetch current remote env vars
111+
this.requireMrtCredentials();
112+
const {mrtOrigin: origin} = this.resolvedConfig.values;
113+
const auth = this.getMrtAuth();
114+
115+
ux.stdout(
116+
t('commands.mrt.env.var.push.fetching', 'Fetching remote env vars for {{project}}/{{environment}}...', {
117+
project,
118+
environment,
119+
}),
120+
);
121+
const {variables: remoteVariables} = await this.operations.listEnvVars(
122+
{projectSlug: project, environment, origin},
123+
auth,
124+
);
125+
const remoteVars = new Map(remoteVariables.map((v) => [v.name, v.value]));
126+
127+
// Step 5: Compute diff
128+
const diff = computeEnvVarDiff(localVars, remoteVars);
129+
const toSync = [...diff.add, ...diff.update];
130+
131+
// Step 6: Display summary
132+
ux.stdout('');
133+
ux.stdout(formatEnvVarDiffSummary(diff));
134+
135+
if (toSync.length === 0) {
136+
return {pushed: 0, failed: 0, skipped: diff.remoteOnly.length};
137+
}
138+
139+
// Step 7: Confirm unless --yes
140+
if (!flags.yes) {
141+
const message = t(
142+
'commands.mrt.env.var.push.confirm',
143+
'Push {{count}} variable(s) ({{add}} new, {{update}} updated) to {{project}}/{{environment}}?',
144+
{
145+
count: toSync.length,
146+
add: diff.add.length,
147+
update: diff.update.length,
148+
project,
149+
environment,
150+
},
151+
);
152+
const confirmed = await confirm({message, default: false});
153+
if (!confirmed) {
154+
ux.stdout(t('commands.mrt.env.var.push.aborted', 'Aborted.'));
155+
return {pushed: 0, failed: 0, skipped: diff.remoteOnly.length};
156+
}
157+
}
158+
159+
// Step 8: Push variables — try batch first, fall back to individual calls
160+
let pushed = 0;
161+
let failed = 0;
162+
163+
const baseParams = {projectSlug: project, environment, origin};
164+
165+
try {
166+
const variables = Object.fromEntries(toSync.map(({key, value}) => [key, value]));
167+
await this.operations.setEnvVars({...baseParams, variables}, auth);
168+
for (const {key} of toSync) {
169+
ux.stdout(t('commands.mrt.env.var.push.varSuccess', ' ✓ {{key}}', {key}));
170+
}
171+
pushed = toSync.length;
172+
} catch {
173+
this.warn(t('commands.mrt.env.var.push.batchFailed', 'Batch push failed, retrying variables individually...'));
174+
const results = await Promise.allSettled(
175+
toSync.map(({key, value}) => this.operations.setEnvVar({...baseParams, key, value}, auth)),
176+
);
177+
178+
for (const [index, result] of results.entries()) {
179+
const {key} = toSync[index];
180+
if (result.status === 'fulfilled') {
181+
ux.stdout(t('commands.mrt.env.var.push.varSuccess', ' ✓ {{key}}', {key}));
182+
pushed++;
183+
} else {
184+
this.warn(
185+
t('commands.mrt.env.var.push.varFailed', ' ✗ {{key}}: {{message}}', {
186+
key,
187+
message: (result.reason as Error).message,
188+
}),
189+
);
190+
failed++;
191+
}
192+
}
193+
}
194+
195+
// Step 9: Summary
196+
ux.stdout('');
197+
ux.stdout(
198+
t(
199+
'commands.mrt.env.var.push.summary',
200+
'Summary: {{pushed}} pushed, {{failed}} failed, {{skipped}} remote-only (not deleted)',
201+
{
202+
pushed,
203+
failed,
204+
skipped: diff.remoteOnly.length,
205+
},
206+
),
207+
);
208+
209+
return {pushed, failed, skipped: diff.remoteOnly.length};
210+
}
211+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
7+
/**
8+
* An entry in the "add" or "unchanged" category of a diff.
9+
*/
10+
export interface EnvVarEntry {
11+
key: string;
12+
value: string;
13+
}
14+
15+
/**
16+
* An entry in the "update" category of a diff, showing both old and new values.
17+
*/
18+
export interface EnvVarUpdateEntry extends EnvVarEntry {
19+
oldValue: string;
20+
}
21+
22+
/**
23+
* The result of comparing local env vars against remote MRT env vars.
24+
*/
25+
export interface EnvVarDiff {
26+
/** Variables present locally but not in MRT — will be added. */
27+
add: EnvVarEntry[];
28+
/** Variables present in both with differing values — will be updated. */
29+
update: EnvVarUpdateEntry[];
30+
/** Variables present in both with identical values — no action needed. */
31+
unchanged: EnvVarEntry[];
32+
/** Variables present in MRT but not locally — will NOT be deleted. */
33+
remoteOnly: EnvVarEntry[];
34+
}
35+
36+
/**
37+
* Returns a new Map excluding entries whose keys start with any of the given prefixes.
38+
*
39+
* @param vars - Map of environment variable key-value pairs
40+
* @param excludePrefixes - Prefixes to exclude (e.g. ['MRT_', 'SFCC_'])
41+
*/
42+
export function filterByPrefix(vars: Map<string, string>, excludePrefixes: string[]): Map<string, string> {
43+
const result = new Map<string, string>();
44+
for (const [key, value] of vars) {
45+
if (!excludePrefixes.some((prefix) => key.startsWith(prefix))) {
46+
result.set(key, value);
47+
}
48+
}
49+
return result;
50+
}
51+
52+
/**
53+
* Computes the diff between local env vars and remote MRT env vars.
54+
*
55+
* @param local - Local env vars (already filtered)
56+
* @param remote - Remote MRT env vars
57+
*/
58+
export function computeEnvVarDiff(local: Map<string, string>, remote: Map<string, string>): EnvVarDiff {
59+
const add: EnvVarEntry[] = [];
60+
const update: EnvVarUpdateEntry[] = [];
61+
const unchanged: EnvVarEntry[] = [];
62+
const remoteOnly: EnvVarEntry[] = [];
63+
64+
for (const [key, value] of local) {
65+
if (remote.has(key)) {
66+
const remoteValue = remote.get(key)!;
67+
if (remoteValue === value) {
68+
unchanged.push({key, value});
69+
} else {
70+
update.push({key, value, oldValue: remoteValue});
71+
}
72+
} else {
73+
add.push({key, value});
74+
}
75+
}
76+
77+
for (const [key, value] of remote) {
78+
if (!local.has(key)) {
79+
remoteOnly.push({key, value});
80+
}
81+
}
82+
83+
return {add, update, unchanged, remoteOnly};
84+
}
85+
86+
/**
87+
* Formats a human-readable diff summary using ASCII markers.
88+
*
89+
* @param diff - The diff to format
90+
*/
91+
export function formatEnvVarDiffSummary(diff: EnvVarDiff): string {
92+
const {add, update, unchanged, remoteOnly} = diff;
93+
const lines: string[] = [];
94+
95+
if (add.length === 0 && update.length === 0) {
96+
lines.push('Nothing to sync — all variables are up-to-date.');
97+
if (unchanged.length > 0) {
98+
lines.push('', ` ${unchanged.length} variable(s) unchanged`);
99+
}
100+
if (remoteOnly.length > 0) {
101+
lines.push('', ` ${remoteOnly.length} remote-only variable(s) (not in local file):`);
102+
for (const {key, value} of remoteOnly) {
103+
lines.push(` * ${key} = ${value}`);
104+
}
105+
}
106+
return lines.join('\n');
107+
}
108+
109+
if (add.length > 0) {
110+
lines.push(`+ Add (${add.length}):`);
111+
for (const {key, value} of add) {
112+
lines.push(` + ${key} = ${value}`);
113+
}
114+
lines.push('');
115+
}
116+
117+
if (update.length > 0) {
118+
lines.push(`~ Update (${update.length}):`);
119+
for (const {key, value, oldValue} of update) {
120+
lines.push(` ~ ${key}: ${oldValue}${value}`);
121+
}
122+
lines.push('');
123+
}
124+
125+
if (unchanged.length > 0) {
126+
lines.push(` Unchanged (${unchanged.length}):`);
127+
for (const {key} of unchanged) {
128+
lines.push(` = ${key}`);
129+
}
130+
lines.push('');
131+
}
132+
133+
if (remoteOnly.length > 0) {
134+
lines.push(` Remote-only (${remoteOnly.length}, not deleted):`);
135+
for (const {key, value} of remoteOnly) {
136+
lines.push(` * ${key} = ${value}`);
137+
}
138+
lines.push('');
139+
}
140+
141+
return lines.join('\n');
142+
}

0 commit comments

Comments
 (0)