Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 18 additions & 23 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
});
Expand All @@ -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),
};
}

Expand Down Expand Up @@ -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;
}

Expand Down
138 changes: 86 additions & 52 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await applyCloudEnvToFs(
projectRoot,
{ config: cloudApp.config, secrets: cloudApp.secrets },
assetFileNames,
appKey,
defaultAppKey,
cloudApp.assets,
assetsSyncEnabled
);
}

export async function pullCommand(options: PullOptions = {}): Promise<void> {
const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey);
const verbose = resolveVerboseFlag(options.verbose);
Expand All @@ -155,6 +180,7 @@ export async function pullCommand(options: PullOptions = {}): Promise<void> {
const enabledByProp = Object.fromEntries(
ArtifactProps.map((prop) => [prop, appOptions[prop] !== false])
) as Record<ArtifactProp, boolean>;
const assetsEnabled = appOptions.assets !== false;

const session = await getValidAuthSession();
if (!session.ok) {
Expand Down Expand Up @@ -229,6 +255,7 @@ export async function pullCommand(options: PullOptions = {}): Promise<void> {
localFiles,
manifestExisting,
enabledByProp,
assetsEnabled,
localEnv: await readProjectEnvFiles(projectRoot, appKey, config.default),
});

Expand Down Expand Up @@ -295,63 +322,70 @@ export async function pullCommand(options: PullOptions = {}): Promise<void> {

// 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.<key>} 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.<key>} 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);
}
19 changes: 19 additions & 0 deletions src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
const enabledByProp = Object.fromEntries(
ArtifactProps.map((prop) => [prop, appOptions[prop] !== false])
) as Record<ArtifactProp, boolean>;
const assetsEnabled = appOptions.assets !== false;

const session = await getValidAuthSession();
if (!session.ok) {
Expand Down Expand Up @@ -295,10 +296,27 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
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,
Expand All @@ -307,6 +325,7 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets },
assetFileNames,
cloudAssets: cloudApp.assets,
assetsSyncEnabled: assetsEnabled,
});
const {
diff: envPushDiff,
Expand Down
28 changes: 28 additions & 0 deletions src/core/assetEnv.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading