shared-things-server 1.0.5 → 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.
package/README.md CHANGED
@@ -59,7 +59,7 @@ shared-things init
59
59
  ### After Setup
60
60
 
61
61
  ```bash
62
- shared-things install # Start daemon (auto-runs on login)
62
+ shared-things start # Start daemon (auto-runs on login)
63
63
  shared-things status # Check sync status
64
64
  shared-things logs -f # Follow sync logs
65
65
  ```
@@ -69,13 +69,16 @@ shared-things logs -f # Follow sync logs
69
69
  | Command | Description |
70
70
  |---------|-------------|
71
71
  | `init` | Setup wizard |
72
- | `install` | Install launchd daemon (auto-starts on login) |
73
- | `uninstall` | Remove launchd daemon |
72
+ | `start` | Start launchd daemon (auto-starts on login) |
73
+ | `stop` | Stop launchd daemon |
74
74
  | `status` | Show sync status & last sync time |
75
75
  | `sync` | Force immediate sync |
76
76
  | `logs [-f]` | Show logs (`-f` to follow) |
77
- | `reset [--server]` | Reset local state (`--server` clears server too) |
78
- | `purge` | Remove all local config |
77
+ | `conflicts [--all]` | Show conflict history |
78
+ | `repair` | Diagnose state issues (no auto-fix) |
79
+ | `reset --local` | Clear local state |
80
+ | `reset --server` | Clear server data for this user |
81
+ | `doctor` | Comprehensive health check |
79
82
 
80
83
  ## Server Setup
81
84
 
@@ -143,9 +146,8 @@ things.yourdomain.com {
143
146
 
144
147
  | Synced | Not Synced |
145
148
  |--------|------------|
146
- | Todo title, notes, due date, tags | Completed todos |
147
- | Headings | Checklist items |
148
- | | Areas |
149
+ | Todo title, notes, due date, tags, status | Checklist items |
150
+ | | Headings, Areas |
149
151
 
150
152
  > **Note:** The project must exist in each user's Things app. Only items within that project sync.
151
153
 
package/dist/cli.js CHANGED
@@ -1,17 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command } from "commander";
5
- import { input, confirm } from "@inquirer/prompts";
4
+ import { spawn } from "child_process";
5
+ import * as fs2 from "fs";
6
+ import * as os2 from "os";
7
+ import * as path2 from "path";
8
+ import cors from "@fastify/cors";
9
+ import { confirm, input } from "@inquirer/prompts";
6
10
  import chalk from "chalk";
11
+ import { Command } from "commander";
12
+ import Fastify from "fastify";
7
13
  import updateNotifier from "update-notifier";
8
14
 
9
15
  // src/db.ts
10
- import Database from "better-sqlite3";
11
- import * as path from "path";
12
- import * as os from "os";
13
- import * as fs from "fs";
14
16
  import * as crypto from "crypto";
17
+ import * as fs from "fs";
18
+ import * as os from "os";
19
+ import * as path from "path";
20
+ import Database from "better-sqlite3";
15
21
  var DATA_DIR = process.env.DATA_DIR || path.join(os.homedir(), ".shared-things-server");
16
22
  var DB_PATH = path.join(DATA_DIR, "data.db");
17
23
  function initDatabase() {
@@ -20,8 +26,9 @@ function initDatabase() {
20
26
  }
21
27
  const db = new Database(DB_PATH);
22
28
  db.pragma("journal_mode = WAL");
29
+ db.pragma("foreign_keys = ON");
30
+ migrateDatabase(db);
23
31
  db.exec(`
24
- -- Users table
25
32
  CREATE TABLE IF NOT EXISTS users (
26
33
  id TEXT PRIMARY KEY,
27
34
  name TEXT NOT NULL,
@@ -29,49 +36,111 @@ function initDatabase() {
29
36
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
30
37
  );
31
38
 
32
- -- Headings table
33
- CREATE TABLE IF NOT EXISTS headings (
34
- id TEXT PRIMARY KEY,
35
- things_id TEXT NOT NULL UNIQUE,
36
- title TEXT NOT NULL,
37
- position INTEGER NOT NULL DEFAULT 0,
38
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
39
- updated_by TEXT NOT NULL REFERENCES users(id),
40
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
41
- );
42
-
43
- -- Todos table
44
39
  CREATE TABLE IF NOT EXISTS todos (
45
40
  id TEXT PRIMARY KEY,
46
- things_id TEXT NOT NULL UNIQUE,
47
41
  title TEXT NOT NULL,
48
42
  notes TEXT NOT NULL DEFAULT '',
49
43
  due_date TEXT,
50
44
  tags TEXT NOT NULL DEFAULT '[]',
51
45
  status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'completed', 'canceled')),
52
- heading_id TEXT REFERENCES headings(id) ON DELETE SET NULL,
53
46
  position INTEGER NOT NULL DEFAULT 0,
54
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
55
- updated_by TEXT NOT NULL REFERENCES users(id),
56
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
47
+ edited_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL,
49
+ created_by TEXT NOT NULL REFERENCES users(id),
50
+ updated_by TEXT NOT NULL REFERENCES users(id)
57
51
  );
58
52
 
59
- -- Deleted items tracking (for sync)
60
53
  CREATE TABLE IF NOT EXISTS deleted_items (
61
54
  id TEXT PRIMARY KEY,
62
- things_id TEXT NOT NULL,
63
- item_type TEXT NOT NULL CHECK (item_type IN ('todo', 'heading')),
64
- deleted_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ server_id TEXT NOT NULL,
56
+ deleted_at TEXT NOT NULL,
57
+ recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
65
58
  deleted_by TEXT NOT NULL REFERENCES users(id)
66
59
  );
67
60
 
68
- -- Indexes
69
61
  CREATE INDEX IF NOT EXISTS idx_todos_updated ON todos(updated_at);
70
- CREATE INDEX IF NOT EXISTS idx_headings_updated ON headings(updated_at);
71
- CREATE INDEX IF NOT EXISTS idx_deleted_at ON deleted_items(deleted_at);
62
+ CREATE INDEX IF NOT EXISTS idx_deleted_recorded ON deleted_items(recorded_at);
72
63
  `);
73
64
  return db;
74
65
  }
