diff --git a/.changeset/job-import-path-subset.md b/.changeset/job-import-path-subset.md new file mode 100644 index 00000000..175292fb --- /dev/null +++ b/.changeset/job-import-path-subset.md @@ -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. diff --git a/docs/cli/jobs.md b/docs/cli/jobs.md index fd6244da..6325cba6 100644 --- a/docs/cli/jobs.md +++ b/docs/cli/jobs.md @@ -260,7 +260,7 @@ 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 @@ -268,6 +268,7 @@ b2c job import TARGET | 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 @@ -297,6 +298,15 @@ 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 @@ -304,6 +314,7 @@ b2c job import ./my-site-data --timeout 300 - 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/...`). --- diff --git a/packages/b2c-cli/src/commands/job/import.ts b/packages/b2c-cli/src/commands/job/import.ts index e39280de..509130d6 100644 --- a/packages/b2c-cli/src/commands/job/import.ts +++ b/packages/b2c-cli/src/commands/job/import.ts @@ -35,6 +35,8 @@ export default class JobImport extends JobCommand { '<%= 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 = { @@ -70,6 +72,10 @@ export default class JobImport extends JobCommand { }), }; + // Allow additional positionals after `target` to specify a subset of + // paths/globs to include from a directory import. + static strict = false; + protected operations = { siteArchiveImport, }; @@ -78,7 +84,12 @@ export default class JobImport extends JobCommand { 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, @@ -88,6 +99,10 @@ export default class JobImport extends JobCommand { '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. @@ -106,6 +121,7 @@ export default class JobImport extends JobCommand { remote, keepArchive, hostname, + paths: extraPaths.length > 0 ? extraPaths : undefined, }); // Run beforeOperation hooks - check for skip @@ -153,6 +169,7 @@ export default class JobImport extends JobCommand { const result = await this.operations.siteArchiveImport(this.instance, importTarget, { keepArchive, wait, + paths: extraPaths.length > 0 ? extraPaths : undefined, waitOptions: { timeoutSeconds: timeout, pollIntervalSeconds: pollInterval, diff --git a/packages/b2c-cli/test/commands/job/import.test.ts b/packages/b2c-cli/test/commands/job/import.test.ts index f954d400..8687d038 100644 --- a/packages/b2c-cli/test/commands/job/import.test.ts +++ b/packages/b2c-cli/test/commands/job/import.test.ts @@ -18,8 +18,8 @@ describe('job import', () => { afterEach(hooks.afterEach); - async function createCommand(flags: Record, args: Record) { - return createTestCommand(JobImport, hooks.getConfig(), flags, args); + async function createCommand(flags: Record, args: Record, argv: string[] = []) { + return createTestCommand(JobImport, hooks.getConfig(), flags, args, argv); } function stubCommon(command: any) { @@ -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); diff --git a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts index eb034180..7d93b693 100644 --- a/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts +++ b/packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts @@ -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'; @@ -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[]; } /** @@ -101,12 +114,18 @@ export async function siteArchiveImport( options: SiteArchiveImportOptions & {archiveName?: string} = {}, ): Promise { 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 @@ -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); @@ -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}`); } @@ -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 { + const logger = getLogger(); + const rootAbs = path.resolve(rootDir); + const matched = new Set(); + + 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 { + 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. */ diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts index 94c718ce..69a077c5 100644 --- a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts @@ -122,6 +122,166 @@ describe('operations/jobs/site-archive', () => { expect(jobExecuted).to.be.true; }); + it('should import only the listed paths under a directory root', async () => { + // Build a multi-area site export + const root = path.join(tempDir, 'site-data'); + fs.mkdirSync(path.join(root, 'sites', 'RefArch'), {recursive: true}); + fs.mkdirSync(path.join(root, 'sites', 'Other'), {recursive: true}); + fs.mkdirSync(path.join(root, 'libraries', 'mylib'), {recursive: true}); + fs.writeFileSync(path.join(root, 'sites', 'RefArch', 'site.xml'), ''); + fs.writeFileSync(path.join(root, 'sites', 'Other', 'site.xml'), ''); + fs.writeFileSync(path.join(root, 'libraries', 'mylib', 'library.xml'), ''); + + let uploadedZip: Buffer | null = null; + + server.use( + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + const url = new URL(request.url); + if (request.method === 'PUT' && url.pathname.includes('Impex/src/instance/')) { + uploadedZip = Buffer.from(await request.arrayBuffer()); + return new HttpResponse(null, {status: 201}); + } + return new HttpResponse(null, {status: 204}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => + HttpResponse.json({id: 'exec-subset', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-subset`, () => + HttpResponse.json({ + id: 'exec-subset', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }), + ), + ); + + const result = await siteArchiveImport(mockInstance, root, { + archiveName: 'subset-import', + paths: ['sites/RefArch', 'libraries/mylib'], + waitOptions: FAST_WAIT_OPTIONS, + }); + + expect(result.execution.id).to.equal('exec-subset'); + expect(uploadedZip).to.not.be.null; + + const resultZip = await JSZip.loadAsync(uploadedZip!); + const entries = Object.keys(resultZip.files).filter((p) => !resultZip.files[p].dir); + expect(entries).to.include('subset-import/sites/RefArch/site.xml'); + expect(entries).to.include('subset-import/libraries/mylib/library.xml'); + expect(entries).to.not.include('subset-import/sites/Other/site.xml'); + }); + + it('should expand glob patterns relative to the import root', async () => { + const root = path.join(tempDir, 'site-data'); + fs.mkdirSync(path.join(root, 'libraries', 'lib-a'), {recursive: true}); + fs.mkdirSync(path.join(root, 'libraries', 'lib-b'), {recursive: true}); + fs.mkdirSync(path.join(root, 'sites', 'RefArch'), {recursive: true}); + fs.writeFileSync(path.join(root, 'libraries', 'lib-a', 'library.xml'), ''); + fs.writeFileSync(path.join(root, 'libraries', 'lib-b', 'library.xml'), ''); + fs.writeFileSync(path.join(root, 'sites', 'RefArch', 'site.xml'), ''); + + let uploadedZip: Buffer | null = null; + + server.use( + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + if (request.method === 'PUT') { + uploadedZip = Buffer.from(await request.arrayBuffer()); + return new HttpResponse(null, {status: 201}); + } + return new HttpResponse(null, {status: 204}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => + HttpResponse.json({id: 'exec-glob', execution_status: 'finished', exit_status: {code: 'OK'}}), + ), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-glob`, () => + HttpResponse.json({ + id: 'exec-glob', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }), + ), + ); + + await siteArchiveImport(mockInstance, root, { + archiveName: 'glob-import', + paths: ['libraries/*'], + waitOptions: FAST_WAIT_OPTIONS, + }); + + const resultZip = await JSZip.loadAsync(uploadedZip!); + const entries = Object.keys(resultZip.files).filter((p) => !resultZip.files[p].dir); + expect(entries).to.include('glob-import/libraries/lib-a/library.xml'); + expect(entries).to.include('glob-import/libraries/lib-b/library.xml'); + expect(entries).to.not.include('glob-import/sites/RefArch/site.xml'); + }); + + it('should reject paths that escape the import root', async () => { + const root = path.join(tempDir, 'site-data'); + fs.mkdirSync(path.join(root, 'inside'), {recursive: true}); + fs.writeFileSync(path.join(root, 'inside', 'a.xml'), ''); + + // Sibling directory outside the root + const outside = path.join(tempDir, 'outside'); + fs.mkdirSync(outside, {recursive: true}); + fs.writeFileSync(path.join(outside, 'b.xml'), ''); + + try { + await siteArchiveImport(mockInstance, root, { + archiveName: 'escape-test', + paths: [path.join(outside, 'b.xml')], + waitOptions: FAST_WAIT_OPTIONS, + }); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('outside import root'); + } + }); + + it('should throw when a non-glob path does not exist', async () => { + const root = path.join(tempDir, 'site-data'); + fs.mkdirSync(root, {recursive: true}); + + try { + await siteArchiveImport(mockInstance, root, { + paths: ['sites/Missing'], + waitOptions: FAST_WAIT_OPTIONS, + }); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Path not found'); + } + }); + + it('should throw when a glob matches nothing', async () => { + const root = path.join(tempDir, 'site-data'); + fs.mkdirSync(path.join(root, 'libraries'), {recursive: true}); + + try { + await siteArchiveImport(mockInstance, root, { + paths: ['libraries/*.xml'], + waitOptions: FAST_WAIT_OPTIONS, + }); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('No files matched'); + } + }); + + it('should reject paths option when target is a remote filename', async () => { + try { + await siteArchiveImport( + mockInstance, + {remoteFilename: 'archive.zip'}, + {paths: ['anything'], waitOptions: FAST_WAIT_OPTIONS}, + ); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('only supported when target is a directory'); + } + }); + it('should import from a zip file', async () => { // Create a test zip file const zipPath = path.join(tempDir, 'test.zip'); diff --git a/skills/b2c-cli/skills/b2c-job/SKILL.md b/skills/b2c-cli/skills/b2c-job/SKILL.md index f4fa0c94..10c2f7e2 100644 --- a/skills/b2c-cli/skills/b2c-job/SKILL.md +++ b/skills/b2c-cli/skills/b2c-job/SKILL.md @@ -68,6 +68,11 @@ b2c job import existing-archive.zip --remote # show job log on failure b2c job import ./my-site-data --show-log + +# import only a subset of a directory (extra positionals are paths/globs +# resolved against the directory; preserves layout inside the archive) +b2c job import ./my-site-data sites/RefArch libraries/mylib +b2c job import ./my-site-data 'libraries/**' ``` ### Export Site Archives