shared-things-server 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 +745 -0
  2. package/package.json +59 -0
package/dist/cli.js ADDED
@@ -0,0 +1,745 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import { input, confirm } from "@inquirer/prompts";
6
+ import chalk from "chalk";
7
+
8
+ // src/db.ts
9
+ import Database from "better-sqlite3";
10
+ import * as path from "path";
11
+ import * as os from "os";
12
+ import * as fs from "fs";
13
+ import * as crypto from "crypto";
14
+ var DATA_DIR = process.env.DATA_DIR || path.join(os.homedir(), ".shared-things-server");
15
+ var DB_PATH = path.join(DATA_DIR, "data.db");
16
+ function initDatabase() {
17
+ if (!fs.existsSync(DATA_DIR)) {
18
+ fs.mkdirSync(DATA_DIR, { recursive: true });
19
+ }
20
+ const db = new Database(DB_PATH);
21
+ db.pragma("journal_mode = WAL");
22
+ db.exec(`
23
+ -- Users table
24
+ CREATE TABLE IF NOT EXISTS users (
25
+ id TEXT PRIMARY KEY,
26
+ name TEXT NOT NULL,
27
+ api_key_hash TEXT NOT NULL UNIQUE,
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
29
+ );
30
+
31
+ -- Headings table
32
+ CREATE TABLE IF NOT EXISTS headings (
33
+ id TEXT PRIMARY KEY,
34
+ things_id TEXT NOT NULL UNIQUE,
35
+ title TEXT NOT NULL,
36
+ position INTEGER NOT NULL DEFAULT 0,
37
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
38
+ updated_by TEXT NOT NULL REFERENCES users(id),
39
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
40
+ );
41
+
42
+ -- Todos table
43
+ CREATE TABLE IF NOT EXISTS todos (
44
+ id TEXT PRIMARY KEY,
45
+ things_id TEXT NOT NULL UNIQUE,
46
+ title TEXT NOT NULL,
47
+ notes TEXT NOT NULL DEFAULT '',
48
+ due_date TEXT,
49
+ tags TEXT NOT NULL DEFAULT '[]',
50
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'completed', 'canceled')),
51
+ heading_id TEXT REFERENCES headings(id) ON DELETE SET NULL,
52
+ position INTEGER NOT NULL DEFAULT 0,
53
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
54
+ updated_by TEXT NOT NULL REFERENCES users(id),
55
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ );
57
+
58
+ -- Deleted items tracking (for sync)
59
+ CREATE TABLE IF NOT EXISTS deleted_items (
60
+ id TEXT PRIMARY KEY,
61
+ things_id TEXT NOT NULL,
62
+ item_type TEXT NOT NULL CHECK (item_type IN ('todo', 'heading')),
63
+ deleted_at TEXT NOT NULL DEFAULT (datetime('now')),
64
+ deleted_by TEXT NOT NULL REFERENCES users(id)
65
+ );
66
+
67
+ -- Indexes
68
+ CREATE INDEX IF NOT EXISTS idx_todos_updated ON todos(updated_at);
69
+ CREATE INDEX IF NOT EXISTS idx_headings_updated ON headings(updated_at);
70
+ CREATE INDEX IF NOT EXISTS idx_deleted_at ON deleted_items(deleted_at);
71
+ `);
72
+ return db;
73
+ }
74
+ function userExists(db, name) {
75
+ const row = db.prepare(`SELECT 1 FROM users WHERE name = ?`).get(name);
76
+ return !!row;
77
+ }
78
+ function createUser(db, name) {
79
+ if (userExists(db, name)) {
80
+ throw new Error(`User "${name}" already exists`);
81
+ }
82
+ const id = crypto.randomUUID();
83
+ const apiKey = crypto.randomBytes(32).toString("hex");
84
+ const apiKeyHash = crypto.createHash("sha256").update(apiKey).digest("hex");
85
+ db.prepare(`
86
+ INSERT INTO users (id, name, api_key_hash)
87
+ VALUES (?, ?, ?)
88
+ `).run(id, name, apiKeyHash);
89
+ return { id, apiKey };
90
+ }
91
+ function getUserByApiKey(db, apiKey) {
92
+ const apiKeyHash = crypto.createHash("sha256").update(apiKey).digest("hex");
93
+ const row = db.prepare(`
94
+ SELECT id, name FROM users WHERE api_key_hash = ?
95
+ `).get(apiKeyHash);
96
+ return row || null;
97
+ }
98
+ function listUsers(db) {
99
+ return db.prepare(`
100
+ SELECT id, name, created_at as createdAt FROM users
101
+ `).all();
102
+ }
103
+ function getAllHeadings(db) {
104
+ return db.prepare(`
105
+ SELECT
106
+ id, things_id as thingsId, title, position,
107
+ updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
108
+ FROM headings
109
+ ORDER BY position
110
+ `).all();
111
+ }
112
+ function getHeadingsSince(db, since) {
113
+ return db.prepare(`
114
+ SELECT
115
+ id, things_id as thingsId, title, position,
116
+ updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
117
+ FROM headings
118
+ WHERE updated_at > ?
119
+ ORDER BY position
120
+ `).all(since);
121
+ }
122
+ function upsertHeading(db, thingsId, title, position, userId) {
123
+ const now = (/* @__PURE__ */ new Date()).toISOString();
124
+ const existing = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(thingsId);
125
+ if (existing) {
126
+ db.prepare(`
127
+ UPDATE headings
128
+ SET title = ?, position = ?, updated_at = ?, updated_by = ?
129
+ WHERE things_id = ?
130
+ `).run(title, position, now, userId, thingsId);
131
+ return existing.id;
132
+ } else {
133
+ const id = crypto.randomUUID();
134
+ db.prepare(`
135
+ INSERT INTO headings (id, things_id, title, position, updated_at, updated_by, created_at)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?)
137
+ `).run(id, thingsId, title, position, now, userId, now);
138
+ return id;
139
+ }
140
+ }
141
+ function deleteHeading(db, thingsId, userId) {
142
+ const existing = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(thingsId);
143
+ if (!existing) return false;
144
+ const now = (/* @__PURE__ */ new Date()).toISOString();
145
+ const deleteId = crypto.randomUUID();
146
+ db.prepare(`
147
+ INSERT INTO deleted_items (id, things_id, item_type, deleted_at, deleted_by)
148
+ VALUES (?, ?, 'heading', ?, ?)
149
+ `).run(deleteId, thingsId, now, userId);
150
+ db.prepare(`DELETE FROM headings WHERE things_id = ?`).run(thingsId);
151
+ return true;
152
+ }
153
+ function getAllTodos(db) {
154
+ const rows = db.prepare(`
155
+ SELECT
156
+ id, things_id as thingsId, title, notes, due_date as dueDate,
157
+ tags, status, heading_id as headingId, position,
158
+ updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
159
+ FROM todos
160
+ ORDER BY position
161
+ `).all();
162
+ return rows.map((row) => ({
163
+ ...row,
164
+ tags: JSON.parse(row.tags)
165
+ }));
166
+ }
167
+ function getTodosSince(db, since) {
168
+ const rows = db.prepare(`
169
+ SELECT
170
+ id, things_id as thingsId, title, notes, due_date as dueDate,
171
+ tags, status, heading_id as headingId, position,
172
+ updated_at as updatedAt, updated_by as updatedBy, created_at as createdAt
173
+ FROM todos
174
+ WHERE updated_at > ?
175
+ ORDER BY position
176
+ `).all(since);
177
+ return rows.map((row) => ({
178
+ ...row,
179
+ tags: JSON.parse(row.tags)
180
+ }));
181
+ }
182
+ function upsertTodoByServerId(db, serverId, data, userId) {
183
+ const now = (/* @__PURE__ */ new Date()).toISOString();
184
+ const tagsJson = JSON.stringify(data.tags);
185
+ if (serverId) {
186
+ const existing = db.prepare(`SELECT id FROM todos WHERE id = ?`).get(serverId);
187
+ if (existing) {
188
+ db.prepare(`
189
+ UPDATE todos
190
+ SET title = ?, notes = ?, due_date = ?, tags = ?, status = ?,
191
+ heading_id = ?, position = ?, updated_at = ?, updated_by = ?
192
+ WHERE id = ?
193
+ `).run(
194
+ data.title,
195
+ data.notes,
196
+ data.dueDate,
197
+ tagsJson,
198
+ data.status,
199
+ data.headingId,
200
+ data.position,
201
+ now,
202
+ userId,
203
+ serverId
204
+ );
205
+ return serverId;
206
+ }
207
+ }
208
+ const existingByThingsId = db.prepare(`SELECT id FROM todos WHERE things_id = ?`).get(data.thingsId);
209
+ if (existingByThingsId) {
210
+ db.prepare(`
211
+ UPDATE todos
212
+ SET title = ?, notes = ?, due_date = ?, tags = ?, status = ?,
213
+ heading_id = ?, position = ?, updated_at = ?, updated_by = ?
214
+ WHERE things_id = ?
215
+ `).run(
216
+ data.title,
217
+ data.notes,
218
+ data.dueDate,
219
+ tagsJson,
220
+ data.status,
221
+ data.headingId,
222
+ data.position,
223
+ now,
224
+ userId,
225
+ data.thingsId
226
+ );
227
+ return existingByThingsId.id;
228
+ }
229
+ const id = serverId || crypto.randomUUID();
230
+ db.prepare(`
231
+ INSERT INTO todos (id, things_id, title, notes, due_date, tags, status, heading_id, position, updated_at, updated_by, created_at)
232
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
233
+ `).run(
234
+ id,
235
+ data.thingsId,
236
+ data.title,
237
+ data.notes,
238
+ data.dueDate,
239
+ tagsJson,
240
+ data.status,
241
+ data.headingId,
242
+ data.position,
243
+ now,
244
+ userId,
245
+ now
246
+ );
247
+ return id;
248
+ }
249
+ function deleteTodoByServerId(db, serverId, userId) {
250
+ const existing = db.prepare(`SELECT id, things_id FROM todos WHERE id = ?`).get(serverId);
251
+ if (!existing) return false;
252
+ const now = (/* @__PURE__ */ new Date()).toISOString();
253
+ const deleteId = crypto.randomUUID();
254
+ db.prepare(`
255
+ INSERT INTO deleted_items (id, things_id, item_type, deleted_at, deleted_by)
256
+ VALUES (?, ?, 'todo', ?, ?)
257
+ `).run(deleteId, serverId, now, userId);
258
+ db.prepare(`DELETE FROM todos WHERE id = ?`).run(serverId);
259
+ return true;
260
+ }
261
+ function getDeletedSince(db, since) {
262
+ const rows = db.prepare(`
263
+ SELECT things_id, item_type FROM deleted_items WHERE deleted_at > ?
264
+ `).all(since);
265
+ return {
266
+ todos: rows.filter((r) => r.item_type === "todo").map((r) => r.things_id),
267
+ headings: rows.filter((r) => r.item_type === "heading").map((r) => r.things_id)
268
+ };
269
+ }
270
+ function resetUserData(db, userId) {
271
+ const todoResult = db.prepare(`DELETE FROM todos WHERE updated_by = ?`).run(userId);
272
+ const headingResult = db.prepare(`DELETE FROM headings WHERE updated_by = ?`).run(userId);
273
+ db.prepare(`DELETE FROM deleted_items WHERE deleted_by = ?`).run(userId);
274
+ return {
275
+ deletedTodos: todoResult.changes,
276
+ deletedHeadings: headingResult.changes
277
+ };
278
+ }
279
+
280
+ // src/cli.ts
281
+ import * as fs2 from "fs";
282
+ import * as path2 from "path";
283
+ import * as os2 from "os";
284
+ import { spawn } from "child_process";
285
+ import Fastify from "fastify";
286
+ import cors from "@fastify/cors";
287
+
288
+ // src/auth.ts
289
+ function authMiddleware(db) {
290
+ return (request, reply, done) => {
291
+ if (request.url === "/health") {
292
+ return done();
293
+ }
294
+ const authHeader = request.headers.authorization;
295
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
296
+ reply.code(401).send({ error: "Missing or invalid authorization header", code: "UNAUTHORIZED" });
297
+ return;
298
+ }
299
+ const apiKey = authHeader.slice(7);
300
+ const user = getUserByApiKey(db, apiKey);
301
+ if (!user) {
302
+ reply.code(401).send({ error: "Invalid API key", code: "UNAUTHORIZED" });
303
+ return;
304
+ }
305
+ request.user = user;
306
+ done();
307
+ };
308
+ }
309
+
310
+ // src/routes.ts
311
+ function registerRoutes(app, db) {
312
+ app.get("/health", async () => {
313
+ return { status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
314
+ });
315
+ app.get("/state", async (request) => {
316
+ const headings = getAllHeadings(db);
317
+ const todos = getAllTodos(db);
318
+ return {
319
+ headings,
320
+ todos,
321
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
322
+ };
323
+ });
324
+ app.get("/delta", async (request) => {
325
+ const { since } = request.query;
326
+ if (!since) {
327
+ return { error: 'Missing "since" query parameter', code: "BAD_REQUEST" };
328
+ }
329
+ const headings = getHeadingsSince(db, since);
330
+ const todos = getTodosSince(db, since);
331
+ const deleted = getDeletedSince(db, since);
332
+ return {
333
+ headings: {
334
+ upserted: headings,
335
+ deleted: deleted.headings
336
+ },
337
+ todos: {
338
+ upserted: todos,
339
+ deleted: deleted.todos
340
+ },
341
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
342
+ };
343
+ });
344
+ app.post("/push", async (request, reply) => {
345
+ const { headings, todos } = request.body;
346
+ const userId = request.user.id;
347
+ const conflicts = [];
348
+ try {
349
+ for (const thingsId of headings.deleted) {
350
+ deleteHeading(db, thingsId, userId);
351
+ }
352
+ for (const heading of headings.upserted) {
353
+ upsertHeading(db, heading.thingsId, heading.title, heading.position, userId);
354
+ }
355
+ for (const serverId of todos.deleted) {
356
+ deleteTodoByServerId(db, serverId, userId);
357
+ }
358
+ for (const todo of todos.upserted) {
359
+ let headingId = null;
360
+ if (todo.headingId) {
361
+ const headingRow = db.prepare(`SELECT id FROM headings WHERE things_id = ?`).get(todo.headingId);
362
+ headingId = headingRow?.id || null;
363
+ }
364
+ upsertTodoByServerId(db, todo.serverId, {
365
+ thingsId: todo.thingsId,
366
+ title: todo.title,
367
+ notes: todo.notes,
368
+ dueDate: todo.dueDate,
369
+ tags: todo.tags,
370
+ status: todo.status,
371
+ headingId,
372
+ position: todo.position
373
+ }, userId);
374
+ }
375
+ } catch (err) {
376
+ const error = err;
377
+ if (error.message?.includes("UNIQUE constraint failed")) {
378
+ reply.status(409);
379
+ return {
380
+ error: 'Sync conflict: Server has data that conflicts with your local state. Run "shared-things reset --server" to start fresh.',
381
+ code: "SYNC_CONFLICT"
382
+ };
383
+ }
384
+ throw err;
385
+ }
386
+ const currentHeadings = getAllHeadings(db);
387
+ const currentTodos = getAllTodos(db);
388
+ return {
389
+ state: {
390
+ headings: currentHeadings,
391
+ todos: currentTodos,
392
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
393
+ },
394
+ conflicts
395
+ };
396
+ });
397
+ app.delete("/reset", async (request) => {
398
+ const userId = request.user.id;
399
+ const result = resetUserData(db, userId);
400
+ return {
401
+ success: true,
402
+ deleted: {
403
+ todos: result.deletedTodos,
404
+ headings: result.deletedHeadings
405
+ }
406
+ };
407
+ });
408
+ }
409
+
410
+ // src/cli.ts
411
+ var DATA_DIR2 = process.env.DATA_DIR || path2.join(os2.homedir(), ".shared-things-server");
412
+ var PID_FILE = path2.join(DATA_DIR2, "server.pid");
413
+ var LOG_FILE = path2.join(DATA_DIR2, "server.log");
414
+ function ensureDataDir() {
415
+ if (!fs2.existsSync(DATA_DIR2)) {
416
+ fs2.mkdirSync(DATA_DIR2, { recursive: true });
417
+ }
418
+ }
419
+ function isServerRunning() {
420
+ if (!fs2.existsSync(PID_FILE)) {
421
+ return { running: false };
422
+ }
423
+ const pid = parseInt(fs2.readFileSync(PID_FILE, "utf-8").trim(), 10);
424
+ try {
425
+ process.kill(pid, 0);
426
+ return { running: true, pid };
427
+ } catch {
428
+ fs2.unlinkSync(PID_FILE);
429
+ return { running: false };
430
+ }
431
+ }
432
+ var program = new Command();
433
+ program.name("shared-things-server").description("Sync server for Things 3 projects").version("0.1.0");
434
+ program.command("start").description("Start the sync server").option("-p, --port <port>", "Port to listen on", "3334").option("--host <host>", "Host to bind to", "0.0.0.0").option("-d, --detach", "Run server in background (detached mode)").action(async (options) => {
435
+ const PORT = parseInt(options.port, 10);
436
+ const HOST = options.host;
437
+ const isChildProcess = process.env.SHARED_THINGS_DETACHED === "1";
438
+ if (!isChildProcess) {
439
+ const status = isServerRunning();
440
+ if (status.running) {
441
+ console.log(chalk.yellow(`
442
+ \u26A0\uFE0F Server already running (PID: ${status.pid})`));
443
+ console.log(chalk.dim('Use "shared-things-server stop" to stop it first.\n'));
444
+ return;
445
+ }
446
+ }
447
+ if (options.detach) {
448
+ ensureDataDir();
449
+ const logFd = fs2.openSync(LOG_FILE, "a");
450
+ const scriptPath = process.argv[1];
451
+ const child = spawn(process.execPath, [scriptPath, "start", "--port", String(PORT), "--host", HOST], {
452
+ detached: true,
453
+ stdio: ["ignore", logFd, logFd],
454
+ env: { ...process.env, SHARED_THINGS_DETACHED: "1" }
455
+ });
456
+ fs2.writeFileSync(PID_FILE, String(child.pid));
457
+ child.unref();
458
+ fs2.closeSync(logFd);
459
+ console.log(chalk.green(`
460
+ \u2705 Server started in background`));
461
+ console.log(` ${chalk.dim("PID:")} ${child.pid}`);
462
+ console.log(` ${chalk.dim("URL:")} http://${HOST}:${PORT}`);
463
+ console.log(` ${chalk.dim("Logs:")} ${LOG_FILE}`);
464
+ console.log(chalk.dim('\nUse "shared-things-server logs -f" to follow logs'));
465
+ console.log(chalk.dim('Use "shared-things-server stop" to stop the server\n'));
466
+ return;
467
+ }
468
+ const db = initDatabase();
469
+ const app = Fastify({
470
+ logger: isChildProcess ? true : true
471
+ });
472
+ await app.register(cors, {
473
+ origin: true
474
+ });
475
+ app.addHook("preHandler", authMiddleware(db));
476
+ registerRoutes(app, db);
477
+ const shutdown = async () => {
478
+ console.log(chalk.dim("\nShutting down..."));
479
+ await app.close();
480
+ if (fs2.existsSync(PID_FILE)) {
481
+ fs2.unlinkSync(PID_FILE);
482
+ }
483
+ process.exit(0);
484
+ };
485
+ process.on("SIGTERM", shutdown);
486
+ process.on("SIGINT", shutdown);
487
+ try {
488
+ await app.listen({ port: PORT, host: HOST });
489
+ if (!process.env.SHARED_THINGS_DETACHED) {
490
+ console.log(chalk.green(`
491
+ \u2705 Server running at http://${HOST}:${PORT}
492
+ `));
493
+ }
494
+ } catch (err) {
495
+ app.log.error(err);
496
+ process.exit(1);
497
+ }
498
+ });
499
+ program.command("stop").description("Stop the background server").action(() => {
500
+ const status = isServerRunning();
501
+ if (!status.running) {
502
+ console.log(chalk.yellow("\n\u26A0\uFE0F Server is not running.\n"));
503
+ return;
504
+ }
505
+ try {
506
+ process.kill(status.pid, "SIGTERM");
507
+ setTimeout(() => {
508
+ if (fs2.existsSync(PID_FILE)) {
509
+ fs2.unlinkSync(PID_FILE);
510
+ }
511
+ }, 500);
512
+ console.log(chalk.green(`
513
+ \u2705 Server stopped (PID: ${status.pid})
514
+ `));
515
+ } catch (err) {
516
+ console.log(chalk.red(`
517
+ \u274C Failed to stop server: ${err}
518
+ `));
519
+ }
520
+ });
521
+ program.command("status").description("Show server status").action(() => {
522
+ const status = isServerRunning();
523
+ console.log(chalk.bold("\n\u{1F4CA} Server Status\n"));
524
+ if (status.running) {
525
+ console.log(` ${chalk.dim("Status:")} ${chalk.green("\u25CF running")}`);
526
+ console.log(` ${chalk.dim("PID:")} ${status.pid}`);
527
+ } else {
528
+ console.log(` ${chalk.dim("Status:")} ${chalk.red("\u25CB stopped")}`);
529
+ }
530
+ if (fs2.existsSync(LOG_FILE)) {
531
+ const stats = fs2.statSync(LOG_FILE);
532
+ const sizeKB = Math.round(stats.size / 1024);
533
+ console.log(` ${chalk.dim("Logs:")} ${LOG_FILE} (${sizeKB}KB)`);
534
+ }
535
+ const dbPath = path2.join(DATA_DIR2, "data.db");
536
+ if (fs2.existsSync(dbPath)) {
537
+ const db = initDatabase();
538
+ const users = listUsers(db);
539
+ const todos = getAllTodos(db);
540
+ console.log(` ${chalk.dim("Users:")} ${users.length}`);
541
+ console.log(` ${chalk.dim("Todos:")} ${todos.length}`);
542
+ }
543
+ console.log();
544
+ });
545
+ program.command("logs").description("Show server logs").option("-f, --follow", "Follow log output").option("-n, --lines <count>", "Number of lines to show", "50").action((options) => {
546
+ if (!fs2.existsSync(LOG_FILE)) {
547
+ console.log(chalk.yellow("\nNo logs yet.\n"));
548
+ return;
549
+ }
550
+ if (options.follow) {
551
+ console.log(chalk.dim(`Following ${LOG_FILE}... (Ctrl+C to stop)
552
+ `));
553
+ const tail = spawn("tail", ["-f", LOG_FILE], { stdio: "inherit" });
554
+ process.on("SIGINT", () => {
555
+ tail.kill();
556
+ process.exit(0);
557
+ });
558
+ } else {
559
+ const tail = spawn("tail", ["-n", options.lines, LOG_FILE], { stdio: "inherit" });
560
+ tail.on("close", () => process.exit(0));
561
+ }
562
+ });
563
+ program.command("create-user").description("Create a new user and generate API key").option("-n, --name <name>", "Username").action(async (options) => {
564
+ const db = initDatabase();
565
+ let name = options.name;
566
+ if (!name) {
567
+ console.log(chalk.bold("\n\u{1F464} Create New User\n"));
568
+ name = await input({
569
+ message: "Username",
570
+ validate: (value) => {
571
+ if (!value.trim()) return "Username is required";
572
+ if (userExists(db, value.trim())) return `User "${value.trim()}" already exists`;
573
+ return true;
574
+ }
575
+ });
576
+ }
577
+ if (userExists(db, name.trim())) {
578
+ console.log(chalk.red(`
579
+ \u274C User "${name.trim()}" already exists.
580
+ `));
581
+ process.exit(1);
582
+ }
583
+ const { id, apiKey } = createUser(db, name.trim());
584
+ console.log(chalk.green("\n\u2705 User created successfully!\n"));
585
+ console.log(` ${chalk.dim("ID:")} ${id}`);
586
+ console.log(` ${chalk.dim("Name:")} ${name}`);
587
+ console.log(` ${chalk.dim("API Key:")} ${chalk.cyan(apiKey)}`);
588
+ console.log(chalk.yellow("\n\u26A0\uFE0F Save this API key - it cannot be retrieved later!\n"));
589
+ });
590
+ program.command("list-users").description("List all users").action(async () => {
591
+ const db = initDatabase();
592
+ const users = listUsers(db);
593
+ if (users.length === 0) {
594
+ console.log(chalk.yellow("\nNo users found.\n"));
595
+ console.log(chalk.dim("Create a user with: shared-things-server create-user\n"));
596
+ } else {
597
+ console.log(chalk.bold(`
598
+ \u{1F465} Users (${users.length})
599
+ `));
600
+ for (const user of users) {
601
+ console.log(` ${chalk.white(user.name)} ${chalk.dim(`(${user.id})`)}`);
602
+ console.log(` ${chalk.dim("Created:")} ${user.createdAt}`);
603
+ }
604
+ console.log();
605
+ }
606
+ });
607
+ program.command("delete-user").description("Delete a user").option("-n, --name <name>", "Username to delete").action(async (options) => {
608
+ const db = initDatabase();
609
+ const users = listUsers(db);
610
+ if (users.length === 0) {
611
+ console.log(chalk.yellow("\nNo users to delete.\n"));
612
+ return;
613
+ }
614
+ let name = options.name;
615
+ if (!name) {
616
+ console.log(chalk.bold("\n\u{1F5D1}\uFE0F Delete User\n"));
617
+ console.log("Available users:");
618
+ for (const user2 of users) {
619
+ console.log(` - ${user2.name}`);
620
+ }
621
+ console.log();
622
+ name = await input({
623
+ message: "Username to delete",
624
+ validate: (value) => {
625
+ if (!value.trim()) return "Username is required";
626
+ if (!users.find((u) => u.name === value.trim())) return "User not found";
627
+ return true;
628
+ }
629
+ });
630
+ }
631
+ const user = users.find((u) => u.name === name);
632
+ if (!user) {
633
+ console.log(chalk.red(`
634
+ \u274C User "${name}" not found.
635
+ `));
636
+ return;
637
+ }
638
+ const confirmed = await confirm({
639
+ message: `Delete user "${name}"? This will also delete all their data.`,
640
+ default: false
641
+ });
642
+ if (!confirmed) {
643
+ console.log(chalk.dim("Cancelled."));
644
+ return;
645
+ }
646
+ db.prepare("DELETE FROM todos WHERE updated_by = ?").run(user.id);
647
+ db.prepare("DELETE FROM headings WHERE updated_by = ?").run(user.id);
648
+ db.prepare("DELETE FROM deleted_items WHERE deleted_by = ?").run(user.id);
649
+ db.prepare("DELETE FROM users WHERE id = ?").run(user.id);
650
+ console.log(chalk.green(`
651
+ \u2705 User "${name}" deleted.
652
+ `));
653
+ });
654
+ program.command("list-todos").description("List all todos").option("-u, --user <name>", "Filter by username").action(async (options) => {
655
+ const db = initDatabase();
656
+ const todos = getAllTodos(db);
657
+ const users = listUsers(db);
658
+ const userMap = new Map(users.map((u) => [u.id, u.name]));
659
+ let filteredTodos = todos;
660
+ if (options.user) {
661
+ const user = users.find((u) => u.name === options.user);
662
+ if (!user) {
663
+ console.log(chalk.red(`
664
+ \u274C User "${options.user}" not found.
665
+ `));
666
+ return;
667
+ }
668
+ filteredTodos = todos.filter((t) => t.updatedBy === user.id);
669
+ }
670
+ if (filteredTodos.length === 0) {
671
+ console.log(chalk.yellow("\nNo todos found.\n"));
672
+ return;
673
+ }
674
+ const title = options.user ? `\u{1F4CB} Todos by ${options.user} (${filteredTodos.length})` : `\u{1F4CB} Todos (${filteredTodos.length})`;
675
+ console.log(chalk.bold(`
676
+ ${title}
677
+ `));
678
+ for (const todo of filteredTodos) {
679
+ const userName = userMap.get(todo.updatedBy) || "unknown";
680
+ const statusIcon = todo.status === "completed" ? "\u2713" : todo.status === "canceled" ? "\u2717" : "\u25CB";
681
+ const statusColor = todo.status === "completed" ? chalk.green : todo.status === "canceled" ? chalk.red : chalk.white;
682
+ console.log(` ${statusColor(statusIcon)} ${chalk.white(todo.title)}`);
683
+ if (todo.notes) {
684
+ const shortNotes = todo.notes.length > 50 ? todo.notes.substring(0, 50) + "..." : todo.notes;
685
+ console.log(` ${chalk.dim("Notes:")} ${shortNotes}`);
686
+ }
687
+ if (todo.dueDate) {
688
+ console.log(` ${chalk.dim("Due:")} ${todo.dueDate}`);
689
+ }
690
+ if (todo.tags && todo.tags.length > 0) {
691
+ console.log(` ${chalk.dim("Tags:")} ${todo.tags.join(", ")}`);
692
+ }
693
+ console.log(` ${chalk.dim("Status:")} ${todo.status} ${chalk.dim("|")} ${chalk.dim("By:")} ${userName} ${chalk.dim("|")} ${todo.updatedAt}`);
694
+ console.log();
695
+ }
696
+ });
697
+ program.command("reset").description("Delete all todos and headings (keeps users)").action(async () => {
698
+ const db = initDatabase();
699
+ const todos = getAllTodos(db);
700
+ const headings = getAllHeadings(db);
701
+ if (todos.length === 0 && headings.length === 0) {
702
+ console.log(chalk.yellow("\nNo data to reset.\n"));
703
+ return;
704
+ }
705
+ console.log(chalk.bold("\n\u{1F504} Reset Server Data\n"));
706
+ console.log(` ${chalk.dim("Todos:")} ${todos.length}`);
707
+ console.log(` ${chalk.dim("Headings:")} ${headings.length}`);
708
+ console.log();
709
+ const confirmed = await confirm({
710
+ message: "Delete all todos and headings? Users will be kept.",
711
+ default: false
712
+ });
713
+ if (!confirmed) {
714
+ console.log(chalk.dim("Cancelled."));
715
+ return;
716
+ }
717
+ db.prepare("DELETE FROM todos").run();
718
+ db.prepare("DELETE FROM headings").run();
719
+ db.prepare("DELETE FROM deleted_items").run();
720
+ console.log(chalk.green("\n\u2705 All todos and headings deleted. Users preserved.\n"));
721
+ });
722
+ program.command("purge").description("Delete entire database (all data including users)").action(async () => {
723
+ const dataDir = process.env.DATA_DIR || path2.join(os2.homedir(), ".shared-things-server");
724
+ const dbPath = path2.join(dataDir, "data.db");
725
+ if (!fs2.existsSync(dbPath)) {
726
+ console.log(chalk.yellow("\nNo database to purge.\n"));
727
+ return;
728
+ }
729
+ console.log(chalk.bold("\n\u26A0\uFE0F Purge Server\n"));
730
+ console.log(` ${chalk.dim("Database:")} ${dbPath}`);
731
+ console.log();
732
+ const confirmed = await confirm({
733
+ message: "Delete the entire database? This cannot be undone!",
734
+ default: false
735
+ });
736
+ if (!confirmed) {
737
+ console.log(chalk.dim("Cancelled."));
738
+ return;
739
+ }
740
+ fs2.unlinkSync(dbPath);
741
+ if (fs2.existsSync(dbPath + "-wal")) fs2.unlinkSync(dbPath + "-wal");
742
+ if (fs2.existsSync(dbPath + "-shm")) fs2.unlinkSync(dbPath + "-shm");
743
+ console.log(chalk.green('\n\u2705 Database deleted. Run "shared-things-server create-user" to start fresh.\n'));
744
+ });
745
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "shared-things-server",
3
+ "version": "1.0.0",
4
+ "description": "Sync server for Things 3 projects",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "shared-things-server": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "dev": "tsx watch src/cli.ts start",
16
+ "prepublishOnly": "pnpm build"
17
+ },
18
+ "keywords": [
19
+ "things",
20
+ "things3",
21
+ "sync",
22
+ "server",
23
+ "macos",
24
+ "todo",
25
+ "productivity",
26
+ "collaboration",
27
+ "self-hosted",
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
+ "@fastify/cors": "^9.0.0",
45
+ "@inquirer/prompts": "^8.1.0",
46
+ "better-sqlite3": "^11.0.0",
47
+ "chalk": "^5.6.2",
48
+ "commander": "^12.0.0",
49
+ "fastify": "^4.26.0"
50
+ },
51
+ "devDependencies": {
52
+ "@shared-things/common": "workspace:*",
53
+ "@types/better-sqlite3": "^7.6.8",
54
+ "@types/node": "^20.0.0",
55
+ "tsup": "^8.5.1",
56
+ "tsx": "^4.7.0",
57
+ "typescript": "^5.3.0"
58
+ }
59
+ }