jowork 0.2.5 → 0.3.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.
Files changed (37) hide show
  1. package/dist/{chunk-ROIINI33.js → chunk-4PIT2GZ4.js} +13 -1
  2. package/dist/{chunk-XLYRHKG6.js → chunk-54SD5GBF.js} +1 -1
  3. package/dist/chunk-63AMINQC.js +156 -0
  4. package/dist/{chunk-XAEGXSEO.js → chunk-74AHY7X6.js} +4 -0
  5. package/dist/{chunk-7U3SXINY.js → chunk-ATAUWJYD.js} +320 -50
  6. package/dist/chunk-DQW74UCN.js +671 -0
  7. package/dist/chunk-EYP6WMFF.js +153 -0
  8. package/dist/{chunk-JSTXMDXI.js → chunk-FCFZCZHR.js} +1 -1
  9. package/dist/chunk-FX6Z3QHV.js +34 -0
  10. package/dist/chunk-HENAABEL.js +419 -0
  11. package/dist/chunk-OXWWOKC7.js +201 -0
  12. package/dist/{chunk-HUHDL7WV.js → chunk-QGHJ45PL.js} +276 -199
  13. package/dist/chunk-RO3KK5RC.js +132 -0
  14. package/dist/{chunk-JE6TOU7W.js → chunk-TFMF3EXE.js} +2 -7
  15. package/dist/{chunk-TN327MDF.js → chunk-VX662YLA.js} +3 -3
  16. package/dist/cli.js +308 -135
  17. package/dist/{config-AI6UIJJN.js → config-FH2XLN7A.js} +2 -2
  18. package/dist/content-reader-VPGTR2SF.js +10 -0
  19. package/dist/context-ZNI3WOB7.js +10 -0
  20. package/dist/{credential-store-ZRZCSRPC.js → credential-store-OS5ZY4OW.js} +2 -2
  21. package/dist/{feishu-A6YVFKEN.js → feishu-XW5T6ER2.js} +8 -3
  22. package/dist/{git-manager-N35XSG4Y.js → git-manager-RVWV2GSV.js} +2 -1
  23. package/dist/github-PQKAYTLO.js +11 -0
  24. package/dist/{paths-JXOMBYIT.js → paths-FFRET6F7.js} +7 -3
  25. package/dist/{server-5GVWN2NB.js → server-WEADPUST.js} +59 -66
  26. package/dist/{setup-SYBQIL2O.js → setup-S2S2CHB2.js} +76 -30
  27. package/dist/sync-SRLFR5NA.js +21 -0
  28. package/dist/transport.js +6 -4
  29. package/package.json +1 -1
  30. package/src/dashboard/public/app.js +34 -8
  31. package/src/dashboard/public/style.css +14 -0
  32. package/dist/chunk-L5ZR7TSK.js +0 -82
  33. package/dist/chunk-LS2AJM5A.js +0 -163
  34. package/dist/chunk-QMOFQX7X.js +0 -612
  35. package/dist/chunk-YJWTKFWX.js +0 -451
  36. package/dist/github-SHWUFNYB.js +0 -10
  37. package/dist/sync-KDSPGY4A.js +0 -18
@@ -2,40 +2,42 @@ import {
2
2
  linkAllUnprocessed,
3
3
  syncGitLab,
4
4
  syncLinear
5
- } from "./chunk-YJWTKFWX.js";
5
+ } from "./chunk-HENAABEL.js";
6
+ import {
7
+ syncGitHub
8
+ } from "./chunk-63AMINQC.js";
9
+ import {
10
+ GitManager
11
+ } from "./chunk-EYP6WMFF.js";
6
12
  import {
7
13
  DbManager
8
- } from "./chunk-XAEGXSEO.js";
14
+ } from "./chunk-74AHY7X6.js";
9
15
  import {
10
16
  listCredentials,
11
17
  loadCredential
12
- } from "./chunk-XLYRHKG6.js";
18
+ } from "./chunk-54SD5GBF.js";
13
19
  import {
14
20
  dbPath,
15
21
  fileRepoDir
16
- } from "./chunk-ROIINI33.js";
22
+ } from "./chunk-4PIT2GZ4.js";
17
23
  import {
18
- syncGitHub
19
- } from "./chunk-LS2AJM5A.js";
20
- import {
21
- contentHash,
22
- formatAnalytics,
23
- formatMessages,
24
24
  syncFeishu,
25
25
  syncFeishuApprovals,
26
26
  syncFeishuDocs,
27
+ syncFeishuLinks,
27
28
  syncFeishuMeetings
28
- } from "./chunk-QMOFQX7X.js";
29
- import {
30
- createId
31
- } from "./chunk-JE6TOU7W.js";
29
+ } from "./chunk-DQW74UCN.js";
32
30
  import {
33
- GitManager
34
- } from "./chunk-L5ZR7TSK.js";
31
+ formatAnalytics,
32
+ formatMessages
33
+ } from "./chunk-RO3KK5RC.js";
35
34
  import {
36
35
  logError,
37
36
  logInfo
38
37
  } from "./chunk-MYDK7MWB.js";
