Skip to content

Commit 13e4000

Browse files
committed
basic scaffold of command and core
1 parent 1f9d88d commit 13e4000

File tree

23 files changed

+726
-23
lines changed

23 files changed

+726
-23
lines changed

packages/b2c-cli/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
"topics": {
6262
"hello": {
6363
"description": "Say hello to the world and others"
64+
},
65+
"code": {
66+
"description": "Manage cartridge code on instances"
6467
}
6568
}
6669
},
@@ -71,7 +74,7 @@
7174
"postpack": "shx rm -f oclif.manifest.json",
7275
"posttest": "pnpm run lint",
7376
"prepack": "oclif manifest && oclif readme",
74-
"pretest": "tsc --noEmit && tsc --noEmit -p test",
77+
"pretest": "tsc --noEmit -p test",
7578
"test": "OCLIF_TEST_ROOT=. mocha --forbid-only \"test/**/*.test.ts\"",
7679
"version": "oclif readme && git add README.md"
7780
},
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Args, Command, Flags } from '@oclif/core';
2+
import { B2CInstance, uploadCartridges } from '@salesforce/b2c-tooling';
3+
import { loadConfig } from '../../config/loader.js';
4+
import { AuthResolver } from '../../config/auth-resolver.js';
5+
6+
export default class Deploy extends Command {
7+
static args = {
8+
cartridgePath: Args.string({
9+
description: 'Path to cartridges directory',
10+
default: './cartridges',
11+
}),
12+
};
13+
14+
static description = 'Deploy cartridges to a B2C Commerce instance';
15+
16+
static examples = [
17+
'<%= config.bin %> <%= command.id %>',
18+
'<%= config.bin %> <%= command.id %> ./my-cartridges',
19+
'<%= config.bin %> <%= command.id %> --hostname my-sandbox.demandware.net --code-version v1',
20+
];
21+
22+
static flags = {
23+
hostname: Flags.string({
24+
char: 'h',
25+
description: 'Instance hostname',
26+
}),
27+
'code-version': Flags.string({
28+
char: 'v',
29+
description: 'Code version to deploy to',
30+
}),
31+
username: Flags.string({
32+
char: 'u',
33+
description: 'Username for Basic Auth',
34+
}),
35+
password: Flags.string({
36+
char: 'p',
37+
description: 'Password for Basic Auth',
38+
}),
39+
'client-id': Flags.string({
40+
description: 'Client ID for OAuth',
41+
}),
42+
'client-secret': Flags.string({
43+
description: 'Client Secret for OAuth',
44+
}),
45+
};
46+
47+
async run(): Promise<void> {
48+
const { args, flags } = await this.parse(Deploy);
49+
50+
// 1. Load Config with precedence (CLI > Env > dw.json)
51+
const config = await loadConfig({
52+
hostname: flags.hostname,
53+
codeVersion: flags['code-version'],
54+
username: flags.username,
55+
password: flags.password,
56+
clientId: flags['client-id'],
57+
clientSecret: flags['client-secret'],
58+
});
59+
60+
// Validate required config
61+
if (!config.hostname) {
62+
this.error('Hostname is required. Set via --hostname, DW_HOSTNAME env var, or dw.json');
63+
}
64+
65+
if (!config.codeVersion) {
66+
this.error(
67+
'Code version is required. Set via --code-version, DW_CODE_VERSION env var, or dw.json'
68+
);
69+
}
70+
71+
// 2. Create Auth Resolver
72+
const resolver = new AuthResolver(config);
73+
74+
if (!resolver.hasWebDavCredentials()) {
75+
this.error(
76+
'No valid credentials found. Provide username/password or clientId/clientSecret.'
77+
);
78+
}
79+
80+
// 3. Create Instance with WebDAV Auth Strategy
81+
const instance = new B2CInstance(
82+
{
83+
hostname: config.hostname,
84+
codeVersion: config.codeVersion,
85+
},
86+
resolver.getForWebDav()
87+
);
88+
89+
// 4. Execute the operation
90+
this.log(`Deploying cartridges from ${args.cartridgePath}...`);
91+
this.log(`Target: ${config.hostname}`);
92+
this.log(`Code Version: ${config.codeVersion}`);
93+
94+
try {
95+
await uploadCartridges(instance, args.cartridgePath);
96+
this.log('✓ Deployment complete');
97+
} catch (error) {
98+
if (error instanceof Error) {
99+
this.error(`Deployment failed: ${error.message}`);
100+
}
101+
throw error;
102+
}
103+
}
104+
}
Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
1-
import {Args, Command, Flags} from '@oclif/core'
2-
import {hello} from '@salesforce/b2c-tooling'
1+
import { Args, Command, Flags } from '@oclif/core';
32

