sizmo 0.4.1 → 0.5.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.
@@ -2,8 +2,11 @@
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
3
3
  // Trust-fix #2: leads() and revenue() paginate to completion.
4
4
  // Trust-fix #3: revenue tracks per-currency (never cross-sums).
5
+ // v0.5.0: calendar list from CRM model; location currency from model.
6
+ // v0.6.0 (C2): modelMeta emitted in JSON envelope; TTY staleness note.
5
7
  import { paginate } from '../lib/paginate.mjs';
6
8
  import { mapLimit } from '../lib/pool.mjs';
9
+ import { ENTITY_SPECS } from '../lib/model.mjs';
7
10
 
8
11
  export const meta = {
9
12
  name: 'snapshot',
@@ -31,6 +34,35 @@ export async function collect(args, ctx) {
31
34
  return Number.isFinite(t) && t >= START && t <= NOW;
32
35
  };
33
36
 
37
+ // Location currency from CRM model (fallback PHP if model missing/blocked)
38
+ let locationCurrency = 'PHP';
39
+ let _snapshotModelLoaded = null;
40
+ let modelMeta = null;
41
+ if (ctx.ensureModel) {
42
+ try {
43
+ _snapshotModelLoaded = await ctx.ensureModel();
44
+ const locCur = _snapshotModelLoaded?.entities?.location?.item?.business?.currency
45
+ || _snapshotModelLoaded?.entities?.location?.item?.currency;
46
+ if (locCur) locationCurrency = locCur.toUpperCase();
47
+ } catch { /* use default */ }
48
+ } else if (ctx.cfg.currency) {
49
+ locationCurrency = ctx.cfg.currency;
50
+ }
51
+ // Build modelMeta for the JSON envelope (C2)
52
+ if (_snapshotModelLoaded) {
53
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
54
+ // Check calendars staleness (the main model-sourced entity in snapshot)
55
+ const calEnt = _snapshotModelLoaded.entities?.calendars;
56
+ const calSpec = specMap.calendars;
57
+ const calStale = calEnt && calSpec ? (NOW - (calEnt.fetchedAt ?? 0)) > calSpec.ttlMs : false;
58
+ modelMeta = {
59
+ syncedAt: _snapshotModelLoaded.syncedAt,
60
+ ageMs: NOW - _snapshotModelLoaded.syncedAt,
61
+ stale: calStale,
62
+ offline: !!(_snapshotModelLoaded.offline),
63
+ };
64
+ }
65
+
34
66
  // ── LEADS: paginate contacts newest→older, stop past window ──
35
67
  async function leads() {
36
68
  let count = 0, pages = 0, oldest = null;
@@ -78,12 +110,27 @@ export async function collect(args, ctx) {
78
110
  // Full fix = date-window splitting; tracked as follow-up. Cheap mitigation: warn + degrade.
79
111
  const EVENTS_CAP = 100;
80
112
  async function bookings() {
81
- const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
82
- if (!cr.ok) return [
83
- metric('Bookings', null, { blocked: true, blocker: `calendars list HTTP ${cr.code}` }),
84
- metric('Show rate', null, { blocked: true, blocker: 'no calendars' }),
85
- ];
86
- const cals = cr.j.calendars || [];
113
+ // Get calendar list from the CRM model if available; fall back to live fetch.
114
+ // Use _snapshotModelLoaded already loaded above — no second ensureModel call.
115
+ let cals = null;
116
+ if (_snapshotModelLoaded?.entities?.calendars && !_snapshotModelLoaded.entities.calendars.blocked && !_snapshotModelLoaded.entities.calendars.networkError) {
117
+ cals = _snapshotModelLoaded.entities.calendars.items ?? [];
118
+ } else if (!_snapshotModelLoaded && ctx.ensureModel) {
119
+ try {
120
+ const model = await ctx.ensureModel();
121
+ if (model?.entities?.calendars && !model.entities.calendars.blocked && !model.entities.calendars.networkError) {
122
+ cals = model.entities.calendars.items ?? [];
123
+ }
124
+ } catch { /* fall through to live fetch */ }
125
+ }
126
+ if (cals === null) {
127
+ const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
128
+ if (!cr.ok) return [
129
+ metric('Bookings', null, { blocked: true, blocker: `calendars list HTTP ${cr.code}` }),
130
+ metric('Show rate', null, { blocked: true, blocker: 'no calendars' }),
131
+ ];
132
+ cals = cr.j.calendars || [];
133
+ }
87
134
  let booked = 0, showed = 0, noshow = 0, calsHit = 0, skippedCalendars = 0;
88
135
  // Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
89
136
  // ONLY the independent per-calendar fetches are parallelized — pagination pages stay sequential.
@@ -152,7 +199,7 @@ export async function collect(args, ctx) {
152
199
  const when = t.createdAt || t.created_at || t.dateAdded;
153
200
  const ok = (t.status || t.paymentStatus || '').toLowerCase();
154
201
  if (inWindow(when) && (ok === 'succeeded' || ok === 'success' || ok === 'paid' || ok === 'completed' || ok === 'captured')) {
155
- const cur = (t.currency || 'PHP').toUpperCase();
202
+ const cur = (t.currency || locationCurrency).toUpperCase();
156
203
  byCur[cur] = byCur[cur] || { sum: 0, n: 0 };
157
204
  byCur[cur].sum += Number(t.amount) || 0;
158
205
  byCur[cur].n++;
@@ -163,7 +210,7 @@ export async function collect(args, ctx) {
163
210
  // If only one currency, match original format; multi-currency → list all
164
211
  const entries = Object.entries(byCur);
165
212
  if (entries.length === 0)
166
- return metric('Collected', money(0, 'PHP'), { note: `0 payment(s) · ${totalScanned} txns scanned` });
213
+ return metric('Collected', money(0, locationCurrency), { note: `0 payment(s) · ${totalScanned} txns scanned` });
167
214
  if (entries.length === 1) {
168
215
  const [cur, { sum, n }] = entries[0];
169
216
  return metric('Collected', money(sum, cur), { note: `${n} payment(s) · ${totalScanned} txns scanned` });
@@ -195,7 +242,7 @@ export async function collect(args, ctx) {
195
242
  sum += Number(o.monetaryValue || o.monetary_value || 0) || 0;
196
243
  n++;
197
244
  }
198
- return metric('Pipeline value', money(sum), { note: `${n} open deal(s)` });
245
+ return metric('Pipeline value', money(sum, locationCurrency), { note: `${n} open deal(s)` });
199
246
  }
200
247
 
201
248
  // ── REPLY RATE ──
@@ -223,6 +270,7 @@ export async function collect(args, ctx) {
223
270
  location: LOC,
224
271
  window: { days: DAYS, startISO, endISO: new Date(NOW).toISOString() },
225
272
  metrics: rows,
273
+ ...(modelMeta ? { modelMeta } : {}),
226
274
  };
227
275
  }
228
276
 
@@ -239,6 +287,16 @@ export async function run(args, ctx) {
239
287
  const line = (l, v) => '│ ' + l.padEnd(16) + ' ' + String(v).padEnd(W - 20) + '│';
240
288
  ctx.out.line('┌' + '─'.repeat(W - 2) + '┐');
241
289
  ctx.out.line(line('SNAPSHOT', winLabel.slice(0, W - 20)));
290
+ // C2: model staleness note
291
+ if (data.modelMeta) {
292
+ const mm = data.modelMeta;
293
+ if (mm.offline) {
294
+ ctx.out.line(line('· model', 'OFFLINE — calendar list from cache'));
295
+ } else if (mm.stale) {
296
+ const ageD = Math.round(mm.ageMs / 86400000);
297
+ ctx.out.line(line('· model', `${ageD}d old — run sizmo sync`));
298
+ }
299
+ }
242
300
  ctx.out.line('├' + '─'.repeat(W - 2) + '┤');
243
301
  for (const m of data.metrics) {
244
302
  if (m.blocked) {
@@ -0,0 +1,84 @@
1
+ // commands/sync.mjs — force-refresh the local CRM model.
2
+ // Fetches all 6 structure entities (or a subset) and stores the blob.
3
+ // READ-ONLY. Only writes to the local model file; never writes to GoHighLevel.
4
+ import { syncModel, ENTITY_SPECS, DEFAULT_MODEL_DIR } from '../lib/model.mjs';
5
+
6
+ export const meta = {
7
+ name: 'sync',
8
+ summary: 'Refresh the local CRM model (pipelines, calendars, tags, fields, users, location)',
9
+ flags: [],
10
+ readOnly: true,
11
+ };
12
+
13
+ export async function run(args, ctx) {
14
+ const dir = ctx._modelDir ?? DEFAULT_MODEL_DIR;
15
+ const loc = ctx.cfg.loc;
16
+ const now = typeof ctx.now === 'function' ? ctx.now : () => ctx.now;
17
+
18
+ // Optional: sync only one entity — `sizmo sync tags`
19
+ const entityArg = args._?.[0];
20
+ const validNames = ENTITY_SPECS.map(s => s.name);
21
+ // Also accept 'fields' as alias for 'customFields'
22
+ const alias = { fields: 'customFields' };
23
+ const resolvedArg = entityArg ? (alias[entityArg] ?? entityArg) : null;
24
+ const only = resolvedArg ? [resolvedArg] : null;
25
+
26
+ if (resolvedArg && !validNames.includes(resolvedArg)) {
27
+ ctx.out.warn(`unknown entity "${entityArg}" — valid: ${validNames.join(', ')}`);
28
+ return 1;
29
+ }
30
+
31
+ let model;
32
+ try {
33
+ model = await syncModel({ http: ctx.http, loc, dir, now, only });
34
+ } catch (e) {
35
+ if (e.offline) {
36
+ ctx.out.warn("⚠ OFFLINE — can't reach GoHighLevel — check your connection; run `sizmo sync` when online");
37
+ return 1;
38
+ }
39
+ // Write failure (disk full, permissions, etc.)
40
+ ctx.out.warn(`sync failed — model not written: ${e.message}`);
41
+ return 1;
42
+ }
43
+
44
+ // Count results
45
+ let synced = 0, blocked = 0, networkErrors = 0;
46
+ for (const [, ent] of Object.entries(model.entities)) {
47
+ if (ent.networkError) networkErrors++;
48
+ else if (ent.blocked) blocked++;
49
+ else synced++;
50
+ }
51
+
52
+ ctx.out.data({ synced, blocked, networkErrors: networkErrors || undefined, offline: model.offline || undefined,
53
+ locationId: loc, syncedAt: model.syncedAt, entities: Object.fromEntries(
54
+ Object.entries(model.entities).map(([name, ent]) => [
55
+ name,
56
+ ent.networkError
57
+ ? { networkError: true, error: ent.error }
58
+ : ent.blocked
59
+ ? { blocked: true, scope: ent.scope }
60
+ : { fetchedAt: ent.fetchedAt, count: ent.items ? ent.items.length : (ent.item ? 1 : 0) },
61
+ ])
62
+ )});
63
+
64
+ ctx.out.card(() => {
65
+ const blockedNote = blocked > 0 ? ` (${blocked} scope-blocked)` : '';
66
+ const netNote = networkErrors > 0 ? ` (${networkErrors} network-error — check connection)` : '';
67
+ ctx.out.line(`synced ${synced} of ${synced + blocked + networkErrors} entities${blockedNote}${netNote} · loc ${loc}`);
68
+ for (const [name, ent] of Object.entries(model.entities)) {
69
+ if (ent.networkError) {
70
+ ctx.out.line(` ⚠ ${name.padEnd(14)} couldn't reach GHL`);
71
+ } else if (ent.blocked) {
72
+ ctx.out.line(` ✖ ${name.padEnd(14)} needs ${ent.scope}`);
73
+ } else {
74
+ const count = ent.items ? ent.items.length : (ent.item ? 1 : 0);
75
+ ctx.out.line(` ✔ ${name.padEnd(14)} ${count} item(s)`);
76
+ }
77
+ }
78
+ if (model.offline) {
79
+ ctx.out.line(' ⚠ some entities could not be reached — model may be partially stale');
80
+ }
81
+ });
82
+
83
+ return 0;
84
+ }
@@ -0,0 +1,105 @@
1
+ # CRM model — how it works
2
+
3
+ `sizmo` keeps a local copy of your CRM's structure — pipelines + stages, calendars, tags, custom fields, users, and location info. Recipes read from this cache instead of re-fetching on every run.
4
+
5
+ ## What is cached
6
+
7
+ | Entity | Endpoint | TTL | Key fields |
8
+ |--------|----------|-----|-----------|
9
+ | pipelines + stages | `GET /opportunities/pipelines` | 24h | pipeline `{id,name}`, stages `[{id,name,position}]` |
10
+ | calendars | `GET /calendars/` | 24h | `{id,name,calendarType,isActive}` |
11
+ | tags | `GET /locations/{loc}/tags` | 12h | `{id,name}` |
12
+ | customFields | `GET /locations/{loc}/customFields` | 12h | `{id,name,fieldKey,dataType}` |
13
+ | users | `GET /users/` | 24h | `{id,firstName,lastName,email}` |
14
+ | location | `GET /locations/{loc}` | 24h | `{name,timezone,currency,country}` |
15
+
16
+ Stored at `~/.config/sizmo/model/<locationId>.json`. Written atomically (temp + rename), permissions 0600.
17
+
18
+ ## First run
19
+
20
+ The model is synced automatically the first time a command needs it. Nothing to do.
21
+
22
+ ## Keeping the model fresh
23
+
24
+ Run `sizmo sync` after:
25
+ - Adding or renaming a pipeline stage
26
+ - Adding or removing a calendar
27
+ - Adding custom fields or tags
28
+ - Onboarding a new user
29
+
30
+ ```sh
31
+ sizmo sync # full refresh (6 single-page calls, rate-safe)
32
+ sizmo sync tags # refresh one entity
33
+ sizmo sync customFields
34
+ ```
35
+
36
+ Valid entity names: `pipelines`, `calendars`, `tags`, `customFields` (or `fields`), `users`, `location`.
37
+
38
+ ## Querying the model
39
+
40
+ ```sh
41
+ sizmo crm # overview: count + age per entity
42
+ sizmo crm pipelines # list pipelines + stages
43
+ sizmo crm calendars # list calendars
44
+ sizmo crm tags # list tags (first 20)
45
+ sizmo crm tags --all # list all tags
46
+ sizmo crm fields # list custom fields
47
+ sizmo crm users # list users
48
+ sizmo crm location # timezone / currency / country
49
+
50
+ # Machine output:
51
+ sizmo crm pipelines --json # includes _meta with source/syncedAt/ageMs/stale
52
+ ```
53
+
54
+ ## Honest staleness
55
+
56
+ Every model read shows the age of the data. Stale means the entity has passed its TTL (24h or 12h).
57
+
58
+ - Fresh (under TTL): no special marker
59
+ - Stale (past TTL): `⚠ STALE — run sizmo sync` banner; served anyway
60
+ - Blocked (scope missing): `✖ needs <scope>`; other entities still served
61
+
62
+ The CLI **never** auto-syncs mid-recipe when stale — it serves the cache and warns. This avoids surprise network calls and rate-limit hits. Use `sizmo sync` to force a refresh.
63
+
64
+ The `--json` output includes `_meta` so agents can branch without parsing prose:
65
+
66
+ ```json
67
+ {
68
+ "_meta": {
69
+ "source": "cache",
70
+ "syncedAt": 1718000000000,
71
+ "ageMs": 3600000,
72
+ "stale": false,
73
+ "offline": false
74
+ }
75
+ }
76
+ ```
77
+
78
+ ## Name resolution
79
+
80
+ Recipes use names (not IDs) in output when the model is available. If an ID is not found in the model (renamed stage, deleted calendar), the output shows:
81
+
82
+ ```
83
+ <unknown:<id> — run sizmo sync>
84
+ ```
85
+
86
+ This is the resolver's miss path. It never fabricates a name. Run `sizmo sync` to refresh the model after structural changes.
87
+
88
+ ## Partial sync (missing scopes)
89
+
90
+ A 401 or 403 on one entity marks it blocked and stores what succeeded. `sizmo crm` shows blocked entities with `✖ needs <scope>`. Other entities are unaffected.
91
+
92
+ Required scopes for a full sync:
93
+
94
+ ```
95
+ opportunities.readonly
96
+ calendars.readonly
97
+ locations/tags.readonly
98
+ locations/customFields.readonly
99
+ users.readonly
100
+ locations.readonly
101
+ ```
102
+
103
+ ## Schema versioning
104
+
105
+ The model blob has a `schemaVersion` field. A format change bumps this version and invalidates the cached file (treated as missing, triggering a re-sync on next use).
package/lib/context.mjs CHANGED
@@ -3,6 +3,8 @@ import { makeHttp } from './http.mjs';
3
3
  import { makeOut } from './output.mjs';
4
4
  import { makeCache } from './cache.mjs';
5
5
  import { GhlError, EXIT } from './errors.mjs';
6
+ import { loadModel, syncModel, DEFAULT_MODEL_DIR } from './model.mjs';
7
+ import { makeResolver } from './resolver.mjs';
6
8
  import { homedir } from 'node:os';
7
9
  import { join } from 'node:path';
8
10
  import { env as processEnv } from 'node:process';
@@ -29,5 +31,38 @@ export function buildCtx({ creds, globals, now = Date.now(), httpFactory = makeH
29
31
  return r;
30
32
  },
31
33
  };
32
- return { http, cfg: creds, out, now };
34
+
35
+ // CRM model — lazy, loaded once per ctx. Auto-syncs if missing.
36
+ // Recipes read ctx.model (the raw blob) and ctx.resolve (the resolver).
37
+ // We expose a lazy getter so commands that don't need the model pay nothing.
38
+ let _model = undefined; // undefined = not yet loaded; null = load attempted, none found + sync ran
39
+ let _resolver = undefined;
40
+
41
+ async function ensureModel() {
42
+ if (_model !== undefined) return _model;
43
+ _model = loadModel(creds.loc, DEFAULT_MODEL_DIR);
44
+ if (!_model) {
45
+ // Auto-sync on first use (model missing)
46
+ try {
47
+ _model = await syncModel({ http: rawHttp, loc: creds.loc, now: () => now });
48
+ } catch {
49
+ _model = null;
50
+ }
51
+ }
52
+ _resolver = makeResolver(_model, { now: () => now });
53
+ return _model;
54
+ }
55
+
56
+ return {
57
+ http,
58
+ cfg: creds,
59
+ out,
60
+ now,
61
+ // Expose model access for recipes
62
+ get model() { return _model; },
63
+ // ensureModel() → async lazy-loads + returns model
64
+ ensureModel,
65
+ // resolve(kind, id) → sync resolver call (after ensureModel was awaited)
66
+ get resolve() { return _resolver; },
67
+ };
33
68
  }
package/lib/model.mjs ADDED
@@ -0,0 +1,213 @@
1
+ // lib/model.mjs — per-profile CRM structure store + sync.
2
+ // Caches 6 slow-changing GHL entities (pipelines, calendars, tags, customFields, users, location)
3
+ // in a single JSON blob per location at ~/.config/sizmo/model/<loc>.json.
4
+ // Atomic write (temp+rename, same-dir to avoid EXDEV), 0600, per-entity age tracked, partial-sync-safe.
5
+ // READ-ONLY. No writes to GoHighLevel.
6
+ import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { env as processEnv } from 'node:process';
10
+ import { mapLimit } from './pool.mjs';
11
+
12
+ const XDG = processEnv.XDG_CONFIG_HOME || join(homedir(), '.config');
13
+ export const DEFAULT_MODEL_DIR = join(XDG, 'sizmo', 'model');
14
+ export const SCHEMA_VERSION = 1;
15
+
16
+ // TTLs: pipelines/calendars/users/location 24h; tags/customFields 12h
17
+ const H24 = 24 * 60 * 60 * 1000;
18
+ const H12 = 12 * 60 * 60 * 1000;
19
+
20
+ // Entity specs — each describes how to fetch + parse one CRM entity.
21
+ // buildPath(loc) → the API path segment; version → GHL API Version header;
22
+ // scope → human-readable scope name for blocked messages;
23
+ // ttlMs → staleness threshold; extract(json) → items[] or item{}.
24
+ export const ENTITY_SPECS = [
25
+ {
26
+ name: 'pipelines',
27
+ buildPath: (loc) => `/opportunities/pipelines?locationId=${loc}`,
28
+ version: '2021-07-28',
29
+ scope: 'opportunities.readonly',
30
+ ttlMs: H24,
31
+ extract: (j) => ({ items: j?.pipelines ?? [] }),
32
+ },
33
+ {
34
+ name: 'calendars',
35
+ buildPath: (loc) => `/calendars/?locationId=${loc}`,
36
+ version: '2021-04-15',
37
+ scope: 'calendars.readonly',
38
+ ttlMs: H24,
39
+ extract: (j) => ({ items: j?.calendars ?? [] }),
40
+ },
41
+ {
42
+ name: 'tags',
43
+ buildPath: (loc) => `/locations/${loc}/tags`,
44
+ version: '2021-07-28',
45
+ scope: 'locations/tags.readonly',
46
+ ttlMs: H12,
47
+ extract: (j) => ({ items: j?.tags ?? [] }),
48
+ },
49
+ {
50
+ name: 'customFields',
51
+ buildPath: (loc) => `/locations/${loc}/customFields?model=all`,
52
+ version: '2021-07-28',
53
+ scope: 'locations/customFields.readonly',
54
+ ttlMs: H12,
55
+ extract: (j) => ({ items: j?.customFields ?? [] }),
56
+ },
57
+ {
58
+ name: 'users',
59
+ buildPath: (loc) => `/users/?locationId=${loc}`,
60
+ version: '2021-07-28',
61
+ scope: 'users.readonly',
62
+ ttlMs: H24,
63
+ extract: (j) => ({ items: j?.users ?? [] }),
64
+ },
65
+ {
66
+ name: 'location',
67
+ buildPath: (loc) => `/locations/${loc}`,
68
+ version: '2021-07-28',
69
+ scope: 'locations.readonly',
70
+ ttlMs: H24,
71
+ extract: (j) => ({ item: j?.location ?? {} }),
72
+ },
73
+ ];
74
+
75
+ /**
76
+ * syncModel — fetch (up to) 6 entities and write the blob atomically.
77
+ * @param {object} opts
78
+ * @param {object} opts.http ctx.http (get returns {code,ok,j})
79
+ * @param {string} opts.loc locationId
80
+ * @param {string} [opts.dir] override default model dir (for tests)
81
+ * @param {Function} [opts.now] injectable clock () => ms
82
+ * @param {string[]} [opts.only] subset of entity names (partial sync; for `sync <entity>`)
83
+ * @returns {object} the written model blob, with an `offline` boolean property:
84
+ * true = at least one entity hit a network/transport error (couldn't reach GHL at all).
85
+ * false = all entities either succeeded or were blocked by an HTTP 401/403 scope error.
86
+ * @throws {Error} if the model is missing AND all fetches hit network errors (cold+offline).
87
+ * Caller must show a real error — do NOT display a fresh-looking empty model in this case.
88
+ */
89
+ export async function syncModel({ http, loc, dir = DEFAULT_MODEL_DIR, now = Date.now, only = null } = {}) {
90
+ const specs = only ? ENTITY_SPECS.filter(s => only.includes(s.name)) : ENTITY_SPECS;
91
+ const syncedAt = now();
92
+
93
+ // Start from any existing model (keep entities not in this sync run)
94
+ const base = loadModel(loc, dir) || {};
95
+ const hadExistingModel = !!(base.entities && Object.keys(base.entities).length > 0);
96
+ const entities = base.entities ? { ...base.entities } : {};
97
+
98
+ let networkErrorCount = 0;
99
+
100
+ await mapLimit(specs, 5, async (spec) => {
101
+ const path = spec.buildPath(loc);
102
+ let r;
103
+ try {
104
+ r = await http.get(path, spec.version !== '2021-07-28' ? { version: spec.version } : undefined);
105
+ } catch (e) {
106
+ // Transport/network failure — couldn't reach GHL at all. Distinct from auth errors.
107
+ networkErrorCount++;
108
+ entities[spec.name] = { networkError: true, error: e?.message ?? 'network error', fetchedAt: now() };
109
+ return;
110
+ }
111
+ // http.get returned code:0 signals a network-level failure (no response from server)
112
+ if (r.code === 0) {
113
+ networkErrorCount++;
114
+ entities[spec.name] = { networkError: true, error: r.message ?? 'no response', fetchedAt: now() };
115
+ return;
116
+ }
117
+ if (r.code === 401 || r.code === 403) {
118
+ // Scope/auth blocked — this is NOT a network error. Clearly distinguished.
119
+ entities[spec.name] = { blocked: true, scope: spec.scope, fetchedAt: now() };
120
+ return;
121
+ }
122
+ if (!r.ok) {
123
+ // Other HTTP errors (5xx, 404, etc.) — mark blocked with code, NOT networkError.
124
+ entities[spec.name] = { blocked: true, scope: spec.scope, httpCode: r.code, fetchedAt: now() };
125
+ return;
126
+ }
127
+ const extracted = spec.extract(r.j);
128
+ entities[spec.name] = { fetchedAt: now(), ...extracted };
129
+ });
130
+
131
+ const offline = networkErrorCount > 0;
132
+
133
+ // Cold + offline: no existing model AND all/some fetches hit network errors.
134
+ // Do NOT write a fresh-looking empty blob. Throw so the caller shows a real error.
135
+ if (!hadExistingModel && offline) {
136
+ const err = new Error(
137
+ "can't reach GoHighLevel — check your connection; run `sizmo sync` when online"
138
+ );
139
+ err.offline = true;
140
+ err.networkErrorCount = networkErrorCount;
141
+ throw err;
142
+ }
143
+
144
+ const model = {
145
+ schemaVersion: SCHEMA_VERSION,
146
+ locationId: loc,
147
+ syncedAt,
148
+ entities,
149
+ offline,
150
+ };
151
+
152
+ writeModelAtomic(loc, model, dir);
153
+ return model;
154
+ }
155
+
156
+ /**
157
+ * loadModel — read the blob for a location. Returns null if missing, corrupt,
158
+ * or schemaVersion mismatch (caller must re-sync).
159
+ */
160
+ export function loadModel(loc, dir = DEFAULT_MODEL_DIR) {
161
+ const path = join(dir, `${loc}.json`);
162
+ try {
163
+ const raw = readFileSync(path, 'utf8');
164
+ const parsed = JSON.parse(raw);
165
+ if (!parsed || parsed.schemaVersion !== SCHEMA_VERSION) return null;
166
+ return parsed;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * isStale — true if the entity's fetchedAt is older than ttlMs relative to now.
174
+ * @param {object} entity model.entities[name]
175
+ * @param {number} nowMs current time in ms
176
+ * @param {number} ttlMs TTL for this entity type
177
+ */
178
+ export function isStale(entity, nowMs, ttlMs) {
179
+ if (!entity || entity.blocked || entity.networkError || typeof entity.fetchedAt !== 'number') return true;
180
+ return (nowMs - entity.fetchedAt) > ttlMs;
181
+ }
182
+
183
+ /**
184
+ * ageMs — ms since this entity was last fetched.
185
+ */
186
+ export function ageMs(entity, nowMs) {
187
+ if (!entity || typeof entity.fetchedAt !== 'number') return null;
188
+ return nowMs - entity.fetchedAt;
189
+ }
190
+
191
+ // ── internal ──────────────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * writeModelAtomic — write model to disk with a same-directory temp+rename pattern.
195
+ * Same-dir temp avoids EXDEV (cross-filesystem rename failure) that occurs when tmpdir()
196
+ * is on a different mount than the model dir. Throws on any write failure so callers can
197
+ * surface the error (M1 + M2 fix).
198
+ */
199
+ function writeModelAtomic(loc, model, dir) {
200
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
201
+ const dest = join(dir, `${loc}.json`);
202
+ // Same directory as dest — no cross-FS EXDEV risk.
203
+ const tmp = join(dir, `.${loc}.json.tmp.${process.pid}`);
204
+ try {
205
+ writeFileSync(tmp, JSON.stringify(model, null, 2), { mode: 0o600 });
206
+ renameSync(tmp, dest);
207
+ } catch (e) {
208
+ // Clean up orphaned temp file on failure; ignore cleanup error.
209
+ try { unlinkSync(tmp); } catch { /* ignore */ }
210
+ // Re-throw so the caller (sync command) can surface "sync failed — nothing written".
211
+ throw e;
212
+ }
213
+ }
package/lib/registry.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // lib/registry.mjs — name → lazy loader. All 10 commands run in-process (v0.3 importable-core).
1
+ // lib/registry.mjs — name → lazy loader. All 12 commands run in-process (v0.5 importable-core).
2
2
  export const registry = {
3
3
  snapshot: () => import('../commands/snapshot.mjs'),
4
4
  triage: () => import('../commands/triage.mjs'),
@@ -10,4 +10,6 @@ export const registry = {
10
10
  'booked-not-paid': () => import('../commands/booked-not-paid.mjs'),
11
11
  brief: () => import('../commands/brief.mjs'),
12
12
  focus: () => import('../commands/focus.mjs'),
13
+ crm: () => import('../commands/crm.mjs'),
14
+ sync: () => import('../commands/sync.mjs'),
13
15
  };