sizmo 0.4.0

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/lib/cli.mjs ADDED
@@ -0,0 +1,311 @@
1
+ // lib/cli.mjs — global-flag parse → route → ctx → run → exit-code map. All commands run
2
+ // in-process via registry (importable core). READ-ONLY router; never writes to GoHighLevel.
3
+ import { fileURLToPath } from 'node:url';
4
+ import { registry } from './registry.mjs';
5
+ import { resolve, loadProfiles, saveProfiles, validateToken, mask, pitAgeDays } from './config.mjs';
6
+ import { makeHttp } from './http.mjs';
7
+ import { buildCtx } from './context.mjs';
8
+ import { buildSchema } from './schema.mjs';
9
+ import { GhlError, EXIT } from './errors.mjs';
10
+ import { readFileSync } from 'node:fs';
11
+
12
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
+
14
+ export async function route(argv, io = {}) {
15
+ const write = io.write || ((s) => process.stdout.write(s));
16
+ const writeErr = io.writeErr || ((s) => process.stderr.write(s));
17
+ // pull global --profile + --json + --fresh/--no-cache
18
+ let profile = null, json = false, fresh = false; const rest = [];
19
+ for (let i = 0; i < argv.length; i++) {
20
+ if (argv[i] === '--profile' && argv[i + 1]) { profile = argv[++i]; continue; }
21
+ if (argv[i] === '--json') { json = true; rest.push(argv[i]); continue; }
22
+ if (argv[i] === '--fresh' || argv[i] === '--no-cache') { fresh = true; continue; }
23
+ rest.push(argv[i]);
24
+ }
25
+ const [cmd, ...args] = rest;
26
+
27
+ if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') { write(helpText(VERSION)); return cmd ? EXIT.OK : EXIT.USAGE; }
28
+ if (cmd === 'version' || cmd === '--version' || cmd === '-V') { write(VERSION + '\n'); return EXIT.OK; }
29
+ if (cmd === 'schema') { write(JSON.stringify(await buildSchema(registry, EXIT), null, 2) + '\n'); return EXIT.OK; }
30
+
31
+ // router-level verbs: auth, config, api
32
+ if (cmd === 'auth' || cmd === 'config') return routerVerb(cmd, args, { profile, json, write, writeErr, readStdin: io.readStdin });
33
+ if (cmd === 'api') return apiVerb(args, { profile, json, write, writeErr });
34
+
35
+ // recipe commands: all run in-process via registry
36
+ const name = cmd === 'recipes' ? args.shift() : cmd;
37
+ try {
38
+ if (registry[name]) {
39
+ const creds = resolve(profile);
40
+ const tty = !!(io.tty ?? process.stdout.isTTY);
41
+ const ctx = buildCtx({ creds, globals: { json, tty, command: name, fresh } });
42
+ const mod = await registry[name]();
43
+ const parsed = parseArgs(args, mod.meta); // validates against meta.flags; throws GhlError USAGE
44
+ const code = await mod.run(parsed, ctx);
45
+ ctx.out.flush();
46
+ return code ?? EXIT.OK;
47
+ }
48
+ throw new GhlError(`unknown command "${name ?? cmd}"`, EXIT.USAGE, 'sizmo help');
49
+ } catch (e) {
50
+ if (e instanceof GhlError) {
51
+ if (json) writeErr(JSON.stringify({ error: e.message, code: e.code, remediation: e.remediation }) + '\n');
52
+ else writeErr(e.message + (e.remediation ? `\n fix: ${e.remediation}` : '') + '\n');
53
+ return e.code;
54
+ }
55
+ writeErr((e?.message || 'error') + '\n');
56
+ return EXIT.API;
57
+ }
58
+ }
59
+ export const run = route;
60
+
61
+ export function parseArgs(args, meta) {
62
+ const out = { _: [] };
63
+ const flags = new Map((meta?.flags || []).map(f => [f.name, f]));
64
+ for (let i = 0; i < args.length; i++) {
65
+ const a = args[i];
66
+ if (a.startsWith('--')) {
67
+ const f = flags.get(a);
68
+ if (!f) { if (a === '--json') { out.json = true; continue; } throw new GhlError(`unknown flag ${a}`, EXIT.USAGE, `sizmo schema`); }
69
+ if (f.type === 'bool') out[a.slice(2)] = true;
70
+ else {
71
+ if (i + 1 >= args.length) throw new GhlError(`flag ${a} needs a value`, EXIT.USAGE, 'sizmo schema');
72
+ out[a.slice(2)] = f.type === 'int' ? Number(args[++i]) : args[++i];
73
+ }
74
+ } else out._.push(a);
75
+ }
76
+ return out;
77
+ }
78
+ function helpText(v) { return `sizmo ${v} — GoHighLevel read-only CLI (money always human-triggered)\n sizmo <command> [--json] [--profile name] [--fresh]\n commands: see sizmo schema\n sizmo auth status · sizmo config list|use|set|rm\n --fresh / --no-cache bypass 60s read cache (always re-fetches live data)\n cacheAgeMs in JSON envelope shows how old the data is when served from cache\n`; }
79
+
80
+ // routerVerb — auth + config local verbs.
81
+ // io.readStdin seam lets tests inject stdin content without actually reading fd 0.
82
+ async function routerVerb(cmd, args, io) {
83
+ const { profile = null, json = false, write, writeErr, readStdin } = io;
84
+ const [verb, ...rest] = args;
85
+
86
+ function die(msg, code = EXIT.API, remediation = null) {
87
+ if (json) writeErr(JSON.stringify({ error: msg, code, ...(remediation && { remediation }) }) + '\n');
88
+ else writeErr(msg + (remediation ? `\n fix: ${remediation}` : '') + '\n');
89
+ return code;
90
+ }
91
+
92
+ // ── auth ────────────────────────────────────────────────────────────────────
93
+ if (cmd === 'auth') {
94
+ if (verb === 'check') {
95
+ // auth check: verify PIT scopes by probing the API directly
96
+ const creds = resolve(profile);
97
+ if (!creds.pit) return die('no credentials found', EXIT.AUTH, 'set GHL_PIT, or: sizmo config set --profile <name> --pit-stdin');
98
+ if (!creds.loc) return die('no location resolved', EXIT.AUTH, 'pass --profile <name>, or set GHL_LOCATION_ID');
99
+ write('auth check: probing GoHighLevel API scopes...\n');
100
+ try {
101
+ const http = makeHttp({ pit: creds.pit });
102
+ const result = await validateToken(http, creds.loc);
103
+ if (!result.ok) {
104
+ writeErr(`WARN: token check failed — ${result.reason}\n`);
105
+ return EXIT.AUTH;
106
+ }
107
+ write(`auth check: PIT accepted for location ${creds.loc}\n`);
108
+ return EXIT.OK;
109
+ } catch (e) {
110
+ writeErr(`auth check: could not reach GoHighLevel (${e?.message ?? 'error'})\n`);
111
+ return EXIT.API;
112
+ }
113
+ }
114
+ if (verb !== 'status') return die('usage: sizmo auth status|check', EXIT.USAGE);
115
+
116
+ // auth status — source, loc, masked PIT, age
117
+ const creds = resolve(profile);
118
+ write(`auth source ${creds.source ?? 'NONE'}\n`);
119
+ write(`location ${creds.loc ?? '(none resolved)'}\n`);
120
+ write(`PIT ${mask(creds.pit)}${creds.label ? ` (${creds.label})` : ''}\n`);
121
+ const age = pitAgeDays(creds.createdAt);
122
+ if (age !== null) {
123
+ const note = age >= 90 ? '✖ EXPIRED-ZONE — rotate NOW (90d limit, 7d dual-token overlap)'
124
+ : age >= 80 ? `⚠ rotate soon — day ${age} of 90`
125
+ : `✅ day ${age} of 90`;
126
+ write(`PIT age ${note}\n`);
127
+ } else {
128
+ write(`PIT age unknown — set with: sizmo config set --profile <name> --created YYYY-MM-DD\n`);
129
+ }
130
+ if (!creds.pit) return die('no credentials found', EXIT.AUTH, 'set GHL_PIT, or: sizmo config set --profile <name> --pit-stdin');
131
+ return EXIT.OK;
132
+ }
133
+
134
+ // ── config ──────────────────────────────────────────────────────────────────
135
+ if (cmd === 'config') {
136
+ const db = loadProfiles();
137
+
138
+ if (verb === 'list') {
139
+ const names = Object.keys(db.profiles ?? {});
140
+ if (json) {
141
+ // Machine output — PIT never emitted (omitted entirely, not masked).
142
+ const profiles = names.map(n => {
143
+ const p = db.profiles[n];
144
+ return {
145
+ name: n,
146
+ locationId: p.locationId ?? null,
147
+ label: p.label ?? null,
148
+ default: n === db.default,
149
+ pitAgeDays: pitAgeDays(p.createdAt),
150
+ };
151
+ });
152
+ write(JSON.stringify({ schemaVersion: 1, profiles }) + '\n');
153
+ return EXIT.OK;
154
+ }
155
+ if (!names.length) {
156
+ write('no profiles yet (GHL_PIT env var in effect if set). sizmo config set --profile <name> ...\n');
157
+ }
158
+ for (const n of names) {
159
+ const p = db.profiles[n];
160
+ const age = pitAgeDays(p.createdAt);
161
+ write(`${n === db.default ? '*' : ' '} ${n.padEnd(16)} loc ${p.locationId ?? '—'} ${mask(p.pit)} ${age !== null ? `day ${age}/90` : ''} ${p.label ?? ''}\n`);
162
+ }
163
+ return EXIT.OK;
164
+ }
165
+
166
+ if (verb === 'use') {
167
+ const name = rest[0];
168
+ if (!db.profiles?.[name]) return die(`no profile "${name}"`, EXIT.NOTFOUND, 'sizmo config list');
169
+ db.default = name;
170
+ saveProfiles(db);
171
+ write(`default → ${name}\n`);
172
+ return EXIT.OK;
173
+ }
174
+
175
+ if (verb === 'rm') {
176
+ const name = rest[0];
177
+ if (!db.profiles?.[name]) return die(`no profile "${name}"`, EXIT.NOTFOUND, 'sizmo config list');
178
+ delete db.profiles[name];
179
+ if (db.default === name) db.default = null;
180
+ saveProfiles(db);
181
+ write(`removed ${name}\n`);
182
+ return EXIT.OK;
183
+ }
184
+
185
+ if (verb === 'set') {
186
+ // flag(n): returns the value string when present, null when absent.
187
+ // Distinguish "flag absent" from "flag present with empty string" — reject empty strings.
188
+ const flagRaw = (n) => { const i = rest.indexOf(n); return i >= 0 ? (rest[i + 1] ?? null) : null; };
189
+ const flagPresent = (n) => rest.indexOf(n) >= 0;
190
+ const flag = (n) => { const v = flagRaw(n); return (v && v !== '') ? v : null; };
191
+ // Reject explicitly-passed empty strings for --loc, --label, --created
192
+ for (const f of ['--loc', '--label', '--created']) {
193
+ if (flagPresent(f) && !flagRaw(f)) return die(`${f} requires a non-empty value`, EXIT.USAGE, `sizmo config set --profile <name> ${f} <value>`);
194
+ }
195
+ const name = flag('--profile') ?? profile;
196
+ if (!name) return die('need --profile <name>', EXIT.USAGE);
197
+ db.profiles ??= {};
198
+ const p = db.profiles[name] ?? {};
199
+ if (flag('--loc')) p.locationId = flag('--loc');
200
+ if (flag('--label')) p.label = flag('--label');
201
+ if (flag('--created')) p.createdAt = flag('--created');
202
+
203
+ if (rest.includes('--pit-stdin')) {
204
+ let tok;
205
+ if (readStdin) {
206
+ tok = readStdin().trim();
207
+ } else {
208
+ tok = readFileSync(0, 'utf8').trim();
209
+ }
210
+ if (!tok.startsWith('pit-')) return die('stdin did not look like a PIT (expected pit-…)', EXIT.USAGE);
211
+ p.pit = tok;
212
+ p.createdAt ??= new Date().toISOString().slice(0, 10);
213
+ } else if (flag('--pit-env')) {
214
+ const envVar = flag('--pit-env');
215
+ const tok = process.env[envVar];
216
+ if (!tok?.startsWith('pit-')) return die(`env ${envVar} empty or not a PIT`, EXIT.USAGE);
217
+ p.pit = tok;
218
+ p.createdAt ??= new Date().toISOString().slice(0, 10);
219
+ }
220
+
221
+ db.profiles[name] = p;
222
+ db.default ??= name;
223
+ saveProfiles(db);
224
+ write(`saved ${name} — loc ${p.locationId ?? '—'} · ${mask(p.pit)} · ${p.createdAt ? 'created ' + p.createdAt : 'no created date'}\n`);
225
+
226
+ // validate PIT→loc after save (warn only — don't hard-fail the save)
227
+ if (p.pit && p.locationId) {
228
+ try {
229
+ const http = makeHttp({ pit: p.pit });
230
+ const result = await validateToken(http, p.locationId);
231
+ if (!result.ok) {
232
+ writeErr(`WARN: token validation failed — ${result.reason}\n The profile was saved. Double-check PIT and loc before using.\n`);
233
+ }
234
+ } catch (e) {
235
+ writeErr(`WARN: could not reach GoHighLevel to validate token (${e?.message ?? 'error'}) — profile saved anyway\n`);
236
+ }
237
+ }
238
+
239
+ return EXIT.OK;
240
+ }
241
+
242
+ return die('usage: sizmo config list|use <name>|set --profile <name> ...|rm <name>', EXIT.USAGE);
243
+ }
244
+
245
+ return EXIT.OK;
246
+ }
247
+
248
+ // apiVerb — GET-only raw escape hatch.
249
+ // Structural read-only: no method flag exists. Uses makeHttp instead of raw fetch.
250
+ async function apiVerb(args, { profile, json, write, writeErr }) {
251
+ const [path, ...rest] = args;
252
+ function die(msg, code = EXIT.API, remediation = null) {
253
+ if (json) writeErr(JSON.stringify({ error: msg, code, ...(remediation && { remediation }) }) + '\n');
254
+ else writeErr(msg + (remediation ? `\n fix: ${remediation}` : '') + '\n');
255
+ return code;
256
+ }
257
+
258
+ if (!path || !path.startsWith('/'))
259
+ return die('usage: sizmo api </path?query> [--paginate] [--max-pages N]', EXIT.USAGE, 'example: sizmo api "/contacts/?limit=5"');
260
+
261
+ const creds = resolve(profile);
262
+ if (!creds.pit)
263
+ return die('no PIT available', EXIT.AUTH, 'set GHL_PIT, or: sizmo config set --profile <name> --pit-stdin');
264
+
265
+ const flag = (n, d) => { const i = rest.indexOf(n); return i >= 0 && rest[i + 1] ? rest[i + 1] : d; };
266
+ const paginate = rest.includes('--paginate');
267
+ const rawMaxPages = flag('--max-pages', null);
268
+ const maxPages = rawMaxPages !== null ? Number(rawMaxPages) : 10;
269
+ // validate --max-pages when --paginate is set — NaN or <=0 produces empty output
270
+ if (paginate && rawMaxPages !== null && !(Number.isInteger(maxPages) && maxPages >= 1))
271
+ return die(`--max-pages must be a positive integer (got ${JSON.stringify(rawMaxPages)})`, EXIT.USAGE, 'example: sizmo api "/contacts/?limit=5" --paginate --max-pages 5');
272
+
273
+ const http = makeHttp({ pit: creds.pit });
274
+
275
+ // auto-fill locationId when absent
276
+ const LOC = creds.loc;
277
+ let urlPath = path;
278
+ if (LOC && !/locationId=|location_id=|altId=/.test(urlPath))
279
+ urlPath += (urlPath.includes('?') ? '&' : '?') + 'locationId=' + LOC;
280
+
281
+ const pages = [];
282
+ const BASE = 'https://services.leadconnectorhq.com';
283
+ let currentPath = urlPath;
284
+
285
+ for (let p = 0; p < (paginate ? maxPages : 1); p++) {
286
+ // For paginated next URLs, strip the base if present
287
+ const reqPath = currentPath.startsWith('http')
288
+ ? currentPath.replace(BASE, '')
289
+ : currentPath;
290
+ const r = await http.get(reqPath);
291
+ if (r.code === 401 || r.code === 403)
292
+ return die(`HTTP ${r.code} — PIT lacks scope for ${path}`, EXIT.AUTH, 'sizmo auth check shows which lanes this PIT can see');
293
+ if (r.code === 404)
294
+ return die(`HTTP 404 — ${path}`, EXIT.NOTFOUND, (r.txt || '').slice(0, 120).replace(/\s+/g, ' '));
295
+ if (!r.ok)
296
+ return die(`HTTP ${r.code} — ${(r.txt || '').slice(0, 200).replace(/\s+/g, ' ')}`, EXIT.API);
297
+
298
+ // If response isn't JSON (no j), write raw text and stop
299
+ if (r.j === null) { write(r.txt + '\n'); return EXIT.OK; }
300
+ pages.push(r.j);
301
+
302
+ const next = r.j?.meta?.nextPageUrl;
303
+ if (!paginate || !next) break;
304
+ currentPath = next;
305
+ }
306
+
307
+ write(JSON.stringify(pages.length === 1 ? pages[0] : pages, null, 2) + '\n');
308
+ if (paginate && pages.length === maxPages)
309
+ writeErr(`note: stopped at --max-pages ${maxPages}; more may exist\n`);
310
+ return EXIT.OK;
311
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,39 @@
1
+ // lib/config.mjs — credential resolution + profiles. NO baked location default.
2
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { env as processEnv } from 'node:process';
6
+
7
+ // XDG-style neutral config path. Override with XDG_CONFIG_HOME.
8
+ const XDG = processEnv.XDG_CONFIG_HOME || join(homedir(), '.config');
9
+ const CFG_DIR = join(XDG, 'sizmo');
10
+ const PROFILES = join(CFG_DIR, 'profiles.json');
11
+
12
+ export function loadProfiles() { try { return JSON.parse(readFileSync(PROFILES, 'utf8')); } catch { return { default: null, profiles: {} }; } }
13
+ export function saveProfiles(db) { mkdirSync(CFG_DIR, { recursive: true }); writeFileSync(PROFILES, JSON.stringify(db, null, 2)); chmodSync(PROFILES, 0o600); }
14
+
15
+ // pure + injectable for tests: precedence env > profile. NO default loc.
16
+ export function resolveCreds(env, profile) {
17
+ const pit = env.GHL_PIT || profile?.pit || null;
18
+ const loc = env.GHL_LOCATION_ID || profile?.locationId || null;
19
+ const source = env.GHL_PIT ? 'env GHL_PIT' : profile?.pit ? 'profile' : null;
20
+ const tz = profile?.tz || 'UTC';
21
+ const currency = profile?.currency || null;
22
+ return { pit, loc, tz, currency, source };
23
+ }
24
+ export function resolve(profileName) {
25
+ const db = loadProfiles();
26
+ const name = profileName || db.default;
27
+ const profile = name ? db.profiles?.[name] : null;
28
+ const r = resolveCreds(process.env, profile);
29
+ return { ...r, profileName: name, profile, label: profile?.label, createdAt: profile?.createdAt };
30
+ }
31
+ // confirm the PIT actually belongs to loc (one live read). http = makeHttp(pit).
32
+ export async function validateToken(http, loc) {
33
+ const r = await http.get('/contacts/', { query: { locationId: loc, limit: 1 } });
34
+ if (r.code === 401 || r.code === 403) return { ok: false, reason: `PIT rejected for ${loc} (HTTP ${r.code})` };
35
+ if (!r.ok && r.code !== 200) return { ok: false, reason: `unexpected HTTP ${r.code}` };
36
+ return { ok: true };
37
+ }
38
+ export const mask = (pit) => pit ? `pit-…${pit.slice(-4)}` : '(none)';
39
+ export const pitAgeDays = (createdAt) => createdAt ? Math.floor((Date.now() - new Date(createdAt).getTime()) / 86400000) : null;
@@ -0,0 +1,33 @@
1
+ // lib/context.mjs — assemble the injected ctx. Enforces "no creds → AUTH".
2
+ import { makeHttp } from './http.mjs';
3
+ import { makeOut } from './output.mjs';
4
+ import { makeCache } from './cache.mjs';
5
+ import { GhlError, EXIT } from './errors.mjs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { env as processEnv } from 'node:process';
9
+
10
+ // XDG-style neutral cache path. Override with XDG_CONFIG_HOME.
11
+ const XDG = processEnv.XDG_CONFIG_HOME || join(homedir(), '.config');
12
+ const CACHE_DIR = join(XDG, 'sizmo', 'cache');
13
+ const CACHE_TTL_MS = 60_000; // 60 seconds
14
+
15
+ export function buildCtx({ creds, globals, now = Date.now(), httpFactory = makeHttp } = {}) {
16
+ if (!creds.pit) throw new GhlError('no PIT available', EXIT.AUTH, 'set GHL_PIT, or: sizmo config set --profile <name> --pit-stdin');
17
+ if (!creds.loc) throw new GhlError('no location resolved', EXIT.AUTH, 'pass --profile <name>, or set GHL_LOCATION_ID');
18
+ // --fresh / --no-cache: bypass cache entirely (always re-fetch)
19
+ const fresh = !!(globals.fresh || globals['no-cache']);
20
+ // Cache is keyed by full URL (includes locationId param) — no cross-profile bleed
21
+ const cache = makeCache({ dir: CACHE_DIR, ttlMs: CACHE_TTL_MS });
22
+ const rawHttp = httpFactory({ pit: creds.pit, cache, fresh });
23
+ const out = makeOut({ json: !!globals.json, tty: !!globals.tty, command: globals.command, location: creds.loc });
24
+ // Wrap http.get to forward cacheAge to out.noteCacheAge — so flush() can surface it in the envelope/TTY note.
25
+ const http = {
26
+ get: async (path, opts) => {
27
+ const r = await rawHttp.get(path, opts);
28
+ if (typeof r.cacheAge === 'number') out.noteCacheAge(r.cacheAge);
29
+ return r;
30
+ },
31
+ };
32
+ return { http, cfg: creds, out, now };
33
+ }
package/lib/errors.mjs ADDED
@@ -0,0 +1,11 @@
1
+ // lib/errors.mjs — typed errors mapped to documented exit codes.
2
+ export const EXIT = { OK: 0, API: 1, USAGE: 2, AUTH: 3, NOTFOUND: 4 };
3
+
4
+ export class GhlError extends Error {
5
+ constructor(message, code = EXIT.API, remediation = null) {
6
+ super(message);
7
+ this.name = 'GhlError';
8
+ this.code = code;
9
+ this.remediation = remediation;
10
+ }
11
+ }
package/lib/http.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // lib/http.mjs — the one GHL HTTP client. Auth, 429 Retry-After + backoff+jitter, timeout.
2
+ // READ paths only are used by this CLI; method defaults to GET. fetch/sleep injectable for tests.
3
+ const DEFAULT_BASE = 'https://services.leadconnectorhq.com';
4
+
5
+ export function makeHttp({ pit, base = DEFAULT_BASE, version = '2021-07-28',
6
+ fetch = globalThis.fetch, sleep = (ms) => new Promise(r => setTimeout(r, ms)),
7
+ maxRetries = 4, timeoutMs = 15000, jitter = () => 0.5,
8
+ maxTimeoutRetries = 2, cache = null, fresh = false } = {}) {
9
+ async function get(path, { query, version: v = version } = {}) {
10
+ const url = new URL(base + path);
11
+ if (query) for (const [k, val] of Object.entries(query)) if (val != null) url.searchParams.set(k, String(val));
12
+ // Cache check: full resolved URL as key (includes locationId param → no cross-profile bleed)
13
+ const cacheKey = url.toString();
14
+ if (cache && !fresh) {
15
+ const hit = cache.get(cacheKey);
16
+ if (hit) return { ...hit.value, cacheAge: hit.ageMs };
17
+ }
18
+ let attempt = 0;
19
+ let timeoutAttempt = 0;
20
+ while (true) {
21
+ const ctl = new AbortController();
22
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
23
+ let res;
24
+ try {
25
+ res = await fetch(url, { method: 'GET', signal: ctl.signal,
26
+ headers: { Authorization: `Bearer ${pit}`, Version: v, Accept: 'application/json' } });
27
+ } catch (e) {
28
+ clearTimeout(timer);
29
+ if (e.name === 'AbortError') {
30
+ if (timeoutAttempt++ < maxTimeoutRetries) { await sleep(backoff(timeoutAttempt, jitter)); continue; }
31
+ return { code: 0, ok: false, j: null, txt: 'timeout' };
32
+ }
33
+ if (attempt++ < maxRetries) { await sleep(backoff(attempt, jitter)); continue; }
34
+ return { code: 0, ok: false, j: null, txt: e.message };
35
+ }
36
+ clearTimeout(timer);
37
+ if (res.status === 429 && attempt < maxRetries) {
38
+ const ra = Number(res.headers.get?.('retry-after'));
39
+ await sleep(Number.isFinite(ra) && ra > 0 ? ra * 1000 : backoff(++attempt, jitter));
40
+ continue;
41
+ }
42
+ if (res.status >= 500 && attempt < maxRetries) { await sleep(backoff(++attempt, jitter)); continue; }
43
+ const txt = await res.text(); let j = null; try { j = JSON.parse(txt); } catch {}
44
+ const result = { code: res.status, ok: res.status >= 200 && res.status < 300, j, txt };
45
+ // Only cache 2xx responses — NEVER cache 4xx/5xx/blocked (fake-fresh bug class)
46
+ if (cache && !fresh && result.ok) cache.set(cacheKey, result);
47
+ return result;
48
+ }
49
+ }
50
+ return { get };
51
+ }
52
+ function backoff(attempt, jitter) { return Math.min(8000, 250 * 2 ** attempt) * (0.5 + jitter() * 0.5); }
package/lib/output.mjs ADDED
@@ -0,0 +1,39 @@
1
+ // lib/output.mjs — bimodal output. TTY → human card; non-TTY/--json → frozen envelope. warn() always stderr.
2
+ export function makeOut({ json, tty, command, location, write = (s) => process.stdout.write(s),
3
+ writeErr = (s) => process.stderr.write(s) } = {}) {
4
+ const warnings = [];
5
+ let degraded = false;
6
+ let payload = null;
7
+ let flushed = false;
8
+ let maxCacheAgeMs = null; // null = no cache hits; number = max age (ms) across all cached responses
9
+ const api = {
10
+ color: tty && !process.env.NO_COLOR,
11
+ data(obj) { payload = obj; }, // machine payload
12
+ warn(str, { degraded: d = false } = {}) { warnings.push(str); if (d) degraded = true; writeErr(str + '\n'); },
13
+ card(fn) { if (!json) fn(); }, // human render (no-op in json mode)
14
+ line(s = '') { if (!json) write(s + '\n'); },
15
+ // Track the max cache age across all responses in this run.
16
+ // Called by context.mjs after each http.get() that returns a cacheAge.
17
+ noteCacheAge(ageMs) {
18
+ if (typeof ageMs === 'number') {
19
+ maxCacheAgeMs = maxCacheAgeMs === null ? ageMs : Math.max(maxCacheAgeMs, ageMs);
20
+ }
21
+ },
22
+ flush() {
23
+ if (flushed) return;
24
+ flushed = true;
25
+ // TTY cache note — never show cached data without surfacing the age
26
+ if (!json && maxCacheAgeMs !== null) {
27
+ const s = Math.round(maxCacheAgeMs / 1000);
28
+ write(`· cached ${s}s ago · --fresh to refresh\n`);
29
+ }
30
+ if (json) {
31
+ const envelope = { schemaVersion: 1, command, location, data: payload, degraded, warnings };
32
+ if (maxCacheAgeMs !== null) envelope.cacheAgeMs = maxCacheAgeMs;
33
+ write(JSON.stringify(envelope, null, 2) + '\n');
34
+ }
35
+ },
36
+ get degraded() { return degraded; },
37
+ };
38
+ return api;
39
+ }
@@ -0,0 +1,13 @@
1
+ // lib/paginate.mjs — strategy-driven, fetch-to-completion. The structural fix for shallow single-page reads.
2
+ export async function* paginate({ fetchPage, getItems, nextCursor, maxPages = 100, startCursor } = {}) {
3
+ let cursor = startCursor, pages = 0;
4
+ while (pages < maxPages) {
5
+ const resp = await fetchPage(cursor);
6
+ pages++;
7
+ const items = getItems(resp) || [];
8
+ for (const it of items) yield it;
9
+ const next = nextCursor(resp, items, cursor);
10
+ if (next == null) return;
11
+ cursor = next;
12
+ }
13
+ }
package/lib/pool.mjs ADDED
@@ -0,0 +1,10 @@
1
+ // lib/pool.mjs — concurrency-capped parallel map. Order-preserving. For rate-limit-safe fan-out.
2
+ export async function mapLimit(items, limit, fn) {
3
+ const out = new Array(items.length);
4
+ let i = 0;
5
+ async function worker() {
6
+ while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
7
+ }
8
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
9
+ return out;
10
+ }
@@ -0,0 +1,146 @@
1
+ // lib/prioritize.mjs — Transparent money-at-stake ranker.
2
+ // Pure function, no I/O. Honesty law: rank ONLY by money we can actually see.
3
+ // Items with no known value go to a SEPARATE unknownValue group — never faked as ₱0.
4
+ // Every output line shows its inputs. No hidden score. No currency conversion.
5
+
6
+ const SYM = { PHP: '₱', USD: '$', EUR: '€', GBP: '£' };
7
+ const fmt = (n, c = 'PHP') => (SYM[c] || c + ' ') + Number(n || 0).toLocaleString('en-PH', { maximumFractionDigits: 0 });
8
+
9
+ /**
10
+ * rankActions({ deals, invoices, threads, noshows, neverBilled })
11
+ *
12
+ * Each input array element shape:
13
+ * deals: { contactId, name, monetaryValue, ageDays }
14
+ * invoices: { contactId, name, due, cur, ageDays }
15
+ * threads: { contactId, name, ageDays } — no known money
16
+ * noshows: { contactId, name, ageDays } — no known money
17
+ * neverBilled: { contactId, name, estValue, ageDays } — estValue>0 counts; 0/null → unknownValue
18
+ *
19
+ * Returns:
20
+ * ranked — money items, sorted money desc, tie-break age desc
21
+ * each: { money, cur, age, kind, contact, name, action, inputs }
22
+ * unknownValue — threads + noshows + zero-est neverBilled, sorted age desc
23
+ * each: { money:null, age, kind, contact, name, action, inputs }
24
+ *
25
+ * Mixed-currency caveat: sort is by raw number with currency labeled per line.
26
+ * PHP 1000 vs USD 100 — the sort compares raw values without conversion.
27
+ * If mixed currencies are present in ranked, callers should surface a caveat footer.
28
+ */
29
+ export function rankActions({
30
+ deals = [],
31
+ invoices = [],
32
+ threads = [],
33
+ noshows = [],
34
+ neverBilled = [],
35
+ } = {}) {
36
+ const ranked = [];
37
+ const unknownValue = [];
38
+
39
+ // ── deals: monetaryValue>0 → ranked; 0/unset → unknownValue ──────────────────
40
+ // A deal with no monetaryValue is value-UNKNOWN (coach didn't enter one), NOT worth ₱0.
41
+ // Faking it as ₱0 and ranking it at the bottom of the money list is the fake-intelligence
42
+ // bug — an unset value is unknown, surfaced honestly, never a fabricated zero.
43
+ for (const d of deals) {
44
+ const money = Number(d.monetaryValue) || 0;
45
+ // GHL opportunity monetaryValue has no currency field — treated as PHP (known GHL limitation)
46
+ if (money > 0) {
47
+ ranked.push({
48
+ money, cur: 'PHP', age: d.ageDays, kind: 'deal', contact: d.contactId, name: d.name,
49
+ action: 'ghl pipeline', inputs: `${fmt(money, 'PHP')} deal · idle ${d.ageDays}d`,
50
+ });
51
+ } else {
52
+ unknownValue.push({
53
+ money: null, age: d.ageDays, kind: 'deal', contact: d.contactId, name: d.name,
54
+ action: 'ghl pipeline', inputs: `open deal · no value set · idle ${d.ageDays}d · value unknown`,
55
+ });
56
+ }
57
+ }
58
+
59
+ // ── invoices: due>0 → ranked; ≤0 → unknownValue (defensive; receivables only returns due>0) ──
60
+ for (const i of invoices) {
61
+ const money = Number(i.due) || 0;
62
+ const cur = (i.cur || 'PHP').toUpperCase();
63
+ if (money > 0) {
64
+ ranked.push({
65
+ money, cur, age: i.ageDays, kind: 'invoice', contact: i.contactId, name: i.name,
66
+ action: 'ghl receivables', inputs: `${fmt(money, cur)} invoice due · aged ${i.ageDays}d`,
67
+ });
68
+ } else {
69
+ unknownValue.push({
70
+ money: null, age: i.ageDays, kind: 'invoice', contact: i.contactId, name: i.name,
71
+ action: 'ghl receivables', inputs: `invoice · no balance shown · aged ${i.ageDays}d · value unknown`,
72
+ });
73
+ }
74
+ }
75
+
76
+ // ── never-billed: real estValue>0 → ranked; 0/null/undefined → unknownValue ──
77
+ for (const b of neverBilled) {
78
+ const est = Number(b.estValue);
79
+ if (est > 0) {
80
+ ranked.push({
81
+ money: est,
82
+ cur: 'PHP',
83
+ age: b.ageDays,
84
+ kind: 'never-billed',
85
+ contact: b.contactId,
86
+ name: b.name,
87
+ action: 'ghl booked-not-paid',
88
+ inputs: `${fmt(est, 'PHP')} est. value · never billed · last session ${b.ageDays}d ago`,
89
+ });
90
+ } else {
91
+ unknownValue.push({
92
+ money: null,
93
+ age: b.ageDays,
94
+ kind: 'never-billed',
95
+ contact: b.contactId,
96
+ name: b.name,
97
+ action: 'ghl booked-not-paid',
98
+ inputs: `never billed · last session ${b.ageDays}d ago · value unknown`,
99
+ });
100
+ }
101
+ }
102
+
103
+ // ── threads (waiting on reply) — no known money ───────────────────────────
104
+ for (const t of threads) {
105
+ unknownValue.push({
106
+ money: null,
107
+ age: t.ageDays,
108
+ kind: 'waiting-reply',
109
+ contact: t.contactId,
110
+ name: t.name,
111
+ action: 'ghl triage',
112
+ inputs: `waiting ${t.ageDays}d · value unknown`,
113
+ });
114
+ }
115
+
116
+ // ── noshows — no known money ───────────────────────────────────────────────
117
+ for (const n of noshows) {
118
+ unknownValue.push({
119
+ money: null,
120
+ age: n.ageDays,
121
+ kind: 'noshow',
122
+ contact: n.contactId,
123
+ name: n.name,
124
+ action: 'ghl noshow',
125
+ inputs: `no-show ${n.ageDays}d ago · value unknown`,
126
+ });
127
+ }
128
+
129
+ // ── sort ───────────────────────────────────────────────────────────────────
130
+ // ranked: money desc, tie-break age desc (older = more urgent)
131
+ ranked.sort((a, b) => b.money - a.money || b.age - a.age);
132
+ // unknownValue: age desc
133
+ unknownValue.sort((a, b) => b.age - a.age);
134
+
135
+ return { ranked, unknownValue };
136
+ }
137
+
138
+ /**
139
+ * hasMixedCurrencies(ranked) → boolean
140
+ * True when ranked items span more than one currency.
141
+ * Callers use this to surface the "raw-number sort, currencies labeled" caveat.
142
+ */
143
+ export function hasMixedCurrencies(ranked) {
144
+ const currencies = new Set(ranked.map(x => x.cur));
145
+ return currencies.size > 1;
146
+ }