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 +10 -8
- package/dist/cli.js +445 -269
- package/package.json +7 -2
- package/scripts/postinstall.js +124 -0
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ shared-things init
|
|
|
59
59
|
### After Setup
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
shared-things
|
|
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
|
-
| `
|
|
73
|
-
| `
|
|
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
|
-
| `
|
|
78
|
-
| `
|
|
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 |
|
|
147
|
-
| Headings
|
|
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 {
|
|
5
|
-
import
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
FROM
|
|
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
|
-
`
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
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
|
|
155
|
-
const rows = db.prepare(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
`
|
|
197
|
+
`
|
|
198
|
+
).all();
|
|
163
199
|
return rows.map((row) => ({
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
`
|
|
220
|
+
`
|
|
221
|
+
).all(since);
|
|
178
222
|
return rows.map((row) => ({
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
215
|
-
WHERE
|
|
216
|
-
`
|
|
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
|
-
|
|
279
|
+
serverId
|
|
227
280
|
);
|
|
228
|
-
return
|
|
281
|
+
return;
|
|
229
282
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
INSERT INTO todos (id,
|
|
233
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
234
|
-
`
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
299
|
+
userId
|
|
247
300
|
);
|
|
248
|
-
return id;
|
|
249
301
|
}
|
|
250
|
-
function deleteTodoByServerId(db, serverId
|
|
251
|
-
const existing = db.prepare(`SELECT id
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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({
|
|
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 (
|
|
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
|
|
393
|
+
deleted
|
|
341
394
|
},
|
|
342
395
|
syncedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
343
396
|
};
|
|
344
397
|
});
|
|
345
|
-
app.post(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
480
|
-
|
|
481
|
-
|
|
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(
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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(
|
|
503
|
-
|
|
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:
|
|
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(
|
|
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], {
|
|
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()))
|
|
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(
|
|
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(
|
|
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()))
|
|
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 =
|
|
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)
|
|
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(
|
|
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
|
|
915
|
+
program.command("reset").description("Delete all todos (keeps users)").action(async () => {
|
|
741
916
|
const db = initDatabase();
|
|
742
917
|
const todos = getAllTodos(db);
|
|
743
|
-
|
|
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
|
|
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
|
|
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
|
|
785
|
-
if (fs2.existsSync(dbPath
|
|
786
|
-
console.log(
|
|
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": "
|
|
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
|
+
}
|