sfmc-dataloader 2.4.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,57 @@
1
1
  # sfmc-dataloader
2
2
 
3
- Command-line tool **`mcdata`** to export and import **Salesforce Marketing Cloud Engagement** Data Extension rows using the same project files as [mcdev](https://github.com/Accenture/sfmc-devtools) (`.mcdevrc.json`, `.mcdev-auth.json`) and [sfmc-sdk](https://www.npmjs.com/package/sfmc-sdk) for REST/SOAP.
3
+ Command-line tool **`mcdata`** to export and import **Salesforce Marketing Cloud Engagement** Data Extension rows using [sfmc-sdk](https://www.npmjs.com/package/sfmc-sdk) for REST/SOAP.
4
+
5
+ Works **standalone** — no mcdev installation required — and also integrates with existing [mcdev](https://github.com/Accenture/sfmc-devtools) projects.
6
+
7
+ ## Config files
8
+
9
+ | File pair | When to use |
10
+ |---|---|
11
+ | `.mcdevrc.json` + `.mcdev-auth.json` | Existing mcdev projects — **always wins** when both pairs present |
12
+ | `.mcdatarc.json` + `.mcdata-auth.json` | Standalone setup; created by `mcdata init` |
13
+
14
+ **Precedence:** When `.mcdevrc.json` and `.mcdev-auth.json` both exist they are loaded; any mcdata files are ignored (a warning is printed to stderr). Both file pairs share the same logical shape (`credentials.<name>.businessUnits`), so all commands work with either layout.
15
+
16
+ ## Standalone setup with `mcdata init`
17
+
18
+ If you don't have an existing mcdev project, run:
19
+
20
+ ```bash
21
+ mcdata init
22
+ ```
23
+
24
+ The interactive wizard collects:
25
+
26
+ 1. Credential name (e.g. `MyOrg`)
27
+ 2. Installed-package **Client ID**
28
+ 3. Installed-package **Client Secret**
29
+ 4. **Auth URL** (e.g. `https://<tenantsubdomain>.auth.marketingcloudapis.com/`)
30
+ 5. **Enterprise MID** (parent account ID)
31
+
32
+ It fetches your Business Unit list via the SOAP API, writes `.mcdatarc.json` and `.mcdata-auth.json`, and adds the auth file to `.gitignore`.
33
+
34
+ ### Non-interactive / CI mode
35
+
36
+ Pass all five flags to skip prompts:
37
+
38
+ ```bash
39
+ mcdata init \
40
+ --credential MyOrg \
41
+ --client-id <id> \
42
+ --client-secret <secret> \
43
+ --auth-url https://<tenantsubdomain>.auth.marketingcloudapis.com/ \
44
+ --enterprise-id <eid> \
45
+ --yes
46
+ ```
47
+
48
+ Use `--yes` to overwrite existing `.mcdatarc.json` / `.mcdata-auth.json` without confirmation.
4
49
 
5
50
  ## Requirements
6
51
 
7
52
  - Node.js `^20.19.0 || ^22.13.0 || >=24` (aligned with `sfmc-sdk`)
8
- - A mcdev-style project with credentials on disk
9
- - Peer: `mcdev` `>=7` (declare alongside your project tooling)
53
+ - An SFMC installed package with Data Extension access
54
+ - **Optional:** `mcdev` `>=7` listed in `optionalDependencies`; install globally if you also use mcdev for other metadata types
10
55
 
11
56
  ## Install
12
57
 
@@ -128,7 +173,15 @@ Interactive: type `YES` when prompted. In CI, add `--i-accept-clear-data-risk` a
128
173
  | `--clear-before-import` | SOAP `ClearData` before REST import |
129
174
  | `--i-accept-clear-data-risk` | Non-interactive consent for clear |
130
175
 
131
- Log lines use paths **relative** to the project root (POSIX-style, `./…`) and include **row counts** where applicable.
176
+ Log lines include **row counts** and show file paths as **absolute paths in double-quotes** (e.g. `"C:\data\MyCred\DEV\Contact.mcdata.csv"`) so they are clickable in VS Code's integrated terminal.
177
+
178
+ ## Programmatic API (Node.js)
179
+
180
+ Import from `sfmc-dataloader` for use in other tools (for example the **SFMC Data Loader** VS Code extension):
181
+
182
+ | Export | Purpose |
183
+ |--------|---------|
184
+ | `fetchDeList(projectRoot, credential, bu)` | Returns all Data Extension **names** and **customer keys** for one BU via SOAP `retrieveBulk` (pagination handled by **sfmc-sdk**). Not a CLI command — intended for in-process callers that already have mcdev/mcdata config on disk. |
132
185
 
133
186
  ## License
134
187
 
package/bin/mcdata.mjs CHANGED
@@ -4,6 +4,6 @@ import { main } from '../lib/cli.mjs';
4
4
  main(process.argv)
5
5
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
6
6
  .catch((ex) => {
7
- console.error(ex);
7
+ console.error(ex.message ?? String(ex));
8
8
  process.exit(1);
9
9
  });
@@ -0,0 +1,93 @@
1
+ import SDK from 'sfmc-sdk';
2
+ import { buildSdkOptions } from './config.mjs';
3
+
4
+ /**
5
+ * Normalize a Business Unit name to a safe identifier, mirroring mcdev's convention:
6
+ * strip non-word/non-space characters, replace spaces with underscores, collapse consecutive underscores.
7
+ *
8
+ * @param {string} name - Raw BU name from the API
9
+ * @returns {string}
10
+ */
11
+ export function normalizeBuName(name) {
12
+ return name
13
+ .replaceAll(/[^\w\s]/gi, '')
14
+ .replaceAll(/ +/g, '_')
15
+ .replaceAll(/__+/g, '_');
16
+ }
17
+
18
+ /**
19
+ * @typedef {object} BusinessUnitsResult
20
+ * @property {number} eid - Enterprise MID (ID of the parent BU)
21
+ * @property {Record<string, number>} businessUnits - Normalized BU name → MID mapping; parent BU is stored under `_ParentBU_`
22
+ */
23
+
24
+ /**
25
+ * Process a SOAP BusinessUnit retrieve result into the mcdata config shape.
26
+ * Exposed separately so it can be tested without a live SDK instance.
27
+ *
28
+ * @param {object[]} results - `buResult.Results` array from the SOAP API
29
+ * @param {number} enterpriseId - Fallback EID when no parent row is found
30
+ * @returns {BusinessUnitsResult}
31
+ */
32
+ export function processBusinessUnitResults(results, enterpriseId) {
33
+ /** @type {Record<string, number>} */
34
+ const businessUnits = {};
35
+ let eid = enterpriseId;
36
+
37
+ for (const row of results) {
38
+ const id = Number.parseInt(row.ID, 10);
39
+ const parentId = Number.parseInt(row.ParentID, 10);
40
+
41
+ if (parentId === 0) {
42
+ businessUnits['_ParentBU_'] = id;
43
+ eid = id;
44
+ } else {
45
+ businessUnits[normalizeBuName(row.Name)] = id;
46
+ }
47
+ }
48
+
49
+ return { eid, businessUnits };
50
+ }
51
+
52
+ /**
53
+ * Fetch all Business Units for a Marketing Cloud instance via the SOAP API.
54
+ * Instantiates a temporary SDK client scoped to the enterprise account (`account_id: enterpriseId`)
55
+ * and calls `BusinessUnit` retrieve with `QueryAllAccounts: true`.
56
+ *
57
+ * @param {{ client_id: string, client_secret: string, auth_url: string }} authCred
58
+ * @param {number} enterpriseId - Enterprise MID (parent account ID)
59
+ * @returns {Promise.<BusinessUnitsResult>}
60
+ */
61
+ export async function fetchBusinessUnits(authCred, enterpriseId) {
62
+ const sdk = new SDK(
63
+ {
64
+ client_id: authCred.client_id,
65
+ client_secret: authCred.client_secret,
66
+ auth_url: authCred.auth_url,
67
+ account_id: enterpriseId,
68
+ },
69
+ buildSdkOptions(),
70
+ );
71
+
72
+ let buResult;
73
+ try {
74
+ buResult = await sdk.soap.retrieve(
75
+ 'BusinessUnit',
76
+ ['Name', 'ID', 'ParentName', 'ParentID', 'IsActive'],
77
+ { QueryAllAccounts: true },
78
+ );
79
+ } catch (ex) {
80
+ throw new Error(
81
+ `Could not retrieve Business Units - check client_id, client_secret, auth_url, and enterprise MID.`,
82
+ { cause: ex },
83
+ );
84
+ }
85
+
86
+ if (!buResult?.Results?.length) {
87
+ throw new Error(
88
+ 'Credentials accepted but no Business Units returned. Verify this installed package has access to the parent BU.',
89
+ );
90
+ }
91
+
92
+ return processBusinessUnitResults(buResult.Results, enterpriseId);
93
+ }
package/lib/cli.mjs CHANGED
@@ -5,13 +5,13 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import SDK from 'sfmc-sdk';
7
7
  import {
8
- loadMcdevProject,
8
+ loadProjectConfig,
9
9
  parseCredBu,
10
10
  resolveCredentialAndMid,
11
11
  buildSdkAuthObject,
12
12
  buildSdkOptions,
13
13
  } from './config.mjs';
14
- import { dataDirectoryForBu, projectRelativePosix } from './paths.mjs';
14
+ import { dataDirectoryForBu } from './paths.mjs';
15
15
  import { exportDataExtensionToFile } from './export-de.mjs';
16
16
  import { findImportCandidates, pickLatestByMtime } from './file-resolve.mjs';
17
17
  import { parseExportBasename } from './filename.mjs';
@@ -23,6 +23,7 @@ import { getDeRowCount } from './row-count.mjs';
23
23
  import { multiBuExport } from './multi-bu-export.mjs';
24
24
  import { crossBuImport } from './cross-bu-import.mjs';
25
25
  import { initDebugLogger } from './debug-logger.mjs';
26
+ import { runMcdataInit } from './init-project.mjs';
26
27
 
27
28
  /** @returns {string} semver from this package's package.json */
28
29
  function readCliPackageVersion() {
@@ -36,9 +37,10 @@ function readCliPackageVersion() {
36
37
  }
37
38
 
38
39
  function printHelp() {
39
- console.log(`mcdata SFMC Data Extension export/import (mcdev project)
40
+ console.log(`mcdata - SFMC Data Extension export/import
40
41
 
41
42
  Usage:
43
+ mcdata init [options]
42
44
  mcdata export <credential>/<bu> --de <key> [--de <key> ...] [options]
43
45
  mcdata export --from <cred>/<bu> [--from <cred>/<bu> ...] --de <key> [--de <key> ...] [options]
44
46
  mcdata import <credential>/<bu> (--de <key> ... | --file <path> ...) [options]
@@ -47,12 +49,20 @@ Usage:
47
49
 
48
50
  Options:
49
51
  --version Print version and exit
50
- -p, --project <dir> mcdev project root (default: cwd)
52
+ -p, --project <dir> Project root (default: cwd)
51
53
  --format <csv|tsv|json> Export file format (default: csv); ignored for imports
52
54
  --json-pretty Pretty-print JSON on export
53
55
  --git Stable filenames: <key>.mcdata.<ext> (no timestamp)
54
56
  --debug Write API requests/responses to ./logs/data/*.log
55
57
 
58
+ Init options:
59
+ --credential <name> Credential name (e.g. MyOrg)
60
+ --client-id <id> Installed package client ID
61
+ --client-secret <sec> Installed package client secret
62
+ --auth-url <url> Auth URL (e.g. https://<tenantsubdomain>.auth.marketingcloudapis.com/)
63
+ --enterprise-id <mid> Enterprise MID (parent account ID)
64
+ -y, --yes Overwrite existing .mcdatarc.json / .mcdata-auth.json without prompt
65
+
56
66
  Import options:
57
67
  --mode <upsert|insert> Row write mode (default: upsert; async REST bulk API)
58
68
  --backup-before-import Export target DE data as a timestamped backup before import (no prompt)
@@ -66,6 +76,10 @@ Multi-BU options:
66
76
  --to <cred>/<bu> Import: target BU (repeatable for multiple targets)
67
77
  Import (file mode): use with --file only (no --from needed)
68
78
 
79
+ Config files:
80
+ .mcdevrc.json / .mcdev-auth.json mcdev layout (wins when both pairs present)
81
+ .mcdatarc.json / .mcdata-auth.json standalone mcdata layout (created by mcdata init)
82
+
69
83
  Notes:
70
84
  Exports are written under ./data/<credential>/<bu>/ using ".mcdata." in the filename.
71
85
  Import with --de resolves the latest matching file (csv/tsv/json) in that folder (by mtime).
@@ -108,6 +122,12 @@ export async function main(argv) {
108
122
  debug: { type: 'boolean', default: false },
109
123
  help: { type: 'boolean', short: 'h', default: false },
110
124
  version: { type: 'boolean', default: false },
125
+ credential: { type: 'string' },
126
+ 'client-id': { type: 'string' },
127
+ 'client-secret': { type: 'string' },
128
+ 'auth-url': { type: 'string' },
129
+ 'enterprise-id': { type: 'string' },
130
+ yes: { type: 'boolean', short: 'y', default: false },
111
131
  },
112
132
  });
113
133
  values = parsed.values;
@@ -135,6 +155,21 @@ export async function main(argv) {
135
155
  const credBuRaw = positionals[1]; // May be undefined when --from/--to flags are used
136
156
 
137
157
  const projectRoot = path.resolve(values.project ?? process.cwd());
158
+
159
+ // ── init ─────────────────────────────────────────────────────────────────
160
+ if (sub === 'init') {
161
+ return runMcdataInit({
162
+ projectRoot,
163
+ isTTY: process.stdin.isTTY === true,
164
+ credential: values.credential,
165
+ clientId: values['client-id'],
166
+ clientSecret: values['client-secret'],
167
+ authUrl: values['auth-url'],
168
+ enterpriseId: values['enterprise-id'],
169
+ yes: values.yes,
170
+ });
171
+ }
172
+
138
173
  const fmt = values.format ?? 'csv';
139
174
  if (!['csv', 'tsv', 'json'].includes(fmt)) {
140
175
  console.error(`Invalid --format: ${fmt}`);
@@ -160,7 +195,7 @@ export async function main(argv) {
160
195
  ? initDebugLogger(projectRoot, readCliPackageVersion(), argv)
161
196
  : null;
162
197
  if (logger) {
163
- console.error(`Debug log: ${projectRelativePosix(projectRoot, logger.logPath)}`);
198
+ console.error(`Debug log: "${path.resolve(logger.logPath)}"`);
164
199
  }
165
200
 
166
201
  // ── export ──────────────────────────────────────────────────────────────
@@ -199,7 +234,7 @@ export async function main(argv) {
199
234
  console.error(ex.message);
200
235
  return 1;
201
236
  }
202
- const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
237
+ const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
203
238
  await multiBuExport({
204
239
  projectRoot,
205
240
  mcdevrc,
@@ -215,7 +250,7 @@ export async function main(argv) {
215
250
  }
216
251
 
217
252
  const { credential, bu } = parseCredBu(credBuRaw);
218
- const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
253
+ const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
219
254
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
220
255
  const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
221
256
  for (const deKey of des) {
@@ -228,8 +263,7 @@ export async function main(argv) {
228
263
  jsonPretty: values['json-pretty'],
229
264
  useGit,
230
265
  });
231
- const rel = projectRelativePosix(projectRoot, out);
232
- console.error(`Exported: ${rel} (${rowCount} rows)`);
266
+ console.error(`Exported: "${path.resolve(out)}" (${rowCount} rows)`);
233
267
  }
234
268
  return 0;
235
269
  }
@@ -267,7 +301,7 @@ export async function main(argv) {
267
301
  console.error(ex.message);
268
302
  return 1;
269
303
  }
270
- const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
304
+ const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
271
305
  const crossBuHadError = await crossBuImport({
272
306
  projectRoot,
273
307
  mcdevrc,
@@ -332,7 +366,7 @@ export async function main(argv) {
332
366
  console.error(ex.message);
333
367
  return 1;
334
368
  }
335
- const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
369
+ const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
336
370
  const crossBuApiHadError = await crossBuImport({
337
371
  projectRoot,
338
372
  mcdevrc,
@@ -372,7 +406,7 @@ export async function main(argv) {
372
406
  }
373
407
 
374
408
  const { credential, bu } = parseCredBu(credBuRaw);
375
- const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
409
+ const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
376
410
  const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
377
411
  const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions(logger));
378
412
 
@@ -389,9 +423,7 @@ export async function main(argv) {
389
423
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
390
424
  useGit: false,
391
425
  });
392
- console.error(
393
- `Backup export: ${projectRelativePosix(projectRoot, outPath)} (${rowCount} rows)`,
394
- );
426
+ console.error(`Backup export: "${path.resolve(outPath)}" (${rowCount} rows)`);
395
427
  }
396
428
  }
397
429
  if (clear) {
@@ -406,7 +438,7 @@ export async function main(argv) {
406
438
  const candidates = await findImportCandidates(dataDir, deKey);
407
439
  if (candidates.length === 0) {
408
440
  console.error(
409
- `No import file (csv/tsv/json) found for DE "${deKey}" under ${projectRelativePosix(projectRoot, dataDir)}`,
441
+ `No import file (csv/tsv/json) found for DE "${deKey}" under "${path.resolve(dataDir)}"`,
410
442
  );
411
443
  return 1;
412
444
  }
@@ -434,8 +466,7 @@ export async function main(argv) {
434
466
  deKey,
435
467
  mode: /** @type {'upsert'|'insert'} */ (mode),
436
468
  });
437
- const rel = projectRelativePosix(projectRoot, filePath);
438
- console.error(`Imported: ${rel} (${n} rows) -> DE ${deKey}`);
469
+ console.error(`Imported: "${path.resolve(filePath)}" (${n} rows) -> DE ${deKey}`);
439
470
 
440
471
  const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
441
472
  if (importHadError) {
@@ -476,9 +507,7 @@ export async function main(argv) {
476
507
  format: /** @type {'csv'|'tsv'|'json'} */ (fmt),
477
508
  useGit: false,
478
509
  });
479
- console.error(
480
- `Backup export: ${projectRelativePosix(projectRoot, outPath)} (${rowCount} rows)`,
481
- );
510
+ console.error(`Backup export: "${path.resolve(outPath)}" (${rowCount} rows)`);
482
511
  }
483
512
  }
484
513
  if (clear) {
@@ -514,8 +543,7 @@ export async function main(argv) {
514
543
  deKey: customerKey,
515
544
  mode: /** @type {'upsert'|'insert'} */ (mode),
516
545
  });
517
- const rel = projectRelativePosix(projectRoot, filePath);
518
- console.error(`Imported: ${rel} (${n} rows)`);
546
+ console.error(`Imported: "${path.resolve(filePath)}" (${n} rows)`);
519
547
 
520
548
  const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
521
549
  if (importHadError) {
package/lib/config.mjs CHANGED
@@ -1,9 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ export const FILE_MCDEV_RC = '.mcdevrc.json';
5
+ export const FILE_MCDEV_AUTH = '.mcdev-auth.json';
6
+ export const FILE_MCDATA_RC = '.mcdatarc.json';
7
+ export const FILE_MCDATA_AUTH = '.mcdata-auth.json';
8
+
9
+ export const WARN_MCDATA_SUPERSEDED =
10
+ 'mcdata: Using .mcdevrc.json / .mcdev-auth.json; .mcdatarc.json / .mcdata-auth.json are ignored.';
11
+
4
12
  /**
5
13
  * @typedef {object} McdevrcCredentials
6
- * @property {Record<string, Record<string, number|string>>} businessUnits
14
+ * @property {number} [eid]
15
+ * @property {Record<string, number|string>} businessUnits
7
16
  */
8
17
 
9
18
  /**
@@ -16,24 +25,74 @@ import path from 'node:path';
16
25
  * @property {string} client_id
17
26
  * @property {string} client_secret
18
27
  * @property {string} auth_url
28
+ * @property {number} [account_id]
19
29
  */
20
30
 
21
31
  /**
32
+ * Loads project config from either mcdev or mcdata file pairs, applying mcdev-wins precedence.
33
+ * If both mcdev files exist they are used and mcdata files (if also present) are ignored with a warning.
34
+ * If only the mcdata pair exists it is used.
35
+ * Partial pairs (one file without the other) or no files at all throw descriptive errors.
36
+ *
22
37
  * @param {string} projectRoot
38
+ * @param {{ stderr?: (msg: string) => void }} [options]
23
39
  * @returns {{ mcdevrc: Mcdevrc, mcdevAuth: Record<string, AuthCredential> }}
24
40
  */
25
- export function loadMcdevProject(projectRoot) {
26
- const rcPath = path.join(projectRoot, '.mcdevrc.json');
27
- const authPath = path.join(projectRoot, '.mcdev-auth.json');
28
- if (!fs.existsSync(rcPath)) {
29
- throw new Error(`Missing ${rcPath}`);
41
+ export function loadProjectConfig(projectRoot, options = {}) {
42
+ const err = options.stderr ?? ((msg) => console.error(msg));
43
+ const rcMcdev = path.join(projectRoot, FILE_MCDEV_RC);
44
+ const authMcdev = path.join(projectRoot, FILE_MCDEV_AUTH);
45
+ const rcMcdata = path.join(projectRoot, FILE_MCDATA_RC);
46
+ const authMcdata = path.join(projectRoot, FILE_MCDATA_AUTH);
47
+
48
+ const hasMcdevRc = fs.existsSync(rcMcdev);
49
+ const hasMcdevAuth = fs.existsSync(authMcdev);
50
+ const hasMcdataRc = fs.existsSync(rcMcdata);
51
+ const hasMcdataAuth = fs.existsSync(authMcdata);
52
+
53
+ const mcdevPairComplete = hasMcdevRc && hasMcdevAuth;
54
+ const mcdataPairComplete = hasMcdataRc && hasMcdataAuth;
55
+
56
+ if (mcdevPairComplete) {
57
+ if (hasMcdataRc || hasMcdataAuth) {
58
+ err(WARN_MCDATA_SUPERSEDED);
59
+ }
60
+ const mcdevrc = JSON.parse(fs.readFileSync(rcMcdev, 'utf8'));
61
+ const mcdevAuth = JSON.parse(fs.readFileSync(authMcdev, 'utf8'));
62
+ return { mcdevrc, mcdevAuth };
63
+ }
64
+
65
+ if (hasMcdevRc !== hasMcdevAuth) {
66
+ if (hasMcdevRc && !hasMcdevAuth) {
67
+ throw new Error(`Missing ${authMcdev} (pair with ${FILE_MCDEV_RC})`);
68
+ }
69
+ throw new Error(`Missing ${rcMcdev} (pair with ${FILE_MCDEV_AUTH})`);
70
+ }
71
+
72
+ if (mcdataPairComplete) {
73
+ const mcdevrc = JSON.parse(fs.readFileSync(rcMcdata, 'utf8'));
74
+ const mcdevAuth = JSON.parse(fs.readFileSync(authMcdata, 'utf8'));
75
+ return { mcdevrc, mcdevAuth };
30
76
  }
31
- if (!fs.existsSync(authPath)) {
32
- throw new Error(`Missing ${authPath}`);
77
+
78
+ if (hasMcdataRc !== hasMcdataAuth) {
79
+ if (hasMcdataRc && !hasMcdataAuth) {
80
+ throw new Error(`Missing ${authMcdata} (pair with ${FILE_MCDATA_RC})`);
81
+ }
82
+ throw new Error(`Missing ${rcMcdata} (pair with ${FILE_MCDATA_AUTH})`);
33
83
  }
34
- const mcdevrc = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
35
- const mcdevAuth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
36
- return { mcdevrc, mcdevAuth };
84
+
85
+ throw new Error(
86
+ `No project config found in ${projectRoot}. Add ${FILE_MCDEV_RC} + ${FILE_MCDEV_AUTH} (mcdev), or ${FILE_MCDATA_RC} + ${FILE_MCDATA_AUTH} from \`mcdata init\`. Install mcdev globally (\`npm i -g mcdev\`) if you want a full mcdev project.`,
87
+ );
88
+ }
89
+
90
+ /**
91
+ * @param {string} projectRoot
92
+ * @returns {{ mcdevrc: Mcdevrc, mcdevAuth: Record<string, AuthCredential> }}
93
+ */
94
+ export function loadMcdevProject(projectRoot) {
95
+ return loadProjectConfig(projectRoot);
37
96
  }
38
97
 
39
98
  /**
@@ -61,7 +120,7 @@ export function parseCredBu(credBu) {
61
120
  export function resolveCredentialAndMid(mcdevrc, mcdevAuth, credentialName, buName) {
62
121
  const credBlock = mcdevrc.credentials?.[credentialName];
63
122
  if (!credBlock) {
64
- throw new Error(`Unknown credential "${credentialName}" in .mcdevrc.json`);
123
+ throw new Error(`Unknown credential "${credentialName}" in project credentials config`);
65
124
  }
66
125
  const midRaw = credBlock.businessUnits?.[buName];
67
126
  if (midRaw === undefined || midRaw === null) {
@@ -74,7 +133,7 @@ export function resolveCredentialAndMid(mcdevrc, mcdevAuth, credentialName, buNa
74
133
  const authCred = mcdevAuth[credentialName];
75
134
  if (!authCred?.client_id || !authCred?.client_secret || !authCred?.auth_url) {
76
135
  throw new Error(
77
- `Missing auth fields for credential "${credentialName}" in .mcdev-auth.json`,
136
+ `Missing auth fields for credential "${credentialName}" in auth config file`,
78
137
  );
79
138
  }
80
139
  return { mid, authCred };
@@ -16,7 +16,7 @@ import { pollAsyncImportCompletion } from './async-status.mjs';
16
16
  import { readRowsFromFile } from './read-rows.mjs';
17
17
  import { clearDataExtensionRows } from './clear-de.mjs';
18
18
  import { confirmClearBeforeImport } from './confirm-clear.mjs';
19
- import { dataDirectoryForBu, projectRelativePosix } from './paths.mjs';
19
+ import { dataDirectoryForBu } from './paths.mjs';
20
20
  import { buildExportBasename, filesystemSafeTimestamp, parseExportBasename } from './filename.mjs';
21
21
  import { getDeRowCount } from './row-count.mjs';
22
22
 
@@ -164,8 +164,7 @@ export async function crossBuImport(params) {
164
164
  format,
165
165
  useGit: false,
166
166
  });
167
- const rel = projectRelativePosix(projectRoot, outPath);
168
- console.error(`Backup export: ${rel} (${rowCount} rows)`);
167
+ console.error(`Backup export: "${path.resolve(outPath)}" (${rowCount} rows)`);
169
168
  }
170
169
  }
171
170
  }
@@ -243,8 +242,7 @@ export async function crossBuImport(params) {
243
242
  serializeRows(rows, format, false, snapshotColumns),
244
243
  'utf8',
245
244
  );
246
- const snapRel = projectRelativePosix(projectRoot, snapshotPath);
247
- console.error(`Download stored: ${snapRel} (${rows.length} rows)`);
245
+ console.error(`Download stored: "${path.resolve(snapshotPath)}" (${rows.length} rows)`);
248
246
 
249
247
  const { count: imported, requestIds } = await importRowsForDe(tgtSdk, {
250
248
  deKey,
@@ -0,0 +1,59 @@
1
+ import SDK from 'sfmc-sdk';
2
+ import {
3
+ loadProjectConfig,
4
+ resolveCredentialAndMid,
5
+ buildSdkAuthObject,
6
+ buildSdkOptions,
7
+ } from './config.mjs';
8
+
9
+ /**
10
+ * Maps a SOAP retrieveBulk result for DataExtension into sorted `{ name, key }` rows.
11
+ * Exported for unit tests; not part of the stable public API contract beyond testing.
12
+ *
13
+ * @param {object|null|undefined} bulkResult - `sdk.soap.retrieveBulk` response
14
+ * @returns {{ name: string, key: string }[]}
15
+ */
16
+ export function normalizeDeListFromBulkResult(bulkResult) {
17
+ const rows = bulkResult?.Results;
18
+ if (!Array.isArray(rows) || rows.length === 0) {
19
+ return [];
20
+ }
21
+
22
+ const items = rows
23
+ .map((row) => ({
24
+ name: String(row.Name ?? ''),
25
+ key: String(row.CustomerKey ?? ''),
26
+ }))
27
+ .filter((item) => item.key.length > 0);
28
+
29
+ items.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
30
+ return items;
31
+ }
32
+
33
+ /**
34
+ * Retrieves all Data Extension `Name` and `CustomerKey` values for a credential/BU via SOAP
35
+ * (`retrieveBulk` handles pagination). For programmatic use (e.g. VS Code extension cache), not the CLI.
36
+ *
37
+ * @param {string} projectRoot - Absolute path to project root (mcdev or mcdata config pair)
38
+ * @param {string} credential - Credential name from config
39
+ * @param {string} bu - Business unit key from config
40
+ * @returns {Promise.<{ name: string, key: string }[]>}
41
+ */
42
+ export async function fetchDeList(projectRoot, credential, bu) {
43
+ const { mcdevrc, mcdevAuth } = loadProjectConfig(projectRoot);
44
+ const { mid, authCred } = resolveCredentialAndMid(mcdevrc, mcdevAuth, credential, bu);
45
+ const sdk = new SDK(buildSdkAuthObject(authCred, mid), buildSdkOptions());
46
+
47
+ let bulkResult;
48
+ try {
49
+ bulkResult = await sdk.soap.retrieveBulk('DataExtension', ['Name', 'CustomerKey'], {});
50
+ } catch (ex) {
51
+ const message = ex instanceof Error ? ex.message : String(ex);
52
+ throw new Error(
53
+ `Could not retrieve Data Extensions — check credentials and BU. Original error: ${message}`,
54
+ { cause: ex },
55
+ );
56
+ }
57
+
58
+ return normalizeDeListFromBulkResult(bulkResult);
59
+ }
package/lib/import-de.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { RestError } from 'sfmc-sdk/util';
1
2
  import { chunkItemsForPayload } from './batch.mjs';
2
3
  import { formatFromExtension } from './file-resolve.mjs';
3
4
  import { resolveImportRoute } from './import-routes.mjs';
@@ -20,9 +21,27 @@ export async function importRowsForDe(sdk, params) {
20
21
  for (const chunk of chunks) {
21
22
  const p = route.path(deKey);
22
23
  const body = { items: chunk };
23
- const resp = await withRetry429(() =>
24
- route.method === 'PUT' ? sdk.rest.put(p, body) : sdk.rest.post(p, body),
25
- );
24
+ let resp;
25
+ try {
26
+ resp = await withRetry429(() =>
27
+ route.method === 'PUT' ? sdk.rest.put(p, body) : sdk.rest.post(p, body),
28
+ );
29
+ } catch (ex) {
30
+ const msgs =
31
+ ex instanceof RestError &&
32
+ ex.response?.status === 400 &&
33
+ Array.isArray(ex.response?.data?.resultMessages) &&
34
+ ex.response.data.resultMessages.length > 0
35
+ ? ex.response.data.resultMessages
36
+ : null;
37
+ if (msgs) {
38
+ const summary = msgs.map((m) => m.message ?? String(m)).join('; ');
39
+ throw new Error(`Import failed for DE "${deKey}" (HTTP 400): ${summary}`, {
40
+ cause: ex,
41
+ });
42
+ }
43
+ throw ex;
44
+ }
26
45
  requestIds.push(resp?.requestId ?? null);
27
46
  }
28
47
  return { count: rows.length, requestIds };
package/lib/index.mjs CHANGED
@@ -15,6 +15,12 @@ export {
15
15
  export { pollAsyncImportCompletion } from './async-status.mjs';
16
16
  export {
17
17
  loadMcdevProject,
18
+ loadProjectConfig,
19
+ WARN_MCDATA_SUPERSEDED,
20
+ FILE_MCDEV_RC,
21
+ FILE_MCDEV_AUTH,
22
+ FILE_MCDATA_RC,
23
+ FILE_MCDATA_AUTH,
18
24
  parseCredBu,
19
25
  resolveCredentialAndMid,
20
26
  buildSdkAuthObject,
@@ -23,3 +29,4 @@ export { multiBuExport } from './multi-bu-export.mjs';
23
29
  export { crossBuImport } from './cross-bu-import.mjs';
24
30
  export { getDeRowCount } from './row-count.mjs';
25
31
  export { projectRelativePosix } from './paths.mjs';
32
+ export { fetchDeList } from './de-list.mjs';
@@ -0,0 +1,214 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { stdin as input, stdout as output } from 'node:process';
5
+ import { FILE_MCDEV_RC, FILE_MCDEV_AUTH, FILE_MCDATA_RC, FILE_MCDATA_AUTH } from './config.mjs';
6
+ import { fetchBusinessUnits } from './business-units.mjs';
7
+
8
+ /**
9
+ * @typedef {object} InitOptions
10
+ * @property {string} projectRoot - Absolute path to the project folder
11
+ * @property {boolean} isTTY - Whether stdin is a TTY (interactive)
12
+ * @property {string} [credential] - Non-interactive: credential name
13
+ * @property {string} [clientId] - Non-interactive: client_id
14
+ * @property {string} [clientSecret] - Non-interactive: client_secret
15
+ * @property {string} [authUrl] - Non-interactive: auth_url
16
+ * @property {string} [enterpriseId] - Non-interactive: enterprise MID (string, will be parsed to int)
17
+ * @property {boolean} [yes] - Skip overwrite confirmation
18
+ * @property {Function} [_buFetcher] - Dependency injection for testing (replaces fetchBusinessUnits)
19
+ * @property {(question: string) => Promise.<boolean>} [_confirm] - Dependency injection for testing (replaces defaultConfirm)
20
+ * @property {(msg: string) => void} [stdout] - Override stdout (for testing)
21
+ * @property {(msg: string) => void} [stderr] - Override stderr (for testing)
22
+ */
23
+
24
+ /**
25
+ * Ask a yes/no question on the terminal and return true only if the user types "y" or "yes".
26
+ *
27
+ * @param {string} question
28
+ * @returns {Promise.<boolean>}
29
+ */
30
+ async function defaultConfirm(question) {
31
+ const rl = readline.createInterface({ input, output });
32
+ try {
33
+ const answer = (await rl.question(question)).trim().toLowerCase();
34
+ return answer === 'y' || answer === 'yes';
35
+ } finally {
36
+ rl.close();
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Ensure .mcdata-auth.json is listed in the project's .gitignore (creates the file if absent).
42
+ *
43
+ * @param {string} projectRoot
44
+ */
45
+ function ensureGitignore(projectRoot) {
46
+ const gitignorePath = path.join(projectRoot, '.gitignore');
47
+ const entry = '.mcdata-auth.json';
48
+ if (!fs.existsSync(gitignorePath)) {
49
+ fs.writeFileSync(gitignorePath, `${entry}\n`, 'utf8');
50
+ return;
51
+ }
52
+ const current = fs.readFileSync(gitignorePath, 'utf8');
53
+ const lines = current.split('\n');
54
+ if (!lines.some((line) => line.trim() === entry)) {
55
+ const appended = current.endsWith('\n')
56
+ ? current + entry + '\n'
57
+ : current + '\n' + entry + '\n';
58
+ fs.writeFileSync(gitignorePath, appended, 'utf8');
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Run the `mcdata init` flow — interactive or non-interactive.
64
+ *
65
+ * @param {InitOptions} opts
66
+ * @returns {Promise.<number>} exit code (0 = success, 1 = failure)
67
+ */
68
+ export async function runMcdataInit(opts) {
69
+ const { projectRoot, isTTY, yes = false, _buFetcher } = opts;
70
+ const out = opts.stdout ?? ((msg) => console.log(msg));
71
+ const err = opts.stderr ?? ((msg) => console.error(msg));
72
+
73
+ // Guard: do not init over an existing mcdev project (both files must be present)
74
+ if (
75
+ fs.existsSync(path.join(projectRoot, FILE_MCDEV_RC)) &&
76
+ fs.existsSync(path.join(projectRoot, FILE_MCDEV_AUTH))
77
+ ) {
78
+ err(
79
+ `This project is managed by mcdev (${FILE_MCDEV_RC} and ${FILE_MCDEV_AUTH} found).\n` +
80
+ `Manage your credentials by editing ${FILE_MCDEV_AUTH} directly, or run 'mcdev init' to re-initialise.`,
81
+ );
82
+ return 1;
83
+ }
84
+
85
+ // Guard: confirm overwrite when mcdata files already present
86
+ const mcdataRcPath = path.join(projectRoot, FILE_MCDATA_RC);
87
+ const mcdataAuthPath = path.join(projectRoot, FILE_MCDATA_AUTH);
88
+ if (!yes && (fs.existsSync(mcdataRcPath) || fs.existsSync(mcdataAuthPath))) {
89
+ if (isTTY) {
90
+ const confirmFn = opts._confirm ?? defaultConfirm;
91
+ const confirmed = await confirmFn(
92
+ `${FILE_MCDATA_RC} or ${FILE_MCDATA_AUTH} already exists. Override existing configuration? [y/N] `,
93
+ );
94
+ if (!confirmed) {
95
+ out('Aborted.');
96
+ return 1;
97
+ }
98
+ } else {
99
+ err(
100
+ `${FILE_MCDATA_RC} or ${FILE_MCDATA_AUTH} already exists in ${projectRoot}.\n` +
101
+ `Pass --yes to overwrite.`,
102
+ );
103
+ return 1;
104
+ }
105
+ }
106
+
107
+ // Collect credentials
108
+ let credentialName = opts.credential;
109
+ let clientId = opts.clientId;
110
+ let clientSecret = opts.clientSecret;
111
+ let authUrl = opts.authUrl;
112
+ let enterpriseIdStr = opts.enterpriseId;
113
+
114
+ if (isTTY && (!credentialName || !clientId || !clientSecret || !authUrl || !enterpriseIdStr)) {
115
+ const rl = readline.createInterface({ input, output });
116
+ try {
117
+ if (!credentialName) {
118
+ credentialName = (await rl.question('Credential name (e.g. MyOrg): ')).trim();
119
+ }
120
+ if (!clientId) {
121
+ clientId = (await rl.question('Client ID: ')).trim();
122
+ }
123
+ if (!clientSecret) {
124
+ clientSecret = (await rl.question('Client Secret: ')).trim();
125
+ }
126
+ if (!authUrl) {
127
+ authUrl = (
128
+ await rl.question(
129
+ 'Auth URL (e.g. https://<tenantsubdomain>.auth.marketingcloudapis.com/): ',
130
+ )
131
+ ).trim();
132
+ }
133
+ if (!enterpriseIdStr) {
134
+ enterpriseIdStr = (await rl.question('Enterprise MID: ')).trim();
135
+ }
136
+ } finally {
137
+ rl.close();
138
+ }
139
+ } else if (!isTTY) {
140
+ // Non-interactive: all flags must be provided
141
+ const missing = [];
142
+ if (!credentialName) {
143
+ missing.push('--credential');
144
+ }
145
+ if (!clientId) {
146
+ missing.push('--client-id');
147
+ }
148
+ if (!clientSecret) {
149
+ missing.push('--client-secret');
150
+ }
151
+ if (!authUrl) {
152
+ missing.push('--auth-url');
153
+ }
154
+ if (!enterpriseIdStr) {
155
+ missing.push('--enterprise-id');
156
+ }
157
+ if (missing.length > 0) {
158
+ err(
159
+ `mcdata init: missing required flags in non-interactive mode: ${missing.join(', ')}`,
160
+ );
161
+ return 1;
162
+ }
163
+ }
164
+
165
+ const enterpriseId = Number.parseInt(String(enterpriseIdStr), 10);
166
+ if (!Number.isInteger(enterpriseId) || enterpriseId <= 0) {
167
+ err(`Invalid enterprise MID: ${enterpriseIdStr}`);
168
+ return 1;
169
+ }
170
+
171
+ // Fetch Business Units
172
+ out('Fetching Business Units from Marketing Cloud...');
173
+ let buResult;
174
+ try {
175
+ const buFetcher = _buFetcher ?? fetchBusinessUnits;
176
+ buResult = await buFetcher(
177
+ { client_id: clientId, client_secret: clientSecret, auth_url: authUrl },
178
+ enterpriseId,
179
+ );
180
+ } catch (ex) {
181
+ err(`Failed to fetch Business Units: ${ex.message}`);
182
+ return 1;
183
+ }
184
+ out(`Found ${Object.keys(buResult.businessUnits).length} Business Unit(s).`);
185
+
186
+ // Build config objects
187
+ const mcdataRc = {
188
+ credentials: {
189
+ [credentialName]: {
190
+ eid: buResult.eid,
191
+ businessUnits: buResult.businessUnits,
192
+ },
193
+ },
194
+ };
195
+
196
+ const mcdataAuth = {
197
+ [credentialName]: {
198
+ client_id: clientId,
199
+ client_secret: clientSecret,
200
+ auth_url: authUrl,
201
+ account_id: buResult.eid,
202
+ },
203
+ };
204
+
205
+ // Write files
206
+ fs.writeFileSync(mcdataRcPath, JSON.stringify(mcdataRc, null, 4) + '\n', 'utf8');
207
+ fs.writeFileSync(mcdataAuthPath, JSON.stringify(mcdataAuth, null, 4) + '\n', 'utf8');
208
+ ensureGitignore(projectRoot);
209
+
210
+ out(`Created ${FILE_MCDATA_RC} and ${FILE_MCDATA_AUTH} in ${projectRoot}`);
211
+ out('Make sure to add .mcdata-auth.json to your .gitignore (done automatically).');
212
+ out("Tip: To use mcdev instead, install it globally and run 'mcdev init'.");
213
+ return 0;
214
+ }
@@ -1,7 +1,7 @@
1
+ import path from 'node:path';
1
2
  import SDK from 'sfmc-sdk';
2
3
  import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './config.mjs';
3
4
  import { exportDataExtensionToFile } from './export-de.mjs';
4
- import { projectRelativePosix } from './paths.mjs';
5
5
 
6
6
  /**
7
7
  * @typedef {{ credential: string, bu: string }} CredBuSource
@@ -50,8 +50,7 @@ export async function multiBuExport({
50
50
  jsonPretty,
51
51
  useGit,
52
52
  });
53
- const rel = projectRelativePosix(projectRoot, outPath);
54
- console.error(`Exported: ${rel} (${rowCount} rows)`);
53
+ console.error(`Exported: "${path.resolve(outPath)}" (${rowCount} rows)`);
55
54
  exported.push(outPath);
56
55
  }
57
56
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sfmc-dataloader",
3
- "version": "2.4.2",
4
- "description": "SFMC Data Loader CLI (mcdata) to export and import Marketing Cloud Data Extension rows using mcdev project config and sfmc-sdk",
3
+ "version": "2.6.0",
4
+ "description": "SFMC Data Loader CLI (mcdata) standalone export/import of Marketing Cloud Data Extension rows; optional mcdev integration",
5
5
  "author": "Jörn Berkefeld <joern.berkefeld@gmail.com>",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -35,9 +35,6 @@
35
35
  "engines": {
36
36
  "node": "^20.19.0 || ^22.13.0 || >=24"
37
37
  },
38
- "peerDependencies": {
39
- "mcdev": ">=7"
40
- },
41
38
  "dependencies": {
42
39
  "csv-parser": "3.2.0",
43
40
  "csv-stringify": "6.7.0",
@@ -61,5 +58,8 @@
61
58
  "globals": "^17.4.0",
62
59
  "husky": "^9.1.7",
63
60
  "prettier": "^3.8.1"
61
+ },
62
+ "optionalDependencies": {
63
+ "mcdev": ">=7"
64
64
  }
65
65
  }