38
+ import {
39
+ SyncContext
40
+ } from "./chunk-OXWWOKC7.js";
39
41
 
40
42
  // src/commands/sync.ts
41
43
  import { existsSync as existsSync2 } from "fs";
@@ -46,7 +48,7 @@ var defaultLogger = {
46
48
  warn: (msg, ctx) => logError("sync", msg, ctx),
47
49
  error: (msg, ctx) => logError("sync", msg, ctx)
48
50
  };
49
- async function syncPostHog(sqlite, data, logger = defaultLogger, fileWriter) {
51
+ async function syncPostHog(ctx, data, logger = defaultLogger) {
50
52
  const { apiKey, host, projectId: rawProjectId } = data;
51
53
  if (!apiKey) throw new Error("Missing PostHog API key");
52
54
  const baseUrl = host || "https://app.posthog.com";
@@ -55,123 +57,139 @@ async function syncPostHog(sqlite, data, logger = defaultLogger, fileWriter) {
55
57
  Authorization: `Bearer ${apiKey}`,
56
58
  "Content-Type": "application/json"
57
59
  };
58
- let events = 0, insights = 0, newObjects = 0;
59
- const checkExists = sqlite.prepare("SELECT id FROM objects WHERE uri = ?");
60
- const insertObj = sqlite.prepare(`
61
- INSERT INTO objects (id, source, source_type, uri, title, summary, tags, content_hash, last_synced_at, created_at)
62
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
- `);
64
- const insertBody = sqlite.prepare(`
65
- INSERT OR REPLACE INTO object_bodies (object_id, content, content_type, fetched_at)
66
- VALUES (?, ?, ?, ?)
67
- `);
68
- const insertFts = sqlite.prepare(`
69
- INSERT INTO objects_fts(rowid, title, summary, tags, source, source_type, body_excerpt)
70
- VALUES (?, ?, ?, ?, ?, ?, ?)
71
- `);
72
- const getRowid = sqlite.prepare("SELECT rowid FROM objects WHERE id = ?");
60
+ let events = 0, insights = 0, newObjects = 0, updatedObjects = 0;
73
61
  try {
74
- const insightsRes = await fetch(`${baseUrl}/api/projects/${projectId}/insights/?limit=50`, { headers });
75
- if (insightsRes.ok) {
76
- const insightsData = await insightsRes.json();
77
- const batch = sqlite.transaction((items) => {
78
- for (const insight of items) {
79
- const uri = `posthog://insight/${insight.id}`;
80
- if (checkExists.get(uri)) continue;
81
- const now = Date.now();
82
- const id = createId("obj");
83
- const summary = insight.description || `Insight: ${insight.name}`;
84
- const tags = JSON.stringify(["posthog", "insight", ...Object.keys(insight.filters).slice(0, 3)]);
85
- const body = JSON.stringify({
86
- name: insight.name,
87
- description: insight.description,
88
- filters: insight.filters,
89
- lastRefresh: insight.last_refresh
90
- }, null, 2);
91
- insertObj.run(id, "posthog", "insight", uri, insight.name, summary, tags, contentHash(body), now, now);
92
- insertBody.run(id, body, "application/json", now);
93
- try {
94
- const rowid = getRowid.get(id);
95
- if (rowid) {
96
- const excerpt = body.length > 500 ? body.slice(0, 500) : body;
97
- insertFts.run(rowid.rowid, insight.name ?? "", summary ?? "", tags, "posthog", "insight", excerpt);
98
- }
99
- } catch {
100
- }
101
- if (fileWriter) {
102
- try {
103
- const fileContent = formatAnalytics({
104
- name: insight.name,
105
- description: insight.description,
106
- filters: insight.filters,
107
- lastRefresh: insight.last_refresh
108
- });
109
- const filePath = fileWriter.writeObject("posthog", "insight", {
110
- id,
111
- title: insight.name
112
- }, fileContent);
113
- sqlite.prepare("UPDATE objects SET file_path = ? WHERE id = ?").run(filePath, id);
114
- } catch {
115
- }
116
- }
117
- insights++;
118
- newObjects++;
119
- }
120
- });
121
- batch(insightsData.results ?? []);
62
+ let insightsUrl = `${baseUrl}/api/projects/${projectId}/insights/?limit=100`;
63
+ const allInsights = [];
64
+ while (insightsUrl) {
65
+ const insightsRes = await fetch(insightsUrl, { headers });
66
+ if (!insightsRes.ok) {
67
+ logger.warn(`Failed to fetch insights: ${insightsRes.status}`);
68
+ break;
69
+ }
70
+ const page = await insightsRes.json();
71
+ allInsights.push(...page.results ?? []);
72
+ insightsUrl = page.next;
73
+ if (insightsUrl) await new Promise((r) => setTimeout(r, 200));
74
+ }
75
+ if (allInsights.length > 0) {
76
+ const items = [];
77
+ for (const insight of allInsights) {
78
+ const uri = `posthog://insight/${insight.id}`;
79
+ const summary = insight.description || `Insight: ${insight.name}`;
80
+ const body = JSON.stringify({
81
+ name: insight.name,
82
+ description: insight.description,
83
+ filters: insight.filters,
84
+ lastRefresh: insight.last_refresh
85
+ }, null, 2);
86
+ const fileContent = formatAnalytics({
87
+ name: insight.name,
88
+ description: insight.description,
89
+ filters: insight.filters,
90
+ lastRefresh: insight.last_refresh
91
+ });
92
+ items.push({
93
+ source: "posthog",
94
+ sourceType: "insight",
95
+ uri,
96
+ title: insight.name,
97
+ summary,
98
+ tags: ["posthog", "insight", ...Object.keys(insight.filters).slice(0, 3)],
99
+ content: body,
100
+ contentType: "application/json",
101
+ fileContent,
102
+ fileMeta: { title: insight.name }
103
+ });
104
+ }
105
+ const result = ctx.batchUpsert(items);
106
+ newObjects += result.inserted;
107
+ updatedObjects += result.updated;
108
+ insights += items.length;
122
109
  logger.info(`Synced ${insights} insights`);
123
- } else {
124
- logger.warn(`Failed to fetch insights: ${insightsRes.status}`);
125
110
  }
126
111
  } catch (err) {
127
112
  logger.error(`Insights sync error: ${err}`);
128
113
  }
129
114
  try {
130
- const eventsRes = await fetch(`${baseUrl}/api/projects/${projectId}/event_definitions/?limit=100`, { headers });
131
- if (eventsRes.ok) {
132
- const eventsData = await eventsRes.json();
133
- const batch = sqlite.transaction((items) => {
134
- for (const event of items) {
135
- const uri = `posthog://event/${event.name}`;
136
- if (checkExists.get(uri)) continue;
137
- const now = Date.now();
138
- const id = createId("obj");
139
- const summary = `${event.name}: ${event.description ?? "no description"} (30d volume: ${event.volume_30_day ?? "N/A"})`;
140
- const tags = JSON.stringify(["posthog", "event_definition"]);
141
- const body = JSON.stringify(event, null, 2);
142
- insertObj.run(id, "posthog", "event_definition", uri, event.name, summary, tags, contentHash(body), now, now);
143
- insertBody.run(id, body, "application/json", now);
144
- try {
145
- const rowid = getRowid.get(id);
146
- if (rowid) {
147
- const excerpt = body.length > 500 ? body.slice(0, 500) : body;
148
- insertFts.run(rowid.rowid, event.name ?? "", summary ?? "", tags, "posthog", "event_definition", excerpt);
149
- }
150
- } catch {
151
- }
152
- if (fileWriter) {
153
- try {
154
- const fileContent = formatAnalytics(event);
155
- const filePath = fileWriter.writeObject("posthog", "event_definition", {
156
- id,
157
- title: event.name
158
- }, fileContent);
159
- sqlite.prepare("UPDATE objects SET file_path = ? WHERE id = ?").run(filePath, id);
160
- } catch {
161
- }
162
- }
163
- events++;
164
- newObjects++;
165
- }
166
- });
167
- batch(eventsData.results ?? []);
115
+ let eventsUrl = `${baseUrl}/api/projects/${projectId}/event_definitions/?limit=100`;
116
+ const allEvents = [];
117
+ while (eventsUrl) {
118
+ const eventsRes = await fetch(eventsUrl, { headers });
119
+ if (!eventsRes.ok) break;
120
+ const page = await eventsRes.json();
121
+ allEvents.push(...page.results ?? []);
122
+ eventsUrl = page.next;
123
+ if (eventsUrl) await new Promise((r) => setTimeout(r, 200));
124
+ }
125
+ if (allEvents.length > 0) {
126
+ const items = [];
127
+ for (const event of allEvents) {
128
+ const uri = `posthog://event/${event.name}`;
129
+ const summary = `${event.name}: ${event.description ?? "no description"} (30d volume: ${event.volume_30_day ?? "N/A"})`;
130
+ const body = JSON.stringify(event, null, 2);
131
+ const fileContent = formatAnalytics(event);
132
+ items.push({
133
+ source: "posthog",
134
+ sourceType: "event_definition",
135
+ uri,
136
+ title: event.name,
137
+ summary,
138
+ tags: ["posthog", "event_definition"],
139
+ content: body,
140
+ contentType: "application/json",
141
+ fileContent,
142
+ fileMeta: { title: event.name }
143
+ });
144
+ }
145
+ const result = ctx.batchUpsert(items);
146
+ newObjects += result.inserted;
147
+ updatedObjects += result.updated;
148
+ events += items.length;
168
149
  logger.info(`Synced ${events} event definitions`);
169
150
  }
170
151
  } catch (err) {
171
152
  logger.error(`Events sync error: ${err}`);
172
153
  }
173
- logger.info("PostHog sync complete", { events, insights, newObjects });
174
- return { events, insights, newObjects };
154
+ try {
155
+ let actionsUrl = `${baseUrl}/api/projects/${projectId}/actions/?limit=100`;
156
+ while (actionsUrl) {
157
+ const actionsRes = await fetch(actionsUrl, { headers });
158
+ if (!actionsRes.ok) break;
159
+ const page = await actionsRes.json();
160
+ const items = [];
161
+ for (const action of page.results ?? []) {
162
+ const uri = `posthog://action/${action.id}`;
163
+ const summary = action.description || `Action: ${action.name}`;
164
+ const body = JSON.stringify(action, null, 2);
165
+ const fileContent = formatAnalytics(action);
166
+ items.push({
167
+ source: "posthog",
168
+ sourceType: "action",
169
+ uri,
170
+ title: action.name,
171
+ summary,
172
+ tags: ["posthog", "action"],
173
+ content: body,
174
+ contentType: "application/json",
175
+ createdAt: new Date(action.created_at).getTime(),
176
+ fileContent,
177
+ fileMeta: { title: action.name }
178
+ });
179
+ }
180
+ const result = ctx.batchUpsert(items);
181
+ newObjects += result.inserted;
182
+ updatedObjects += result.updated;
183
+ actionsUrl = page.next;
184
+ if (actionsUrl) await new Promise((r) => setTimeout(r, 200));
185
+ }
186
+ } catch (err) {
187
+ logger.warn(`Actions sync error (non-fatal): ${err}`);
188
+ }
189
+ ctx.saveTimestampCursor("posthog:insights");
190
+ ctx.saveTimestampCursor("posthog:events");
191
+ logger.info("PostHog sync complete", { events, insights, newObjects, updatedObjects });
192
+ return { events, insights, newObjects, updatedObjects };
175
193
  }
176
194
 
177
195
  // src/sync/firebase.ts
@@ -180,24 +198,10 @@ var defaultLogger2 = {
180
198
  warn: (msg, ctx) => logError("sync", msg, ctx),
181
199
  error: (msg, ctx) => logError("sync", msg, ctx)
182
200
  };
183
- async function syncFirebase(sqlite, data, logger = defaultLogger2, fileWriter) {
201
+ async function syncFirebase(ctx, data, logger = defaultLogger2) {
184
202
  const { projectId, apiKey } = data;
185
203
  if (!projectId) throw new Error("Missing Firebase project ID");
186
- let events = 0, newObjects = 0;
187
- const checkExists = sqlite.prepare("SELECT id FROM objects WHERE uri = ?");
188
- const insertObj = sqlite.prepare(`
189
- INSERT INTO objects (id, source, source_type, uri, title, summary, tags, content_hash, last_synced_at, created_at)
190
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
191
- `);
192
- const insertBody = sqlite.prepare(`
193
- INSERT OR REPLACE INTO object_bodies (object_id, content, content_type, fetched_at)
194
- VALUES (?, ?, ?, ?)
195
- `);
196
- const insertFts = sqlite.prepare(`
197
- INSERT INTO objects_fts(rowid, title, summary, tags, source, source_type, body_excerpt)
198
- VALUES (?, ?, ?, ?, ?, ?, ?)
199
- `);
200
- const getRowid = sqlite.prepare("SELECT rowid FROM objects WHERE id = ?");
204
+ let events = 0, newObjects = 0, updatedObjects = 0;
201
205
  if (apiKey) {
202
206
  try {
203
207
  const propertyId = data.propertyId ?? projectId;
@@ -207,51 +211,41 @@ async function syncFirebase(sqlite, data, logger = defaultLogger2, fileWriter) {
207
211
  method: "POST",
208
212
  headers: { "Content-Type": "application/json" },
209
213
  body: JSON.stringify({
210
- dateRanges: [{ startDate: "7daysAgo", endDate: "today" }],
214
+ dateRanges: [{ startDate: "30daysAgo", endDate: "today" }],
211
215
  dimensions: [{ name: "eventName" }],
212
- metrics: [{ name: "eventCount" }],
213
- limit: 50
216
+ metrics: [{ name: "eventCount" }, { name: "totalUsers" }],
217
+ limit: 500
214
218
  })
215
219
  }
216
220
  );
217
221
  if (res.ok) {
218
222
  const report = await res.json();
219
- const batch = sqlite.transaction((rows) => {
220
- for (const row of rows) {
221
- const eventName = row.dimensionValues[0]?.value ?? "unknown";
222
- const eventCount = parseInt(row.metricValues[0]?.value ?? "0");
223
- const uri = `firebase://${projectId}/event/${eventName}`;
224
- if (checkExists.get(uri)) continue;
225
- const nowMs = Date.now();
226
- const id = createId("obj");
227
- const summary = `${eventName}: ${eventCount} events (last 7 days)`;
228
- const tags = JSON.stringify(["firebase", "analytics", "event"]);
229
- const body = JSON.stringify({ eventName, eventCount, period: "7daysAgo..today" }, null, 2);
230
- insertObj.run(id, "firebase", "analytics_event", uri, eventName, summary, tags, contentHash(body), nowMs, nowMs);
231
- insertBody.run(id, body, "application/json", nowMs);
232
- try {
233
- const rowid = getRowid.get(id);
234
- if (rowid) {
235
- insertFts.run(rowid.rowid, eventName ?? "", summary ?? "", tags, "firebase", "analytics_event", body.length > 500 ? body.slice(0, 500) : body);
236
- }
237
- } catch {
238
- }
239
- if (fileWriter) {
240
- try {
241
- const fileContent = formatAnalytics({ eventName, eventCount, period: "7daysAgo..today" });
242
- const filePath = fileWriter.writeObject("firebase", "analytics_event", {
243
- id,
244
- title: eventName
245
- }, fileContent);
246
- sqlite.prepare("UPDATE objects SET file_path = ? WHERE id = ?").run(filePath, id);
247
- } catch {
248
- }
249
- }
250
- events++;
251
- newObjects++;
252
- }
253
- });
254
- batch(report.rows ?? []);
223
+ const items = [];
224
+ for (const row of report.rows ?? []) {
225
+ const eventName = row.dimensionValues[0]?.value ?? "unknown";
226
+ const eventCount = parseInt(row.metricValues[0]?.value ?? "0");
227
+ const totalUsers = parseInt(row.metricValues[1]?.value ?? "0");
228
+ const uri = `firebase://${projectId}/event/${eventName}`;
229
+ const summary = `${eventName}: ${eventCount} events, ${totalUsers} users (last 30 days)`;
230
+ const body = JSON.stringify({ eventName, eventCount, totalUsers, period: "30daysAgo..today" }, null, 2);
231
+ const fileContent = formatAnalytics({ eventName, eventCount, totalUsers, period: "30daysAgo..today" });
232
+ items.push({
233
+ source: "firebase",
234
+ sourceType: "analytics_event",
235
+ uri,
236
+ title: eventName,
237
+ summary,
238
+ tags: ["firebase", "analytics", "event"],
239
+ content: body,
240
+ contentType: "application/json",
241
+ fileContent,
242
+ fileMeta: { title: eventName }
243
+ });
244
+ }
245
+ const result = ctx.batchUpsert(items);
246
+ newObjects += result.inserted;
247
+ updatedObjects += result.updated;
248
+ events += items.length;
255
249
  } else {
256
250
  logger.warn(`Firebase Analytics API: ${res.status}`);
257
251
  }
@@ -261,8 +255,9 @@ async function syncFirebase(sqlite, data, logger = defaultLogger2, fileWriter) {
261
255
  } else {
262
256
  logger.warn("Firebase sync requires apiKey. Provide via jowork connect firebase --api-key <key>");
263
257
  }
264
- logger.info("Firebase sync complete", { events, newObjects });
265
- return { events, newObjects };
258
+ ctx.saveTimestampCursor("firebase:analytics");
259
+ logger.info("Firebase sync complete", { events, newObjects, updatedObjects });
260
+ return { events, newObjects, updatedObjects };
266
261
  }
267
262
 
268
263
  // src/sync/file-writer.ts
@@ -354,6 +349,8 @@ ${sanitizeContent(m.content)}`).join("\n");
354
349
  return join("feishu", "approvals", `${slugify(meta.title ?? "approval")}-${meta.id}.md`);
355
350
  if (sourceType === "document")
356
351
  return join("feishu", "docs", `${slugify(meta.title ?? "doc")}.md`);
352
+ if (sourceType === "link")
353
+ return join("feishu", "links", `${slugify(meta.title ?? meta.id)}.md`);
357
354
  return join("feishu", "other", `${meta.id}.md`);
358
355
  }
359
356
  case "posthog":
@@ -438,6 +435,30 @@ function elapsed(start) {
438
435
  const ms = Date.now() - start;
439
436
  return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
440
437
  }
438
+ function getSyncErrorHint(source, error) {
439
+ const is401 = /401|unauthorized|auth.*fail/i.test(error);
440
+ const is403 = /403|forbidden/i.test(error);
441
+ if (is401 || is403) {
442
+ switch (source) {
443
+ case "github":
444
+ return "Token invalid or expired. Create a new one at https://github.com/settings/tokens";
445
+ case "gitlab":
446
+ return "Token invalid. Check at https://gitlab.com/-/profile/personal_access_tokens";
447
+ case "feishu":
448
+ return "App ID/Secret invalid. Check at https://open.feishu.cn/app";
449
+ case "linear":
450
+ return "API key invalid. Get one at https://linear.app/settings/api";
451
+ case "posthog":
452
+ return "API key invalid. Get one at https://app.posthog.com/project/settings";
453
+ case "firebase":
454
+ return "API key invalid. Check at https://console.cloud.google.com \u2192 APIs & Services \u2192 Credentials";
455
+ }
456
+ }
457
+ if (/ENOTFOUND|ECONNREFUSED|network/i.test(error)) {
458
+ return "Network error. Check your internet connection and try again.";
459
+ }
460
+ return null;
461
+ }
441
462
  async function runSync(sources) {
442
463
  const db = new DbManager(dbPath());
443
464
  db.ensureTables();
@@ -470,63 +491,82 @@ async function runSync(sources) {
470
491
  try {
471
492
  switch (source) {
472
493
  case "feishu": {
473
- const result = await syncFeishu(db.getSqlite(), cred.data, logger, fileWriter);
494
+ const feishuCtx = new SyncContext(db.getSqlite(), logger, fileWriter);
495
+ const result = await syncFeishu(feishuCtx, cred.data, logger);
474
496
  resultLine(true, `${result.newMessages} new messages from ${result.chats} chats`);
475
497
  totalNew += result.newMessages;
476
498
  syncResults.push({ source: "feishu", newObjects: result.newMessages, label: "messages" });
477
499
  try {
478
- const mr = await syncFeishuMeetings(db.getSqlite(), cred.data, logger, fileWriter);
500
+ const mr = await syncFeishuMeetings(feishuCtx, cred.data, logger);
479
501
  if (mr.newObjects > 0) resultLine(true, `${mr.newObjects} calendar events`);
480
502
  syncResults.push({ source: "feishu/meetings", newObjects: mr.newObjects, label: "events" });
481
503
  } catch {
482
504
  }
483
505
  try {
484
- const dr = await syncFeishuDocs(db.getSqlite(), cred.data, logger, fileWriter);
506
+ const dr = await syncFeishuDocs(feishuCtx, cred.data, logger);
485
507
  if (dr.newObjects > 0) resultLine(true, `${dr.newObjects} documents`);
486
508
  syncResults.push({ source: "feishu/docs", newObjects: dr.newObjects, label: "docs" });
487
509
  } catch {
488
510
  }
489
511
  try {
490
- const ar = await syncFeishuApprovals(db.getSqlite(), cred.data, logger, fileWriter);
512
+ const ar = await syncFeishuApprovals(feishuCtx, cred.data, logger);
491
513
  if (ar.newObjects > 0) resultLine(true, `${ar.newObjects} approvals`);
492
514
  syncResults.push({ source: "feishu/approvals", newObjects: ar.newObjects, label: "approvals" });
493
515
  } catch {
494
516
  }
517
+ try {
518
+ const lr = await syncFeishuLinks(feishuCtx, cred.data, logger);
519
+ if (lr.fetched > 0) resultLine(true, `${lr.fetched} link contents fetched (${lr.extracted} URLs found)`);
520
+ else if (lr.extracted > 0) resultLine(false, `${lr.extracted} URLs found, ${lr.failed} failed to fetch`);
521
+ syncResults.push({ source: "feishu/links", newObjects: lr.fetched, label: "links" });
522
+ } catch {
523
+ }
495
524
  break;
496
525
  }
497
526
  case "github": {
498
- const r = await syncGitHub(db.getSqlite(), cred.data, logger, fileWriter);
499
- resultLine(true, `${r.repos} repos, ${r.prs} PRs, ${r.issues} issues ${c.dim}(${r.newObjects} new)${c.reset}`);
527
+ const ghCtx = new SyncContext(db.getSqlite(), logger, fileWriter);
528
+ const r = await syncGitHub(ghCtx, cred.data, logger);
529
+ const changeInfo = r.updatedObjects > 0 ? `, ${r.updatedObjects} updated` : "";
530
+ const cloneInfo = r.clonedRepos > 0 ? ` | ${r.clonedRepos} repos cloned` : "";
531
+ resultLine(true, `${r.repos} repos, ${r.prs} PRs, ${r.issues} issues ${c.dim}(${r.newObjects} new${changeInfo})${cloneInfo}${c.reset}`);
500
532
  totalNew += r.newObjects;
501
- syncResults.push({ source: "github", newObjects: r.newObjects });
533
+ syncResults.push({ source: "github", newObjects: r.newObjects + r.updatedObjects });
502
534
  break;
503
535
  }
504
536
  case "gitlab": {
505
- const r = await syncGitLab(db.getSqlite(), cred.data, logger, fileWriter);
506
- resultLine(true, `${r.projects} projects, ${r.mrs} MRs, ${r.issues} issues ${c.dim}(${r.newObjects} new)${c.reset}`);
537
+ const glCtx = new SyncContext(db.getSqlite(), logger, fileWriter);
538
+ const r = await syncGitLab(glCtx, cred.data, logger);
539
+ const glChangeInfo = r.updatedObjects > 0 ? `, ${r.updatedObjects} updated` : "";
540
+ resultLine(true, `${r.projects} projects, ${r.mrs} MRs, ${r.issues} issues ${c.dim}(${r.newObjects} new${glChangeInfo})${c.reset}`);
507
541
  totalNew += r.newObjects;
508
- syncResults.push({ source: "gitlab", newObjects: r.newObjects });
542
+ syncResults.push({ source: "gitlab", newObjects: r.newObjects + r.updatedObjects });
509
543
  break;
510
544
  }
511
545
  case "linear": {
512
- const r = await syncLinear(db.getSqlite(), cred.data, logger, fileWriter);
513
- resultLine(true, `${r.issues} issues ${c.dim}(${r.newObjects} new)${c.reset}`);
546
+ const lnCtx = new SyncContext(db.getSqlite(), logger, fileWriter);
547
+ const r = await syncLinear(lnCtx, cred.data, logger);
548
+ const lnChangeInfo = r.updatedObjects > 0 ? `, ${r.updatedObjects} updated` : "";
549
+ resultLine(true, `${r.issues} issues ${c.dim}(${r.newObjects} new${lnChangeInfo})${c.reset}`);
514
550
  totalNew += r.newObjects;
515
- syncResults.push({ source: "linear", newObjects: r.newObjects, label: "issues" });
551
+ syncResults.push({ source: "linear", newObjects: r.newObjects + r.updatedObjects, label: "issues" });
516
552
  break;
517
553
  }
518
554
  case "posthog": {
519
- const r = await syncPostHog(db.getSqlite(), cred.data, logger, fileWriter);
520
- resultLine(true, `${r.insights} insights, ${r.events} events ${c.dim}(${r.newObjects} new)${c.reset}`);
555
+ const phCtx = new SyncContext(db.getSqlite(), logger, fileWriter);
556
+ const r = await syncPostHog(phCtx, cred.data, logger);
557
+ const phChangeInfo = r.updatedObjects > 0 ? `, ${r.updatedObjects} updated` : "";
558
+ resultLine(true, `${r.insights} insights, ${r.events} events ${c.dim}(${r.newObjects} new${phChangeInfo})${c.reset}`);
521
559
  totalNew += r.newObjects;
522
- syncResults.push({ source: "posthog", newObjects: r.newObjects });
560
+ syncResults.push({ source: "posthog", newObjects: r.newObjects + r.updatedObjects });
523
561
  break;
524
562
  }
525
563
  case "firebase": {
526
- const r = await syncFirebase(db.getSqlite(), cred.data, logger, fileWriter);
527
- resultLine(true, `${r.events} events ${c.dim}(${r.newObjects} new)${c.reset}`);
564
+ const fbCtx = new SyncContext(db.getSqlite(), logger, fileWriter);
565
+ const r = await syncFirebase(fbCtx, cred.data, logger);
566
+ const fbChangeInfo = r.updatedObjects > 0 ? `, ${r.updatedObjects} updated` : "";
567
+ resultLine(true, `${r.events} events ${c.dim}(${r.newObjects} new${fbChangeInfo})${c.reset}`);
528
568
  totalNew += r.newObjects;
529
- syncResults.push({ source: "firebase", newObjects: r.newObjects, label: "events" });
569
+ syncResults.push({ source: "firebase", newObjects: r.newObjects + r.updatedObjects, label: "events" });
530
570
  break;
531
571
  }
532
572
  default:
@@ -535,7 +575,12 @@ async function runSync(sources) {
535
575
  console.log(` ${c.dim}${elapsed(sourceStart)}${c.reset}`);
536
576
  } catch (err) {
537
577
  logError("sync", `Failed to sync ${source}`, { error: String(err) });
538
- console.log(` ${icon.fail} ${c.red}sync failed${c.reset} ${c.dim}${String(err).slice(0, 60)}${c.reset}`);
578
+ const errStr = String(err);
579
+ console.log(` ${icon.fail} ${c.red}sync failed${c.reset} ${c.dim}${errStr.slice(0, 60)}${c.reset}`);
580
+ const hint = getSyncErrorHint(source, errStr);
581
+ if (hint) {
582
+ console.log(` ${c.yellow}Hint: ${hint}${c.reset}`);
583
+ }
539
584
  }
540
585
  if (sources.length > 1) {
541
586
  console.log(` ${progressBar(i + 1, sources.length)}`);
@@ -547,6 +592,38 @@ async function runSync(sources) {
547
592
  if (processed > 0) {
548
593
  resultLine(true, `${linksCreated} links from ${processed} objects`);
549
594
  }
595
+ try {
596
+ const sqlite = db.getSqlite();
597
+ const { readObjectContent } = await import("./content-reader-VPGTR2SF.js");
598
+ const noFilePath = sqlite.prepare(`
599
+ SELECT o.id, o.source, o.source_type, o.uri, o.title
600
+ FROM objects o
601
+ WHERE o.file_path IS NULL
602
+ LIMIT 500
603
+ `).all();
604
+ if (noFilePath.length > 0) {
605
+ console.log(` ${icon.sync} ${c.dim}backfilling ${noFilePath.length} objects without files...${c.reset}`);
606
+ let backfilled = 0;
607
+ for (const obj of noFilePath) {
608
+ try {
609
+ const content = readObjectContent(sqlite, obj.id, null);
610
+ if (!content) continue;
611
+ const filePath = fileWriter.writeObject(obj.source, obj.source_type, {
612
+ id: obj.id,
613
+ title: obj.title,
614
+ uri: obj.uri
615
+ }, content);
616
+ sqlite.prepare("UPDATE objects SET file_path = ? WHERE id = ?").run(filePath, obj.id);
617
+ backfilled++;
618
+ } catch {
619
+ }
620
+ }
621
+ if (backfilled > 0) {
622
+ resultLine(true, `${backfilled} files backfilled`);
623
+ }
624
+ }
625
+ } catch {
626
+ }
550
627
  db.close();
551
628
  if (gitManager) {
552
629
  try {