pm-csv 2026.5.31 → 2026.6.2-1

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/dist/index.js CHANGED
@@ -2,7 +2,33 @@ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  const defineExtension = ((extension) => extension);
5
- // Columns accepted on import (order independent — driven by header row)
5
+ // ---------------------------------------------------------------------------
6
+ // Error contract
7
+ // ---------------------------------------------------------------------------
8
+ // pm's extension command runtime only treats a thrown error as a cleanly
9
+ // handled non-zero exit when the error carries a numeric `exitCode` property
10
+ // (see @unbrained/pm-cli runCommandHandler). A plain `Error` makes the runtime
11
+ // fall through to its "unhandled" path, which RE-INVOKES the command handler a
12
+ // second time and exits with a generic code. We mirror the SDK's EXIT_CODE
13
+ // contract here rather than importing it: standalone-installed extensions load
14
+ // only their own `dist/`, so `@unbrained/pm-cli` is not resolvable at runtime.
15
+ const EXIT_CODE = {
16
+ GENERIC_FAILURE: 1,
17
+ USAGE: 2,
18
+ NOT_FOUND: 3,
19
+ };
20
+ class CommandError extends Error {
21
+ exitCode;
22
+ constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE) {
23
+ super(message);
24
+ this.name = "CommandError";
25
+ this.exitCode = exitCode;
26
+ }
27
+ }
28
+ // Columns accepted on import (order independent — driven by header row).
29
+ // The relational/planning fields (parent, assignee, sprint, release,
30
+ // blocked_by) all map to real `pm create`/`pm update` flags verified against
31
+ // the installed CLI (--parent, --assignee, --sprint, --release, --blocked-by).
6
32
  const IMPORT_COLUMNS = [
7
33
  "title",
8
34
  "type",
@@ -11,6 +37,11 @@ const IMPORT_COLUMNS = [
11
37
  "tags",
12
38
  "deadline",
13
39
  "body",
40
+ "parent",
41
+ "assignee",
42
+ "sprint",
43
+ "release",
44
+ "blocked_by",
14
45
  ];
15
46
  // Columns written on export (fixed order)
16
47
  const EXPORT_COLUMNS = [
@@ -22,9 +53,26 @@ const EXPORT_COLUMNS = [
22
53
  "tags",
23
54
  "deadline",
24
55
  "body",
56
+ "parent",
57
+ "assignee",
58
+ "sprint",
59
+ "release",
60
+ "blocked_by",
61
+ "csv_source",
25
62
  "created_at",
26
63
  "updated_at",
27
64
  ];
65
+ /** Recognized status values for the `csv validate` report. */
66
+ const KNOWN_STATUSES = new Set([
67
+ "open", "todo", "new",
68
+ "in_progress", "wip", "in progress", "doing",
69
+ "blocked", "on_hold", "on hold",
70
+ "closed", "done", "complete", "completed",
71
+ "canceled", "cancelled",
72
+ "draft",
73
+ ]);
74
+ /** Supported file encodings for `--encoding` on import. */
75
+ const SUPPORTED_ENCODINGS = ["utf-8", "utf8", "utf16le", "latin1"];
28
76
  // ---------------------------------------------------------------------------
29
77
  // CSV parser — no external dependencies
30
78
  // ---------------------------------------------------------------------------
@@ -100,6 +148,14 @@ function parseCSV(text, delimiter = ",") {
100
148
  }
101
149
  return rows;
102
150
  }
151
+ /**
152
+ * Strip a leading UTF-8 BOM (U+FEFF) if present. Files exported by Excel and
153
+ * many Windows tools start with a BOM; without removing it the first header
154
+ * name silently becomes "title" and the required-column check fails.
155
+ */
156
+ function stripBOM(text) {
157
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
158
+ }
103
159
  /**
104
160
  * Serialize a single field value for CSV output.
105
161
  * Wraps in double-quotes when the value contains the delimiter, quotes, or newlines.
@@ -117,10 +173,14 @@ function serializeField(value, delimiter) {
117
173
  /**
118
174
  * Serialize a full array of rows into a CSV string.
119
175
  */
120
- function serializeCSV(rows, delimiter) {
176
+ function serializeCSV(rows, delimiterOrOpts) {
177
+ const opts = typeof delimiterOrOpts === "string"
178
+ ? { delimiter: delimiterOrOpts }
179
+ : delimiterOrOpts;
180
+ const eol = opts.eol ?? "\n";
121
181
  return rows
122
- .map((row) => row.map((f) => serializeField(f, delimiter)).join(delimiter))
123
- .join("\n");
182
+ .map((row) => row.map((f) => serializeField(f, opts.delimiter)).join(opts.delimiter))
183
+ .join(eol);
124
184
  }
125
185
  // ---------------------------------------------------------------------------
126
186
  // Helpers
@@ -137,6 +197,91 @@ function readBoolOption(options, ...keys) {
137
197
  }
138
198
  return false;
139
199
  }
200
+ /**
201
+ * Resolve a user-supplied delimiter, accepting friendly aliases so TSV is easy:
202
+ * --delimiter tab --delimiter "\t" --delimiter ";"
203
+ * A literal backslash-t is interpreted as a tab.
204
+ */
205
+ function resolveDelimiter(raw) {
206
+ if (raw === undefined || raw === "")
207
+ return ",";
208
+ const lower = raw.toLowerCase();
209
+ if (lower === "tab" || lower === "\\t" || lower === "tsv")
210
+ return "\t";
211
+ if (lower === "comma")
212
+ return ",";
213
+ if (lower === "semicolon")
214
+ return ";";
215
+ if (lower === "pipe")
216
+ return "|";
217
+ return raw;
218
+ }
219
+ /**
220
+ * Provenance tag prefix used for idempotent upsert. When the importer is told
221
+ * to key on a column, the created item is tagged `csv-key:<value>` so a later
222
+ * re-import can find and update the same item instead of duplicating it.
223
+ */
224
+ const KEY_TAG_PREFIX = "csv-key:";
225
+ // Provenance tag prefix written when `--source <label>` is given. The CLI's
226
+ // registerItemFields registers the `csv_source` schema field but (as of
227
+ // pm 2026.5.31) does not expose a `pm create --csv_source` setter, so we
228
+ // persist the label as a queryable tag. Stripped from exports like csv-key.
229
+ const SOURCE_TAG_PREFIX = "csv-source:";
230
+ const PM_LIST_MAX_BUFFER = 16 * 1024 * 1024;
231
+ /**
232
+ * Normalize a dedup key value for stable matching. pm lower-cases tags on
233
+ * storage, so a `csv-key:` tag written from "Fix Bug" comes back as
234
+ * "fix bug"; we therefore fold the key to lower-case on BOTH write and lookup
235
+ * so re-imports match (and thus update) instead of duplicating.
236
+ */
237
+ function normalizeKeyValue(value) {
238
+ return value.trim().toLowerCase();
239
+ }
240
+ function encodeKeyTagValue(value) {
241
+ return encodeURIComponent(value);
242
+ }
243
+ function decodeKeyTagValue(value) {
244
+ try {
245
+ return decodeURIComponent(value);
246
+ }
247
+ catch {
248
+ return value;
249
+ }
250
+ }
251
+ /**
252
+ * Parse a `--map csvHeader=field` spec (repeatable / comma-joined) into a
253
+ * lookup from a normalized CSV header name to the canonical pm field name.
254
+ * Example: `--map "Summary=title,Owner=tags"`.
255
+ */
256
+ function parseFieldMap(spec) {
257
+ const map = {};
258
+ if (spec === undefined)
259
+ return map;
260
+ const parts = (Array.isArray(spec) ? spec : [spec]).flatMap((s) => s.split(","));
261
+ for (const part of parts) {
262
+ const trimmed = part.trim();
263
+ if (!trimmed)
264
+ continue;
265
+ const eq = trimmed.indexOf("=");
266
+ if (eq < 0) {
267
+ throw new CommandError(`Invalid --map entry "${trimmed}"; expected csvHeader=field`, EXIT_CODE.USAGE);
268
+ }
269
+ const from = trimmed.slice(0, eq).trim().toLowerCase();
270
+ const to = trimmed.slice(eq + 1).trim().toLowerCase();
271
+ if (!from || !to) {
272
+ throw new CommandError(`Invalid --map entry "${trimmed}"; expected csvHeader=field`, EXIT_CODE.USAGE);
273
+ }
274
+ map[from] = to;
275
+ }
276
+ return map;
277
+ }
278
+ /**
279
+ * Apply a field map to a list of header names, producing the effective
280
+ * (canonical) header used for column lookup.
281
+ */
282
+ function applyFieldMap(headers, fieldMap) {
283
+ return headers.map((h) => fieldMap[h] ?? h);
284
+ }
140
285
  /**
141
286
  * Map an arbitrary status string (from the CSV) to a valid SDK status.
142
287
  * Falls back to "open".
@@ -181,12 +326,27 @@ function stringifyTags(tags) {
181
326
  return "";
182
327
  return tags.join(",");
183
328
  }
329
+ /**
330
+ * Validate and normalize a user-supplied `--encoding` value to a Node-supported
331
+ * BufferEncoding. Accepts utf-8/utf8, utf16le, latin1. Throws USAGE otherwise.
332
+ */
333
+ function resolveEncoding(raw) {
334
+ if (raw === undefined || raw === "")
335
+ return "utf-8";
336
+ const lower = raw.trim().toLowerCase();
337
+ if (SUPPORTED_ENCODINGS.includes(lower)) {
338
+ return lower;
339
+ }
340
+ throw new CommandError(`Unknown --encoding '${raw}'. Supported: ${SUPPORTED_ENCODINGS.join(", ")}`, EXIT_CODE.USAGE);
341
+ }
184
342
  /**
185
343
  * Read rows from a CSV file, returning header and data rows separately.
186
- * Skips fully-empty rows.
344
+ * Skips fully-empty rows. Decodes with the given encoding (default utf-8).
187
345
  */
188
- function readCSVFile(filePath, delimiter) {
189
- const text = readFileSync(filePath, "utf-8");
346
+ function readCSVFile(filePath, delimiter, encoding = "utf-8") {
347
+ // Node's BufferEncoding spells utf-8 as "utf8"; normalize.
348
+ const bufEnc = encoding === "utf-8" ? "utf8" : encoding;
349
+ const text = stripBOM(readFileSync(filePath, bufEnc));
190
350
  const rows = parseCSV(text, delimiter).filter((r) => r.some((f) => f.trim() !== ""));
191
351
  if (rows.length === 0) {
192
352
  return { headers: [], dataRows: [] };
@@ -195,124 +355,491 @@ function readCSVFile(filePath, delimiter) {
195
355
  const dataRows = rows.slice(1);
196
356
  return { headers, dataRows };
197
357
  }
198
- // ---------------------------------------------------------------------------
199
- // Extension
200
- // ---------------------------------------------------------------------------
358
+ /**
359
+ * Build the canonical field accessor for a header row + data row.
360
+ */
361
+ function rowFields(headers, row) {
362
+ const col = (name) => headers.indexOf(name);
363
+ const get = (name) => {
364
+ const idx = col(name);
365
+ return idx >= 0 ? (row[idx] ?? "").trim() : "";
366
+ };
367
+ const rawStatus = get("status");
368
+ const rawPriority = get("priority");
369
+ const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
370
+ return {
371
+ get,
372
+ parsed: {
373
+ title: get("title"),
374
+ status: rawStatus ? normalizeStatus(rawStatus) : "open",
375
+ priority: priority !== undefined && !isNaN(priority) ? priority : undefined,
376
+ tags: parseTags(get("tags")),
377
+ type: get("type") || undefined,
378
+ // pm has no milestone/due_date fields; map deadline (accept legacy header).
379
+ deadline: get("deadline") || get("due_date") || undefined,
380
+ body: get("body") || undefined,
381
+ parent: get("parent") || undefined,
382
+ assignee: get("assignee") || undefined,
383
+ sprint: get("sprint") || undefined,
384
+ release: get("release") || undefined,
385
+ // Accept both blocked_by and a friendlier blocked-by header.
386
+ blocked_by: get("blocked_by") || get("blocked-by") || undefined,
387
+ },
388
+ };
389
+ }
390
+ /**
391
+ * List existing items once and build a lookup from csv-key provenance value to
392
+ * item id, for idempotent upsert.
393
+ */
394
+ function loadKeyIndex(pmRoot) {
395
+ const index = new Map();
396
+ const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json"], { encoding: "utf-8", maxBuffer: PM_LIST_MAX_BUFFER });
397
+ if (result.error)
398
+ throw new CommandError(`pm list-all failed: ${result.error.message}`);
399
+ if (result.status !== 0) {
400
+ throw new CommandError(result.stderr?.trim() || "pm list-all failed");
401
+ }
402
+ let items = [];
403
+ try {
404
+ items = JSON.parse(result.stdout).items ?? [];
405
+ }
406
+ catch {
407
+ return index;
408
+ }
409
+ for (const item of items) {
410
+ for (const tag of item.tags ?? []) {
411
+ if (tag.startsWith(KEY_TAG_PREFIX)) {
412
+ index.set(normalizeKeyValue(decodeKeyTagValue(tag.slice(KEY_TAG_PREFIX.length))), item.id);
413
+ }
414
+ }
415
+ }
416
+ return index;
417
+ }
418
+ function importCSV(pmRoot, filePath, opts) {
419
+ const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8");
420
+ const result = { imported: 0, updated: 0, skipped: 0, errors: [], previews: [] };
421
+ if (rawHeaders.length === 0)
422
+ return result;
423
+ const headers = applyFieldMap(rawHeaders, opts.fieldMap);
424
+ if (!headers.includes("title")) {
425
+ throw new CommandError(`CSV is missing required 'title' column (after --map). Found: ${headers.join(", ")}`, EXIT_CODE.USAGE);
426
+ }
427
+ if (opts.keyField && !headers.includes(opts.keyField)) {
428
+ throw new CommandError(`--key column '${opts.keyField}' not found in CSV. Found: ${headers.join(", ")}`, EXIT_CODE.USAGE);
429
+ }
430
+ // Pre-load the dedup index only when upserting (one extra pm call, not per-row).
431
+ const keyIndex = opts.keyField && !opts.dryRun ? loadKeyIndex(pmRoot) : new Map();
432
+ for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
433
+ const row = dataRows[rowIndex];
434
+ const lineNo = rowIndex + 2; // 1-based + header row
435
+ const { get, parsed } = rowFields(headers, row);
436
+ if (!parsed.title) {
437
+ const msg = `Row ${lineNo}: skipping — 'title' is empty`;
438
+ console.error(msg);
439
+ result.skipped++;
440
+ continue;
441
+ }
442
+ const keyValue = opts.keyField ? get(opts.keyField) : "";
443
+ const existingId = keyValue ? keyIndex.get(normalizeKeyValue(keyValue)) : undefined;
444
+ if (opts.dryRun) {
445
+ result.previews.push({
446
+ action: existingId ? "update" : "create",
447
+ ...parsed,
448
+ ...(opts.keyField ? { key: keyValue } : {}),
449
+ ...(opts.source ? { csv_source: opts.source } : {}),
450
+ });
451
+ if (existingId)
452
+ result.updated++;
453
+ else
454
+ result.imported++;
455
+ continue;
456
+ }
457
+ try {
458
+ if (existingId) {
459
+ upsertUpdate(pmRoot, existingId, parsed, opts.source);
460
+ result.updated++;
461
+ }
462
+ else {
463
+ const newId = upsertCreate(pmRoot, parsed, opts.keyField ? keyValue : undefined, opts.source);
464
+ if (opts.keyField && keyValue && newId)
465
+ keyIndex.set(normalizeKeyValue(keyValue), newId);
466
+ result.imported++;
467
+ }
468
+ }
469
+ catch (err) {
470
+ const msg = `Row ${lineNo}: ${existingId ? "update" : "create"} failed — ${err instanceof Error ? err.message : String(err)}`;
471
+ console.error(msg);
472
+ result.errors.push(msg);
473
+ result.skipped++;
474
+ }
475
+ }
476
+ return result;
477
+ }
478
+ /**
479
+ * Append the relational/planning field flags shared by create and update.
480
+ * Flag names verified against the installed `pm create`/`pm update` contracts:
481
+ * --parent, --assignee, --sprint, --release, --blocked-by.
482
+ */
483
+ function appendRelationalArgs(args, p) {
484
+ if (p.parent)
485
+ args.push("--parent", p.parent);
486
+ if (p.assignee)
487
+ args.push("--assignee", p.assignee);
488
+ if (p.sprint)
489
+ args.push("--sprint", p.sprint);
490
+ if (p.release)
491
+ args.push("--release", p.release);
492
+ if (p.blocked_by)
493
+ args.push("--blocked-by", p.blocked_by);
494
+ }
495
+ /** Create a new item, optionally carrying a csv-key provenance tag. Returns the new id. */
496
+ function upsertCreate(pmRoot, p, keyValue, source) {
497
+ const tags = [...p.tags];
498
+ // Encode the lower-cased key so the stored tag matches the lookup index
499
+ // regardless of pm's tag case-folding (see normalizeKeyValue).
500
+ if (keyValue)
501
+ tags.push(`${KEY_TAG_PREFIX}${encodeKeyTagValue(normalizeKeyValue(keyValue))}`);
502
+ // Provenance for the schema-registered csv_source field, persisted as a tag
503
+ // since the CLI exposes no scalar setter for extension-registered fields.
504
+ if (source)
505
+ tags.push(`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`);
506
+ const args = ["--path", pmRoot, "create", "--title", p.title, "--status", p.status, "--json"];
507
+ if (p.body)
508
+ args.push("--body", p.body);
509
+ if (p.priority !== undefined)
510
+ args.push("--priority", String(p.priority));
511
+ if (p.type)
512
+ args.push("--type", p.type);
513
+ if (p.deadline)
514
+ args.push("--deadline", p.deadline);
515
+ appendRelationalArgs(args, p);
516
+ if (tags.length > 0)
517
+ args.push("--tags", tags.join(","));
518
+ const r = spawnSync("pm", args, { encoding: "utf-8" });
519
+ if (r.status !== 0)
520
+ throw new Error(r.stderr?.trim() || "pm create failed");
521
+ try {
522
+ const parsed = JSON.parse(r.stdout);
523
+ return parsed.id ?? parsed.item?.id ?? "";
524
+ }
525
+ catch {
526
+ return "";
527
+ }
528
+ }
529
+ /** Update an existing item in place (status via update; close handled separately). */
530
+ function upsertUpdate(pmRoot, id, p, source) {
531
+ const args = ["--path", pmRoot, "update", id, "--title", p.title];
532
+ if (p.body !== undefined)
533
+ args.push("--body", p.body);
534
+ if (p.priority !== undefined)
535
+ args.push("--priority", String(p.priority));
536
+ if (p.type)
537
+ args.push("--type", p.type);
538
+ if (p.deadline)
539
+ args.push("--deadline", p.deadline);
540
+ appendRelationalArgs(args, p);
541
+ // Preserve the csv-key tag (additive) and refresh the user tags.
542
+ const addTags = [...p.tags];
543
+ if (source)
544
+ addTags.push(`${SOURCE_TAG_PREFIX}${encodeKeyTagValue(source)}`);
545
+ if (addTags.length > 0)
546
+ args.push("--add-tags", addTags.join(","));
547
+ // `update` cannot set a closed status; only set non-closed statuses here.
548
+ if (p.status !== "closed" && p.status !== "canceled")
549
+ args.push("--status", p.status);
550
+ const r = spawnSync("pm", args, { encoding: "utf-8" });
551
+ if (r.status !== 0)
552
+ throw new Error(r.stderr?.trim() || "pm update failed");
553
+ // Apply terminal statuses through the dedicated close command.
554
+ if (p.status === "closed" || p.status === "canceled") {
555
+ const reason = p.status === "canceled" ? "canceled" : "completed";
556
+ const cr = spawnSync("pm", ["--path", pmRoot, "close", id, "--reason", reason], { encoding: "utf-8" });
557
+ if (cr.status !== 0)
558
+ throw new Error(cr.stderr?.trim() || "pm close failed");
559
+ }
560
+ }
561
+ /**
562
+ * Parse a CSV and report data-quality issues without writing anything.
563
+ * Pure function over file contents — exposed for unit testing via the
564
+ * lower-level {@link validateParsedCSV} helper below.
565
+ */
566
+ function validateCSV(filePath, opts) {
567
+ const { headers: rawHeaders, dataRows } = readCSVFile(filePath, opts.delimiter, opts.encoding ?? "utf-8");
568
+ return validateParsedCSV(rawHeaders, dataRows, opts.fieldMap);
569
+ }
570
+ /**
571
+ * Core validation logic over already-parsed headers + rows. Pure and
572
+ * side-effect-free so it can be unit tested directly.
573
+ */
574
+ function validateParsedCSV(rawHeaders, dataRows, fieldMap) {
575
+ const mappedColumns = applyFieldMap(rawHeaders, fieldMap);
576
+ const hasTitleColumn = mappedColumns.includes("title");
577
+ const issues = [];
578
+ let rowsMissingTitle = 0;
579
+ let rowsWithUnknownStatus = 0;
580
+ let rowsWithNonIntegerPriority = 0;
581
+ const titleIdx = mappedColumns.indexOf("title");
582
+ const statusIdx = mappedColumns.indexOf("status");
583
+ const priorityIdx = mappedColumns.indexOf("priority");
584
+ for (const row of dataRows) {
585
+ if (hasTitleColumn) {
586
+ const title = (row[titleIdx] ?? "").trim();
587
+ if (!title)
588
+ rowsMissingTitle++;
589
+ }
590
+ if (statusIdx >= 0) {
591
+ const status = (row[statusIdx] ?? "").trim().toLowerCase();
592
+ if (status && !KNOWN_STATUSES.has(status))
593
+ rowsWithUnknownStatus++;
594
+ }
595
+ if (priorityIdx >= 0) {
596
+ const priority = (row[priorityIdx] ?? "").trim();
597
+ if (priority && !/^-?\d+$/.test(priority))
598
+ rowsWithNonIntegerPriority++;
599
+ }
600
+ }
601
+ if (rawHeaders.length === 0) {
602
+ issues.push("CSV is empty (no header row).");
603
+ }
604
+ if (!hasTitleColumn) {
605
+ issues.push(`Missing required 'title' column (after --map). Detected: ${mappedColumns.join(", ") || "(none)"}`);
606
+ }
607
+ if (rowsMissingTitle > 0) {
608
+ issues.push(`${rowsMissingTitle} row(s) have an empty title and would be skipped.`);
609
+ }
610
+ if (rowsWithUnknownStatus > 0) {
611
+ issues.push(`${rowsWithUnknownStatus} row(s) have an unrecognized status (would fall back to 'open').`);
612
+ }
613
+ if (rowsWithNonIntegerPriority > 0) {
614
+ issues.push(`${rowsWithNonIntegerPriority} row(s) have a non-integer priority (would be ignored).`);
615
+ }
616
+ // Only a missing title column (or empty file) is a structural problem.
617
+ const ok = hasTitleColumn && rawHeaders.length > 0;
618
+ return {
619
+ ok,
620
+ rowCount: dataRows.length,
621
+ detectedColumns: rawHeaders,
622
+ mappedColumns,
623
+ hasTitleColumn,
624
+ rowsMissingTitle,
625
+ rowsWithUnknownStatus,
626
+ rowsWithNonIntegerPriority,
627
+ issues,
628
+ };
629
+ }
630
+ // Parse a `--columns id,title,status` spec into a validated, ordered subset of
631
+ // the export columns. Unknown column names throw a USAGE error; an empty/absent
632
+ // spec falls back to the full default column set.
633
+ function selectExportColumns(spec) {
634
+ if (!spec || !spec.trim())
635
+ return EXPORT_COLUMNS;
636
+ const requested = spec.split(",").map((s) => s.trim()).filter(Boolean);
637
+ const valid = new Set(EXPORT_COLUMNS);
638
+ const unknown = requested.filter((c) => !valid.has(c));
639
+ if (unknown.length > 0) {
640
+ throw new CommandError(`Unknown export column(s): ${unknown.join(", ")}. Valid: ${EXPORT_COLUMNS.join(", ")}`, EXIT_CODE.USAGE);
641
+ }
642
+ return requested;
643
+ }
644
+ function buildCsvExport(pmRoot, opts) {
645
+ const result = spawnSync("pm", ["--path", pmRoot, "list-all", "--json", "--include-body"], { encoding: "utf-8" });
646
+ if (result.status !== 0) {
647
+ throw new CommandError(result.stderr || "pm list-all failed");
648
+ }
649
+ let items = JSON.parse(result.stdout).items ?? [];
650
+ if (opts.statusFilter)
651
+ items = items.filter((i) => i.status === opts.statusFilter);
652
+ if (opts.typeFilter)
653
+ items = items.filter((i) => i.type === opts.typeFilter);
654
+ // Surface provenance: derive csv_source from the internal csv-source: tag.
655
+ for (const item of items) {
656
+ const sourceTag = (item.tags ?? []).find((t) => t.startsWith(SOURCE_TAG_PREFIX));
657
+ if (sourceTag)
658
+ item.csv_source = decodeKeyTagValue(sourceTag.slice(SOURCE_TAG_PREFIX.length));
659
+ }
660
+ const dataRows = items.map((item) => opts.columns.map((col) => {
661
+ const val = item[col];
662
+ if (val === undefined || val === null)
663
+ return "";
664
+ if (Array.isArray(val)) {
665
+ // Strip internal provenance tags (csv-key / csv-source) so a round-trip
666
+ // export stays clean.
667
+ const visible = val.filter((t) => typeof t === "string" &&
668
+ !t.startsWith(KEY_TAG_PREFIX) &&
669
+ !t.startsWith(SOURCE_TAG_PREFIX));
670
+ return stringifyTags(visible);
671
+ }
672
+ return String(val);
673
+ }));
674
+ const allRows = opts.noHeader ? dataRows : [opts.columns.map(String), ...dataRows];
675
+ // --excel implies CRLF (and a UTF-8 BOM prefix, added below).
676
+ const eol = opts.crlf || opts.excel ? "\r\n" : "\n";
677
+ let csvText = serializeCSV(allRows, { delimiter: opts.delimiter, eol });
678
+ if (opts.excel)
679
+ csvText = "" + csvText;
680
+ return {
681
+ csvText,
682
+ count: items.length,
683
+ };
684
+ }
201
685
  export default defineExtension({
202
686
  name: "pm-csv",
203
- version: "2026.5.31",
687
+ version: "2026.6.2-1",
204
688
  activate(api) {
689
+ // -----------------------------------------------------------------------
690
+ // Schema: register an optional `csv_source` provenance field so imported
691
+ // items can record where they came from (set via `pm csv import --source`).
692
+ // Guarded: only call when the running SDK exposes registerItemFields, so
693
+ // older hosts that lack the schema capability degrade to a no-op (and the
694
+ // manifest still declares "schema" because we genuinely implement it).
695
+ //
696
+ // NOTE: pm 2026.5.31 accepts the field into the schema registry but exposes
697
+ // no `pm create --csv_source` setter for extension-registered scalar fields,
698
+ // so the importer persists the provenance label as a `csv-source:` tag
699
+ // (stripped from exports and surfaced back via the csv_source export column).
700
+ // -----------------------------------------------------------------------
701
+ if (typeof api.registerItemFields === "function") {
702
+ try {
703
+ api.registerItemFields([
704
+ { name: "csv_source", type: "string", optional: true },
705
+ ]);
706
+ }
707
+ catch (err) {
708
+ // Never let a schema-registration hiccup break command registration.
709
+ console.error(`pm-csv: csv_source field not registered — ${err instanceof Error ? err.message : String(err)}`);
710
+ }
711
+ }
205
712
  // -----------------------------------------------------------------------
206
713
  // Command: pm csv import <file>
207
714
  // -----------------------------------------------------------------------
208
715
  api.registerCommand({
209
716
  name: "csv import",
210
- description: "Import pm items from a CSV file. " +
211
- "Expected columns: title, type, status, priority, tags, milestone, due_date, body. " +
212
- "Only 'title' is required; all other columns are optional.",
717
+ description: "Import pm items from a CSV file with full RFC-4180 parsing (quoted fields, " +
718
+ "embedded newlines, escaped quotes, BOM, CRLF). Expected columns: title, type, " +
719
+ "status, priority, tags, deadline, body, parent, assignee, sprint, release, " +
720
+ "blocked_by. Only 'title' is required. Use --map to remap arbitrary headers, " +
721
+ "--key for idempotent re-import (upsert), --encoding for non-UTF-8 files, and " +
722
+ "--source to record import provenance in the csv_source field.",
213
723
  intent: "import items from a CSV file into pm",
214
724
  examples: [
215
725
  "pm csv import tasks.csv",
216
726
  "pm csv import backlog.csv --delimiter ';'",
727
+ "pm csv import data.tsv --delimiter tab",
728
+ "pm csv import jira.csv --map 'Summary=title,Owner=tags'",
729
+ "pm csv import items.csv --key title # idempotent re-import (no duplicates)",
730
+ "pm csv import legacy.csv --encoding latin1",
731
+ "pm csv import sprint12.csv --source 'jira-export-2026-06'",
217
732
  "pm csv import items.csv --dry-run",
218
733
  ],
219
734
  flags: [
220
- { long: "--delimiter", value_name: "char", description: "CSV field delimiter (default: ,)" },
735
+ { long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
736
+ { long: "--map", value_name: "col=field", description: "Remap a CSV header to a pm field (repeatable, comma-joined). e.g. --map 'Summary=title'" },
737
+ { long: "--key", value_name: "field", description: "Dedup key column: re-import updates the matching item instead of creating a duplicate" },
738
+ { long: "--encoding", value_name: "enc", description: "Source file encoding: utf-8 (default) | utf16le | latin1" },
739
+ { long: "--source", value_name: "label", description: "Record an import-provenance label in the csv_source field of created/updated items" },
221
740
  { long: "--dry-run", description: "Preview without writing" },
222
741
  ],
223
742
  async run(ctx) {
224
743
  const filePath = ctx.args[0];
225
744
  if (!filePath) {
226
- throw new Error("Usage: pm csv import <file> [--delimiter <char>] [--dry-run]");
745
+ throw new CommandError("Usage: pm csv import <file> [--delimiter <char>] [--map col=field] [--key field] [--encoding enc] [--source label] [--dry-run]", EXIT_CODE.USAGE);
227
746
  }
228
- const delimiter = ctx.options["delimiter"] ?? ",";
747
+ const delimiter = resolveDelimiter(ctx.options["delimiter"]);
229
748
  const dryRun = readBoolOption(ctx.options, "dry-run", "dryRun");
749
+ const fieldMap = parseFieldMap(ctx.options["map"]);
750
+ const keyField = (ctx.options["key"] ?? "").trim().toLowerCase() || undefined;
751
+ const encoding = resolveEncoding(ctx.options["encoding"]);
752
+ const source = (ctx.options["source"] ?? "").trim() || undefined;
230
753
  const absolutePath = resolve(filePath);
231
754
  console.error(`Reading CSV from: ${absolutePath}`);
232
- let headers;
233
- let dataRows;
755
+ let res;
234
756
  try {
235
- ({ headers, dataRows } = readCSVFile(absolutePath, delimiter));
757
+ res = importCSV(ctx.pm_root, absolutePath, { delimiter, dryRun, fieldMap, keyField, encoding, source });
236
758
  }
237
759
  catch (err) {
760
+ if (err instanceof CommandError)
761
+ throw err;
238
762
  const msg = err instanceof Error ? err.message : String(err);
239
- throw new Error(`Failed to read file: ${msg}`);
763
+ const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
764
+ throw new CommandError(`Failed to import: ${msg}`, exitCode);
240
765
  }
241
- if (headers.length === 0) {
242
- console.error("CSV file is empty or has no headers.");
243
- return { imported: 0, skipped: 0 };
766
+ if (dryRun) {
767
+ console.error(`[dry-run] Would create ${res.imported}, update ${res.updated}, skip ${res.skipped}.`);
768
+ return {
769
+ dryRun: true,
770
+ wouldCreate: res.imported,
771
+ wouldUpdate: res.updated,
772
+ wouldSkip: res.skipped,
773
+ previews: res.previews,
774
+ };
244
775
  }
245
- // Validate that at minimum 'title' is present
246
- if (!headers.includes("title")) {
247
- throw new Error(`CSV is missing required 'title' column. Found: ${headers.join(", ")}`);
776
+ console.error(`Imported ${res.imported}, updated ${res.updated}, skipped ${res.skipped}.`);
777
+ return { imported: res.imported, updated: res.updated, skipped: res.skipped, errors: res.errors };
778
+ },
779
+ });
780
+ // -----------------------------------------------------------------------
781
+ // Command: pm csv validate <file>
782
+ // -----------------------------------------------------------------------
783
+ api.registerCommand({
784
+ name: "csv validate",
785
+ description: "Validate a CSV without importing it. Reports row count, detected/mapped " +
786
+ "columns, rows missing a title, rows with unrecognized status, rows with a " +
787
+ "non-integer priority, and whether the required 'title' column is present " +
788
+ "(after --map). Exits non-zero on structural problems (missing title column). " +
789
+ "Honors --delimiter, --map, --encoding; supports --json.",
790
+ intent: "validate a CSV file without importing",
791
+ examples: [
792
+ "pm csv validate tasks.csv",
793
+ "pm csv validate jira.csv --map 'Summary=title'",
794
+ "pm csv validate data.tsv --delimiter tab --json",
795
+ ],
796
+ flags: [
797
+ { long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
798
+ { long: "--map", value_name: "col=field", description: "Remap a CSV header to a pm field (repeatable, comma-joined) before validating" },
799
+ { long: "--encoding", value_name: "enc", description: "Source file encoding: utf-8 (default) | utf16le | latin1" },
800
+ { long: "--json", description: "Emit the report as JSON" },
801
+ ],
802
+ async run(ctx) {
803
+ const filePath = ctx.args[0];
804
+ if (!filePath) {
805
+ throw new CommandError("Usage: pm csv validate <file> [--delimiter <char>] [--map col=field] [--encoding enc] [--json]", EXIT_CODE.USAGE);
248
806
  }
249
- // Index columns
250
- const col = (name) => headers.indexOf(name);
251
- let imported = 0;
252
- let skipped = 0;
253
- const previews = [];
254
- for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
255
- const row = dataRows[rowIndex];
256
- const get = (name) => {
257
- const idx = col(name);
258
- return idx >= 0 ? (row[idx] ?? "").trim() : "";
259
- };
260
- const title = get("title");
261
- if (!title) {
262
- console.error(`Row ${rowIndex + 2}: skipping — 'title' is empty`);
263
- skipped++;
264
- continue;
265
- }
266
- const rawStatus = get("status");
267
- const status = rawStatus ? normalizeStatus(rawStatus) : "open";
268
- const rawPriority = get("priority");
269
- const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
270
- const tags = parseTags(get("tags"));
271
- const type = get("type") || undefined;
272
- // pm has no milestone/due_date fields on `create`; the deadline column
273
- // (accept legacy "due_date" header too) maps to `--deadline`.
274
- const deadline = get("deadline") || get("due_date") || undefined;
275
- const body = get("body") || undefined;
276
- if (dryRun) {
277
- previews.push({ title, type, status, priority, tags, deadline, body });
278
- imported++;
279
- continue;
280
- }
281
- try {
282
- const spawnArgs = [
283
- "--path", ctx.pm_root,
284
- "create",
285
- "--title", title,
286
- "--status", status,
287
- ];
288
- if (body)
289
- spawnArgs.push("--body", body);
290
- if (priority !== undefined && !isNaN(priority))
291
- spawnArgs.push("--priority", String(priority));
292
- if (type)
293
- spawnArgs.push("--type", type);
294
- if (deadline)
295
- spawnArgs.push("--deadline", deadline);
296
- if (tags.length > 0)
297
- spawnArgs.push("--tags", tags.join(","));
298
- const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
299
- if (result.status !== 0) {
300
- throw new Error(result.stderr || "pm create failed");
301
- }
302
- imported++;
303
- }
304
- catch (err) {
305
- const msg = err instanceof Error ? err.message : String(err);
306
- console.error(`Row ${rowIndex + 2}: create failed — ${msg}`);
307
- skipped++;
308
- }
807
+ const delimiter = resolveDelimiter(ctx.options["delimiter"]);
808
+ const fieldMap = parseFieldMap(ctx.options["map"]);
809
+ const encoding = resolveEncoding(ctx.options["encoding"]);
810
+ const asJson = readBoolOption(ctx.options, "json");
811
+ const absolutePath = resolve(filePath);
812
+ let report;
813
+ try {
814
+ report = validateCSV(absolutePath, { delimiter, fieldMap, encoding });
309
815
  }
310
- if (dryRun) {
311
- console.error(`[dry-run] Would import ${imported} item(s), skip ${skipped} item(s).`);
312
- return { dryRun: true, wouldImport: imported, wouldSkip: skipped, previews };
816
+ catch (err) {
817
+ if (err instanceof CommandError)
818
+ throw err;
819
+ const msg = err instanceof Error ? err.message : String(err);
820
+ const exitCode = /ENOENT|no such file/i.test(msg) ? EXIT_CODE.NOT_FOUND : EXIT_CODE.GENERIC_FAILURE;
821
+ throw new CommandError(`Failed to validate: ${msg}`, exitCode);
313
822
  }
314
- console.error(`Imported ${imported} item(s), skipped ${skipped} item(s).`);
315
- return { imported, skipped };
823
+ // Human-readable summary on stderr (so --json stdout stays clean).
824
+ console.error(`Rows: ${report.rowCount}`);
825
+ console.error(`Detected columns: ${report.detectedColumns.join(", ") || "(none)"}`);
826
+ console.error(`Mapped columns: ${report.mappedColumns.join(", ") || "(none)"}`);
827
+ console.error(`Has 'title' column: ${report.hasTitleColumn ? "yes" : "no"}`);
828
+ console.error(`Rows missing title: ${report.rowsMissingTitle}`);
829
+ console.error(`Rows w/ unknown status: ${report.rowsWithUnknownStatus}`);
830
+ console.error(`Rows w/ non-integer priority: ${report.rowsWithNonIntegerPriority}`);
831
+ for (const issue of report.issues)
832
+ console.error(` - ${issue}`);
833
+ console.error(report.ok ? "Validation OK." : "Validation FAILED (structural problems).");
834
+ // Structural problems (no title column / empty) → non-zero exit.
835
+ if (!report.ok) {
836
+ if (asJson) {
837
+ // Surface the structured report even on failure before throwing.
838
+ console.error(JSON.stringify(report, null, 2));
839
+ }
840
+ throw new CommandError("CSV is missing the required 'title' column (after --map).", EXIT_CODE.USAGE);
841
+ }
842
+ return report;
316
843
  },
317
844
  });
318
845
  // -----------------------------------------------------------------------
@@ -329,62 +856,80 @@ export default defineExtension({
329
856
  "pm csv export --output backlog.csv --delimiter ';'",
330
857
  "pm csv export --status open --output todos.csv",
331
858
  "pm csv export --type Feature --output features.csv",
859
+ "pm csv export --excel --output for-excel.csv",
332
860
  ],
333
861
  flags: [
334
862
  { long: "--output", value_name: "file", description: "Output file path (default: print to stdout)" },
335
- { long: "--delimiter", value_name: "char", description: "CSV field delimiter (default: ,)" },
863
+ { long: "--delimiter", value_name: "char", description: "Field delimiter, or alias tab|comma|semicolon|pipe (default: ,)" },
336
864
  { long: "--status", value_name: "filter", description: "Filter by status: open | in_progress | blocked | closed | canceled | draft" },
337
865
  { long: "--type", value_name: "type", description: "Filter by item type" },
866
+ { long: "--columns", value_name: "list", description: `Comma-separated columns to export, in order (default: all). Valid: ${EXPORT_COLUMNS.join(", ")}` },
867
+ { long: "--no-header", description: "Omit the CSV header row" },
868
+ { long: "--crlf", description: "Use CRLF line endings (RFC-4180 / Excel)" },
869
+ { long: "--excel", description: "Excel-friendly output: CRLF line endings + a UTF-8 BOM prefix" },
338
870
  ],
339
871
  async run(ctx) {
340
- const delimiter = ctx.options["delimiter"] ?? ",";
872
+ const delimiter = resolveDelimiter(ctx.options["delimiter"]);
341
873
  const outputPath = ctx.options["output"];
342
- const statusFilter = ctx.options["status"];
343
- const typeFilter = ctx.options["type"];
344
- // --include-body is required or the `body` column is always empty.
345
- const spawnArgs = ["--path", ctx.pm_root, "list-all", "--json", "--include-body"];
874
+ const columns = selectExportColumns(ctx.options["columns"]);
875
+ const noHeader = readBoolOption(ctx.options, "no-header", "noHeader");
876
+ const crlf = readBoolOption(ctx.options, "crlf");
877
+ const excel = readBoolOption(ctx.options, "excel");
346
878
  console.error("Fetching pm items…");
347
- const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
348
- if (result.status !== 0) {
349
- const msg = result.stderr || "pm list-all failed";
350
- throw new Error(msg);
351
- }
352
- let allItems = JSON.parse(result.stdout).items ?? [];
353
- // Apply filters client-side
354
- if (statusFilter) {
355
- allItems = allItems.filter((item) => item.status === statusFilter);
356
- }
357
- if (typeFilter) {
358
- allItems = allItems.filter((item) => item.type === typeFilter);
359
- }
360
- const items = allItems;
361
- if (items.length === 0) {
879
+ const { csvText, count } = buildCsvExport(ctx.pm_root, {
880
+ statusFilter: ctx.options["status"],
881
+ typeFilter: ctx.options["type"],
882
+ delimiter,
883
+ columns,
884
+ noHeader,
885
+ crlf,
886
+ excel,
887
+ });
888
+ if (count === 0) {
362
889
  console.error("No items found.");
363
890
  return { exported: 0 };
364
891
  }
365
- // Build CSV rows
366
- const headerRow = EXPORT_COLUMNS.map(String);
367
- const dataRows = items.map((item) => EXPORT_COLUMNS.map((col) => {
368
- const val = item[col];
369
- if (val === undefined || val === null)
370
- return "";
371
- if (Array.isArray(val))
372
- return stringifyTags(val);
373
- return String(val);
374
- }));
375
- const csvText = serializeCSV([headerRow, ...dataRows], delimiter);
376
892
  if (outputPath) {
377
893
  const absolutePath = resolve(outputPath);
378
894
  writeFileSync(absolutePath, csvText + "\n", "utf-8");
379
- console.error(`Exported ${items.length} item(s) to: ${absolutePath}`);
380
- return { exported: items.length, file: absolutePath };
895
+ console.error(`Exported ${count} item(s) to: ${absolutePath}`);
896
+ return { exported: count, file: absolutePath };
381
897
  }
382
898
  // Print to stdout — return as data so the CLI host renders it
383
- console.error(`Exported ${items.length} item(s).`);
384
- return { exported: items.length, csv: csvText };
899
+ console.error(`Exported ${count} item(s).`);
900
+ return { exported: count, csv: csvText };
385
901
  },
386
902
  });
387
903
  // -----------------------------------------------------------------------
904
+ // Exporter: csv-export (native export pipeline — `pm csv-export export`)
905
+ // Mirrors the importer so CSV is a first-class import/export pair.
906
+ // -----------------------------------------------------------------------
907
+ api.registerExporter("csv-export", async (ctx) => {
908
+ const delimiter = resolveDelimiter(ctx.options["delimiter"]);
909
+ const outputPath = ctx.options["output"];
910
+ const columns = selectExportColumns(ctx.options["columns"]);
911
+ const noHeader = readBoolOption(ctx.options, "no-header", "noHeader");
912
+ const crlf = readBoolOption(ctx.options, "crlf");
913
+ const excel = readBoolOption(ctx.options, "excel");
914
+ const { csvText, count } = buildCsvExport(ctx.pm_root, {
915
+ statusFilter: ctx.options["status"],
916
+ typeFilter: ctx.options["type"],
917
+ delimiter,
918
+ columns,
919
+ noHeader,
920
+ crlf,
921
+ excel,
922
+ });
923
+ if (outputPath) {
924
+ const absolutePath = resolve(outputPath);
925
+ writeFileSync(absolutePath, csvText + "\n", "utf-8");
926
+ console.error(`csv-export: wrote ${count} item(s) to ${absolutePath}`);
927
+ return { exported: count, file: absolutePath };
928
+ }
929
+ console.log(csvText);
930
+ return { exported: count, csv: csvText };
931
+ });
932
+ // -----------------------------------------------------------------------
388
933
  // Importer: csv-import (programmatic / config-driven)
389
934
  // -----------------------------------------------------------------------
390
935
  api.registerImporter("csv-import", async (ctx) => {
@@ -393,76 +938,28 @@ export default defineExtension({
393
938
  console.error("csv-import: no 'file' provided in options — skipping.");
394
939
  return;
395
940
  }
396
- const delimiter = ctx.options["delimiter"] ?? ",";
941
+ const delimiter = resolveDelimiter(ctx.options["delimiter"]);
942
+ const fieldMap = parseFieldMap(ctx.options["map"]);
943
+ const keyField = (ctx.options["key"] ?? "").trim().toLowerCase() || undefined;
944
+ const encoding = resolveEncoding(ctx.options["encoding"]);
945
+ const source = (ctx.options["source"] ?? "").trim() || undefined;
397
946
  const absolutePath = resolve(filePath);
398
947
  console.error(`csv-import: reading ${absolutePath}`);
399
- let headers;
400
- let dataRows;
948
+ let res;
401
949
  try {
402
- ({ headers, dataRows } = readCSVFile(absolutePath, delimiter));
950
+ res = importCSV(ctx.pm_root, absolutePath, { delimiter, dryRun: false, fieldMap, keyField, encoding, source });
403
951
  }
404
952
  catch (err) {
405
953
  const msg = err instanceof Error ? err.message : String(err);
406
- console.error(`csv-import: failed to read file — ${msg}`);
954
+ console.error(`csv-import: failed — ${msg}`);
407
955
  return;
408
956
  }
409
- if (!headers.includes("title")) {
410
- console.error("csv-import: CSV is missing required 'title' column — skipping.");
411
- return;
412
- }
413
- const col = (name) => headers.indexOf(name);
414
- let imported = 0;
415
- let skipped = 0;
416
- for (let rowIndex = 0; rowIndex < dataRows.length; rowIndex++) {
417
- const row = dataRows[rowIndex];
418
- const get = (name) => {
419
- const idx = col(name);
420
- return idx >= 0 ? (row[idx] ?? "").trim() : "";
421
- };
422
- const title = get("title");
423
- if (!title) {
424
- skipped++;
425
- continue;
426
- }
427
- const rawStatus = get("status");
428
- const status = rawStatus ? normalizeStatus(rawStatus) : "open";
429
- const rawPriority = get("priority");
430
- const priority = rawPriority ? parseInt(rawPriority, 10) : undefined;
431
- const tags = parseTags(get("tags"));
432
- const type = get("type") || undefined;
433
- const deadline = get("deadline") || get("due_date") || undefined;
434
- const body = get("body") || undefined;
435
- try {
436
- const spawnArgs = [
437
- "--path", ctx.pm_root,
438
- "create",
439
- "--title", title,
440
- "--status", status,
441
- ];
442
- if (body)
443
- spawnArgs.push("--body", body);
444
- if (priority !== undefined && !isNaN(priority))
445
- spawnArgs.push("--priority", String(priority));
446
- if (type)
447
- spawnArgs.push("--type", type);
448
- if (deadline)
449
- spawnArgs.push("--deadline", deadline);
450
- if (tags.length > 0)
451
- spawnArgs.push("--tags", tags.join(","));
452
- const result = spawnSync("pm", spawnArgs, { encoding: "utf-8" });
453
- if (result.status !== 0) {
454
- throw new Error(result.stderr || "pm create failed");
455
- }
456
- imported++;
457
- }
458
- catch (err) {
459
- const msg = err instanceof Error ? err.message : String(err);
460
- console.error(`csv-import: row ${rowIndex + 2} create failed — ${msg}`);
461
- skipped++;
462
- }
463
- }
464
- console.error(`csv-import: done — imported ${imported}, skipped ${skipped}.`);
957
+ console.error(`csv-import: done — imported ${res.imported}, updated ${res.updated}, skipped ${res.skipped}.`);
465
958
  });
466
959
  },
467
960
  });
961
+ // ---------------------------------------------------------------------------
962
+ // Named exports — pure helpers exposed for unit testing (no side effects).
963
+ // ---------------------------------------------------------------------------
964
+ export { parseCSV, serializeCSV, serializeField, stripBOM, resolveDelimiter, parseFieldMap, applyFieldMap, normalizeStatus, parseTags, stringifyTags, encodeKeyTagValue, decodeKeyTagValue, normalizeKeyValue, selectExportColumns, resolveEncoding, validateParsedCSV, EXPORT_COLUMNS, IMPORT_COLUMNS, };
468
965
  //# sourceMappingURL=index.js.map