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.
@@ -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,4 @@
1
+ Widget,Colour,Units,Notes
2
+ Spinner,Blue,12,"Back-ordered, ""rush"""
3
+ Spinner,Red,3,
4
+ Cube,Green,40,
@@ -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
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "a1dbd3933be90f29496b9f5d4ea0fc27d92c2162",
3
- "builtAt": "2026-10-26T17:07:55.994Z"
2
+ "commit": "74d495d27e60a4524fafb879c164d9ec48ae6fdf",
3
+ "builtAt": "2026-10-26T18:10:15.500Z"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewly",
3
- "version": "1.20.446",
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": [