66
+ function migrateDatabase(db) {
67
+ db.pragma("foreign_keys = OFF");
68
+ const hasTodos = db.prepare(
69
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='todos'`
70
+ ).get();
71
+ if (hasTodos) {
72
+ const columns = db.prepare(`PRAGMA table_info(todos)`).all();
73
+ const hasThingsId = columns.some((col) => col.name === "things_id");
74
+ const hasEditedAt = columns.some((col) => col.name === "edited_at");
75
+ if (hasThingsId || !hasEditedAt) {
76
+ db.exec(`
77
+ CREATE TABLE IF NOT EXISTS todos_new (
78
+ id TEXT PRIMARY KEY,
79
+ title TEXT NOT NULL,
80
+ notes TEXT NOT NULL DEFAULT '',
81
+ due_date TEXT,
82
+ tags TEXT NOT NULL DEFAULT '[]',
83
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'completed', 'canceled')),
84
+ position INTEGER NOT NULL DEFAULT 0,
85
+ edited_at TEXT NOT NULL,
86
+ updated_at TEXT NOT NULL,
87
+ created_by TEXT NOT NULL,
88
+ updated_by TEXT NOT NULL
89
+ );
90
+ `);
91
+ db.exec(`
92
+ INSERT INTO todos_new (id, title, notes, due_date, tags, status, position, edited_at, updated_at, created_by, updated_by)
93
+ SELECT id, title, notes, due_date, tags, status, position, updated_at, updated_at, updated_by, updated_by
94
+ FROM todos;
95
+ `);
96
+ db.exec(`
97
+ DROP TABLE todos;
98
+ ALTER TABLE todos_new RENAME TO todos;
99
+ `);
100
+ }
101
+ }
102
+ const hasDeleted = db.prepare(
103
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='deleted_items'`
104
+ ).get();
105
+ if (hasDeleted) {
106
+ const columns = db.prepare(`PRAGMA table_info(deleted_items)`).all();
107
+ const hasServerId = columns.some((col) => col.name === "server_id");
108
+ const hasRecordedAt = columns.some((col) => col.name === "recorded_at");
109
+ if (!hasServerId) {
110
+ db.exec(`
111
+ CREATE TABLE IF NOT EXISTS deleted_items_new (
112
+ id TEXT PRIMARY KEY,
113
+ server_id TEXT NOT NULL,
114
+ deleted_at TEXT NOT NULL,
115
+ recorded_at TEXT NOT NULL,
116
+ deleted_by TEXT NOT NULL
117
+ );
118
+ `);
119
+ db.exec(`
120
+ INSERT INTO deleted_items_new (id, server_id, deleted_at, recorded_at, deleted_by)
121
+ SELECT id, things_id, deleted_at, deleted_at, deleted_by
122
+ FROM deleted_items
123
+ WHERE item_type = 'todo';
124
+ `);
125
+ db.exec(`
126
+ DROP TABLE deleted_items;
127
+ ALTER TABLE deleted_items_new RENAME TO deleted_items;
128
+ `);
129
+ } else if (!hasRecordedAt) {
130
+ db.exec(`
131
+ ALTER TABLE deleted_items ADD COLUMN recorded_at TEXT;
132
+ UPDATE deleted_items SET recorded_at = deleted_at WHERE recorded_at IS NULL;
133
+ `);
134
+ }
135
+ }
136
+ const hasHeadings = db.prepare(
137
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='headings'`
138
+ ).get();
139
+ if (hasHeadings) {
140
+ db.exec(`DROP TABLE headings;`);
141
+ }
142
+ db.pragma("foreign_keys = ON");
143
+ }
75
144
  function userExists(db, name) {
76
145
  const row = db.prepare(`SELECT 1 FROM users WHERE name = ?`).get(name);
77
146
  return !!row;
@@ -91,201 +160,188 @@ function createUser(db, name) {
91
160
  }
92
161
  function getUserByApiKey(db, apiKey) {
93
162
  const apiKeyHash = crypto.createHash("sha256").update(apiKey).digest("hex");
94
- const row = db.prepare(`
95
- SELECT id, name FROM users WHERE api_key_hash = ?
96
- `).get(apiKeyHash);
163
+ const row = db.prepare(`SELECT id, name FROM users WHERE api_key_hash = ?`).get(apiKeyHash);
97
164
  return row || null;
98
165
  }
99
166
  function listUsers(db) {
100
- return db.prepare(`
101
- SELECT id, name, created_at as createdAt FROM users
102
- `).all();
103
- }
104
- function getAllHeadings(db) {
105
- return db.prepare(`
106
- SELECT
107
- id, things_id as thingsId, title, position,
108
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
109
- FROM headings
110
- ORDER BY position
111
- `).all();
167
+ return db.prepare(`SELECT id, name, created_at as createdAt FROM users`).all();
112
168
  }
113
- function getHeadingsSince(db, since) {
114
- return db.prepare(`
115
- SELECT
116
- id, things_id as thingsId, title, position,
117
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
118
- FROM headings
119
- WHERE updated_at > ?
169
+ function getAllTodos(db) {
170
+ const rows = db.prepare(
171
+ `
172
+ SELECT id, title, notes, due_date, tags, status, position,
173
+ edited_at, updated_at, updated_by
174
+ FROM todos
120
175
  ORDER BY position
121
- `).all(since);
122
- }
123
- function upsertHeading(db, thingsId, title, position, userId) {
124
- const now = (/* @__PURE__ */ new Date()).toISOString();
125
- const existing = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(thingsId);
126
- if (existing) {
127
- db.prepare(`
128
- UPDATE headings
129
- SET title = ?, position = ?, updated_at = ?, updated_by = ?
130
- WHERE things_id = ?
131
- `).run(title, position, now, userId, thingsId);
132
- return existing.id;
133
- } else {
134
- const id = crypto.randomUUID();
135
- db.prepare(`
136
- INSERT INTO headings (id, things_id, title, position, updated_at, updated_by, created_at)
137
- VALUES (?, ?, ?, ?, ?, ?, ?)
138
- `).run(id, thingsId, title, position, now, userId, now);
139
- return id;
140
- }
141
- }
142
- function deleteHeading(db, thingsId, userId) {
143
- const existing = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(thingsId);
144
- if (!existing) return false;
145
- const now = (/* @__PURE__ */ new Date()).toISOString();
146
- const deleteId = crypto.randomUUID();
147
- db.prepare(`
148
- INSERT INTO deleted_items (id, things_id, item_type, deleted_at, deleted_by)
149
- VALUES (?, ?, 'heading', ?, ?)
150
- `).run(deleteId, thingsId, now, userId);
151
- db.prepare(`DELETE FROM headings WHERE things_id = ?`).run(thingsId);
152
- return true;
176
+ `
177
+ ).all();
178
+ return rows.map((row) => ({
179
+ id: row.id,
180
+ title: row.title,
181
+ notes: row.notes,
182
+ dueDate: row.due_date,
183
+ tags: JSON.parse(row.tags),
184
+ status: row.status,
185
+ position: row.position,
186
+ editedAt: row.edited_at,
187
+ updatedAt: row.updated_at
188
+ }));
153
189
  }
154
- function getAllTodos(db) {
155
- const rows = db.prepare(`
156
- SELECT
157
- id, things_id as thingsId, title, notes, due_date as dueDate,
158
- tags, status, heading_id as headingId, position,
159
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
190
+ function getAllTodosWithMeta(db) {
191
+ const rows = db.prepare(
192
+ `
193
+ SELECT id, title, notes, due_date, tags, status, position,
194
+ edited_at, updated_at, updated_by
160
195
  FROM todos
161
196
  ORDER BY position
162
- `).all();
197
+ `
198
+ ).all();
163
199
  return rows.map((row) => ({
164
- ...row,
165
- tags: JSON.parse(row.tags)
200
+ id: row.id,
201
+ title: row.title,
202
+ notes: row.notes,
203
+ dueDate: row.due_date,
204
+ tags: JSON.parse(row.tags),
205
+ status: row.status,
206
+ position: row.position,
207
+ editedAt: row.edited_at,
208
+ updatedAt: row.updated_at,
209
+ updatedBy: row.updated_by
166
210
  }));
167
211
  }
168
212
  function getTodosSince(db, since) {
169
- const rows = db.prepare(`
170
- SELECT
171
- id, things_id as thingsId, title, notes, due_date as dueDate,
172
- tags, status, heading_id as headingId, position,
173
- updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
213
+ const rows = db.prepare(
214
+ `
215
+ SELECT id, title, notes, due_date, tags, status, position,
216
+ edited_at, updated_at, updated_by
174
217
  FROM todos
175
218
  WHERE updated_at > ?
176
219
  ORDER BY position
177
- `).all(since);
220
+ `
221
+ ).all(since);
178
222
  return rows.map((row) => ({
179
- ...row,
180
- tags: JSON.parse(row.tags)
223
+ id: row.id,
224
+ title: row.title,
225
+ notes: row.notes,
226
+ dueDate: row.due_date,
227
+ tags: JSON.parse(row.tags),
228
+ status: row.status,
229
+ position: row.position,
230
+ editedAt: row.edited_at,
231
+ updatedAt: row.updated_at
181
232
  }));
182
233
  }
183
- function upsertTodoByServerId(db, serverId, data, userId) {
234
+ function getTodoByServerId(db, serverId) {
235
+ const row = db.prepare(
236
+ `
237
+ SELECT id, title, notes, due_date, tags, status, position,
238
+ edited_at, updated_at, updated_by
239
+ FROM todos
240
+ WHERE id = ?
241
+ `
242
+ ).get(serverId);
243
+ if (!row) return null;
244
+ return {
245
+ id: row.id,
246
+ title: row.title,
247
+ notes: row.notes,
248
+ dueDate: row.due_date,
249
+ tags: JSON.parse(row.tags),
250
+ status: row.status,
251
+ position: row.position,
252
+ editedAt: row.edited_at,
253
+ updatedAt: row.updated_at,
254
+ updatedBy: row.updated_by
255
+ };
256
+ }
257
+ function upsertTodo(db, serverId, data, userId) {
184
258
  const now = (/* @__PURE__ */ new Date()).toISOString();
185
259
  const tagsJson = JSON.stringify(data.tags);
186
- if (serverId) {
187
- const existing = db.prepare(`SELECT id FROM todos WHERE id = ?`).get(serverId);
188
- if (existing) {
189
- db.prepare(`
190
- UPDATE todos
191
- SET title = ?, notes = ?, due_date = ?, tags = ?, status = ?,
192
- heading_id = ?, position = ?, updated_at = ?, updated_by = ?
193
- WHERE id = ?
194
- `).run(
195
- data.title,
196
- data.notes,
197
- data.dueDate,
198
- tagsJson,
199
- data.status,
200
- data.headingId,
201
- data.position,
202
- now,
203
- userId,
204
- serverId
205
- );
206
- return serverId;
207
- }
208
- }
209
- const existingByThingsId = db.prepare(`SELECT id FROM todos WHERE things_id = ?`).get(data.thingsId);
210
- if (existingByThingsId) {
211
- db.prepare(`
260
+ const existing = db.prepare(`SELECT id FROM todos WHERE id = ?`).get(serverId);
261
+ if (existing) {
262
+ db.prepare(
263
+ `
212
264
  UPDATE todos
213
265
  SET title = ?, notes = ?, due_date = ?, tags = ?, status = ?,
214
- heading_id = ?, position = ?, updated_at = ?, updated_by = ?
215
- WHERE things_id = ?
216
- `).run(
266
+ position = ?, edited_at = ?, updated_at = ?, updated_by = ?
267
+ WHERE id = ?
268
+ `
269
+ ).run(
217
270
  data.title,
218
271
  data.notes,
219
272
  data.dueDate,
220
273
  tagsJson,
221
274
  data.status,
222
- data.headingId,
223
275
  data.position,
276
+ data.editedAt,
224
277
  now,
225
278
  userId,
226
- data.thingsId
279
+ serverId
227
280
  );
228
- return existingByThingsId.id;
281
+ return;
229
282
  }
230
- const id = serverId || crypto.randomUUID();
231
- db.prepare(`
232
- INSERT INTO todos (id, things_id, title, notes, due_date, tags, status, heading_id, position, updated_at, updated_by, created_at)
233
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
234
- `).run(
235
- id,
236
- data.thingsId,
283
+ db.prepare(
284
+ `
285
+ INSERT INTO todos (id, title, notes, due_date, tags, status, position, edited_at, updated_at, created_by, updated_by)
286
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
287
+ `
288
+ ).run(
289
+ serverId,
237
290
  data.title,
238
291
  data.notes,
239
292
  data.dueDate,
240
293
  tagsJson,
241
294
  data.status,
242
- data.headingId,
243
295
  data.position,
296
+ data.editedAt,
244
297
  now,
245
298
  userId,
246
- now
299
+ userId
247
300
  );
248
- return id;
249
301
  }
250
- function deleteTodoByServerId(db, serverId, userId) {
251
- const existing = db.prepare(`SELECT id, things_id FROM todos WHERE id = ?`).get(serverId);
302
+ function deleteTodoByServerId(db, serverId) {
303
+ const existing = db.prepare(`SELECT id FROM todos WHERE id = ?`).get(serverId);
252
304
  if (!existing) return false;
253
- const now = (/* @__PURE__ */ new Date()).toISOString();
254
- const deleteId = crypto.randomUUID();
255
- db.prepare(`
256
- INSERT INTO deleted_items (id, things_id, item_type, deleted_at, deleted_by)
257
- VALUES (?, ?, 'todo', ?, ?)
258
- `).run(deleteId, serverId, now, userId);
259
305
  db.prepare(`DELETE FROM todos WHERE id = ?`).run(serverId);
260
306
  return true;
261
307
  }
308
+ function getDeletedByServerId(db, serverId) {
309
+ const row = db.prepare(
310
+ `SELECT deleted_at as deletedAt, deleted_by as deletedBy FROM deleted_items WHERE server_id = ? ORDER BY deleted_at DESC LIMIT 1`
311
+ ).get(serverId);
312
+ return row || null;
313
+ }
314
+ function recordDeletion(db, serverId, deletedAt, userId) {
315
+ db.prepare(`DELETE FROM deleted_items WHERE server_id = ?`).run(serverId);
316
+ const deleteId = crypto.randomUUID();
317
+ const recordedAt = (/* @__PURE__ */ new Date()).toISOString();
318
+ db.prepare(
319
+ `
320
+ INSERT INTO deleted_items (id, server_id, deleted_at, recorded_at, deleted_by)
321
+ VALUES (?, ?, ?, ?, ?)
322
+ `
323
+ ).run(deleteId, serverId, deletedAt, recordedAt, userId);
324
+ }
325
+ function clearDeletion(db, serverId) {
326
+ db.prepare(`DELETE FROM deleted_items WHERE server_id = ?`).run(serverId);
327
+ }
262
328
  function getDeletedSince(db, since) {
263
- const rows = db.prepare(`
264
- SELECT things_id, item_type FROM deleted_items WHERE deleted_at > ?
265
- `).all(since);
266
- return {
267
- todos: rows.filter((r) => r.item_type === "todo").map((r) => r.things_id),
268
- headings: rows.filter((r) => r.item_type === "heading").map((r) => r.things_id)
269
- };
329
+ return db.prepare(
330
+ `
331
+ SELECT server_id as serverId, deleted_at as deletedAt
332
+ FROM deleted_items
333
+ WHERE recorded_at > ?
334
+ `
335
+ ).all(since);
270
336
  }
271
337
  function resetUserData(db, userId) {
272
- const todoResult = db.prepare(`DELETE FROM todos WHERE updated_by = ?`).run(userId);
273
- const headingResult = db.prepare(`DELETE FROM headings WHERE updated_by = ?`).run(userId);
338
+ const todoResult = db.prepare(`DELETE FROM todos WHERE updated_by = ? OR created_by = ?`).run(userId, userId);
274
339
  db.prepare(`DELETE FROM deleted_items WHERE deleted_by = ?`).run(userId);
275
340
  return {
276
- deletedTodos: todoResult.changes,
277
- deletedHeadings: headingResult.changes
341
+ deletedTodos: todoResult.changes
278
342
  };
279
343
  }
280
344
 
281
- // src/cli.ts
282
- import * as fs2 from "fs";
283
- import * as path2 from "path";
284
- import * as os2 from "os";
285
- import { spawn } from "child_process";
286
- import Fastify from "fastify";
287
- import cors from "@fastify/cors";
288
-
289
345
  // src/auth.ts
290
346
  function authMiddleware(db) {
291
347
  return (request, reply, done) => {
@@ -294,7 +350,10 @@ function authMiddleware(db) {
294
350
  }
295
351
  const authHeader = request.headers.authorization;
296
352
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
297
- reply.code(401).send({ error: "Missing or invalid authorization header", code: "UNAUTHORIZED" });
353
+ reply.code(401).send({
354
+ error: "Missing or invalid authorization header",
355
+ code: "UNAUTHORIZED"
356
+ });
298
357
  return;
299
358
  }
300
359
  const apiKey = authHeader.slice(7);
@@ -309,15 +368,14 @@ function authMiddleware(db) {
309
368
  }
310
369
 
311
370
  // src/routes.ts
371
+ import * as crypto2 from "crypto";
312
372
  function registerRoutes(app, db) {
313
373
  app.get("/health", async () => {
314
374
  return { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
315
375
  });
316
- app.get("/state", async (request) => {
317
- const headings = getAllHeadings(db);
376
+ app.get("/state", async (_request) => {
318
377
  const todos = getAllTodos(db);
319
378
  return {
320
- headings,
321
379
  todos,
322
380
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
323
381
  };
@@ -327,89 +385,181 @@ function registerRoutes(app, db) {
327
385
  if (!since) {
328
386
  return { error: 'Missing "since" query parameter', code: "BAD_REQUEST" };
329
387
  }
330
- const headings = getHeadingsSince(db, since);
331
388
  const todos = getTodosSince(db, since);
332
389
  const deleted = getDeletedSince(db, since);
333
390
  return {
334
- headings: {
335
- upserted: headings,
336
- deleted: deleted.headings
337
- },
338
391
  todos: {
339
392
  upserted: todos,
340
- deleted: deleted.todos
393
+ deleted
341
394
  },
342
395
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
343
396
  };
344
397
  });
345
- app.post("/push", async (request, reply) => {
346
- const { headings, todos } = request.body;
347
- const userId = request.user.id;
348
- const conflicts = [];
349
- try {
350
- for (const thingsId of headings.deleted) {
351
- deleteHeading(db, thingsId, userId);
352
- }
353
- for (const heading of headings.upserted) {
354
- upsertHeading(db, heading.thingsId, heading.title, heading.position, userId);
355
- }
356
- for (const serverId of todos.deleted) {
357
- deleteTodoByServerId(db, serverId, userId);
358
- }
359
- for (const todo of todos.upserted) {
360
- let headingId = null;
361
- if (todo.headingId) {
362
- const headingRow = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(todo.headingId);
363
- headingId = headingRow?.id || null;
398
+ app.post(
399
+ "/push",
400
+ async (request, reply) => {
401
+ const { todos } = request.body;
402
+ const userId = request.user.id;
403
+ const conflicts = [];
404
+ const mappings = [];
405
+ try {
406
+ const transaction = db.transaction(() => {
407
+ for (const deletion of todos.deleted) {
408
+ const existing = getTodoByServerId(db, deletion.serverId);
409
+ if (!existing) {
410
+ const existingDeletion = getDeletedByServerId(
411
+ db,
412
+ deletion.serverId
413
+ );
414
+ if (!existingDeletion || compareIso(deletion.deletedAt, existingDeletion.deletedAt) > 0) {
415
+ recordDeletion(
416
+ db,
417
+ deletion.serverId,
418
+ deletion.deletedAt,
419
+ userId
420
+ );
421
+ }
422
+ continue;
423
+ }
424
+ const shouldDelete = shouldApplyChange(
425
+ deletion.deletedAt,
426
+ existing.editedAt,
427
+ userId,
428
+ existing.updatedBy
429
+ );
430
+ if (!shouldDelete) {
431
+ conflicts.push({
432
+ serverId: deletion.serverId,
433
+ reason: "Remote edit was newer",
434
+ serverTodo: toTodo(existing),
435
+ clientDeletedAt: deletion.deletedAt
436
+ });
437
+ continue;
438
+ }
439
+ deleteTodoByServerId(db, deletion.serverId);
440
+ recordDeletion(db, deletion.serverId, deletion.deletedAt, userId);
441
+ }
442
+ for (const todo of todos.upserted) {
443
+ const serverId = todo.serverId || crypto2.randomUUID();
444
+ const position = typeof todo.position === "number" && Number.isFinite(todo.position) ? todo.position : 0;
445
+ const existingDeletion = getDeletedByServerId(db, serverId);
446
+ if (existingDeletion) {
447
+ const editWins = shouldApplyChange(
448
+ todo.editedAt,
449
+ existingDeletion.deletedAt,
450
+ userId,
451
+ existingDeletion.deletedBy
452
+ );
453
+ if (!editWins) {
454
+ conflicts.push({
455
+ serverId,
456
+ reason: "Remote delete was newer",
457
+ serverTodo: null,
458
+ clientTodo: todo
459
+ });
460
+ continue;
461
+ }
462
+ clearDeletion(db, serverId);
463
+ }
464
+ const existing = getTodoByServerId(db, serverId);
465
+ if (existing) {
466
+ const shouldApply = shouldApplyChange(
467
+ todo.editedAt,
468
+ existing.editedAt,
469
+ userId,
470
+ existing.updatedBy
471
+ );
472
+ if (!shouldApply) {
473
+ conflicts.push({
474
+ serverId,
475
+ reason: "Remote edit was newer",
476
+ serverTodo: toTodo(existing),
477
+ clientTodo: todo
478
+ });
479
+ continue;
480
+ }
481
+ } else if (todo.serverId) {
482
+ }
483
+ upsertTodo(
484
+ db,
485
+ serverId,
486
+ {
487
+ title: todo.title,
488
+ notes: todo.notes,
489
+ dueDate: todo.dueDate,
490
+ tags: todo.tags,
491
+ status: todo.status,
492
+ position,
493
+ editedAt: todo.editedAt
494
+ },
495
+ userId
496
+ );
497
+ if (!todo.serverId && todo.clientId) {
498
+ mappings?.push({ serverId, clientId: todo.clientId });
499
+ }
500
+ }
501
+ });
502
+ transaction();
503
+ } catch (err) {
504
+ const error = err;
505
+ if (error.message?.includes("UNIQUE constraint failed")) {
506
+ reply.status(409);
507
+ return {
508
+ error: 'Sync conflict: Server has data that conflicts with your local state. Run "shared-things reset --server" to start fresh.',
509
+ code: "SYNC_CONFLICT"
510
+ };
364
511
  }
365
- upsertTodoByServerId(db, todo.serverId, {
366
- thingsId: todo.thingsId,
367
- title: todo.title,
368
- notes: todo.notes,
369
- dueDate: todo.dueDate,
370
- tags: todo.tags,
371
- status: todo.status,
372
- headingId,
373
- position: todo.position
374
- }, userId);
512
+ throw err;
375
513
  }
376
- } catch (err) {
377
- const error = err;
378
- if (error.message?.includes("UNIQUE constraint failed")) {
379
- reply.status(409);
380
- return {
381
- error: 'Sync conflict: Server has data that conflicts with your local state. Run "shared-things reset --server" to start fresh.',
382
- code: "SYNC_CONFLICT"
383
- };
384
- }
385
- throw err;
514
+ const currentTodos = getAllTodos(db);
515
+ return {
516
+ state: {
517
+ todos: currentTodos,
518
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
519
+ },
520
+ conflicts,
521
+ mappings: mappings?.length ? mappings : void 0
522
+ };
386
523
  }
387
- const currentHeadings = getAllHeadings(db);
388
- const currentTodos = getAllTodos(db);
389
- return {
390
- state: {
391
- headings: currentHeadings,
392
- todos: currentTodos,
393
- syncedAt: (/* @__PURE__ */ new Date()).toISOString()
394
- },
395
- conflicts
396
- };
397
- });
524
+ );
398
525
  app.delete("/reset", async (request) => {
399
526
  const userId = request.user.id;
400
527
  const result = resetUserData(db, userId);
401
528
  return {
402
529
  success: true,
403
530
  deleted: {
404
- todos: result.deletedTodos,
405
- headings: result.deletedHeadings
531
+ todos: result.deletedTodos
406
532
  }
407
533
  };
408
534
  });
409
535
  }
536
+ function compareIso(a, b) {
537
+ return new Date(a).getTime() - new Date(b).getTime();
538
+ }
539
+ function shouldApplyChange(incomingEditedAt, storedEditedAt, incomingUserId, storedUserId) {
540
+ const diff = compareIso(incomingEditedAt, storedEditedAt);
541
+ if (diff > 0) return true;
542
+ if (diff < 0) return false;
543
+ return incomingUserId > storedUserId;
544
+ }
545
+ function toTodo(todo) {
546
+ return {
547
+ id: todo.id,
548
+ title: todo.title,
549
+ notes: todo.notes,
550
+ dueDate: todo.dueDate,
551
+ tags: todo.tags,
552
+ status: todo.status,
553
+ position: todo.position,
554
+ editedAt: todo.editedAt,
555
+ updatedAt: todo.updatedAt
556
+ };
557
+ }
410
558
 
411
559
  // src/cli.ts
412
- var pkg = JSON.parse(fs2.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
560
+ var pkg = JSON.parse(
561
+ fs2.readFileSync(new URL("../package.json", import.meta.url), "utf-8")
562
+ );
413
563
  var updateCheckInterval = 1e3 * 60 * 60;
414
564
  var notifier = updateNotifier({ pkg, updateCheckInterval });
415
565
  if (notifier.update) {
@@ -439,8 +589,10 @@ if (notifier.update && notifier.update.current !== notifier.update.latest) {
439
589
  process.on("exit", () => {
440
590
  if (notifier.update && notifier.update.current !== notifier.update.latest) {
441
591
  console.error(
442
- chalk.yellow(`
443
- Update available: ${notifier.update.current} \u2192 ${notifier.update.latest}`) + chalk.dim(`
592
+ chalk.yellow(
593
+ `
594
+ Update available: ${notifier.update.current} \u2192 ${notifier.update.latest}`
595
+ ) + chalk.dim(`
444
596
  Run: npm i -g ${pkg.name}
445
597
  `)
446
598
  );
@@ -476,9 +628,13 @@ program.command("start").description("Start the sync server").option("-p, --port
476
628
  if (!isChildProcess) {
477
629
  const status = isServerRunning();
478
630
  if (status.running) {
479
- console.log(chalk.yellow(`
480
- \u26A0\uFE0F Server already running (PID: ${status.pid})`));
481
- console.log(chalk.dim('Use "shared-things-server stop" to stop it first.\n'));
631
+ console.log(
632
+ chalk.yellow(`
633
+ \u26A0\uFE0F Server already running (PID: ${status.pid})`)
634
+ );
635
+ console.log(
636
+ chalk.dim('Use "shared-things-server stop" to stop it first.\n')
637
+ );
482
638
  return;
483
639
  }
484
640
  }
@@ -486,11 +642,15 @@ program.command("start").description("Start the sync server").option("-p, --port
486
642
  ensureDataDir();
487
643
  const logFd = fs2.openSync(LOG_FILE, "a");
488
644
  const scriptPath = process.argv[1];
489
- const child = spawn(process.execPath, [scriptPath, "start", "--port", String(PORT), "--host", HOST], {
490
- detached: true,
491
- stdio: ["ignore", logFd, logFd],
492
- env: { ...process.env, SHARED_THINGS_DETACHED: "1" }
493
- });
645
+ const child = spawn(
646
+ process.execPath,
647
+ [scriptPath, "start", "--port", String(PORT), "--host", HOST],
648
+ {
649
+ detached: true,
650
+ stdio: ["ignore", logFd, logFd],
651
+ env: { ...process.env, SHARED_THINGS_DETACHED: "1" }
652
+ }
653
+ );
494
654
  fs2.writeFileSync(PID_FILE, String(child.pid));
495
655
  child.unref();
496
656
  fs2.closeSync(logFd);
@@ -499,13 +659,17 @@ program.command("start").description("Start the sync server").option("-p, --port
499
659
  console.log(` ${chalk.dim("PID:")} ${child.pid}`);
500
660
  console.log(` ${chalk.dim("URL:")} http://${HOST}:${PORT}`);
501
661
  console.log(` ${chalk.dim("Logs:")} ${LOG_FILE}`);
502
- console.log(chalk.dim('\nUse "shared-things-server logs -f" to follow logs'));
503
- console.log(chalk.dim('Use "shared-things-server stop" to stop the server\n'));
662
+ console.log(
663
+ chalk.dim('\nUse "shared-things-server logs -f" to follow logs')
664
+ );
665
+ console.log(
666
+ chalk.dim('Use "shared-things-server stop" to stop the server\n')
667
+ );
504
668
  return;
505
669
  }
506
670
  const db = initDatabase();
507
671
  const app = Fastify({
508
- logger: isChildProcess ? true : true
672
+ logger: true
509
673
  });
510
674
  await app.register(cors, {
511
675
  origin: true
@@ -525,9 +689,11 @@ program.command("start").description("Start the sync server").option("-p, --port
525
689
  try {
526
690
  await app.listen({ port: PORT, host: HOST });
527
691
  if (!process.env.SHARED_THINGS_DETACHED) {
528
- console.log(chalk.green(`
692
+ console.log(
693
+ chalk.green(`
529
694
  \u2705 Server running at http://${HOST}:${PORT}
530
- `));
695
+ `)
696
+ );
531
697
  }
532
698
  } catch (err) {
533
699
  app.log.error(err);
@@ -599,7 +765,9 @@ program.command("logs").description("Show server logs").option("-f, --follow", "
599
765
  process.exit(0);
600
766
  });
601
767
  } else {
602
- const tail = spawn("tail", ["-n", options.lines, LOG_FILE], { stdio: "inherit" });
768
+ const tail = spawn("tail", ["-n", options.lines, LOG_FILE], {
769
+ stdio: "inherit"
770
+ });
603
771
  tail.on("close", () => process.exit(0));
604
772
  }
605
773
  });
@@ -612,7 +780,8 @@ program.command("create-user").description("Create a new user and generate API k
612
780
  message: "Username",
613
781
  validate: (value) => {
614
782
  if (!value.trim()) return "Username is required";
615
- if (userExists(db, value.trim())) return `User "${value.trim()}" already exists`;
783
+ if (userExists(db, value.trim()))
784
+ return `User "${value.trim()}" already exists`;
616
785
  return true;
617
786
  }
618
787
  });
@@ -628,14 +797,18 @@ program.command("create-user").description("Create a new user and generate API k
628
797
  console.log(` ${chalk.dim("ID:")} ${id}`);
629
798
  console.log(` ${chalk.dim("Name:")} ${name}`);
630
799
  console.log(` ${chalk.dim("API Key:")} ${chalk.cyan(apiKey)}`);
631
- console.log(chalk.yellow("\n\u26A0\uFE0F Save this API key - it cannot be retrieved later!\n"));
800
+ console.log(
801
+ chalk.yellow("\n\u26A0\uFE0F Save this API key - it cannot be retrieved later!\n")
802
+ );
632
803
  });
633
804
  program.command("list-users").description("List all users").action(async () => {
634
805
  const db = initDatabase();
635
806
  const users = listUsers(db);
636
807
  if (users.length === 0) {
637
808
  console.log(chalk.yellow("\nNo users found.\n"));
638
- console.log(chalk.dim("Create a user with: shared-things-server create-user\n"));
809
+ console.log(
810
+ chalk.dim("Create a user with: shared-things-server create-user\n")
811
+ );
639
812
  } else {
640
813
  console.log(chalk.bold(`
641
814
  \u{1F465} Users (${users.length})
@@ -666,7 +839,8 @@ program.command("delete-user").description("Delete a user").option("-n, --name <
666
839
  message: "Username to delete",
667
840
  validate: (value) => {
668
841
  if (!value.trim()) return "Username is required";
669
- if (!users.find((u) => u.name === value.trim())) return "User not found";
842
+ if (!users.find((u) => u.name === value.trim()))
843
+ return "User not found";
670
844
  return true;
671
845
  }
672
846
  });
@@ -687,7 +861,6 @@ program.command("delete-user").description("Delete a user").option("-n, --name <
687
861
  return;
688
862
  }
689
863
  db.prepare("DELETE FROM todos WHERE updated_by = ?").run(user.id);
690
- db.prepare("DELETE FROM headings WHERE updated_by = ?").run(user.id);
691
864
  db.prepare("DELETE FROM deleted_items WHERE deleted_by = ?").run(user.id);
692
865
  db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
693
866
  console.log(chalk.green(`
@@ -696,7 +869,7 @@ program.command("delete-user").description("Delete a user").option("-n, --name <
696
869
  });
697
870
  program.command("list-todos").description("List all todos").option("-u, --user <name>", "Filter by username").action(async (options) => {
698
871
  const db = initDatabase();
699
- const todos = getAllTodos(db);
872
+ const todos = getAllTodosWithMeta(db);
700
873
  const users = listUsers(db);
701
874
  const userMap = new Map(users.map((u) => [u.id, u.name]));
702
875
  let filteredTodos = todos;
@@ -724,7 +897,7 @@ ${title}
724
897
  const statusColor = todo.status === "completed" ? chalk.green : todo.status === "canceled" ? chalk.red : chalk.white;
725
898
  console.log(` ${statusColor(statusIcon)} ${chalk.white(todo.title)}`);
726
899
  if (todo.notes) {
727
- const shortNotes = todo.notes.length > 50 ? todo.notes.substring(0, 50) + "..." : todo.notes;
900
+ const shortNotes = todo.notes.length > 50 ? `${todo.notes.substring(0, 50)}...` : todo.notes;
728
901
  console.log(` ${chalk.dim("Notes:")} ${shortNotes}`);
729
902
  }
730
903
  if (todo.dueDate) {
@@ -733,24 +906,24 @@ ${title}
733
906
  if (todo.tags && todo.tags.length > 0) {
734
907
  console.log(` ${chalk.dim("Tags:")} ${todo.tags.join(", ")}`);
735
908
  }
736
- console.log(` ${chalk.dim("Status:")} ${todo.status} ${chalk.dim("|")} ${chalk.dim("By:")} ${userName} ${chalk.dim("|")} ${todo.updatedAt}`);
909
+ console.log(
910
+ ` ${chalk.dim("Status:")} ${todo.status} ${chalk.dim("|")} ${chalk.dim("By:")} ${userName} ${chalk.dim("|")} ${todo.updatedAt}`
911
+ );
737
912
  console.log();
738
913
  }
739
914
  });
740
- program.command("reset").description("Delete all todos and headings (keeps users)").action(async () => {
915
+ program.command("reset").description("Delete all todos (keeps users)").action(async () => {
741
916
  const db = initDatabase();
742
917
  const todos = getAllTodos(db);
743
- const headings = getAllHeadings(db);
744
- if (todos.length === 0 && headings.length === 0) {
918
+ if (todos.length === 0) {
745
919
  console.log(chalk.yellow("\nNo data to reset.\n"));
746
920
  return;
747
921
  }
748
922
  console.log(chalk.bold("\n\u{1F504} Reset Server Data\n"));
749
923
  console.log(` ${chalk.dim("Todos:")} ${todos.length}`);
750
- console.log(` ${chalk.dim("Headings:")} ${headings.length}`);
751
924
  console.log();
752
925
  const confirmed = await confirm({
753
- message: "Delete all todos and headings? Users will be kept.",
926
+ message: "Delete all todos? Users will be kept.",
754
927
  default: false
755
928
  });
756
929
  if (!confirmed) {
@@ -758,9 +931,8 @@ program.command("reset").description("Delete all todos and headings (keeps users
758
931
  return;
759
932
  }
760
933
  db.prepare("DELETE FROM todos").run();
761
- db.prepare("DELETE FROM headings").run();
762
934
  db.prepare("DELETE FROM deleted_items").run();
763
- console.log(chalk.green("\n\u2705 All todos and headings deleted. Users preserved.\n"));
935
+ console.log(chalk.green("\n\u2705 All todos deleted. Users preserved.\n"));
764
936
  });
765
937
  program.command("purge").description("Delete entire database (all data including users)").action(async () => {
766
938
  const dataDir = process.env.DATA_DIR || path2.join(os2.homedir(), ".shared-things-server");
@@ -781,8 +953,12 @@ program.command("purge").description("Delete entire database (all data including
781
953
  return;
782
954
  }
783
955
  fs2.unlinkSync(dbPath);
784
- if (fs2.existsSync(dbPath + "-wal")) fs2.unlinkSync(dbPath + "-wal");
785
- if (fs2.existsSync(dbPath + "-shm")) fs2.unlinkSync(dbPath + "-shm");
786
- console.log(chalk.green('\n\u2705 Database deleted. Run "shared-things-server create-user" to start fresh.\n'));
956
+ if (fs2.existsSync(`${dbPath}-wal`)) fs2.unlinkSync(`${dbPath}-wal`);
957
+ if (fs2.existsSync(`${dbPath}-shm`)) fs2.unlinkSync(`${dbPath}-shm`);
958
+ console.log(
959
+ chalk.green(
960
+ '\n\u2705 Database deleted. Run "shared-things-server create-user" to start fresh.\n'
961
+ )
962
+ );
787
963
  });
788
964
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shared-things-server",
3
- "version": "1.0.5",
3
+ "version": "2.0.0",
4
4
  "description": "Sync server for Things 3 projects",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "scripts",
12
13
  "README.md"
13
14
  ],
14
15
  "keywords": [
@@ -55,6 +56,10 @@
55
56
  },
56
57
  "scripts": {
57
58
  "build": "tsup",
58
- "dev": "tsx watch src/cli.ts start"
59
+ "dev": "tsx watch src/cli.ts start",
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
63
+ "postinstall": "node scripts/postinstall.js || true"
59
64
  }
60
65
  }
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Postinstall script: Automatically restart the server if it's running.
5
+ * This ensures users get the new version after `npm update -g shared-things-server`.
6
+ *
7
+ * Safe behaviors:
8
+ * - Silent success if server is not running
9
+ * - Never fails npm install (catches all errors)
10
+ * - Works on any platform (Linux, macOS)
11
+ */
12
+
13
+ import { execSync } from "node:child_process";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+
18
+ const DATA_DIR =
19
+ process.env.DATA_DIR || path.join(os.homedir(), ".shared-things-server");
20
+ const PID_FILE = path.join(DATA_DIR, "server.pid");
21
+
22
+ function isServerRunning() {
23
+ if (!fs.existsSync(PID_FILE)) {
24
+ return { running: false };
25
+ }
26
+
27
+ const pid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
28
+
29
+ try {
30
+ // Check if process exists (signal 0 doesn't kill, just checks)
31
+ process.kill(pid, 0);
32
+ return { running: true, pid };
33
+ } catch {
34
+ // Process doesn't exist, clean up stale PID file
35
+ try {
36
+ fs.unlinkSync(PID_FILE);
37
+ } catch {
38
+ // Ignore cleanup errors
39
+ }
40
+ return { running: false };
41
+ }
42
+ }
43
+
44
+ function stopServer(pid) {
45
+ try {
46
+ process.kill(pid, "SIGTERM");
47
+ // Wait for graceful shutdown
48
+ let attempts = 0;
49
+ while (attempts < 10) {
50
+ try {
51
+ process.kill(pid, 0);
52
+ // Still running, wait
53
+ execSync("sleep 0.2", { stdio: "pipe" });
54
+ attempts++;
55
+ } catch {
56
+ // Process stopped
57
+ break;
58
+ }
59
+ }
60
+ // Clean up PID file if still exists
61
+ if (fs.existsSync(PID_FILE)) {
62
+ fs.unlinkSync(PID_FILE);
63
+ }
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function startServer() {
71
+ // Verify the command exists
72
+ try {
73
+ execSync("which shared-things-server", { stdio: "pipe" });
74
+ } catch {
75
+ // Command not in PATH, can't start
76
+ return false;
77
+ }
78
+
79
+ // Use the CLI's built-in detach mode (-d flag)
80
+ // This properly handles daemonization and PID file management
81
+ try {
82
+ execSync("shared-things-server start -d", {
83
+ stdio: "pipe",
84
+ timeout: 10000,
85
+ });
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function main() {
93
+ const status = isServerRunning();
94
+
95
+ if (!status.running) {
96
+ // Server not running, nothing to restart
97
+ return;
98
+ }
99
+
100
+ // Stop the old server
101
+ const stopped = stopServer(status.pid);
102
+ if (!stopped) {
103
+ console.log(
104
+ ' ℹ️ Could not auto-restart server. Run "shared-things-server stop && shared-things-server start -d" manually.',
105
+ );
106
+ return;
107
+ }
108
+
109
+ // Start with new version
110
+ const started = startServer();
111
+ if (started) {
112
+ console.log(" ✅ Server restarted with new version");
113
+ } else {
114
+ console.log(
115
+ ' ℹ️ Server stopped. Run "shared-things-server start -d" to start it again.',
116
+ );
117
+ }
118
+ }
119
+
120
+ try {
121
+ main();
122
+ } catch {
123
+ // Never fail npm install
124
+ }