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
11 changes: 11 additions & 0 deletions .changeset/job-import-path-subset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@salesforce/b2c-cli': minor
'@salesforce/b2c-tooling-sdk': minor
'@salesforce/b2c-agent-plugins': patch
---

`b2c job import` now accepts an optional list of paths or globs after the directory `TARGET`, allowing you to import a subset of a site export. Paths are resolved literally first (so shell-expanded globs work) and fall back to root-relative or internal glob expansion when the literal path doesn't exist. The archive preserves each path's layout under `TARGET`.

Example: `b2c job import ./my-site-data sites/RefArch libraries/mylib`

The SDK's `siteArchiveImport` operation gains a corresponding `paths` option for directory targets.
13 changes: 12 additions & 1 deletion docs/cli/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,15 @@ Import a site archive to a B2C Commerce instance using the `sfcc-site-archive-im
### Usage

```bash
b2c job import TARGET
b2c job import TARGET [PATHS...]
```

### Arguments

| Argument | Description | Required |
|----------|-------------|----------|
| `TARGET` | Directory, zip file, or remote filename to import | Yes |
| `PATHS...` | Optional subset of files, directories, or glob patterns under `TARGET` to include in the archive. When omitted, the entire directory is archived. Only valid when `TARGET` is a directory. | No |

### Flags

Expand Down Expand Up @@ -297,13 +298,23 @@ b2c job import existing-archive.zip --remote

# With timeout
b2c job import ./my-site-data --timeout 300

# Import only specific parts of a site export
b2c job import ./my-site-data sites/RefArch libraries/mylib

# Import all libraries using a glob pattern
b2c job import ./my-site-data 'libraries/**'

# Mix sites and libraries
b2c job import ./my-site-data sites/RefArch 'libraries/*'
```

### Notes

- When importing a directory, it will be automatically zipped before upload
- The archive is uploaded to `Impex/src/instance/` on the instance
- By default, the archive is deleted after successful import (use `--keep-archive` to retain)
- When `PATHS` are given, only those files/directories are included in the archive — their location under `TARGET` is preserved (e.g. `sites/RefArch/...` stays at `sites/RefArch/...`).

---

Expand Down
17 changes: 17 additions & 0 deletions packages/b2c-cli/src/commands/job/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export default class JobImport extends JobCommand<typeof JobImport> {
'<%= config.bin %> <%= command.id %> ./export.zip',
'<%= config.bin %> <%= command.id %> ./my-site-data --keep-archive',
'<%= config.bin %> <%= command.id %> existing-archive.zip --remote',
'<%= config.bin %> <%= command.id %> ./my-site-data sites/RefArch libraries/mylib',
"<%= config.bin %> <%= command.id %> ./my-site-data 'libraries/**'",
];

static flags = {
Expand Down Expand Up @@ -70,6 +72,10 @@ export default class JobImport extends JobCommand<typeof JobImport> {
}),
};

// Allow additional positionals after `target` to specify a subset of
// paths/globs to include from a directory import.
static strict = false;

