Skip to content

Commit ce9870c

Browse files
committed
fix(push): confirm cloud env/secrets wipe
Missing or empty .env.config/.env.secrets now warn and prompt [y/N] before clearing cloud values; empty secrets push {}
1 parent ac23063 commit ce9870c

4 files changed

Lines changed: 150 additions & 78 deletions

File tree

src/commands/push.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,6 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
344344
cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets },
345345
assetFileNames,
346346
cloudAssets: cloudApp.assets,
347-
warn: (message) => ui.warn(message),
348347
});
349348
const {
350349
diff: envPushDiff,
@@ -354,6 +353,7 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
354353
} = envPush;
355354
const envConfigChanged = envPushDiff.configChanged;
356355
const envSecretsChanged = envPushDiff.secretsChanged;
356+
const { wouldClearConfig, wouldClearSecrets } = envPushDiff;
357357

358358
await writeVerboseJson(root, 'ensemble-bundle.json', bundle, {
359359
verbose,
@@ -413,6 +413,11 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
413413
if (envConfigChanged || envSecretsChanged) {
414414
ui.note('Env file changes would also be pushed (.env.config / .env.secrets).');
415415
}
416+
if (wouldClearConfig || wouldClearSecrets) {
417+
ui.warn(
418+
'Push would delete all cloud env/secrets (.env.config and/or .env.secrets missing or empty).'
419+
);
420+
}
416421
return;
417422
}
418423

@@ -430,6 +435,8 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
430435
console.log(' env:\n ✏️ modified .env.secrets');
431436
}
432437

438+
const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
439+
433440
const appHome = appConfig.appHome as string | undefined;
434441
const cloudHome = getCloudHomeScreenName(cloudApp);
435442
const hasHomeConflict = appHome && cloudHome && appHome !== cloudHome;
@@ -447,10 +454,40 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
447454
}
448455
}
449456

457+
if (wouldClearConfig || wouldClearSecrets) {
458+
const targets = [
459+
wouldClearConfig && 'env variables (.env.config)',
460+
wouldClearSecrets && 'secrets (.env.secrets)',
461+
].filter((t): t is string => Boolean(t));
462+
ui.warn(`Pushing will delete all cloud ${targets.join(' and ')}.`);
463+
464+
if (!options.yes) {
465+
if (!isInteractive) {
466+
ui.error(
467+
'Refusing to clear cloud env/secrets non-interactively without --yes. Re-run with --dry-run to inspect changes.'
468+
);
469+
process.exitCode = 1;
470+
return;
471+
}
472+
const { proceed: clearEnv } = await prompts({
473+
type: 'confirm',
474+
name: 'proceed',
475+
message: `Delete all ${targets.join(' and ')} from cloud? Continue? [y/N]`,
476+
initial: false,
477+
});
478+
if (!clearEnv) {
479+
ui.warn('Push cancelled.');
480+
process.exitCode = 130;
481+
return;
482+
}
483+
} else {
484+
ui.note('Proceeding without interactive confirmation because --yes was provided.');
485+
}
486+
}
487+
450488
const manifestNeedsRefresh = hasManifestRelevantChanges(cloudApp, plan.diff);
451489

452490
let confirmed = options.yes ?? false;
453-
const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
454491
const hasDeletes = summary.counts.deleted > 0;
455492
const largeChangeSet = yamlChangeTotal >= DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD;
456493

src/core/envSync.ts

Lines changed: 57 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,39 @@ export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDT
155155
: undefined;
156156
}
157157

