diff --git a/package.json b/package.json index fe22621..bbff604 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,14 @@ "dependencies": { "commander": "^12.1.0", "jiti": "^2.4.2", + "number-to-words": "^1.2.4", "picocolors": "^1.1.0", "prompts": "^2.4.2", "tar": "^7.4.3" }, "devDependencies": { "@types/node": "^22.10.1", + "@types/number-to-words": "^1.2.3", "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", diff --git a/src/commands/add.ts b/src/commands/add.ts index 604c5a5..8392c27 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -2,13 +2,11 @@ import fs from 'fs/promises'; import path from 'path'; import prompts from 'prompts'; -import { loadProjectConfig, resolveAppContext } from '../config/projectConfig.js'; -import { getValidAuthSession } from '../auth/session.js'; -import { uploadAssetToStudio } from '../cloud/assetClient.js'; -import { upsertEnvConfig } from '../core/envConfig.js'; +import { loadProjectConfig } from '../config/projectConfig.js'; +import { buildLocalAssetEnvEntries, buildLocalAssetUsageKey } from '../core/assetEnv.js'; +import { readEnvFile, upsertEnvConfig } from '../core/envConfig.js'; import { upsertManifestEntry } from '../core/manifest.js'; import { ui } from '../core/ui.js'; -import { withSpinner } from '../lib/spinner.js'; export type AddKind = 'screen' | 'widget' | 'script' | 'action' | 'translation' | 'asset'; @@ -109,7 +107,7 @@ async function addAsset( message: `Asset already exists at ${path.relative(projectRoot, targetPath)}. What do you want to do?`, choices: [ { title: 'Cancel', value: 'cancel' }, - { title: 'Overwrite (reupload)', value: 'overwrite' }, + { title: 'Overwrite', value: 'overwrite' }, ], initial: 0, }); @@ -128,27 +126,23 @@ async function addAsset( await fs.copyFile(resolvedInputPath, targetPath); - const fileBuffer = await fs.readFile(targetPath); - const fileDataBase64 = fileBuffer.toString('base64'); - - const { appId } = await resolveAppContext(); - const session = await getValidAuthSession(); - if (!session.ok) { - throw new Error(`${session.message}\nRun \`ensemble login\` and try again.`); + let existingAssetsBaseUrl: string | undefined; + try { + const existingConfig = await readEnvFile(projectRoot, '.env.config'); + const assetsEntry = existingConfig.find((entry) => entry.key === 'assets'); + if (assetsEntry?.value) { + existingAssetsBaseUrl = assetsEntry.value; + } + } catch { + existingAssetsBaseUrl = undefined; } - const uploadResult = await withSpinner('Uploading asset to cloud...', async () => { - const result = await uploadAssetToStudio(appId, fileName, fileDataBase64, session.idToken); - await upsertEnvConfig(projectRoot, [ - { key: 'assets', value: result.assetBaseUrl, overwrite: false }, - { key: result.envVariable.key, value: result.envVariable.value }, - ]); - return result; - }); + + await upsertEnvConfig(projectRoot, buildLocalAssetEnvEntries(fileName, existingAssetsBaseUrl)); return { fileName, createdPath: path.relative(projectRoot, targetPath), - usageKey: uploadResult.usageKey, + usageKey: buildLocalAssetUsageKey(fileName), }; } @@ -278,8 +272,9 @@ export async function addCommand( } ui.success(`Created asset "${fileName}" at ${createdPath} and updated .env.config.`); if (usageKey) { - ui.note(`Usage Example: ${usageKey}`); + ui.note(`Usage: ${usageKey}`); } + ui.note('Asset saved locally. Run `ensemble push` to upload to cloud.'); return; } diff --git a/src/commands/pull.ts b/src/commands/pull.ts index b17cfe9..70da2d5 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -145,6 +145,31 @@ function printPullSummary(summary: PullSummary): void { } } +function cloudAssetFileNames(assets: CloudApp['assets'] | undefined): string[] { + return (assets ?? []) + .map((asset) => asset.fileName) + .filter((fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0); +} + +async function applyCloudEnvFromPull( + projectRoot: string, + cloudApp: CloudApp, + appKey: string, + defaultAppKey: string, + assetFileNames: string[], + assetsSyncEnabled: boolean +): Promise { + await applyCloudEnvToFs( + projectRoot, + { config: cloudApp.config, secrets: cloudApp.secrets }, + assetFileNames, + appKey, + defaultAppKey, + cloudApp.assets, + assetsSyncEnabled + ); +} + export async function pullCommand(options: PullOptions = {}): Promise { const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey); const verbose = resolveVerboseFlag(options.verbose); @@ -155,6 +180,7 @@ export async function pullCommand(options: PullOptions = {}): Promise { const enabledByProp = Object.fromEntries( ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) ) as Record; + const assetsEnabled = appOptions.assets !== false; const session = await getValidAuthSession(); if (!session.ok) { @@ -229,6 +255,7 @@ export async function pullCommand(options: PullOptions = {}): Promise { localFiles, manifestExisting, enabledByProp, + assetsEnabled, localEnv: await readProjectEnvFiles(projectRoot, appKey, config.default), }); @@ -295,63 +322,70 @@ export async function pullCommand(options: PullOptions = {}): Promise { // Sync assets/ after YAML files are written. // This pulls binary files via each asset's publicUrl and deletes local extras. - await withSpinner('Syncing assets...', async () => { - const result = await applyCloudAssetsToFs({ - projectRoot, - cloudAssets: cloudApp.assets, - }); - // Fold any asset changes into the already-computed pullSummary so the final output reflects what we did. - if (result.created || result.deleted || result.skipped) { - ( - pullSummary.changes as PullSummary['changes'] as unknown as Array<{ - kind: string; - file: string; - operation: string; - }> - ).push(...result.changes); - (pullSummary as unknown as { created: number }).created += result.created; - (pullSummary as unknown as { deleted: number }).deleted += result.deleted; - (pullSummary as unknown as { skipped: number }).skipped += result.skipped; - } + if (assetsEnabled) { + await withSpinner('Syncing assets...', async () => { + const result = await applyCloudAssetsToFs({ + projectRoot, + cloudAssets: cloudApp.assets, + }); + // Fold any asset changes into the already-computed pullSummary so the final output reflects what we did. + if (result.created || result.deleted || result.skipped) { + ( + pullSummary.changes as PullSummary['changes'] as unknown as Array<{ + kind: string; + file: string; + operation: string; + }> + ).push(...result.changes); + (pullSummary as unknown as { created: number }).created += result.created; + (pullSummary as unknown as { deleted: number }).deleted += result.deleted; + (pullSummary as unknown as { skipped: number }).skipped += result.skipped; + } - if (result.failures.length > 0) { - ui.warn(`Some assets failed to download (${result.failures.length}).`); - const maxLines = 8; - for (const f of result.failures.slice(0, maxLines)) { - ui.warn(f.message); + if (result.failures.length > 0) { + ui.warn(`Some assets failed to download (${result.failures.length}).`); + const maxLines = 8; + for (const f of result.failures.slice(0, maxLines)) { + ui.warn(f.message); + } + if (result.failures.length > maxLines) { + ui.note(`(and ${result.failures.length - maxLines} more asset download issues...)`); + } } - if (result.failures.length > maxLines) { - ui.note(`(and ${result.failures.length - maxLines} more asset download issues...)`); + + // Always (best-effort) update env config for assets so ${env.assets}${env.} references work after pull. + const envLayout = await readProjectEnvFiles(projectRoot, appKey, config.default); + const envResult = buildEnvConfigForCloudAssets(cloudApp.assets); + if (envResult.entries.length > 0) { + await upsertEnvFile(projectRoot, envLayout.configWriteFile, envResult.entries); + } + if (envResult.failures.length > 0) { + ui.warn( + `Some assets had invalid metadata and may be missing from ${envLayout.configWriteFile} (${envResult.failures.length}).` + ); } - } - // Always (best-effort) update env config for assets so ${env.assets}${env.} references work after pull. - const envLayout = await readProjectEnvFiles(projectRoot, appKey, config.default); - const envResult = buildEnvConfigForCloudAssets(cloudApp.assets); - if (envResult.entries.length > 0) { - await upsertEnvFile(projectRoot, envLayout.configWriteFile, envResult.entries); - } - if (envResult.failures.length > 0) { - ui.warn( - `Some assets had invalid metadata and may be missing from ${envLayout.configWriteFile} (${envResult.failures.length}).` + await applyCloudEnvFromPull( + projectRoot, + cloudApp, + appKey, + config.default, + cloudAssetFileNames(cloudApp.assets), + true ); - } - - await applyCloudEnvToFs( - projectRoot, - { - config: cloudApp.config, - secrets: cloudApp.secrets, - }, - (cloudApp.assets ?? []) - .map((asset) => asset.fileName) - .filter( - (fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0 - ), - appKey, - config.default - ); - }); + }); + } else if (pullSummary.changes.some((change) => change.kind === 'env')) { + await withSpinner('Syncing env files...', async () => { + await applyCloudEnvFromPull( + projectRoot, + cloudApp, + appKey, + config.default, + localFiles.assetFiles ?? [], + false + ); + }); + } printPullSummary(pullSummary); } diff --git a/src/commands/push.ts b/src/commands/push.ts index 52888cd..f14c370 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -203,6 +203,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { const enabledByProp = Object.fromEntries( ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) ) as Record; + const assetsEnabled = appOptions.assets !== false; const session = await getValidAuthSession(); if (!session.ok) { @@ -295,10 +296,27 @@ export async function pushCommand(options: PushOptions = {}): Promise { localApp, cloudApp, enabledByProp, + assetsEnabled, updatedBy, }); bundle = plan.bundle; + if (!assetsEnabled) { + const cloudActiveAssets = (cloudApp.assets ?? []).filter((a) => a.isArchived !== true); + const localAssetFiles = new Set((data.assetFiles ?? []).filter(Boolean)); + const cloudAssetFiles = new Set( + cloudActiveAssets + .map((a) => a.fileName) + .filter((fileName): fileName is string => typeof fileName === 'string' && fileName !== '') + ); + const assetsDiffer = + cloudAssetFiles.size !== localAssetFiles.size || + [...cloudAssetFiles].some((fileName) => !localAssetFiles.has(fileName)); + if (assetsDiffer) { + ui.note('Skipping assets (options.assets: false in ensemble.config.json).'); + } + } + const assetFileNames = data.assetFiles ?? []; const envPush = await prepareEnvPushState({ projectRoot: root, @@ -307,6 +325,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets }, assetFileNames, cloudAssets: cloudApp.assets, + assetsSyncEnabled: assetsEnabled, }); const { diff: envPushDiff, diff --git a/src/core/assetEnv.ts b/src/core/assetEnv.ts new file mode 100644 index 0000000..ed9dca7 --- /dev/null +++ b/src/core/assetEnv.ts @@ -0,0 +1,28 @@ +import { toWords } from 'number-to-words'; + +import type { EnvEntry } from './envConfig.js'; + +/** mirrors studio_service/studio/src/addAssetsArtifact.ts convertNumbersInFilename */ +export function convertNumbersInFilename(filename: string): string { + // First, replace any non-alphanumeric characters with underscores + let envFileName = filename.replace(/[^A-Za-z0-9_]/g, '_'); + // Convert all numbers to words without spaces or commas + envFileName = envFileName.replace(/^\d+/, (match) => + toWords(parseInt(match, 10)) + .replace(/[,-\s]/g, '') + .toLowerCase() + ); + return envFileName; +} + +export function buildLocalAssetUsageKey(fileName: string): string { + return `\${env.assets}\${env.${convertNumbersInFilename(fileName)}}`; +} + +export function buildLocalAssetEnvEntries(fileName: string, existingBaseUrl?: string): EnvEntry[] { + const entries: EnvEntry[] = [{ key: convertNumbersInFilename(fileName), value: fileName }]; + if (existingBaseUrl) { + return [{ key: 'assets', value: existingBaseUrl, overwrite: false }, ...entries]; + } + return entries; +} diff --git a/src/core/assetEnvSync.ts b/src/core/assetEnvSync.ts new file mode 100644 index 0000000..ab642f0 --- /dev/null +++ b/src/core/assetEnvSync.ts @@ -0,0 +1,219 @@ +import path from 'node:path'; + +import type { ConfigDTO } from './dto.js'; +import type { EnvEntry } from './envConfig.js'; +import { convertNumbersInFilename } from './assetEnv.js'; +import { resolveAssetEnvKey } from './pullAssets.js'; + +export type CloudAssetEnvRef = { + fileName?: string; + copyText?: string; + isArchived?: boolean; +}; + +type AssetKeyContext = { + localKeys: Set; + excludedKeys: Set; + staleKeys: Set; + cloudByFile: Map; +}; + +const activeCloudAssetRefs = (cloudAssets?: CloudAssetEnvRef[]) => + (cloudAssets ?? []).filter( + (a) => typeof a.fileName === 'string' && a.fileName !== '' && a.isArchived !== true + ); + +export function assetFileNameFromEnvValue(rawValue: string): string | undefined { + const value = rawValue.trim(); + if (!value) return undefined; + const base = path.basename(value.split('?')[0] ?? value); + return base.includes('.') ? base : undefined; +} + +function inferAssetFileNamesFromEntries(entries: EnvEntry[]): string[] { + const names = new Set(); + for (const entry of entries) { + if (entry.key === 'assets') continue; + const fileName = assetFileNameFromEnvValue(entry.value); + if (fileName) names.add(fileName); + } + return [...names]; +} + +export function collectAssetEnvKeys(assetFileNames: string[] = []): Set { + return new Set(['assets', ...assetFileNames.map(convertNumbersInFilename)]); +} + +export function buildAssetKeyContext( + localAssetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): AssetKeyContext { + const cloudByFile = new Map( + activeCloudAssetRefs(cloudAssets).map((a) => [a.fileName as string, a]) + ); + const localFiles = new Set(localAssetFileNames); + const localKeys = new Set(['assets']); + const staleKeys = new Set(); + + for (const fileName of localAssetFileNames) { + const cloudAsset = cloudByFile.get(fileName); + localKeys.add(resolveAssetEnvKey({ fileName, copyText: cloudAsset?.copyText })); + localKeys.add(convertNumbersInFilename(fileName)); + } + for (const [fileName, asset] of cloudByFile) { + if (!localFiles.has(fileName)) { + staleKeys.add(resolveAssetEnvKey({ fileName, copyText: asset.copyText })); + } + } + + return { + localKeys, + staleKeys, + excludedKeys: new Set([...localKeys, ...staleKeys]), + cloudByFile, + }; +} + +/** keys to strip from env compare/push when assets sync is off or for non-asset slices */ +export function getAssetEnvKeysToExclude( + entries: EnvEntry[], + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): Set { + const excludeKeys = new Set( + cloudAssets + ? buildAssetKeyContext(assetFileNames, cloudAssets).excludedKeys + : collectAssetEnvKeys(assetFileNames) + ); + for (const entry of entries) { + if (entry.key === 'assets') { + excludeKeys.add('assets'); + continue; + } + const fileName = assetFileNameFromEnvValue(entry.value); + if (fileName && entry.key === convertNumbersInFilename(fileName)) { + excludeKeys.add(entry.key); + } + } + return excludeKeys; +} + +export function mergeAssetFileNamesForEnvCompare( + localAssetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] +): string[] { + const fromCloud = activeCloudAssetRefs(cloudAssets).map((a) => a.fileName as string); + return [...new Set([...localAssetFileNames, ...fromCloud])]; +} + +export interface DisabledEnvSyncPlan { + localAssetFileNames: string[]; + pushConfig: ConfigDTO; + compareLocal: ConfigDTO; + compareCloud: ConfigDTO | undefined; +} + +/** single plan for assets:false compare + push */ +export function planDisabledEnvSync( + localEntries: EnvEntry[], + cloudConfig: ConfigDTO | undefined, + cloudAssets?: CloudAssetEnvRef[] +): DisabledEnvSyncPlan { + const cloudEntries = entriesFromConfigDto(cloudConfig); + const { local, cloud } = assetFileNamesForDisabledEnvSync( + localEntries, + cloudEntries, + cloudAssets + ); + return { + localAssetFileNames: local, + pushConfig: buildPushConfigWhenAssetsDisabled( + localEntries, + cloudEntries, + cloudConfig, + local, + cloud, + cloudAssets + ), + compareLocal: buildNonAssetConfigFromEntries(localEntries, local, cloudAssets), + compareCloud: stripAssetKeysFromEntries(cloudEntries, cloud, cloudAssets), + }; +} + +export function assetFileNamesForDisabledEnvSync( + localEntries: EnvEntry[], + cloudEntries: EnvEntry[], + cloudAssets?: CloudAssetEnvRef[] +): { local: string[]; cloud: string[] } { + const fromRefs = activeCloudAssetRefs(cloudAssets).map((a) => a.fileName as string); + return { + local: [...new Set(inferAssetFileNamesFromEntries(localEntries))], + cloud: [...new Set([...fromRefs, ...inferAssetFileNamesFromEntries(cloudEntries)])], + }; +} + +export function buildNonAssetConfigFromEntries( + entries: EnvEntry[], + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO { + return entriesToConfigDto( + entries, + getAssetEnvKeysToExclude(entries, assetFileNames, cloudAssets) + ); +} + +export function stripAssetKeysFromEntries( + entries: EnvEntry[], + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO | undefined { + const config = entriesToConfigDto( + entries, + getAssetEnvKeysToExclude(entries, assetFileNames, cloudAssets) + ); + return Object.keys(config.envVariables ?? {}).length > 0 ? config : undefined; +} + +/** local non-asset vars + preserve cloud asset env keys (assets: false push) */ +export function buildPushConfigWhenAssetsDisabled( + localEntries: EnvEntry[], + cloudEntries: EnvEntry[], + cloudConfig: ConfigDTO | undefined, + localFileNames: string[], + cloudFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO { + const localExclude = getAssetEnvKeysToExclude(localEntries, localFileNames, cloudAssets); + const cloudExclude = getAssetEnvKeysToExclude(cloudEntries, cloudFileNames, cloudAssets); + const mergedByKey = new Map(); + + for (const entry of cloudEntries) { + if (cloudExclude.has(entry.key)) mergedByKey.set(entry.key, entry.value); + } + for (const entry of localEntries) { + if (!localExclude.has(entry.key)) mergedByKey.set(entry.key, entry.value); + } + + return { + envVariables: Object.fromEntries(mergedByKey), + ...(cloudConfig?.baseUrl !== undefined && { baseUrl: cloudConfig.baseUrl }), + ...(cloudConfig?.useBrowserUrl !== undefined && { useBrowserUrl: cloudConfig.useBrowserUrl }), + }; +} + +function entriesToConfigDto(entries: EnvEntry[], excludeKeys: Set): ConfigDTO { + const envVariables: Record = {}; + for (const entry of entries) { + if (!excludeKeys.has(entry.key)) envVariables[entry.key] = entry.value; + } + return { envVariables }; +} + +function entriesFromConfigDto(config: ConfigDTO | undefined): EnvEntry[] { + const record = config?.envVariables as Record | undefined; + if (!record) return []; + return Object.entries(record) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => ({ key, value: String(value) })); +} diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 09f5b3a..88440cc 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -1,7 +1,16 @@ -import path from 'node:path'; - import type { ConfigDTO, SecretDTO } from './dto.js'; -import { deriveAssetEnvKey, resolveAssetEnvKey } from './pullAssets.js'; +import { resolveAssetEnvKey } from './pullAssets.js'; +import { convertNumbersInFilename } from './assetEnv.js'; +import { + assetFileNameFromEnvValue, + buildAssetKeyContext, + collectAssetEnvKeys, + getAssetEnvKeysToExclude, + mergeAssetFileNamesForEnvCompare, + planDisabledEnvSync, + stripAssetKeysFromEntries, + type CloudAssetEnvRef, +} from './assetEnvSync.js'; import { ENV_CONFIG_BASE, ENV_SECRETS_BASE, @@ -38,52 +47,6 @@ export interface LocalEnvFiles { scopedSecretsPresent: boolean; } -export type CloudAssetEnvRef = { - fileName?: string; - copyText?: string; - isArchived?: boolean; -}; - -type AssetKeyContext = { - localKeys: Set; - excludedKeys: Set; - staleKeys: Set; - cloudByFile: Map; -}; - -const activeCloudAssets = (cloudAssets?: CloudAssetEnvRef[]) => - (cloudAssets ?? []).filter( - (a) => typeof a.fileName === 'string' && a.fileName !== '' && a.isArchived !== true - ); - -function buildAssetKeyContext( - localAssetFileNames: string[], - cloudAssets?: CloudAssetEnvRef[] -): AssetKeyContext { - const cloudByFile = new Map(activeCloudAssets(cloudAssets).map((a) => [a.fileName as string, a])); - const localFiles = new Set(localAssetFileNames); - const localKeys = new Set(['assets']); - const staleKeys = new Set(); - - for (const fileName of localAssetFileNames) { - const cloudAsset = cloudByFile.get(fileName); - localKeys.add(resolveAssetEnvKey({ fileName, copyText: cloudAsset?.copyText })); - localKeys.add(deriveAssetEnvKey(fileName)); - } - for (const [fileName, asset] of cloudByFile) { - if (!localFiles.has(fileName)) { - staleKeys.add(resolveAssetEnvKey({ fileName, copyText: asset.copyText })); - } - } - - return { - localKeys, - staleKeys, - excludedKeys: new Set([...localKeys, ...staleKeys]), - cloudByFile, - }; -} - function entriesEqual(a: EnvEntry[], b: EnvEntry[]): boolean { const mapB = new Map(b.map((e) => [e.key, e.value])); return a.length === mapB.size && a.every((e) => mapB.get(e.key) === e.value); @@ -112,24 +75,6 @@ function dtoFromEntries(entries: EnvEntry[], excludeKeys?: Set): ConfigD return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; } -function omitAssetKeys( - entries: EnvEntry[], - assetFileNames: string[], - cloudAssets?: CloudAssetEnvRef[] -): ConfigDTO | undefined { - const excludeKeys = cloudAssets - ? buildAssetKeyContext(assetFileNames, cloudAssets).excludedKeys - : collectAssetEnvKeys(assetFileNames); - return dtoFromEntries(entries, excludeKeys); -} - -function assetFileNameFromEnvValue(rawValue: string): string | undefined { - const value = rawValue.trim(); - if (!value) return undefined; - const base = path.basename(value.split('?')[0] ?? value); - return base.includes('.') ? base : undefined; -} - export function configDtoToEnvEntries(config: ConfigDTO | undefined): EnvEntry[] { return entriesFromRecord(config?.envVariables as Record | undefined); } @@ -143,24 +88,12 @@ export function secretsDtoToEnvEntries(secrets: SecretDTO | undefined): EnvEntry return entriesFromRecord(nested, (key) => key === 'secrets'); } -export function collectAssetEnvKeys(assetFileNames: string[] = []): Set { - return new Set(['assets', ...assetFileNames.map(deriveAssetEnvKey)]); -} - export function stripAssetKeysFromConfigDto( config: ConfigDTO | undefined, assetFileNames: string[] = [], cloudAssets?: CloudAssetEnvRef[] ): ConfigDTO | undefined { - return omitAssetKeys(configDtoToEnvEntries(config), assetFileNames, cloudAssets); -} - -export function buildConfigDtoFromEnvConfigFile( - entries: EnvEntry[], - assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] -): ConfigDTO | undefined { - return omitAssetKeys(entries, assetFileNames, cloudAssets); + return stripAssetKeysFromEntries(configDtoToEnvEntries(config), assetFileNames, cloudAssets); } export const buildConfigDtoFromEnvEntries = dtoFromEntries; @@ -178,7 +111,7 @@ function localNonAssetConfigEntries( ): EnvEntry[] { if (!localEnv.envConfigPresent) return []; return configDtoToEnvEntries( - buildConfigDtoFromEnvConfigFile(localEnv.envConfig, assetFileNames, cloudAssets) + stripAssetKeysFromEntries(localEnv.envConfig, assetFileNames, cloudAssets) ); } @@ -214,7 +147,11 @@ export function pruneStaleAssetEnvEntries( if (entry.key === 'assets') return assetFileNames.length > 0; if (staleKeys.has(entry.key)) return false; const fileName = assetFileNameFromEnvValue(entry.value); - return !(fileName && !localFiles.has(fileName) && entry.key === deriveAssetEnvKey(fileName)); + return !( + fileName && + !localFiles.has(fileName) && + entry.key === convertNumbersInFilename(fileName) + ); }); } @@ -246,11 +183,13 @@ export function buildPushConfigDto( if (assetsBase) envVariables.assets = assetsBase; for (const fileName of assetFileNames) { + // new assets get env key+token from studio-uploadAsset; don't push local stubs + if (!ctx.cloudByFile.has(fileName)) continue; const envKey = resolveAssetEnvKey({ fileName, copyText: ctx.cloudByFile.get(fileName)?.copyText, }); - const value = localAsset[envKey] ?? localAsset[deriveAssetEnvKey(fileName)]; + const value = localAsset[envKey] ?? localAsset[convertNumbersInFilename(fileName)]; if (typeof value === 'string') envVariables[envKey] = value; } @@ -303,25 +242,21 @@ export async function readProjectEnvFiles( }; } -export function mergeAssetFileNamesForEnvCompare( - localAssetFileNames: string[] = [], - cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] -): string[] { - const fromCloud = (cloudAssets ?? []) - .filter((a) => a.isArchived !== true && typeof a.fileName === 'string' && a.fileName !== '') - .map((a) => a.fileName as string); - return [...new Set([...localAssetFileNames, ...fromCloud])]; -} - export function envConfigEntriesMatchCloud( localEntries: EnvEntry[], cloudConfig: ConfigDTO | undefined, assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] + cloudAssets?: CloudAssetEnvRef[], + assetsSyncEnabled = true ): boolean { + if (!assetsSyncEnabled) { + const plan = planDisabledEnvSync(localEntries, cloudConfig, cloudAssets); + return configEntriesEqual(plan.compareLocal, plan.compareCloud); + } + if ( !configEntriesEqual( - buildConfigDtoFromEnvConfigFile(localEntries, assetFileNames, cloudAssets), + stripAssetKeysFromEntries(localEntries, assetFileNames, cloudAssets), stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) ) ) { @@ -358,19 +293,33 @@ export function buildEnvPushDiff( localEnv: LocalEnvFiles, cloudEnv: CloudEnvState, assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] + cloudAssets?: CloudAssetEnvRef[], + assetsSyncEnabled = true ): EnvPushDiff { - const pushConfig = buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets); + const effectiveAssetFileNames = assetsSyncEnabled ? assetFileNames : []; + const disabledPlan = assetsSyncEnabled + ? undefined + : planDisabledEnvSync(localEnv.envConfig, cloudEnv.config, cloudAssets); + + const pushConfig = assetsSyncEnabled + ? buildPushConfigDto(localEnv, cloudEnv.config, effectiveAssetFileNames, cloudAssets) + : disabledPlan!.pushConfig; + const compareLocal = assetsSyncEnabled ? pushConfig : disabledPlan!.compareLocal; + const cloudConfigForCompare = assetsSyncEnabled ? cloudEnv.config : disabledPlan!.compareCloud; + const envSyncAssetFileNames = assetsSyncEnabled + ? effectiveAssetFileNames + : disabledPlan!.localAssetFileNames; + const wouldClearConfig = wouldClearConfigOnPush( localEnv, cloudEnv.config, - assetFileNames, + envSyncAssetFileNames, cloudAssets ); const wouldClearSecrets = wouldClearSecretsOnPush(localEnv, cloudEnv.secrets); const configChanged = wouldClearConfig || - (localEnv.envConfigPresent && !configEntriesEqual(pushConfig, cloudEnv.config)); + (localEnv.envConfigPresent && !configEntriesEqual(compareLocal, cloudConfigForCompare)); const secretsChanged = wouldClearSecrets || (localEnv.envSecretsPresent && @@ -408,14 +357,18 @@ export function computeEnvPullChanges( cloudConfig: ConfigDTO | undefined, cloudSecrets: SecretDTO | undefined, localAssetFileNames: string[] = [], - cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] + cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [], + assetsSyncEnabled = true ): EnvPullChanges { - const assetFileNames = mergeAssetFileNamesForEnvCompare(localAssetFileNames, cloudAssets); + const assetFileNames = assetsSyncEnabled + ? mergeAssetFileNamesForEnvCompare(localAssetFileNames, cloudAssets) + : []; const configMatch = envConfigEntriesMatchCloud( localEnv?.envConfig ?? [], cloudConfig, assetFileNames, - cloudAssets + cloudAssets, + assetsSyncEnabled ); const secretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudSecrets); const filesToUpdate: string[] = []; @@ -445,15 +398,23 @@ export async function prepareEnvPushState(params: { cloudEnv: CloudEnvState; assetFileNames: string[]; cloudAssets?: CloudAssetEnvRef[]; + assetsSyncEnabled?: boolean; }): Promise { + const assetsSyncEnabled = params.assetsSyncEnabled !== false; + const effectiveAssetFileNames = assetsSyncEnabled ? params.assetFileNames : []; const localEnvRaw = await readProjectEnvFiles( params.projectRoot, params.appKey, params.defaultAppKey ); - const prunedConfigSource = localEnvRaw.envConfigPresent - ? pruneStaleAssetEnvEntries(localEnvRaw.envConfig, params.assetFileNames, params.cloudAssets) - : localEnvRaw.envConfig; + const prunedConfigSource = + assetsSyncEnabled && localEnvRaw.envConfigPresent + ? pruneStaleAssetEnvEntries( + localEnvRaw.envConfig, + effectiveAssetFileNames, + params.cloudAssets + ) + : localEnvRaw.envConfig; const localEnv: LocalEnvFiles = { ...localEnvRaw, envConfig: prunedConfigSource, @@ -464,8 +425,9 @@ export async function prepareEnvPushState(params: { const diff = buildEnvPushDiff( localEnv, params.cloudEnv, - params.assetFileNames, - params.cloudAssets + effectiveAssetFileNames, + params.cloudAssets, + assetsSyncEnabled ); return { @@ -497,25 +459,37 @@ export async function applyCloudEnvToFs( cloudEnv: CloudEnvState, assetFileNames: string[] = [], appKey = 'default', - defaultAppKey = appKey + defaultAppKey = appKey, + cloudAssets?: CloudAssetEnvRef[], + assetsSyncEnabled = true ): Promise { const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); const configWriteFile = layout.configWriteFile; const secretsWriteFile = layout.secretsWriteFile; - const assetKeys = collectAssetEnvKeys(assetFileNames); + const assetKeys = assetsSyncEnabled + ? collectAssetEnvKeys(assetFileNames) + : getAssetEnvKeysToExclude( + await readEnvFile(projectRoot, configWriteFile), + assetFileNames, + cloudAssets + ); 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); + + if (assetsSyncEnabled) { + 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) + stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames, cloudAssets) ); await writeEnvFile(projectRoot, configWriteFile, [...keptAssetEntries, ...nonAssetEntries]); await writeEnvFile(projectRoot, secretsWriteFile, secretsDtoToEnvEntries(cloudEnv.secrets)); diff --git a/src/core/pullAssets.ts b/src/core/pullAssets.ts index 16d90d5..0307f26 100644 --- a/src/core/pullAssets.ts +++ b/src/core/pullAssets.ts @@ -4,6 +4,7 @@ import path from 'path'; import type { AssetDTO } from './dto.js'; import type { upsertEnvConfig } from './envConfig.js'; import { isIgnoredAssetFileName } from './assetIgnore.js'; +import { convertNumbersInFilename } from './assetEnv.js'; export class AssetPullError extends Error { constructor( @@ -72,12 +73,8 @@ function extractEnvKeyFromCopyText(copyText: string | undefined): string | undef return nonAssets.length === 1 ? nonAssets[0] : (nonAssets[0] ?? unique[0]); } -export function deriveAssetEnvKey(fileName: string): string { - return fileName.replace(/[^\w]+/g, '_'); -} - export function resolveAssetEnvKey(asset: { fileName: string; copyText?: string }): string { - return extractEnvKeyFromCopyText(asset.copyText) ?? deriveAssetEnvKey(asset.fileName); + return extractEnvKeyFromCopyText(asset.copyText) ?? convertNumbersInFilename(asset.fileName); } function tryDeriveAssetBaseAndValue( diff --git a/src/core/sync.ts b/src/core/sync.ts index e6534ee..f75ebba 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -53,6 +53,7 @@ export interface ComputePushPlanArgs { localApp: ApplicationDTO; cloudApp: CloudApp; enabledByProp: Record; + assetsEnabled?: boolean; updatedBy: { name: string; email?: string; id: string }; } @@ -147,7 +148,16 @@ function computePushSummary( } export function computePushPlan(args: ComputePushPlanArgs): PushPlan { - const { appId, appName, environment, localApp, cloudApp, enabledByProp, updatedBy } = args; + const { + appId, + appName, + environment, + localApp, + cloudApp, + enabledByProp, + assetsEnabled = true, + updatedBy, + } = args; const bundle = buildMergedBundle(localApp, cloudApp, updatedBy); let diff = computeBundleDiff(bundle, cloudApp, localApp); @@ -171,6 +181,10 @@ export function computePushPlan(args: ComputePushPlanArgs): PushPlan { } as BundleDiff; } + if (!assetsEnabled) { + diff = { ...diff, assets: { changed: [], new: [] } }; + } + const summary = computePushSummary(appId, appName, environment, diff); return { @@ -215,6 +229,7 @@ export interface ComputePullPlanArgs { localFiles: ParsedAppFiles; manifestExisting: RootManifest; enabledByProp: Record; + assetsEnabled?: boolean; localEnv?: LocalEnvFiles; } @@ -225,6 +240,7 @@ export function computePullPlan({ localFiles, manifestExisting, enabledByProp, + assetsEnabled = true, localEnv, }: ComputePullPlanArgs): PullPlan { const matchesByProp: Partial> = {}; @@ -296,7 +312,7 @@ export function computePullPlan({ // Asset files live under assets/ and are binary, so they are not part of ArtifactProps/ARTIFACT_FS_CONFIG. // Track their match separately so "Nothing to pull" is only true when assets also match. - { + if (assetsEnabled) { const cloudActiveAssets = ((cloudApp.assets ?? []) as AssetDTO[]).filter( (a) => a.isArchived !== true ); @@ -318,7 +334,8 @@ export function computePullPlan({ cloudApp.config, cloudApp.secrets, localFiles.assetFiles ?? [], - cloudApp.assets + cloudApp.assets, + assetsEnabled ); const envMatch = envPull.match; @@ -426,30 +443,32 @@ export function computePullPlan({ // Assets are binary files under assets/, so they are handled separately from ARTIFACT_FS_CONFIG. // We only plan create/delete based on file presence; we do not attempt to detect modifications. - const cloudActiveAssets = ((cloudApp.assets ?? []) as AssetDTO[]).filter( - (a) => a.isArchived !== true - ); - const expected = new Set(cloudActiveAssets.map((a) => a.fileName).filter(Boolean)); - const actual = new Set((localFiles.assetFiles ?? []).filter(Boolean)); + if (assetsEnabled) { + const cloudActiveAssets = ((cloudApp.assets ?? []) as AssetDTO[]).filter( + (a) => a.isArchived !== true + ); + const expected = new Set(cloudActiveAssets.map((a) => a.fileName).filter(Boolean)); + const actual = new Set((localFiles.assetFiles ?? []).filter(Boolean)); - for (const fileName of expected) { - if (!actual.has(fileName)) { - createdCount += 1; - changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'create' }); + for (const fileName of expected) { + if (!actual.has(fileName)) { + createdCount += 1; + changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'create' }); + } } - } - for (const fileName of actual) { - if (!expected.has(fileName)) { - deletedCount += 1; - changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'delete' }); + for (const fileName of actual) { + if (!expected.has(fileName)) { + deletedCount += 1; + changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'delete' }); + } } - } - // If the cloud has assets without publicUrl, we can't download them; count as skipped so the summary is honest. - const missingPublicUrl = cloudActiveAssets.filter( - (a) => !a.publicUrl || typeof a.publicUrl !== 'string' || a.publicUrl.trim() === '' - ).length; - skippedCount += missingPublicUrl; + // If the cloud has assets without publicUrl, we can't download them; count as skipped so the summary is honest. + const missingPublicUrl = cloudActiveAssets.filter( + (a) => !a.publicUrl || typeof a.publicUrl !== 'string' || a.publicUrl.trim() === '' + ).length; + skippedCount += missingPublicUrl; + } for (const envFile of envPull.filesToUpdate) { updatedCount += 1; diff --git a/tests/commands/add.test.ts b/tests/commands/add.test.ts index 7450d9f..7456998 100644 --- a/tests/commands/add.test.ts +++ b/tests/commands/add.test.ts @@ -31,31 +31,6 @@ const projectConfigMock = vi.hoisted(() => ({ vi.mock('../../src/config/projectConfig.js', () => projectConfigMock); -const authSessionMock = vi.hoisted(() => ({ - getValidAuthSession: vi.fn(async () => ({ - ok: true as const, - idToken: 'id-token', - userId: 'user-1', - refreshed: false, - })), -})); - -vi.mock('../../src/auth/session.js', () => authSessionMock); - -const assetClientMock = vi.hoisted(() => ({ - uploadAssetToStudio: vi.fn(async () => ({ - success: true, - assetBaseUrl: 'https://cdn.example.com/assets/', - envVariable: { - key: 'image_2_png', - value: 'image-2.png?alt=media&token=abc', - }, - usageKey: '${env.assets}${env.image_2_png}', - })), -})); - -vi.mock('../../src/cloud/assetClient.js', () => assetClientMock); - import { addCommand } from '../../src/commands/add.js'; describe('addCommand asset', () => { @@ -76,7 +51,7 @@ describe('addCommand asset', () => { vi.clearAllMocks(); }); - it('copies asset, uploads it, and writes .env.config keys', async () => { + it('copies asset locally and writes stub .env.config keys without cloud upload', async () => { const sourceFile = path.join(projectRoot, 'logo.png'); await fs.writeFile(sourceFile, Buffer.from([1, 2, 3, 4])); @@ -85,16 +60,9 @@ describe('addCommand asset', () => { const copied = await fs.readFile(path.join(projectRoot, 'assets', 'logo.png')); expect(copied.equals(Buffer.from([1, 2, 3, 4]))).toBe(true); - const uploadMock = assetClientMock.uploadAssetToStudio as ReturnType; - expect(uploadMock).toHaveBeenCalledTimes(1); - expect(uploadMock.mock.calls[0]?.[0]).toBe('app-1'); - expect(uploadMock.mock.calls[0]?.[1]).toBe('logo.png'); - expect(typeof uploadMock.mock.calls[0]?.[2]).toBe('string'); - expect(uploadMock.mock.calls[0]?.[3]).toBe('id-token'); - const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/assets/'); - expect(envConfig).toContain('image_2_png=image-2.png?alt=media&token=abc'); + expect(envConfig).toContain('logo_png=logo.png'); + expect(envConfig).not.toContain('token='); expect(envConfig.includes('\n\n')).toBe(false); }); @@ -108,7 +76,7 @@ describe('addCommand asset', () => { expect(copied.equals(Buffer.from([1, 2, 3]))).toBe(true); }); - it('preserves existing assets key and appends env variable key', async () => { + it('preserves existing assets key and appends local env variable key', async () => { await fs.writeFile( path.join(projectRoot, '.env.config'), ['assets=https://existing.example.com/base/', 'EXTRA=value'].join('\n') + '\n', @@ -122,7 +90,18 @@ describe('addCommand asset', () => { const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); expect(envConfig).toContain('assets=https://existing.example.com/base/'); expect(envConfig).toContain('EXTRA=value'); - expect(envConfig).toContain('image_2_png=image-2.png?alt=media&token=abc'); + expect(envConfig).toContain('logo2_png=logo2.png'); + }); + + it('writes env key using studio convertNumbersInFilename rules', async () => { + const sourceFile = path.join(projectRoot, 't-3265 (1).png'); + await fs.writeFile(sourceFile, Buffer.from([1, 2, 3])); + + await addCommand('asset', sourceFile); + + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + expect(envConfig).toContain('t_3265__1__png=t-3265 (1).png'); + expect(envConfig).not.toContain('t_3265_1_png='); }); it('errors when asset already exists (non-interactive)', async () => { diff --git a/tests/commands/pushPull.test.ts b/tests/commands/pushPull.test.ts index 8bfe090..c6ddd5c 100644 --- a/tests/commands/pushPull.test.ts +++ b/tests/commands/pushPull.test.ts @@ -88,15 +88,17 @@ const cloudModuleMock = vi.hoisted(() => { vi.mock('../../src/cloud/firestoreClient.js', () => cloudModuleMock); +import { convertNumbersInFilename } from '../../src/core/assetEnv.js'; + const assetClientMock = vi.hoisted(() => ({ uploadAssetToStudio: vi.fn(async (_appId: string, fileName: string) => ({ success: true, assetBaseUrl: 'https://cdn.example.com/assets/', envVariable: { - key: fileName.replace(/[^\w]+/g, '_'), + key: convertNumbersInFilename(fileName), value: `${fileName}?token=abc`, }, - usageKey: '${env.assets}${env.file}', + usageKey: `\${env.assets}\${env.${convertNumbersInFilename(fileName)}}`, })), })); @@ -253,6 +255,86 @@ describe('push/pull integration (commands)', () => { expect(submitCliPush).not.toHaveBeenCalled(); }); + it('push respects options.assets false and does not plan cloud asset deletes', async () => { + appOptionsRef.value = { assets: false }; + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + assets: [ + { + id: 'a1', + name: 'wifi_scan.json', + fileName: 'wifi_scan.json', + content: '', + type: 'asset', + }, + ] as unknown[], + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await pushCommand({ verbose: false, dryRun: true }); + + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + expect(submitCliPush).not.toHaveBeenCalled(); + + const lines = logSpy.mock.calls.map(([msg]) => String(msg)); + expect(lines.some((line) => line.includes('wifi_scan'))).toBe(false); + expect( + lines.some((line) => + line.includes('Skipping assets (options.assets: false in ensemble.config.json)') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('add asset locally then push uploads to cloud', async () => { + const sourceFile = path.join(projectRoot, 'logo.png'); + await fs.writeFile(sourceFile, Buffer.from([1, 2, 3])); + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + await fs.mkdir(path.join(projectRoot, 'assets'), { recursive: true }); + await fs.copyFile(sourceFile, path.join(projectRoot, 'assets', 'logo.png')); + await fs.writeFile(path.join(projectRoot, '.env.config'), 'logo_png=logo.png\n', 'utf8'); + + await pushCommand({ verbose: false, yes: true }); + + const uploadAssetMock = assetClientMock.uploadAssetToStudio as ReturnType; + expect(uploadAssetMock).toHaveBeenCalledTimes(1); + expect(uploadAssetMock.mock.calls[0]?.[1]).toBe('logo.png'); + + const envAfterPush = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + expect(envAfterPush).toContain('assets=https://cdn.example.com/assets/'); + expect(envAfterPush).toContain('logo_png=logo.png?token=abc'); + + const { submitEnvDocumentsPush } = cloudModuleMock; + const envPushCalls = (submitEnvDocumentsPush as ReturnType).mock.calls; + for (const call of envPushCalls) { + const config = (call[2] as { config?: { envVariables?: Record } })?.config; + const logoValue = config?.envVariables?.logo_png; + if (logoValue !== undefined) { + expect(logoValue).toContain('token='); + } + } + }); + it('push dry run shows diff but does not submit payload', async () => { // Arrange: create a minimal Home screen plus a simple local file and cloud app with no existing artifacts. await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); @@ -632,6 +714,52 @@ describe('push/pull integration (commands)', () => { expect(Object.keys(files.screens)).toEqual([]); }); + it('pull respects options.assets false and does not sync assets', async () => { + appOptionsRef.value = { assets: false }; + await fs.mkdir(path.join(projectRoot, 'assets'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'assets', 'local-only.png'), Buffer.from([1])); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + assets: [ + { + id: 'a1', + name: 'cloud-only.png', + fileName: 'cloud-only.png', + content: '', + type: 'asset', + publicUrl: 'https://cdn.example.com/cloud-only.png', + }, + ] as unknown[], + config: { + envVariables: { + assets: 'https://cdn.example.com/', + cloud_only_png: 'cloud-only.png?token=abc', + }, + }, + }); + + await pullCommand({ verbose: false, yes: true }); + + const files = await collectAppFiles(projectRoot); + expect(files.assetFiles).toEqual(['local-only.png']); + await expect(fs.access(path.join(projectRoot, 'assets', 'cloud-only.png'))).rejects.toThrow(); + }); + it('pull dry run shows summary but does not modify files', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/tests/core/assetEnv.test.ts b/tests/core/assetEnv.test.ts new file mode 100644 index 0000000..495c7f1 --- /dev/null +++ b/tests/core/assetEnv.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { convertNumbersInFilename } from '../../src/core/assetEnv.js'; + +describe('convertNumbersInFilename', () => { + it('handles filename with no numbers', () => { + expect(convertNumbersInFilename('image.png')).toBe('image_png'); + }); + + it('keeps underscores between special characters', () => { + expect(convertNumbersInFilename('t-3265 (1).png')).toBe('t_3265__1__png'); + }); + + it('converts leading numbers to words', () => { + expect(convertNumbersInFilename('16789675.png')).toBe( + 'sixteenmillionsevenhundredeightyninethousandsixhundredseventyfive_png' + ); + }); + + it('does not convert numbers that are not at the start', () => { + expect(convertNumbersInFilename('asset16789675.png')).toBe('asset16789675_png'); + }); +}); diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 97dd683..58f93ba 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -191,6 +191,7 @@ describe('envSync', () => { }, }, assets: ['logo.png'], + cloudAssets: [{ fileName: 'logo.png' }], configChanged: true, secretsChanged: false, cloudConfig: { @@ -264,6 +265,128 @@ describe('envSync', () => { } ); + it('buildEnvPushDiff ignores cloud asset env keys when assets sync is disabled', () => { + const diff = buildEnvPushDiff( + localEnvFromParts([{ key: 'E1', value: 'EV1' }]), + { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + wifi_scan_json: 'wifi_scan.json?token=abc', + E1: 'EV1', + }, + }, + }, + [], + [{ fileName: 'wifi_scan.json' }], + false + ); + expect(diff.configChanged).toBe(false); + expect(diff.local).toEqual({}); + }); + + it('buildEnvPushDiff ignores local-only asset env stubs when assets sync is disabled', () => { + const diff = buildEnvPushDiff( + localEnvFromParts( + [{ key: 'E1', value: 'EK1' }], + [ + { + key: 'MIH_4723_Unable_to_delete_phone_numbers_from_WebUI_pdf', + value: 'MIH-4723.Unable.to.delete.phone.numbers.from.WebUI.pdf', + }, + ] + ), + { + config: { + envVariables: { + E1: 'EK1', + }, + }, + }, + [], + [], + false + ); + expect(diff.configChanged).toBe(false); + expect(diff.local).toEqual({}); + }); + + it('buildEnvPushDiff ignores cloud and local asset env keys when assets sync is disabled', () => { + const diff = buildEnvPushDiff( + localEnvFromParts( + [ + { key: 'E1', value: 'EK1' }, + { key: 'E11', value: 'EK11' }, + ], + [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'kwnd_png', value: 'kwnd.png?alt=media&token=abc' }, + { + key: 'MIH_4723_Unable_to_delete_phone_numbers_from_WebUI_pdf', + value: 'MIH-4723.Unable.to.delete.phone.numbers.from.WebUI.pdf?alt=media&token=abc', + }, + ] + ), + { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + kwnd_png: 'kwnd.png?alt=media&token=def', + E1: 'EK1', + E11: 'EK11', + }, + }, + }, + [], + [ + { fileName: 'kwnd.png' }, + { fileName: 'MIH-4723.Unable.to.delete.phone.numbers.from.WebUI.pdf' }, + ], + false + ); + expect(diff.configChanged).toBe(false); + expect(diff.local).toEqual({}); + }); + + it('buildEnvPushDiff preserves cloud asset env vars in push payload when assets sync is disabled', () => { + const diff = buildEnvPushDiff( + localEnvFromParts( + [ + { key: 'E1', value: 'EK1' }, + { key: 'E11', value: 'EK12' }, + ], + [ + { key: 'assets', value: 'https://local.example.com/' }, + { key: 'kwnd_png', value: 'kwnd.png' }, + ] + ), + { + config: { + envVariables: { + assets: 'https://cloud.example.com/', + kwnd_png: 'kwnd.png?alt=media&token=abc', + E1: 'EK1', + E11: 'EK11', + }, + baseUrl: 'https://anserwaseem.com/', + useBrowserUrl: true, + }, + }, + [], + [{ fileName: 'kwnd.png' }], + false + ); + expect(diff.configChanged).toBe(true); + expect(diff.local.config?.envVariables).toEqual({ + assets: 'https://cloud.example.com/', + kwnd_png: 'kwnd.png?alt=media&token=abc', + E1: 'EK1', + E11: 'EK12', + }); + expect(diff.local.config?.baseUrl).toBe('https://anserwaseem.com/'); + expect(diff.local.config?.useBrowserUrl).toBe(true); + }); + it('buildPushConfigDto keeps local assets and drops deleted cloud asset keys', () => { expect( buildPushConfigDto( @@ -284,7 +407,8 @@ describe('envSync', () => { E1: 'EV1', }, }, - ['logo.png'] + ['logo.png'], + [{ fileName: 'logo.png' }] ).envVariables ).toEqual({ assets: 'https://cdn.example.com/', @@ -338,6 +462,25 @@ describe('envSync', () => { ).toEqual({ E1: 'EV11' }); }); + it('buildPushConfigDto omits local stubs for assets not yet in cloud', () => { + expect( + buildPushConfigDto( + localEnvFromParts( + [{ key: 'E1', value: 'EV1' }], + [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 't_3265__1__png', value: 't-3265 (1).png' }, + ] + ), + { envVariables: { E1: 'EV1' } }, + ['t-3265 (1).png'] + ).envVariables + ).toEqual({ + assets: 'https://cdn.example.com/', + E1: 'EV1', + }); + }); + it.each<{ name: string; entries: Array<{ key: string; value: string }>; diff --git a/tests/core/sync.test.ts b/tests/core/sync.test.ts index 7af2960..c6c5364 100644 --- a/tests/core/sync.test.ts +++ b/tests/core/sync.test.ts @@ -463,6 +463,76 @@ describe('computePullPlan + computePushPlan consistency', () => { pullPlan.summary.changes.some((c) => c.kind === 'asset' && c.operation === 'create') ).toBe(true); }); + + it('computePushPlan clears asset diff when assetsEnabled is false', () => { + const cloudApp: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + assets: [asset('a1', 'wifi_scan.json')], + }; + const localFiles: ParsedAppFiles = { + screens: {}, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + assetFiles: [], + }; + const localApp = buildDocumentsFromParsed(localFiles, 'app1', 'App'); + const pushPlan = computePushPlan({ + appId: 'app1', + appName: 'App', + environment: 'dev', + localApp, + cloudApp, + enabledByProp, + assetsEnabled: false, + updatedBy: { name: 'Test', id: 'u1' }, + }); + + expect(pushPlan.diff.assets.changed).toHaveLength(0); + expect(pushPlan.diff.assets.new).toHaveLength(0); + expect(pushPlan.summary.byKind.assets.deleted).toBe(0); + }); + + it('computePullPlan ignores asset drift when assetsEnabled is false', () => { + const cloudApp: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + assets: [ + asset('a1', 'cloud-only.png', { publicUrl: 'https://cdn.example.com/cloud-only.png' }), + ], + }; + const localFiles: ParsedAppFiles = { + screens: {}, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + assetFiles: ['local-only.png'], + }; + + const pullPlan = computePullPlan({ + appName: 'App', + environment: 'dev', + cloudApp, + localFiles, + manifestExisting: emptyManifest, + enabledByProp, + assetsEnabled: false, + }); + + expect(pullPlan.summary.changes.some((c) => c.kind === 'asset')).toBe(false); + expect(pullPlan.allArtifactsMatch).toBe(true); + }); }); describe('ARTIFACT_FS_CONFIG', () => {