crewly 1.20.446 → 1.20.448
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/config/skills/agent/ingest-exports/SKILL.md +97 -0
- package/config/skills/agent/ingest-exports/execute.sh +35 -0
- package/config/skills/agent/ingest-exports/execute.test.sh +82 -0
- package/config/skills/agent/ingest-exports/fixtures/jackrabbit-enrollments.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/jotform-submissions.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/mailchimp-audience.csv +3 -0
- package/config/skills/agent/ingest-exports/fixtures/quickbooks-invoices.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/square-customers.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/square-transactions.csv +5 -0
- package/config/skills/agent/ingest-exports/fixtures/unknown-report.csv +4 -0
- package/config/skills/agent/ingest-exports/ingest.mjs +368 -0
- package/dist/backend/build-info.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Ingest Exports
|
|
3
|
+
description: Load CSV exports from the tools a small business already uses (Square, QuickBooks, Mailchimp, Jotform, Jackrabbit, Mindbody, Calendly, Stripe) into one common schema (contact / booking / payment / invoice) with the original columns kept, and report rows and date range per file. Unknown CSVs still load with a "could not map" note. --hash-names keeps people's names, emails and phones out of what you store.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
category: analysis
|
|
6
|
+
skillType: claude-skill
|
|
7
|
+
assignableRoles:
|
|
8
|
+
- "*"
|
|
9
|
+
triggers:
|
|
10
|
+
- ingest exports
|
|
11
|
+
- load csv export
|
|
12
|
+
- import csv
|
|
13
|
+
- square export
|
|
14
|
+
- quickbooks export
|
|
15
|
+
- mailchimp export
|
|
16
|
+
- jotform export
|
|
17
|
+
- check-up data
|
|
18
|
+
tags:
|
|
19
|
+
- csv
|
|
20
|
+
- export
|
|
21
|
+
- check-up
|
|
22
|
+
- small-business
|
|
23
|
+
- square
|
|
24
|
+
- quickbooks
|
|
25
|
+
- mailchimp
|
|
26
|
+
- jotform
|
|
27
|
+
execution:
|
|
28
|
+
type: script
|
|
29
|
+
script:
|
|
30
|
+
file: execute.sh
|
|
31
|
+
interpreter: bash
|
|
32
|
+
timeoutMs: 120000
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
# Ingest Exports
|
|
36
|
+
|
|
37
|
+
Most tools a small business uses have an **Export to CSV** button even when
|
|
38
|
+
they have no API you can get a key for. This skill is the universal fallback
|
|
39
|
+
of the small-business check-up (crewly#1055): the owner exports, you ingest,
|
|
40
|
+
and every file ends up in the same shape so the analysis does not care where
|
|
41
|
+
it came from.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bash execute.sh --in ~/Downloads/exports --out "$CREWLY_HOME/checkup/<customer>" --hash-names
|
|
45
|
+
bash execute.sh --in square-transactions.csv --in invoices.csv --out /tmp/checkup
|
|
46
|
+
bash execute.sh --in exports/ --detect-only # what is each file? writes nothing
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Getting the files
|
|
50
|
+
|
|
51
|
+
- **Google Drive** (the usual drop point): `drive-search` for the folder the
|
|
52
|
+
owner shared, then `drive-read --id <id> --out <path>` per file. Save them
|
|
53
|
+
into one folder and pass that folder as `--in`.
|
|
54
|
+
- **App form / attachment**: files land under `$CREWLY_HOME/received/`;
|
|
55
|
+
pass the file paths.
|
|
56
|
+
|
|
57
|
+
Never ask the owner to paste exported rows into chat.
|
|
58
|
+
|
|
59
|
+
## What comes out
|
|
60
|
+
|
|
61
|
+
One JSON line, and under `--out` one `<name>.json` per file plus
|
|
62
|
+
`summary.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{"success":true,"files":[{"file":"square-transactions.csv","source":"square","kind":"payment","rows":4,
|
|
66
|
+
"dateRange":{"from":"2026-09-02","to":"2026-09-20"},"unmappedColumns":["Tax","Tip","Card Brand"],"notes":[]}],
|
|
67
|
+
"totals":{"files":1,"rows":4,"byKind":{"payment":4}}}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every record has `kind` (`contact`, `booking`, `payment`, `invoice`, or
|
|
71
|
+
`other`), `source`, `file`, `row`, the mapped fields it could fill (`id`,
|
|
72
|
+
`name`, `email`, `phone`, `at`, `dueAt`, `endAt`, `amount`, `balance`,
|
|
73
|
+
`currency`, `status`, `item`, `tags`) and `original` — every exported column
|
|
74
|
+
as it was. Dates are ISO (`2026-09-02` or `2026-09-02T10:14:03`); amounts are
|
|
75
|
+
numbers (`"$1,250.00"` → `1250`).
|
|
76
|
+
|
|
77
|
+
Recognised exports: Square transactions and customers, QuickBooks Online
|
|
78
|
+
invoice and customer lists, Mailchimp audience, Jotform submissions,
|
|
79
|
+
Jackrabbit enrollments, Mindbody clients, Calendly scheduled events, Stripe
|
|
80
|
+
payments. A file that matches none still loads: `source: "unknown"`,
|
|
81
|
+
`kind: "other"`, records carry `original` only, and `notes` says
|
|
82
|
+
"could not map". Tell the owner which file that was and what tool it came
|
|
83
|
+
from; `--source <name>` forces a known mapping when the headers are close.
|
|
84
|
+
|
|
85
|
+
## People's data
|
|
86
|
+
|
|
87
|
+
Children's names, parents' emails and phone numbers are in most of these
|
|
88
|
+
exports. Run with `--hash-names` unless the analysis truly needs the names:
|
|
89
|
+
`name`, `email`, `phone` and every original column that looks personal become
|
|
90
|
+
a short stable hash (`h:3f1a…`), so the same person still counts once across
|
|
91
|
+
files but nobody's name is stored. Report counts, not names, and delete the
|
|
92
|
+
`--out` folder when the check-up ends.
|
|
93
|
+
|
|
94
|
+
## Report back
|
|
95
|
+
|
|
96
|
+
Per file, in one line each: tool, kind, rows, date range, and anything in
|
|
97
|
+
`notes`. Then the totals. Say plainly which files could not be mapped.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# ingest-exports — load CSV exports from small-business tools (Square,
|
|
4
|
+
# QuickBooks, Mailchimp, Jotform, Jackrabbit, Mindbody, Calendly, Stripe)
|
|
5
|
+
# into one common schema for the small-business check-up (crewly#1061).
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# bash execute.sh --in ~/Downloads/exports --out /tmp/checkup # every .csv in the folder
|
|
9
|
+
# bash execute.sh --in square.csv --in invoices.csv --out /tmp/checkup --hash-names
|
|
10
|
+
# bash execute.sh --in exports/ --detect-only # say what each file is, write nothing
|
|
11
|
+
# bash execute.sh '{"in":["exports/"],"out":"/tmp/checkup","hashNames":true}'
|
|
12
|
+
#
|
|
13
|
+
# Output: one JSON line (per-file source, kind, rows, date range, unmapped
|
|
14
|
+
# columns, notes) and, with --out, <name>.json per file + summary.json.
|
|
15
|
+
# Exit 0 = every file loaded, 2 = some file could not be read, 1 = usage.
|
|
16
|
+
# =============================================================================
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
INGEST="${SCRIPT_DIR}/ingest.mjs"
|
|
20
|
+
|
|
21
|
+
command -v node >/dev/null 2>&1 || { printf '{"success":false,"error":"node is required but not on PATH"}\n'; exit 1; }
|
|
22
|
+
|
|
23
|
+
ARGS=()
|
|
24
|
+
if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
|
|
25
|
+
command -v jq >/dev/null 2>&1 || { printf '{"success":false,"error":"jq is required to read JSON input"}\n'; exit 1; }
|
|
26
|
+
while IFS= read -r p; do [ -n "$p" ] && ARGS+=(--in "$p"); done < <(printf '%s' "$1" | jq -r '(.in // .inputs // []) | if type=="string" then [.] else . end | .[]')
|
|
27
|
+
out=$(printf '%s' "$1" | jq -r '.out // empty'); [ -n "$out" ] && ARGS+=(--out "$out")
|
|
28
|
+
src=$(printf '%s' "$1" | jq -r '.source // empty'); [ -n "$src" ] && ARGS+=(--source "$src")
|
|
29
|
+
[ "$(printf '%s' "$1" | jq -r '.hashNames // false')" = "true" ] && ARGS+=(--hash-names)
|
|
30
|
+
[ "$(printf '%s' "$1" | jq -r '.detectOnly // false')" = "true" ] && ARGS+=(--detect-only)
|
|
31
|
+
shift
|
|
32
|
+
fi
|
|
33
|
+
ARGS+=("$@")
|
|
34
|
+
|
|
35
|
+
exec node "$INGEST" "${ARGS[@]}"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for ingest-exports — run with: bash execute.test.sh
|
|
3
|
+
# Offline: fixture CSVs only. Needs node (and jq for the JSON-input case).
|
|
4
|
+
set -u
|
|
5
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
EXEC="$HERE/execute.sh"
|
|
7
|
+
FX="$HERE/fixtures"
|
|
8
|
+
PASS=0; FAIL=0
|
|
9
|
+
check() {
|
|
10
|
+
local name="$1"; local got="$2"; local want="$3"
|
|
11
|
+
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
|
|
12
|
+
}
|
|
13
|
+
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
14
|
+
|
|
15
|
+
# 1. Every fixture is recognised as the right tool and kind
|
|
16
|
+
OUT="$(bash "$EXEC" --in "$FX" --out "$T/all")"; RC=$?
|
|
17
|
+
check "folder ingest exits 0" "$RC" "0"
|
|
18
|
+
check "7 files loaded" "$(printf '%s' "$OUT" | jq -r '.totals.files')" "7"
|
|
19
|
+
for spec in "square-transactions.csv square payment 4" "square-customers.csv square contact 3" "quickbooks-invoices.csv quickbooks invoice 3" \
|
|
20
|
+
"mailchimp-audience.csv mailchimp contact 2" "jotform-submissions.csv jotform booking 3" "jackrabbit-enrollments.csv jackrabbit booking 3" \
|
|
21
|
+
"unknown-report.csv unknown other 3"; do
|
|
22
|
+
set -- $spec
|
|
23
|
+
check "$1 source" "$(printf '%s' "$OUT" | jq -r --arg f "$1" '.files[] | select(.file==$f) | .source')" "$2"
|
|
24
|
+
check "$1 kind" "$(printf '%s' "$OUT" | jq -r --arg f "$1" '.files[] | select(.file==$f) | .kind')" "$3"
|
|
25
|
+
check "$1 rows" "$(printf '%s' "$OUT" | jq -r --arg f "$1" '.files[] | select(.file==$f) | .rows')" "$4"
|
|
26
|
+
check "$1 output file exists" "$([ -f "$T/all/${1%.csv}.json" ] && echo yes || echo no)" "yes"
|
|
27
|
+
done
|
|
28
|
+
check "summary.json written" "$([ -f "$T/all/summary.json" ] && echo yes || echo no)" "yes"
|
|
29
|
+
check "totals.rows" "$(printf '%s' "$OUT" | jq -r '.totals.rows')" "21"
|
|
30
|
+
|
|
31
|
+
# 2. Date ranges and value normalisation
|
|
32
|
+
check "square date range" "$(printf '%s' "$OUT" | jq -c '.files[] | select(.file=="square-transactions.csv") | .dateRange')" '{"from":"2026-09-02","to":"2026-09-20"}'
|
|
33
|
+
check "quickbooks US dates → ISO" "$(jq -r '.records[0].at' "$T/all/quickbooks-invoices.json")" "2026-09-01"
|
|
34
|
+
check "quickbooks amount with thousands" "$(jq -r '.records[0].amount' "$T/all/quickbooks-invoices.json")" "1250"
|
|
35
|
+
check "quickbooks paid status from open balance" "$(jq -r '.records[1].status' "$T/all/quickbooks-invoices.json")" "paid"
|
|
36
|
+
check "quickbooks due date" "$(jq -r '.records[2].dueAt' "$T/all/quickbooks-invoices.json")" "2026-10-15"
|
|
37
|
+
check "square time joined to date" "$(jq -r '.records[0].at' "$T/all/square-transactions.json")" "2026-09-02T10:14:03"
|
|
38
|
+
check "square total collected" "$(jq -r '.records[1].amount' "$T/all/square-transactions.json")" "117.45"
|
|
39
|
+
check "square refunded status" "$(jq -r '.records[2].status' "$T/all/square-transactions.json")" "refunded"
|
|
40
|
+
check "square customer name joined" "$(jq -r '.records[0].name' "$T/all/square-customers.json")" "Maria Lopez"
|
|
41
|
+
check "mailchimp tags split" "$(jq -c '.records[0].tags' "$T/all/mailchimp-audience.json")" '["parent","pottery"]'
|
|
42
|
+
check "jotform parent name" "$(jq -r '.records[0].name' "$T/all/jotform-submissions.json")" "Alex Rivera"
|
|
43
|
+
check "jotform class as item" "$(jq -r '.records[0].item' "$T/all/jotform-submissions.json")" "Kids Pottery"
|
|
44
|
+
check "jackrabbit dropped status" "$(jq -r '.records[2].status' "$T/all/jackrabbit-enrollments.json")" "dropped"
|
|
45
|
+
check "jackrabbit enrolled status" "$(jq -r '.records[0].status' "$T/all/jackrabbit-enrollments.json")" "enrolled"
|
|
46
|
+
check "jackrabbit end date" "$(jq -r '.records[2].endAt' "$T/all/jackrabbit-enrollments.json")" "2026-09-22"
|
|
47
|
+
|
|
48
|
+
# 3. Unknown CSV: original columns kept, note says could not map, quoted field intact
|
|
49
|
+
check "unknown note" "$(printf '%s' "$OUT" | jq -r '.files[] | select(.file=="unknown-report.csv") | .notes[0]' | cut -c1-13)" "could not map"
|
|
50
|
+
check "unknown keeps original columns" "$(jq -c '.records[0].original' "$T/all/unknown-report.json")" '{"Widget":"Spinner","Colour":"Blue","Units":"12","Notes":"Back-ordered, \"rush\""}'
|
|
51
|
+
check "unknown has no mapped name" "$(jq -r '.records[0] | has("name")' "$T/all/unknown-report.json")" "false"
|
|
52
|
+
check "unknown unmapped columns = all" "$(printf '%s' "$OUT" | jq -c '.files[] | select(.file=="unknown-report.csv") | .unmappedColumns')" '["Widget","Colour","Units","Notes"]'
|
|
53
|
+
|
|
54
|
+
# 4. --hash-names: no plain name/email/phone anywhere in the output
|
|
55
|
+
HOUT="$(bash "$EXEC" --in "$FX/jotform-submissions.csv" --in "$FX/jackrabbit-enrollments.csv" --out "$T/hashed" --hash-names)"
|
|
56
|
+
check "hashed flag in summary" "$(printf '%s' "$HOUT" | jq -r '.namesHashed')" "true"
|
|
57
|
+
check "no plain names in hashed jotform" "$(grep -c -E "Rivera|Natarajan|Baker|example\.test|555 010" "$T/hashed/jotform-submissions.json")" "0"
|
|
58
|
+
check "no plain names in hashed jackrabbit" "$(grep -c -E "Lopez|Rivera|Natarajan|example\.test" "$T/hashed/jackrabbit-enrollments.json")" "0"
|
|
59
|
+
check "hashed name has h: prefix" "$(jq -r '.records[0].name' "$T/hashed/jotform-submissions.json" | cut -c1-2)" "h:"
|
|
60
|
+
check "same person hashes the same across files" "$(jq -r '.records[0].email' "$T/hashed/jotform-submissions.json")" "$(jq -r '.records[1].email' "$T/hashed/jackrabbit-enrollments.json")"
|
|
61
|
+
check "child age kept (not personal)" "$(jq -r '.records[0].original["Child'"'"'s Age"]' "$T/hashed/jotform-submissions.json")" "7"
|
|
62
|
+
check "child name hashed" "$(jq -r '.records[0].original["Child'"'"'s Name"]' "$T/hashed/jotform-submissions.json" | cut -c1-2)" "h:"
|
|
63
|
+
check "rows unchanged by hashing" "$(printf '%s' "$HOUT" | jq -r '.totals.rows')" "6"
|
|
64
|
+
|
|
65
|
+
# 5. --detect-only writes nothing; JSON input form; missing file → exit 2
|
|
66
|
+
DOUT="$(bash "$EXEC" --in "$FX/square-customers.csv" --detect-only)"
|
|
67
|
+
check "detect-only source" "$(printf '%s' "$DOUT" | jq -r '.files[0].source')" "square"
|
|
68
|
+
check "detect-only has no out" "$(printf '%s' "$DOUT" | jq -r '.out')" "null"
|
|
69
|
+
JOUT="$(bash "$EXEC" "{\"in\":[\"$FX/mailchimp-audience.csv\"],\"out\":\"$T/json\",\"hashNames\":true}")"
|
|
70
|
+
check "json input works" "$(printf '%s' "$JOUT" | jq -r '.files[0].kind + " " + (.namesHashed|tostring)')" "contact true"
|
|
71
|
+
MOUT="$(bash "$EXEC" --in "$T/nope.csv" --in "$FX/unknown-report.csv" --out "$T/miss")"; RC=$?
|
|
72
|
+
check "missing file exits 2" "$RC" "2"
|
|
73
|
+
check "missing file reported, other loaded" "$(printf '%s' "$MOUT" | jq -r '(.files[0].error) + "/" + (.totals.files|tostring)')" "not found/1"
|
|
74
|
+
UOUT="$(bash "$EXEC" --out "$T/u" 2>/dev/null)"; RC=$?
|
|
75
|
+
check "no --in is a usage error" "$RC" "1"
|
|
76
|
+
|
|
77
|
+
# 6. --source forces a known mapping only when the headers fit
|
|
78
|
+
SOUT="$(bash "$EXEC" --in "$FX/unknown-report.csv" --out "$T/forced" --source square)"
|
|
79
|
+
check "--source cannot force square onto unrelated headers" "$(printf '%s' "$SOUT" | jq -r '.files[0].source')" "unknown"
|
|
80
|
+
|
|
81
|
+
echo "ingest-exports: $PASS passed, $FAIL failed"
|
|
82
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
Family,Student First Name,Student Last Name,Student Birth Date,Class,Session,Enroll Date,Drop Date,Tuition Fee,Primary Contact Email
|
|
2
|
+
Lopez,Sofia,Lopez,2018-04-12,Kids Pottery Sat 10am,Fall 2026,2026-08-28,,180.00,maria@example.test
|
|
3
|
+
Rivera,Sam,Rivera,2019-02-03,Kids Pottery Sat 10am,Fall 2026,2026-09-10,,180.00,alex@example.test
|
|
4
|
+
Natarajan,Anika,Natarajan,2017-07-21,Teen Wheel Tue 4pm,Fall 2026,2026-09-01,2026-09-22,220.00,priya@example.test
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
Submission Date,Submission ID,Parent Name,Child's Name,Child's Age,Email,Phone Number,Which class?,Preferred day,Anything we should know?
|
|
2
|
+
2026-09-08 14:22:10,6012345678901234567,Alex Rivera,Sam Rivera,7,alex@example.test,+1 555 010 1003,Kids Pottery,Saturday,Allergic to peanuts
|
|
3
|
+
2026-09-09 08:05:44,6012345678901234568,Priya Natarajan,Anika Natarajan,9,priya@example.test,,Teen Wheel,Tuesday,
|
|
4
|
+
2026-09-12 19:47:02,6012345678901234569,Tom Baker,Lily Baker,5,tom@example.test,+1 555 010 1004,Parent & Me,Sunday,First time
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
Email Address,First Name,Last Name,Address,Phone Number,MEMBER_RATING,OPTIN_TIME,OPTIN_IP,CONFIRM_TIME,CONFIRM_IP,LATITUDE,LONGITUDE,GMTOFF,DSTOFF,TIMEZONE,CC,REGION,LAST_CHANGED,LEID,EUID,NOTES,TAGS
|
|
2
|
+
maria@example.test,Maria,Lopez,"12 Elm St, Oakland, CA 94601",+1 555 010 1001,4,2025-11-02 18:04:11,,2025-11-02 18:05:02,,,,,,,,,2026-08-01 09:00:00,100001,abc001,,"parent,pottery"
|
|
3
|
+
tom@example.test,Tom,Baker,,,2,2026-02-14 10:00:00,,2026-02-14 10:01:00,,,,,,,,,2026-02-14 10:01:00,100002,abc002,,newsletter
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
Date,Transaction Type,Num,Customer,Memo/Description,Due Date,Amount,Open Balance
|
|
2
|
+
09/01/2026,Invoice,1042,Harbor Dental,September cleaning contract,09/30/2026,"1,250.00","1,250.00"
|
|
3
|
+
09/03/2026,Invoice,1043,Oak Street Cafe,Menu design,10/03/2026,380.00,0.00
|
|
4
|
+
09/15/2026,Invoice,1044,Greenline Movers,Website copy,10/15/2026,"2,140.50","2,140.50"
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
First Name,Last Name,Company Name,Email Address,Phone Number,Street Address 1,City,State,Postal Code,Creation Source,First Visit,Last Visit,Transaction Count,Total Spend,Reference ID
|
|
2
|
+
Maria,Lopez,,maria@example.test,+1 555 010 1001,12 Elm St,Oakland,CA,94601,Directory,2025-11-02,2026-09-11,6,$284.00,
|
|
3
|
+
Daniel,Chen,Chen Family,daniel@example.test,+1 555 010 1002,,Oakland,CA,94602,Instant Profile,2026-09-05,2026-09-05,1,$117.45,
|
|
4
|
+
Priya,Natarajan,,priya@example.test,,,Berkeley,CA,94704,Directory,2026-03-14,2026-08-30,3,$135.00,REF-77
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Date,Time,Time Zone,Gross Sales,Discounts,Net Sales,Tax,Tip,Total Collected,Payment ID,Card Brand,PAN Suffix,Customer Name,Customer Email,Description,Transaction ID,Payment Status
|
|
2
|
+
2026-09-02,10:14:03,America/Los_Angeles,$45.00,$0.00,$45.00,$3.94,$5.00,$53.94,PAY-1001,Visa,4242,Maria Lopez,maria@example.test,"Kids Pottery, Saturday",TX-9001,Completed
|
|
3
|
+
2026-09-05,16:40:11,America/Los_Angeles,$120.00,$12.00,$108.00,$9.45,$0.00,$117.45,PAY-1002,Mastercard,1881,Daniel Chen,daniel@example.test,"Birthday Party Deposit",TX-9002,Completed
|
|
4
|
+
2026-09-11,09:02:45,America/Los_Angeles,$45.00,$0.00,$45.00,$3.94,$0.00,$48.94,PAY-1003,Visa,4242,Maria Lopez,maria@example.test,"Kids Pottery, Saturday",TX-9003,Refunded
|
|
5
|
+
2026-09-20,13:25:00,America/Los_Angeles,$30.00,$0.00,$30.00,$2.63,$2.00,$34.63,PAY-1004,Amex,0005,,,"Walk-in clay bar",TX-9004,Completed
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ingest-exports — read CSV exports from small-business tools and write them
|
|
4
|
+
* in one common schema (crewly#1061, small-business check-up fallback).
|
|
5
|
+
*
|
|
6
|
+
* The source tool is recognised by its column headers; rows become records
|
|
7
|
+
* of kind contact / booking / payment / invoice with the original columns
|
|
8
|
+
* kept. A CSV nobody recognises still loads (kind "other", source "unknown")
|
|
9
|
+
* with a "could not map" note, so nothing the owner exported is lost.
|
|
10
|
+
*
|
|
11
|
+
* Usage (normally through execute.sh):
|
|
12
|
+
* node ingest.mjs --in <file-or-dir> [--in ...] --out <dir> [--hash-names] [--detect-only]
|
|
13
|
+
*
|
|
14
|
+
* Output: one JSON line on stdout (the summary), and under --out one
|
|
15
|
+
* <name>.json per input file plus summary.json. Exit 0 when every file loaded,
|
|
16
|
+
* 2 when some file could not be read at all, 1 on a usage error.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { basename, extname, join, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** Schema version written into every output file. */
|
|
23
|
+
const SCHEMA_VERSION = '1.0';
|
|
24
|
+
|
|
25
|
+
/** Record kinds of the common schema. */
|
|
26
|
+
const KINDS = ['contact', 'booking', 'payment', 'invoice', 'other'];
|
|
27
|
+
|
|
28
|
+
/** Columns whose values identify a person; hashed under --hash-names. */
|
|
29
|
+
const PERSONAL_COLUMN_RE = /(^|[\s_-])(name|first|last|surname|family|student|child|parent|email|e-mail|phone|mobile|address|contact|customer|invitee)([\s_'-]|$)/i;
|
|
30
|
+
|
|
31
|
+
/** Columns that mention a person but hold a count or age, not an identity ("Child's Age", "Customer Count"). */
|
|
32
|
+
const NOT_PERSONAL_COLUMN_RE = /(^|[\s_-])(age|count|rating|total|spend|visits?)([\s_'-]|$)/i;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Source signatures: a file is that source when every `requires` header is
|
|
36
|
+
* present (case-insensitive) and no `forbids` header is. First match wins,
|
|
37
|
+
* so the more specific signatures come first.
|
|
38
|
+
*/
|
|
39
|
+
const SIGNATURES = [
|
|
40
|
+
{
|
|
41
|
+
source: 'square', kind: 'payment', label: 'Square transactions export',
|
|
42
|
+
requires: ['date', 'gross sales', 'net sales', 'payment id'],
|
|
43
|
+
map: { at: ['date', 'time'], amount: 'total collected', currency: () => 'USD', status: 'payment status', id: 'transaction id', name: 'customer name', email: 'customer email', item: 'description' },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
source: 'square', kind: 'contact', label: 'Square customers export',
|
|
47
|
+
requires: ['first name', 'last name', 'email address', 'creation source'],
|
|
48
|
+
map: { name: ['first name', 'last name'], email: 'email address', phone: 'phone number', at: 'first visit', amount: 'total spend', id: 'reference id' },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
source: 'quickbooks', kind: 'invoice', label: 'QuickBooks Online invoice list',
|
|
52
|
+
requires: ['date', 'transaction type', 'num', 'customer', 'amount', 'open balance'],
|
|
53
|
+
map: { at: 'date', amount: 'amount', currency: () => 'USD', name: 'customer', id: 'num', item: 'memo/description', dueAt: 'due date', balance: 'open balance', status: (r) => (toNumber(r['Open Balance']) === 0 ? 'paid' : 'open') },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
source: 'quickbooks', kind: 'contact', label: 'QuickBooks Online customer list',
|
|
57
|
+
requires: ['customer', 'email', 'phone numbers'],
|
|
58
|
+
map: { name: 'customer', email: 'email', phone: 'phone numbers' },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
source: 'mailchimp', kind: 'contact', label: 'Mailchimp audience export',
|
|
62
|
+
requires: ['email address', 'first name', 'last name', 'member_rating', 'optin_time'],
|
|
63
|
+
map: { name: ['first name', 'last name'], email: 'email address', phone: 'phone number', at: 'optin_time', tags: 'tags', status: () => 'subscribed' },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
source: 'jotform', kind: 'booking', label: 'Jotform submissions export',
|
|
67
|
+
requires: ['submission date', 'submission id'],
|
|
68
|
+
map: { at: 'submission date', id: 'submission id', name: (r) => firstMatching(r, /parent name|^name$|full name|your name/i), email: (r) => firstMatching(r, /e-?mail/i), phone: (r) => firstMatching(r, /phone/i), item: (r) => firstMatching(r, /class|service|event|session|which/i) },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
source: 'jackrabbit', kind: 'booking', label: 'Jackrabbit enrollment export',
|
|
72
|
+
requires: ['family', 'class', 'enroll date'],
|
|
73
|
+
map: { at: 'enroll date', name: ['student first name', 'student last name'], email: 'primary contact email', item: 'class', amount: 'tuition fee', currency: () => 'USD', status: (r) => (r['Drop Date'] ? 'dropped' : 'enrolled'), endAt: 'drop date' },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
source: 'mindbody', kind: 'contact', label: 'Mindbody client export',
|
|
77
|
+
requires: ['client id', 'first name', 'last name'],
|
|
78
|
+
map: { id: 'client id', name: ['first name', 'last name'], email: 'email', phone: (r) => firstMatching(r, /mobile|phone/i), status: 'membership' },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
source: 'calendly', kind: 'booking', label: 'Calendly scheduled events export',
|
|
82
|
+
requires: ['event type', 'invitee name', 'invitee email', 'start date & time'],
|
|
83
|
+
map: { at: 'start date & time', name: 'invitee name', email: 'invitee email', item: 'event type', status: (r) => (r['Canceled'] && /yes|true/i.test(r['Canceled']) ? 'cancelled' : 'scheduled') },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
source: 'stripe', kind: 'payment', label: 'Stripe payments export',
|
|
87
|
+
requires: ['id', 'amount', 'currency', 'status', 'created (utc)'],
|
|
88
|
+
map: { id: 'id', amount: 'amount', currency: 'currency', status: 'status', at: 'created (utc)', email: 'customer email', item: 'description' },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse one CSV text (RFC 4180: quoted fields, doubled quotes, CR/LF, BOM).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} text - File content
|
|
96
|
+
* @returns {{ headers: string[], rows: Record<string, string>[] }}
|
|
97
|
+
*/
|
|
98
|
+
export function parseCsv(text) {
|
|
99
|
+
const src = text.replace(/^/, '');
|
|
100
|
+
const records = [];
|
|
101
|
+
let field = '';
|
|
102
|
+
let row = [];
|
|
103
|
+
let inQuotes = false;
|
|
104
|
+
for (let i = 0; i < src.length; i++) {
|
|
105
|
+
const ch = src[i];
|
|
106
|
+
if (inQuotes) {
|
|
107
|
+
if (ch === '"') {
|
|
108
|
+
if (src[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; }
|
|
109
|
+
} else {
|
|
110
|
+
field += ch;
|
|
111
|
+
}
|
|
112
|
+
} else if (ch === '"') {
|
|
113
|
+
inQuotes = true;
|
|
114
|
+
} else if (ch === ',') {
|
|
115
|
+
row.push(field); field = '';
|
|
116
|
+
} else if (ch === '\n' || ch === '\r') {
|
|
117
|
+
if (ch === '\r' && src[i + 1] === '\n') i++;
|
|
118
|
+
row.push(field); field = '';
|
|
119
|
+
records.push(row); row = [];
|
|
120
|
+
} else {
|
|
121
|
+
field += ch;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (field.length > 0 || row.length > 0) { row.push(field); records.push(row); }
|
|
125
|
+
const nonEmpty = records.filter((r) => r.some((c) => c.trim() !== ''));
|
|
126
|
+
if (nonEmpty.length === 0) return { headers: [], rows: [] };
|
|
127
|
+
const headers = nonEmpty[0].map((h) => h.trim());
|
|
128
|
+
const rows = nonEmpty.slice(1).map((r) => {
|
|
129
|
+
const obj = {};
|
|
130
|
+
headers.forEach((h, idx) => { obj[h] = (r[idx] ?? '').trim(); });
|
|
131
|
+
return obj;
|
|
132
|
+
});
|
|
133
|
+
return { headers, rows };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Find the signature whose required headers are all present.
|
|
138
|
+
*
|
|
139
|
+
* @param {string[]} headers - Column headers as exported
|
|
140
|
+
* @returns {(typeof SIGNATURES)[number] | null}
|
|
141
|
+
*/
|
|
142
|
+
export function detectSource(headers) {
|
|
143
|
+
const have = new Set(headers.map((h) => h.toLowerCase().trim()));
|
|
144
|
+
return SIGNATURES.find((sig) => sig.requires.every((h) => have.has(h)) && !(sig.forbids ?? []).some((h) => have.has(h))) ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Value of the first column whose header matches `re`, else ''. */
|
|
148
|
+
function firstMatching(row, re) {
|
|
149
|
+
const key = Object.keys(row).find((k) => re.test(k));
|
|
150
|
+
return key ? row[key] : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* "1,250.00", "$45.00", "(12.50)" → number; '' or junk → null.
|
|
155
|
+
*
|
|
156
|
+
* @param {string | undefined} s - A money or count cell
|
|
157
|
+
* @returns {number | null}
|
|
158
|
+
*/
|
|
159
|
+
export function toNumber(s) {
|
|
160
|
+
if (s === undefined || s === null) return null;
|
|
161
|
+
const neg = /^\(.*\)$/.test(s.trim());
|
|
162
|
+
const cleaned = s.replace(/[^0-9.-]/g, '');
|
|
163
|
+
if (cleaned === '' || cleaned === '-' || cleaned === '.') return null;
|
|
164
|
+
const n = Number(cleaned);
|
|
165
|
+
if (!Number.isFinite(n)) return null;
|
|
166
|
+
return neg ? -Math.abs(n) : n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Normalise a date cell to ISO `YYYY-MM-DD[THH:MM:SS]`. Accepts ISO, US
|
|
171
|
+
* `MM/DD/YYYY`, and `YYYY-MM-DD HH:MM:SS`; anything else → null.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} date - Date cell
|
|
174
|
+
* @param {string} [time] - Optional separate time cell
|
|
175
|
+
* @returns {string | null}
|
|
176
|
+
*/
|
|
177
|
+
export function toIsoDate(date, time) {
|
|
178
|
+
if (!date) return null;
|
|
179
|
+
let d = date.trim();
|
|
180
|
+
const us = d.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})(.*)$/);
|
|
181
|
+
if (us) d = `${us[3]}-${us[1].padStart(2, '0')}-${us[2].padStart(2, '0')}${us[4]}`;
|
|
182
|
+
const m = d.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}(?::\d{2})?))?/);
|
|
183
|
+
if (!m) return null;
|
|
184
|
+
const t = (time && time.trim().match(/^\d{2}:\d{2}(:\d{2})?/)?.[0]) || m[2];
|
|
185
|
+
return t ? `${m[1]}T${t.length === 5 ? `${t}:00` : t}` : m[1];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Short stable hash of a personal value (lower-cased, trimmed). */
|
|
189
|
+
function hashValue(v) {
|
|
190
|
+
return `h:${createHash('sha256').update(v.trim().toLowerCase()).digest('hex').slice(0, 12)}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Read a mapping entry: a column name, a list of columns to join, or a function. */
|
|
194
|
+
function readField(row, spec) {
|
|
195
|
+
if (spec === undefined) return '';
|
|
196
|
+
if (typeof spec === 'function') return spec(row) ?? '';
|
|
197
|
+
const cols = Array.isArray(spec) ? spec : [spec];
|
|
198
|
+
const lower = Object.fromEntries(Object.keys(row).map((k) => [k.toLowerCase(), k]));
|
|
199
|
+
return cols.map((c) => row[lower[c.toLowerCase()]] ?? '').filter(Boolean).join(' ').trim();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Turn one CSV row into a common-schema record.
|
|
204
|
+
*
|
|
205
|
+
* @param {Record<string, string>} row - The row as exported
|
|
206
|
+
* @param {(typeof SIGNATURES)[number] | null} sig - Detected source, or null
|
|
207
|
+
* @param {{ file: string, index: number, hashNames: boolean }} ctx
|
|
208
|
+
* @returns {object}
|
|
209
|
+
*/
|
|
210
|
+
export function toRecord(row, sig, ctx) {
|
|
211
|
+
const rec = {
|
|
212
|
+
kind: sig ? sig.kind : 'other',
|
|
213
|
+
source: sig ? sig.source : 'unknown',
|
|
214
|
+
file: ctx.file,
|
|
215
|
+
row: ctx.index + 1,
|
|
216
|
+
};
|
|
217
|
+
if (sig) {
|
|
218
|
+
const m = sig.map;
|
|
219
|
+
const id = readField(row, m.id); if (id) rec.id = id;
|
|
220
|
+
const name = readField(row, m.name); if (name) rec.name = name;
|
|
221
|
+
const email = readField(row, m.email); if (email) rec.email = email.toLowerCase();
|
|
222
|
+
const phone = readField(row, m.phone); if (phone) rec.phone = phone;
|
|
223
|
+
const atSpec = m.at;
|
|
224
|
+
const at = Array.isArray(atSpec) ? toIsoDate(readField(row, atSpec[0]), readField(row, atSpec[1])) : toIsoDate(readField(row, atSpec));
|
|
225
|
+
if (at) rec.at = at;
|
|
226
|
+
const dueAt = toIsoDate(readField(row, m.dueAt)); if (dueAt) rec.dueAt = dueAt;
|
|
227
|
+
const endAt = toIsoDate(readField(row, m.endAt)); if (endAt) rec.endAt = endAt;
|
|
228
|
+
const amount = toNumber(readField(row, m.amount)); if (amount !== null) rec.amount = amount;
|
|
229
|
+
const balance = toNumber(readField(row, m.balance)); if (balance !== null) rec.balance = balance;
|
|
230
|
+
const currency = readField(row, m.currency); if (currency) rec.currency = currency.toUpperCase();
|
|
231
|
+
const status = readField(row, m.status); if (status) rec.status = status.toLowerCase();
|
|
232
|
+
const item = readField(row, m.item); if (item) rec.item = item;
|
|
233
|
+
const tags = readField(row, m.tags); if (tags) rec.tags = tags.split(',').map((t) => t.trim()).filter(Boolean);
|
|
234
|
+
}
|
|
235
|
+
rec.original = { ...row };
|
|
236
|
+
if (ctx.hashNames) {
|
|
237
|
+
for (const k of ['name', 'email', 'phone']) if (rec[k]) rec[k] = hashValue(rec[k]);
|
|
238
|
+
for (const [k, v] of Object.entries(rec.original)) {
|
|
239
|
+
if (v && PERSONAL_COLUMN_RE.test(k) && !NOT_PERSONAL_COLUMN_RE.test(k)) rec.original[k] = hashValue(v);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return rec;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Ingest one CSV file.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} path - Absolute path
|
|
249
|
+
* @param {{ hashNames: boolean, sourceOverride?: string }} opts
|
|
250
|
+
* @returns {object} The per-file result (also written to <out>/<name>.json)
|
|
251
|
+
*/
|
|
252
|
+
export function ingestFile(path, opts) {
|
|
253
|
+
const file = basename(path);
|
|
254
|
+
const text = readFileSync(path, 'utf8');
|
|
255
|
+
const { headers, rows } = parseCsv(text);
|
|
256
|
+
let sig = detectSource(headers);
|
|
257
|
+
if (opts.sourceOverride) {
|
|
258
|
+
const forced = SIGNATURES.find((s) => s.source === opts.sourceOverride && s.requires.every((h) => headers.map((x) => x.toLowerCase()).includes(h)));
|
|
259
|
+
sig = forced ?? sig;
|
|
260
|
+
}
|
|
261
|
+
const records = rows.map((row, index) => toRecord(row, sig, { file, index, hashNames: opts.hashNames }));
|
|
262
|
+
const dates = records.map((r) => r.at).filter(Boolean).sort();
|
|
263
|
+
const mappedCols = new Set();
|
|
264
|
+
if (sig) {
|
|
265
|
+
for (const spec of Object.values(sig.map)) {
|
|
266
|
+
if (typeof spec === 'string') mappedCols.add(spec.toLowerCase());
|
|
267
|
+
else if (Array.isArray(spec)) spec.forEach((c) => mappedCols.add(c.toLowerCase()));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const unmappedColumns = headers.filter((h) => !mappedCols.has(h.toLowerCase()));
|
|
271
|
+
const notes = [];
|
|
272
|
+
if (!sig) notes.push('could not map: no known export matches these headers; rows kept with their original columns only');
|
|
273
|
+
if (headers.length === 0) notes.push('empty file: no header row');
|
|
274
|
+
if (sig && records.length > 0 && dates.length === 0) notes.push('no row has a readable date');
|
|
275
|
+
return {
|
|
276
|
+
schemaVersion: SCHEMA_VERSION,
|
|
277
|
+
file,
|
|
278
|
+
path,
|
|
279
|
+
source: sig ? sig.source : 'unknown',
|
|
280
|
+
sourceLabel: sig ? sig.label : null,
|
|
281
|
+
kind: sig ? sig.kind : 'other',
|
|
282
|
+
rows: records.length,
|
|
283
|
+
columns: headers,
|
|
284
|
+
unmappedColumns,
|
|
285
|
+
dateRange: dates.length ? { from: dates[0].slice(0, 10), to: dates[dates.length - 1].slice(0, 10) } : null,
|
|
286
|
+
namesHashed: opts.hashNames,
|
|
287
|
+
notes,
|
|
288
|
+
records,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Expand --in arguments: a directory contributes its *.csv files (one level). */
|
|
293
|
+
function expandInputs(inputs) {
|
|
294
|
+
const files = [];
|
|
295
|
+
for (const p of inputs) {
|
|
296
|
+
const abs = resolve(p);
|
|
297
|
+
if (!existsSync(abs)) { files.push({ path: abs, error: 'not found' }); continue; }
|
|
298
|
+
if (statSync(abs).isDirectory()) {
|
|
299
|
+
for (const name of readdirSync(abs).sort()) {
|
|
300
|
+
if (extname(name).toLowerCase() === '.csv') files.push({ path: join(abs, name) });
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
files.push({ path: abs });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return files;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function usage() {
|
|
310
|
+
process.stderr.write('Usage: node ingest.mjs --in <file-or-dir> [--in ...] [--out <dir>] [--hash-names] [--detect-only] [--source <name>]\n');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** CLI entry. */
|
|
315
|
+
function main(argv) {
|
|
316
|
+
const inputs = [];
|
|
317
|
+
let out = '';
|
|
318
|
+
let hashNames = false;
|
|
319
|
+
let detectOnly = false;
|
|
320
|
+
let sourceOverride;
|
|
321
|
+
for (let i = 0; i < argv.length; i++) {
|
|
322
|
+
const a = argv[i];
|
|
323
|
+
if (a === '--in') inputs.push(argv[++i] ?? usage());
|
|
324
|
+
else if (a === '--out') out = argv[++i] ?? usage();
|
|
325
|
+
else if (a === '--hash-names') hashNames = true;
|
|
326
|
+
else if (a === '--detect-only') detectOnly = true;
|
|
327
|
+
else if (a === '--source') sourceOverride = argv[++i] ?? usage();
|
|
328
|
+
else if (a === '--help' || a === '-h') usage();
|
|
329
|
+
else usage();
|
|
330
|
+
}
|
|
331
|
+
if (inputs.length === 0) usage();
|
|
332
|
+
if (!detectOnly && !out) usage();
|
|
333
|
+
|
|
334
|
+
const files = expandInputs(inputs);
|
|
335
|
+
const summary = { success: true, schemaVersion: SCHEMA_VERSION, out: detectOnly ? null : resolve(out), namesHashed: hashNames, files: [], totals: { files: 0, rows: 0, byKind: {} } };
|
|
336
|
+
let failed = 0;
|
|
337
|
+
if (!detectOnly) mkdirSync(resolve(out), { recursive: true });
|
|
338
|
+
for (const f of files) {
|
|
339
|
+
if (f.error) { summary.files.push({ file: basename(f.path), path: f.path, error: f.error }); failed++; continue; }
|
|
340
|
+
let result;
|
|
341
|
+
try {
|
|
342
|
+
result = ingestFile(f.path, { hashNames, sourceOverride });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
summary.files.push({ file: basename(f.path), path: f.path, error: err instanceof Error ? err.message : String(err) });
|
|
345
|
+
failed++;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const { records, ...head } = result;
|
|
349
|
+
if (!detectOnly) {
|
|
350
|
+
const target = join(resolve(out), `${basename(f.path, extname(f.path))}.json`);
|
|
351
|
+
writeFileSync(target, JSON.stringify(result, null, 2));
|
|
352
|
+
head.output = target;
|
|
353
|
+
}
|
|
354
|
+
summary.files.push(head);
|
|
355
|
+
summary.totals.files++;
|
|
356
|
+
summary.totals.rows += records.length;
|
|
357
|
+
summary.totals.byKind[head.kind] = (summary.totals.byKind[head.kind] ?? 0) + records.length;
|
|
358
|
+
}
|
|
359
|
+
summary.success = failed === 0;
|
|
360
|
+
if (failed) summary.error = `${failed} file(s) could not be read`;
|
|
361
|
+
if (!detectOnly) writeFileSync(join(resolve(out), 'summary.json'), JSON.stringify(summary, null, 2));
|
|
362
|
+
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
363
|
+
process.exit(failed ? 2 : 0);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (process.argv[1] && resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
|
|
367
|
+
main(process.argv.slice(2));
|
|
368
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crewly",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.448",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Multi-agent orchestration platform for AI coding teams — coordinates Claude Code, Gemini CLI, and Codex agents with a real-time web dashboard",
|
|
6
6
|
"workspaces": [
|