158+
function localNonAssetConfigEntries(
159+
localEnv: LocalEnvFiles,
160+
assetFileNames: string[],
161+
cloudAssets?: CloudAssetEnvRef[]
162+
): EnvEntry[] {
163+
if (!localEnv.envConfigPresent) return [];
164+
return configDtoToEnvEntries(
165+
buildConfigDtoFromEnvConfigFile(localEnv.envConfig, assetFileNames, cloudAssets)
166+
);
167+
}
168+
169+
function wouldClearConfigOnPush(
170+
localEnv: LocalEnvFiles,
171+
cloudConfig: ConfigDTO | undefined,
172+
assetFileNames: string[],
173+
cloudAssets?: CloudAssetEnvRef[]
174+
): boolean {
175+
const cloudNonAsset = configDtoToEnvEntries(
176+
stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets)
177+
);
178+
return (
179+
cloudNonAsset.length > 0 &&
180+
localNonAssetConfigEntries(localEnv, assetFileNames, cloudAssets).length === 0
181+
);
182+
}
183+
184+
function wouldClearSecretsOnPush(localEnv: LocalEnvFiles, cloudSecrets?: SecretDTO): boolean {
185+
return (
186+
secretsDtoToEnvEntries(cloudSecrets).length > 0 &&
187+
(!localEnv.envSecretsPresent || localEnv.envSecrets.length === 0)
188+
);
189+
}
190+
158191
export function pruneStaleAssetEnvEntries(
159192
entries: EnvEntry[],
160193
assetFileNames: string[],
@@ -209,29 +242,6 @@ export function buildPushConfigDto(
209242
return { envVariables };
210243
}
211244

212-
export function warnIfMissingEnvFilesForPush(
213-
localEnv: LocalEnvFiles,
214-
cloudEnv: CloudEnvState,
215-
assetFileNames: string[] = [],
216-
warn: (message: string) => void
217-
): void {
218-
const checks: Array<[boolean, string]> = [
219-
[
220-
!localEnv.envConfigPresent &&
221-
configDtoToEnvEntries(stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames)).length >
222-
0,
223-
'.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.',
224-
],
225-
[
226-
!localEnv.envSecretsPresent && secretsDtoToEnvEntries(cloudEnv.secrets).length > 0,
227-
'.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.',
228-
],
229-
];
230-
for (const [missing, message] of checks) {
231-
if (missing) warn(message);
232-
}
233-
}
234-
235245
export async function readProjectEnvFiles(projectRoot: string): Promise<LocalEnvFiles> {
236246
const [envConfigPresent, envSecretsPresent] = await Promise.all([
237247
envFileExists(projectRoot, '.env.config'),
@@ -284,15 +294,14 @@ export function envSecretsEntriesMatchCloud(
284294
localEntries: EnvEntry[],
285295
cloudSecrets: SecretDTO | undefined
286296
): boolean {
287-
return entriesEqual(
288-
secretsDtoToEnvEntries(buildSecretsDtoFromEnvSecretsFile(localEntries)),
289-
secretsDtoToEnvEntries(cloudSecrets)
290-
);
297+
return entriesEqual(localEntries, secretsDtoToEnvEntries(cloudSecrets));
291298
}
292299

293300
export interface EnvPushDiff {
294301
configChanged: boolean;
295302
secretsChanged: boolean;
303+
wouldClearConfig: boolean;
304+
wouldClearSecrets: boolean;
296305
local: { config?: ConfigDTO; secrets?: SecretDTO };
297306
cloud: { config?: ConfigDTO; secrets?: SecretDTO };
298307
}
@@ -303,24 +312,33 @@ export function buildEnvPushDiff(
303312
assetFileNames: string[] = [],
304313
cloudAssets?: CloudAssetEnvRef[]
305314
): EnvPushDiff {
315+
const pushConfig = buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets);
316+
const wouldClearConfig = wouldClearConfigOnPush(
317+
localEnv,
318+
cloudEnv.config,
319+
assetFileNames,
320+
cloudAssets
321+
);
322+
const wouldClearSecrets = wouldClearSecretsOnPush(localEnv, cloudEnv.secrets);
306323
const configChanged =
307-
localEnv.envConfigPresent &&
308-
!configEntriesEqual(
309-
buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets),
310-
cloudEnv.config
311-
);
324+
wouldClearConfig ||
325+
(localEnv.envConfigPresent && !configEntriesEqual(pushConfig, cloudEnv.config));
312326
const secretsChanged =
313-
localEnv.envSecretsPresent &&
314-
!envSecretsEntriesMatchCloud(localEnv.envSecrets, cloudEnv.secrets);
327+
wouldClearSecrets ||
328+
(localEnv.envSecretsPresent &&
329+
!entriesEqual(localEnv.envSecrets, secretsDtoToEnvEntries(cloudEnv.secrets)));
330+
const pushSecrets: SecretDTO = {
331+
secrets: Object.fromEntries(localEnv.envSecrets.map((e) => [e.key, e.value])),
332+
};
315333

316334
return {
317335
configChanged,
318336
secretsChanged,
337+
wouldClearConfig,
338+
wouldClearSecrets,
319339
local: {
320-
...(configChanged && {
321-
config: buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets),
322-
}),
323-
...(secretsChanged && { secrets: buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets) }),
340+
...(configChanged && { config: pushConfig }),
341+
...(secretsChanged && { secrets: pushSecrets }),
324342
},
325343
cloud: {
326344
...(configChanged && cloudEnv.config && { config: cloudEnv.config }),
@@ -376,7 +394,6 @@ export async function prepareEnvPushState(params: {
376394
cloudEnv: CloudEnvState;
377395
assetFileNames: string[];
378396
cloudAssets?: CloudAssetEnvRef[];
379-
warn: (message: string) => void;
380397
}): Promise<EnvPushState> {
381398
const localEnvRaw = await readProjectEnvFiles(params.projectRoot);
382399
const prunedEnvConfig = localEnvRaw.envConfigPresent
@@ -390,19 +407,10 @@ export async function prepareEnvPushState(params: {
390407
params.cloudAssets
391408
);
392409

393-
warnIfMissingEnvFilesForPush(localEnv, params.cloudEnv, params.assetFileNames, params.warn);
394-
395410
return {
396411
localEnv,
397412
diff,
398-
pushConfigDto: localEnvRaw.envConfigPresent
399-
? buildPushConfigDto(
400-
localEnv,
401-
params.cloudEnv.config,
402-
params.assetFileNames,
403-
params.cloudAssets
404-
)
405-
: undefined,
413+
pushConfigDto: diff.local.config,
406414
pushSecretsDto: diff.local.secrets,
407415
...(localEnvRaw.envConfigPresent &&
408416
!entriesEqual(prunedEnvConfig, localEnvRaw.envConfig) && {

tests/commands/pushPull.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,4 +1040,47 @@ describe('push/pull integration (commands)', () => {
10401040
expect(payload.config?.envVariables?.API_URL).toBe('https://local.example.com');
10411041
expect(payload.secrets?.secrets?.S1).toBe('local-secret');
10421042
});
1043+
1044+
it.each([
1045+
['deleted', async () => {}],
1046+
[
1047+
'empty',
1048+
async () => {
1049+
await fs.writeFile(path.join(projectRoot, '.env.secrets'), '', 'utf8');
1050+
},
1051+
],
1052+
])('push clears cloud secrets when .env.secrets is %s', async (_label, setupSecrets) => {
1053+
await setupSecrets();
1054+
(resolveAppContext as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
1055+
projectRoot,
1056+
config: {
1057+
default: 'dev',
1058+
apps: {
1059+
dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value },
1060+
},
1061+
},
1062+
appKey: 'dev',
1063+
appId: 'app1',
1064+
});
1065+
(cloudModuleMock.fetchCloudApp as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
1066+
id: 'app1',
1067+
name: 'App',
1068+
screens: [],
1069+
widgets: [],
1070+
scripts: [],
1071+
translations: [],
1072+
secrets: { secrets: { S1: 'cloud-secret' } },
1073+
});
1074+
1075+
await pushCommand({ yes: true });
1076+
1077+
const payload = (
1078+
(cloudModuleMock.submitEnvDocumentsPush as ReturnType<typeof vi.fn>).mock.calls[0] as [
1079+
string,
1080+
string,
1081+
{ secrets?: { secrets?: Record<string, string> } },
1082+
]
1083+
)[2];
1084+
expect(payload.secrets?.secrets).toEqual({});
1085+
});
10431086
});

tests/core/envSync.test.ts

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
computeEnvPullChanges,
1414
pruneStaleAssetEnvEntries,
1515
prepareEnvPushState,
16-
warnIfMissingEnvFilesForPush,
1716
type CloudEnvState,
1817
type LocalEnvFiles,
1918
} from '../../src/core/envSync.js';
@@ -75,6 +74,8 @@ describe('envSync', () => {
7574
cloudAssets?: Array<{ fileName?: string; copyText?: string }>;
7675
configChanged: boolean;
7776
secretsChanged: boolean;
77+
wouldClearConfig?: boolean;
78+
wouldClearSecrets?: boolean;
7879
cloudConfig?: Record<string, string>;
7980
localConfig?: Record<string, string>;
8081
}>([
@@ -111,7 +112,7 @@ describe('envSync', () => {
111112
secretsChanged: false,
112113
},
113114
{
114-
name: 'skips push when env files are missing',
115+
name: 'flags clear-all push when env files are missing but cloud has values',
115116
local: {
116117
envConfig: [],
117118
envSecrets: [],
@@ -123,8 +124,10 @@ describe('envSync', () => {
123124
secrets: { secrets: { S1: 'SK1' } },
124125
},
125126
assets: [],
126-
configChanged: false,
127-
secretsChanged: false,
127+
configChanged: true,
128+
secretsChanged: true,
129+
wouldClearConfig: true,
130+
wouldClearSecrets: true,
128131
},
129132
{
130133
name: 'shows full push config vs cloud including asset keys',
@@ -205,12 +208,16 @@ describe('envSync', () => {
205208
cloudAssets,
206209
configChanged,
207210
secretsChanged,
211+
wouldClearConfig = false,
212+
wouldClearSecrets = false,
208213
cloudConfig,
209214
localConfig,
210215
}) => {
211216
const diff = buildEnvPushDiff(local, cloud, assets, cloudAssets);
212217
expect(diff.configChanged).toBe(configChanged);
213218
expect(diff.secretsChanged).toBe(secretsChanged);
219+
expect(diff.wouldClearConfig).toBe(wouldClearConfig);
220+
expect(diff.wouldClearSecrets).toBe(wouldClearSecrets);
214221
if (!configChanged && !secretsChanged) {
215222
expect(diff.local).toEqual({});
216223
expect(diff.cloud).toEqual({});
@@ -380,7 +387,6 @@ describe('envSync', () => {
380387
config: { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1' } },
381388
},
382389
assetFileNames: [],
383-
warn: () => {},
384390
});
385391

386392
expect(state.diff.configChanged).toBe(true);
@@ -398,7 +404,6 @@ describe('envSync', () => {
398404
projectRoot: tmpDir,
399405
cloudEnv: { config: { envVariables: { E1: 'EV1' } } },
400406
assetFileNames: ['img1.png'],
401-
warn: () => {},
402407
});
403408

404409
const envConfigBeforeConfirm = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8');
@@ -426,25 +431,4 @@ describe('envSync', () => {
426431
expect(envConfig).toContain('logo_png=logo.png');
427432
expect(envConfig).toContain('E1=EV1');
428433
});
429-
430-
it('warnIfMissingEnvFilesForPush warns when cloud has values but local files are missing', () => {
431-
const warnings: string[] = [];
432-
warnIfMissingEnvFilesForPush(
433-
{
434-
envConfig: [],
435-
envSecrets: [],
436-
envConfigPresent: false,
437-
envSecretsPresent: false,
438-
},
439-
{
440-
config: { envVariables: { E1: 'EV1' } },
441-
secrets: { secrets: { S1: 'SK1' } },
442-
},
443-
[],
444-
(message) => warnings.push(message)
445-
);
446-
expect(warnings).toHaveLength(2);
447-
expect(warnings[0]).toContain('.env.config is missing');
448-
expect(warnings[1]).toContain('.env.secrets is missing');
449-
});
450434
});

0 commit comments

Comments
 (0)