shared-things-daemon 1.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 (2) hide show
  1. package/dist/cli.js +815 -0
  2. package/package.json +56 -0
package/dist/cli.js ADDED
@@ -0,0 +1,815 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import { input, select, password, confirm } from "@inquirer/prompts";
6
+ import chalk from "chalk";
7
+ import updateNotifier from "update-notifier";
8
+
9
+ // src/config.ts
10
+ import * as fs from "fs";
11
+ import * as path from "path";
12
+ import * as os from "os";
13
+ var CONFIG_DIR = path.join(os.homedir(), ".shared-things");
14
+ var CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
15
+ function getConfigDir() {
16
+ return CONFIG_DIR;
17
+ }
18
+ function ensureConfigDir() {
19
+ if (!fs.existsSync(CONFIG_DIR)) {
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
21
+ }
22
+ }
23
+ function loadConfig() {
24
+ if (!fs.existsSync(CONFIG_PATH)) {
25
+ return null;
26
+ }
27
+ try {
28
+ const content = fs.readFileSync(CONFIG_PATH, "utf-8");
29
+ return JSON.parse(content);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ function saveConfig(config) {
35
+ ensureConfigDir();
36
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
37
+ }
38
+ function configExists() {
39
+ return fs.existsSync(CONFIG_PATH);
40
+ }
41
+
42
+ // src/things.ts
43
+ import { execSync } from "child_process";
44
+ function runAppleScript(script) {
45
+ try {
46
+ return execSync(`osascript -e '${script.replace(/'/g, `'"'"'`)}'`, {
47
+ encoding: "utf-8",
48
+ maxBuffer: 10 * 1024 * 1024
49
+ // 10MB buffer
50
+ }).trim();
51
+ } catch (error) {
52
+ throw new Error(`AppleScript failed: ${error}`);
53
+ }
54
+ }
55
+ function getTodosFromProject(projectName) {
56
+ const script = `
57
+ tell application "Things3"
58
+ set todoList to {}
59
+ set proj to project "${projectName}"
60
+ repeat with t in to dos of proj
61
+ set todoId to id of t
62
+ set todoTitle to name of t
63
+ set todoNotes to notes of t
64
+ set todoStatus to status of t
65
+ set todoDue to due date of t
66
+ set todoTags to tag names of t
67
+
68
+ -- Get heading if any
69
+ set todoHeading to ""
70
+ try
71
+ set todoHeading to id of (get area of t)
72
+ end try
73
+
74
+ -- Format due date
75
+ set dueDateStr to ""
76
+ if todoDue is not missing value then
77
+ set dueDateStr to (year of todoDue as string) & "-" & \xAC
78
+ (text -2 thru -1 of ("0" & (month of todoDue as integer) as string)) & "-" & \xAC
79
+ (text -2 thru -1 of ("0" & (day of todoDue) as string))
80
+ end if
81
+
82
+ -- Format status
83
+ set statusStr to "open"
84
+ if todoStatus is completed then
85
+ set statusStr to "completed"
86
+ else if todoStatus is canceled then
87
+ set statusStr to "canceled"
88
+ end if
89
+
90
+ set end of todoList to todoId & "|||" & todoTitle & "|||" & todoNotes & "|||" & dueDateStr & "|||" & todoTags & "|||" & statusStr & "|||" & todoHeading
91
+ end repeat
92
+
93
+ set AppleScript's text item delimiters to "^^^"
94
+ return todoList as string
95
+ end tell
96
+ `;
97
+ const result = runAppleScript(script);
98
+ if (!result) return [];
99
+ return result.split("^^^").map((line) => {
100
+ const [thingsId, title, notes, dueDate, tags, status, headingThingsId] = line.split("|||");
101
+ return {
102
+ thingsId,
103
+ title: title || "",
104
+ notes: notes || "",
105
+ dueDate: dueDate || null,
106
+ tags: tags ? tags.split(", ").filter(Boolean) : [],
107
+ status: status || "open",
108
+ headingThingsId: headingThingsId || null
109
+ };
110
+ });
111
+ }
112
+ function createTodo(projectName, todo) {
113
+ const params = new URLSearchParams();
114
+ params.set("title", todo.title);
115
+ if (todo.notes) params.set("notes", todo.notes);
116
+ if (todo.dueDate) params.set("when", todo.dueDate);
117
+ if (todo.tags?.length) params.set("tags", todo.tags.join(","));
118
+ params.set("list", projectName);
119
+ const url = `things:///add?${params.toString().replace(/\+/g, "%20")}`;
120
+ execSync(`open -g "${url}"`);
121
+ }
122
+ function updateTodo(authToken, thingsId, updates) {
123
+ const params = new URLSearchParams();
124
+ params.set("auth-token", authToken);
125
+ params.set("id", thingsId);
126
+ if (updates.title !== void 0) params.set("title", updates.title);
127
+ if (updates.notes !== void 0) params.set("notes", updates.notes);
128
+ if (updates.dueDate !== void 0) params.set("when", updates.dueDate);
129
+ if (updates.completed !== void 0) params.set("completed", updates.completed.toString());
130
+ if (updates.canceled !== void 0) params.set("canceled", updates.canceled.toString());
131
+ const url = `things:///update?${params.toString().replace(/\+/g, "%20")}`;
132
+ execSync(`open -g "${url}"`);
133
+ }
134
+ function isThingsRunning() {
135
+ try {
136
+ const result = runAppleScript(`
137
+ tell application "System Events"
138
+ return (name of processes) contains "Things3"
139
+ end tell
140
+ `);
141
+ return result === "true";
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+ function listProjects() {
147
+ const result = runAppleScript(`
148
+ tell application "Things3"
149
+ set projectNames to {}
150
+ repeat with p in projects
151
+ set end of projectNames to name of p
152
+ end repeat
153
+ set AppleScript's text item delimiters to "|||"
154
+ return projectNames as string
155
+ end tell
156
+ `);
157
+ if (!result) return [];
158
+ return result.split("|||").filter(Boolean);
159
+ }
160
+
161
+ // src/api.ts
162
+ var ApiClient = class {
163
+ constructor(serverUrl, apiKey) {
164
+ this.serverUrl = serverUrl;
165
+ this.apiKey = apiKey;
166
+ }
167
+ async request(path6, options = {}) {
168
+ const url = `${this.serverUrl}${path6}`;
169
+ const headers = {
170
+ "Authorization": `Bearer ${this.apiKey}`,
171
+ "Content-Type": "application/json",
172
+ ...options.headers
173
+ };
174
+ const response = await fetch(url, { ...options, headers });
175
+ if (!response.ok) {
176
+ const error = await response.json().catch(() => ({ error: "Unknown error" }));
177
+ throw new Error(`API error: ${error.error || response.statusText}`);
178
+ }
179
+ return response.json();
180
+ }
181
+ /**
182
+ * Get full project state
183
+ */
184
+ async getState() {
185
+ return this.request("/state");
186
+ }
187
+ /**
188
+ * Get changes since timestamp
189
+ */
190
+ async getDelta(since) {
191
+ return this.request(`/delta?since=${encodeURIComponent(since)}`);
192
+ }
193
+ /**
194
+ * Push local changes
195
+ */
196
+ async push(request) {
197
+ return this.request("/push", {
198
+ method: "POST",
199
+ body: JSON.stringify(request)
200
+ });
201
+ }
202
+ /**
203
+ * Health check
204
+ */
205
+ async health() {
206
+ return this.request("/health");
207
+ }
208
+ /**
209
+ * Reset all user data on server
210
+ */
211
+ async reset() {
212
+ return this.request("/reset", {
213
+ method: "DELETE"
214
+ });
215
+ }
216
+ };
217
+
218
+ // src/logger.ts
219
+ import * as fs2 from "fs";
220
+ import * as path2 from "path";
221
+ var LOG_FILE = "daemon.log";
222
+ var MAX_LOG_SIZE = 1024 * 1024;
223
+ function getLogPath() {
224
+ return path2.join(getConfigDir(), LOG_FILE);
225
+ }
226
+ function rotateIfNeeded() {
227
+ const logPath = getLogPath();
228
+ if (!fs2.existsSync(logPath)) return;
229
+ const stats = fs2.statSync(logPath);
230
+ if (stats.size > MAX_LOG_SIZE) {
231
+ const oldLog = logPath + ".1";
232
+ if (fs2.existsSync(oldLog)) {
233
+ fs2.unlinkSync(oldLog);
234
+ }
235
+ fs2.renameSync(logPath, oldLog);
236
+ }
237
+ }
238
+ function log(message) {
239
+ ensureConfigDir();
240
+ rotateIfNeeded();
241
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
242
+ const line = `[${timestamp}] ${message}
243
+ `;
244
+ fs2.appendFileSync(getLogPath(), line);
245
+ }
246
+ function logError(message, error) {
247
+ const errorMsg = error instanceof Error ? error.message : String(error);
248
+ log(`ERROR: ${message}${error ? ` - ${errorMsg}` : ""}`);
249
+ }
250
+ function logSync(pushed, pulled, isFirstSync) {
251
+ if (isFirstSync) {
252
+ log(`First sync completed: pushed=${pushed}, pulled=${pulled}`);
253
+ } else if (pushed > 0 || pulled > 0) {
254
+ log(`Sync: pushed=${pushed}, pulled=${pulled}`);
255
+ } else {
256
+ log("Sync: no changes");
257
+ }
258
+ }
259
+ function logMapping(serverId, thingsId) {
260
+ log(`Mapped server ${serverId} -> local ${thingsId}`);
261
+ }
262
+ function logTodoCreated(title) {
263
+ log(`Created todo: "${title}"`);
264
+ }
265
+ function logTodoUpdated(thingsId, title) {
266
+ log(`Updated todo ${thingsId}: "${title}"`);
267
+ }
268
+ function logDaemonStart() {
269
+ log("Daemon started");
270
+ }
271
+ function logDaemonStop() {
272
+ log("Daemon stopped");
273
+ }
274
+
275
+ // src/sync.ts
276
+ import * as fs3 from "fs";
277
+ import * as path3 from "path";
278
+ var STATE_FILE = path3.join(getConfigDir(), "state.json");
279
+ function loadLocalState() {
280
+ if (!fs3.existsSync(STATE_FILE)) {
281
+ return {
282
+ lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
283
+ todos: /* @__PURE__ */ new Map(),
284
+ serverIdToThingsId: /* @__PURE__ */ new Map()
285
+ };
286
+ }
287
+ try {
288
+ const content = fs3.readFileSync(STATE_FILE, "utf-8");
289
+ const data = JSON.parse(content);
290
+ return {
291
+ lastSyncedAt: data.lastSyncedAt,
292
+ todos: new Map(Object.entries(data.todos || {})),
293
+ serverIdToThingsId: new Map(Object.entries(data.serverIdToThingsId || {}))
294
+ };
295
+ } catch {
296
+ return {
297
+ lastSyncedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
298
+ todos: /* @__PURE__ */ new Map(),
299
+ serverIdToThingsId: /* @__PURE__ */ new Map()
300
+ };
301
+ }
302
+ }
303
+ function saveLocalState(state) {
304
+ const data = {
305
+ lastSyncedAt: state.lastSyncedAt,
306
+ todos: Object.fromEntries(state.todos),
307
+ serverIdToThingsId: Object.fromEntries(state.serverIdToThingsId)
308
+ };
309
+ fs3.writeFileSync(STATE_FILE, JSON.stringify(data, null, 2));
310
+ }
311
+ async function runSync() {
312
+ const config = loadConfig();
313
+ if (!config) {
314
+ throw new Error('Not configured. Run "shared-things init" first.');
315
+ }
316
+ const api = new ApiClient(config.serverUrl, config.apiKey);
317
+ const localState = loadLocalState();
318
+ const isFirstSync = localState.lastSyncedAt === (/* @__PURE__ */ new Date(0)).toISOString();
319
+ const currentTodos = getTodosFromProject(config.projectName);
320
+ const currentTodosMap = new Map(currentTodos.map((t) => [t.thingsId, t]));
321
+ const localChanges = {
322
+ upserted: [],
323
+ deleted: []
324
+ };
325
+ for (const [thingsId, todo] of currentTodosMap) {
326
+ const prev = localState.todos.get(thingsId);
327
+ if (!prev || hasChanged(prev, todo)) {
328
+ localChanges.upserted.push(todo);
329
+ }
330
+ }
331
+ for (const thingsId of localState.todos.keys()) {
332
+ if (!currentTodosMap.has(thingsId)) {
333
+ localChanges.deleted.push(thingsId);
334
+ }
335
+ }
336
+ const thingsIdToServerId = /* @__PURE__ */ new Map();
337
+ for (const [serverId, thingsId] of localState.serverIdToThingsId) {
338
+ thingsIdToServerId.set(thingsId, serverId);
339
+ }
340
+ let pushed = 0;
341
+ if (localChanges.upserted.length > 0 || localChanges.deleted.length > 0) {
342
+ const pushRequest = {
343
+ headings: { upserted: [], deleted: [] },
344
+ todos: {
345
+ upserted: localChanges.upserted.map((t, idx) => ({
346
+ // Include serverId if we know it (for updates)
347
+ serverId: thingsIdToServerId.get(t.thingsId),
348
+ thingsId: t.thingsId,
349
+ title: t.title,
350
+ notes: t.notes,
351
+ dueDate: t.dueDate,
352
+ tags: t.tags,
353
+ status: t.status,
354
+ headingId: t.headingThingsId,
355
+ position: idx
356
+ })),
357
+ // Convert thingsIds to serverIds for deletions
358
+ deleted: localChanges.deleted.map((thingsId) => thingsIdToServerId.get(thingsId)).filter((id) => id !== void 0)
359
+ },
360
+ lastSyncedAt: localState.lastSyncedAt
361
+ };
362
+ const pushResponse = await api.push(pushRequest);
363
+ pushed = localChanges.upserted.length + localChanges.deleted.length;
364
+ for (const serverTodo of pushResponse.state.todos) {
365
+ const localTodo = currentTodosMap.get(serverTodo.thingsId);
366
+ if (localTodo) {
367
+ localState.serverIdToThingsId.set(serverTodo.id, localTodo.thingsId);
368
+ }
369
+ }
370
+ }
371
+ const delta = await api.getDelta(localState.lastSyncedAt);
372
+ let pulled = 0;
373
+ for (const remoteTodo of delta.todos.upserted) {
374
+ const localThingsId = localState.serverIdToThingsId.get(remoteTodo.id);
375
+ const localTodo = localThingsId ? currentTodosMap.get(localThingsId) : null;
376
+ if (!localTodo) {
377
+ const beforeTodos = new Set(currentTodosMap.keys());
378
+ createTodo(config.projectName, {
379
+ title: remoteTodo.title,
380
+ notes: remoteTodo.notes,
381
+ dueDate: remoteTodo.dueDate || void 0,
382
+ tags: remoteTodo.tags
383
+ });
384
+ logTodoCreated(remoteTodo.title);
385
+ let newTodo;
386
+ for (let attempt = 0; attempt < 3; attempt++) {
387
+ await new Promise((resolve) => setTimeout(resolve, 500));
388
+ const afterTodos = getTodosFromProject(config.projectName);
389
+ newTodo = afterTodos.find(
390
+ (t) => !beforeTodos.has(t.thingsId) && t.title === remoteTodo.title
391
+ );
392
+ if (!newTodo) {
393
+ newTodo = afterTodos.find((t) => !beforeTodos.has(t.thingsId));
394
+ }
395
+ if (newTodo) break;
396
+ }
397
+ if (newTodo) {
398
+ localState.serverIdToThingsId.set(remoteTodo.id, newTodo.thingsId);
399
+ currentTodosMap.set(newTodo.thingsId, newTodo);
400
+ logMapping(remoteTodo.id, newTodo.thingsId);
401
+ } else {
402
+ logError(`Failed to find newly created todo for server ${remoteTodo.id}`, remoteTodo.title);
403
+ }
404
+ pulled++;
405
+ } else if (hasRemoteChanged(localTodo, remoteTodo)) {
406
+ updateTodo(config.thingsAuthToken, localTodo.thingsId, {
407
+ title: remoteTodo.title,
408
+ notes: remoteTodo.notes,
409
+ dueDate: remoteTodo.dueDate || void 0,
410
+ completed: remoteTodo.status === "completed",
411
+ canceled: remoteTodo.status === "canceled"
412
+ });
413
+ logTodoUpdated(localTodo.thingsId, remoteTodo.title);
414
+ pulled++;
415
+ }
416
+ }
417
+ if (delta.todos.deleted.length > 0) {
418
+ log(`Remote deletions (manual action needed): ${delta.todos.deleted.join(", ")}`);
419
+ }
420
+ localState.lastSyncedAt = delta.syncedAt;
421
+ localState.todos = currentTodosMap;
422
+ saveLocalState(localState);
423
+ logSync(pushed, pulled, isFirstSync);
424
+ return { pushed, pulled, isFirstSync };
425
+ }
426
+ function hasChanged(prev, curr) {
427
+ 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);
428
+ }
429
+ function hasRemoteChanged(local, remote) {
430
+ 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);
431
+ }
432
+
433
+ // src/launchagent.ts
434
+ import * as fs4 from "fs";
435
+ import * as path4 from "path";
436
+ import * as os2 from "os";
437
+ import { execSync as execSync2 } from "child_process";
438
+ var PLIST_NAME = "com.shared-things.daemon.plist";
439
+ var LAUNCH_AGENTS_DIR = path4.join(os2.homedir(), "Library", "LaunchAgents");
440
+ var PLIST_PATH = path4.join(LAUNCH_AGENTS_DIR, PLIST_NAME);
441
+ function installLaunchAgent() {
442
+ if (!fs4.existsSync(LAUNCH_AGENTS_DIR)) {
443
+ fs4.mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
444
+ }
445
+ let binPath;
446
+ try {
447
+ binPath = execSync2("which shared-things", { encoding: "utf-8" }).trim();
448
+ } catch {
449
+ const npmPrefix = execSync2("npm prefix -g", { encoding: "utf-8" }).trim();
450
+ binPath = path4.join(npmPrefix, "bin", "shared-things");
451
+ }
452
+ const logPath = path4.join(os2.homedir(), ".shared-things", "daemon.log");
453
+ let nodeBinDir;
454
+ try {
455
+ const nodePath = execSync2("which node", { encoding: "utf-8" }).trim();
456
+ nodeBinDir = path4.dirname(nodePath);
457
+ } catch {
458
+ nodeBinDir = "/usr/local/bin";
459
+ }
460
+ const envPath = [
461
+ nodeBinDir,
462
+ "/usr/local/bin",
463
+ "/usr/bin",
464
+ "/bin",
465
+ "/opt/homebrew/bin"
466
+ ].filter((p, i, arr) => arr.indexOf(p) === i).join(":");
467
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
468
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
469
+ <plist version="1.0">
470
+ <dict>
471
+ <key>Label</key>
472
+ <string>com.shared-things.daemon</string>
473
+
474
+ <key>ProgramArguments</key>
475
+ <array>
476
+ <string>${binPath}</string>
477
+ <string>daemon</string>
478
+ </array>
479
+
480
+ <key>RunAtLoad</key>
481
+ <true/>
482
+
483
+ <key>KeepAlive</key>
484
+ <true/>
485
+
486
+ <key>StandardOutPath</key>
487
+ <string>${logPath}</string>
488
+
489
+ <key>StandardErrorPath</key>
490
+ <string>${logPath}</string>
491
+
492
+ <key>EnvironmentVariables</key>
493
+ <dict>
494
+ <key>PATH</key>
495
+ <string>${envPath}</string>
496
+ </dict>
497
+
498
+ <key>ThrottleInterval</key>
499
+ <integer>30</integer>
500
+ </dict>
501
+ </plist>
502
+ `;
503
+ fs4.writeFileSync(PLIST_PATH, plist);
504
+ console.log(`Created: ${PLIST_PATH}`);
505
+ try {
506
+ execSync2(`launchctl unload "${PLIST_PATH}" 2>/dev/null || true`);
507
+ execSync2(`launchctl load "${PLIST_PATH}"`);
508
+ console.log("LaunchAgent installed and started.");
509
+ } catch (error) {
510
+ console.warn(`Warning: Could not load LaunchAgent: ${error}`);
511
+ console.warn("You may need to manually load it or restart your Mac.");
512
+ }
513
+ }
514
+ function uninstallLaunchAgent() {
515
+ if (!fs4.existsSync(PLIST_PATH)) {
516
+ console.log("LaunchAgent not installed.");
517
+ return;
518
+ }
519
+ try {
520
+ execSync2(`launchctl unload "${PLIST_PATH}"`);
521
+ } catch {
522
+ }
523
+ fs4.unlinkSync(PLIST_PATH);
524
+ console.log("LaunchAgent uninstalled.");
525
+ }
526
+ function isLaunchAgentInstalled() {
527
+ return fs4.existsSync(PLIST_PATH);
528
+ }
529
+ function getLaunchAgentStatus() {
530
+ if (!isLaunchAgentInstalled()) {
531
+ return "not-installed";
532
+ }
533
+ try {
534
+ const result = execSync2(`launchctl list | grep com.shared-things.daemon`, {
535
+ encoding: "utf-8"
536
+ });
537
+ return result.includes("com.shared-things.daemon") ? "running" : "stopped";
538
+ } catch {
539
+ return "stopped";
540
+ }
541
+ }
542
+
543
+ // src/cli.ts
544
+ import * as fs5 from "fs";
545
+ import * as path5 from "path";
546
+ import { spawn } from "child_process";
547
+ var pkg = JSON.parse(fs5.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
548
+ updateNotifier({ pkg }).notify();
549
+ var program = new Command();
550
+ program.name("shared-things").description("Sync a Things 3 project between multiple users").version("0.1.0");
551
+ program.command("init").description("Setup wizard").action(async () => {
552
+ console.log("\n\u{1F504} shared-things Setup\n");
553
+ if (configExists()) {
554
+ const overwrite = await confirm({
555
+ message: "Configuration already exists. Overwrite?",
556
+ default: false
557
+ });
558
+ if (!overwrite) {
559
+ console.log(chalk.dim("Cancelled."));
560
+ return;
561
+ }
562
+ const statePath = path5.join(getConfigDir(), "state.json");
563
+ if (fs5.existsSync(statePath)) {
564
+ fs5.unlinkSync(statePath);
565
+ console.log(chalk.dim("Old sync state cleared.\n"));
566
+ }
567
+ }
568
+ if (!isThingsRunning()) {
569
+ console.log("\u26A0\uFE0F Things 3 is not running. Please start it first.\n");
570
+ }
571
+ const serverUrl = await input({
572
+ message: "Server URL",
573
+ default: "https://things.example.com",
574
+ validate: (value) => {
575
+ if (!value) return "Server URL is required";
576
+ if (!value.startsWith("http://") && !value.startsWith("https://")) {
577
+ return "URL must start with http:// or https://";
578
+ }
579
+ return true;
580
+ }
581
+ });
582
+ const apiKey = await password({
583
+ message: "API Key",
584
+ mask: "*",
585
+ validate: (value) => value ? true : "API key is required"
586
+ });
587
+ console.log("\n\u23F3 Verifying connection...");
588
+ const api = new ApiClient(serverUrl, apiKey);
589
+ try {
590
+ await api.health();
591
+ console.log("\u2705 Server reachable");
592
+ } catch (error) {
593
+ console.error(`\u274C Failed to connect to server: ${error}`);
594
+ process.exit(1);
595
+ }
596
+ console.log("\u23F3 Verifying API key...");
597
+ try {
598
+ await api.getState();
599
+ console.log("\u2705 API key valid!\n");
600
+ } catch (error) {
601
+ console.error(`\u274C Invalid API key: ${error}`);
602
+ process.exit(1);
603
+ }
604
+ const projects = listProjects();
605
+ if (projects.length === 0) {
606
+ console.error("\u274C No Things projects found. Create a project in Things first.");
607
+ process.exit(1);
608
+ }
609
+ const projectName = await select({
610
+ message: "Things project to sync",
611
+ choices: projects.map((p) => ({ name: p, value: p }))
612
+ });
613
+ console.log("\n\u23F3 Checking Things project...");
614
+ const todos = getTodosFromProject(projectName);
615
+ console.log(`\u2705 Found ${todos.length} todo${todos.length === 1 ? "" : "s"} in "${projectName}"
616
+ `);
617
+ console.log("\u{1F4CB} Find your Things Auth Token in:");
618
+ console.log(" Things \u2192 Settings \u2192 General \u2192 Things URLs \u2192 Manage\n");
619
+ const thingsAuthToken = await password({
620
+ message: "Things Auth Token",
621
+ mask: "*",
622
+ validate: (value) => value ? true : "Auth token is required for updating tasks"
623
+ });
624
+ console.log(chalk.yellow("\n\u26A0\uFE0F Auth token will be verified on first sync.\n"));
625
+ saveConfig({
626
+ serverUrl,
627
+ apiKey,
628
+ projectName,
629
+ pollInterval: 30,
630
+ thingsAuthToken
631
+ });
632
+ console.log("\n\u2705 Configuration saved!\n");
633
+ console.log("Next steps:");
634
+ console.log(' 1. Run "shared-things install" to start the daemon');
635
+ console.log(' 2. Or run "shared-things sync" for a one-time sync\n');
636
+ });
637
+ program.command("install").description("Install launchd daemon (auto-start at login)").action(() => {
638
+ if (!configExists()) {
639
+ console.error('Not configured. Run "shared-things init" first.');
640
+ process.exit(1);
641
+ }
642
+ installLaunchAgent();
643
+ });
644
+ program.command("uninstall").description("Remove launchd daemon").action(() => {
645
+ uninstallLaunchAgent();
646
+ });
647
+ program.command("status").description("Show sync status").action(async () => {
648
+ if (!configExists()) {
649
+ console.log(chalk.yellow("\u26A0\uFE0F Not configured"));
650
+ console.log(chalk.dim('Run "shared-things init" to get started.'));
651
+ return;
652
+ }
653
+ const config = loadConfig();
654
+ const daemonStatus = getLaunchAgentStatus();
655
+ const isRunning = daemonStatus === "running";
656
+ console.log(chalk.bold("\n\u{1F4CA} shared-things Status\n"));
657
+ const api = new ApiClient(config.serverUrl, config.apiKey);
658
+ let serverReachable = false;
659
+ try {
660
+ await api.health();
661
+ serverReachable = true;
662
+ } catch {
663
+ serverReachable = false;
664
+ }
665
+ console.log(`${chalk.dim("Server:")} ${chalk.cyan(config.serverUrl)} ${serverReachable ? chalk.green("\u25CF") : chalk.red("\u25CB")}`);
666
+ console.log(`${chalk.dim("Project:")} ${chalk.white(config.projectName)}`);
667
+ console.log(`${chalk.dim("Interval:")} ${config.pollInterval}s`);
668
+ console.log(`${chalk.dim("Daemon:")} ${isRunning ? chalk.green("\u25CF running") : chalk.red("\u25CB stopped")}`);
669
+ const statePath = path5.join(getConfigDir(), "state.json");
670
+ if (fs5.existsSync(statePath)) {
671
+ const state = JSON.parse(fs5.readFileSync(statePath, "utf-8"));
672
+ const lastSync = new Date(state.lastSyncedAt);
673
+ const ago = formatTimeAgo(lastSync);
674
+ console.log(`${chalk.dim("Last sync:")} ${ago}`);
675
+ } else {
676
+ console.log(`${chalk.dim("Last sync:")} ${chalk.yellow("never")}`);
677
+ }
678
+ console.log();
679
+ });
680
+ function formatTimeAgo(date) {
681
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1e3);
682
+ if (seconds < 60) return "just now";
683
+ const minutes = Math.floor(seconds / 60);
684
+ if (minutes < 60) return `${minutes}m ago`;
685
+ const hours = Math.floor(minutes / 60);
686
+ if (hours < 24) return `${hours}h ago`;
687
+ const days = Math.floor(hours / 24);
688
+ return `${days}d ago`;
689
+ }
690
+ program.command("sync").description("Manual one-time sync").action(async () => {
691
+ if (!configExists()) {
692
+ console.error('Not configured. Run "shared-things init" first.');
693
+ process.exit(1);
694
+ }
695
+ try {
696
+ const result = await runSync();
697
+ if (result.isFirstSync) {
698
+ console.log(chalk.cyan("\u{1F4E5} First sync completed!"));
699
+ }
700
+ console.log(chalk.green(`\u2705 Done! Pushed: ${result.pushed}, Pulled: ${result.pulled}`));
701
+ } catch (error) {
702
+ logError("Manual sync failed", error);
703
+ console.error(chalk.red(`\u274C Sync failed: ${error}`));
704
+ process.exit(1);
705
+ }
706
+ });
707
+ program.command("daemon").description("Run sync daemon (used by launchd)").action(async () => {
708
+ if (!configExists()) {
709
+ console.error('Not configured. Run "shared-things init" first.');
710
+ process.exit(1);
711
+ }
712
+ const config = loadConfig();
713
+ logDaemonStart();
714
+ log(`Polling interval: ${config.pollInterval}s`);
715
+ console.log(`Daemon started. Syncing every ${config.pollInterval}s...`);
716
+ const shutdown = () => {
717
+ logDaemonStop();
718
+ process.exit(0);
719
+ };
720
+ process.on("SIGTERM", shutdown);
721
+ process.on("SIGINT", shutdown);
722
+ try {
723
+ await runSync();
724
+ log("Initial sync complete");
725
+ } catch (error) {
726
+ logError("Initial sync failed", error);
727
+ }
728
+ setInterval(async () => {
729
+ try {
730
+ await runSync();
731
+ } catch (error) {
732
+ logError("Sync failed", error);
733
+ }
734
+ }, config.pollInterval * 1e3);
735
+ });
736
+ program.command("logs").description("Show daemon logs").option("-f, --follow", "Follow log output").action((options) => {
737
+ const logPath = path5.join(getConfigDir(), "daemon.log");
738
+ if (!fs5.existsSync(logPath)) {
739
+ console.log("No logs yet.");
740
+ return;
741
+ }
742
+ if (options.follow) {
743
+ spawn("tail", ["-f", logPath], { stdio: "inherit" });
744
+ } else {
745
+ const logs = fs5.readFileSync(logPath, "utf-8");
746
+ console.log(logs);
747
+ }
748
+ });
749
+ 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) => {
750
+ if (!configExists()) {
751
+ console.log(chalk.yellow("Not configured. Nothing to reset."));
752
+ return;
753
+ }
754
+ const statePath = path5.join(getConfigDir(), "state.json");
755
+ const hasLocalState = fs5.existsSync(statePath);
756
+ if (!hasLocalState && !options.server) {
757
+ console.log(chalk.yellow("No sync state to reset."));
758
+ return;
759
+ }
760
+ let message = "This will clear your local sync state.";
761
+ if (options.server) {
762
+ message = "This will clear your local sync state AND delete all your data on the server.";
763
+ }
764
+ message += " Continue?";
765
+ const confirmed = await confirm({
766
+ message,
767
+ default: false
768
+ });
769
+ if (!confirmed) {
770
+ console.log(chalk.dim("Cancelled."));
771
+ return;
772
+ }
773
+ if (options.server) {
774
+ const config = loadConfig();
775
+ const api = new ApiClient(config.serverUrl, config.apiKey);
776
+ try {
777
+ console.log(chalk.dim("Deleting server data..."));
778
+ const result = await api.reset();
779
+ log(`Server reset: deleted ${result.deleted.todos} todos, ${result.deleted.headings} headings`);
780
+ console.log(chalk.green(`\u2705 Server data deleted (${result.deleted.todos} todos, ${result.deleted.headings} headings)`));
781
+ } catch (error) {
782
+ logError("Server reset failed", error);
783
+ console.error(chalk.red(`\u274C Failed to reset server: ${error}`));
784
+ return;
785
+ }
786
+ }
787
+ if (hasLocalState) {
788
+ fs5.unlinkSync(statePath);
789
+ log("Sync state reset by user");
790
+ }
791
+ console.log(chalk.green('\u2705 Reset complete. Run "shared-things sync" for a fresh sync.'));
792
+ });
793
+ program.command("purge").description("Remove all local data (config, state, logs)").action(async () => {
794
+ const configDir = getConfigDir();
795
+ if (!fs5.existsSync(configDir)) {
796
+ console.log(chalk.yellow("Nothing to purge."));
797
+ return;
798
+ }
799
+ const daemonStatus = getLaunchAgentStatus();
800
+ if (daemonStatus === "running") {
801
+ console.log(chalk.yellow("\u26A0\uFE0F Daemon is still running. Stopping it first..."));
802
+ uninstallLaunchAgent();
803
+ }
804
+ const confirmed = await confirm({
805
+ message: `This will delete all local data in ${configDir}. You will need to run "init" again. Continue?`,
806
+ default: false
807
+ });
808
+ if (!confirmed) {
809
+ console.log(chalk.dim("Cancelled."));
810
+ return;
811
+ }
812
+ fs5.rmSync(configDir, { recursive: true, force: true });
813
+ console.log(chalk.green('\u2705 All local data removed. Run "shared-things init" to start fresh.'));
814
+ });
815
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "shared-things-daemon",
3
+ "version": "1.0.0",
4
+ "description": "Sync Things 3 projects between multiple macOS users",
5
+ "type": "module",
6
+ "main": "./dist/cli.js",
7
+ "bin": {
8
+ "shared-things": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "dev": "tsx watch src/cli.ts",
16
+ "prepublishOnly": "pnpm build"
17
+ },
18
+ "keywords": [
19
+ "things",
20
+ "things3",
21
+ "sync",
22
+ "macos",
23
+ "productivity",
24
+ "todo",
25
+ "collaboration",
26
+ "daemon",
27
+ "launchd",
28
+ "cli"
29
+ ],
30
+ "author": "yungweng",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/yungweng/shared-things.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/yungweng/shared-things/issues"
38
+ },
39
+ "homepage": "https://github.com/yungweng/shared-things#readme",
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "dependencies": {
44
+ "@inquirer/prompts": "^8.1.0",
45
+ "chalk": "^5.6.2",
46
+ "commander": "^12.0.0",
47
+ "update-notifier": "^7.3.1"
48
+ },
49
+ "devDependencies": {
50
+ "@shared-things/common": "workspace:*",
51
+ "@types/node": "^20.0.0",
52
+ "tsup": "^8.5.1",
53
+ "tsx": "^4.7.0",
54
+ "typescript": "^5.3.0"
55
+ }
56
+ }