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.
- package/config/skills/agent/checkup-analyse/SKILL.md +99 -0
- package/config/skills/agent/checkup-analyse/analyse.mjs +603 -0
- package/config/skills/agent/checkup-analyse/execute.sh +34 -0
- package/config/skills/agent/checkup-analyse/execute.test.sh +126 -0
- 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,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.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": [
|