shared-things-daemon 1.1.0 → 2.0.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 (3) hide show
  1. package/README.md +12 -8
  2. package/dist/cli.js +782 -210
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -54,12 +54,14 @@ shared-things init
54
54
  ? Things auth token: ****
55
55
  ```
56
56
 
57
+ > **Note:** The Things project must be empty when you run `init`.
58
+
57
59
  > **Things auth token:** Required for syncing updates to existing todos. Find it in Things → Settings → General → Things URLs → Enable Things URLs.
58
60
 
59
61
  ### After Setup
60
62
 
61
63
  ```bash
62
- shared-things install # Start daemon (auto-runs on login)
64
+ shared-things start # Start daemon (auto-runs on login)
63
65
  shared-things status # Check sync status
64
66
  shared-things logs -f # Follow sync logs
65
67
  ```
@@ -69,13 +71,16 @@ shared-things logs -f # Follow sync logs
69
71
  | Command | Description |
70
72
  |---------|-------------|
71
73
  | `init` | Setup wizard |
72
- | `install` | Install launchd daemon (auto-starts on login) |
73
- | `uninstall` | Remove launchd daemon |
74
+ | `start` | Start launchd daemon (auto-starts on login) |
75
+ | `stop` | Stop launchd daemon |
74
76
  | `status` | Show sync status & last sync time |
75
77
  | `sync` | Force immediate sync |
76
78
  | `logs [-f]` | Show logs (`-f` to follow) |
77
- | `reset [--server]` | Reset local state (`--server` clears server too) |
78
- | `purge` | Remove all local config |
79
+ | `conflicts [--all]` | Show conflict history |
80
+ | `repair` | Diagnose state issues (no auto-fix) |
81
+ | `reset --local` | Clear local state |
82
+ | `reset --server` | Clear server data for this user |
83
+ | `doctor` | Comprehensive health check |
79
84
 
80
85
  ## Server Setup
81
86
 
@@ -143,9 +148,8 @@ things.yourdomain.com {
143
148
 
144
149
  | Synced | Not Synced |
145
150
  |--------|------------|
146
- | Todo title, notes, due date, tags | Completed todos |
147
- | Headings | Checklist items |
148
- | | Areas |
151
+ | Todo title, notes, due date, tags, status | Checklist items |
152
+ | | Headings, Areas |
149
153
 
150
154
  > **Note:** The project must exist in each user's Things app. Only items within that project sync.
151
155
 
package/dist/cli.js CHANGED
@@ -10,11 +10,12 @@ import { Command } from "commander";
10
10
  import updateNotifier from "update-notifier";
11
11
 
12
12
  // src/api.ts
13
- var ApiClient = class {
13
+ var ApiClient = class _ApiClient {
14
14
  constructor(serverUrl, apiKey) {
15
15
  this.serverUrl = serverUrl;
16
16
  this.apiKey = apiKey;
17
17
  }
18
+ static TIMEOUT_MS = 3e4;
18
19
  async request(path6, options = {}) {
19
20
  const url = `${this.serverUrl}${path6}`;
20
21
  const headers = {
@@ -22,7 +23,23 @@ var ApiClient = class {
22
23
  "Content-Type": "application/json",
23
24
  ...options.headers
24
25
  };
25
- const response = await fetch(url, { ...options, headers });
26
+ const controller = new AbortController();
27
+ const timeout = setTimeout(() => controller.abort(), _ApiClient.TIMEOUT_MS);
28
+ let response;
29
+ try {
30
+ response = await fetch(url, {
31
+ ...options,
32
+ headers,
33
+ signal: controller.signal
34
+ });
35
+ } catch (error) {
36
+ if (error.name === "AbortError") {
37
+ throw new Error(`API timeout after ${_ApiClient.TIMEOUT_MS}ms`);
38
+ }
39
+ throw error;
40
+ } finally {
41
+ clearTimeout(timeout);
42
+ }
26
43
  if (!response.ok) {
27
44
  const error = await response.json().catch(() => ({ error: "Unknown error" }));
28
45
  throw new Error(`API error: ${error.error || response.statusText}`);
@@ -118,7 +135,7 @@ function installLaunchAgent() {
118
135
  const npmPrefix = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
119
136
  binPath = path2.join(npmPrefix, "bin", "shared-things");
120
137
  }
121
- const logPath = path2.join(os2.homedir(), ".shared-things", "daemon.log");
138
+ const logPath = path2.join(os2.homedir(), ".shared-things", "sync.log");
122
139
  let nodeBinDir;
123
140
  try {
124
141
  const nodePath = execSync("which node", { encoding: "utf-8" }).trim();
@@ -180,6 +197,30 @@ function installLaunchAgent() {
180
197
  console.warn("You may need to manually load it or restart your Mac.");
181
198
  }
182
199
  }
200
+ function startLaunchAgent() {
201
+ if (!fs2.existsSync(PLIST_PATH)) {
202
+ installLaunchAgent();
203
+ return;
204
+ }
205
+ try {
206
+ execSync(`launchctl load "${PLIST_PATH}"`);
207
+ console.log("LaunchAgent started.");
208
+ } catch (error) {
209
+ console.warn(`Warning: Could not start LaunchAgent: ${error}`);
210
+ }
211
+ }
212
+ function stopLaunchAgent() {
213
+ if (!fs2.existsSync(PLIST_PATH)) {
214
+ console.log("LaunchAgent not installed.");
215
+ return;
216
+ }
217
+ try {
218
+ execSync(`launchctl unload "${PLIST_PATH}"`);
219
+ console.log("LaunchAgent stopped.");
220
+ } catch (error) {
221
+ console.warn(`Warning: Could not stop LaunchAgent: ${error}`);
222
+ }
223
+ }
183
224
  function uninstallLaunchAgent() {
184
225
  if (!fs2.existsSync(PLIST_PATH)) {
185
226
  console.log("LaunchAgent not installed.");
@@ -212,7 +253,7 @@ function getLaunchAgentStatus() {
212
253
  // src/logger.ts
213
254
  import * as fs3 from "fs";
214
255
  import * as path3 from "path";
215
- var LOG_FILE = "daemon.log";
256
+ var LOG_FILE = "sync.log";
216
257
  var MAX_LOG_SIZE = 1024 * 1024;
217
258
  function getLogPath() {
218
259
  return path3.join(getConfigDir(), LOG_FILE);
@@ -229,49 +270,60 @@ function rotateIfNeeded() {
229
270
  fs3.renameSync(logPath, oldLog);
230
271
  }
231
272
  }
232
- function log(message) {
273
+ function log(level, message) {
233
274
  ensureConfigDir();
234
275
  rotateIfNeeded();
235
276
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
236
- const line = `[${timestamp}] ${message}
277
+ const line = `[${timestamp}] [${level}] ${message}
237
278
  `;
238
279
  fs3.appendFileSync(getLogPath(), line);
239
280
  }
240
281
  function logError(message, error) {
241
282
  const errorMsg = error instanceof Error ? error.message : String(error);
242
- log(`ERROR: ${message}${error ? ` - ${errorMsg}` : ""}`);
283
+ log("ERROR", `${message}${error ? ` - ${errorMsg}` : ""}`);
284
+ }
285
+ function logWarn(message) {
286
+ log("WARN", message);
287
+ }
288
+ function logInfo(message) {
289
+ log("INFO", message);
243
290
  }
