Skip to content

Commit 451608d

Browse files
committed
Added new export command to export existing environment variables that are used as replacements in sfdx-project.json
1 parent 1a6975a commit 451608d

4 files changed

Lines changed: 481 additions & 3 deletions

File tree

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@
6969
"hooks": {
7070
"prerun": "./lib/hooks/prerun"
7171
},
72-
"commands": "./lib/commands"
72+
"commands": "./lib/commands",
73+
"topics": {
74+
"dotenv": {
75+
"description": "Leverage .env files within sf CLI"
76+
}
77+
}
7378
},
7479
"scripts": {
7580
"prepare": "husky",

src/commands/dotenv/export.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
2+
3+
import * as fs from 'node:fs';
4+
import * as path from 'node:path';
5+
6+
import { PLUGIN_NAME } from '../../shared/constants.js';
7+
8+
interface SfdxProjectReplacement {
9+
filename?: string;
10+
stringToReplace?: string;
11+
replaceWithEnv?: string;
12+
[key: string]: unknown;
13+
}
14+
15+
interface SfdxProject {
16+
replacements?: SfdxProjectReplacement[];
17+
[key: string]: unknown;
18+
}
19+
20+
const AUTO_ADDED_COMMENT = '# Auto-added by sf dotenv export';
21+
const CHECK = '\x1b[32m✔\x1b[0m ';
22+
const DELIMITER = `\n ${CHECK}`;
23+
const DEFAULT_OUTPUT_FILE = '.env';
24+
const SFDX_PROJECT_FILE = 'sfdx-project.json';
25+
26+
export default class DotEnvExport extends SfCommand<void> {
27+
public static pluginName = PLUGIN_NAME;
28+
29+
public static readonly summary =
30+
'Generate or update an .env file with "replaceWithEnv" entries in sfdx-project.json';
31+
public static readonly description =
32+
'Reads sfdx-project.json and writes any "replaceWithEnv" values from the "replacements" node to a .env file. ' +
33+
'If the output file already exists, only missing keys are appended.';
34+
35+
public static readonly examples = [
36+
'<%= config.bin %> <%= command.id %>',
37+
'<%= config.bin %> <%= command.id %> --output-file .env.local',
38+
'<%= config.bin %> <%= command.id %> --output-file .env.local',
39+
'<%= config.bin %> <%= command.id %> --output-file .env.test',
40+
];
41+
42+
public static readonly flags = {
43+
'output-file': Flags.string({
44+
char: 'o',
45+
summary: 'Path to the output .env file.',
46+
description: `Specify the output file path. Defaults to "${DEFAULT_OUTPUT_FILE}" in the current directory.`,
47+
default: DEFAULT_OUTPUT_FILE,
48+
}),
49+
};
50+
51+
public async run(): Promise<void> {
52+
const { flags } = await this.parse(DotEnvExport);
53+
const outputFile = flags['output-file'];
54+
55+
const projectConfig = this.loadProjectConfig();
56+
const projectEnvironmentKeys = this.getProjectEnvironmentKeys(projectConfig);
57+
if (projectEnvironmentKeys === null) {
58+
return;
59+
}
60+
61+
const { outputFilePath, existingContent, existingKeys } = this.readExistingEnvFile(outputFile);
62+
63+
const missingKeys = this.getMissingKeys(projectEnvironmentKeys, existingKeys, outputFile);
64+
if (missingKeys === null) {
65+
return;
66+
}
67+
68+
const appendContent = this.buildEnvFileLines(missingKeys, existingContent);
69+
this.writeEnvFile(outputFilePath, appendContent, existingContent);
70+
71+
const sortedEnvironmentKeys = [...missingKeys].sort();
72+
const environmentVariableLabel = `environment variable${missingKeys.length === 1 ? '' : 's'}`;
73+
74+
const header = `\n ───────── Exporting Environment Variables ────────\n`;
75+
const loadingLine = `\nExporting ${String(missingKeys.length)} ${environmentVariableLabel} from ${SFDX_PROJECT_FILE} to ${outputFile}:`;
76+
const printedMessage = DELIMITER + sortedEnvironmentKeys.join(DELIMITER);
77+
78+
this.log(`${header}${loadingLine}${printedMessage}`);
79+
}
80+
81+
private loadProjectConfig(): SfdxProject {
82+
const projectFilePath = path.resolve(process.cwd(), SFDX_PROJECT_FILE);
83+
84+
if (!fs.existsSync(projectFilePath)) {
85+
this.error(
86+
`Could not find ${SFDX_PROJECT_FILE} in the current directory (${process.cwd()}).`
87+
);
88+
}
89+
90+
try {
91+
const raw = fs.readFileSync(projectFilePath, 'utf-8');
92+
return JSON.parse(raw) as SfdxProject;
93+
} catch (err) {
94+
this.error(`Failed to parse ${SFDX_PROJECT_FILE}: ${(err as Error).message}`);
95+
}
96+
}
97+
98+
private getProjectEnvironmentKeys(project: SfdxProject): string[] | null {
99+
const projectReplacements = project.replacements ?? [];
100+
const replacementEnvironmentKeys: string[] = projectReplacements
101+
.filter(
102+
(replacement): replacement is SfdxProjectReplacement & { replaceWithEnv: string } =>
103+
replacement.replaceWithEnv != null
104+
)
105+
.map((replacement) => replacement.replaceWithEnv);
106+
const replacementEnvironmentKeysCount = replacementEnvironmentKeys.length;
107+
108+
if (replacementEnvironmentKeysCount === 0) {
109+
if (!this.jsonEnabled()) {
110+
this.log('No "replaceWithEnv" entries found in sfdx-project.json. Nothing to do.');
111+
}
112+
return null;
113+
}
114+
115+
const environmentVariableLabel = `environment variable${replacementEnvironmentKeysCount === 1 ? '' : 's'}`;
116+
const sortedEnvironmentKeys = [...replacementEnvironmentKeys].sort();
117+
this.log(
118+
`Found ${String(replacementEnvironmentKeysCount)} ${environmentVariableLabel} in ${SFDX_PROJECT_FILE}:\n\t- ${sortedEnvironmentKeys.join('\n\t- ')}\n\n`
119+
);
120+
return replacementEnvironmentKeys;
121+
}
122+
123+
private readExistingEnvFile(outputFile: string): {
124+
outputFilePath: string;
125+
existingContent: string;
126+
existingKeys: Set<string>;
127+
} {
128+
const outputFilePath = path.resolve(process.cwd(), outputFile);
129+
let existingContent = '';
130+
const existingKeys = new Set<string>();
131+
132+
if (fs.existsSync(outputFilePath)) {
133+
const stat = fs.statSync(outputFilePath);
134+
if (stat.isDirectory()) {
135+
this.error(
136+
`Output path ${outputFilePath} is a directory, not a file. Use --output-file to specify a file path.`
137+
);
138+
}
139+
existingContent = fs.readFileSync(outputFilePath, 'utf-8');
140+
141+
for (const line of existingContent.split('\n')) {
142+
const trimmedLine = line.trim();
143+
if (trimmedLine && !trimmedLine.startsWith('#')) {
144+
const equalSignIndex = trimmedLine.indexOf('=');
145+
if (equalSignIndex !== -1) {
146+
existingKeys.add(trimmedLine.substring(0, equalSignIndex).trim());
147+
}
148+
}
149+
}
150+
151+
this.log(`${outputFile} already exists with ${String(existingKeys.size)} key(s) defined.`);
152+
}
153+
154+
return { outputFilePath, existingContent, existingKeys };
155+
}
156+
157+
private getMissingKeys(
158+
environmentKeys: string[],
159+
existingKeys: Set<string>,
160+
outputFile: string
161+
): string[] | null {
162+
const missingKeys = environmentKeys.filter((k) => !existingKeys.has(k)).sort();
163+
164+
if (missingKeys.length === 0) {
165+
if (!this.jsonEnabled()) {
166+
this.log(
167+
`\n${CHECK} All environment variables referenced in ${SFDX_PROJECT_FILE} are already present in ${outputFile}. Nothing to add.\n`
168+
);
169+
}
170+
171+
return null;
172+
}
173+
174+
return missingKeys;
175+
}
176+
177+
private buildEnvFileLines(missingKeys: string[], existingContent: string): string {
178+
const envFileNewLines: string[] = [];
179+
180+
if (existingContent && !existingContent.endsWith('\n')) {
181+
envFileNewLines.push('');
182+
}
183+
184+
envFileNewLines.push('');
185+
envFileNewLines.push(AUTO_ADDED_COMMENT);
186+
187+
for (const key of missingKeys) {
188+
envFileNewLines.push(`${key}=${process.env[key] ?? ''}`);
189+
}
190+
191+
envFileNewLines.push('');
192+
193+
return envFileNewLines.join('\n');
194+
}
195+
196+
private writeEnvFile(
197+
outputFilePath: string,
198+
appendContent: string,
199+
existingContent: string
200+
): void {
201+
if (existingContent) {
202+
fs.appendFileSync(outputFilePath, appendContent, 'utf-8');
203+
} else {
204+
fs.writeFileSync(outputFilePath, appendContent.trimStart(), 'utf-8');
205+
}
206+
}
207+
}

src/shared/loadingMessage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export function displayLoadedEnvVars(
3030

3131
const sortedEnvironmentKeys = [...loadedVars].sort();
3232
const environmentVariableLabel = `environment variable${loadedCount === 1 ? '' : 's'}`;
33+
3334
const header = `\n ────────── Loading Environment Variables ─────────\n`;
34-
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
35-
const loadingLine = `Loading ${loadedCount} ${environmentVariableLabel} from file ${envConfig.envFilePath}:`;
35+
const loadingLine = `Loading ${String(loadedCount)} ${environmentVariableLabel} from file ${envConfig.envFilePath}:`;
3636
let printedMessage = sortedEnvironmentKeys.join(DELIMITER);
3737

3838
if (options.showValues) {

0 commit comments

Comments
 (0)