sfmc-dataloader 2.3.0 → 2.4.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 +1 -1
- package/lib/async-status.mjs +89 -0
- package/lib/cli.mjs +24 -11
- package/lib/cross-bu-import.mjs +20 -4
- package/lib/import-de.mjs +6 -4
- package/lib/import-routes.mjs +20 -0
- package/lib/index.mjs +8 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Creates one file per BU/DE combination using the same `.mcdata.` naming rules.
|
|
|
44
44
|
mcdata import MyCred/MyBU --de MyDE_CustomerKey --mode upsert
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Imports use the **asynchronous** bulk row API only: `POST` for `--mode insert`, `PUT` for `--mode upsert` (same endpoint path).
|
|
47
|
+
Imports use the **asynchronous** bulk row API only: `POST` for `--mode insert`, `PUT` for `--mode upsert` (same endpoint path). After uploading, `mcdata` polls the async status endpoint every 5 seconds until the job reaches `Complete` or `Error`. On `Error`, per-row error messages are printed to the log so you can see exactly which records failed. The process exits with code 1 if any import job fails, which also surfaces as an error notification in the VS Code extension.
|
|
48
48
|
|
|
49
49
|
Resolves the latest matching export file under `./data/MyCred/MyBU/` for that DE key. The file format is detected automatically from the file extension (`.csv`, `.tsv`, `.json`).
|
|
50
50
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { asyncRequestResultsPath, asyncRequestStatusPath } from './import-routes.mjs';
|
|
2
|
+
|
|
3
|
+
const POLL_INTERVAL_MS = 5000;
|
|
4
|
+
const PENDING_STATUSES = new Set(['Pending', 'Executing']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {number} ms
|
|
8
|
+
* @returns {Promise.<void>}
|
|
9
|
+
*/
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Polls the async import status API for each requestId until the job reaches
|
|
16
|
+
* a terminal state ("Complete" or "Error"). On error, fetches per-row results
|
|
17
|
+
* and logs each item message so the user can see what failed.
|
|
18
|
+
*
|
|
19
|
+
* @param {{ rest: { get: Function } }} sdk
|
|
20
|
+
* @param {(string|null|undefined)[]} requestIds - one per imported chunk
|
|
21
|
+
* @returns {Promise.<boolean>} true if at least one chunk job returned "Error"
|
|
22
|
+
*/
|
|
23
|
+
export async function pollAsyncImportCompletion(sdk, requestIds) {
|
|
24
|
+
let hasError = false;
|
|
25
|
+
|
|
26
|
+
for (const requestId of requestIds) {
|
|
27
|
+
if (!requestId) {
|
|
28
|
+
console.error('Async import: no requestId returned for chunk — skipping status check.');
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.error(`Waiting for async import to complete (requestId: ${requestId})…`);
|
|
33
|
+
await sleep(POLL_INTERVAL_MS);
|
|
34
|
+
|
|
35
|
+
let requestStatus;
|
|
36
|
+
do {
|
|
37
|
+
let statusResult;
|
|
38
|
+
try {
|
|
39
|
+
statusResult = await sdk.rest.get(asyncRequestStatusPath(requestId));
|
|
40
|
+
} catch (ex) {
|
|
41
|
+
console.error(
|
|
42
|
+
`Async import: status check failed for requestId ${requestId}: ${ex.message}`,
|
|
43
|
+
);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
requestStatus = statusResult?.status?.requestStatus;
|
|
48
|
+
|
|
49
|
+
if (PENDING_STATUSES.has(requestStatus)) {
|
|
50
|
+
console.error(
|
|
51
|
+
`Async import still in progress (requestId: ${requestId}) — retrying in 5 s…`,
|
|
52
|
+
);
|
|
53
|
+
await sleep(POLL_INTERVAL_MS);
|
|
54
|
+
}
|
|
55
|
+
} while (PENDING_STATUSES.has(requestStatus));
|
|
56
|
+
|
|
57
|
+
if (requestStatus === 'Complete') {
|
|
58
|
+
console.error(`Async import completed successfully (requestId: ${requestId}).`);
|
|
59
|
+
} else if (requestStatus === 'Error') {
|
|
60
|
+
console.error(`Async import job failed (requestId: ${requestId}).`);
|
|
61
|
+
hasError = true;
|
|
62
|
+
|
|
63
|
+
let resultsResult;
|
|
64
|
+
try {
|
|
65
|
+
resultsResult = await sdk.rest.get(asyncRequestResultsPath(requestId));
|
|
66
|
+
} catch (ex) {
|
|
67
|
+
console.error(
|
|
68
|
+
`Async import: could not retrieve error details for requestId ${requestId}: ${ex.message}`,
|
|
69
|
+
);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const items = resultsResult?.items ?? [];
|
|
74
|
+
if (items.length === 0) {
|
|
75
|
+
console.error('Async import: no item-level error details returned by API.');
|
|
76
|
+
} else {
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
console.error(`Import error: ${item.message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} else if (requestStatus !== undefined) {
|
|
82
|
+
console.error(
|
|
83
|
+
`Async import: unexpected status "${requestStatus}" for requestId ${requestId}.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return hasError;
|
|
89
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { exportDataExtensionToFile } from './export-de.mjs';
|
|
|
16
16
|
import { findImportCandidates, pickLatestByMtime } from './file-resolve.mjs';
|
|
17
17
|
import { parseExportBasename } from './filename.mjs';
|
|
18
18
|
import { importFromFile } from './import-de.mjs';
|
|
19
|
+
import { pollAsyncImportCompletion } from './async-status.mjs';
|
|
19
20
|
import { clearDataExtensionRows } from './clear-de.mjs';
|
|
20
21
|
import { confirmClearBeforeImport } from './confirm-clear.mjs';
|
|
21
22
|
import { getDeRowCount } from './row-count.mjs';
|
|
@@ -267,7 +268,7 @@ export async function main(argv) {
|
|
|
267
268
|
return 1;
|
|
268
269
|
}
|
|
269
270
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
270
|
-
await crossBuImport({
|
|
271
|
+
const crossBuHadError = await crossBuImport({
|
|
271
272
|
projectRoot,
|
|
272
273
|
mcdevrc,
|
|
273
274
|
mcdevAuth,
|
|
@@ -282,7 +283,7 @@ export async function main(argv) {
|
|
|
282
283
|
useGit,
|
|
283
284
|
logger,
|
|
284
285
|
});
|
|
285
|
-
return 0;
|
|
286
|
+
return crossBuHadError ? 1 : 0;
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
// ── Cross-BU import (API mode): --from + --to + --de ────────────────
|
|
@@ -332,7 +333,7 @@ export async function main(argv) {
|
|
|
332
333
|
return 1;
|
|
333
334
|
}
|
|
334
335
|
const { mcdevrc, mcdevAuth } = loadMcdevProject(projectRoot);
|
|
335
|
-
await crossBuImport({
|
|
336
|
+
const crossBuApiHadError = await crossBuImport({
|
|
336
337
|
projectRoot,
|
|
337
338
|
mcdevrc,
|
|
338
339
|
mcdevAuth,
|
|
@@ -349,7 +350,7 @@ export async function main(argv) {
|
|
|
349
350
|
useGit,
|
|
350
351
|
logger,
|
|
351
352
|
});
|
|
352
|
-
return 0;
|
|
353
|
+
return crossBuApiHadError ? 1 : 0;
|
|
353
354
|
}
|
|
354
355
|
|
|
355
356
|
// ── Single-BU import (original behavior) ────────────────────────────
|
|
@@ -400,6 +401,7 @@ export async function main(argv) {
|
|
|
400
401
|
isTTY: process.stdin.isTTY === true,
|
|
401
402
|
});
|
|
402
403
|
}
|
|
404
|
+
let anyError = false;
|
|
403
405
|
for (const deKey of deKeys) {
|
|
404
406
|
const candidates = await findImportCandidates(dataDir, deKey);
|
|
405
407
|
if (candidates.length === 0) {
|
|
@@ -427,7 +429,7 @@ export async function main(argv) {
|
|
|
427
429
|
}
|
|
428
430
|
}
|
|
429
431
|
|
|
430
|
-
const n = await importFromFile(sdk, {
|
|
432
|
+
const { count: n, requestIds } = await importFromFile(sdk, {
|
|
431
433
|
filePath,
|
|
432
434
|
deKey,
|
|
433
435
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
@@ -435,24 +437,29 @@ export async function main(argv) {
|
|
|
435
437
|
const rel = projectRelativePosix(projectRoot, filePath);
|
|
436
438
|
console.error(`Imported: ${rel} (${n} rows) -> DE ${deKey}`);
|
|
437
439
|
|
|
440
|
+
const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
|
|
441
|
+
if (importHadError) {
|
|
442
|
+
anyError = true;
|
|
443
|
+
}
|
|
444
|
+
|
|
438
445
|
const countAfter = await getDeRowCount(sdk, deKey);
|
|
439
446
|
console.error(
|
|
440
447
|
`Row count after import: ${countAfter ?? '(unavailable)'} (DE "${deKey}")`,
|
|
441
448
|
);
|
|
442
449
|
if (countAfter === null) {
|
|
443
450
|
console.error(`Could not verify import result for DE "${deKey}".`);
|
|
444
|
-
} else if (countBefore !== null
|
|
451
|
+
} else if (countBefore !== null) {
|
|
445
452
|
// Insert: expect countBefore + n; upsert on empty: same; upsert on non-empty: expect >= n
|
|
446
453
|
const expected =
|
|
447
454
|
mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
|
|
448
455
|
if (countAfter < expected) {
|
|
449
456
|
console.error(
|
|
450
|
-
`Import result for DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}
|
|
457
|
+
`Import result for DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
|
|
451
458
|
);
|
|
452
459
|
}
|
|
453
460
|
}
|
|
454
461
|
}
|
|
455
|
-
return 0;
|
|
462
|
+
return anyError ? 1 : 0;
|
|
456
463
|
}
|
|
457
464
|
|
|
458
465
|
const fileList = values.file ?? [];
|
|
@@ -481,6 +488,7 @@ export async function main(argv) {
|
|
|
481
488
|
isTTY: process.stdin.isTTY === true,
|
|
482
489
|
});
|
|
483
490
|
}
|
|
491
|
+
let anyFileError = false;
|
|
484
492
|
for (const filePath of fileList) {
|
|
485
493
|
const base = path.basename(filePath);
|
|
486
494
|
const { customerKey } = parseExportBasename(base);
|
|
@@ -501,7 +509,7 @@ export async function main(argv) {
|
|
|
501
509
|
}
|
|
502
510
|
}
|
|
503
511
|
|
|
504
|
-
const n = await importFromFile(sdk, {
|
|
512
|
+
const { count: n, requestIds } = await importFromFile(sdk, {
|
|
505
513
|
filePath,
|
|
506
514
|
deKey: customerKey,
|
|
507
515
|
mode: /** @type {'upsert'|'insert'} */ (mode),
|
|
@@ -509,6 +517,11 @@ export async function main(argv) {
|
|
|
509
517
|
const rel = projectRelativePosix(projectRoot, filePath);
|
|
510
518
|
console.error(`Imported: ${rel} (${n} rows)`);
|
|
511
519
|
|
|
520
|
+
const importHadError = await pollAsyncImportCompletion(sdk, requestIds);
|
|
521
|
+
if (importHadError) {
|
|
522
|
+
anyFileError = true;
|
|
523
|
+
}
|
|
524
|
+
|
|
512
525
|
const countAfter = await getDeRowCount(sdk, customerKey);
|
|
513
526
|
console.error(
|
|
514
527
|
`Row count after import: ${countAfter ?? '(unavailable)'} (DE "${customerKey}")`,
|
|
@@ -520,12 +533,12 @@ export async function main(argv) {
|
|
|
520
533
|
mode === 'insert' || countBefore === 0 ? (countBefore ?? 0) + n : n;
|
|
521
534
|
if (countAfter < expected) {
|
|
522
535
|
console.error(
|
|
523
|
-
`Import result for DE "${customerKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}
|
|
536
|
+
`Import result for DE "${customerKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
|
|
524
537
|
);
|
|
525
538
|
}
|
|
526
539
|
}
|
|
527
540
|
}
|
|
528
|
-
return 0;
|
|
541
|
+
return anyFileError ? 1 : 0;
|
|
529
542
|
}
|
|
530
543
|
|
|
531
544
|
console.error(`Unknown command: ${sub}`);
|
package/lib/cross-bu-import.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveCredentialAndMid, buildSdkAuthObject, buildSdkOptions } from './
|
|
|
7
7
|
import { fetchAllRowObjects, serializeRows, exportDataExtensionToFile } from './export-de.mjs';
|
|
8
8
|
import { formatFromExtension } from './file-resolve.mjs';
|
|
9
9
|
import { importRowsForDe } from './import-de.mjs';
|
|
10
|
+
import { pollAsyncImportCompletion } from './async-status.mjs';
|
|
10
11
|
import { readRowsFromFile } from './read-rows.mjs';
|
|
11
12
|
import { clearDataExtensionRows } from './clear-de.mjs';
|
|
12
13
|
import { confirmClearBeforeImport } from './confirm-clear.mjs';
|
|
@@ -82,7 +83,7 @@ async function offerPreExportBackup({ targets, deKeys, stdin: stdinStream, stdou
|
|
|
82
83
|
* @param {import('./config.mjs').DebugLogger|null} [params.logger] - debug logger for API requests/responses
|
|
83
84
|
* @param {NodeJS.ReadableStream} [params.stdin] Override for testing
|
|
84
85
|
* @param {NodeJS.WritableStream} [params.stdout] Override for testing
|
|
85
|
-
* @returns {Promise.<
|
|
86
|
+
* @returns {Promise.<boolean>} true if at least one import job returned an error
|
|
86
87
|
*/
|
|
87
88
|
export async function crossBuImport(params) {
|
|
88
89
|
const {
|
|
@@ -169,6 +170,8 @@ export async function crossBuImport(params) {
|
|
|
169
170
|
await confirmClearBeforeImport({ deKeys, targets, acceptRiskFlag, isTTY, stdin, stdout });
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
let hasError = false;
|
|
174
|
+
|
|
172
175
|
// Load rows once per DE then fan out to every target
|
|
173
176
|
for (const deKey of deKeys) {
|
|
174
177
|
let rows;
|
|
@@ -216,9 +219,18 @@ export async function crossBuImport(params) {
|
|
|
216
219
|
const snapRel = projectRelativePosix(projectRoot, snapshotPath);
|
|
217
220
|
console.error(`Download stored: ${snapRel} (${rows.length} rows)`);
|
|
218
221
|
|
|
219
|
-
const imported = await importRowsForDe(tgtSdk, {
|
|
222
|
+
const { count: imported, requestIds } = await importRowsForDe(tgtSdk, {
|
|
223
|
+
deKey,
|
|
224
|
+
rows,
|
|
225
|
+
mode,
|
|
226
|
+
});
|
|
220
227
|
console.error(`Imported: ${credential}/${bu} DE ${deKey} (${imported} rows)`);
|
|
221
228
|
|
|
229
|
+
const importHadError = await pollAsyncImportCompletion(tgtSdk, requestIds);
|
|
230
|
+
if (importHadError) {
|
|
231
|
+
hasError = true;
|
|
232
|
+
}
|
|
233
|
+
|
|
222
234
|
const countAfter = await getDeRowCount(tgtSdk, deKey);
|
|
223
235
|
console.error(
|
|
224
236
|
`Row count after import: ${countAfter ?? '(unavailable)'} (${credential}/${bu} DE "${deKey}")`,
|
|
@@ -229,13 +241,17 @@ export async function crossBuImport(params) {
|
|
|
229
241
|
);
|
|
230
242
|
} else {
|
|
231
243
|
const expected =
|
|
232
|
-
mode === 'insert' || countBefore === 0
|
|
244
|
+
mode === 'insert' || countBefore === 0
|
|
245
|
+
? (countBefore ?? 0) + imported
|
|
246
|
+
: imported;
|
|
233
247
|
if (countAfter < expected) {
|
|
234
248
|
console.error(
|
|
235
|
-
`Import result for ${credential}/${bu} DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}
|
|
249
|
+
`Import result for ${credential}/${bu} DE "${deKey}" looks unexpected: expected at least ${expected} rows, got ${countAfter}.`,
|
|
236
250
|
);
|
|
237
251
|
}
|
|
238
252
|
}
|
|
239
253
|
}
|
|
240
254
|
}
|
|
255
|
+
|
|
256
|
+
return hasError;
|
|
241
257
|
}
|
package/lib/import-de.mjs
CHANGED
|
@@ -10,20 +10,22 @@ import { readRowsFromFile } from './read-rows.mjs';
|
|
|
10
10
|
* @param {string} params.deKey
|
|
11
11
|
* @param {object[]} params.rows
|
|
12
12
|
* @param {'upsert'|'insert'} params.mode
|
|
13
|
-
* @returns {Promise.<
|
|
13
|
+
* @returns {Promise.<{ count: number, requestIds: (string|null)[] }>}
|
|
14
14
|
*/
|
|
15
15
|
export async function importRowsForDe(sdk, params) {
|
|
16
16
|
const { deKey, rows, mode } = params;
|
|
17
17
|
const route = resolveImportRoute(mode);
|
|
18
18
|
const chunks = chunkItemsForPayload(rows);
|
|
19
|
+
const requestIds = [];
|
|
19
20
|
for (const chunk of chunks) {
|
|
20
21
|
const p = route.path(deKey);
|
|
21
22
|
const body = { items: chunk };
|
|
22
|
-
await withRetry429(() =>
|
|
23
|
+
const resp = await withRetry429(() =>
|
|
23
24
|
route.method === 'PUT' ? sdk.rest.put(p, body) : sdk.rest.post(p, body),
|
|
24
25
|
);
|
|
26
|
+
requestIds.push(resp?.requestId ?? null);
|
|
25
27
|
}
|
|
26
|
-
return rows.length;
|
|
28
|
+
return { count: rows.length, requestIds };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -33,7 +35,7 @@ export async function importRowsForDe(sdk, params) {
|
|
|
33
35
|
* @param {string} params.deKey - target DE customer key for API
|
|
34
36
|
* @param {'csv'|'tsv'|'json'} [params.format] - optional; auto-detected from file extension if omitted
|
|
35
37
|
* @param {'upsert'|'insert'} params.mode
|
|
36
|
-
* @returns {Promise.<
|
|
38
|
+
* @returns {Promise.<{ count: number, requestIds: (string|null)[] }>}
|
|
37
39
|
*/
|
|
38
40
|
export async function importFromFile(sdk, params) {
|
|
39
41
|
const format = params.format || formatFromExtension(params.filePath);
|
package/lib/import-routes.mjs
CHANGED
|
@@ -21,6 +21,26 @@ export function asyncDataExtensionRowsPath(deKey) {
|
|
|
21
21
|
return `/data/v1/async/dataextensions/key:${encodeURIComponent(deKey)}/rows`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Status endpoint for a previously submitted async request.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} requestId
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function asyncRequestStatusPath(requestId) {
|
|
31
|
+
return `/data/v1/async/${encodeURIComponent(requestId)}/status`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Results endpoint for a previously submitted async request.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} requestId
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function asyncRequestResultsPath(requestId) {
|
|
41
|
+
return `/data/v1/async/${encodeURIComponent(requestId)}/results`;
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
/**
|
|
25
45
|
* @param {'upsert'|'insert'} mode
|
|
26
46
|
* @returns {{ method: 'PUT'|'POST', path: (de: string) => string }}
|
package/lib/index.mjs
CHANGED
|
@@ -5,7 +5,14 @@ export {
|
|
|
5
5
|
parseExportBasename,
|
|
6
6
|
} from './filename.mjs';
|
|
7
7
|
export { chunkItemsForPayload, DEFAULT_MAX_BODY_BYTES, MAX_OBJECTS_PER_BATCH } from './batch.mjs';
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
resolveImportRoute,
|
|
10
|
+
rowsetGetPath,
|
|
11
|
+
asyncDataExtensionRowsPath,
|
|
12
|
+
asyncRequestStatusPath,
|
|
13
|
+
asyncRequestResultsPath,
|
|
14
|
+
} from './import-routes.mjs';
|
|
15
|
+
export { pollAsyncImportCompletion } from './async-status.mjs';
|
|
9
16
|
export {
|
|
10
17
|
loadMcdevProject,
|
|
11
18
|
parseCredBu,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfmc-dataloader",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "SFMC Data Loader CLI (mcdata) to export and import Marketing Cloud Data Extension rows using mcdev project config and sfmc-sdk",
|
|
5
5
|
"author": "Jörn Berkefeld <joern.berkefeld@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"csv-parser": "3.2.0",
|
|
43
|
-
"csv-stringify": "6.
|
|
43
|
+
"csv-stringify": "6.7.0",
|
|
44
44
|
"sfmc-sdk": "3.0.3"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"test": "node --test test/*.test.js",
|
|
48
48
|
"lint": "eslint .",
|
|
49
49
|
"lint:fix": "eslint --fix .",
|
|
50
|
+
"prepare": "husky || true",
|
|
50
51
|
"format": "prettier --write .",
|
|
51
52
|
"format:check": "prettier --check ."
|
|
52
53
|
},
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"eslint-plugin-prettier": "^5.5.0",
|
|
59
60
|
"eslint-plugin-unicorn": "^64.0.0",
|
|
60
61
|
"globals": "^17.4.0",
|
|
62
|
+
"husky": "^9.1.7",
|
|
61
63
|
"prettier": "^3.8.1"
|
|
62
64
|
}
|
|
63
65
|
}
|