244
- function logSync(pushed, pulled, isFirstSync) {
291
+ function logDebug(message) {
292
+ log("DEBUG", message);
293
+ }
294
+ function logSync(pushed, pulled, isFirstSync, conflicts) {
245
295
  if (isFirstSync) {
246
- log(`First sync completed: pushed=${pushed}, pulled=${pulled}`);
247
- } else if (pushed > 0 || pulled > 0) {
248
- log(`Sync: pushed=${pushed}, pulled=${pulled}`);
296
+ logInfo(
297
+ `Sync complete (first): pushed=${pushed}, pulled=${pulled}, conflicts=${conflicts}`
298
+ );
299
+ } else if (pushed > 0 || pulled > 0 || conflicts > 0) {
300
+ logInfo(
301
+ `Sync complete: pushed=${pushed}, pulled=${pulled}, conflicts=${conflicts}`
302
+ );
249
303
  } else {
250
- log("Sync: no changes");
304
+ logDebug("Sync: no changes");
251
305
  }
252
306
  }
253
307
  function logMapping(serverId, thingsId) {
254
- log(`Mapped server ${serverId} -> local ${thingsId}`);
308
+ logDebug(`Mapped server ${serverId} -> local ${thingsId}`);
255
309
  }
256
310
  function logTodoCreated(title) {
257
- log(`Created todo: "${title}"`);
311
+ logInfo(`Created todo: "${title}"`);
258
312
  }
259
313
  function logTodoUpdated(thingsId, title) {
260
- log(`Updated todo ${thingsId}: "${title}"`);
261
- }
262
- function logDaemonStart() {
263
- log("Daemon started");
264
- }
265
- function logDaemonStop() {
266
- log("Daemon stopped");
314
+ logInfo(`Updated todo ${thingsId}: "${title}"`);
267
315
  }
268
316
 
269
317
  // src/sync.ts
318
+ import { execSync as execSync3 } from "child_process";
270
319
  import * as fs4 from "fs";
271
320
  import * as path4 from "path";
272
321
 
273
322
  // src/things.ts
274
323
  import { execSync as execSync2 } from "child_process";
324
+ var PIPE_TOKEN = "{{PIPE}}";
325
+ var CARET_TOKEN = "{{CARET}}";
326
+ var MAX_URL_LENGTH = 2e3;
275
327
  function runAppleScript(script) {
276
328
  try {
277
329
  return execSync2(`osascript -e '${script.replace(/'/g, `'"'"'`)}'`, {
@@ -285,22 +337,34 @@ function runAppleScript(script) {
285
337
  }
286
338
  function getTodosFromProject(projectName) {
287
339
  const script = `
340
+ on replaceText(findText, replaceText, theText)
341
+ set AppleScript's text item delimiters to findText
342
+ set theItems to every text item of theText
343
+ set AppleScript's text item delimiters to replaceText
344
+ set theText to theItems as string
345
+ set AppleScript's text item delimiters to ""
346
+ return theText
347
+ end replaceText
348
+
349
+ on escapeText(t)
350
+ if t is missing value then return ""
351
+ set t to my replaceText("|||", "${PIPE_TOKEN}", t as string)
352
+ set t to my replaceText("^^^", "${CARET_TOKEN}", t as string)
353
+ return t
354
+ end escapeText
355
+
288
356
  tell application "Things3"
289
357
  set todoList to {}
290
358
  set proj to project "${projectName}"
291
359
  repeat with t in to dos of proj
292
360
  set todoId to id of t
293
- set todoTitle to name of t
294
- set todoNotes to notes of t
361
+ set todoTitle to my escapeText(name of t)
362
+ set todoNotes to my escapeText(notes of t)
295
363
  set todoStatus to status of t
296
364
  set todoDue to due date of t
297
- set todoTags to tag names of t
298
-
299
- -- Get heading if any
300
- set todoHeading to ""
301
- try
302
- set todoHeading to id of (get area of t)
303
- end try
365
+ set AppleScript's text item delimiters to ", "
366
+ set todoTags to my escapeText((tag names of t) as string)
367
+ set AppleScript's text item delimiters to ""
304
368
 
305
369
  -- Format due date
306
370
  set dueDateStr to ""
@@ -318,7 +382,7 @@ function getTodosFromProject(projectName) {
318
382
  set statusStr to "canceled"
319
383
  end if
320
384
 
321
- set end of todoList to todoId & "|||" & todoTitle & "|||" & todoNotes & "|||" & dueDateStr & "|||" & todoTags & "|||" & statusStr & "|||" & todoHeading
385
+ set end of todoList to todoId & "|||" & todoTitle & "|||" & todoNotes & "|||" & dueDateStr & "|||" & todoTags & "|||" & statusStr
322
386
  end repeat
323
387
 
324
388
  set AppleScript's text item delimiters to "^^^"
@@ -328,18 +392,24 @@ function getTodosFromProject(projectName) {
328
392
  const result = runAppleScript(script);
329
393
  if (!result) return [];
330
394
  return result.split("^^^").map((line) => {
331
- const [thingsId, title, notes, dueDate, tags, status, headingThingsId] = line.split("|||");
395
+ const [thingsId, title, notes, dueDate, tags, status] = line.split("|||");
396
+ const decodedTitle = unescapeField(title);
397
+ const decodedNotes = unescapeField(notes);
398
+ const decodedTags = unescapeField(tags);
332
399
  return {
333
400
  thingsId,
334
- title: title || "",
335
- notes: notes || "",
401
+ title: decodedTitle || "",
402
+ notes: decodedNotes || "",
336
403
  dueDate: dueDate || null,
337
- tags: tags ? tags.split(", ").filter(Boolean) : [],
338
- status: status || "open",
339
- headingThingsId: headingThingsId || null
404
+ tags: decodedTags ? decodedTags.split(", ").filter(Boolean) : [],
405
+ status: status || "open"
340
406
  };
341
407
  });
342
408
  }
409
+ function unescapeField(value) {
410
+ if (!value) return "";
411
+ return value.replaceAll(PIPE_TOKEN, "|||").replaceAll(CARET_TOKEN, "^^^");
412
+ }
343
413
  function createTodo(projectName, todo) {
344
414
  const params = new URLSearchParams();
345
415
  params.set("title", todo.title);
@@ -347,7 +417,19 @@ function createTodo(projectName, todo) {
347
417
  if (todo.dueDate) params.set("when", todo.dueDate);
348
418
  if (todo.tags?.length) params.set("tags", todo.tags.join(","));
349
419
  params.set("list", projectName);
350
- const url = `things:///add?${params.toString().replace(/\+/g, "%20")}`;
420
+ let url = `things:///add?${params.toString().replace(/\+/g, "%20")}`;
421
+ if (url.length > MAX_URL_LENGTH && todo.notes) {
422
+ const originalNotes = todo.notes;
423
+ let truncated = originalNotes;
424
+ while (truncated.length > 0 && url.length > MAX_URL_LENGTH) {
425
+ truncated = truncated.slice(0, Math.max(0, truncated.length - 200));
426
+ params.set("notes", truncated);
427
+ url = `things:///add?${params.toString().replace(/\+/g, "%20")}`;
428
+ }
429
+ logWarn(
430
+ `Create URL exceeded ${MAX_URL_LENGTH} chars; notes truncated from ${originalNotes.length} to ${truncated.length}`
431
+ );
432
+ }
351
433
  execSync2(`open -g "${url}"`);
352
434
  }
353
435
  function updateTodo(authToken, thingsId, updates) {
@@ -393,139 +475,411 @@ function listProjects() {
393
475
 
394
476
  // src/sync.ts
395
477
  var STATE_FILE = path4.join(getConfigDir(), "state.json");
478
+ var STATE_BAK_FILE = path4.join(getConfigDir(), "state.json.bak");
479
+ var LOCK_FILE = path4.join(getConfigDir(), "sync.lock");
480
+ var CONFLICTS_FILE = path4.join(getConfigDir(), "conflicts.json");
396
481
  function loadLocalState() {
397
482
  if (!fs4.existsSync(STATE_FILE)) {
398
- return {
399
- lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
400
- todos: /* @__PURE__ */ new Map(),
401
- serverIdToThingsId: /* @__PURE__ */ new Map()
402
- };
483
+ throw new Error('State file missing. Run "shared-things init" first.');
403
484
  }
485
+ ensureConfigDir();
486
+ fs4.copyFileSync(STATE_FILE, STATE_BAK_FILE);
487
+ const raw = fs4.readFileSync(STATE_FILE, "utf-8");
488
+ let data;
404
489
  try {
405
- const content = fs4.readFileSync(STATE_FILE, "utf-8");
406
- const data = JSON.parse(content);
407
- return {
408
- lastSyncedAt: data.lastSyncedAt,
409
- todos: new Map(Object.entries(data.todos || {})),
410
- serverIdToThingsId: new Map(
411
- Object.entries(data.serverIdToThingsId || {})
412
- )
413
- };
490
+ data = JSON.parse(raw);
414
491
  } catch {
415
- return {
416
- lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
417
- todos: /* @__PURE__ */ new Map(),
418
- serverIdToThingsId: /* @__PURE__ */ new Map()
419
- };
492
+ throw new Error("State file is corrupted (invalid JSON).");
420
493
  }
494
+ if (typeof data !== "object" || data === null) {
495
+ throw new Error("State file is corrupted (invalid structure).");
496
+ }
497
+ const obj = data;
498
+ const lastSyncedAt = typeof obj.lastSyncedAt === "string" ? obj.lastSyncedAt : null;
499
+ const todos = typeof obj.todos === "object" && obj.todos !== null ? obj.todos : null;
500
+ const serverIdToThingsId = typeof obj.serverIdToThingsId === "object" && obj.serverIdToThingsId !== null ? obj.serverIdToThingsId : null;
501
+ const dirtyObj = typeof obj.dirty === "object" && obj.dirty !== null ? obj.dirty : null;
502
+ if (!lastSyncedAt || !todos || !serverIdToThingsId) {
503
+ throw new Error("State file is corrupted (missing fields).");
504
+ }
505
+ const dirty = {
506
+ upserted: Array.isArray(dirtyObj?.upserted) ? dirtyObj?.upserted : [],
507
+ deleted: {}
508
+ };
509
+ if (dirtyObj?.deleted) {
510
+ if (Array.isArray(dirtyObj.deleted)) {
511
+ for (const serverId of dirtyObj.deleted) {
512
+ if (typeof serverId === "string") {
513
+ dirty.deleted[serverId] = (/* @__PURE__ */ new Date()).toISOString();
514
+ }
515
+ }
516
+ } else if (typeof dirtyObj.deleted === "object" && dirtyObj.deleted !== null) {
517
+ for (const [serverId, deletedAt] of Object.entries(
518
+ dirtyObj.deleted
519
+ )) {
520
+ if (typeof deletedAt === "string") {
521
+ dirty.deleted[serverId] = deletedAt;
522
+ }
523
+ }
524
+ }
525
+ }
526
+ for (const [thingsId, todo] of Object.entries(todos)) {
527
+ if (!todo.editedAt) {
528
+ todos[thingsId] = {
529
+ thingsId,
530
+ title: todo.title || "",
531
+ notes: todo.notes || "",
532
+ dueDate: todo.dueDate ?? null,
533
+ tags: Array.isArray(todo.tags) ? todo.tags : [],
534
+ status: todo.status || "open",
535
+ position: typeof todo.position === "number" && Number.isFinite(todo.position) ? todo.position : 0,
536
+ editedAt: lastSyncedAt
537
+ };
538
+ } else if (typeof todo.position !== "number" || !Number.isFinite(todo.position)) {
539
+ todos[thingsId] = {
540
+ ...todo,
541
+ position: 0
542
+ };
543
+ }
544
+ }
545
+ validateMapping(todos, serverIdToThingsId);
546
+ return {
547
+ lastSyncedAt,
548
+ todos,
549
+ serverIdToThingsId,
550
+ dirty
551
+ };
421
552
  }
422
553
  function saveLocalState(state) {
423
- const data = {
424
- lastSyncedAt: state.lastSyncedAt,
425
- todos: Object.fromEntries(state.todos),
426
- serverIdToThingsId: Object.fromEntries(state.serverIdToThingsId)
427
- };
428
- fs4.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2));
554
+ ensureConfigDir();
555
+ const tempFile = `${STATE_FILE}.tmp-${process.pid}`;
556
+ const data = JSON.stringify(state, null, 2);
557
+ fs4.writeFileSync(tempFile, data);
558
+ fs4.renameSync(tempFile, STATE_FILE);
559
+ }
560
+ function acquireLock() {
561
+ ensureConfigDir();
562
+ if (fs4.existsSync(LOCK_FILE)) {
563
+ const pid = parseInt(fs4.readFileSync(LOCK_FILE, "utf-8"), 10);
564
+ if (pid) {
565
+ try {
566
+ process.kill(pid, 0);
567
+ return false;
568
+ } catch {
569
+ fs4.unlinkSync(LOCK_FILE);
570
+ }
571
+ }
572
+ }
573
+ fs4.writeFileSync(LOCK_FILE, String(process.pid));
574
+ return true;
575
+ }
576
+ function releaseLock() {
577
+ if (fs4.existsSync(LOCK_FILE)) {
578
+ fs4.unlinkSync(LOCK_FILE);
579
+ }
580
+ }
581
+ function appendConflicts(entries) {
582
+ if (entries.length === 0) return;
583
+ ensureConfigDir();
584
+ let existing = [];
585
+ if (fs4.existsSync(CONFLICTS_FILE)) {
586
+ try {
587
+ const raw = fs4.readFileSync(CONFLICTS_FILE, "utf-8");
588
+ existing = JSON.parse(raw);
589
+ } catch {
590
+ existing = [];
591
+ }
592
+ }
593
+ const next = [...existing, ...entries];
594
+ const tempFile = `${CONFLICTS_FILE}.tmp-${process.pid}`;
595
+ fs4.writeFileSync(tempFile, JSON.stringify(next, null, 2));
596
+ fs4.renameSync(tempFile, CONFLICTS_FILE);
597
+ }
598
+ function notifyConflicts(count) {
599
+ if (count <= 0) return;
600
+ try {
601
+ const message = `Sync complete. ${count} conflict${count === 1 ? "" : "s"} resolved (your edits were older).`;
602
+ const safeMessage = message.replace(/"/g, '\\"');
603
+ const cmd = `osascript -e 'display notification "${safeMessage}" with title "shared-things"'`;
604
+ execSync3(cmd);
605
+ } catch {
606
+ }
429
607
  }
430
608
  async function runSync() {
431
609
  const config = loadConfig();
432
610
  if (!config) {
433
611
  throw new Error('Not configured. Run "shared-things init" first.');
434
612
  }
613
+ if (!acquireLock()) {
614
+ logWarn("Sync skipped: another sync process is running.");
615
+ return {
616
+ pushed: 0,
617
+ pulled: 0,
618
+ isFirstSync: false,
619
+ skipped: true,
620
+ conflicts: 0
621
+ };
622
+ }
623
+ let localState;
624
+ try {
625
+ localState = loadLocalState();
626
+ } catch (error) {
627
+ releaseLock();
628
+ throw error;
629
+ }
630
+ logDebug(
631
+ `Loaded state: todos=${Object.keys(localState.todos).length}, mappings=${Object.keys(localState.serverIdToThingsId).length}, dirtyUpserted=${localState.dirty.upserted.length}, dirtyDeleted=${Object.keys(localState.dirty.deleted).length}`
632
+ );
633
+ logInfo("Sync started");
435
634
  const api = new ApiClient(config.serverUrl, config.apiKey);
436
- const localState = loadLocalState();
437
- const isFirstSync = localState.lastSyncedAt === (/* @__PURE__ */ new Date(0)).toISOString();
438
- const currentTodos = getTodosFromProject(config.projectName);
439
- const currentTodosMap = new Map(currentTodos.map((t) => [t.thingsId, t]));
440
- const localChanges = {
441
- upserted: [],
442
- deleted: []
443
- };
444
- for (const [thingsId, todo] of currentTodosMap) {
445
- const prev = localState.todos.get(thingsId);
446
- if (!prev || hasChanged(prev, todo)) {
447
- localChanges.upserted.push(todo);
635
+ const isFirstSync = Object.keys(localState.todos).length === 0 && Object.keys(localState.serverIdToThingsId).length === 0;
636
+ let pushed = 0;
637
+ let pulled = 0;
638
+ let conflictCount = 0;
639
+ try {
640
+ const currentTodos = getTodosFromProject(config.projectName);
641
+ logDebug(`Read Things: todos=${currentTodos.length}`);
642
+ const positionMap = new Map(
643
+ currentTodos.map((todo, idx) => [todo.thingsId, idx])
644
+ );
645
+ const currentTodosMap = new Map(currentTodos.map((t) => [t.thingsId, t]));
646
+ const now = (/* @__PURE__ */ new Date()).toISOString();
647
+ const dirtyUpserted = new Set(localState.dirty.upserted);
648
+ for (const [thingsId, todo] of currentTodosMap) {
649
+ const prev = localState.todos[thingsId];
650
+ const position = positionMap.get(thingsId) ?? 0;
651
+ if (!prev) {
652
+ localState.todos[thingsId] = {
653
+ thingsId,
654
+ title: todo.title,
655
+ notes: todo.notes,
656
+ dueDate: todo.dueDate,
657
+ tags: todo.tags,
658
+ status: todo.status,
659
+ position,
660
+ editedAt: now
661
+ };
662
+ dirtyUpserted.add(thingsId);
663
+ continue;
664
+ }
665
+ if (hasChanged(prev, todo, position)) {
666
+ localState.todos[thingsId] = {
667
+ ...prev,
668
+ title: todo.title,
669
+ notes: todo.notes,
670
+ dueDate: todo.dueDate,
671
+ tags: todo.tags,
672
+ status: todo.status,
673
+ position,
674
+ editedAt: now
675
+ };
676
+ dirtyUpserted.add(thingsId);
677
+ }
448
678
  }
679
+ for (const thingsId of Object.keys(localState.todos)) {
680
+ if (!currentTodosMap.has(thingsId)) {
681
+ const serverId = findServerId(localState.serverIdToThingsId, thingsId);
682
+ if (serverId) {
683
+ if (!localState.dirty.deleted[serverId]) {
684
+ localState.dirty.deleted[serverId] = now;
685
+ }
686
+ }
687
+ delete localState.todos[thingsId];
688
+ }
689
+ }
690
+ localState.dirty.upserted = Array.from(dirtyUpserted);
691
+ localState.dirty.upserted = localState.dirty.upserted.filter(
692
+ (id) => currentTodosMap.has(id)
693
+ );
694
+ logDebug(
695
+ `Local changes: upserted=${localState.dirty.upserted.length}, deleted=${Object.keys(localState.dirty.deleted).length}`
696
+ );
697
+ const pushUpserts = buildUpserts(currentTodosMap, localState);
698
+ const pushDeletes = buildDeletes(currentTodosMap, localState);
699
+ logDebug(
700
+ `Push payload: upserted=${pushUpserts.length}, deleted=${pushDeletes.length}`
701
+ );
702
+ if (pushUpserts.length > 0 || pushDeletes.length > 0) {
703
+ logDebug(
704
+ `Push payload body: ${JSON.stringify({
705
+ todos: { upserted: pushUpserts, deleted: pushDeletes },
706
+ lastSyncedAt: localState.lastSyncedAt
707
+ })}`
708
+ );
709
+ }
710
+ if (pushUpserts.length > 0 || pushDeletes.length > 0) {
711
+ const pushResponse = await api.push({
712
+ todos: {
713
+ upserted: pushUpserts,
714
+ deleted: pushDeletes
715
+ },
716
+ lastSyncedAt: localState.lastSyncedAt
717
+ });
718
+ pushed = pushUpserts.length + pushDeletes.length;
719
+ processPushMappings(localState, pushResponse);
720
+ const conflictEntries = conflictsFromPush(pushResponse);
721
+ logDebug(
722
+ `Push response: conflicts=${conflictEntries.length}, mappings=${pushResponse.mappings?.length ?? 0}`
723
+ );
724
+ conflictCount += conflictEntries.length;
725
+ appendConflicts(conflictEntries);
726
+ localState.dirty = { upserted: [], deleted: {} };
727
+ }
728
+ const delta = await getServerDelta(api, localState, currentTodos);
729
+ logDebug(
730
+ `Delta response: upserted=${delta.todos.upserted.length}, deleted=${delta.todos.deleted.length}, syncedAt=${delta.syncedAt}`
731
+ );
732
+ const remoteResult = await applyRemoteChanges(
733
+ config.thingsAuthToken,
734
+ config.projectName,
735
+ delta.todos.upserted,
736
+ delta.todos.deleted,
737
+ currentTodosMap,
738
+ localState
739
+ );
740
+ pulled += remoteResult.applied;
741
+ conflictCount += remoteResult.conflicts.length;
742
+ appendConflicts(remoteResult.conflicts);
743
+ localState.lastSyncedAt = delta.syncedAt;
744
+ saveLocalState(localState);
745
+ logSync(pushed, pulled, isFirstSync, conflictCount);
746
+ if (conflictCount > 0) {
747
+ notifyConflicts(conflictCount);
748
+ }
749
+ } catch (error) {
750
+ logError("Sync failed", error);
751
+ saveLocalState(localState);
752
+ throw error;
753
+ } finally {
754
+ releaseLock();
755
+ }
756
+ return { pushed, pulled, isFirstSync, conflicts: conflictCount };
757
+ }
758
+ function hasChanged(prev, curr, position) {
759
+ return prev.title !== curr.title || prev.notes !== curr.notes || prev.dueDate !== curr.dueDate || prev.status !== curr.status || prev.position !== position || JSON.stringify(prev.tags) !== JSON.stringify(curr.tags);
760
+ }
761
+ function buildUpserts(currentTodosMap, state) {
762
+ const thingsIdToServerId = invertMapping(state.serverIdToThingsId);
763
+ const upserts = [];
764
+ for (const thingsId of state.dirty.upserted) {
765
+ const todo = currentTodosMap.get(thingsId);
766
+ const stored = state.todos[thingsId];
767
+ if (!todo || !stored) continue;
768
+ upserts.push({
769
+ serverId: thingsIdToServerId.get(thingsId),
770
+ clientId: thingsId,
771
+ title: stored.title,
772
+ notes: stored.notes,
773
+ dueDate: stored.dueDate,
774
+ tags: stored.tags,
775
+ status: stored.status,
776
+ position: stored.position,
777
+ editedAt: stored.editedAt
778
+ });
449
779
  }
450
- for (const thingsId of localState.todos.keys()) {
451
- if (!currentTodosMap.has(thingsId)) {
452
- localChanges.deleted.push(thingsId);
780
+ return upserts;
781
+ }
782
+ function buildDeletes(currentTodosMap, state) {
783
+ const deletes = [];
784
+ for (const [serverId, deletedAt] of Object.entries(state.dirty.deleted)) {
785
+ const thingsId = state.serverIdToThingsId[serverId];
786
+ if (thingsId && currentTodosMap.has(thingsId)) {
787
+ delete state.dirty.deleted[serverId];
788
+ continue;
453
789
  }
790
+ deletes.push({ serverId, deletedAt });
454
791
  }
455
- const thingsIdToServerId = /* @__PURE__ */ new Map();
456
- for (const [serverId, thingsId] of localState.serverIdToThingsId) {
457
- thingsIdToServerId.set(thingsId, serverId);
792
+ return deletes;
793
+ }
794
+ function processPushMappings(state, response) {
795
+ if (!response.mappings) return;
796
+ for (const mapping of response.mappings) {
797
+ if (!mapping.clientId) continue;
798
+ setMapping(state, mapping.serverId, mapping.clientId);
458
799
  }
459
- let pushed = 0;
460
- if (localChanges.upserted.length > 0 || localChanges.deleted.length > 0) {
461
- const pushRequest = {
462
- headings: { upserted: [], deleted: [] },
800
+ }
801
+ function conflictsFromPush(response) {
802
+ if (!response.conflicts || response.conflicts.length === 0) return [];
803
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
804
+ return response.conflicts.map((conflict) => ({
805
+ timestamp,
806
+ serverId: conflict.serverId,
807
+ title: conflict.clientTodo?.title || conflict.serverTodo?.title || "Unknown",
808
+ yourVersion: conflict.clientTodo ? {
809
+ title: conflict.clientTodo.title,
810
+ editedAt: conflict.clientTodo.editedAt
811
+ } : conflict.clientDeletedAt ? { deletedAt: conflict.clientDeletedAt } : {},
812
+ winningVersion: conflict.serverTodo ? {
813
+ title: conflict.serverTodo.title,
814
+ editedAt: conflict.serverTodo.editedAt
815
+ } : conflict.clientDeletedAt ? { deletedAt: conflict.clientDeletedAt } : {},
816
+ reason: conflict.reason
817
+ }));
818
+ }
819
+ async function getServerDelta(api, state, currentTodos) {
820
+ const shouldBootstrap = Object.keys(state.todos).length === 0 && Object.keys(state.serverIdToThingsId).length === 0 && currentTodos.length === 0;
821
+ if (shouldBootstrap) {
822
+ const fullState = await api.getState();
823
+ return {
463
824
  todos: {
464
- upserted: localChanges.upserted.map((t, idx) => ({
465
- // Include serverId if we know it (for updates)
466
- serverId: thingsIdToServerId.get(t.thingsId),
467
- thingsId: t.thingsId,
468
- title: t.title,
469
- notes: t.notes,
470
- dueDate: t.dueDate,
471
- tags: t.tags,
472
- status: t.status,
473
- headingId: t.headingThingsId,
474
- position: idx
475
- })),
476
- // Convert thingsIds to serverIds for deletions
477
- deleted: localChanges.deleted.map((thingsId) => thingsIdToServerId.get(thingsId)).filter((id) => id !== void 0)
825
+ upserted: fullState.todos,
826
+ deleted: []
478
827
  },
479
- lastSyncedAt: localState.lastSyncedAt
828
+ syncedAt: fullState.syncedAt
480
829
  };
481
- const pushResponse = await api.push(pushRequest);
482
- pushed = localChanges.upserted.length + localChanges.deleted.length;
483
- for (const serverTodo of pushResponse.state.todos) {
484
- const localTodo = currentTodosMap.get(serverTodo.thingsId);
485
- if (localTodo) {
486
- localState.serverIdToThingsId.set(serverTodo.id, localTodo.thingsId);
487
- }
488
- }
489
830
  }
490
- const delta = await api.getDelta(localState.lastSyncedAt);
491
- let pulled = 0;
492
- for (const remoteTodo of delta.todos.upserted) {
493
- const localThingsId = localState.serverIdToThingsId.get(remoteTodo.id);
494
- const localTodo = localThingsId ? currentTodosMap.get(localThingsId) : null;
495
- if (!localTodo) {
496
- const beforeTodos = new Set(currentTodosMap.keys());
497
- createTodo(config.projectName, {
831
+ return api.getDelta(state.lastSyncedAt);
832
+ }
833
+ async function applyRemoteChanges(authToken, projectName, upserted, deleted, currentTodosMap, state) {
834
+ let applied = 0;
835
+ const conflicts = [];
836
+ for (const remoteTodo of upserted) {
837
+ const localThingsId = state.serverIdToThingsId[remoteTodo.id];
838
+ const localTodo = localThingsId ? currentTodosMap.get(localThingsId) : void 0;
839
+ const localStateTodo = localThingsId ? state.todos[localThingsId] : void 0;
840
+ if (!localTodo || !localThingsId) {
841
+ const before = new Set(currentTodosMap.keys());
842
+ createTodo(projectName, {
498
843
  title: remoteTodo.title,
499
844
  notes: remoteTodo.notes,
500
845
  dueDate: remoteTodo.dueDate || void 0,
501
846
  tags: remoteTodo.tags
502
847
  });
503
848
  logTodoCreated(remoteTodo.title);
504
- let newTodo;
505
- for (let attempt = 0; attempt < 3; attempt++) {
506
- await new Promise((resolve) => setTimeout(resolve, 500));
507
- const afterTodos = getTodosFromProject(config.projectName);
508
- newTodo = afterTodos.find(
509
- (t) => !beforeTodos.has(t.thingsId) && t.title === remoteTodo.title
510
- );
511
- if (!newTodo) {
512
- newTodo = afterTodos.find((t) => !beforeTodos.has(t.thingsId));
513
- }
514
- if (newTodo) break;
515
- }
849
+ const newTodo = await findNewTodo(projectName, before, remoteTodo.title);
516
850
  if (newTodo) {
517
- localState.serverIdToThingsId.set(remoteTodo.id, newTodo.thingsId);
851
+ setMapping(state, remoteTodo.id, newTodo.thingsId);
852
+ state.todos[newTodo.thingsId] = {
853
+ thingsId: newTodo.thingsId,
854
+ title: remoteTodo.title,
855
+ notes: remoteTodo.notes,
856
+ dueDate: remoteTodo.dueDate,
857
+ tags: remoteTodo.tags,
858
+ status: remoteTodo.status,
859
+ position: remoteTodo.position,
860
+ editedAt: remoteTodo.editedAt
861
+ };
518
862
  currentTodosMap.set(newTodo.thingsId, newTodo);
519
- logMapping(remoteTodo.id, newTodo.thingsId);
863
+ if (remoteTodo.status !== "open") {
864
+ try {
865
+ updateTodo(authToken, newTodo.thingsId, {
866
+ completed: remoteTodo.status === "completed",
867
+ canceled: remoteTodo.status === "canceled"
868
+ });
869
+ } catch (error) {
870
+ logWarn(
871
+ `Failed to set status for ${newTodo.thingsId}: ${String(error)}`
872
+ );
873
+ }
874
+ }
520
875
  } else {
521
- logError(
522
- `Failed to find newly created todo for server ${remoteTodo.id}`,
523
- remoteTodo.title
524
- );
876
+ logWarn(`Failed to locate created todo for server ${remoteTodo.id}`);
525
877
  }
526
- pulled++;
527
- } else if (hasRemoteChanged(localTodo, remoteTodo)) {
528
- updateTodo(config.thingsAuthToken, localTodo.thingsId, {
878
+ applied += 1;
879
+ continue;
880
+ }
881
+ if (!localStateTodo || compareIso(remoteTodo.editedAt, localStateTodo.editedAt) >= 0) {
882
+ updateTodo(authToken, localTodo.thingsId, {
529
883
  title: remoteTodo.title,
530
884
  notes: remoteTodo.notes,
531
885
  dueDate: remoteTodo.dueDate || void 0,
@@ -533,25 +887,101 @@ async function runSync() {
533
887
  canceled: remoteTodo.status === "canceled"
534
888
  });
535
889
  logTodoUpdated(localTodo.thingsId, remoteTodo.title);
536
- pulled++;
890
+ state.todos[localTodo.thingsId] = {
891
+ thingsId: localTodo.thingsId,
892
+ title: remoteTodo.title,
893
+ notes: remoteTodo.notes,
894
+ dueDate: remoteTodo.dueDate,
895
+ tags: remoteTodo.tags,
896
+ status: remoteTodo.status,
897
+ position: remoteTodo.position,
898
+ editedAt: remoteTodo.editedAt
899
+ };
900
+ applied += 1;
537
901
  }
538
902
  }
539
- if (delta.todos.deleted.length > 0) {
540
- log(
541
- `Remote deletions (manual action needed): ${delta.todos.deleted.join(", ")}`
542
- );
903
+ for (const deletion of deleted) {
904
+ const localThingsId = state.serverIdToThingsId[deletion.serverId];
905
+ if (!localThingsId) continue;
906
+ const existsInThings = currentTodosMap.has(localThingsId);
907
+ const localStateTodo = state.todos[localThingsId];
908
+ if (!existsInThings && !localStateTodo) {
909
+ delete state.serverIdToThingsId[deletion.serverId];
910
+ continue;
911
+ }
912
+ if (!localStateTodo) continue;
913
+ if (compareIso(deletion.deletedAt, localStateTodo.editedAt) < 0) {
914
+ continue;
915
+ }
916
+ conflicts.push({
917
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
918
+ serverId: deletion.serverId,
919
+ title: localStateTodo.title,
920
+ yourVersion: {
921
+ title: localStateTodo.title,
922
+ editedAt: localStateTodo.editedAt
923
+ },
924
+ winningVersion: { deletedAt: deletion.deletedAt },
925
+ reason: "Remote delete was newer (manual delete required)"
926
+ });
543
927
  }
544
- localState.lastSyncedAt = delta.syncedAt;
545
- localState.todos = currentTodosMap;
546
- saveLocalState(localState);
547
- logSync(pushed, pulled, isFirstSync);
548
- return { pushed, pulled, isFirstSync };
928
+ return { applied, conflicts };
549
929
  }
550
- function hasChanged(prev, curr) {
551
- return prev.title !== curr.title || prev.notes !== curr.notes || prev.dueDate !== curr.dueDate || prev.status !== curr.status || JSON.stringify(prev.tags) !== JSON.stringify(curr.tags);
930
+ function invertMapping(mapping) {
931
+ const result = /* @__PURE__ */ new Map();
932
+ for (const [serverId, thingsId] of Object.entries(mapping)) {
933
+ result.set(thingsId, serverId);
934
+ }
935
+ return result;
552
936
  }
553
- function hasRemoteChanged(local, remote) {
554
- return local.title !== remote.title || local.notes !== remote.notes || local.dueDate !== remote.dueDate || local.status !== remote.status || JSON.stringify(local.tags) !== JSON.stringify(remote.tags);
937
+ function findServerId(mapping, thingsId) {
938
+ for (const [serverId, mappedThingsId] of Object.entries(mapping)) {
939
+ if (mappedThingsId === thingsId) return serverId;
940
+ }
941
+ return void 0;
942
+ }
943
+ function setMapping(state, serverId, thingsId) {
944
+ const existing = state.serverIdToThingsId[serverId];
945
+ if (existing && existing !== thingsId) {
946
+ if (state.todos[existing]) {
947
+ throw new Error(
948
+ `Duplicate mapping detected for serverId ${serverId} (${existing} vs ${thingsId})`
949
+ );
950
+ }
951
+ }
952
+ for (const [sid, tid] of Object.entries(state.serverIdToThingsId)) {
953
+ if (tid === thingsId && sid !== serverId) {
954
+ throw new Error(
955
+ `Duplicate mapping detected for thingsId ${thingsId} (${sid} vs ${serverId})`
956
+ );
957
+ }
958
+ }
959
+ state.serverIdToThingsId[serverId] = thingsId;
960
+ logMapping(serverId, thingsId);
961
+ }
962
+ function validateMapping(todos, mapping) {
963
+ const thingsIds = new Set(Object.values(mapping));
964
+ if (thingsIds.size !== Object.values(mapping).length) {
965
+ throw new Error("Invalid mapping detected: duplicate thingsId entries.");
966
+ }
967
+ for (const thingsId of Object.values(mapping)) {
968
+ if (!todos[thingsId]) {
969
+ }
970
+ }
971
+ }
972
+ function compareIso(a, b) {
973
+ return new Date(a).getTime() - new Date(b).getTime();
974
+ }
975
+ async function findNewTodo(projectName, before, title) {
976
+ for (let attempt = 0; attempt < 3; attempt++) {
977
+ await new Promise((resolve) => setTimeout(resolve, 500));
978
+ const after = getTodosFromProject(projectName);
979
+ const found = after.find(
980
+ (t) => !before.has(t.thingsId) && t.title === title
981
+ );
982
+ if (found) return found;
983
+ }
984
+ return void 0;
555
985
  }
556
986
 
557
987
  // src/cli.ts
@@ -664,10 +1094,14 @@ program.command("init").description("Setup wizard").action(async () => {
664
1094
  });
665
1095
  console.log("\n\u23F3 Checking Things project...");
666
1096
  const todos = getTodosFromProject(projectName);
667
- console.log(
668
- `\u2705 Found ${todos.length} todo${todos.length === 1 ? "" : "s"} in "${projectName}"
669
- `
670
- );
1097
+ if (todos.length > 0) {
1098
+ console.error(
1099
+ `\u274C Project "${projectName}" must be empty for first sync (found ${todos.length}).`
1100
+ );
1101
+ process.exit(1);
1102
+ }
1103
+ console.log(`\u2705 Project "${projectName}" is empty.
1104
+ `);
671
1105
  console.log("\u{1F4CB} Find your Things Auth Token in:");
672
1106
  console.log(" Things \u2192 Settings \u2192 General \u2192 Things URLs \u2192 Manage\n");
673
1107
  const thingsAuthToken = await password({
@@ -685,19 +1119,42 @@ program.command("init").description("Setup wizard").action(async () => {
685
1119
  pollInterval: 30,
686
1120
  thingsAuthToken
687
1121
  });
1122
+ writeState({
1123
+ lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString(),
1124
+ todos: {},
1125
+ serverIdToThingsId: {},
1126
+ dirty: { upserted: [], deleted: {} }
1127
+ });
688
1128
  console.log("\n\u2705 Configuration saved!\n");
689
- console.log("Next steps:");
690
- console.log(' 1. Run "shared-things install" to start the daemon');
691
- console.log(' 2. Or run "shared-things sync" for a one-time sync\n');
1129
+ const install = await confirm({
1130
+ message: "Install LaunchAgent to run sync automatically on login?",
1131
+ default: true
1132
+ });
1133
+ if (install) {
1134
+ installLaunchAgent();
1135
+ }
1136
+ console.log("\nNext steps:");
1137
+ console.log(' 1. Run "shared-things sync" for a one-time sync');
1138
+ console.log(' 2. Or run "shared-things start" to run the daemon\n');
1139
+ });
1140
+ program.command("start").description("Start the sync daemon (launchd)").action(() => {
1141
+ if (!configExists()) {
1142
+ console.error('Not configured. Run "shared-things init" first.');
1143
+ process.exit(1);
1144
+ }
1145
+ startLaunchAgent();
1146
+ });
1147
+ program.command("stop").description("Stop the sync daemon (launchd)").action(() => {
1148
+ stopLaunchAgent();
692
1149
  });
693
- program.command("install").description("Install launchd daemon (auto-start at login)").action(() => {
1150
+ program.command("install").description("Install launchd daemon (deprecated)").action(() => {
694
1151
  if (!configExists()) {
695
1152
  console.error('Not configured. Run "shared-things init" first.');
696
1153
  process.exit(1);
697
1154
  }
698
1155
  installLaunchAgent();
699
1156
  });
700
- program.command("uninstall").description("Remove launchd daemon").action(() => {
1157
+ program.command("uninstall").description("Remove launchd daemon (deprecated)").action(() => {
701
1158
  uninstallLaunchAgent();
702
1159
  });
703
1160
  program.command("status").description("Show sync status").action(async () => {
@@ -728,15 +1185,23 @@ program.command("status").description("Show sync status").action(async () => {
728
1185
  console.log(
729
1186
  `${chalk.dim("Daemon:")} ${isRunning ? chalk.green("\u25CF running") : chalk.red("\u25CB stopped")}`
730
1187
  );
731
- const statePath = path5.join(getConfigDir(), "state.json");
732
- if (fs5.existsSync(statePath)) {
733
- const state = JSON.parse(fs5.readFileSync(statePath, "utf-8"));
1188
+ const state = readState();
1189
+ if (state) {
734
1190
  const lastSync = new Date(state.lastSyncedAt);
735
1191
  const ago = formatTimeAgo(lastSync);
736
1192
  console.log(`${chalk.dim("Last sync:")} ${ago}`);
1193
+ console.log(
1194
+ `${chalk.dim("Dirty:")} upserted=${state.dirty.upserted.length}, deleted=${Object.keys(state.dirty.deleted).length}`
1195
+ );
737
1196
  } else {
738
1197
  console.log(`${chalk.dim("Last sync:")} ${chalk.yellow("never")}`);
739
1198
  }
1199
+ const conflicts = readConflicts();
1200
+ if (conflicts.length > 0) {
1201
+ console.log(
1202
+ `${chalk.dim("Conflicts:")} ${chalk.yellow(conflicts.length)} (run "shared-things conflicts")`
1203
+ );
1204
+ }
740
1205
  console.log();
741
1206
  });
742
1207
  function formatTimeAgo(date) {
@@ -756,12 +1221,15 @@ program.command("sync").description("Manual one-time sync").action(async () => {
756
1221
  }
757
1222
  try {
758
1223
  const result = await runSync();
759
- if (result.isFirstSync) {
760
- console.log(chalk.cyan("\u{1F4E5} First sync completed!"));
1224
+ if (result.skipped) {
1225
+ console.log(chalk.yellow("\u26A0\uFE0F Sync skipped (another sync is running)."));
1226
+ return;
761
1227
  }
1228
+ if (result.isFirstSync)
1229
+ console.log(chalk.cyan("\u{1F4E5} First sync completed!"));
762
1230
  console.log(
763
1231
  chalk.green(
764
- `\u2705 Done! Pushed: ${result.pushed}, Pulled: ${result.pulled}`
1232
+ `\u2705 Done! Pushed: ${result.pushed}, Pulled: ${result.pulled}, Conflicts: ${result.conflicts}`
765
1233
  )
766
1234
  );
767
1235
  } catch (error) {
@@ -776,18 +1244,18 @@ program.command("daemon").description("Run sync daemon (used by launchd)").actio
776
1244
  process.exit(1);
777
1245
  }
778
1246
  const config = loadConfig();
779
- logDaemonStart();
780
- log(`Polling interval: ${config.pollInterval}s`);
1247
+ logInfo("Daemon started");
1248
+ logInfo(`Polling interval: ${config.pollInterval}s`);
781
1249
  console.log(`Daemon started. Syncing every ${config.pollInterval}s...`);
782
1250
  const shutdown = () => {
783
- logDaemonStop();
1251
+ logInfo("Daemon stopped");
784
1252
  process.exit(0);
785
1253
  };
786
1254
  process.on("SIGTERM", shutdown);
787
1255
  process.on("SIGINT", shutdown);
788
1256
  try {
789
1257
  await runSync();
790
- log("Initial sync complete");
1258
+ logInfo("Initial sync complete");
791
1259
  } catch (error) {
792
1260
  logError("Initial sync failed", error);
793
1261
  }
@@ -799,8 +1267,8 @@ program.command("daemon").description("Run sync daemon (used by launchd)").actio
799
1267
  }
800
1268
  }, config.pollInterval * 1e3);
801
1269
  });
802
- program.command("logs").description("Show daemon logs").option("-f, --follow", "Follow log output").action((options) => {
803
- const logPath = path5.join(getConfigDir(), "daemon.log");
1270
+ program.command("logs").description("Show sync logs").option("-f, --follow", "Follow log output").action((options) => {
1271
+ const logPath = path5.join(getConfigDir(), "sync.log");
804
1272
  if (!fs5.existsSync(logPath)) {
805
1273
  console.log("No logs yet.");
806
1274
  return;
@@ -812,24 +1280,34 @@ program.command("logs").description("Show daemon logs").option("-f, --follow", "
812
1280
  console.log(logs);
813
1281
  }
814
1282
  });
815
- program.command("reset").description("Reset sync state (next sync will be a fresh start)").option("-s, --server", "Also delete all data on the server").action(async (options) => {
1283
+ program.command("reset").description("Reset sync state").option("--local", "Clear local state (re-sync from server)").option("--server", "Clear server data for this user").action(async (options) => {
816
1284
  if (!configExists()) {
817
1285
  console.log(chalk.yellow("Not configured. Nothing to reset."));
818
1286
  return;
819
1287
  }
820
- const statePath = path5.join(getConfigDir(), "state.json");
821
- const hasLocalState = fs5.existsSync(statePath);
822
- if (!hasLocalState && !options.server) {
823
- console.log(chalk.yellow("No sync state to reset."));
1288
+ if (!options.local && !options.server) {
1289
+ console.log(chalk.yellow("Specify --local and/or --server to reset."));
824
1290
  return;
825
1291
  }
826
- let message = "This will clear your local sync state.";
827
- if (options.server) {
828
- message = "This will clear your local sync state AND delete all your data on the server.";
1292
+ const config = loadConfig();
1293
+ const statePath = path5.join(getConfigDir(), "state.json");
1294
+ const hasLocalState = fs5.existsSync(statePath);
1295
+ if (options.local) {
1296
+ try {
1297
+ const todos = getTodosFromProject(config.projectName);
1298
+ if (todos.length > 0) {
1299
+ console.error(
1300
+ `\u274C Project "${config.projectName}" must be empty to reset local state.`
1301
+ );
1302
+ return;
1303
+ }
1304
+ } catch (error) {
1305
+ console.error(`\u274C Failed to read Things project: ${error}`);
1306
+ return;
1307
+ }
829
1308
  }
830
- message += " Continue?";
831
1309
  const confirmed = await confirm({
832
- message,
1310
+ message: "This action cannot be undone. Continue?",
833
1311
  default: false
834
1312
  });
835
1313
  if (!confirmed) {
@@ -837,18 +1315,13 @@ program.command("reset").description("Reset sync state (next sync will be a fres
837
1315
  return;
838
1316
  }
839
1317
  if (options.server) {
840
- const config = loadConfig();
841
1318
  const api = new ApiClient(config.serverUrl, config.apiKey);
842
1319
  try {
843
1320
  console.log(chalk.dim("Deleting server data..."));
844
1321
  const result = await api.reset();
845
- log(
846
- `Server reset: deleted ${result.deleted.todos} todos, ${result.deleted.headings} headings`
847
- );
1322
+ logInfo(`Server reset: deleted ${result.deleted.todos} todos`);
848
1323
  console.log(
849
- chalk.green(
850
- `\u2705 Server data deleted (${result.deleted.todos} todos, ${result.deleted.headings} headings)`
851
- )
1324
+ chalk.green(`\u2705 Server data deleted (${result.deleted.todos} todos)`)
852
1325
  );
853
1326
  } catch (error) {
854
1327
  logError("Server reset failed", error);
@@ -856,9 +1329,19 @@ program.command("reset").description("Reset sync state (next sync will be a fres
856
1329
  return;
857
1330
  }
858
1331
  }
859
- if (hasLocalState) {
860
- fs5.unlinkSync(statePath);
861
- log("Sync state reset by user");
1332
+ if (options.local) {
1333
+ if (hasLocalState) {
1334
+ fs5.unlinkSync(statePath);
1335
+ }
1336
+ const conflictsPath = path5.join(getConfigDir(), "conflicts.json");
1337
+ if (fs5.existsSync(conflictsPath)) fs5.unlinkSync(conflictsPath);
1338
+ writeState({
1339
+ lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
1340
+ todos: {},
1341
+ serverIdToThingsId: {},
1342
+ dirty: { upserted: [], deleted: {} }
1343
+ });
1344
+ logInfo("Local sync state reset by user");
862
1345
  }
863
1346
  console.log(
864
1347
  chalk.green(
@@ -866,32 +1349,121 @@ program.command("reset").description("Reset sync state (next sync will be a fres
866
1349
  )
867
1350
  );
868
1351
  });
869
- program.command("purge").description("Remove all local data (config, state, logs)").action(async () => {
870
- const configDir = getConfigDir();
871
- if (!fs5.existsSync(configDir)) {
872
- console.log(chalk.yellow("Nothing to purge."));
1352
+ program.command("conflicts").description("Show conflict history").option("--all", "Show all conflicts").action((options) => {
1353
+ const conflicts = readConflicts();
1354
+ if (conflicts.length === 0) {
1355
+ console.log("No conflicts recorded.");
873
1356
  return;
874
1357
  }
875
- const daemonStatus = getLaunchAgentStatus();
876
- if (daemonStatus === "running") {
1358
+ const shown = options.all ? conflicts : conflicts.slice(-10);
1359
+ console.log(chalk.bold(`
1360
+ \u26A0\uFE0F Conflicts (${shown.length})
1361
+ `));
1362
+ for (const conflict of shown) {
877
1363
  console.log(
878
- chalk.yellow("\u26A0\uFE0F Daemon is still running. Stopping it first...")
1364
+ `${chalk.dim(conflict.timestamp)} ${chalk.white(conflict.title)} (${conflict.serverId})`
879
1365
  );
880
- uninstallLaunchAgent();
1366
+ console.log(` ${chalk.dim("Reason:")} ${conflict.reason}`);
1367
+ if (conflict.yourVersion.editedAt || conflict.yourVersion.deletedAt) {
1368
+ console.log(
1369
+ ` ${chalk.dim("Yours:")} ${formatConflictVersion(conflict.yourVersion)}`
1370
+ );
1371
+ }
1372
+ if (conflict.winningVersion.editedAt || conflict.winningVersion.deletedAt) {
1373
+ console.log(
1374
+ ` ${chalk.dim("Winner:")} ${formatConflictVersion(conflict.winningVersion)}`
1375
+ );
1376
+ }
1377
+ console.log();
881
1378
  }
882
- const confirmed = await confirm({
883
- message: `This will delete all local data in ${configDir}. You will need to run "init" again. Continue?`,
884
- default: false
885
- });
886
- if (!confirmed) {
887
- console.log(chalk.dim("Cancelled."));
1379
+ });
1380
+ program.command("repair").description("Diagnose state issues (no auto-fix)").action(() => {
1381
+ const issues = [];
1382
+ if (!configExists()) {
1383
+ issues.push("Missing config.json (run init)");
1384
+ }
1385
+ const state = readState();
1386
+ if (!state) {
1387
+ issues.push("Missing or invalid state.json");
1388
+ }
1389
+ if (issues.length === 0) {
1390
+ console.log(chalk.green("No issues detected."));
1391
+ } else {
1392
+ console.log(chalk.yellow("Issues detected:"));
1393
+ for (const issue of issues) {
1394
+ console.log(`- ${issue}`);
1395
+ }
1396
+ }
1397
+ });
1398
+ program.command("doctor").description("Comprehensive health check").action(async () => {
1399
+ console.log(chalk.bold("\n\u{1FA7A} shared-things Doctor\n"));
1400
+ if (!configExists()) {
1401
+ console.log(chalk.red("Config: missing (run init)"));
888
1402
  return;
889
1403
  }
890
- fs5.rmSync(configDir, { recursive: true, force: true });
1404
+ const config = loadConfig();
1405
+ console.log(chalk.green("Config: ok"));
1406
+ const state = readState();
891
1407
  console.log(
892
- chalk.green(
893
- '\u2705 All local data removed. Run "shared-things init" to start fresh.'
894
- )
1408
+ state ? chalk.green("State: ok") : chalk.red("State: missing/invalid")
895
1409
  );
1410
+ if (!isThingsRunning()) {
1411
+ console.log(chalk.yellow("Things 3: not running"));
1412
+ } else {
1413
+ console.log(chalk.green("Things 3: running"));
1414
+ }
1415
+ const projects = listProjects();
1416
+ if (!projects.includes(config.projectName)) {
1417
+ console.log(chalk.red(`Project: "${config.projectName}" not found`));
1418
+ } else {
1419
+ console.log(chalk.green(`Project: ${config.projectName}`));
1420
+ }
1421
+ const api = new ApiClient(config.serverUrl, config.apiKey);
1422
+ try {
1423
+ await api.health();
1424
+ console.log(chalk.green("Server: reachable"));
1425
+ } catch {
1426
+ console.log(chalk.red("Server: unreachable"));
1427
+ }
1428
+ const daemonStatus = getLaunchAgentStatus();
1429
+ console.log(chalk.green(`Daemon: ${daemonStatus}`));
1430
+ console.log();
896
1431
  });
1432
+ function readState() {
1433
+ const statePath = path5.join(getConfigDir(), "state.json");
1434
+ if (!fs5.existsSync(statePath)) return null;
1435
+ try {
1436
+ const raw = fs5.readFileSync(statePath, "utf-8");
1437
+ const data = JSON.parse(raw);
1438
+ return {
1439
+ lastSyncedAt: data.lastSyncedAt,
1440
+ dirty: {
1441
+ upserted: data.dirty?.upserted ?? [],
1442
+ deleted: data.dirty?.deleted ?? {}
1443
+ }
1444
+ };
1445
+ } catch {
1446
+ return null;
1447
+ }
1448
+ }
1449
+ function readConflicts() {
1450
+ const conflictsPath = path5.join(getConfigDir(), "conflicts.json");
1451
+ if (!fs5.existsSync(conflictsPath)) return [];
1452
+ try {
1453
+ return JSON.parse(fs5.readFileSync(conflictsPath, "utf-8"));
1454
+ } catch {
1455
+ return [];
1456
+ }
1457
+ }
1458
+ function formatConflictVersion(version) {
1459
+ if (version.deletedAt) return `deletedAt=${version.deletedAt}`;
1460
+ if (version.editedAt) return `editedAt=${version.editedAt}`;
1461
+ return "unknown";
1462
+ }
1463
+ function writeState(state) {
1464
+ const statePath = path5.join(getConfigDir(), "state.json");
1465
+ const temp = `${statePath}.tmp-${process.pid}`;
1466
+ fs5.writeFileSync(temp, JSON.stringify(state, null, 2));
1467
+ fs5.renameSync(temp, statePath);
1468
+ }
897
1469
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shared-things-daemon",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Sync Things 3 projects between multiple macOS users",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",