crewly 1.20.447 → 1.20.449

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,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": "b6799f94f46164cb998551dc4076a0c5a69a1ea6",
3
- "builtAt": "2026-10-26T17:37:10.349Z"
2
+ "commit": "3e52dcdf0d59f9670552b0e94e8ba21219bff540",
3
+ "builtAt": "2026-10-26T18:41:25.490Z"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewly",
3
- "version": "1.20.447",
3
+ "version": "1.20.449",
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": [