From 932dc527d799a2e8314a3ca12865e81ebb9244d3 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Tue, 23 Jun 2026 19:02:38 +0500 Subject: [PATCH 1/3] feat(release): encrypt release snapshots Plain JSON in Storage exposed secrets. Snapshots now AES-256-GCM to .enc.json; use decrypts locally and restores env files. --- README.md | 18 +- docs/Env-config-aliases.md | 12 +- src/cloud/storageClient.ts | 33 ++-- src/commands/release.ts | 90 ++++++++- src/core/encryption.ts | 170 +++++++++++++++++ src/core/envSync.ts | 14 +- src/lib/spinner.ts | 5 + tests/cloud/firestoreClient.test.ts | 2 +- tests/cloud/storageClient.test.ts | 48 ++--- tests/commands/release.test.ts | 278 ++++++++++------------------ tests/core/encryption.test.ts | 83 +++++++++ tests/core/envSync.test.ts | 7 +- tests/lib/spinner.test.ts | 16 ++ 13 files changed, 531 insertions(+), 245 deletions(-) create mode 100644 src/core/encryption.ts create mode 100644 tests/core/encryption.test.ts diff --git a/README.md b/README.md index 9db852f..5dd9e52 100644 --- a/README.md +++ b/README.md @@ -165,11 +165,21 @@ Details: [docs/Env-config-aliases.md](docs/Env-config-aliases.md). ### Versions / releases (snapshots) -You can save and use snapshots of your app state in the cloud: +You can save and use snapshots of your app state in the cloud. Releases are **always encrypted** and require `ENSEMBLE_ENCRYPTION_KEY` in your alias secrets file: -- **Create a release from local state:** After you have local changes you want to “tag”, run **`ensemble release create`** to save a snapshot (release) of the **current local app state** with an optional message. -- **List releases:** Run **`ensemble release list`** to see recent releases. -- **Use a release locally:** Run **`ensemble release use`** to choose a release and update **local files only** to that snapshot. Then run **`ensemble push`** to apply that state to the cloud. +```bash +openssl rand -hex 32 # add to .env.secrets or .env.secrets. +``` + +`release create` and `release use` read the same alias-scoped secrets file as `push` / `pull`. + +- **Create:** `ensemble release create` — encrypts snapshot (AES-256-GCM) to `.enc.json` in Storage. +- **List:** `ensemble release list` +- **Use:** `ensemble release use` — downloads from Storage (Firebase auth), decrypts locally, restores files + secrets. + +Legacy plain `.json` releases are not supported. Re-create after adding the encryption key. + +**Firebase Storage rules (prod):** Restrict `releases/{appId}/*` to app collaborators via Firestore `get()` (see team lead / ops). Encryption protects snapshot contents; rules control who can download ciphertext. When you run `ensemble release` **without a subcommand** in an interactive terminal, the CLI opens an interactive menu that lets you choose between **create**, **list**, and **use**. In non-interactive environments (e.g. CI), you must call an explicit subcommand such as `ensemble release list` or `ensemble release use --hash `. diff --git a/docs/Env-config-aliases.md b/docs/Env-config-aliases.md index 642928c..f2ddb7a 100644 --- a/docs/Env-config-aliases.md +++ b/docs/Env-config-aliases.md @@ -110,7 +110,17 @@ Commands use the resolved pair for the selected `--app` alias (see resolution ru ### Release use -- `ensemble release use` restores snapshot config into the same write target as pull (scoped or base). +- `ensemble release use` restores snapshot config and secrets into the same write targets as pull (scoped or base). +- `release create` and `release use` require `ENSEMBLE_ENCRYPTION_KEY` in the alias secrets file (`.env.secrets` or `.env.secrets.`). Generate with `openssl rand -hex 32`. +- Snapshots are stored encrypted as `.enc.json` in Firebase Storage. Download uses normal Firebase Storage auth; access is enforced by Storage rules (app collaborators). + +### Release encryption (CDN vs CLI) + +| Key | CDN publish | CLI releases | +| ------------------------- | ---------------------------------------- | ------------------------------------------------ | +| `ENSEMBLE_ENCRYPTION_KEY` | Encrypts `encrypted-manifest.json` on R2 | Encrypts release `.enc.json` on Firebase Storage | + +CDN may also use `ENSEMBLE_MANIFEST_KEY` for Cloudflare WAF. CLI releases do not use a manifest key. --- diff --git a/src/cloud/storageClient.ts b/src/cloud/storageClient.ts index b610152..8e7d195 100644 --- a/src/cloud/storageClient.ts +++ b/src/cloud/storageClient.ts @@ -4,7 +4,7 @@ export class StorageClientError extends Error { status?: number; hint?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any - cause?: any; + cause?: unknown; constructor(params: { message: string; status?: number; hint?: string; cause?: unknown }) { super(params.message); @@ -20,13 +20,11 @@ export interface UploadReleaseSnapshotResult { objectPath: string; } -function objectPathForRelease(appId: string, versionId: string): string { - return `releases/${appId}/${versionId}.json`; +export function objectPathForRelease(appId: string, versionId: string): string { + return `releases/${appId}/${versionId}.enc.json`; } function storageAuthHeader(idToken: string): string { - // Firebase Storage v0 API accepts Firebase Auth tokens. - // Note: This is different from Google Cloud Storage OAuth tokens. return `Firebase ${idToken}`; } @@ -37,17 +35,28 @@ async function toStorageError(context: string, res: Response): Promise { const bucket = `${getEnsembleFirebaseProject()}.appspot.com`; const objectPath = objectPathForRelease(appId, versionId); @@ -61,7 +70,7 @@ export async function uploadReleaseSnapshot( Authorization: storageAuthHeader(idToken), 'Content-Type': 'application/json', }, - body: snapshotJson, + body: toFetchBody(body), }); if (!res.ok) { @@ -73,12 +82,12 @@ export async function uploadReleaseSnapshot( export async function downloadReleaseSnapshotJson( idToken: string, - snapshotPath: string + objectPath: string ): Promise { const bucket = `${getEnsembleFirebaseProject()}.appspot.com`; const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent( bucket - )}/o/${encodeURIComponent(snapshotPath)}?alt=media`; + )}/o/${encodeURIComponent(objectPath)}?alt=media`; const res = await fetch(url, { headers: { @@ -90,5 +99,5 @@ export async function downloadReleaseSnapshotJson( throw await toStorageError('download release snapshot', res); } - return await res.text(); + return res.text(); } diff --git a/src/commands/release.ts b/src/commands/release.ts index 9c8c102..da42f30 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -13,16 +13,24 @@ import { type VersionDoc, } from '../cloud/firestoreClient.js'; import { - downloadReleaseSnapshotJson, StorageClientError, + downloadReleaseSnapshotJson, uploadReleaseSnapshot, } from '../cloud/storageClient.js'; import { applyCloudStateToFs } from '../core/applyToFs.js'; import { - applyReleaseConfigToFs, + applyReleaseEnvToFs, buildConfigDtoFromEnvEntries, + buildSecretsDtoFromEnvSecretsFile, readProjectEnvFiles, + type LocalEnvFiles, } from '../core/envSync.js'; +import { + encryptReleaseSnapshot, + EncryptionError, + parseReleaseSnapshotBody, + requireReleaseEncryptionKey, +} from '../core/encryption.js'; import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; import { collectAppFiles } from '../core/appCollector.js'; @@ -73,6 +81,37 @@ function releasePushHint(appKey: string, defaultAppKey: string): string { return appKey === defaultAppKey ? 'ensemble push' : `ensemble push --app ${appKey}`; } +interface ReleaseKeyContext { + key: string; + secretsWriteFile: string; + localEnv: LocalEnvFiles; +} + +function reportEncryptionError(err: unknown): void { + if (err instanceof EncryptionError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + return; + } + ui.error(err instanceof Error ? err.message : String(err)); +} + +async function loadReleaseKeyOrFail( + projectRoot: string, + appKey: string, + defaultAppKey: string +): Promise { + const localEnv = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); + try { + const key = requireReleaseEncryptionKey(localEnv.envSecrets, localEnv.secretsWriteFile); + return { key, secretsWriteFile: localEnv.secretsWriteFile, localEnv }; + } catch (err) { + reportEncryptionError(err); + process.exitCode = 1; + return undefined; + } +} + /** Commander stores --app on the release parent when subcommands also declare it; read parent opts. */ export function resolveReleaseAppKey(command: Command): string | undefined { return command.parent?.opts()?.app as string | undefined; @@ -88,6 +127,11 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): return; } + const keyCtx = await loadReleaseKeyOrFail(root, appKey, config.default); + if (!keyCtx) { + return; + } + const session = await getValidAuthSession(); if (!session.ok) { ui.error(session.message); @@ -125,8 +169,9 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): const appName = (appConfig.name as string | undefined) ?? 'App'; const appHome = appConfig.appHome as string | undefined; const localFiles = await collectAppFiles(root); - const localEnv = await readProjectEnvFiles(root, appKey, config.default); + const { key: encryptionKey, localEnv } = keyCtx; const localConfig = buildConfigDtoFromEnvEntries(localEnv.envConfig); + const localSecrets = buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets); const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, undefined); const snapshot: CloudApp = { id: localApp.id, @@ -142,13 +187,15 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): ...(localApp.theme && { theme: localApp.theme }), ...(localApp.assets && localApp.assets.length > 0 && { assets: localApp.assets }), ...(localConfig && { config: localConfig }), + ...(localSecrets && { secrets: localSecrets }), }; try { const snapshotJson = JSON.stringify(snapshot); + const envelopeJson = encryptReleaseSnapshot(snapshotJson, encryptionKey); const versionId = crypto.randomUUID().replace(/-/g, ''); const upload = await withSpinner('Uploading snapshot to storage...', () => - uploadReleaseSnapshot(appId, idToken, versionId, snapshotJson) + uploadReleaseSnapshot(appId, idToken, versionId, envelopeJson) ); await createVersion( @@ -182,6 +229,8 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): // eslint-disable-next-line no-console console.log(typeof err.cause === 'string' ? err.cause : String(err.cause)); } + } else if (err instanceof EncryptionError) { + reportEncryptionError(err); } else { ui.error(err instanceof Error ? err.message : String(err)); } @@ -190,7 +239,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): } export async function releaseListCommand(options: ReleaseListOptions = {}): Promise { - const { config, appKey, appId } = await resolveAppContext(options.appKey); + const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey); const appConfig = config.apps[appKey]; if (!appConfig) { ui.error(`No app configured for key "${appKey}".`); @@ -198,6 +247,10 @@ export async function releaseListCommand(options: ReleaseListOptions = {}): Prom return; } + if (!(await loadReleaseKeyOrFail(projectRoot, appKey, config.default))) { + return; + } + const session = await getValidAuthSession(); if (!session.ok) { ui.error(session.message); @@ -281,6 +334,12 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis process.exitCode = 1; return; } + + const keyCtx = await loadReleaseKeyOrFail(projectRoot, appKey, config.default); + if (!keyCtx) { + return; + } + const appOptions = (appConfig.options ?? {}) as Record; const enabledByProp = Object.fromEntries( ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) @@ -391,9 +450,15 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis } } - const snapshotJson = await withSpinner('Downloading snapshot...', () => + const snapshotBody = await withSpinner('Downloading snapshot...', () => downloadReleaseSnapshotJson(idToken, versionDoc.snapshotPath) ); + const snapshotJson = parseReleaseSnapshotBody( + snapshotBody, + versionDoc.snapshotPath, + keyCtx.key, + keyCtx.secretsWriteFile + ); const snapshot = JSON.parse(snapshotJson) as CloudApp; const localFiles = await collectAppFiles(projectRoot); @@ -408,7 +473,13 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis }, }) ); - await applyReleaseConfigToFs(projectRoot, snapshot.config, appKey, config.default); + await applyReleaseEnvToFs( + projectRoot, + snapshot.config, + snapshot.secrets, + appKey, + config.default + ); ui.success( `Local files updated to selected release. Run "${releasePushHint(appKey, config.default)}" to apply to the cloud.` ); @@ -416,6 +487,11 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis if (err instanceof FirestoreClientError) { ui.error(err.message); if (err.hint) ui.note(err.hint); + } else if (err instanceof StorageClientError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + } else if (err instanceof EncryptionError) { + reportEncryptionError(err); } else { ui.error(err instanceof Error ? err.message : String(err)); } diff --git a/src/core/encryption.ts b/src/core/encryption.ts new file mode 100644 index 0000000..15d40db --- /dev/null +++ b/src/core/encryption.ts @@ -0,0 +1,170 @@ +import crypto from 'node:crypto'; +import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib'; + +import type { EnvEntry } from './envConfig.js'; + +export const ENSEMBLE_ENCRYPTION_KEY_NAME = 'ENSEMBLE_ENCRYPTION_KEY'; + +const KEY_HINT = 'Get the key from your team, or generate one with: openssl rand -hex 32'; + +export class EncryptionError extends Error { + hint?: string; + + constructor(message: string, hint?: string) { + super(message); + this.name = 'EncryptionError'; + this.hint = hint; + } +} + +interface EncryptedEnvelope { + v: number; + alg: 'AES-256-GCM'; + comp: 'br'; + iv: string; + tag: string; + ciphertext: string; +} + +function throwReleaseKeyError( + kind: 'missing' | 'invalid' | 'wrong', + secretsWriteFile: string +): never { + if (kind === 'missing') { + throw new EncryptionError( + `Releases are encrypted. Add ENSEMBLE_ENCRYPTION_KEY to ${secretsWriteFile} before creating or restoring a release.`, + KEY_HINT + ); + } + if (kind === 'invalid') { + throw new EncryptionError( + `Invalid ENSEMBLE_ENCRYPTION_KEY in ${secretsWriteFile}. Use a 256-bit key (64 hex characters).`, + KEY_HINT + ); + } + throw new EncryptionError( + `Could not decrypt this release. ENSEMBLE_ENCRYPTION_KEY in ${secretsWriteFile} does not match the key used when the release was created.`, + 'Get the correct key from your team.' + ); +} + +export function parse256BitSecret(value: string, name: string): Buffer { + const hexLike = /^[0-9a-fA-F]+$/.test(value); + if (hexLike && value.length === 64) { + return Buffer.from(value, 'hex'); + } + + try { + const b64 = Buffer.from(value, 'base64'); + if (b64.length === 32) return b64; + } catch { + // ignore invalid base64 + } + + const utf8 = Buffer.from(value, 'utf8'); + if (utf8.length === 32) return utf8; + + throw new EncryptionError( + `Invalid ${name}: must be 256-bit (32 bytes) as base64, 64-char hex, or 32-byte UTF-8 string.`, + KEY_HINT + ); +} + +export function requireReleaseEncryptionKey( + envSecrets: EnvEntry[], + secretsWriteFile: string +): string { + const entry = envSecrets.find((e) => e.key === ENSEMBLE_ENCRYPTION_KEY_NAME); + const encryptionKey = typeof entry?.value === 'string' ? entry.value.trim() : ''; + if (!encryptionKey) { + throwReleaseKeyError('missing', secretsWriteFile); + } + try { + parse256BitSecret(encryptionKey, ENSEMBLE_ENCRYPTION_KEY_NAME); + } catch (err) { + if (err instanceof EncryptionError) { + throwReleaseKeyError('invalid', secretsWriteFile); + } + throw err; + } + return encryptionKey; +} + +function encryptAes256Gcm(plaintext: Buffer, key: Buffer): Omit { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + alg: 'AES-256-GCM', + iv: iv.toString('base64'), + tag: tag.toString('base64'), + ciphertext: encrypted.toString('base64'), + }; +} + +function decryptAes256Gcm(envelope: EncryptedEnvelope, key: Buffer): Buffer { + const iv = Buffer.from(envelope.iv, 'base64'); + const tag = Buffer.from(envelope.tag, 'base64'); + const ciphertext = Buffer.from(envelope.ciphertext, 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +export function isEncryptedReleaseEnvelope(text: string): boolean { + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object') return false; + const envelope = parsed as Partial; + return ( + envelope.v === 1 && + envelope.alg === 'AES-256-GCM' && + typeof envelope.iv === 'string' && + typeof envelope.tag === 'string' && + typeof envelope.ciphertext === 'string' + ); + } catch { + return false; + } +} + +export function encryptReleaseSnapshot(plaintextJson: string, encryptionKeyStr: string): string { + const key = parse256BitSecret(encryptionKeyStr, ENSEMBLE_ENCRYPTION_KEY_NAME); + const plaintextCompressed = brotliCompressSync(Buffer.from(plaintextJson, 'utf8'), { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, + }); + const encrypted = encryptAes256Gcm(plaintextCompressed, key); + const envelope: EncryptedEnvelope = { + v: 1, + comp: 'br', + ...encrypted, + }; + return JSON.stringify(envelope); +} + +export function parseReleaseSnapshotBody( + body: string, + snapshotPath: string, + encryptionKey: string, + secretsWriteFile = '.env.secrets' +): string { + if (!isEncryptedReleaseEnvelope(body)) { + if (snapshotPath.endsWith('.enc.json')) { + throw new EncryptionError('Invalid encrypted release envelope.'); + } + throw new EncryptionError( + 'This release is unencrypted legacy plaintext. Re-create it with `ensemble release create` after adding ENSEMBLE_ENCRYPTION_KEY.' + ); + } + + const parsed = JSON.parse(body) as EncryptedEnvelope; + const key = parse256BitSecret(encryptionKey, ENSEMBLE_ENCRYPTION_KEY_NAME); + try { + const decrypted = decryptAes256Gcm(parsed, key); + const decompressed = brotliDecompressSync(decrypted); + return decompressed.toString('utf8'); + } catch { + throwReleaseKeyError('wrong', secretsWriteFile); + } +} diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 09f5b3a..4c8b537 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -480,16 +480,22 @@ export async function prepareEnvPushState(params: { }; } -export async function applyReleaseConfigToFs( +export async function applyReleaseEnvToFs( projectRoot: string, config: ConfigDTO | undefined, + secrets: SecretDTO | undefined, appKey: string, defaultAppKey: string ): Promise { + const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); const configEntries = configDtoToEnvEntries(config); - if (configEntries.length === 0) return; - const { configWriteFile } = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); - await writeEnvFile(projectRoot, configWriteFile, configEntries); + if (configEntries.length > 0) { + await writeEnvFile(projectRoot, layout.configWriteFile, configEntries); + } + const secretEntries = secretsDtoToEnvEntries(secrets); + if (secretEntries.length > 0) { + await writeEnvFile(projectRoot, layout.secretsWriteFile, secretEntries); + } } export async function applyCloudEnvToFs( diff --git a/src/lib/spinner.ts b/src/lib/spinner.ts index 4cf08aa..bc57c6e 100644 --- a/src/lib/spinner.ts +++ b/src/lib/spinner.ts @@ -1,6 +1,11 @@ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; export async function withSpinner(message: string, fn: () => Promise): Promise { + const disabled = process.env.ENSEMBLE_NO_SPINNER === '1'; + if (disabled) { + return fn(); + } + let i = 0; const id = setInterval(() => { process.stdout.write(`\r ${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]} ${message} `); diff --git a/tests/cloud/firestoreClient.test.ts b/tests/cloud/firestoreClient.test.ts index 2a613f3..28ea3da 100644 --- a/tests/cloud/firestoreClient.test.ts +++ b/tests/cloud/firestoreClient.test.ts @@ -844,7 +844,7 @@ describe('createVersion', () => { createdAt: '2025-01-15T12:00:00Z', expiresAt: '2025-02-15T12:00:00Z', createdBy: { name: 'User', id: 'uid1' }, - snapshotPath: 'releases/app1/abc123.json', + snapshotPath: 'releases/app1/abc123.enc.json', }); expect(result.id).toBe('abc123'); diff --git a/tests/cloud/storageClient.test.ts b/tests/cloud/storageClient.test.ts index edf2181..4bf683b 100644 --- a/tests/cloud/storageClient.test.ts +++ b/tests/cloud/storageClient.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { downloadReleaseSnapshotJson, + objectPathForRelease, uploadReleaseSnapshot, } from '../../src/cloud/storageClient.js'; @@ -12,7 +13,11 @@ describe('storageClient', () => { globalThis.fetch = originalFetch; }); - it('uploadReleaseSnapshot posts to releases path with Firebase auth header', async () => { + it('objectPathForRelease uses encrypted .enc.json suffix', () => { + expect(objectPathForRelease('app1', 'ver-123')).toBe('releases/app1/ver-123.enc.json'); + }); + + it('uploadReleaseSnapshot posts envelope json to encrypted releases path', async () => { let captured: { url: string; method?: string; headers?: HeadersInit; body?: string } | null = null; @@ -32,28 +37,15 @@ describe('storageClient', () => { return new Response(JSON.stringify({}), { status: 200 }); }) as unknown as typeof fetch; - const result = await uploadReleaseSnapshot('app1', 'id-token', 'ver-123', '{"foo":"bar"}'); + const envelope = '{"v":1,"alg":"AES-256-GCM","comp":"br","iv":"a","tag":"b","ciphertext":"c"}'; + const result = await uploadReleaseSnapshot('app1', 'id-token', 'ver-123', envelope); - // Bucket is derived from env/project; we assert the path and headers, not the exact bucket. - expect(result.objectPath).toBe('releases/app1/ver-123.json'); - expect(captured).not.toBeNull(); - expect(captured!.url).toContain('https://firebasestorage.googleapis.com/v0/b/'); - expect(captured!.url).toContain('name=' + encodeURIComponent('releases/app1/ver-123.json')); + expect(result.objectPath).toBe('releases/app1/ver-123.enc.json'); expect(captured!.method).toBe('POST'); const headers = new Headers(captured!.headers); expect(headers.get('Authorization')).toBe('Firebase id-token'); expect(headers.get('Content-Type')).toBe('application/json'); - expect(captured!.body).toBe('{"foo":"bar"}'); - }); - - it('uploadReleaseSnapshot throws StorageClientError on non-2xx', async () => { - globalThis.fetch = (async () => { - return new Response('forbidden', { status: 403 }); - }) as unknown as typeof fetch; - - await expect( - uploadReleaseSnapshot('app1', 'id-token', 'ver-123', '{"foo":"bar"}') - ).rejects.toThrow('Storage upload release snapshot failed (403)'); + expect(captured!.body).toBe(envelope); }); it('downloadReleaseSnapshotJson GETs from releases path with Firebase auth header', async () => { @@ -67,28 +59,16 @@ describe('storageClient', () => { ? input.toString() : (input as Request).url; captured = { url: urlStr, headers: init?.headers }; - return new Response('{"id":"app1"}', { status: 200 }); + return new Response('{"v":1}', { status: 200 }); }) as unknown as typeof fetch; - const json = await downloadReleaseSnapshotJson('id-token', 'releases/app1/ver-123.json'); + const json = await downloadReleaseSnapshotJson('id-token', 'releases/app1/ver-123.enc.json'); - expect(json).toBe('{"id":"app1"}'); - expect(captured).not.toBeNull(); - expect(captured!.url).toContain('https://firebasestorage.googleapis.com/v0/b/'); + expect(json).toBe('{"v":1}'); expect(captured!.url).toContain( - encodeURIComponent('releases/app1/ver-123.json') + '?alt=media' + encodeURIComponent('releases/app1/ver-123.enc.json') + '?alt=media' ); const headers = new Headers(captured!.headers); expect(headers.get('Authorization')).toBe('Firebase id-token'); }); - - it('downloadReleaseSnapshotJson throws StorageClientError on non-2xx', async () => { - globalThis.fetch = (async () => { - return new Response('not found', { status: 404 }); - }) as unknown as typeof fetch; - - await expect( - downloadReleaseSnapshotJson('id-token', 'releases/app1/ver-123.json') - ).rejects.toThrow('Storage download release snapshot failed (404)'); - }); }); diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index d738f93..a637316 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -75,7 +75,8 @@ import { } from '../../src/commands/release.js'; import { resolveAppContext } from '../../src/config/projectConfig.js'; import type { CloudApp } from '../../src/cloud/firestoreClient.js'; -import { EnsembleDocumentType } from '../../src/core/dto.js'; +import { encryptReleaseSnapshot, parseReleaseSnapshotBody } from '../../src/core/encryption.js'; +import { TEST_ENCRYPTION_KEY } from '../core/encryption.test.js'; function defaultAppContext(requestedAppKey?: string) { const appKey = requestedAppKey ?? 'default'; @@ -101,11 +102,29 @@ async function writeEnvConfig(projectRoot: string, lines: string[]): Promise { + const secretsFile = alias ? `.env.secrets.${alias}` : '.env.secrets'; + await fs.writeFile( + path.join(root, secretsFile), + `ENSEMBLE_ENCRYPTION_KEY=${TEST_ENCRYPTION_KEY}\n`, + 'utf8' + ); +} + function snapshotFromUploadMock(): CloudApp { expect(uploadReleaseSnapshotMock).toHaveBeenCalledTimes(1); - const snapshotJson = uploadReleaseSnapshotMock.mock.calls[0]?.[3]; - expect(typeof snapshotJson).toBe('string'); - return JSON.parse(snapshotJson as string) as CloudApp; + const body = uploadReleaseSnapshotMock.mock.calls[0]?.[3]; + const envelopeJson = typeof body === 'string' ? body : (body as Buffer).toString('utf8'); + const snapshotJson = parseReleaseSnapshotBody( + envelopeJson, + 'releases/app1/ver.enc.json', + TEST_ENCRYPTION_KEY + ); + return JSON.parse(snapshotJson) as CloudApp; +} + +function mockEncryptedSnapshot(snapshot: CloudApp): string { + return encryptReleaseSnapshot(JSON.stringify(snapshot), TEST_ENCRYPTION_KEY); } describe('release commands', () => { @@ -120,13 +139,13 @@ describe('release commands', () => { defaultAppContext(requestedAppKey) ); - // Minimal app files for buildDocumentsFromParsed: appHome is "Home". await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); await fs.writeFile( path.join(projectRoot, 'screens', 'Home.yaml'), 'View:\n body:\n Text:\n text: Hello', 'utf8' ); + await writeEncryptionKey(projectRoot); checkAppAccessMock.mockResolvedValue({ ok: true as const, app: { name: 'App' } }); createVersionMock.mockResolvedValue({ id: 'ver-123' }); @@ -138,7 +157,7 @@ describe('release commands', () => { createdAt: '2025-01-15T12:00:00Z', createdBy: { name: 'User', id: 'uid1' }, expiresAt: '2025-02-15T12:00:00Z', - snapshotPath: 'releases/app1/hash-1.json', + snapshotPath: 'releases/app1/hash-1.enc.json', }, ], nextStartAfter: undefined, @@ -149,13 +168,15 @@ describe('release commands', () => { createdAt: '2025-01-15T12:00:00Z', createdBy: { name: 'User', id: 'uid1' }, expiresAt: '2025-02-15T12:00:00Z', - snapshotPath: 'releases/app1/hash-1.json', + snapshotPath: 'releases/app1/hash-1.enc.json', }); uploadReleaseSnapshotMock.mockResolvedValue({ bucket: 'bucket', - objectPath: 'releases/app1/ver-123.json', + objectPath: 'releases/app1/ver-123.enc.json', }); - downloadReleaseSnapshotJsonMock.mockResolvedValue('{"id":"app1","name":"App","screens":[]}'); + downloadReleaseSnapshotJsonMock.mockResolvedValue( + mockEncryptedSnapshot({ id: 'app1', name: 'App', screens: [] }) + ); promptsMock.mockResolvedValue({ message: 'My release' }); uiErrorMock.mockImplementation(() => {}); uiWarnMock.mockImplementation(() => {}); @@ -170,168 +191,71 @@ describe('release commands', () => { vi.clearAllMocks(); }); - it('release create stores env config in snapshot without secrets or asset publicUrl', async () => { + it('release create blocks when ENSEMBLE_ENCRYPTION_KEY is missing', async () => { + await fs.rm(path.join(projectRoot, '.env.secrets')); + + await releaseCreateCommand({ message: 'missing key', yes: true }); + + expect(uploadReleaseSnapshotMock).not.toHaveBeenCalled(); + expect(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining('Releases are encrypted')); + expect(uiNoteMock).toHaveBeenCalledWith(expect.stringContaining('openssl rand -hex 32')); + expect(process.exitCode).toBe(1); + }); + + it('release create stores env config and secrets in encrypted snapshot', async () => { const assetsDir = path.join(projectRoot, 'assets'); await fs.mkdir(assetsDir, { recursive: true }); await fs.writeFile(path.join(assetsDir, 'logo.png'), 'png-bytes', 'utf8'); - await fs.writeFile(path.join(assetsDir, 'Case1_Working.png'), 'png-bytes', 'utf8'); - await writeEnvConfig(projectRoot, [ - 'assets=https://cdn.example.com/base/', - 'logo_png=logo.png?token=abc', - 'E1=EV1', - ]); - await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=SK1\n', 'utf8'); + await writeEnvConfig(projectRoot, ['E1=EV1']); + await fs.appendFile(path.join(projectRoot, '.env.secrets'), 'S1=SK1\n', 'utf8'); await releaseCreateCommand({ message: 'env snapshot', yes: true }); - expect(uiSuccessMock).toHaveBeenCalledWith( - 'Release saved. Run "ensemble release use" to use it.' - ); const snapshot = snapshotFromUploadMock(); - expect(snapshot.config?.envVariables).toEqual({ - assets: 'https://cdn.example.com/base/', - logo_png: 'logo.png?token=abc', - E1: 'EV1', + expect(snapshot.config?.envVariables).toEqual({ E1: 'EV1' }); + expect(snapshot.secrets?.secrets).toMatchObject({ + ENSEMBLE_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, + S1: 'SK1', }); - expect(snapshot.secrets).toBeUndefined(); - expect(snapshot.config?.envVariables?.Case1_Working_png).toBeUndefined(); - for (const asset of snapshot.assets ?? []) { - expect(asset.publicUrl).toBeUndefined(); - expect(asset.copyText).toBeUndefined(); - } }); - it('release create hints alias-specific use command for non-default app', async () => { - vi.mocked(resolveAppContext).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app-dev', name: 'Dev App' }, - uat: { appId: 'app-uat', name: 'Uat App' }, - }, - }, - appKey: 'uat', - appId: 'app-uat', - }); - - await releaseCreateCommand({ appKey: 'uat', message: 'uat release', yes: true }); - - expect(uiSuccessMock).toHaveBeenCalledWith( - 'Release saved. Run "ensemble release use --app uat" to use it.' - ); - }); - - it('release create passes the same version id to storage upload and Firestore', async () => { - await releaseCreateCommand({ message: 'sync ids', yes: true }); - - const uploadVersionId = uploadReleaseSnapshotMock.mock.calls[0]?.[2]; - const createParams = createVersionMock.mock.calls[0]?.[2] as { id: string }; - expect(typeof uploadVersionId).toBe('string'); - expect(createParams.id).toBe(uploadVersionId); - }); - - it('release use restores config to scoped alias file for non-default app', async () => { - vi.mocked(resolveAppContext).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app-dev', name: 'Dev App' }, - uat: { appId: 'app-uat', name: 'Uat App' }, - }, - }, - appKey: 'uat', - appId: 'app-uat', - }); - getVersionMock.mockResolvedValue({ - id: 'hash-1', - message: 'Uat release', - createdAt: '2025-01-15T12:00:00Z', - createdBy: { name: 'User', id: 'uid1' }, - expiresAt: '2025-02-15T12:00:00Z', - snapshotPath: 'releases/app-uat/hash-1.json', - }); - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( - JSON.stringify({ - id: 'app-uat', - name: 'Uat App', - screens: [], - config: { envVariables: { E1: 'UAT-EV1' } }, - } satisfies CloudApp) - ); - await fs.writeFile(path.join(projectRoot, '.env.config'), 'E1=dev\n', 'utf8'); - await fs.writeFile(path.join(projectRoot, '.env.config.uat'), 'E1=old-uat\n', 'utf8'); - + it('release use blocks when ENSEMBLE_ENCRYPTION_KEY is missing', async () => { + await fs.rm(path.join(projectRoot, '.env.secrets')); Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - await releaseUseCommand({ appKey: 'uat', hash: 'hash-1' }); + await releaseUseCommand({ hash: 'hash-1' }); - const baseConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - const uatConfig = await fs.readFile(path.join(projectRoot, '.env.config.uat'), 'utf8'); - expect(baseConfig).toContain('E1=dev'); - expect(uatConfig).toContain('E1=UAT-EV1'); - expect(uatConfig).not.toContain('old-uat'); + expect(getVersionMock).not.toHaveBeenCalled(); + expect(downloadReleaseSnapshotJsonMock).not.toHaveBeenCalled(); + expect(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining('Releases are encrypted')); + expect(uiNoteMock).toHaveBeenCalledWith(expect.stringContaining('openssl rand -hex 32')); + expect(process.exitCode).toBe(1); }); - it('release use hints alias-specific push command for non-default app', async () => { - vi.mocked(resolveAppContext).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app-dev', name: 'Dev App' }, - uat: { appId: 'app-uat', name: 'Uat App' }, - }, - }, - appKey: 'uat', - appId: 'app-uat', - }); - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( - JSON.stringify({ - id: 'app-uat', - name: 'Uat App', - screens: [], - config: { envVariables: { E1: 'UAT-EV1' } }, - } satisfies CloudApp) - ); - - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + it('release list blocks when ENSEMBLE_ENCRYPTION_KEY is missing', async () => { + await fs.rm(path.join(projectRoot, '.env.secrets')); - await releaseUseCommand({ appKey: 'uat', hash: 'hash-1' }); + await releaseListCommand({}); - expect(uiSuccessMock).toHaveBeenCalledWith( - 'Local files updated to selected release. Run "ensemble push --app uat" to apply to the cloud.' - ); + expect(listVersionsMock).not.toHaveBeenCalled(); + expect(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining('Releases are encrypted')); + expect(uiNoteMock).toHaveBeenCalledWith(expect.stringContaining('openssl rand -hex 32')); + expect(process.exitCode).toBe(1); }); - it('release use restores snapshot config and never touches secrets', async () => { + it('release use restores snapshot config and secrets', async () => { downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( - JSON.stringify({ + mockEncryptedSnapshot({ id: 'app1', name: 'App', screens: [], - assets: [ - { - id: 'asset:Case1_Working.png', - name: 'Case1_Working.png', - fileName: 'Case1_Working.png', - content: '', - type: EnsembleDocumentType.Asset, - }, - ], - config: { envVariables: { assets: 'https://cdn.example.com/base/', E1: 'EV1' } }, + config: { envVariables: { E1: 'EV1' } }, secrets: { secrets: { S1: 'SNAPSHOT-SECRET' } }, - } satisfies CloudApp) + }) ); - await writeEnvConfig(projectRoot, [ - 'assets=https://cdn.example.com/old/', - 'Case1_Working_png=Case1_Working.png?token=old', - 'E1=EV-WRONG', - ]); - await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=LOCAL-SECRET\n', 'utf8'); + await writeEnvConfig(projectRoot, ['E1=EV-WRONG']); + await fs.appendFile(path.join(projectRoot, '.env.secrets'), 'S1=LOCAL-SECRET\n', 'utf8'); Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); @@ -340,57 +264,53 @@ describe('release commands', () => { const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/base/'); expect(envConfig).toContain('E1=EV1'); - expect(envConfig).not.toContain('Case1_Working_png='); - expect(envSecrets).toContain('S1=LOCAL-SECRET'); - expect(envSecrets).not.toContain('SNAPSHOT-SECRET'); + expect(envSecrets).toContain('S1=SNAPSHOT-SECRET'); + expect(envSecrets).not.toContain('LOCAL-SECRET'); }); - it('release list prints heading and lines when versions exist', async () => { - await releaseListCommand({}); - - expect(checkAppAccessMock).toHaveBeenCalledTimes(1); - expect(listVersionsMock).toHaveBeenCalledTimes(1); - expect(uiWarnMock).not.toHaveBeenCalled(); - }); - - it('release list warns when no versions exist', async () => { - listVersionsMock.mockResolvedValueOnce({ versions: [], nextStartAfter: undefined }); - - await releaseListCommand({}); - - expect(uiWarnMock).toHaveBeenCalledWith( - 'No releases found. Create one with "ensemble release create".' + it('release use rejects legacy plain json snapshots', async () => { + getVersionMock.mockResolvedValueOnce({ + id: 'legacy-1', + message: 'Legacy', + createdAt: '2025-01-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + expiresAt: '2025-02-15T12:00:00Z', + snapshotPath: 'releases/app1/legacy-1.json', + }); + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + JSON.stringify({ id: 'app1', name: 'App', screens: [] }) ); - }); - it('release use interactive picker omits hash from release labels', async () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); - promptsMock.mockResolvedValueOnce({ selected: 0 }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - await releaseUseCommand({}); + await releaseUseCommand({ hash: 'legacy-1' }); - const promptArgs = promptsMock.mock.calls[0]?.[0] as { - choices: { title: string; value: number | string }[]; - }; - expect(promptArgs.choices[0]?.title).toContain('First release'); - expect(promptArgs.choices[0]?.title).not.toContain('[hash:'); + expect(uiErrorMock).toHaveBeenCalledWith( + expect.stringContaining('unencrypted legacy plaintext') + ); + expect(process.exitCode).toBe(1); }); - it('release use --hash uses non-interactive path', async () => { - // Make non-interactive by clearing TTY flags; hash should still work. + it('release use --hash downloads from storage directly', async () => { Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); await releaseUseCommand({ hash: 'hash-1' }); - expect(getVersionMock).toHaveBeenCalledWith('app1', 'token', 'hash-1', undefined); expect(downloadReleaseSnapshotJsonMock).toHaveBeenCalledWith( 'token', - 'releases/app1/hash-1.json' + 'releases/app1/hash-1.enc.json' ); expect(uiErrorMock).not.toHaveBeenCalled(); }); + + it('release list warns when no versions exist', async () => { + listVersionsMock.mockResolvedValueOnce({ versions: [], nextStartAfter: undefined }); + await releaseListCommand({}); + expect(uiWarnMock).toHaveBeenCalledWith( + 'No releases found. Create one with "ensemble release create".' + ); + }); }); diff --git a/tests/core/encryption.test.ts b/tests/core/encryption.test.ts new file mode 100644 index 0000000..1c15343 --- /dev/null +++ b/tests/core/encryption.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; + +import { + encryptReleaseSnapshot, + EncryptionError, + isEncryptedReleaseEnvelope, + parse256BitSecret, + parseReleaseSnapshotBody, + requireReleaseEncryptionKey, +} from '../../src/core/encryption.js'; +import type { EnvEntry } from '../../src/core/envConfig.js'; + +export const TEST_ENCRYPTION_KEY = 'a'.repeat(64); +const TEST_SNAPSHOT_PATH = 'releases/app1/ver.enc.json'; + +describe('encryption', () => { + it('parse256BitSecret accepts 64-char hex', () => { + const key = parse256BitSecret(TEST_ENCRYPTION_KEY, 'ENSEMBLE_ENCRYPTION_KEY'); + expect(key).toHaveLength(32); + }); + + it('encryptReleaseSnapshot roundtrips plaintext json', () => { + const plaintext = JSON.stringify({ id: 'app1', name: 'App' }); + const envelope = encryptReleaseSnapshot(plaintext, TEST_ENCRYPTION_KEY); + expect(isEncryptedReleaseEnvelope(envelope)).toBe(true); + expect(parseReleaseSnapshotBody(envelope, TEST_SNAPSHOT_PATH, TEST_ENCRYPTION_KEY)).toBe( + plaintext + ); + }); + + it('requireReleaseEncryptionKey returns key when present', () => { + const entries: EnvEntry[] = [{ key: 'ENSEMBLE_ENCRYPTION_KEY', value: TEST_ENCRYPTION_KEY }]; + expect(requireReleaseEncryptionKey(entries, '.env.secrets')).toBe(TEST_ENCRYPTION_KEY); + }); + + it('requireReleaseEncryptionKey throws with secrets file hint when key missing', () => { + expect(() => requireReleaseEncryptionKey([], '.env.secrets.uat')).toThrow(EncryptionError); + try { + requireReleaseEncryptionKey([], '.env.secrets.uat'); + } catch (err) { + expect(err).toBeInstanceOf(EncryptionError); + const encErr = err as EncryptionError; + expect(encErr.message).toContain('.env.secrets.uat'); + expect(encErr.message).toContain('Releases are encrypted'); + expect(encErr.hint).toContain('openssl rand -hex 32'); + } + }); + + it('requireReleaseEncryptionKey throws when key format is invalid', () => { + const entries: EnvEntry[] = [{ key: 'ENSEMBLE_ENCRYPTION_KEY', value: 'tooshort' }]; + expect(() => requireReleaseEncryptionKey(entries, '.env.secrets')).toThrow(EncryptionError); + try { + requireReleaseEncryptionKey(entries, '.env.secrets'); + } catch (err) { + const encErr = err as EncryptionError; + expect(encErr.message).toContain('Invalid ENSEMBLE_ENCRYPTION_KEY in .env.secrets'); + expect(encErr.hint).toContain('openssl rand -hex 32'); + } + }); + + it('parseReleaseSnapshotBody throws when encryption key does not match', () => { + const plaintext = JSON.stringify({ id: 'app1' }); + const envelope = encryptReleaseSnapshot(plaintext, TEST_ENCRYPTION_KEY); + const wrongKey = `${TEST_ENCRYPTION_KEY.slice(0, -1)}c`; + expect(() => + parseReleaseSnapshotBody(envelope, TEST_SNAPSHOT_PATH, wrongKey, '.env.secrets') + ).toThrow(EncryptionError); + try { + parseReleaseSnapshotBody(envelope, TEST_SNAPSHOT_PATH, wrongKey, '.env.secrets'); + } catch (err) { + const encErr = err as EncryptionError; + expect(encErr.message).toContain('Could not decrypt this release'); + expect(encErr.message).toContain('.env.secrets'); + expect(encErr.hint).toContain('Get the correct key from your team'); + } + }); + + it('parseReleaseSnapshotBody rejects legacy plain json', () => { + expect(() => + parseReleaseSnapshotBody('{"id":"app1"}', 'releases/app1/ver.json', TEST_ENCRYPTION_KEY) + ).toThrow('unencrypted legacy plaintext'); + }); +}); diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 97dd683..095425b 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { writeEnvFile, type EnvEntry } from '../../src/core/envConfig.js'; import { applyCloudEnvToFs, - applyReleaseConfigToFs, + applyReleaseEnvToFs, buildEnvPushDiff, buildPushConfigDto, computeEnvPullChanges, @@ -480,8 +480,8 @@ describe('envSync', () => { expect(envConfig).not.toContain('del_png='); }); - it('applyReleaseConfigToFs restores full snapshot config', async () => { - await applyReleaseConfigToFs( + it('applyReleaseEnvToFs restores full snapshot config', async () => { + await applyReleaseEnvToFs( tmpDir, { envVariables: { @@ -490,6 +490,7 @@ describe('envSync', () => { E1: 'EV1', }, }, + undefined, 'default', 'default' ); diff --git a/tests/lib/spinner.test.ts b/tests/lib/spinner.test.ts index 0cbfe26..5e9187f 100644 --- a/tests/lib/spinner.test.ts +++ b/tests/lib/spinner.test.ts @@ -42,4 +42,20 @@ describe('withSpinner', () => { const calls = writeSpy.mock.calls.map((c) => c[0]); expect(calls.some((s) => typeof s === 'string' && s.includes('✗'))).toBe(true); }); + + it('skips spinner output when ENSEMBLE_NO_SPINNER is set', async () => { + const original = process.env.ENSEMBLE_NO_SPINNER; + process.env.ENSEMBLE_NO_SPINNER = '1'; + writeSpy.mockClear(); + + const result = await withSpinner('Quiet', async () => 'ok'); + + expect(result).toBe('ok'); + expect(writeSpy).not.toHaveBeenCalled(); + if (original === undefined) { + delete process.env.ENSEMBLE_NO_SPINNER; + } else { + process.env.ENSEMBLE_NO_SPINNER = original; + } + }); }); From 047ba3faf744908266577e7fb9c0f77bd335d527 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Wed, 24 Jun 2026 15:49:17 +0500 Subject: [PATCH 2/3] fix: snapshot-faithful manifest on use - Release create now orders snapshot lists from .manifest.json so filesystem/readdir order cannot scramble widgets or langs. - Release use writes manifest literally via manifestFromSnapshot instead of merging stale local state. - Env files upsert on use to preserve key order. --- src/commands/pull.ts | 2 +- src/commands/push.ts | 2 +- src/commands/release.ts | 53 ++++++++--- src/core/applyToFs.ts | 12 +-- src/core/envSync.ts | 4 +- src/core/manifest.ts | 131 ++++++++++++++++++++------ tests/commands/release.test.ts | 165 +++++++++++++++++++++++++++++++++ tests/core/applyToFs.test.ts | 4 +- tests/core/envSync.test.ts | 27 ++++++ tests/core/manifest.test.ts | 82 +++++++++++++++- 10 files changed, 428 insertions(+), 54 deletions(-) diff --git a/src/commands/pull.ts b/src/commands/pull.ts index b17cfe9..49cd0ae 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -285,7 +285,7 @@ export async function pullCommand(options: PullOptions = {}): Promise { await withSpinner('Writing local files...', async () => { await applyCloudStateToFs(projectRoot, cloudApp, localFiles, enabledByProp, { - manifestOptions: {}, + refreshManifest: true, onProgress: (completed, total) => { // eslint-disable-next-line no-console console.log(`Writing files... (${completed}/${total})`); diff --git a/src/commands/push.ts b/src/commands/push.ts index 52888cd..a0e6387 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -540,7 +540,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { // Only refresh manifest when artifact changes can affect its contents. try { await withSpinner('Refreshing local manifest...', async () => { - await buildAndWriteManifest(root, bundle as CloudApp, {}); + await buildAndWriteManifest(root, bundle as CloudApp); }); } catch (manifestErr) { if (verbose) { diff --git a/src/commands/release.ts b/src/commands/release.ts index da42f30..895a4c1 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -32,6 +32,11 @@ import { requireReleaseEncryptionKey, } from '../core/encryption.js'; import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; +import { + orderByManifestNames, + readProjectManifest, + writeManifestFromSnapshot, +} from '../core/manifest.js'; import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; import { collectAppFiles } from '../core/appCollector.js'; import { resolveAppContext } from '../config/projectConfig.js'; @@ -169,21 +174,47 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): const appName = (appConfig.name as string | undefined) ?? 'App'; const appHome = appConfig.appHome as string | undefined; const localFiles = await collectAppFiles(root); + const manifest = await readProjectManifest(root); + const defaultLanguage = + typeof manifest.defaultLanguage === 'string' && manifest.defaultLanguage.trim() !== '' + ? manifest.defaultLanguage.trim() + : undefined; const { key: encryptionKey, localEnv } = keyCtx; const localConfig = buildConfigDtoFromEnvEntries(localEnv.envConfig); const localSecrets = buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets); - const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, undefined); + const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, defaultLanguage); + const orderedWidgets = localApp.widgets?.length + ? orderByManifestNames( + localApp.widgets, + manifest.widgets?.map((w) => w.name) + ) + : localApp.widgets; + const orderedScripts = localApp.scripts?.length + ? orderByManifestNames( + localApp.scripts, + manifest.scripts?.map((s) => s.name) + ) + : localApp.scripts; + const orderedActions = localApp.actions?.length + ? orderByManifestNames( + localApp.actions, + manifest.actions?.map((a) => a.name) + ) + : localApp.actions; + const orderedTranslations = localApp.translations?.length + ? orderByManifestNames(localApp.translations, manifest.languages) + : localApp.translations; const snapshot: CloudApp = { id: localApp.id, name: localApp.name, createdAt: localApp.createdAt, updatedAt: localApp.updatedAt, ...(localApp.screens && localApp.screens.length > 0 && { screens: localApp.screens }), - ...(localApp.widgets && localApp.widgets.length > 0 && { widgets: localApp.widgets }), - ...(localApp.scripts && localApp.scripts.length > 0 && { scripts: localApp.scripts }), - ...(localApp.actions && localApp.actions.length > 0 && { actions: localApp.actions }), - ...(localApp.translations && - localApp.translations.length > 0 && { translations: localApp.translations }), + ...(orderedWidgets && orderedWidgets.length > 0 && { widgets: orderedWidgets }), + ...(orderedScripts && orderedScripts.length > 0 && { scripts: orderedScripts }), + ...(orderedActions && orderedActions.length > 0 && { actions: orderedActions }), + ...(orderedTranslations && + orderedTranslations.length > 0 && { translations: orderedTranslations }), ...(localApp.theme && { theme: localApp.theme }), ...(localApp.assets && localApp.assets.length > 0 && { assets: localApp.assets }), ...(localConfig && { config: localConfig }), @@ -462,17 +493,17 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis const snapshot = JSON.parse(snapshotJson) as CloudApp; const localFiles = await collectAppFiles(projectRoot); - await withSpinner('Writing local files...', () => - applyCloudStateToFs(projectRoot, snapshot, localFiles, enabledByProp, { - manifestOptions: {}, + await withSpinner('Writing local files...', async () => { + await applyCloudStateToFs(projectRoot, snapshot, localFiles, enabledByProp, { onProgress: (completed, total) => { if (total > 0 && completed % 25 === 0) { // eslint-disable-next-line no-console console.log(`Writing files... (${completed}/${total})`); } }, - }) - ); + }); + await writeManifestFromSnapshot(projectRoot, snapshot); + }); await applyReleaseEnvToFs( projectRoot, snapshot.config, diff --git a/src/core/applyToFs.ts b/src/core/applyToFs.ts index 8c29722..d9a5538 100644 --- a/src/core/applyToFs.ts +++ b/src/core/applyToFs.ts @@ -12,15 +12,15 @@ import { ARTIFACT_FS_CONFIG } from './artifacts.js'; import type { ArtifactProp } from './artifacts.js'; import { processWithConcurrency } from './concurrency.js'; import { safeFileName } from './fileNames.js'; -import { buildAndWriteManifest, type BuildManifestOptions } from './manifest.js'; +import { buildAndWriteManifest } from './manifest.js'; async function ensureDir(dir: string): Promise { await fs.mkdir(dir, { recursive: true }); } export interface ApplyCloudStateToFsOptions { - /** When set, manifest is built and written after applying files. */ - manifestOptions?: BuildManifestOptions; + /** When true, merge cloud lists into existing .manifest.json (pull). */ + refreshManifest?: boolean; /** Called every 25 completed tasks with (completed, total). */ onProgress?: (completed: number, total: number) => void; } @@ -40,7 +40,7 @@ export async function applyCloudStateToFs( enabledByProp: Record, options: ApplyCloudStateToFsOptions = {} ): Promise { - const { manifestOptions, onProgress } = options; + const { refreshManifest, onProgress } = options; const tasks: WriteTask[] = []; @@ -114,7 +114,7 @@ export async function applyCloudStateToFs( } }); - if (manifestOptions !== undefined) { - await buildAndWriteManifest(projectRoot, cloudApp, manifestOptions); + if (refreshManifest) { + await buildAndWriteManifest(projectRoot, cloudApp); } } diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 4c8b537..adcaf2c 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -490,11 +490,11 @@ export async function applyReleaseEnvToFs( const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); const configEntries = configDtoToEnvEntries(config); if (configEntries.length > 0) { - await writeEnvFile(projectRoot, layout.configWriteFile, configEntries); + await upsertEnvFile(projectRoot, layout.configWriteFile, configEntries); } const secretEntries = secretsDtoToEnvEntries(secrets); if (secretEntries.length > 0) { - await writeEnvFile(projectRoot, layout.secretsWriteFile, secretEntries); + await upsertEnvFile(projectRoot, layout.secretsWriteFile, secretEntries); } } diff --git a/src/core/manifest.ts b/src/core/manifest.ts index 06b8155..6c04a62 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -11,8 +11,6 @@ export type RootManifest = Record & { languages?: string[]; }; -export type BuildManifestOptions = Record; - /** Preserve existing manifest entries by name and order; only add minimal { name } for new ones. */ function mergeByName( existing: T[] | undefined, @@ -33,51 +31,111 @@ function mergeByName( return [...keptExisting, ...appended]; } -export function buildManifestObject( - existing: RootManifest, - cloudApp: CloudApp, - options: BuildManifestOptions = {} -): RootManifest { - void options; +function mergeLanguageNames(existing: string[] | undefined, cloudNames: string[]): string[] { + return mergeByName( + (existing ?? []).map((name) => ({ name })), + cloudNames + ).map((entry) => entry.name); +} + +/** Build manifest literally from a release snapshot (pull/push merge rules do not apply). */ +export function manifestFromSnapshot(cloudApp: CloudApp): RootManifest { + const widgets = (cloudApp.widgets ?? []) + .filter((w) => w.isArchived !== true) + .map((w) => ({ name: w.name })); + const scripts = (cloudApp.scripts ?? []) + .filter((s) => s.isArchived !== true) + .map((s) => ({ name: s.name })); + const actions = (cloudApp.actions ?? []) + .filter((a) => a.isArchived !== true) + .map((a) => ({ name: a.name })); + + const translations = (cloudApp.translations ?? []).filter((t) => t.isArchived !== true); + const languages = translations.map((t) => t.name); + const defaultLanguage = translations.find((t) => t.defaultLocale === true)?.name ?? languages[0]; + + const manifest: RootManifest = {}; + if (widgets.length > 0) manifest.widgets = widgets; + if (scripts.length > 0) manifest.scripts = scripts; + if (actions.length > 0) manifest.actions = actions; + if (languages.length > 0) { + manifest.languages = languages; + if (defaultLanguage) manifest.defaultLanguage = defaultLanguage; + } + return manifest; +} + +export async function writeManifestFromSnapshot( + projectRoot: string, + cloudApp: CloudApp +): Promise { + const manifest = manifestFromSnapshot(cloudApp); + await fs.writeFile( + path.join(projectRoot, '.manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8' + ); +} +/** Merge cloud lists into an existing manifest (pull/push). */ +export function buildManifestObject(existing: RootManifest, cloudApp: CloudApp): RootManifest { const cloudWidgetNames = (cloudApp.widgets ?? []) .filter((w) => w.isArchived !== true) .map((w) => w.name); - const widgets = mergeByName(existing.widgets, cloudWidgetNames); - const cloudScriptNames = (cloudApp.scripts ?? []) .filter((s) => s.isArchived !== true) .map((s) => s.name); - const scripts = mergeByName(existing.scripts, cloudScriptNames); - const cloudActionNames = (cloudApp.actions ?? []) .filter((a) => a.isArchived !== true) .map((a) => a.name); - const actions = mergeByName(existing.actions, cloudActionNames); const translations = (cloudApp.translations ?? []).filter((t) => t.isArchived !== true); const languages = translations.map((t) => t.name); - const defaultLanguage = - translations.find((t) => t.defaultLocale === true)?.name ?? - (typeof existing.defaultLanguage === 'string' ? existing.defaultLanguage : undefined) ?? - languages[0]; + const cloudDefault = translations.find((t) => t.defaultLocale === true)?.name; + + const widgets = mergeByName(existing.widgets, cloudWidgetNames); + const scripts = mergeByName(existing.scripts, cloudScriptNames); + const actions = mergeByName(existing.actions, cloudActionNames); + const mergedLanguages = mergeLanguageNames(existing.languages, languages); + + const existingDefault = + typeof existing.defaultLanguage === 'string' ? existing.defaultLanguage : undefined; + const mergedDefaultLanguage = + cloudDefault ?? + (existingDefault && mergedLanguages.includes(existingDefault) ? existingDefault : undefined) ?? + mergedLanguages[0]; const merged: RootManifest = { ...existing, - widgets, - scripts, - actions, - ...(languages.length > 0 ? { languages } : {}), - ...(defaultLanguage ? { defaultLanguage } : {}), + languages: mergedLanguages, }; + for (const [key, value] of [ + ['widgets', widgets], + ['scripts', scripts], + ['actions', actions], + ] as const) { + if (value.length > 0) { + merged[key] = value; + } else if (key in existing) { + merged[key] = value; + } else { + delete merged[key]; + } + } + + if (mergedLanguages.length > 0 && mergedDefaultLanguage) { + merged.defaultLanguage = mergedDefaultLanguage; + } else { + delete merged.defaultLanguage; + } + return merged; } export async function buildAndWriteManifest( projectRoot: string, - cloudApp: CloudApp, - options: BuildManifestOptions = {} + cloudApp: CloudApp ): Promise { const manifestPath = path.join(projectRoot, '.manifest.json'); let existing: RootManifest = {}; @@ -88,8 +146,27 @@ export async function buildAndWriteManifest( existing = {}; } - const merged = buildManifestObject(existing, cloudApp, options); - await fs.writeFile(manifestPath, JSON.stringify(merged, null, 2) + '\n', 'utf8'); + const merged = buildManifestObject(existing, cloudApp); + await fs.writeFile(manifestPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8'); +} + +export async function readProjectManifest(projectRoot: string): Promise { + return readRootManifest(path.join(projectRoot, '.manifest.json')); +} + +export function orderByManifestNames( + items: T[], + manifestNames: string[] | undefined +): T[] { + if (!manifestNames?.length) { + return items; + } + const order = new Map(manifestNames.map((name, index) => [name, index])); + return [...items].sort((a, b) => { + const ai = order.get(a.name) ?? Number.MAX_SAFE_INTEGER; + const bi = order.get(b.name) ?? Number.MAX_SAFE_INTEGER; + return ai - bi || a.name.localeCompare(b.name); + }); } async function readRootManifest(manifestPath: string): Promise { @@ -102,7 +179,7 @@ async function readRootManifest(manifestPath: string): Promise { } async function writeRootManifest(manifestPath: string, manifest: RootManifest): Promise { - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); } export async function upsertManifestEntry( diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index a637316..192366b 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -75,6 +75,7 @@ import { } from '../../src/commands/release.js'; import { resolveAppContext } from '../../src/config/projectConfig.js'; import type { CloudApp } from '../../src/cloud/firestoreClient.js'; +import { EnsembleDocumentType } from '../../src/core/dto.js'; import { encryptReleaseSnapshot, parseReleaseSnapshotBody } from '../../src/core/encryption.js'; import { TEST_ENCRYPTION_KEY } from '../core/encryption.test.js'; @@ -219,6 +220,135 @@ describe('release commands', () => { }); }); + it('release create stores manifest list order in snapshot', async () => { + await fs.mkdir(path.join(projectRoot, 'widgets'), { recursive: true }); + await fs.mkdir(path.join(projectRoot, 'translations'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid2.yaml'), 'View:\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid1.yaml'), 'View:\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'k: v\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'ar.yaml'), 'k: v\n', 'utf8'); + await fs.writeFile( + path.join(projectRoot, '.manifest.json'), + `${JSON.stringify( + { + widgets: [{ name: 'Wid1' }, { name: 'Wid2' }], + languages: ['ar', 'en'], + defaultLanguage: 'ar', + }, + null, + 2 + )}\n`, + 'utf8' + ); + + await releaseCreateCommand({ message: 'manifest order', yes: true }); + + const snapshot = snapshotFromUploadMock(); + expect(snapshot.widgets?.map((widget) => widget.name)).toEqual(['Wid1', 'Wid2']); + expect(snapshot.translations?.map((t) => t.name)).toEqual(['ar', 'en']); + expect(snapshot.translations?.find((t) => t.defaultLocale)?.name).toBe('ar'); + }); + + it('release create then use leaves manifest unchanged', async () => { + await fs.mkdir(path.join(projectRoot, 'widgets'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid2.yaml'), 'View:\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid1.yaml'), 'View:\n', 'utf8'); + const manifestBefore = { widgets: [{ name: 'Wid1' }, { name: 'Wid2' }] }; + const manifestRaw = `${JSON.stringify(manifestBefore, null, 2)}\n`; + await fs.writeFile(path.join(projectRoot, '.manifest.json'), manifestRaw, 'utf8'); + + await releaseCreateCommand({ message: 'roundtrip', yes: true }); + const snapshot = snapshotFromUploadMock(); + + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + encryptReleaseSnapshot(JSON.stringify(snapshot), TEST_ENCRYPTION_KEY) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const manifestAfter = await fs.readFile(path.join(projectRoot, '.manifest.json'), 'utf8'); + expect(manifestAfter).toBe(manifestRaw); + }); + + it('release use restores latest manifest after visiting older release', async () => { + const olderSnapshot = mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + translations: [ + { + id: 't-en', + name: 'en', + content: 'hello: hello', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + { + id: 't-ar', + name: 'ar', + content: 'hello: marhaba', + type: EnsembleDocumentType.I18n, + }, + ], + }); + const latestSnapshot = mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + translations: [ + { + id: 't-en', + name: 'en', + content: 'hello: hello', + type: EnsembleDocumentType.I18n, + }, + { + id: 't-de', + name: 'de', + content: 'hello: hallo', + type: EnsembleDocumentType.I18n, + }, + { + id: 't-ar', + name: 'ar', + content: 'hello: marhaba', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + ], + }); + + await fs.mkdir(path.join(projectRoot, 'translations'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'hello: hello\n', 'utf8'); + await fs.writeFile( + path.join(projectRoot, 'translations', 'ar.yaml'), + 'hello: marhaba\n', + 'utf8' + ); + await fs.writeFile(path.join(projectRoot, 'translations', 'de.yaml'), 'hello: hallo\n', 'utf8'); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce(olderSnapshot); + await releaseUseCommand({ hash: 'hash-old' }); + + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce(latestSnapshot); + await releaseUseCommand({ hash: 'hash-latest' }); + + const manifestAfter = JSON.parse( + await fs.readFile(path.join(projectRoot, '.manifest.json'), 'utf8') + ) as { languages: string[]; defaultLanguage: string }; + expect(manifestAfter.languages).toEqual(['en', 'de', 'ar']); + expect(manifestAfter.defaultLanguage).toBe('ar'); + await expect( + fs.access(path.join(projectRoot, 'translations', 'de.yaml')) + ).resolves.toBeUndefined(); + }); + it('release use blocks when ENSEMBLE_ENCRYPTION_KEY is missing', async () => { await fs.rm(path.join(projectRoot, '.env.secrets')); Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); @@ -269,6 +399,41 @@ describe('release commands', () => { expect(envSecrets).not.toContain('LOCAL-SECRET'); }); + it('release use preserves .env.config key order', async () => { + await fs.writeFile( + path.join(projectRoot, '.env.config'), + 'assets=https://old/\nkwnd_png=old.png\nE1=old\n', + 'utf8' + ); + + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + config: { + envVariables: { + E1: 'EV1', + assets: 'https://new/', + kwnd_png: 'new.png', + }, + }, + }) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const lines = (await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8')) + .trim() + .split('\n'); + expect(lines[0]).toMatch(/^assets=https:\/\/new\//); + expect(lines[1]).toMatch(/^kwnd_png=new\.png$/); + expect(lines[2]).toMatch(/^E1=EV1$/); + }); + it('release use rejects legacy plain json snapshots', async () => { getVersionMock.mockResolvedValueOnce({ id: 'legacy-1', diff --git a/tests/core/applyToFs.test.ts b/tests/core/applyToFs.test.ts index d05826b..c9ac3e7 100644 --- a/tests/core/applyToFs.test.ts +++ b/tests/core/applyToFs.test.ts @@ -162,7 +162,7 @@ describe('applyCloudStateToFs', () => { await expect(fs.access(path.join(projectRoot, 'screens', 'Home.yaml'))).rejects.toThrow(); }); - it('writes .manifest.json when manifestOptions provided', async () => { + it('writes .manifest.json when refreshManifest is true', async () => { const cloudApp: CloudApp = { id: 'app1', name: 'App', @@ -182,7 +182,7 @@ describe('applyCloudStateToFs', () => { }; await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled, { - manifestOptions: {}, + refreshManifest: true, }); const manifestPath = path.join(projectRoot, '.manifest.json'); diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 095425b..3539cc2 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -501,6 +501,33 @@ describe('envSync', () => { expect(envConfig).toContain('E1=EV1'); }); + it('applyReleaseEnvToFs preserves env file key order', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'assets=https://old/\nkwnd_png=old.png\nE1=old\n', + 'utf8' + ); + + await applyReleaseEnvToFs( + tmpDir, + { + envVariables: { + E1: 'EV1', + assets: 'https://new/', + kwnd_png: 'new.png', + }, + }, + undefined, + 'default', + 'default' + ); + + const lines = (await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8')).trim().split('\n'); + expect(lines[0]).toMatch(/^assets=https:\/\/new\//); + expect(lines[1]).toMatch(/^kwnd_png=new\.png$/); + expect(lines[2]).toMatch(/^E1=EV1$/); + }); + it('readProjectEnvFiles uses scoped pair when both alias files exist', async () => { await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\nE2=shared\n', 'utf8'); await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); diff --git a/tests/core/manifest.test.ts b/tests/core/manifest.test.ts index 5b2b93d..b8a0acc 100644 --- a/tests/core/manifest.test.ts +++ b/tests/core/manifest.test.ts @@ -1,13 +1,71 @@ import { describe, it, expect } from 'vitest'; -import { buildManifestObject, type RootManifest } from '../../src/core/manifest.js'; +import { + buildManifestObject, + manifestFromSnapshot, + orderByManifestNames, + type RootManifest, +} from '../../src/core/manifest.js'; import type { CloudApp } from '../../src/cloud/firestoreClient.js'; import { EnsembleDocumentType } from '../../src/core/dto.js'; -describe('buildManifestObject manifest lists', () => { - it('preserves existing scripts order and only adds new ones', () => { +describe('manifest', () => { + it('orderByManifestNames sorts items to match manifest list order', () => { + const items = [ + { name: 'Wid2', content: '' }, + { name: 'Wid1', content: '' }, + ]; + + const ordered = orderByManifestNames(items, ['Wid1', 'Wid2']); + + expect(ordered.map((item) => item.name)).toEqual(['Wid1', 'Wid2']); + }); + + it('manifestFromSnapshot uses snapshot list order and defaultLocale', () => { + const cloud: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [ + { id: 'w2', name: 'Wid2', content: '', type: EnsembleDocumentType.Widget }, + { id: 'w1', name: 'Wid1', content: '', type: EnsembleDocumentType.Widget }, + ], + scripts: [], + translations: [ + { + id: 't-en', + name: 'en', + content: '', + type: EnsembleDocumentType.I18n, + }, + { + id: 't-de', + name: 'de', + content: '', + type: EnsembleDocumentType.I18n, + }, + { + id: 't-ar', + name: 'ar', + content: '', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + ], + }; + + const manifest = manifestFromSnapshot(cloud); + + expect(manifest.widgets?.map((w) => w.name)).toEqual(['Wid2', 'Wid1']); + expect(manifest.languages).toEqual(['en', 'de', 'ar']); + expect(manifest.defaultLanguage).toBe('ar'); + }); + + it('buildManifestObject preserves existing list order on pull', () => { const existing: RootManifest = { widgets: [], scripts: [{ name: 'S1' }, { name: 'S2' }], + languages: ['ar', 'en', 'bn'], + defaultLanguage: 'ar', }; const cloud: CloudApp = { id: 'app1', @@ -19,11 +77,27 @@ describe('buildManifestObject manifest lists', () => { { id: 's1', name: 'S1', content: '', type: EnsembleDocumentType.Script }, { id: 's3', name: 'S3', content: '', type: EnsembleDocumentType.Script }, ], - translations: [], + translations: [ + { + id: 't-en', + name: 'en', + content: '', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + { + id: 't-ar', + name: 'ar', + content: '', + type: EnsembleDocumentType.I18n, + }, + ], }; const merged = buildManifestObject(existing, cloud); expect(merged.scripts?.map((s) => s.name)).toEqual(['S1', 'S2', 'S3']); + expect(merged.languages).toEqual(['ar', 'en']); + expect(merged.defaultLanguage).toBe('en'); }); }); From 9329323a5cb27a851471864412a7ab99f022f6d2 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Fri, 26 Jun 2026 19:02:39 +0500 Subject: [PATCH 3/3] fix(release): canonical env restore and manifest merge - Unify pull and release use on asset-then-config env layout - Prune local-only keys on restore; skip rewrite when unchanged - Merge manifest from snapshot instead of full replace - Snapshot env file order on release create --- src/commands/pull.ts | 3 +- src/commands/release.ts | 22 +++- src/core/envConfig.ts | 78 ++++++++++++--- src/core/envSync.ts | 177 ++++++++++++++++++++++++++++----- src/core/manifest.ts | 77 ++++++++++---- tests/commands/release.test.ts | 50 +++++++++- tests/core/envSync.test.ts | 149 ++++++++++++++++++++++++++- tests/core/manifest.test.ts | 62 +++++++++--- 8 files changed, 535 insertions(+), 83 deletions(-) diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 49cd0ae..334c7e7 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -349,7 +349,8 @@ export async function pullCommand(options: PullOptions = {}): Promise { (fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0 ), appKey, - config.default + config.default, + cloudApp.assets ); }); diff --git a/src/commands/release.ts b/src/commands/release.ts index 895a4c1..9bea735 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -39,6 +39,7 @@ import { } from '../core/manifest.js'; import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; import { collectAppFiles } from '../core/appCollector.js'; +import { readEnvFilePreservingOrder } from '../core/envConfig.js'; import { resolveAppContext } from '../config/projectConfig.js'; import { getValidAuthSession } from '../auth/session.js'; import { withSpinner } from '../lib/spinner.js'; @@ -180,8 +181,14 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): ? manifest.defaultLanguage.trim() : undefined; const { key: encryptionKey, localEnv } = keyCtx; - const localConfig = buildConfigDtoFromEnvEntries(localEnv.envConfig); - const localSecrets = buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets); + const orderedConfig = localEnv.envConfigPresent + ? await readEnvFilePreservingOrder(root, localEnv.configWriteFile) + : []; + const orderedSecrets = localEnv.envSecretsPresent + ? await readEnvFilePreservingOrder(root, localEnv.secretsWriteFile) + : []; + const localConfig = buildConfigDtoFromEnvEntries(orderedConfig); + const localSecrets = buildSecretsDtoFromEnvSecretsFile(orderedSecrets); const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, defaultLanguage); const orderedWidgets = localApp.widgets?.length ? orderByManifestNames( @@ -509,7 +516,16 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis snapshot.config, snapshot.secrets, appKey, - config.default + config.default, + (snapshot.assets ?? []) + .filter( + (asset) => + asset.isArchived !== true && + typeof asset.fileName === 'string' && + asset.fileName.length > 0 + ) + .map((asset) => asset.fileName as string), + snapshot.assets ); ui.success( `Local files updated to selected release. Run "${releasePushHint(appKey, config.default)}" to apply to the cloud.` diff --git a/src/core/envConfig.ts b/src/core/envConfig.ts index 7b02ed3..5e7946a 100644 --- a/src/core/envConfig.ts +++ b/src/core/envConfig.ts @@ -45,6 +45,56 @@ export async function envFileExists(projectRoot: string, fileName: string): Prom } export async function readEnvFile(projectRoot: string, fileName: string): Promise { + const entries = await readEnvFilePreservingOrder(projectRoot, fileName); + return [...entries].sort((a, b) => a.key.localeCompare(b.key)); +} + +export function parseEnvEntriesPreservingOrder(raw: string): EnvEntry[] { + const parsed = parseEnvFile(raw); + const entries: EnvEntry[] = []; + const seen = new Set(); + for (const line of parsed.lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + entries.push({ key, value: trimmed.slice(eq + 1) }); + } + return entries; +} + +export function entriesEqualOrdered(a: EnvEntry[], b: EnvEntry[]): boolean { + return ( + a.length === b.length && + a.every((entry, index) => { + const other = b[index]; + return other !== undefined && entry.key === other.key && entry.value === other.value; + }) + ); +} + +export function envEntriesFromRecordInKeyOrder( + record: Record | undefined, + skip?: (key: string) => boolean +): EnvEntry[] { + if (!record) return []; + const entries: EnvEntry[] = []; + for (const key of Object.keys(record)) { + if (skip?.(key)) continue; + const value = record[key]; + if (value === undefined || value === null) continue; + entries.push({ key, value: String(value) }); + } + return entries; +} + +export async function readEnvFilePreservingOrder( + projectRoot: string, + fileName: string +): Promise { const envPath = path.join(projectRoot, fileName); let raw = ''; try { @@ -52,15 +102,7 @@ export async function readEnvFile(projectRoot: string, fileName: string): Promis } catch { return []; } - const parsed = parseEnvFile(raw); - const entries: EnvEntry[] = []; - for (const [key, lineIndex] of parsed.keyToLineIndex) { - const line = parsed.lines[lineIndex] ?? ''; - const eq = line.indexOf('='); - if (eq <= 0) continue; - entries.push({ key, value: line.slice(eq + 1) }); - } - return entries.sort((a, b) => a.key.localeCompare(b.key)); + return parseEnvEntriesPreservingOrder(raw); } export async function upsertEnvFile( @@ -93,17 +135,25 @@ export async function upsertEnvFile( await fs.writeFile(envPath, normalized, 'utf8'); } +export function formatEnvFileContent(entries: EnvEntry[]): string { + return formatEnvFileContentWithEol(entries, true); +} + +export function formatEnvFileContentWithEol(entries: EnvEntry[], trailingNewline: boolean): string { + const body = entries.map((entry) => `${entry.key}=${entry.value}`).join('\n'); + if (body === '') { + return trailingNewline ? '\n' : ''; + } + return trailingNewline ? `${body}\n` : body; +} + export async function writeEnvFile( projectRoot: string, fileName: string, entries: EnvEntry[] ): Promise { const envPath = path.join(projectRoot, fileName); - const normalized = entries - .map((entry) => `${entry.key}=${entry.value}`) - .join('\n') - .replace(/\n*$/, '\n'); - await fs.writeFile(envPath, normalized, 'utf8'); + await fs.writeFile(envPath, formatEnvFileContent(entries), 'utf8'); } export async function upsertEnvConfig(projectRoot: string, entries: EnvEntry[]): Promise { diff --git a/src/core/envSync.ts b/src/core/envSync.ts index adcaf2c..0908236 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs/promises'; import path from 'node:path'; import type { ConfigDTO, SecretDTO } from './dto.js'; @@ -8,8 +9,10 @@ import { envConfigScopedFile, envFileExists, envSecretsScopedFile, + entriesEqualOrdered, + formatEnvFileContentWithEol, + parseEnvEntriesPreservingOrder, readEnvFile, - upsertEnvFile, writeEnvFile, type EnvEntry, } from './envConfig.js'; @@ -134,6 +137,37 @@ export function configDtoToEnvEntries(config: ConfigDTO | undefined): EnvEntry[] return entriesFromRecord(config?.envVariables as Record | undefined); } +/** Asset keys first (alpha), then non-asset config keys (alpha) — same layout as pull. */ +export function buildCanonicalEnvConfigEntries( + config: ConfigDTO | undefined, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[], + existingEntries: EnvEntry[] = [], + retainLocalAssetValues = false +): EnvEntry[] { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const cloudVars = config?.envVariables ?? {}; + const existingByKey = new Map(existingEntries.map((entry) => [entry.key, entry.value])); + + const assetEntries = [...assetKeys] + .sort((a, b) => a.localeCompare(b)) + .flatMap((key) => { + const cloudValue = cloudVars[key]; + const value = + cloudValue !== undefined && cloudValue !== null + ? String(cloudValue) + : retainLocalAssetValues + ? existingByKey.get(key) + : undefined; + return value !== undefined ? [{ key, value }] : []; + }); + + const nonAssetEntries = configDtoToEnvEntries( + stripAssetKeysFromConfigDto(config, assetFileNames, cloudAssets) + ); + return [...assetEntries, ...nonAssetEntries]; +} + export function secretsDtoToEnvEntries(secrets: SecretDTO | undefined): EnvEntry[] { if (!secrets || typeof secrets !== 'object') return []; const nested = @@ -480,21 +514,113 @@ export async function prepareEnvPushState(params: { }; } +async function writeCanonicalEnvFileIfChanged( + projectRoot: string, + fileName: string, + config: ConfigDTO | undefined, + assetFileNames: string[], + cloudAssets: CloudAssetEnvRef[] | undefined, + filePresent: boolean, + retainLocalAssetValues: boolean +): Promise { + const envPath = path.join(projectRoot, fileName); + let raw = ''; + if (filePresent) { + try { + raw = await fs.readFile(envPath, 'utf8'); + } catch { + raw = ''; + } + } + const existing = parseEnvEntriesPreservingOrder(raw); + const canonicalEntries = buildCanonicalEnvConfigEntries( + config, + assetFileNames, + cloudAssets, + existing, + retainLocalAssetValues + ); + + if (!filePresent) { + if (canonicalEntries.length > 0) { + await writeEnvFile(projectRoot, fileName, canonicalEntries); + } + return; + } + + if (entriesEqualOrdered(existing, canonicalEntries)) { + return; + } + + const nextRaw = formatEnvFileContentWithEol(canonicalEntries, raw.endsWith('\n')); + if (nextRaw === raw) { + return; + } + await fs.writeFile(envPath, nextRaw, 'utf8'); +} + +async function writeCanonicalSecretsFileIfChanged( + projectRoot: string, + fileName: string, + secrets: SecretDTO | undefined, + filePresent: boolean +): Promise { + const canonicalEntries = secretsDtoToEnvEntries(secrets); + if (!filePresent) { + if (canonicalEntries.length > 0) { + await writeEnvFile(projectRoot, fileName, canonicalEntries); + } + return; + } + + const envPath = path.join(projectRoot, fileName); + let raw = ''; + try { + raw = await fs.readFile(envPath, 'utf8'); + } catch { + raw = ''; + } + + const existing = parseEnvEntriesPreservingOrder(raw); + if (entriesEqualOrdered(existing, canonicalEntries)) { + return; + } + + const nextRaw = formatEnvFileContentWithEol(canonicalEntries, raw.endsWith('\n')); + if (nextRaw === raw) { + return; + } + await fs.writeFile(envPath, nextRaw, 'utf8'); +} + export async function applyReleaseEnvToFs( projectRoot: string, config: ConfigDTO | undefined, secrets: SecretDTO | undefined, appKey: string, - defaultAppKey: string + defaultAppKey: string, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] ): Promise { const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); - const configEntries = configDtoToEnvEntries(config); - if (configEntries.length > 0) { - await upsertEnvFile(projectRoot, layout.configWriteFile, configEntries); + if (config !== undefined) { + await writeCanonicalEnvFileIfChanged( + projectRoot, + layout.configWriteFile, + config, + assetFileNames, + cloudAssets, + layout.envConfigPresent, + false + ); } - const secretEntries = secretsDtoToEnvEntries(secrets); - if (secretEntries.length > 0) { - await upsertEnvFile(projectRoot, layout.secretsWriteFile, secretEntries); + if (secrets !== undefined) { + await writeCanonicalSecretsFileIfChanged( + projectRoot, + layout.secretsWriteFile, + secrets, + layout.envSecretsPresent + ); } } @@ -503,26 +629,23 @@ export async function applyCloudEnvToFs( cloudEnv: CloudEnvState, assetFileNames: string[] = [], appKey = 'default', - defaultAppKey = appKey + defaultAppKey = appKey, + cloudAssets?: CloudAssetEnvRef[] ): Promise { const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); - const configWriteFile = layout.configWriteFile; - const secretsWriteFile = layout.secretsWriteFile; - - const assetKeys = collectAssetEnvKeys(assetFileNames); - const cloudVars = cloudEnv.config?.envVariables ?? {}; - const assetEntries = [...assetKeys] - .map((key) => ({ key, value: cloudVars[key] })) - .filter((entry): entry is EnvEntry => typeof entry.value === 'string'); - if (assetEntries.length > 0) { - await upsertEnvFile(projectRoot, configWriteFile, assetEntries); - } - - const existing = await readEnvFile(projectRoot, configWriteFile); - const keptAssetEntries = existing.filter((entry) => assetKeys.has(entry.key)); - const nonAssetEntries = configDtoToEnvEntries( - stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames) + await writeCanonicalEnvFileIfChanged( + projectRoot, + layout.configWriteFile, + cloudEnv.config, + assetFileNames, + cloudAssets, + layout.envConfigPresent, + true + ); + await writeCanonicalSecretsFileIfChanged( + projectRoot, + layout.secretsWriteFile, + cloudEnv.secrets, + layout.envSecretsPresent ); - await writeEnvFile(projectRoot, configWriteFile, [...keptAssetEntries, ...nonAssetEntries]); - await writeEnvFile(projectRoot, secretsWriteFile, secretsDtoToEnvEntries(cloudEnv.secrets)); } diff --git a/src/core/manifest.ts b/src/core/manifest.ts index 6c04a62..ab6154b 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -38,38 +38,81 @@ function mergeLanguageNames(existing: string[] | undefined, cloudNames: string[] ).map((entry) => entry.name); } -/** Build manifest literally from a release snapshot (pull/push merge rules do not apply). */ -export function manifestFromSnapshot(cloudApp: CloudApp): RootManifest { - const widgets = (cloudApp.widgets ?? []) +function mergeSnapshotNameList( + existing: T[] | undefined, + snapshotNames: string[] +): T[] { + const existingByName = new Map((existing ?? []).map((entry) => [entry.name, entry])); + return snapshotNames.map((name) => { + const kept = existingByName.get(name); + return kept ? { ...kept, name } : ({ name } as T); + }); +} + +/** Sync snapshot list fields into an existing manifest; preserve other keys and entry metadata. */ +export function mergeManifestFromSnapshot( + existing: RootManifest, + cloudApp: CloudApp +): RootManifest { + const widgetNames = (cloudApp.widgets ?? []) .filter((w) => w.isArchived !== true) - .map((w) => ({ name: w.name })); - const scripts = (cloudApp.scripts ?? []) + .map((w) => w.name); + const scriptNames = (cloudApp.scripts ?? []) .filter((s) => s.isArchived !== true) - .map((s) => ({ name: s.name })); - const actions = (cloudApp.actions ?? []) + .map((s) => s.name); + const actionNames = (cloudApp.actions ?? []) .filter((a) => a.isArchived !== true) - .map((a) => ({ name: a.name })); + .map((a) => a.name); const translations = (cloudApp.translations ?? []).filter((t) => t.isArchived !== true); const languages = translations.map((t) => t.name); const defaultLanguage = translations.find((t) => t.defaultLocale === true)?.name ?? languages[0]; - const manifest: RootManifest = {}; - if (widgets.length > 0) manifest.widgets = widgets; - if (scripts.length > 0) manifest.scripts = scripts; - if (actions.length > 0) manifest.actions = actions; - if (languages.length > 0) { - manifest.languages = languages; - if (defaultLanguage) manifest.defaultLanguage = defaultLanguage; + const merged: RootManifest = { ...existing }; + + for (const [key, names] of [ + ['widgets', widgetNames], + ['scripts', scriptNames], + ['actions', actionNames], + ] as const) { + if (names.length === 0) { + if (key in existing) { + merged[key] = []; + } else { + delete merged[key]; + } + } else { + merged[key] = mergeSnapshotNameList(merged[key] as { name: string }[] | undefined, names); + } } - return manifest; + + if (languages.length === 0) { + if ('languages' in existing) { + merged.languages = []; + } else { + delete merged.languages; + } + if (!('defaultLanguage' in existing) || languages.length > 0) { + delete merged.defaultLanguage; + } + } else { + merged.languages = languages; + if (defaultLanguage) { + merged.defaultLanguage = defaultLanguage; + } else { + delete merged.defaultLanguage; + } + } + + return merged; } export async function writeManifestFromSnapshot( projectRoot: string, cloudApp: CloudApp ): Promise { - const manifest = manifestFromSnapshot(cloudApp); + const existing = await readProjectManifest(projectRoot); + const manifest = mergeManifestFromSnapshot(existing, cloudApp); await fs.writeFile( path.join(projectRoot, '.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index 192366b..6afdc7b 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -175,6 +175,7 @@ describe('release commands', () => { bucket: 'bucket', objectPath: 'releases/app1/ver-123.enc.json', }); + downloadReleaseSnapshotJsonMock.mockReset(); downloadReleaseSnapshotJsonMock.mockResolvedValue( mockEncryptedSnapshot({ id: 'app1', name: 'App', screens: [] }) ); @@ -253,7 +254,11 @@ describe('release commands', () => { await fs.mkdir(path.join(projectRoot, 'widgets'), { recursive: true }); await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid2.yaml'), 'View:\n', 'utf8'); await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid1.yaml'), 'View:\n', 'utf8'); - const manifestBefore = { widgets: [{ name: 'Wid1' }, { name: 'Wid2' }] }; + const manifestBefore = { + studioVersion: 2, + actions: [], + widgets: [{ name: 'Wid1', customId: 'local-id' }, { name: 'Wid2' }], + }; const manifestRaw = `${JSON.stringify(manifestBefore, null, 2)}\n`; await fs.writeFile(path.join(projectRoot, '.manifest.json'), manifestRaw, 'utf8'); @@ -333,12 +338,16 @@ describe('release commands', () => { Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce(olderSnapshot); + let downloadCall = 0; + downloadReleaseSnapshotJsonMock.mockImplementation(async () => { + downloadCall += 1; + return downloadCall === 1 ? olderSnapshot : latestSnapshot; + }); await releaseUseCommand({ hash: 'hash-old' }); - - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce(latestSnapshot); await releaseUseCommand({ hash: 'hash-latest' }); + expect(downloadCall).toBe(2); + const manifestAfter = JSON.parse( await fs.readFile(path.join(projectRoot, '.manifest.json'), 'utf8') ) as { languages: string[]; defaultLanguage: string }; @@ -399,7 +408,29 @@ describe('release commands', () => { expect(envSecrets).not.toContain('LOCAL-SECRET'); }); - it('release use preserves .env.config key order', async () => { + it('release use removes env keys not in snapshot', async () => { + await writeEnvConfig(projectRoot, ['A1=a', 'E1=local-only', 'B1=b']); + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + config: { envVariables: { A1: 'a', B1: 'b' } }, + }) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const lines = (await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8')) + .trim() + .split('\n'); + expect(lines).toEqual(['A1=a', 'B1=b']); + }); + + it('release use writes canonical asset-then-config layout', async () => { await fs.writeFile( path.join(projectRoot, '.env.config'), 'assets=https://old/\nkwnd_png=old.png\nE1=old\n', @@ -411,6 +442,15 @@ describe('release commands', () => { id: 'app1', name: 'App', screens: [], + assets: [ + { + id: 'asset-kwnd', + name: 'kwnd.png', + fileName: 'kwnd.png', + content: '', + type: EnsembleDocumentType.Asset, + }, + ], config: { envVariables: { E1: 'EV1', diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 3539cc2..fec180c 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -8,6 +8,7 @@ import { writeEnvFile, type EnvEntry } from '../../src/core/envConfig.js'; import { applyCloudEnvToFs, applyReleaseEnvToFs, + buildCanonicalEnvConfigEntries, buildEnvPushDiff, buildPushConfigDto, computeEnvPullChanges, @@ -480,6 +481,21 @@ describe('envSync', () => { expect(envConfig).not.toContain('del_png='); }); + it('buildCanonicalEnvConfigEntries places asset keys before non-asset config keys', () => { + const entries = buildCanonicalEnvConfigEntries( + { + envVariables: { + E1: 'K1', + assets: 'https://cdn/', + logo_png: 'logo.png', + }, + }, + ['logo.png'] + ); + + expect(entries.map((entry) => entry.key)).toEqual(['assets', 'logo_png', 'E1']); + }); + it('applyReleaseEnvToFs restores full snapshot config', async () => { await applyReleaseEnvToFs( tmpDir, @@ -492,7 +508,8 @@ describe('envSync', () => { }, undefined, 'default', - 'default' + 'default', + ['logo.png'] ); const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); @@ -501,7 +518,7 @@ describe('envSync', () => { expect(envConfig).toContain('E1=EV1'); }); - it('applyReleaseEnvToFs preserves env file key order', async () => { + it('applyReleaseEnvToFs writes canonical asset-then-config layout', async () => { await fs.writeFile( path.join(tmpDir, '.env.config'), 'assets=https://old/\nkwnd_png=old.png\nE1=old\n', @@ -519,7 +536,8 @@ describe('envSync', () => { }, undefined, 'default', - 'default' + 'default', + ['kwnd.png'] ); const lines = (await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8')).trim().split('\n'); @@ -528,6 +546,131 @@ describe('envSync', () => { expect(lines[2]).toMatch(/^E1=EV1$/); }); + it('applyReleaseEnvToFs removes config keys not in snapshot', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'A1=old\nE1=local-only\nB1=old\n', 'utf8'); + + await applyReleaseEnvToFs( + tmpDir, + { envVariables: { A1: 'a', B1: 'b' } }, + undefined, + 'default', + 'default' + ); + + const lines = (await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8')).trim().split('\n'); + expect(lines).toEqual(['A1=a', 'B1=b']); + }); + + it('applyReleaseEnvToFs removes secret keys not in snapshot', async () => { + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=old\nS2=extra\n', 'utf8'); + + await applyReleaseEnvToFs(tmpDir, undefined, { secrets: { S1: 'new' } }, 'default', 'default'); + + const lines = (await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8')).trim().split('\n'); + expect(lines).toEqual(['S1=new']); + }); + + it('applyReleaseEnvToFs clears env file when snapshot config is empty', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=local\n', 'utf8'); + + await applyReleaseEnvToFs(tmpDir, { envVariables: {} }, undefined, 'default', 'default'); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig.trim()).toBe(''); + }); + + it('applyReleaseEnvToFs does not rewrite env files when snapshot matches local', async () => { + const envPath = path.join(tmpDir, '.env.config'); + const secretsPath = path.join(tmpDir, '.env.secrets'); + const envRaw = 'assets=https://cdn/\nE1=EV1'; + const secretsRaw = `ENSEMBLE_ENCRYPTION_KEY=${'a'.repeat(64)}\nS1=SK1`; + await fs.writeFile(envPath, envRaw, 'utf8'); + await fs.writeFile(secretsPath, secretsRaw, 'utf8'); + + await applyReleaseEnvToFs( + tmpDir, + { envVariables: { assets: 'https://cdn/', E1: 'EV1' } }, + { + secrets: { + ENSEMBLE_ENCRYPTION_KEY: 'a'.repeat(64), + S1: 'SK1', + }, + }, + 'default', + 'default', + [] + ); + + expect(await fs.readFile(envPath, 'utf8')).toBe(envRaw); + expect(await fs.readFile(secretsPath, 'utf8')).toBe(secretsRaw); + }); + + it('applyReleaseEnvToFs normalizes env config to canonical layout', async () => { + const envPath = path.join(tmpDir, '.env.config'); + const assetFiles = ['V12_Aansluiten.png', 't-3276-unenroll-mw-after.png']; + await fs.writeFile( + envPath, + 'E1=K1\nV12_Aansluiten_png=token\nt_3276_unenroll_mw_after_png=token2', + 'utf8' + ); + + await applyReleaseEnvToFs( + tmpDir, + { + envVariables: { + V12_Aansluiten_png: 'token', + t_3276_unenroll_mw_after_png: 'token2', + E1: 'K1', + }, + }, + undefined, + 'default', + 'default', + assetFiles + ); + + expect(await fs.readFile(envPath, 'utf8')).toBe( + 't_3276_unenroll_mw_after_png=token2\nV12_Aansluiten_png=token\nE1=K1' + ); + }); + + it('applyReleaseEnvToFs skips env files when snapshot omits config and secrets', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=local\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local\n', 'utf8'); + + await applyReleaseEnvToFs(tmpDir, undefined, undefined, 'default', 'default'); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); + expect(envConfig).toContain('E1=local'); + expect(envSecrets).toContain('S1=local'); + }); + + it('applyReleaseEnvToFs removes local-only keys in canonical layout', async () => { + const envPath = path.join(tmpDir, '.env.config'); + const assetFiles = ['V12_Aansluiten.png', 't-3276-unenroll-mw-after.png']; + const envRaw = 'V12_Aansluiten_png=token\nt_3276_unenroll_mw_after_png=token2\nE1=K1'; + await fs.writeFile(envPath, envRaw, 'utf8'); + + await applyReleaseEnvToFs( + tmpDir, + { + envVariables: { + V12_Aansluiten_png: 'token', + t_3276_unenroll_mw_after_png: 'token2', + }, + }, + undefined, + 'default', + 'default', + assetFiles + ); + + expect(await fs.readFile(envPath, 'utf8')).toBe( + 't_3276_unenroll_mw_after_png=token2\nV12_Aansluiten_png=token' + ); + }); + it('readProjectEnvFiles uses scoped pair when both alias files exist', async () => { await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\nE2=shared\n', 'utf8'); await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); diff --git a/tests/core/manifest.test.ts b/tests/core/manifest.test.ts index b8a0acc..bc11338 100644 --- a/tests/core/manifest.test.ts +++ b/tests/core/manifest.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { buildManifestObject, - manifestFromSnapshot, + mergeManifestFromSnapshot, orderByManifestNames, type RootManifest, } from '../../src/core/manifest.js'; @@ -20,7 +20,14 @@ describe('manifest', () => { expect(ordered.map((item) => item.name)).toEqual(['Wid1', 'Wid2']); }); - it('manifestFromSnapshot uses snapshot list order and defaultLocale', () => { + it('mergeManifestFromSnapshot syncs lists from snapshot but keeps other manifest keys', () => { + const existing: RootManifest = { + screens: [{ name: 'Home' }], + studioVersion: 3, + widgets: [{ name: 'Wid1', customId: 'keep-me' } as unknown as { name: string }], + languages: ['ar', 'en'], + defaultLanguage: 'ar', + }; const cloud: CloudApp = { id: 'app1', name: 'App', @@ -36,28 +43,24 @@ describe('manifest', () => { name: 'en', content: '', type: EnsembleDocumentType.I18n, - }, - { - id: 't-de', - name: 'de', - content: '', - type: EnsembleDocumentType.I18n, + defaultLocale: true, }, { id: 't-ar', name: 'ar', content: '', type: EnsembleDocumentType.I18n, - defaultLocale: true, }, ], }; - const manifest = manifestFromSnapshot(cloud); + const merged = mergeManifestFromSnapshot(existing, cloud); - expect(manifest.widgets?.map((w) => w.name)).toEqual(['Wid2', 'Wid1']); - expect(manifest.languages).toEqual(['en', 'de', 'ar']); - expect(manifest.defaultLanguage).toBe('ar'); + expect(merged.screens).toEqual([{ name: 'Home' }]); + expect(merged.studioVersion).toBe(3); + expect(merged.widgets).toEqual([{ name: 'Wid2' }, { name: 'Wid1', customId: 'keep-me' }]); + expect(merged.languages).toEqual(['en', 'ar']); + expect(merged.defaultLanguage).toBe('en'); }); it('buildManifestObject preserves existing list order on pull', () => { @@ -100,4 +103,37 @@ describe('manifest', () => { expect(merged.languages).toEqual(['ar', 'en']); expect(merged.defaultLanguage).toBe('en'); }); + + it('mergeManifestFromSnapshot keeps empty list keys that already exist', () => { + const existing: RootManifest = { + actions: [], + defaultLanguage: 'nl', + languages: ['nl', 'en'], + }; + const cloud: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + translations: [ + { + id: 't-nl', + name: 'nl', + content: '', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + { + id: 't-en', + name: 'en', + content: '', + type: EnsembleDocumentType.I18n, + }, + ], + }; + + const merged = mergeManifestFromSnapshot(existing, cloud); + + expect(merged.actions).toEqual([]); + expect(merged.defaultLanguage).toBe('nl'); + }); });