43
export default class Hello extends Command {
54
static args = {
6-
person: Args.string({description: 'Person to say hello to', required: true}),
7-
}
8-
static description = 'Say hello'
5+
person: Args.string({ description: 'Person to say hello to', required: true }),
6+
};
7+
8+
static description = 'Say hello';
9+
910
static examples = [
1011
`<%= config.bin %> <%= command.id %> friend --from oclif
11-
hello friend from oclif! (./src/commands/hello/index.ts)
12+
hello friend from oclif!
1213
`,
13-
]
14+
];
15+
1416
static flags = {
15-
from: Flags.string({char: 'f', description: 'Who is saying hello', required: true}),
16-
}
17+
from: Flags.string({ char: 'f', description: 'Who is saying hello', required: true }),
18+
};
1719

1820
async run(): Promise<void> {
19-
const {args, flags} = await this.parse(Hello)
21+
const { args, flags } = await this.parse(Hello);
2022

21-
this.log(`${hello(args.person, flags.from)} (./src/commands/hello/index.ts)`)
23+
this.log(`hello ${args.person} from ${flags.from}!`);
2224
}
2325
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
AuthStrategy,
3+
BasicAuthStrategy,
4+
OAuthStrategy,
5+
ApiKeyStrategy,
6+
} from '@salesforce/b2c-tooling';
7+
import { ResolvedConfig } from './loader.js';
8+
9+
/**
10+
* The Auth Resolver is the "Mix-Mode" brain.
11+
* It decides which authentication strategy to use based on available credentials
12+
* and the specific operation being performed.
13+
*/
14+
export class AuthResolver {
15+
constructor(private config: ResolvedConfig) {}
16+
17+
/**
18+
* Resolution Strategy: WebDAV
19+
* Preference: Basic (Stability) -> OAuth (Fallback)
20+
*
21+
* Basic auth is preferred for WebDAV because it's simpler and more reliable
22+
* for file operations. OAuth is used as a fallback when credentials aren't available.
23+
*/
24+
getForWebDav(): AuthStrategy {
25+
if (this.config.username && this.config.password) {
26+
return new BasicAuthStrategy(this.config.username, this.config.password);
27+
}
28+
29+
// Fallback to OAuth if no password provided
30+
if (this.config.clientId && this.config.clientSecret) {
31+
return new OAuthStrategy({
32+
clientId: this.config.clientId,
33+
clientSecret: this.config.clientSecret,
34+
});
35+
}
36+
37+
throw new Error(
38+
'No valid WebDAV credentials found. Provide either username/password or clientId/clientSecret.'
39+
);
40+
}
41+
42+
/**
43+
* Resolution Strategy: OCAPI
44+
* Preference: OAuth (Required)
45+
*
46+
* OCAPI always requires OAuth authentication.
47+
*/
48+
getForApi(): AuthStrategy {
49+
if (this.config.clientId && this.config.clientSecret) {
50+
return new OAuthStrategy({
51+
clientId: this.config.clientId,
52+
clientSecret: this.config.clientSecret,
53+
});
54+
}
55+
56+
throw new Error('OCAPI requires Client ID and Secret.');
57+
}
58+
59+
/**
60+
* Resolution Strategy: MRT
61+
* Preference: API Key
62+
*
63+
* MRT uses API key authentication.
64+
*/
65+
getForMrt(): AuthStrategy {
66+
if (this.config.mrtApiKey) {
67+
return new ApiKeyStrategy(this.config.mrtApiKey);
68+
}
69+
70+
throw new Error('MRT requires an API Key.');
71+
}
72+
73+
/**
74+
* Check if WebDAV credentials are available.
75+
*/
76+
hasWebDavCredentials(): boolean {
77+
return Boolean(
78+
(this.config.username && this.config.password) ||
79+
(this.config.clientId && this.config.clientSecret)
80+
);
81+
}
82+
83+
/**
84+
* Check if API credentials are available.
85+
*/
86+
hasApiCredentials(): boolean {
87+
return Boolean(this.config.clientId && this.config.clientSecret);
88+
}
89+
90+
/**
91+
* Check if MRT credentials are available.
92+
*/
93+
hasMrtCredentials(): boolean {
94+
return Boolean(this.config.mrtApiKey);
95+
}
96+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
4+
export interface ResolvedConfig {
5+
hostname?: string;
6+
codeVersion?: string;
7+
username?: string; // For Basic Auth
8+
password?: string; // For Basic Auth
9+
clientId?: string; // For OAuth
10+
clientSecret?: string; // For OAuth
11+
mrtApiKey?: string; // For MRT
12+
}
13+
14+
interface DwJson {
15+
hostname?: string;
16+
'code-version'?: string;
17+
username?: string;
18+
password?: string;
19+
'client-id'?: string;
20+
'client-secret'?: string;
21+
}
22+
23+
/**
24+
* Loads configuration with precedence: CLI flags > Environment variables > dw.json
25+
*/
26+
export async function loadConfig(flags: Partial<ResolvedConfig> = {}): Promise<ResolvedConfig> {
27+
// Load dw.json if it exists
28+
const dwJsonConfig = loadDwJson();
29+
30+
// Load from environment variables
31+
const envConfig: ResolvedConfig = {
32+
hostname: process.env.DW_HOSTNAME || process.env.SFCC_HOSTNAME,
33+
codeVersion: process.env.DW_CODE_VERSION || process.env.SFCC_CODE_VERSION,
34+
username: process.env.DW_USERNAME || process.env.SFCC_USERNAME,
35+
password: process.env.DW_PASSWORD || process.env.SFCC_PASSWORD,
36+
clientId: process.env.DW_CLIENT_ID || process.env.SFCC_CLIENT_ID,
37+
clientSecret: process.env.DW_CLIENT_SECRET || process.env.SFCC_CLIENT_SECRET,
38+
mrtApiKey: process.env.DW_MRT_API_KEY || process.env.SFCC_MRT_API_KEY,
39+
};
40+
41+
// Merge with precedence: flags > env > dw.json
42+
return {
43+
hostname: flags.hostname || envConfig.hostname || dwJsonConfig.hostname,
44+
codeVersion: flags.codeVersion || envConfig.codeVersion || dwJsonConfig.codeVersion,
45+
username: flags.username || envConfig.username || dwJsonConfig.username,
46+
password: flags.password || envConfig.password || dwJsonConfig.password,
47+
clientId: flags.clientId || envConfig.clientId || dwJsonConfig.clientId,
48+
clientSecret: flags.clientSecret || envConfig.clientSecret || dwJsonConfig.clientSecret,
49+
mrtApiKey: flags.mrtApiKey || envConfig.mrtApiKey,
50+
};
51+
}
52+
53+
/**
54+
* Loads configuration from dw.json file in current directory or parents.
55+
*/
56+
function loadDwJson(): ResolvedConfig {
57+
const dwJsonPath = findDwJson();
58+
if (!dwJsonPath) {
59+
return {};
60+
}
61+
62+
try {
63+
const content = fs.readFileSync(dwJsonPath, 'utf8');
64+
const json = JSON.parse(content) as DwJson;
65+
66+
return {
67+
hostname: json.hostname,
68+
codeVersion: json['code-version'],
69+
username: json.username,
70+
password: json.password,
71+
clientId: json['client-id'],
72+
clientSecret: json['client-secret'],
73+
};
74+
} catch {
75+
// Silently ignore parse errors
76+
return {};
77+
}
78+
}
79+
80+
/**
81+
* Finds dw.json by walking up from current directory.
82+
*/
83+
function findDwJson(): string | null {
84+
let dir = process.cwd();
85+
const root = path.parse(dir).root;
86+
87+
while (dir !== root) {
88+
const dwJsonPath = path.join(dir, 'dw.json');
89+
if (fs.existsSync(dwJsonPath)) {
90+
return dwJsonPath;
91+
}
92+
dir = path.dirname(dir);
93+
}
94+
95+
return null;
96+
}

packages/b2c-cli/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
export {run} from '@oclif/core'
1+
export { run } from '@oclif/core';
2+
3+
// Config utilities for programmatic use
4+
export { loadConfig, ResolvedConfig } from './config/loader.js';
5+
export { AuthResolver } from './config/auth-resolver.js';
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"root":["./src/index.ts","./src/commands/hello/index.ts","./src/commands/hello/world.ts"],"version":"5.9.3"}
1+
{"root":["./src/index.ts","./src/commands/code/deploy.ts","./src/commands/hello/index.ts","./src/commands/hello/world.ts","./src/config/auth-resolver.ts","./src/config/loader.ts"],"version":"5.9.3"}

0 commit comments

Comments
 (0)