protected operations = {
siteArchiveImport,
};
Expand All @@ -78,7 +84,12 @@ export default class JobImport extends JobCommand<typeof JobImport> {
this.requireOAuthCredentials();
this.requireWebDavCredentials();

const {argv} = await this.parse(JobImport);
const {target} = this.args;
// Additional positionals after `target` are paths/globs to include from
// a directory import. Variadic args from oclif arrive in `argv` after the
// declared positionals, so drop the first element (which is `target`).
const extraPaths = (argv as string[]).slice(1);
const {
wait,
'keep-archive': keepArchive,
Expand All @@ -88,6 +99,10 @@ export default class JobImport extends JobCommand<typeof JobImport> {
'show-log': showLog = true,
} = this.flags;

if (extraPaths.length > 0 && remote) {
this.error('Path arguments are not supported with --remote.');
}

const hostname = this.resolvedConfig.values.hostname!;

// Safety evaluation — check rules for import job before executing.
Expand All @@ -106,6 +121,7 @@ export default class JobImport extends JobCommand<typeof JobImport> {
remote,
keepArchive,
hostname,
paths: extraPaths.length > 0 ? extraPaths : undefined,
});

// Run beforeOperation hooks - check for skip
Expand Down Expand Up @@ -153,6 +169,7 @@ export default class JobImport extends JobCommand<typeof JobImport> {
const result = await this.operations.siteArchiveImport(this.instance, importTarget, {
keepArchive,
wait,
paths: extraPaths.length > 0 ? extraPaths : undefined,
waitOptions: {
timeoutSeconds: timeout,
pollIntervalSeconds: pollInterval,
Expand Down
71 changes: 69 additions & 2 deletions packages/b2c-cli/test/commands/job/import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ describe('job import', () => {

afterEach(hooks.afterEach);

async function createCommand(flags: Record<string, unknown>, args: Record<string, unknown>) {
return createTestCommand(JobImport, hooks.getConfig(), flags, args);
async function createCommand(flags: Record<string, unknown>, args: Record<string, unknown>, argv: string[] = []) {
return createTestCommand(JobImport, hooks.getConfig(), flags, args, argv);
}

function stubCommon(command: any) {
Expand Down Expand Up @@ -135,6 +135,73 @@ describe('job import', () => {
expect(options.wait).to.equal(true);
});

it('forwards extra positionals as paths to siteArchiveImport', async () => {
const command: any = await createCommand({json: true}, {target: './my-site-data'}, [
'./my-site-data',
'sites/RefArch',
'libraries/mylib',
]);
stubCommon(command);

sinon.stub(command, 'runBeforeHooks').resolves({skip: false});
sinon.stub(command, 'runAfterHooks').resolves(void 0);

const importStub = sinon.stub().resolves({
execution: {execution_status: 'finished', exit_status: {code: 'OK'}} as any,
archiveFilename: 'a.zip',
archiveKept: false,
});
command.operations = {...command.operations, siteArchiveImport: importStub};

await command.run();

expect(importStub.calledOnce).to.equal(true);
const options = importStub.getCall(0).args[2];
expect(options.paths).to.deep.equal(['sites/RefArch', 'libraries/mylib']);
});

it('does not pass paths option when no extra positionals are given', async () => {
const command: any = await createCommand({json: true}, {target: './dir'});
stubCommon(command);

sinon.stub(command, 'runBeforeHooks').resolves({skip: false});
sinon.stub(command, 'runAfterHooks').resolves(void 0);

const importStub = sinon.stub().resolves({
execution: {execution_status: 'finished', exit_status: {code: 'OK'}} as any,
archiveFilename: 'a.zip',
archiveKept: false,
});
command.operations = {...command.operations, siteArchiveImport: importStub};

await command.run();

const options = importStub.getCall(0).args[2];
expect(options.paths).to.equal(undefined);
});

it('errors when extra positionals are combined with --remote', async () => {
const command: any = await createCommand({remote: true, json: true}, {target: 'a.zip'}, ['a.zip', 'sites/RefArch']);
stubCommon(command);

sinon.stub(command, 'runBeforeHooks').resolves({skip: false});

const importStub = sinon.stub().rejects(new Error('Should not be called'));
command.operations = {...command.operations, siteArchiveImport: importStub};

const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error'));

try {
await command.run();
expect.fail('Should have thrown');
} catch {
// expected
}

expect(errorStub.called).to.equal(true);
expect(importStub.called).to.equal(false);
});

it('shows job log and errors on JobExecutionError when show-log is true', async () => {
const command: any = await createCommand({json: true}, {target: './dir'});
stubCommon(command);
Expand Down
135 changes: 132 additions & 3 deletions packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {glob, hasMagic} from 'glob';
import JSZip from 'jszip';
import {B2CInstance} from '../../instance/index.js';
import {getLogger} from '../../logging/logger.js';
Expand All @@ -30,6 +31,18 @@ export interface SiteArchiveImportOptions {
wait?: boolean;
/** Wait options for job completion */
waitOptions?: WaitForJobOptions;
/**
* Optional list of paths or glob patterns to include in the archive when
* importing from a directory. Each entry may be:
* - An absolute path under the root directory
* - A path relative to the root directory
* - A glob pattern (relative to the root) — magic chars `* ? [ ] { }`
*
* When omitted, the entire root directory is archived (current behavior).
* Entries that resolve outside the root directory throw an error.
* Ignored when target is a zip file, Buffer, or remote filename.
*/
paths?: string[];
}

/**
Expand Down Expand Up @@ -101,12 +114,18 @@ export async function siteArchiveImport(
options: SiteArchiveImportOptions & {archiveName?: string} = {},
): Promise<SiteArchiveImportResult> {
const logger = getLogger();
const {keepArchive = false, wait = true, waitOptions, archiveName} = options;
const {keepArchive = false, wait = true, waitOptions, archiveName, paths} = options;

let zipFilename: string;
let needsUpload = true;
let archiveContent: Buffer | NodeJS.ReadableStream | undefined;

if (paths && paths.length > 0) {
if (Buffer.isBuffer(target) || (typeof target === 'object' && 'remoteFilename' in target)) {
throw new Error('paths option is only supported when target is a directory');
}
}

// Handle different input types
if (typeof target === 'object' && 'remoteFilename' in target) {
// Remote filename - no upload needed
Expand Down Expand Up @@ -138,6 +157,9 @@ export async function siteArchiveImport(
const stat = await fs.promises.stat(targetPath);

if (stat.isFile()) {
if (paths && paths.length > 0) {
throw new Error('paths option is only supported when target is a directory');
}
// Existing zip file
archiveContent = await fs.promises.readFile(targetPath);
zipFilename = path.basename(targetPath);
Expand All @@ -147,8 +169,17 @@ export async function siteArchiveImport(
const archiveDirName = archiveName || `import-${timestamp}`;
zipFilename = `${archiveDirName}.zip`;

logger.debug({path: targetPath}, `Creating archive from directory: ${targetPath}`);
archiveContent = await createArchiveFromDirectory(targetPath, archiveDirName);
if (paths && paths.length > 0) {
const resolved = await resolveSubsetPaths(targetPath, paths);
logger.debug(
{path: targetPath, count: resolved.length},
`Creating archive from ${resolved.length} path(s) under: ${targetPath}`,
);
archiveContent = await createArchiveFromPaths(targetPath, resolved, archiveDirName);
} else {
logger.debug({path: targetPath}, `Creating archive from directory: ${targetPath}`);
archiveContent = await createArchiveFromDirectory(targetPath, archiveDirName);
}
} else {
throw new Error(`Target must be a file or directory: ${targetPath}`);
}
Expand Down Expand Up @@ -235,6 +266,104 @@ export async function siteArchiveImport(
};
}

/**
* Resolves a list of user-provided paths/globs against a root directory.
*
* Each entry is matched in this order:
* 1. If it exists as-is (absolute or cwd-relative), use it.
* 2. Else if it exists when resolved against the root directory, use that.
* 3. Else if it contains glob magic characters, expand against the root.
*
* All resolved paths must live under the root directory.
*/
async function resolveSubsetPaths(rootDir: string, entries: string[]): Promise<string[]> {
const logger = getLogger();
const rootAbs = path.resolve(rootDir);
const matched = new Set<string>();

for (const entry of entries) {
const candidates: string[] = [];
let resolutionMode: 'literal' | 'root-relative' | 'glob';

// Try literal path resolution first (handles shell-expanded paths)
const asGiven = path.resolve(entry);
const asRootRelative = path.resolve(rootAbs, entry);
if (fs.existsSync(asGiven)) {
candidates.push(asGiven);
resolutionMode = 'literal';
} else if (asGiven !== asRootRelative && fs.existsSync(asRootRelative)) {
candidates.push(asRootRelative);
resolutionMode = 'root-relative';
} else if (hasMagic(entry)) {
// Glob expansion (always relative to root, not cwd)
const matches = await glob(entry, {cwd: rootAbs, absolute: true, dot: true, nodir: false});
if (matches.length === 0) {
throw new Error(`No files matched pattern: ${entry}`);
}
candidates.push(...matches);
resolutionMode = 'glob';
} else {
throw new Error(`Path not found: ${entry}`);
}

logger.debug(
{entry, mode: resolutionMode, matches: candidates.map((c) => path.relative(rootAbs, c))},
`Resolved "${entry}" (${resolutionMode}) → ${candidates.length} match(es)`,
);

for (const candidate of candidates) {
const rel = path.relative(rootAbs, candidate);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Path is outside import root (${rootAbs}): ${candidate}`);
}
matched.add(candidate);
}
}

return [...matched];
}

/**
* Creates a zip archive from a specific set of files/directories under a root.
*
* Each entry's path inside the archive is its location relative to `rootDir`,
* preserved under `archiveDirName/`. Directories are recursed; files are added
* as-is.
*/
async function createArchiveFromPaths(rootDir: string, entries: string[], archiveDirName: string): Promise<Buffer> {
const logger = getLogger();
const zip = new JSZip();
const rootFolder = zip.folder(archiveDirName)!;
const rootAbs = path.resolve(rootDir);

for (const entry of entries) {
const stat = await fs.promises.stat(entry);
const rel = path.relative(rootAbs, entry);
const relPosix = rel.split(path.sep).join('/');

if (stat.isDirectory()) {
// Create the nested folder hierarchy and recurse
const folder = relPosix ? rootFolder.folder(relPosix)! : rootFolder;
await addDirectoryToZip(folder, entry);
} else if (stat.isFile()) {
const content = await fs.promises.readFile(entry);
rootFolder.file(relPosix, content);
}
}

// After all entries are added, log the final list of files in the archive so
// users can verify exactly what was included (especially after directory
// recursion, which is otherwise opaque from the path arguments alone).
const archivedFiles = Object.keys(zip.files).filter((p) => !zip.files[p].dir);
logger.debug({count: archivedFiles.length, files: archivedFiles}, `Archive contains ${archivedFiles.length} file(s)`);

return zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: {level: 9},
});
}

/**
* Creates a zip archive from a directory.
*/
Expand Down
Loading
Loading