Skip to content
Merged
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
50 changes: 50 additions & 0 deletions packages/b2c-cli/src/commands/mrt/env/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,48 @@ function printEnvDetails(env: MrtEnvironment, project: string): void {
ui.div({text: 'Log Level:', width: labelWidth}, {text: env.log_level});
}

if (env.ssr_proxy_configs && env.ssr_proxy_configs.length > 0) {
ui.div({text: 'Proxies:', width: labelWidth}, {text: ''});
for (const proxy of env.ssr_proxy_configs) {
const proxyPath = (proxy as {path?: string}).path ?? '';
ui.div({text: '', width: labelWidth}, {text: ` ${proxyPath} → ${proxy.host}`});
}
}

ux.stdout(ui.toString());
}

/**
* Proxy configuration for SSR.
*/
interface SsrProxyConfig {
host: string;
path: string;
}

/**
* Parse a proxy string in format "path=host" into a proxy config object.
*/
function parseProxyString(proxyStr: string): SsrProxyConfig {
const eqIndex = proxyStr.indexOf('=');
if (eqIndex === -1) {
throw new Error(`Invalid proxy format: "${proxyStr}". Expected format: path=host.example.com`);
}

const path = proxyStr.slice(0, eqIndex);
const host = proxyStr.slice(eqIndex + 1);

if (!path) {
throw new Error(`Invalid proxy format: "${proxyStr}". Path cannot be empty.`);
}

if (!host) {
throw new Error(`Invalid proxy format: "${proxyStr}". Host cannot be empty.`);
}

return {path, host};
}

/**
* Valid AWS regions for MRT environments.
*/
Expand Down Expand Up @@ -99,6 +138,7 @@ export default class MrtEnvCreate extends MrtCommand<typeof MrtEnvCreate> {
'<%= config.bin %> <%= command.id %> staging --project my-storefront --name "Staging Environment"',
'<%= config.bin %> <%= command.id %> production --project my-storefront --name "Production" --production',
'<%= config.bin %> <%= command.id %> feature-test -p my-storefront -n "Feature Test" --region eu-west-1',
'<%= config.bin %> <%= command.id %> staging -p my-storefront -n "Staging" --proxy api=api.example.com --proxy ocapi=ocapi.example.com',
];

static flags = {
Expand Down Expand Up @@ -136,6 +176,10 @@ export default class MrtEnvCreate extends MrtCommand<typeof MrtEnvCreate> {
default: false,
allowNo: true,
}),
proxy: Flags.string({
description: 'Proxy configuration in format path=host (can be specified multiple times)',
multiple: true,
}),
};

async run(): Promise<MrtEnvironment> {
Expand All @@ -159,8 +203,12 @@ export default class MrtEnvCreate extends MrtCommand<typeof MrtEnvCreate> {
'external-domain': externalDomain,
'allow-cookies': allowCookies,
'enable-source-maps': enableSourceMaps,
proxy: proxyStrings,
} = this.flags;

// Parse proxy configurations
const proxyConfigs = proxyStrings?.map((p) => parseProxyString(p));

this.log(
t('commands.mrt.env.create.creating', 'Creating environment "{{slug}}" in {{project}}...', {slug, project}),
);
Expand All @@ -178,6 +226,8 @@ export default class MrtEnvCreate extends MrtCommand<typeof MrtEnvCreate> {
externalDomain,
allowCookies: allowCookies || undefined,
enableSourceMaps: enableSourceMaps || undefined,
proxyConfigs,
origin: this.resolvedConfig.mrtOrigin,
},
this.getMrtAuth(),
);
Expand Down
1 change: 1 addition & 0 deletions packages/b2c-cli/src/commands/mrt/env/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default class MrtEnvDelete extends MrtCommand<typeof MrtEnvDelete> {
{
projectSlug: project,
slug,
origin: this.resolvedConfig.mrtOrigin,
},
this.getMrtAuth(),
);
Expand Down
1 change: 1 addition & 0 deletions packages/b2c-cli/src/commands/mrt/env/var/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default class MrtEnvVarDelete extends MrtCommand<typeof MrtEnvVarDelete>
projectSlug: project,
environment,
key,
origin: this.resolvedConfig.mrtOrigin,
},
this.getMrtAuth(),
);
Expand Down
1 change: 1 addition & 0 deletions packages/b2c-cli/src/commands/mrt/env/var/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default class MrtEnvVarList extends MrtCommand<typeof MrtEnvVarList> {
{
projectSlug: project,
environment,
origin: this.resolvedConfig.mrtOrigin,
},
this.getMrtAuth(),
);
Expand Down
1 change: 1 addition & 0 deletions packages/b2c-cli/src/commands/mrt/env/var/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export default class MrtEnvVarSet extends MrtCommand<typeof MrtEnvVarSet> {
projectSlug: project,
environment,
variables,
origin: this.resolvedConfig.mrtOrigin,
},
this.getMrtAuth(),
);
Expand Down
3 changes: 2 additions & 1 deletion packages/b2c-cli/src/commands/mrt/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default class MrtPush extends MrtCommand<typeof MrtPush> {
}),
'ssr-only': Flags.string({
description: 'Glob patterns for server-only files (comma-separated)',
default: 'ssr.js,server/**/*',
default: 'ssr.js,ssr.mjs,server/**/*',
}),
'ssr-shared': Flags.string({
description: 'Glob patterns for shared files (comma-separated)',
Expand Down Expand Up @@ -111,6 +111,7 @@ export default class MrtPush extends MrtCommand<typeof MrtPush> {
ssrOnly,
ssrShared,
ssrParameters,
origin: this.resolvedConfig.mrtOrigin,
},
this.getMrtAuth(),
);
Expand Down
33 changes: 27 additions & 6 deletions packages/b2c-tooling/src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface ResolvedConfig {
mrtProject?: string;
/** MRT environment name (e.g., staging, production) */
mrtEnvironment?: string;
/** MRT API origin URL override */
mrtOrigin?: string;
instanceName?: string;
/** Allowed authentication methods (in priority order). If not set, all methods are allowed. */
authMethods?: AuthMethod[];
Expand Down Expand Up @@ -185,6 +187,7 @@ function mergeConfigs(
mrtApiKey: flags.mrtApiKey,
mrtProject: flags.mrtProject || dwJson.mrtProject,
mrtEnvironment: flags.mrtEnvironment || dwJson.mrtEnvironment,
mrtOrigin: flags.mrtOrigin,
instanceName: dwJson.instanceName || options.instance,
authMethods: flags.authMethods || dwJson.authMethods,
};
Expand Down Expand Up @@ -228,16 +231,34 @@ export interface MobifyConfigResult {
* }
* ```
*
* When a cloudOrigin is provided, looks for ~/.mobify--[cloudOrigin] instead.
* For example, if cloudOrigin is "https://cloud-staging.mobify.com", the file
* would be ~/.mobify--cloud-staging.mobify.com
*
* @param cloudOrigin - Optional cloud origin URL to determine which config file to read
* @returns The API key and username if found, undefined otherwise
*/
export function loadMobifyConfig(): MobifyConfigResult {
export function loadMobifyConfig(cloudOrigin?: string): MobifyConfigResult {
const logger = getLogger();
const mobifyPath = path.join(os.homedir(), '.mobify');

logger.trace({path: mobifyPath}, '[Config] Checking for ~/.mobify');
let mobifyPath: string;
if (cloudOrigin) {
// Extract hostname from origin URL for the config file suffix
try {
const url = new URL(cloudOrigin);
mobifyPath = path.join(os.homedir(), `.mobify--${url.hostname}`);
} catch {
// If URL parsing fails, use the origin as-is
mobifyPath = path.join(os.homedir(), `.mobify--${cloudOrigin}`);
}
} else {
mobifyPath = path.join(os.homedir(), '.mobify');
}

logger.trace({path: mobifyPath}, '[Config] Checking for mobify config');

if (!fs.existsSync(mobifyPath)) {
logger.trace('[Config] No ~/.mobify found');
logger.trace({path: mobifyPath}, '[Config] No mobify config found');
return {};
}

Expand All @@ -246,14 +267,14 @@ export function loadMobifyConfig(): MobifyConfigResult {
const config = JSON.parse(content) as MobifyConfig;

const hasApiKey = Boolean(config.api_key);
logger.trace({path: mobifyPath, hasApiKey, username: config.username}, '[Config] Loaded ~/.mobify');
logger.trace({path: mobifyPath, hasApiKey, username: config.username}, '[Config] Loaded mobify config');

return {
apiKey: config.api_key,
username: config.username,
};
} catch (error) {
logger.trace({path: mobifyPath, error}, '[Config] Failed to parse ~/.mobify');
logger.trace({path: mobifyPath, error}, '[Config] Failed to parse mobify config');
return {};
}
}
20 changes: 17 additions & 3 deletions packages/b2c-tooling/src/cli/mrt-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {ApiKeyStrategy} from '../auth/api-key.js';
import {MrtClient} from '../platform/mrt.js';
import type {MrtProject} from '../platform/mrt.js';
import {t} from '../i18n/index.js';
import {DEFAULT_MRT_ORIGIN} from '../clients/mrt.js';

/**
* Base command for Managed Runtime (MRT) operations.
Expand All @@ -15,12 +16,17 @@ import {t} from '../i18n/index.js';
* API key resolution order:
* 1. --api-key flag
* 2. SFCC_MRT_API_KEY environment variable
* 3. ~/.mobify config file (api_key field)
* 3. ~/.mobify config file (api_key field), or ~/.mobify--[hostname] if --cloud-origin is set
*
* Project/environment resolution order:
* 1. --project / --environment flags
* 2. SFCC_MRT_PROJECT / SFCC_MRT_ENVIRONMENT environment variables
* 3. dw.json (mrtProject / mrtEnvironment fields)
*
* Cloud origin resolution:
* 1. --cloud-origin flag
* 2. SFCC_MRT_CLOUD_ORIGIN environment variable
* 3. Default: https://cloud.mobify.com
*/
export abstract class MrtCommand<T extends typeof Command> extends BaseCommand<T> {
static baseFlags = {
Expand All @@ -40,6 +46,10 @@ export abstract class MrtCommand<T extends typeof Command> extends BaseCommand<T
description: 'MRT environment (e.g., staging, production; or set mrtEnvironment in dw.json)',
env: 'SFCC_MRT_ENVIRONMENT',
}),
'cloud-origin': Flags.string({
description: `MRT cloud origin URL (default: ${DEFAULT_MRT_ORIGIN})`,
env: 'SFCC_MRT_CLOUD_ORIGIN',
}),
};

protected override loadConfiguration(): ResolvedConfig {
Expand All @@ -48,15 +58,19 @@ export abstract class MrtCommand<T extends typeof Command> extends BaseCommand<T
configPath: this.flags.config,
};

// Load from ~/.mobify as fallback
const mobifyConfig = loadMobifyConfig();
const cloudOrigin = this.flags['cloud-origin'] as string | undefined;

// Load from ~/.mobify (or ~/.mobify--[hostname] if cloud-origin specified) as fallback
const mobifyConfig = loadMobifyConfig(cloudOrigin);

const flagConfig: Partial<ResolvedConfig> = {
// Flag/env takes precedence, then ~/.mobify
mrtApiKey: this.flags['api-key'] || mobifyConfig.apiKey,
// Project/environment from flags (if present - subclasses define these)
mrtProject: this.flags.project as string | undefined,
mrtEnvironment: this.flags.environment as string | undefined,
// Cloud origin override
mrtOrigin: cloudOrigin,
};

return loadConfig(flagConfig, options);
Expand Down
7 changes: 6 additions & 1 deletion packages/b2c-tooling/src/clients/mrt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ export const DEFAULT_MRT_ORIGIN = 'https://cloud.mobify.com';
* });
*/
export function createMrtClient(config: MrtClientConfig, auth: AuthStrategy): MrtClient {
const origin = config.origin || DEFAULT_MRT_ORIGIN;
let origin = config.origin || DEFAULT_MRT_ORIGIN;

// Normalize origin: add https:// if no protocol specified
if (origin && !origin.startsWith('http://') && !origin.startsWith('https://')) {
origin = `https://${origin}`;
}

const client = createClient<paths>({
baseUrl: origin,
Expand Down
17 changes: 17 additions & 0 deletions packages/b2c-tooling/src/operations/mrt/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ export interface CreateEnvOptions {
*/
whitelistedIps?: string;

/**
* Proxy configurations for SSR.
* Each proxy maps a path prefix to a backend host.
*/
proxyConfigs?: Array<{
/** The path prefix to proxy (e.g., 'api', 'ocapi', 'einstein'). */
path: string;
/** The backend host to proxy to (e.g., 'api.example.com'). */
host: string;
}>;

/**
* MRT API origin URL.
* @default "https://cloud.mobify.com"
Expand Down Expand Up @@ -162,6 +173,12 @@ export async function createEnv(options: CreateEnvOptions, auth: AuthStrategy):
body.ssr_whitelisted_ips = options.whitelistedIps;
}

if (options.proxyConfigs && options.proxyConfigs.length > 0) {
// The API accepts ssr_proxy_configs - cast to handle the path field
// which may not be in the generated types but is accepted by the API
body.ssr_proxy_configs = options.proxyConfigs as typeof body.ssr_proxy_configs;
}

const {data, error} = await client.POST('/api/projects/{project_slug}/target/', {
params: {
path: {project_slug: projectSlug},
Expand Down