@hasna/cloud 0.1.31 → 0.1.32

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 (45) hide show
  1. package/LICENSE +2 -1
  2. package/README.md +17 -0
  3. package/dist/adapter.test.d.ts +2 -0
  4. package/dist/adapter.test.d.ts.map +1 -0
  5. package/dist/auto-sync.d.ts.map +1 -1
  6. package/dist/cli/cmd-doctor.d.ts +3 -0
  7. package/dist/cli/cmd-doctor.d.ts.map +1 -0
  8. package/dist/cli/cmd-feedback.d.ts +3 -0
  9. package/dist/cli/cmd-feedback.d.ts.map +1 -0
  10. package/dist/cli/cmd-migrate.d.ts +3 -0
  11. package/dist/cli/cmd-migrate.d.ts.map +1 -0
  12. package/dist/cli/cmd-setup.d.ts +3 -0
  13. package/dist/cli/cmd-setup.d.ts.map +1 -0
  14. package/dist/cli/cmd-sync.d.ts +4 -0
  15. package/dist/cli/cmd-sync.d.ts.map +1 -0
  16. package/dist/cli/index.js +2330 -1079
  17. package/dist/config.d.ts +138 -4
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/daemon-sync.d.ts +108 -0
  20. package/dist/daemon-sync.d.ts.map +1 -0
  21. package/dist/dialect.test.d.ts +2 -0
  22. package/dist/dialect.test.d.ts.map +1 -0
  23. package/dist/discover.test.d.ts +2 -0
  24. package/dist/discover.test.d.ts.map +1 -0
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1143 -153
  28. package/dist/machines.d.ts +63 -0
  29. package/dist/machines.d.ts.map +1 -0
  30. package/dist/mcp/http.d.ts +27 -0
  31. package/dist/mcp/http.d.ts.map +1 -0
  32. package/dist/mcp/index.d.ts +2 -1
  33. package/dist/mcp/index.d.ts.map +1 -1
  34. package/dist/mcp/index.js +2125 -438
  35. package/dist/scheduled-sync.js +205 -44
  36. package/dist/sync-conflicts.test.d.ts +2 -0
  37. package/dist/sync-conflicts.test.d.ts.map +1 -0
  38. package/dist/sync-incremental.d.ts +5 -0
  39. package/dist/sync-incremental.d.ts.map +1 -1
  40. package/dist/sync-schedule.test.d.ts +2 -0
  41. package/dist/sync-schedule.test.d.ts.map +1 -0
  42. package/dist/sync.d.ts.map +1 -1
  43. package/dist/sync.test.d.ts +2 -0
  44. package/dist/sync.test.d.ts.map +1 -0
  45. package/package.json +1 -1
package/dist/cli/index.js CHANGED
@@ -6158,7 +6158,7 @@ var require_arrayParser = __commonJS((exports, module) => {
6158
6158
  };
6159
6159
  });
6160
6160
 
6161
- // node_modules/pg-types/node_modules/postgres-date/index.js
6161
+ // node_modules/postgres-date/index.js
6162
6162
  var require_postgres_date = __commonJS((exports, module) => {
6163
6163
  var DATE_TIME = /(\d{1,})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d{1,})?.*?( BC)?$/;
6164
6164
  var DATE = /^(\d{1,})-(\d{2})-(\d{2})( BC)?$/;
@@ -6260,7 +6260,7 @@ var require_mutable = __commonJS((exports, module) => {
6260
6260
  }
6261
6261
  });
6262
6262
 
6263
- // node_modules/pg-types/node_modules/postgres-interval/index.js
6263
+ // node_modules/postgres-interval/index.js
6264
6264
  var require_postgres_interval = __commonJS((exports, module) => {
6265
6265
  var extend = require_mutable();
6266
6266
  module.exports = PostgresInterval;
@@ -6352,7 +6352,7 @@ var require_postgres_interval = __commonJS((exports, module) => {
6352
6352
  }
6353
6353
  });
6354
6354
 
6355
- // node_modules/pg-types/node_modules/postgres-bytea/index.js
6355
+ // node_modules/postgres-bytea/index.js
6356
6356
  var require_postgres_bytea = __commonJS((exports, module) => {
6357
6357
  var bufferFrom = Buffer.from || Buffer;
6358
6358
  module.exports = function parseBytea(input) {
@@ -11254,6 +11254,32 @@ function getDbPath(serviceName) {
11254
11254
  const dir = getDataDir(serviceName);
11255
11255
  return join(dir, `${serviceName}.db`);
11256
11256
  }
11257
+ function migrateDotfile(serviceName) {
11258
+ const legacyDir = join(homedir(), `.${serviceName}`);
11259
+ const newDir = join(HASNA_DIR, serviceName);
11260
+ if (!existsSync(legacyDir))
11261
+ return [];
11262
+ if (existsSync(newDir))
11263
+ return [];
11264
+ mkdirSync(newDir, { recursive: true });
11265
+ const migrated = [];
11266
+ copyDirRecursive(legacyDir, newDir, legacyDir, migrated);
11267
+ return migrated;
11268
+ }
11269
+ function copyDirRecursive(src, dest, root, migrated) {
11270
+ const entries = readdirSync(src, { withFileTypes: true });
11271
+ for (const entry of entries) {
11272
+ const srcPath = join(src, entry.name);
11273
+ const destPath = join(dest, entry.name);
11274
+ if (entry.isDirectory()) {
11275
+ mkdirSync(destPath, { recursive: true });
11276
+ copyDirRecursive(srcPath, destPath, root, migrated);
11277
+ } else {
11278
+ copyFileSync(srcPath, destPath);
11279
+ migrated.push(relative(root, srcPath));
11280
+ }
11281
+ }
11282
+ }
11257
11283
  function getHasnaDir() {
11258
11284
  mkdirSync(HASNA_DIR, { recursive: true });
11259
11285
  return HASNA_DIR;
@@ -11263,111 +11289,28 @@ var init_dotfile = __esm(() => {
11263
11289
  HASNA_DIR = join(homedir(), ".hasna");
11264
11290
  });
11265
11291
 
11266
- // src/config.ts
11267
- var exports_config = {};
11268
- __export(exports_config, {
11269
- saveCloudConfig: () => saveCloudConfig2,
11270
- getConnectionString: () => getConnectionString2,
11271
- getConfigPath: () => getConfigPath,
11272
- getConfigDir: () => getConfigDir,
11273
- getCloudConfig: () => getCloudConfig2,
11274
- createDatabase: () => createDatabase2,
11275
- CloudConfigSchema: () => CloudConfigSchema2
11276
- });
11277
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
11278
- import { homedir as homedir3 } from "os";
11279
- import { join as join3 } from "path";
11280
- function getConfigDir() {
11281
- return CONFIG_DIR2;
11282
- }
11283
- function getConfigPath() {
11284
- return CONFIG_PATH2;
11285
- }
11286
- function getCloudConfig2() {
11287
- if (!existsSync3(CONFIG_PATH2)) {
11288
- return CloudConfigSchema2.parse({});
11289
- }
11290
- try {
11291
- const raw = readFileSync2(CONFIG_PATH2, "utf-8");
11292
- return CloudConfigSchema2.parse(JSON.parse(raw));
11293
- } catch {
11294
- return CloudConfigSchema2.parse({});
11295
- }
11296
- }
11297
- function saveCloudConfig2(config) {
11298
- mkdirSync3(CONFIG_DIR2, { recursive: true });
11299
- writeFileSync2(CONFIG_PATH2, JSON.stringify(config, null, 2) + `
11300
- `, "utf-8");
11301
- }
11302
- function getConnectionString2(dbName) {
11303
- const config = getCloudConfig2();
11304
- const { host, port, username, password_env, ssl } = config.rds;
11305
- if (!host || !username) {
11306
- throw new Error("Cloud RDS not configured. Run `cloud setup` to configure.");
11307
- }
11308
- const password = process.env[password_env];
11309
- if (password === undefined || password === "") {
11310
- throw new Error(`RDS password not set. Export ${password_env} in your shell or add it to ~/.secrets/hasna/rds/live.env`);
11311
- }
11312
- const sslParam = ssl ? "?sslmode=require" : "";
11313
- return `postgres://${username}:${encodeURIComponent(password)}@${host}:${port}/${dbName}${sslParam}`;
11314
- }
11315
- function createDatabase2(options) {
11316
- const config = getCloudConfig2();
11317
- const mode = options.mode ?? config.mode;
11318
- if (mode === "cloud") {
11319
- const connStr = options.pgConnectionString ?? getConnectionString2(options.service);
11320
- return new PgAdapter(connStr);
11321
- }
11322
- const dbPath = options.sqlitePath ?? getDbPath(options.service);
11323
- return new SqliteAdapter(dbPath);
11324
- }
11325
- var CloudConfigSchema2, CONFIG_DIR2, CONFIG_PATH2;
11326
- var init_config = __esm(() => {
11327
- init_zod();
11328
- init_adapter();
11329
- init_dotfile();
11330
- CloudConfigSchema2 = exports_external.object({
11331
- rds: exports_external.object({
11332
- host: exports_external.string().default(""),
11333
- port: exports_external.number().default(5432),
11334
- username: exports_external.string().default(""),
11335
- password_env: exports_external.string().default("HASNA_RDS_PASSWORD"),
11336
- ssl: exports_external.boolean().default(true)
11337
- }).default({}),
11338
- mode: exports_external.enum(["local", "cloud", "hybrid"]).default("hybrid"),
11339
- auto_sync_interval_minutes: exports_external.number().default(0),
11340
- feedback_endpoint: exports_external.string().default("https://feedback.hasna.com/api/v1/feedback"),
11341
- sync: exports_external.object({
11342
- schedule_minutes: exports_external.number().default(0)
11343
- }).default({})
11344
- });
11345
- CONFIG_DIR2 = join3(homedir3(), ".hasna", "cloud");
11346
- CONFIG_PATH2 = join3(CONFIG_DIR2, "config.json");
11347
- });
11348
-
11349
11292
  // src/discover.ts
11350
11293
  var exports_discover = {};
11351
11294
  __export(exports_discover, {
11352
- isSyncExcludedTable: () => isSyncExcludedTable2,
11295
+ isSyncExcludedTable: () => isSyncExcludedTable,
11353
11296
  getServiceDbPath: () => getServiceDbPath,
11354
- discoverSyncableServices: () => discoverSyncableServices2,
11355
- discoverServices: () => discoverServices2,
11356
- SYNC_EXCLUDED_TABLE_PATTERNS: () => SYNC_EXCLUDED_TABLE_PATTERNS2,
11297
+ discoverSyncableServices: () => discoverSyncableServices,
11298
+ discoverServices: () => discoverServices,
11299
+ SYNC_EXCLUDED_TABLE_PATTERNS: () => SYNC_EXCLUDED_TABLE_PATTERNS,
11357
11300
  KNOWN_PG_SERVICES: () => KNOWN_PG_SERVICES
11358
11301
  });
11359
- import { readdirSync as readdirSync5, existsSync as existsSync8 } from "fs";
11360
- import { join as join8 } from "path";
11361
- import { homedir as homedir7 } from "os";
11362
- function isSyncExcludedTable2(table) {
11363
- return SYNC_EXCLUDED_TABLE_PATTERNS2.some((p) => p.test(table));
11302
+ import { readdirSync as readdirSync2, existsSync as existsSync2 } from "fs";
11303
+ import { join as join2 } from "path";
11304
+ import { homedir as homedir2 } from "os";
11305
+ function isSyncExcludedTable(table) {
11306
+ return SYNC_EXCLUDED_TABLE_PATTERNS.some((p) => p.test(table));
11364
11307
  }
11365
- function discoverServices2() {
11366
- const dataDir = join8(homedir7(), ".hasna");
11367
- if (!existsSync8(dataDir))
11308
+ function discoverServices() {
11309
+ const dataDir = join2(homedir2(), ".hasna");
11310
+ if (!existsSync2(dataDir))
11368
11311
  return [];
11369
11312
  try {
11370
- const entries = readdirSync5(dataDir, { withFileTypes: true });
11313
+ const entries = readdirSync2(dataDir, { withFileTypes: true });
11371
11314
  return entries.filter((e) => {
11372
11315
  if (!e.isDirectory())
11373
11316
  return false;
@@ -11379,35 +11322,35 @@ function discoverServices2() {
11379
11322
  return [];
11380
11323
  }
11381
11324
  }
11382
- function discoverSyncableServices2() {
11383
- const local = discoverServices2();
11325
+ function discoverSyncableServices() {
11326
+ const local = discoverServices();
11384
11327
  const pgSet = new Set(KNOWN_PG_SERVICES);
11385
11328
  return local.filter((s) => pgSet.has(s));
11386
11329
  }
11387
11330
  function getServiceDbPath(service) {
11388
- const dataDir = join8(homedir7(), ".hasna", service);
11389
- if (!existsSync8(dataDir))
11331
+ const dataDir = join2(homedir2(), ".hasna", service);
11332
+ if (!existsSync2(dataDir))
11390
11333
  return null;
11391
11334
  const candidates = [
11392
- join8(dataDir, `${service}.db`),
11393
- join8(dataDir, "data.db"),
11394
- join8(dataDir, "database.db")
11335
+ join2(dataDir, `${service}.db`),
11336
+ join2(dataDir, "data.db"),
11337
+ join2(dataDir, "database.db")
11395
11338
  ];
11396
11339
  try {
11397
- const files = readdirSync5(dataDir);
11340
+ const files = readdirSync2(dataDir);
11398
11341
  for (const f of files) {
11399
11342
  if (f.endsWith(".db") && !f.endsWith("-wal") && !f.endsWith("-shm")) {
11400
- candidates.push(join8(dataDir, f));
11343
+ candidates.push(join2(dataDir, f));
11401
11344
  }
11402
11345
  }
11403
11346
  } catch {}
11404
11347
  for (const p of candidates) {
11405
- if (existsSync8(p))
11348
+ if (existsSync2(p))
11406
11349
  return p;
11407
11350
  }
11408
11351
  return null;
11409
11352
  }
11410
- var KNOWN_PG_SERVICES, SYNC_EXCLUDED_TABLE_PATTERNS2;
11353
+ var KNOWN_PG_SERVICES, SYNC_EXCLUDED_TABLE_PATTERNS;
11411
11354
  var init_discover = __esm(() => {
11412
11355
  KNOWN_PG_SERVICES = [
11413
11356
  "assistants",
@@ -11446,7 +11389,7 @@ var init_discover = __esm(() => {
11446
11389
  "todos",
11447
11390
  "wallets"
11448
11391
  ];
11449
- SYNC_EXCLUDED_TABLE_PATTERNS2 = [
11392
+ SYNC_EXCLUDED_TABLE_PATTERNS = [
11450
11393
  /^sqlite_/,
11451
11394
  /_fts$/,
11452
11395
  /_fts_/,
@@ -11455,48 +11398,433 @@ var init_discover = __esm(() => {
11455
11398
  ];
11456
11399
  });
11457
11400
 
11458
- // node_modules/commander/esm.mjs
11459
- var import__ = __toESM(require_commander(), 1);
11460
- var {
11461
- program,
11462
- createCommand,
11463
- createArgument,
11464
- createOption,
11465
- CommanderError,
11466
- InvalidArgumentError,
11467
- InvalidOptionArgumentError,
11468
- Command,
11469
- Argument,
11470
- Option,
11471
- Help
11472
- } = import__.default;
11401
+ // src/machines.ts
11402
+ import { spawnSync } from "child_process";
11403
+ import { existsSync as existsSync3 } from "fs";
11404
+ import { homedir as homedir3, hostname, platform, arch, userInfo } from "os";
11405
+ import { dirname, join as join3 } from "path";
11406
+ function quoteSqlString(value) {
11407
+ return `'${value.replace(/'/g, "''")}'`;
11408
+ }
11409
+ function normalizePlatform(value) {
11410
+ if (value === "darwin")
11411
+ return "macos";
11412
+ if (value === "win32")
11413
+ return "windows";
11414
+ return value;
11415
+ }
11416
+ function detectWorkspacePath() {
11417
+ const home = homedir3();
11418
+ const candidates = [join3(home, "workspace"), join3(home, "Workspace")];
11419
+ for (const candidate of candidates) {
11420
+ if (existsSync3(candidate))
11421
+ return candidate;
11422
+ }
11423
+ const cwd = process.cwd();
11424
+ const workspaceIdx = cwd.indexOf("/workspace/");
11425
+ if (workspaceIdx >= 0) {
11426
+ return cwd.slice(0, workspaceIdx + "/workspace".length);
11427
+ }
11428
+ const workspaceUpperIdx = cwd.indexOf("/Workspace/");
11429
+ if (workspaceUpperIdx >= 0) {
11430
+ return cwd.slice(0, workspaceUpperIdx + "/Workspace".length);
11431
+ }
11432
+ return cwd;
11433
+ }
11434
+ function detectBunPath() {
11435
+ return dirname(process.execPath);
11436
+ }
11437
+ function toFlag(value, fallback = 0) {
11438
+ if (value === undefined)
11439
+ return fallback;
11440
+ return value ? 1 : 0;
11441
+ }
11442
+ function getCurrentMachineId() {
11443
+ return hostname();
11444
+ }
11445
+ function detectCurrentMachine(opts = {}) {
11446
+ const id = opts.id ?? getCurrentMachineId();
11447
+ const username = userInfo().username;
11448
+ return {
11449
+ id,
11450
+ ssh_address: opts.ssh_address ?? `${username}@${id}`,
11451
+ arch: opts.arch ?? `${normalizePlatform(platform())}-${arch()}`,
11452
+ workspace_path: opts.workspace_path ?? detectWorkspacePath(),
11453
+ bun_path: opts.bun_path ?? detectBunPath(),
11454
+ is_primary: opts.is_primary,
11455
+ archived: opts.archived,
11456
+ last_seen_at: opts.last_seen_at,
11457
+ registered_at: opts.registered_at
11458
+ };
11459
+ }
11460
+ function ensureMachinesTable(db) {
11461
+ db.exec(MACHINES_TABLE_SQL);
11462
+ }
11463
+ function getMachineRecord(db, id) {
11464
+ ensureMachinesTable(db);
11465
+ return db.get(`SELECT id, ssh_address, arch, workspace_path, bun_path, is_primary, last_seen_at, registered_at, archived
11466
+ FROM machines
11467
+ WHERE id = ?`, id) ?? null;
11468
+ }
11469
+ function registerMachine(db, opts = {}) {
11470
+ ensureMachinesTable(db);
11471
+ const detected = detectCurrentMachine(opts);
11472
+ const id = detected.id ?? getCurrentMachineId();
11473
+ const now = new Date().toISOString();
11474
+ const existing = getMachineRecord(db, id);
11475
+ const isPrimary = toFlag(detected.is_primary, existing?.is_primary ?? 0);
11476
+ const archived = toFlag(detected.archived, existing?.archived ?? 0);
11477
+ if (isPrimary === 1 && archived === 1) {
11478
+ throw new Error(`Primary machine "${id}" cannot be archived.`);
11479
+ }
11480
+ const record = {
11481
+ id,
11482
+ ssh_address: detected.ssh_address ?? existing?.ssh_address ?? "",
11483
+ arch: detected.arch ?? existing?.arch ?? "",
11484
+ workspace_path: detected.workspace_path ?? existing?.workspace_path ?? "",
11485
+ bun_path: detected.bun_path ?? existing?.bun_path ?? "",
11486
+ is_primary: isPrimary,
11487
+ last_seen_at: detected.last_seen_at ?? now,
11488
+ registered_at: existing?.registered_at ?? detected.registered_at ?? now,
11489
+ archived
11490
+ };
11491
+ db.run(`INSERT INTO machines (
11492
+ id,
11493
+ ssh_address,
11494
+ arch,
11495
+ workspace_path,
11496
+ bun_path,
11497
+ is_primary,
11498
+ last_seen_at,
11499
+ registered_at,
11500
+ archived
11501
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
11502
+ ON CONFLICT(id) DO UPDATE SET
11503
+ ssh_address = excluded.ssh_address,
11504
+ arch = excluded.arch,
11505
+ workspace_path = excluded.workspace_path,
11506
+ bun_path = excluded.bun_path,
11507
+ is_primary = excluded.is_primary,
11508
+ last_seen_at = excluded.last_seen_at,
11509
+ registered_at = excluded.registered_at,
11510
+ archived = excluded.archived`, record.id, record.ssh_address, record.arch, record.workspace_path, record.bun_path, record.is_primary, record.last_seen_at, record.registered_at, record.archived);
11511
+ return getMachineRecord(db, record.id) ?? record;
11512
+ }
11513
+ function listMachines(db, opts = {}) {
11514
+ ensureMachinesTable(db);
11515
+ const includeArchived = opts.includeArchived ?? false;
11516
+ const whereClause = includeArchived ? "" : "WHERE archived = 0";
11517
+ return db.all(`SELECT id, ssh_address, arch, workspace_path, bun_path, is_primary, last_seen_at, registered_at, archived
11518
+ FROM machines
11519
+ ${whereClause}
11520
+ ORDER BY is_primary DESC, id ASC`);
11521
+ }
11522
+ function pingMachine(machine) {
11523
+ const record = typeof machine === "string" ? {
11524
+ id: machine,
11525
+ ssh_address: machine
11526
+ } : machine;
11527
+ const startedAt = Date.now();
11528
+ const checkedAt = new Date().toISOString();
11529
+ const currentId = getCurrentMachineId();
11530
+ if (record.id === currentId) {
11531
+ return {
11532
+ id: record.id,
11533
+ online: true,
11534
+ checked_at: checkedAt,
11535
+ latency_ms: Date.now() - startedAt
11536
+ };
11537
+ }
11538
+ const target = record.ssh_address || record.id;
11539
+ if (!target) {
11540
+ return {
11541
+ id: record.id,
11542
+ online: false,
11543
+ error: "Machine has no ssh target.",
11544
+ checked_at: checkedAt,
11545
+ latency_ms: Date.now() - startedAt
11546
+ };
11547
+ }
11548
+ const result = spawnSync("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", target, "true"], {
11549
+ encoding: "utf-8",
11550
+ timeout: 6000
11551
+ });
11552
+ return {
11553
+ id: record.id,
11554
+ online: result.status === 0,
11555
+ error: result.status === 0 ? undefined : (result.stderr || result.error?.message || "SSH health check failed").trim(),
11556
+ checked_at: checkedAt,
11557
+ latency_ms: Date.now() - startedAt
11558
+ };
11559
+ }
11560
+ function getMachineStatus(db, opts = {}) {
11561
+ return listMachines(db, opts).map((machine) => pingMachine(machine));
11562
+ }
11563
+ function tableExists(db, table) {
11564
+ try {
11565
+ if (typeof db.query === "function") {
11566
+ const rows2 = db.all(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, table);
11567
+ return rows2.length > 0;
11568
+ }
11569
+ const rows = db.all(`SELECT table_name
11570
+ FROM information_schema.tables
11571
+ WHERE table_schema = 'public' AND table_name = ?`, table);
11572
+ return rows.length > 0;
11573
+ } catch {
11574
+ return false;
11575
+ }
11576
+ }
11577
+ function hasMachineIdColumn(db, table) {
11578
+ try {
11579
+ if (typeof db.query === "function") {
11580
+ const rows2 = db.all(`PRAGMA table_info("${table}")`);
11581
+ return rows2.some((row) => row.name === "machine_id");
11582
+ }
11583
+ const rows = db.all(`SELECT column_name
11584
+ FROM information_schema.columns
11585
+ WHERE table_schema = 'public' AND table_name = ? AND column_name = 'machine_id'`, table);
11586
+ return rows.length > 0;
11587
+ } catch {
11588
+ return false;
11589
+ }
11590
+ }
11591
+ function shouldTrackMachineId(table) {
11592
+ return table !== "machines" && !isSyncExcludedTable(table);
11593
+ }
11594
+ function ensureMachineIdColumn(db, table) {
11595
+ if (!shouldTrackMachineId(table) || !tableExists(db, table)) {
11596
+ return false;
11597
+ }
11598
+ if (hasMachineIdColumn(db, table)) {
11599
+ return false;
11600
+ }
11601
+ db.exec(`ALTER TABLE "${table}" ADD COLUMN machine_id TEXT DEFAULT ''`);
11602
+ return true;
11603
+ }
11604
+ function extractTableName(sql, pattern) {
11605
+ const match = sql.match(pattern);
11606
+ if (!match)
11607
+ return null;
11608
+ return match[2] ?? match[3] ?? null;
11609
+ }
11610
+ function appendLiteralToValueTuples(sql, literal) {
11611
+ let output = "";
11612
+ let depth = 0;
11613
+ let inSingle = false;
11614
+ let inDouble = false;
11615
+ let sawTuple = false;
11616
+ let inValues = true;
11617
+ for (let i = 0;i < sql.length; i++) {
11618
+ const char = sql[i];
11619
+ const prev = sql[i - 1];
11620
+ if (!inDouble && char === "'" && prev !== "\\") {
11621
+ inSingle = !inSingle;
11622
+ output += char;
11623
+ continue;
11624
+ }
11625
+ if (!inSingle && char === `"` && prev !== "\\") {
11626
+ inDouble = !inDouble;
11627
+ output += char;
11628
+ continue;
11629
+ }
11630
+ if (inSingle || inDouble) {
11631
+ output += char;
11632
+ continue;
11633
+ }
11634
+ if (inValues && char === "(") {
11635
+ depth += 1;
11636
+ if (depth === 1)
11637
+ sawTuple = true;
11638
+ output += char;
11639
+ continue;
11640
+ }
11641
+ if (inValues && char === ")" && depth === 1) {
11642
+ output += `, ${literal})`;
11643
+ depth = 0;
11644
+ continue;
11645
+ }
11646
+ if (inValues && char === ")" && depth > 1) {
11647
+ depth -= 1;
11648
+ output += char;
11649
+ continue;
11650
+ }
11651
+ if (inValues && depth === 0 && sawTuple && /[A-Za-z]/.test(char)) {
11652
+ inValues = false;
11653
+ }
11654
+ output += char;
11655
+ }
11656
+ return sawTuple ? output : null;
11657
+ }
11658
+ function rewriteInsertSql(sql, machineId) {
11659
+ const match = sql.match(/^\s*(insert(?:\s+or\s+\w+)?\s+into\s+((?:"([^"]+)")|([A-Za-z_][\w$]*))\s*)\(([^)]*)\)(\s*values\s*)([\s\S]*)$/i);
11660
+ if (!match)
11661
+ return null;
11662
+ const table = match[3] ?? match[4];
11663
+ if (!table || !shouldTrackMachineId(table))
11664
+ return null;
11665
+ const columns = match[5].split(",").map((column) => column.trim().replace(/^"|"$/g, ""));
11666
+ if (columns.some((column) => column.toLowerCase() === "machine_id")) {
11667
+ return { table, sql };
11668
+ }
11669
+ const rewrittenValues = appendLiteralToValueTuples(match[7], quoteSqlString(machineId));
11670
+ if (!rewrittenValues)
11671
+ return { table, sql };
11672
+ const nextColumns = `${match[5].trim()}, "machine_id"`;
11673
+ return {
11674
+ table,
11675
+ sql: `${match[1]}(${nextColumns})${match[6]}${rewrittenValues}`
11676
+ };
11677
+ }
11678
+ function rewriteUpdateSql(sql, machineId) {
11679
+ const match = sql.match(/^\s*(update\s+((?:"([^"]+)")|([A-Za-z_][\w$]*))\s+set\s*)([\s\S]*?)(\s+(?:where|returning)\b[\s\S]*|\s*)$/i);
11680
+ if (!match)
11681
+ return null;
11682
+ const table = match[3] ?? match[4];
11683
+ if (!table || !shouldTrackMachineId(table))
11684
+ return null;
11685
+ if (/\bmachine_id\b/i.test(match[5])) {
11686
+ return { table, sql };
11687
+ }
11688
+ return {
11689
+ table,
11690
+ sql: `${match[1]}${match[5].trimEnd()}, "machine_id" = ${quoteSqlString(machineId)}${match[6]}`
11691
+ };
11692
+ }
11693
+ function maybeRewriteMachineSql(db, sql, machineId) {
11694
+ const trimmed = sql.trimStart();
11695
+ if (/^insert\b/i.test(trimmed)) {
11696
+ const rewritten = rewriteInsertSql(sql, machineId);
11697
+ if (rewritten?.table) {
11698
+ ensureMachineIdColumn(db, rewritten.table);
11699
+ return rewritten.sql;
11700
+ }
11701
+ return sql;
11702
+ }
11703
+ if (/^update\b/i.test(trimmed)) {
11704
+ const rewritten = rewriteUpdateSql(sql, machineId);
11705
+ if (rewritten?.table) {
11706
+ ensureMachineIdColumn(db, rewritten.table);
11707
+ return rewritten.sql;
11708
+ }
11709
+ return sql;
11710
+ }
11711
+ return sql;
11712
+ }
11713
+ function maybeEnsureCreatedTableHasMachineId(db, sql) {
11714
+ const table = extractTableName(sql, /^\s*(create\s+table(?:\s+if\s+not\s+exists)?\s+((?:"([^"]+)")|([A-Za-z_][\w$]*)))/i);
11715
+ if (table) {
11716
+ ensureMachineIdColumn(db, table);
11717
+ }
11718
+ }
11719
+ function createMachineRegistry(db, machineId = getCurrentMachineId()) {
11720
+ return {
11721
+ register(opts = {}) {
11722
+ return registerMachine(db, { ...opts, id: opts.id ?? machineId });
11723
+ },
11724
+ list(opts = {}) {
11725
+ return listMachines(db, opts);
11726
+ },
11727
+ ping(machine) {
11728
+ if (!machine) {
11729
+ return pingMachine(registerMachine(db, { id: machineId }));
11730
+ }
11731
+ return pingMachine(typeof machine === "string" ? getMachineRecord(db, machine) ?? machine : machine);
11732
+ },
11733
+ status(opts = {}) {
11734
+ return getMachineStatus(db, opts);
11735
+ },
11736
+ currentMachine() {
11737
+ return registerMachine(db, { id: machineId });
11738
+ }
11739
+ };
11740
+ }
11741
+ function createMachineAwareAdapter(db) {
11742
+ ensureMachinesTable(db);
11743
+ const machineId = registerMachine(db).id;
11744
+ const machines = createMachineRegistry(db, machineId);
11745
+ const wrapped = {
11746
+ machine_id: machineId,
11747
+ machines,
11748
+ run(sql, ...params) {
11749
+ return db.run(maybeRewriteMachineSql(db, sql, machineId), ...params);
11750
+ },
11751
+ get(sql, ...params) {
11752
+ return db.get(sql, ...params);
11753
+ },
11754
+ all(sql, ...params) {
11755
+ return db.all(sql, ...params);
11756
+ },
11757
+ exec(sql) {
11758
+ db.exec(sql);
11759
+ maybeEnsureCreatedTableHasMachineId(db, sql);
11760
+ },
11761
+ prepare(sql) {
11762
+ const statement = db.prepare(maybeRewriteMachineSql(db, sql, machineId));
11763
+ return {
11764
+ run(...params) {
11765
+ return statement.run(...params);
11766
+ },
11767
+ get(...params) {
11768
+ return statement.get(...params);
11769
+ },
11770
+ all(...params) {
11771
+ return statement.all(...params);
11772
+ },
11773
+ finalize() {
11774
+ statement.finalize();
11775
+ }
11776
+ };
11777
+ },
11778
+ close() {
11779
+ db.close();
11780
+ },
11781
+ transaction(fn) {
11782
+ return db.transaction(fn);
11783
+ },
11784
+ raw: db.raw,
11785
+ query: typeof db.query === "function" ? db.query.bind(db) : undefined
11786
+ };
11787
+ return wrapped;
11788
+ }
11789
+ var MACHINES_TABLE_SQL = `
11790
+ CREATE TABLE IF NOT EXISTS machines (
11791
+ id TEXT PRIMARY KEY,
11792
+ ssh_address TEXT DEFAULT '',
11793
+ arch TEXT DEFAULT '',
11794
+ workspace_path TEXT DEFAULT '',
11795
+ bun_path TEXT DEFAULT '',
11796
+ is_primary INTEGER DEFAULT 0 CHECK (is_primary IN (0, 1)),
11797
+ last_seen_at TEXT,
11798
+ registered_at TEXT,
11799
+ archived INTEGER DEFAULT 0 CHECK (archived IN (0, 1)),
11800
+ CHECK (NOT (is_primary = 1 AND archived = 1))
11801
+ )`;
11802
+ var init_machines = __esm(() => {
11803
+ init_discover();
11804
+ });
11473
11805
 
11474
11806
  // src/config.ts
11475
- init_zod();
11476
- init_adapter();
11477
- init_dotfile();
11478
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
11479
- import { homedir as homedir2 } from "os";
11480
- import { join as join2 } from "path";
11481
- var CloudConfigSchema = exports_external.object({
11482
- rds: exports_external.object({
11483
- host: exports_external.string().default(""),
11484
- port: exports_external.number().default(5432),
11485
- username: exports_external.string().default(""),
11486
- password_env: exports_external.string().default("HASNA_RDS_PASSWORD"),
11487
- ssl: exports_external.boolean().default(true)
11488
- }).default({}),
11489
- mode: exports_external.enum(["local", "cloud", "hybrid"]).default("hybrid"),
11490
- auto_sync_interval_minutes: exports_external.number().default(0),
11491
- feedback_endpoint: exports_external.string().default("https://feedback.hasna.com/api/v1/feedback"),
11492
- sync: exports_external.object({
11493
- schedule_minutes: exports_external.number().default(0)
11494
- }).default({})
11807
+ var exports_config = {};
11808
+ __export(exports_config, {
11809
+ saveCloudConfig: () => saveCloudConfig,
11810
+ getConnectionString: () => getConnectionString,
11811
+ getConfigPath: () => getConfigPath,
11812
+ getConfigDir: () => getConfigDir,
11813
+ getCloudConfig: () => getCloudConfig,
11814
+ createDatabase: () => createDatabase,
11815
+ CloudConfigSchema: () => CloudConfigSchema
11495
11816
  });
11496
- var CONFIG_DIR = join2(homedir2(), ".hasna", "cloud");
11497
- var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
11817
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
11818
+ import { homedir as homedir4 } from "os";
11819
+ import { join as join4 } from "path";
11820
+ function getConfigDir() {
11821
+ return CONFIG_DIR;
11822
+ }
11823
+ function getConfigPath() {
11824
+ return CONFIG_PATH;
11825
+ }
11498
11826
  function getCloudConfig() {
11499
- if (!existsSync2(CONFIG_PATH)) {
11827
+ if (!existsSync4(CONFIG_PATH)) {
11500
11828
  return CloudConfigSchema.parse({});
11501
11829
  }
11502
11830
  try {
@@ -11529,14 +11857,73 @@ function createDatabase(options) {
11529
11857
  const mode = options.mode ?? config.mode;
11530
11858
  if (mode === "cloud") {
11531
11859
  const connStr = options.pgConnectionString ?? getConnectionString(options.service);
11532
- return new PgAdapter(connStr);
11860
+ return createMachineAwareAdapter(new PgAdapter(connStr));
11533
11861
  }
11534
11862
  const dbPath = options.sqlitePath ?? getDbPath(options.service);
11535
- return new SqliteAdapter(dbPath);
11863
+ return createMachineAwareAdapter(new SqliteAdapter(dbPath));
11536
11864
  }
11865
+ var DaemonConfigSchema, CloudConfigSchema, CONFIG_DIR, CONFIG_PATH;
11866
+ var init_config = __esm(() => {
11867
+ init_zod();
11868
+ init_adapter();
11869
+ init_dotfile();
11870
+ init_machines();
11871
+ DaemonConfigSchema = exports_external.object({
11872
+ enabled: exports_external.boolean().default(false),
11873
+ paused: exports_external.boolean().default(false),
11874
+ watch_interval_seconds: exports_external.number().int().positive().default(5),
11875
+ pull_interval_seconds: exports_external.number().int().positive().default(60),
11876
+ push_debounce_seconds: exports_external.number().int().positive().default(5),
11877
+ conflict_strategy: exports_external.enum(["newest-wins", "local-wins", "remote-wins"]).default("newest-wins"),
11878
+ services: exports_external.array(exports_external.string()).default([]),
11879
+ table_intervals: exports_external.record(exports_external.string(), exports_external.record(exports_external.string(), exports_external.number().int().positive())).default({}),
11880
+ file_rules: exports_external.array(exports_external.object({
11881
+ path: exports_external.string(),
11882
+ interval_seconds: exports_external.number().int().positive().default(30),
11883
+ enabled: exports_external.boolean().default(true)
11884
+ })).default([])
11885
+ }).default({});
11886
+ CloudConfigSchema = exports_external.object({
11887
+ rds: exports_external.object({
11888
+ host: exports_external.string().default(""),
11889
+ port: exports_external.number().default(5432),
11890
+ username: exports_external.string().default(""),
11891
+ password_env: exports_external.string().default("HASNA_RDS_PASSWORD"),
11892
+ ssl: exports_external.boolean().default(true)
11893
+ }).default({}),
11894
+ mode: exports_external.enum(["local", "cloud", "hybrid"]).default("hybrid"),
11895
+ auto_sync_interval_minutes: exports_external.number().default(0),
11896
+ feedback_endpoint: exports_external.string().default("https://feedback.hasna.com/api/v1/feedback"),
11897
+ sync: exports_external.object({
11898
+ schedule_minutes: exports_external.number().default(0)
11899
+ }).default({}),
11900
+ daemon: DaemonConfigSchema
11901
+ });
11902
+ CONFIG_DIR = join4(homedir4(), ".hasna", "cloud");
11903
+ CONFIG_PATH = join4(CONFIG_DIR, "config.json");
11904
+ });
11537
11905
 
11538
- // src/sync.ts
11539
- async function syncPush(local, remote, options) {
11906
+ // node_modules/commander/esm.mjs
11907
+ var import__ = __toESM(require_commander(), 1);
11908
+ var {
11909
+ program,
11910
+ createCommand,
11911
+ createArgument,
11912
+ createOption,
11913
+ CommanderError,
11914
+ InvalidArgumentError,
11915
+ InvalidOptionArgumentError,
11916
+ Command,
11917
+ Argument,
11918
+ Option,
11919
+ Help
11920
+ } = import__.default;
11921
+
11922
+ // src/cli/cmd-setup.ts
11923
+ init_config();
11924
+
11925
+ // src/sync.ts
11926
+ async function syncPush(local, remote, options) {
11540
11927
  const orderedTables = await getTableOrder(remote, options.tables);
11541
11928
  return syncTransfer(local, remote, { ...options, tables: orderedTables }, "push");
11542
11929
  }
@@ -11711,6 +12098,9 @@ async function ensureTablesExist(source, target, tables) {
11711
12098
  }
11712
12099
  async function filterColumnsForTarget(target, table, sourceColumns) {
11713
12100
  try {
12101
+ if (sourceColumns.includes("machine_id") && table !== "machines") {
12102
+ await ensureMachineIdColumnInTarget(target, table);
12103
+ }
11714
12104
  if (!isAsyncAdapter(target)) {
11715
12105
  const colInfo = target.all(`PRAGMA table_info("${table}")`);
11716
12106
  if (Array.isArray(colInfo) && colInfo.length > 0) {
@@ -11733,6 +12123,22 @@ async function filterColumnsForTarget(target, table, sourceColumns) {
11733
12123
  } catch {}
11734
12124
  return sourceColumns;
11735
12125
  }
12126
+ async function ensureMachineIdColumnInTarget(target, table) {
12127
+ if (!isAsyncAdapter(target)) {
12128
+ const colInfo2 = target.all(`PRAGMA table_info("${table}")`);
12129
+ const hasMachineId = Array.isArray(colInfo2) ? colInfo2.some((column) => column.name === "machine_id") : false;
12130
+ if (!hasMachineId) {
12131
+ target.exec(`ALTER TABLE "${table}" ADD COLUMN machine_id TEXT DEFAULT ''`);
12132
+ }
12133
+ return;
12134
+ }
12135
+ const colInfo = await target.all(`SELECT column_name
12136
+ FROM information_schema.columns
12137
+ WHERE table_schema = 'public' AND table_name = '${table}' AND column_name = 'machine_id'`);
12138
+ if (colInfo.length === 0) {
12139
+ await target.exec(`ALTER TABLE "${table}" ADD COLUMN machine_id TEXT DEFAULT ''`);
12140
+ }
12141
+ }
11736
12142
  async function syncTransfer(source, target, options, _direction) {
11737
12143
  const {
11738
12144
  tables,
@@ -11966,253 +12372,158 @@ async function listPgTables(db) {
11966
12372
  return rows.map((r) => r.tablename);
11967
12373
  }
11968
12374
 
11969
- // src/feedback.ts
12375
+ // src/cli/cmd-setup.ts
12376
+ init_adapter();
12377
+ init_discover();
12378
+ init_dotfile();
12379
+
12380
+ // src/pg-migrate.ts
12381
+ init_adapter();
11970
12382
  init_config();
11971
- import { hostname } from "os";
11972
- var FEEDBACK_TABLE_SQL = `
11973
- CREATE TABLE IF NOT EXISTS feedback (
11974
- id TEXT PRIMARY KEY,
11975
- service TEXT NOT NULL,
11976
- version TEXT DEFAULT '',
11977
- message TEXT NOT NULL,
11978
- email TEXT DEFAULT '',
11979
- machine_id TEXT DEFAULT '',
11980
- created_at TEXT DEFAULT (datetime('now'))
11981
- )`;
11982
- function ensureFeedbackTable(db) {
11983
- db.exec(FEEDBACK_TABLE_SQL);
11984
- }
11985
- function saveFeedback(db, feedback) {
11986
- ensureFeedbackTable(db);
11987
- const id = feedback.id ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
11988
- const now = new Date().toISOString();
11989
- const machineId = feedback.machine_id ?? hostname();
11990
- db.run(`INSERT INTO feedback (id, service, version, message, email, machine_id, created_at)
11991
- VALUES (?, ?, ?, ?, ?, ?, ?)`, id, feedback.service, feedback.version ?? "", feedback.message, feedback.email ?? "", machineId, feedback.created_at ?? now);
11992
- return id;
11993
- }
11994
- async function sendFeedback(feedback, db) {
11995
- const config = getCloudConfig2();
11996
- const id = feedback.id ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
11997
- const machineId = feedback.machine_id ?? hostname();
11998
- const now = new Date().toISOString();
11999
- const payload = {
12000
- id,
12001
- service: feedback.service,
12002
- version: feedback.version ?? "",
12003
- message: feedback.message,
12004
- email: feedback.email ?? "",
12005
- machine_id: machineId,
12006
- created_at: feedback.created_at ?? now
12383
+ async function applyPgMigrations(connectionString, migrations, service = "unknown") {
12384
+ const pg2 = new PgAdapterAsync(connectionString);
12385
+ const result = {
12386
+ service,
12387
+ applied: [],
12388
+ alreadyApplied: [],
12389
+ errors: [],
12390
+ totalMigrations: migrations.length
12007
12391
  };
12008
12392
  try {
12009
- const res = await fetch(config.feedback_endpoint, {
12010
- method: "POST",
12011
- headers: { "Content-Type": "application/json" },
12012
- body: JSON.stringify(payload),
12013
- signal: AbortSignal.timeout(1e4)
12014
- });
12015
- if (!res.ok) {
12016
- throw new Error(`HTTP ${res.status}: ${res.statusText}`);
12017
- }
12018
- if (db) {
12019
- try {
12020
- saveFeedback(db, { ...feedback, id });
12021
- } catch {}
12022
- }
12023
- return { sent: true, id };
12024
- } catch (err) {
12025
- const errorMsg = err?.message ?? String(err);
12026
- if (db) {
12393
+ await pg2.run(`CREATE TABLE IF NOT EXISTS _pg_migrations (
12394
+ id SERIAL PRIMARY KEY,
12395
+ version INT UNIQUE NOT NULL,
12396
+ applied_at TIMESTAMPTZ DEFAULT NOW()
12397
+ )`);
12398
+ const applied = await pg2.all("SELECT version FROM _pg_migrations ORDER BY version");
12399
+ const appliedSet = new Set(applied.map((r) => r.version));
12400
+ for (let i = 0;i < migrations.length; i++) {
12401
+ if (appliedSet.has(i)) {
12402
+ result.alreadyApplied.push(i);
12403
+ continue;
12404
+ }
12027
12405
  try {
12028
- saveFeedback(db, { ...feedback, id });
12029
- } catch {}
12406
+ await pg2.exec(migrations[i]);
12407
+ await pg2.run("INSERT INTO _pg_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", i);
12408
+ result.applied.push(i);
12409
+ } catch (err) {
12410
+ result.errors.push(`Migration ${i}: ${err?.message ?? String(err)}`);
12411
+ break;
12412
+ }
12030
12413
  }
12031
- return { sent: false, id, error: errorMsg };
12414
+ } finally {
12415
+ await pg2.close();
12032
12416
  }
12417
+ return result;
12033
12418
  }
12034
-
12035
- // src/dotfile.ts
12036
- import {
12037
- existsSync as existsSync4,
12038
- mkdirSync as mkdirSync4,
12039
- readdirSync as readdirSync2,
12040
- copyFileSync as copyFileSync2
12041
- } from "fs";
12042
- import { homedir as homedir4 } from "os";
12043
- import { join as join4, relative as relative2 } from "path";
12044
- var HASNA_DIR2 = join4(homedir4(), ".hasna");
12045
- function getDataDir2(serviceName) {
12046
- const dir = join4(HASNA_DIR2, serviceName);
12047
- mkdirSync4(dir, { recursive: true });
12048
- return dir;
12049
- }
12050
- function getDbPath2(serviceName) {
12051
- const dir = getDataDir2(serviceName);
12052
- return join4(dir, `${serviceName}.db`);
12053
- }
12054
- function migrateDotfile(serviceName) {
12055
- const legacyDir = join4(homedir4(), `.${serviceName}`);
12056
- const newDir = join4(HASNA_DIR2, serviceName);
12057
- if (!existsSync4(legacyDir))
12058
- return [];
12059
- if (existsSync4(newDir))
12060
- return [];
12061
- mkdirSync4(newDir, { recursive: true });
12062
- const migrated = [];
12063
- copyDirRecursive(legacyDir, newDir, legacyDir, migrated);
12064
- return migrated;
12419
+ function getServicePackage(service) {
12420
+ return `@hasna/${service}`;
12065
12421
  }
12066
- function copyDirRecursive(src, dest, root, migrated) {
12067
- const entries = readdirSync2(src, { withFileTypes: true });
12068
- for (const entry of entries) {
12069
- const srcPath = join4(src, entry.name);
12070
- const destPath = join4(dest, entry.name);
12071
- if (entry.isDirectory()) {
12072
- mkdirSync4(destPath, { recursive: true });
12073
- copyDirRecursive(srcPath, destPath, root, migrated);
12074
- } else {
12075
- copyFileSync2(srcPath, destPath);
12076
- migrated.push(relative2(root, srcPath));
12077
- }
12422
+ async function loadServiceMigrations(service) {
12423
+ const pkg = getServicePackage(service);
12424
+ const paths = [
12425
+ `${pkg}/pg-migrations`,
12426
+ `${pkg}/dist/db/pg-migrations.js`,
12427
+ `${pkg}/dist/db/pg-migrations`
12428
+ ];
12429
+ for (const path of paths) {
12430
+ try {
12431
+ const mod = await import(path);
12432
+ if (Array.isArray(mod.PG_MIGRATIONS)) {
12433
+ return mod.PG_MIGRATIONS;
12434
+ }
12435
+ if (mod.default && Array.isArray(mod.default.PG_MIGRATIONS)) {
12436
+ return mod.default.PG_MIGRATIONS;
12437
+ }
12438
+ } catch {}
12078
12439
  }
12440
+ return null;
12079
12441
  }
12080
-
12081
- // src/adapter.ts
12082
- init_esm();
12083
- import { Database as Database2 } from "bun:sqlite";
12084
-
12085
- class SqliteAdapter2 {
12086
- db;
12087
- constructor(path) {
12088
- this.db = new Database2(path, { create: true });
12089
- this.db.exec("PRAGMA journal_mode=WAL");
12090
- this.db.exec("PRAGMA foreign_keys=ON");
12091
- }
12092
- run(sql, ...params) {
12093
- const stmt = this.db.prepare(sql);
12094
- const result = stmt.run(...params);
12442
+ async function migrateService(service, connectionString) {
12443
+ const connStr = connectionString ?? getConnectionString(service);
12444
+ const migrations = await loadServiceMigrations(service);
12445
+ if (!migrations) {
12095
12446
  return {
12096
- changes: result.changes,
12097
- lastInsertRowid: result.lastInsertRowid
12447
+ service,
12448
+ applied: [],
12449
+ alreadyApplied: [],
12450
+ errors: [`No PG migrations found for service "${service}"`],
12451
+ totalMigrations: 0
12098
12452
  };
12099
12453
  }
12100
- get(sql, ...params) {
12101
- const stmt = this.db.prepare(sql);
12102
- return stmt.get(...params);
12454
+ return applyPgMigrations(connStr, migrations, service);
12455
+ }
12456
+ async function migrateAllServices() {
12457
+ const { discoverServices: discoverServices2 } = await Promise.resolve().then(() => (init_discover(), exports_discover));
12458
+ const services = discoverServices2();
12459
+ const results = [];
12460
+ for (const service of services) {
12461
+ try {
12462
+ const result = await migrateService(service);
12463
+ results.push(result);
12464
+ } catch (err) {
12465
+ results.push({
12466
+ service,
12467
+ applied: [],
12468
+ alreadyApplied: [],
12469
+ errors: [err?.message ?? String(err)],
12470
+ totalMigrations: 0
12471
+ });
12472
+ }
12103
12473
  }
12104
- all(sql, ...params) {
12105
- const stmt = this.db.prepare(sql);
12106
- return stmt.all(...params);
12474
+ return results;
12475
+ }
12476
+ async function ensurePgDatabase(service) {
12477
+ const config = (await Promise.resolve().then(() => (init_config(), exports_config))).getCloudConfig();
12478
+ const { host, port, username, password_env, ssl } = config.rds;
12479
+ if (!host || !username)
12480
+ return false;
12481
+ const password = process.env[password_env] ?? "";
12482
+ const sslParam = ssl ? "?sslmode=require" : "";
12483
+ const adminConnStr = `postgres://${username}:${encodeURIComponent(password)}@${host}:${port}/postgres${sslParam}`;
12484
+ const pg2 = new PgAdapterAsync(adminConnStr);
12485
+ try {
12486
+ const existing = await pg2.all(`SELECT 1 FROM pg_database WHERE datname = $1`, service);
12487
+ if (existing.length === 0) {
12488
+ await pg2.exec(`CREATE DATABASE "${service}"`);
12489
+ return true;
12490
+ }
12491
+ return false;
12492
+ } finally {
12493
+ await pg2.close();
12107
12494
  }
12108
- exec(sql) {
12109
- this.db.exec(sql);
12495
+ }
12496
+ async function ensureAllPgDatabases() {
12497
+ const { discoverServices: discoverServices2 } = await Promise.resolve().then(() => (init_discover(), exports_discover));
12498
+ const services = discoverServices2();
12499
+ const results = [];
12500
+ for (const service of services) {
12501
+ try {
12502
+ const created = await ensurePgDatabase(service);
12503
+ results.push({ service, created });
12504
+ } catch (err) {
12505
+ results.push({ service, created: false, error: err?.message ?? String(err) });
12506
+ }
12110
12507
  }
12111
- query(sql) {
12112
- return this.db.query(sql);
12113
- }
12114
- prepare(sql) {
12115
- const stmt = this.db.prepare(sql);
12116
- return {
12117
- run(...params) {
12118
- const r = stmt.run(...params);
12119
- return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
12120
- },
12121
- get(...params) {
12122
- return stmt.get(...params);
12123
- },
12124
- all(...params) {
12125
- return stmt.all(...params);
12126
- },
12127
- finalize() {
12128
- stmt.finalize();
12129
- }
12130
- };
12131
- }
12132
- close() {
12133
- this.db.close();
12134
- }
12135
- transaction(fn) {
12136
- const wrapped = this.db.transaction(fn);
12137
- return wrapped();
12138
- }
12139
- get raw() {
12140
- return this.db;
12141
- }
12142
- }
12143
- class PgAdapterAsync2 {
12144
- pool;
12145
- constructor(arg) {
12146
- if (typeof arg === "string") {
12147
- const sslConfig = arg.includes("sslmode=require") || arg.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
12148
- this.pool = new esm_default.Pool({ connectionString: arg, ssl: sslConfig });
12149
- } else {
12150
- this.pool = arg;
12151
- }
12152
- }
12153
- async run(sql, ...params) {
12154
- const pgSql = translateSql(sql, "pg");
12155
- const pgParams = translateParams(params);
12156
- const res = await this.pool.query(pgSql, pgParams);
12157
- return {
12158
- changes: res.rowCount ?? 0,
12159
- lastInsertRowid: res.rows?.[0]?.id ?? 0
12160
- };
12161
- }
12162
- async get(sql, ...params) {
12163
- const pgSql = translateSql(sql, "pg");
12164
- const pgParams = translateParams(params);
12165
- const res = await this.pool.query(pgSql, pgParams);
12166
- return res.rows[0] ?? null;
12167
- }
12168
- async all(sql, ...params) {
12169
- const pgSql = translateSql(sql, "pg");
12170
- const pgParams = translateParams(params);
12171
- const res = await this.pool.query(pgSql, pgParams);
12172
- return res.rows;
12173
- }
12174
- async exec(sql) {
12175
- const pgSql = translateSql(sql, "pg");
12176
- await this.pool.query(pgSql);
12177
- }
12178
- async close() {
12179
- await this.pool.end();
12180
- }
12181
- async transaction(fn) {
12182
- const client = await this.pool.connect();
12183
- try {
12184
- await client.query("BEGIN");
12185
- const result = await fn(client);
12186
- await client.query("COMMIT");
12187
- return result;
12188
- } catch (err) {
12189
- await client.query("ROLLBACK");
12190
- throw err;
12191
- } finally {
12192
- client.release();
12193
- }
12194
- }
12195
- get raw() {
12196
- return this.pool;
12197
- }
12198
- }
12199
-
12200
- // src/sync-schedule.ts
12201
- init_config();
12202
- import { join as join5, dirname } from "path";
12203
- import { existsSync as existsSync5, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync5 } from "fs";
12204
- import { homedir as homedir5, platform } from "os";
12205
- var SERVICE_NAME = "hasna-cloud-sync";
12206
- var CONFIG_DIR3 = join5(homedir5(), ".hasna", "cloud");
12207
- function parseInterval(input) {
12208
- const trimmed = input.trim().toLowerCase();
12209
- const hourMatch = trimmed.match(/^(\d+)\s*h$/);
12210
- if (hourMatch) {
12211
- const hours = parseInt(hourMatch[1], 10);
12212
- if (hours <= 0) {
12213
- throw new Error(`Invalid interval "${input}". Value must be greater than 0.`);
12214
- }
12215
- return hours * 60;
12508
+ return results;
12509
+ }
12510
+
12511
+ // src/sync-schedule.ts
12512
+ init_config();
12513
+ import { join as join5, dirname as dirname2 } from "path";
12514
+ import { existsSync as existsSync5, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync3 } from "fs";
12515
+ import { homedir as homedir5, platform as platform2 } from "os";
12516
+ var SERVICE_NAME = "hasna-cloud-sync";
12517
+ var CONFIG_DIR2 = join5(homedir5(), ".hasna", "cloud");
12518
+ function parseInterval(input) {
12519
+ const trimmed = input.trim().toLowerCase();
12520
+ const hourMatch = trimmed.match(/^(\d+)\s*h$/);
12521
+ if (hourMatch) {
12522
+ const hours = parseInt(hourMatch[1], 10);
12523
+ if (hours <= 0) {
12524
+ throw new Error(`Invalid interval "${input}". Value must be greater than 0.`);
12525
+ }
12526
+ return hours * 60;
12216
12527
  }
12217
12528
  const minMatch = trimmed.match(/^(\d+)\s*m$/);
12218
12529
  if (minMatch) {
@@ -12243,7 +12554,7 @@ function minutesToCron(minutes) {
12243
12554
  return `*/${minutes} * * * *`;
12244
12555
  }
12245
12556
  function getWorkerPath() {
12246
- const dir = typeof import.meta.dir === "string" ? import.meta.dir : dirname(import.meta.url.replace("file://", ""));
12557
+ const dir = typeof import.meta.dir === "string" ? import.meta.dir : dirname2(import.meta.url.replace("file://", ""));
12247
12558
  const tsPath = join5(dir, "scheduled-sync.ts");
12248
12559
  const jsPath = join5(dir, "scheduled-sync.js");
12249
12560
  try {
@@ -12270,8 +12581,8 @@ function getLaunchdPlistPath() {
12270
12581
  function createLaunchdPlist(intervalMinutes) {
12271
12582
  const workerPath = getWorkerPath();
12272
12583
  const bunPath = getBunPath();
12273
- const logPath = join5(CONFIG_DIR3, "sync.log");
12274
- const errorLogPath = join5(CONFIG_DIR3, "sync-error.log");
12584
+ const logPath = join5(CONFIG_DIR2, "sync.log");
12585
+ const errorLogPath = join5(CONFIG_DIR2, "sync-error.log");
12275
12586
  return `<?xml version="1.0" encoding="UTF-8"?>
12276
12587
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
12277
12588
  <plist version="1.0">
@@ -12304,12 +12615,12 @@ function createLaunchdPlist(intervalMinutes) {
12304
12615
  }
12305
12616
  async function registerLaunchd(intervalMinutes) {
12306
12617
  const plistPath = getLaunchdPlistPath();
12307
- const plistDir = dirname(plistPath);
12308
- mkdirSync5(plistDir, { recursive: true });
12618
+ const plistDir = dirname2(plistPath);
12619
+ mkdirSync3(plistDir, { recursive: true });
12309
12620
  try {
12310
12621
  await Bun.spawn(["launchctl", "unload", plistPath]).exited;
12311
12622
  } catch {}
12312
- writeFileSync3(plistPath, createLaunchdPlist(intervalMinutes));
12623
+ writeFileSync2(plistPath, createLaunchdPlist(intervalMinutes));
12313
12624
  await Bun.spawn(["launchctl", "load", plistPath]).exited;
12314
12625
  }
12315
12626
  async function removeLaunchd() {
@@ -12356,9 +12667,9 @@ WantedBy=timers.target
12356
12667
  }
12357
12668
  async function registerSystemd(intervalMinutes) {
12358
12669
  const dir = getSystemdDir();
12359
- mkdirSync5(dir, { recursive: true });
12360
- writeFileSync3(join5(dir, `${SERVICE_NAME}.service`), createSystemdService());
12361
- writeFileSync3(join5(dir, `${SERVICE_NAME}.timer`), createSystemdTimer(intervalMinutes));
12670
+ mkdirSync3(dir, { recursive: true });
12671
+ writeFileSync2(join5(dir, `${SERVICE_NAME}.service`), createSystemdService());
12672
+ writeFileSync2(join5(dir, `${SERVICE_NAME}.timer`), createSystemdTimer(intervalMinutes));
12362
12673
  await Bun.spawn(["systemctl", "--user", "daemon-reload"]).exited;
12363
12674
  await Bun.spawn(["systemctl", "--user", "enable", "--now", `${SERVICE_NAME}.timer`]).exited;
12364
12675
  }
@@ -12381,33 +12692,33 @@ async function registerSyncSchedule(intervalMinutes) {
12381
12692
  if (intervalMinutes <= 0) {
12382
12693
  throw new Error("Interval must be a positive number of minutes.");
12383
12694
  }
12384
- mkdirSync5(CONFIG_DIR3, { recursive: true });
12385
- if (platform() === "darwin") {
12695
+ mkdirSync3(CONFIG_DIR2, { recursive: true });
12696
+ if (platform2() === "darwin") {
12386
12697
  await registerLaunchd(intervalMinutes);
12387
12698
  } else {
12388
12699
  await registerSystemd(intervalMinutes);
12389
12700
  }
12390
- const config = getCloudConfig2();
12701
+ const config = getCloudConfig();
12391
12702
  config.sync.schedule_minutes = intervalMinutes;
12392
- saveCloudConfig2(config);
12703
+ saveCloudConfig(config);
12393
12704
  }
12394
12705
  async function removeSyncSchedule() {
12395
- if (platform() === "darwin") {
12706
+ if (platform2() === "darwin") {
12396
12707
  await removeLaunchd();
12397
12708
  } else {
12398
12709
  await removeSystemd();
12399
12710
  }
12400
- const config = getCloudConfig2();
12711
+ const config = getCloudConfig();
12401
12712
  config.sync.schedule_minutes = 0;
12402
- saveCloudConfig2(config);
12713
+ saveCloudConfig(config);
12403
12714
  }
12404
12715
  function getSyncScheduleStatus() {
12405
- const config = getCloudConfig2();
12716
+ const config = getCloudConfig();
12406
12717
  const minutes = config.sync.schedule_minutes;
12407
12718
  const registered = minutes > 0;
12408
12719
  let mechanism = "none";
12409
12720
  if (registered) {
12410
- if (platform() === "darwin") {
12721
+ if (platform2() === "darwin") {
12411
12722
  mechanism = existsSync5(getLaunchdPlistPath()) ? "launchd" : "none";
12412
12723
  } else {
12413
12724
  mechanism = existsSync5(join5(getSystemdDir(), `${SERVICE_NAME}.timer`)) ? "systemd" : "none";
@@ -12421,6 +12732,184 @@ function getSyncScheduleStatus() {
12421
12732
  };
12422
12733
  }
12423
12734
 
12735
+ // src/cli/cmd-setup.ts
12736
+ function registerSetupCommand(program2) {
12737
+ program2.command("setup").description("Configure cloud settings — interactive wizard or flags").option("--host <host>", "RDS hostname").option("--port <port>", "RDS port", "5432").option("--username <user>", "RDS username").option("--password-env <env>", "Env var for RDS password", "HASNA_RDS_PASSWORD").option("--ssl", "Enable SSL", true).option("--no-ssl", "Disable SSL").option("--mode <mode>", "Mode: local, cloud, or hybrid").option("--schedule <interval>", "Sync schedule (e.g. 30m, 1h)").option("--migrate", "Run PG migrations after setup").option("--pull", "Pull data from cloud after setup").action(async (opts) => {
12738
+ const config = getCloudConfig();
12739
+ const isAutoDetect = !opts.host && !opts.username;
12740
+ if (isAutoDetect) {
12741
+ const envHost = process.env.HASNA_RDS_HOST;
12742
+ const envUser = process.env.HASNA_RDS_USERNAME;
12743
+ if (envHost && !config.rds.host) {
12744
+ config.rds.host = envHost;
12745
+ console.log(`Auto-detected RDS host: ${envHost}`);
12746
+ }
12747
+ if (envUser && !config.rds.username) {
12748
+ config.rds.username = envUser;
12749
+ console.log(`Auto-detected RDS username: ${envUser}`);
12750
+ }
12751
+ }
12752
+ if (opts.host)
12753
+ config.rds.host = opts.host;
12754
+ if (opts.port)
12755
+ config.rds.port = parseInt(opts.port, 10);
12756
+ if (opts.username)
12757
+ config.rds.username = opts.username;
12758
+ if (opts.passwordEnv)
12759
+ config.rds.password_env = opts.passwordEnv;
12760
+ config.rds.ssl = opts.ssl;
12761
+ if (opts.mode) {
12762
+ config.mode = opts.mode;
12763
+ } else if (config.mode === "local" && config.rds.host) {
12764
+ config.mode = "hybrid";
12765
+ console.log("Mode set to: hybrid (auto-upgraded from local)");
12766
+ }
12767
+ saveCloudConfig(config);
12768
+ console.log(`
12769
+ ✓ Configuration saved
12770
+ `);
12771
+ const password = process.env[config.rds.password_env];
12772
+ if (!password) {
12773
+ console.error(`✗ ${config.rds.password_env} not set in environment`);
12774
+ console.error(` Add it to ~/.secrets/hasna/rds/live.env and source it`);
12775
+ return;
12776
+ }
12777
+ if (config.rds.host) {
12778
+ process.stdout.write("Testing PG connection... ");
12779
+ try {
12780
+ const connStr = getConnectionString("postgres");
12781
+ const pg2 = new PgAdapterAsync(connStr);
12782
+ await pg2.all("SELECT 1");
12783
+ await pg2.close();
12784
+ console.log(`✓ Connected
12785
+ `);
12786
+ } catch (err) {
12787
+ console.log(`✗ Failed: ${err?.message ?? String(err)}`);
12788
+ return;
12789
+ }
12790
+ if (opts.migrate !== false) {
12791
+ console.log("Creating databases & running migrations...");
12792
+ const dbResults = await ensureAllPgDatabases();
12793
+ const created = dbResults.filter((r) => r.created);
12794
+ if (created.length > 0) {
12795
+ console.log(` Created ${created.length} database(s): ${created.map((r) => r.service).join(", ")}`);
12796
+ }
12797
+ const migResults = await migrateAllServices();
12798
+ const totalApplied = migResults.reduce((s, r) => s + r.applied.length, 0);
12799
+ const applied = migResults.filter((r) => r.applied.length > 0);
12800
+ if (totalApplied > 0) {
12801
+ console.log(` Applied ${totalApplied} migration(s) across ${applied.length} service(s)`);
12802
+ } else {
12803
+ console.log(" All migrations up to date");
12804
+ }
12805
+ console.log("");
12806
+ }
12807
+ if (opts.schedule) {
12808
+ try {
12809
+ const minutes = parseInterval(opts.schedule);
12810
+ await registerSyncSchedule(minutes);
12811
+ console.log(`✓ Sync scheduled every ${minutes}m
12812
+ `);
12813
+ } catch (err) {
12814
+ console.error(`✗ Schedule failed: ${err?.message}`);
12815
+ }
12816
+ }
12817
+ if (opts.pull) {
12818
+ console.log("Pulling data from cloud...");
12819
+ const services = discoverServices();
12820
+ for (const service of services) {
12821
+ try {
12822
+ const local = new SqliteAdapter(getDbPath(service));
12823
+ const cloud = new PgAdapterAsync(getConnectionString(service));
12824
+ const tables = (await listPgTables(cloud)).filter((t) => !isSyncExcludedTable(t));
12825
+ if (tables.length > 0) {
12826
+ const results = await syncPull(cloud, local, { tables });
12827
+ const written = results.reduce((s, r) => s + r.rowsWritten, 0);
12828
+ if (written > 0)
12829
+ console.log(` ${service}: ${written} rows`);
12830
+ }
12831
+ local.close();
12832
+ await cloud.close();
12833
+ } catch {}
12834
+ }
12835
+ console.log("");
12836
+ }
12837
+ }
12838
+ console.log("Setup complete. Run `cloud doctor` to verify everything.");
12839
+ });
12840
+ }
12841
+
12842
+ // node_modules/commander/esm.mjs
12843
+ var import__2 = __toESM(require_commander(), 1);
12844
+ var {
12845
+ program: program2,
12846
+ createCommand: createCommand2,
12847
+ createArgument: createArgument2,
12848
+ createOption: createOption2,
12849
+ CommanderError: CommanderError2,
12850
+ InvalidArgumentError: InvalidArgumentError2,
12851
+ InvalidOptionArgumentError: InvalidOptionArgumentError2,
12852
+ Command: Command2,
12853
+ Argument: Argument2,
12854
+ Option: Option2,
12855
+ Help: Help2
12856
+ } = import__2.default;
12857
+
12858
+ // src/cli/cmd-sync.ts
12859
+ init_config();
12860
+ init_adapter();
12861
+ init_discover();
12862
+ init_dotfile();
12863
+
12864
+ // src/sync-conflicts.ts
12865
+ function ensureConflictsTable(db) {
12866
+ db.exec(`
12867
+ CREATE TABLE IF NOT EXISTS _sync_conflicts (
12868
+ id TEXT PRIMARY KEY,
12869
+ table_name TEXT,
12870
+ row_id TEXT,
12871
+ local_data TEXT,
12872
+ remote_data TEXT,
12873
+ local_updated_at TEXT,
12874
+ remote_updated_at TEXT,
12875
+ resolution TEXT,
12876
+ resolved_at TEXT,
12877
+ created_at TEXT DEFAULT (datetime('now'))
12878
+ )
12879
+ `);
12880
+ }
12881
+ function listConflicts(db, opts) {
12882
+ ensureConflictsTable(db);
12883
+ let sql = `SELECT * FROM _sync_conflicts WHERE 1=1`;
12884
+ const params = [];
12885
+ if (opts?.resolved !== undefined) {
12886
+ if (opts.resolved) {
12887
+ sql += ` AND resolution IS NOT NULL AND resolved_at IS NOT NULL`;
12888
+ } else {
12889
+ sql += ` AND (resolution IS NULL OR resolved_at IS NULL)`;
12890
+ }
12891
+ }
12892
+ if (opts?.table) {
12893
+ sql += ` AND table_name = ?`;
12894
+ params.push(opts.table);
12895
+ }
12896
+ sql += ` ORDER BY created_at DESC`;
12897
+ return db.all(sql, ...params);
12898
+ }
12899
+ function resolveConflict(db, conflictId, strategy) {
12900
+ ensureConflictsTable(db);
12901
+ const row = db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
12902
+ if (!row)
12903
+ return null;
12904
+ db.run(`UPDATE _sync_conflicts SET resolution = ?, resolved_at = datetime('now') WHERE id = ?`, strategy, conflictId);
12905
+ return db.get(`SELECT * FROM _sync_conflicts WHERE id = ?`, conflictId);
12906
+ }
12907
+ function purgeResolvedConflicts(db) {
12908
+ ensureConflictsTable(db);
12909
+ const result = db.run(`DELETE FROM _sync_conflicts WHERE resolution IS NOT NULL AND resolved_at IS NOT NULL`);
12910
+ return result.changes;
12911
+ }
12912
+
12424
12913
  // src/scheduled-sync.ts
12425
12914
  init_config();
12426
12915
  init_adapter();
@@ -12428,6 +12917,7 @@ import { existsSync as existsSync6, readdirSync as readdirSync3 } from "fs";
12428
12917
  import { join as join6 } from "path";
12429
12918
 
12430
12919
  // src/sync-incremental.ts
12920
+ init_machines();
12431
12921
  var SYNC_META_TABLE_SQL = `
12432
12922
  CREATE TABLE IF NOT EXISTS _sync_meta (
12433
12923
  table_name TEXT PRIMARY KEY,
@@ -12459,6 +12949,11 @@ function transferRows(source, target, table, rows, options) {
12459
12949
  if (rows.length === 0)
12460
12950
  return { written, skipped, errors: errors2 };
12461
12951
  const columns = Object.keys(rows[0]);
12952
+ if (columns.includes("machine_id") && table !== "machines") {
12953
+ try {
12954
+ ensureMachineIdColumn(target, table);
12955
+ } catch {}
12956
+ }
12462
12957
  const hasConflictCol = columns.includes(conflictColumn);
12463
12958
  const hasPrimaryKey = columns.includes(primaryKey);
12464
12959
  if (!hasPrimaryKey) {
@@ -12469,12 +12964,21 @@ function transferRows(source, target, table, rows, options) {
12469
12964
  try {
12470
12965
  const existing = target.get(`SELECT "${primaryKey}"${hasConflictCol ? `, "${conflictColumn}"` : ""} FROM "${table}" WHERE "${primaryKey}" = ?`, row[primaryKey]);
12471
12966
  if (existing) {
12472
- if (hasConflictCol && existing[conflictColumn] && row[conflictColumn]) {
12473
- const existingTime = new Date(existing[conflictColumn]).getTime();
12474
- const incomingTime = new Date(row[conflictColumn]).getTime();
12475
- if (existingTime >= incomingTime) {
12476
- skipped++;
12477
- continue;
12967
+ const conflictStrategy = options.conflictStrategy ?? "newest-wins";
12968
+ const sourceRole = options.sourceRole ?? "local";
12969
+ const sourceWins = conflictStrategy === "local-wins" && sourceRole === "local" || conflictStrategy === "remote-wins" && sourceRole === "remote";
12970
+ if (!sourceWins && conflictStrategy !== "newest-wins") {
12971
+ skipped++;
12972
+ continue;
12973
+ }
12974
+ if (conflictStrategy === "newest-wins") {
12975
+ if (hasConflictCol && existing[conflictColumn] && row[conflictColumn]) {
12976
+ const existingTime = new Date(existing[conflictColumn]).getTime();
12977
+ const incomingTime = new Date(row[conflictColumn]).getTime();
12978
+ if (existingTime >= incomingTime) {
12979
+ skipped++;
12980
+ continue;
12981
+ }
12478
12982
  }
12479
12983
  }
12480
12984
  const setClauses = columns.filter((c) => c !== primaryKey).map((c) => `"${c}" = ?`).join(", ");
@@ -12525,7 +13029,10 @@ function incrementalSyncPush(local, remote, tables, options = {}) {
12525
13029
  }
12526
13030
  for (let offset = 0;offset < rows.length; offset += batchSize) {
12527
13031
  const batch = rows.slice(offset, offset + batchSize);
12528
- const result = transferRows(local, remote, table, batch, options);
13032
+ const result = transferRows(local, remote, table, batch, {
13033
+ ...options,
13034
+ sourceRole: "local"
13035
+ });
12529
13036
  stat.synced_rows += result.written;
12530
13037
  stat.skipped_rows += result.skipped;
12531
13038
  stat.errors.push(...result.errors);
@@ -12547,18 +13054,66 @@ function incrementalSyncPush(local, remote, tables, options = {}) {
12547
13054
  }
12548
13055
  return results;
12549
13056
  }
12550
-
12551
- // src/scheduled-sync.ts
12552
- init_dotfile();
12553
-
12554
- // src/sync.ts
12555
- function listSqliteTables2(db) {
12556
- const rows = db.all(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`);
12557
- return rows.map((r) => r.name);
13057
+ function incrementalSyncPull(remote, local, tables, options = {}) {
13058
+ const { conflictColumn = "updated_at", batchSize = 500 } = options;
13059
+ const results = [];
13060
+ ensureSyncMetaTable(local);
13061
+ for (const table of tables) {
13062
+ const stat = {
13063
+ table,
13064
+ total_rows: 0,
13065
+ synced_rows: 0,
13066
+ skipped_rows: 0,
13067
+ errors: [],
13068
+ first_sync: false
13069
+ };
13070
+ try {
13071
+ const countResult = remote.get(`SELECT COUNT(*) as cnt FROM "${table}"`);
13072
+ stat.total_rows = countResult?.cnt ?? 0;
13073
+ const meta = getSyncMeta(local, table);
13074
+ let rows;
13075
+ if (meta?.last_synced_at) {
13076
+ try {
13077
+ rows = remote.all(`SELECT * FROM "${table}" WHERE "${conflictColumn}" > ?`, meta.last_synced_at);
13078
+ } catch {
13079
+ rows = remote.all(`SELECT * FROM "${table}"`);
13080
+ stat.first_sync = true;
13081
+ }
13082
+ } else {
13083
+ rows = remote.all(`SELECT * FROM "${table}"`);
13084
+ stat.first_sync = true;
13085
+ }
13086
+ for (let offset = 0;offset < rows.length; offset += batchSize) {
13087
+ const batch = rows.slice(offset, offset + batchSize);
13088
+ const result = transferRows(remote, local, table, batch, {
13089
+ ...options,
13090
+ sourceRole: "remote"
13091
+ });
13092
+ stat.synced_rows += result.written;
13093
+ stat.skipped_rows += result.skipped;
13094
+ stat.errors.push(...result.errors);
13095
+ }
13096
+ if (rows.length === 0) {
13097
+ stat.skipped_rows = stat.total_rows;
13098
+ }
13099
+ const now = new Date().toISOString();
13100
+ upsertSyncMeta(local, {
13101
+ table_name: table,
13102
+ last_synced_at: now,
13103
+ last_synced_row_count: stat.synced_rows,
13104
+ direction: "pull"
13105
+ });
13106
+ } catch (err) {
13107
+ stat.errors.push(`Table "${table}": ${err?.message ?? String(err)}`);
13108
+ }
13109
+ results.push(stat);
13110
+ }
13111
+ return results;
12558
13112
  }
12559
13113
 
12560
13114
  // src/scheduled-sync.ts
12561
- function discoverSyncableServices() {
13115
+ init_dotfile();
13116
+ function discoverSyncableServices2() {
12562
13117
  const hasnaDir = getHasnaDir();
12563
13118
  const services = [];
12564
13119
  try {
@@ -12575,10 +13130,10 @@ function discoverSyncableServices() {
12575
13130
  return services;
12576
13131
  }
12577
13132
  async function runScheduledSync() {
12578
- const config = getCloudConfig2();
13133
+ const config = getCloudConfig();
12579
13134
  if (config.mode === "local")
12580
13135
  return [];
12581
- const services = discoverSyncableServices();
13136
+ const services = discoverSyncableServices2();
12582
13137
  const results = [];
12583
13138
  let remote = null;
12584
13139
  for (const service of services) {
@@ -12594,14 +13149,14 @@ async function runScheduledSync() {
12594
13149
  continue;
12595
13150
  }
12596
13151
  const local = new SqliteAdapter(dbPath);
12597
- const tables = listSqliteTables2(local).filter((t) => !t.startsWith("_") && !t.startsWith("sqlite_"));
13152
+ const tables = listSqliteTables(local).filter((t) => !t.startsWith("_") && !t.startsWith("sqlite_"));
12598
13153
  if (tables.length === 0) {
12599
13154
  local.close();
12600
13155
  continue;
12601
13156
  }
12602
13157
  try {
12603
- const connStr = getConnectionString2(service);
12604
- remote = new PgAdapterAsync(connStr);
13158
+ const connStr = getConnectionString(service);
13159
+ remote = new PgAdapter(connStr);
12605
13160
  } catch (err) {
12606
13161
  result.errors.push(`Connection failed: ${err?.message ?? String(err)}`);
12607
13162
  local.close();
@@ -12617,7 +13172,7 @@ async function runScheduledSync() {
12617
13172
  result.errors.push(...s.errors);
12618
13173
  }
12619
13174
  local.close();
12620
- await remote.close();
13175
+ remote.close();
12621
13176
  remote = null;
12622
13177
  } catch (err) {
12623
13178
  result.errors.push(err?.message ?? String(err));
@@ -12626,785 +13181,1481 @@ async function runScheduledSync() {
12626
13181
  }
12627
13182
  if (remote) {
12628
13183
  try {
12629
- await remote.close();
13184
+ remote.close();
12630
13185
  } catch {}
12631
13186
  }
12632
13187
  return results;
12633
13188
  }
12634
13189
 
12635
- // src/discover.ts
12636
- import { readdirSync as readdirSync4, existsSync as existsSync7 } from "fs";
12637
- import { join as join7 } from "path";
13190
+ // src/cli/cmd-sync.ts
13191
+ import { existsSync as existsSync8, statSync as statSync5 } from "fs";
13192
+ import { join as join8 } from "path";
13193
+ import { homedir as homedir7 } from "os";
13194
+
13195
+ // src/daemon-sync.ts
13196
+ init_adapter();
13197
+ init_config();
13198
+ init_discover();
13199
+ init_dotfile();
13200
+ import { spawn } from "child_process";
13201
+ import {
13202
+ existsSync as existsSync7,
13203
+ mkdirSync as mkdirSync4,
13204
+ readFileSync as readFileSync3,
13205
+ statSync as statSync4,
13206
+ writeFileSync as writeFileSync3
13207
+ } from "fs";
12638
13208
  import { homedir as homedir6 } from "os";
12639
- var SYNC_EXCLUDED_TABLE_PATTERNS = [
12640
- /^sqlite_/,
12641
- /_fts$/,
12642
- /_fts_/,
12643
- /^_sync_/,
12644
- /^_pg_migrations$/
12645
- ];
12646
- function isSyncExcludedTable(table) {
12647
- return SYNC_EXCLUDED_TABLE_PATTERNS.some((p) => p.test(table));
13209
+ import { join as join7 } from "path";
13210
+ var DAEMON_STATE_PATH = join7(homedir6(), ".hasna", "cloud", "daemon-state.json");
13211
+ var defaultDaemonAdapterFactory = {
13212
+ getLocalDbPath: (service) => getDbPath(service),
13213
+ openLocal: (service) => new SqliteAdapter(getDbPath(service)),
13214
+ openRemote: (service) => new PgAdapter(getConnectionString(service)),
13215
+ listRemoteTables: (remote) => remote.all(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`).map((row) => row.tablename)
13216
+ };
13217
+ function nowIso() {
13218
+ return new Date().toISOString();
12648
13219
  }
12649
- function discoverServices() {
12650
- const dataDir = join7(homedir6(), ".hasna");
12651
- if (!existsSync7(dataDir))
12652
- return [];
13220
+ function sleepSync(ms) {
13221
+ if (ms <= 0)
13222
+ return;
13223
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
13224
+ Atomics.wait(sleeper, 0, 0, ms);
13225
+ }
13226
+ function cloneState(state) {
13227
+ return JSON.parse(JSON.stringify(state));
13228
+ }
13229
+ function getDefaultDaemonConfig() {
13230
+ return {
13231
+ enabled: false,
13232
+ paused: false,
13233
+ watch_interval_seconds: 5,
13234
+ pull_interval_seconds: 60,
13235
+ push_debounce_seconds: 5,
13236
+ conflict_strategy: "newest-wins",
13237
+ services: [],
13238
+ table_intervals: {},
13239
+ file_rules: []
13240
+ };
13241
+ }
13242
+ function getDaemonConfig(config = getCloudConfig()) {
13243
+ const defaults2 = getDefaultDaemonConfig();
13244
+ return {
13245
+ ...defaults2,
13246
+ ...config.daemon,
13247
+ services: [...config.daemon?.services ?? defaults2.services],
13248
+ table_intervals: { ...config.daemon?.table_intervals ?? defaults2.table_intervals },
13249
+ file_rules: [...config.daemon?.file_rules ?? defaults2.file_rules]
13250
+ };
13251
+ }
13252
+ function saveDaemonConfig(config) {
13253
+ const cloudConfig = getCloudConfig();
13254
+ cloudConfig.daemon = {
13255
+ ...getDefaultDaemonConfig(),
13256
+ ...config,
13257
+ services: [...config.services],
13258
+ table_intervals: { ...config.table_intervals },
13259
+ file_rules: [...config.file_rules]
13260
+ };
13261
+ saveCloudConfig(cloudConfig);
13262
+ return getDaemonConfig(cloudConfig);
13263
+ }
13264
+ function createDefaultDaemonState() {
13265
+ return {
13266
+ pid: null,
13267
+ status: "stopped",
13268
+ started_at: null,
13269
+ updated_at: null,
13270
+ last_push_at: null,
13271
+ last_pull_at: null,
13272
+ last_error: null,
13273
+ services: {},
13274
+ files: {}
13275
+ };
13276
+ }
13277
+ function readDaemonState() {
13278
+ if (!existsSync7(DAEMON_STATE_PATH)) {
13279
+ return createDefaultDaemonState();
13280
+ }
12653
13281
  try {
12654
- const entries = readdirSync4(dataDir, { withFileTypes: true });
12655
- return entries.filter((e) => {
12656
- if (!e.isDirectory())
12657
- return false;
12658
- if (e.name === "cloud" || e.name.startsWith("."))
12659
- return false;
12660
- return true;
12661
- }).map((e) => e.name).sort();
13282
+ const raw = JSON.parse(readFileSync3(DAEMON_STATE_PATH, "utf-8"));
13283
+ const defaults2 = createDefaultDaemonState();
13284
+ return {
13285
+ ...defaults2,
13286
+ ...raw,
13287
+ services: raw?.services ?? defaults2.services,
13288
+ files: raw?.files ?? defaults2.files
13289
+ };
12662
13290
  } catch {
12663
- return [];
13291
+ return createDefaultDaemonState();
12664
13292
  }
12665
13293
  }
12666
-
12667
- // src/pg-migrate.ts
12668
- init_adapter();
12669
- init_config();
12670
- async function applyPgMigrations(connectionString, migrations, service = "unknown") {
12671
- const pg2 = new PgAdapterAsync(connectionString);
12672
- const result = {
12673
- service,
12674
- applied: [],
12675
- alreadyApplied: [],
12676
- errors: [],
12677
- totalMigrations: migrations.length
13294
+ function writeDaemonState(state) {
13295
+ mkdirSync4(join7(homedir6(), ".hasna", "cloud"), { recursive: true });
13296
+ writeFileSync3(DAEMON_STATE_PATH, JSON.stringify(state, null, 2) + `
13297
+ `, "utf-8");
13298
+ }
13299
+ function getServiceState(state, service) {
13300
+ return state.services[service] ?? {
13301
+ last_local_db_mtime_ms: 0,
13302
+ last_push_at: null,
13303
+ last_pull_at: null,
13304
+ last_error: null,
13305
+ tables: {}
12678
13306
  };
13307
+ }
13308
+ function isProcessRunning(pid) {
13309
+ if (!pid || pid <= 0)
13310
+ return false;
12679
13311
  try {
12680
- await pg2.run(`CREATE TABLE IF NOT EXISTS _pg_migrations (
12681
- id SERIAL PRIMARY KEY,
12682
- version INT UNIQUE NOT NULL,
12683
- applied_at TIMESTAMPTZ DEFAULT NOW()
12684
- )`);
12685
- const applied = await pg2.all("SELECT version FROM _pg_migrations ORDER BY version");
12686
- const appliedSet = new Set(applied.map((r) => r.version));
12687
- for (let i = 0;i < migrations.length; i++) {
12688
- if (appliedSet.has(i)) {
12689
- result.alreadyApplied.push(i);
12690
- continue;
12691
- }
12692
- try {
12693
- await pg2.exec(migrations[i]);
12694
- await pg2.run("INSERT INTO _pg_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", i);
12695
- result.applied.push(i);
12696
- } catch (err) {
12697
- result.errors.push(`Migration ${i}: ${err?.message ?? String(err)}`);
12698
- break;
12699
- }
12700
- }
12701
- } finally {
12702
- await pg2.close();
13312
+ process.kill(pid, 0);
13313
+ return true;
13314
+ } catch {
13315
+ return false;
12703
13316
  }
12704
- return result;
12705
13317
  }
12706
- function getServicePackage(service) {
12707
- return `@hasna/${service}`;
13318
+ function resolveDaemonServices(config) {
13319
+ if (config.services.length > 0) {
13320
+ return [...new Set(config.services)].sort();
13321
+ }
13322
+ return discoverSyncableServices();
12708
13323
  }
12709
- async function loadServiceMigrations(service) {
12710
- const pkg = getServicePackage(service);
12711
- const paths = [
12712
- `${pkg}/pg-migrations`,
12713
- `${pkg}/dist/db/pg-migrations.js`,
12714
- `${pkg}/dist/db/pg-migrations`
12715
- ];
12716
- for (const path of paths) {
13324
+ function parseTableIntervalRule(raw) {
13325
+ const match = raw.match(/^([^.:=]+)[.:]([^=]+)=(\d+)$/);
13326
+ if (!match) {
13327
+ throw new Error(`Invalid table interval "${raw}". Use service.table=seconds or service:table=seconds.`);
13328
+ }
13329
+ const intervalSeconds = parseInt(match[3], 10);
13330
+ if (intervalSeconds <= 0) {
13331
+ throw new Error(`Invalid table interval "${raw}". Seconds must be > 0.`);
13332
+ }
13333
+ return {
13334
+ service: match[1],
13335
+ table: match[2],
13336
+ interval_seconds: intervalSeconds
13337
+ };
13338
+ }
13339
+ function applyTableIntervalRules(existing, rules) {
13340
+ const next = { ...existing };
13341
+ for (const raw of rules) {
13342
+ const parsed = parseTableIntervalRule(raw);
13343
+ next[parsed.service] = {
13344
+ ...next[parsed.service] ?? {},
13345
+ [parsed.table]: parsed.interval_seconds
13346
+ };
13347
+ }
13348
+ return next;
13349
+ }
13350
+ function parseFileRule(raw) {
13351
+ const [pathPart, intervalPart] = raw.split("=", 2);
13352
+ const path = pathPart?.trim();
13353
+ if (!path) {
13354
+ throw new Error(`Invalid file rule "${raw}". Use /path/to/file=seconds.`);
13355
+ }
13356
+ const intervalSeconds = intervalPart ? parseInt(intervalPart, 10) : 30;
13357
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
13358
+ throw new Error(`Invalid file rule "${raw}". Seconds must be > 0.`);
13359
+ }
13360
+ return {
13361
+ path,
13362
+ interval_seconds: intervalSeconds,
13363
+ enabled: true
13364
+ };
13365
+ }
13366
+ function applyFileRules(existing, rules) {
13367
+ const next = [...existing];
13368
+ for (const raw of rules) {
13369
+ next.push(parseFileRule(raw));
13370
+ }
13371
+ return next;
13372
+ }
13373
+ function isIntervalDue(lastAt, intervalSeconds, nowMs) {
13374
+ if (!intervalSeconds)
13375
+ return true;
13376
+ if (!lastAt)
13377
+ return true;
13378
+ return nowMs - Date.parse(lastAt) >= intervalSeconds * 1000;
13379
+ }
13380
+ function scanConfiguredFiles(config, state) {
13381
+ for (const rule of config.file_rules) {
13382
+ const key = rule.path;
13383
+ let lastMtime = 0;
13384
+ let exists = false;
12717
13385
  try {
12718
- const mod = await import(path);
12719
- if (Array.isArray(mod.PG_MIGRATIONS)) {
12720
- return mod.PG_MIGRATIONS;
12721
- }
12722
- if (mod.default && Array.isArray(mod.default.PG_MIGRATIONS)) {
12723
- return mod.default.PG_MIGRATIONS;
12724
- }
12725
- } catch {}
13386
+ const stats = statSync4(rule.path);
13387
+ lastMtime = stats.mtimeMs;
13388
+ exists = true;
13389
+ } catch {
13390
+ exists = false;
13391
+ }
13392
+ state.files[key] = {
13393
+ path: key,
13394
+ enabled: rule.enabled,
13395
+ interval_seconds: rule.interval_seconds,
13396
+ exists,
13397
+ last_mtime_ms: lastMtime
13398
+ };
12726
13399
  }
12727
- return null;
12728
13400
  }
12729
- async function migrateService(service, connectionString) {
12730
- const connStr = connectionString ?? getConnectionString2(service);
12731
- const migrations = await loadServiceMigrations(service);
12732
- if (!migrations) {
12733
- return {
12734
- service,
12735
- applied: [],
12736
- alreadyApplied: [],
12737
- errors: [`No PG migrations found for service "${service}"`],
12738
- totalMigrations: 0
13401
+ function filterDueTables(service, tables, direction, serviceState, config, nowMs) {
13402
+ const serviceIntervals = config.table_intervals[service] ?? {};
13403
+ return tables.filter((table) => {
13404
+ const tableState = serviceState.tables[table];
13405
+ const lastAt = direction === "push" ? tableState?.last_push_at ?? null : tableState?.last_pull_at ?? null;
13406
+ return isIntervalDue(lastAt, serviceIntervals[table], nowMs);
13407
+ });
13408
+ }
13409
+ function recordTableRuns(serviceState, tables, direction, at) {
13410
+ for (const table of tables) {
13411
+ const current = serviceState.tables[table] ?? {
13412
+ last_push_at: null,
13413
+ last_pull_at: null
12739
13414
  };
13415
+ if (direction === "push") {
13416
+ current.last_push_at = at;
13417
+ } else {
13418
+ current.last_pull_at = at;
13419
+ }
13420
+ serviceState.tables[table] = current;
12740
13421
  }
12741
- return applyPgMigrations(connStr, migrations, service);
12742
13422
  }
12743
- async function migrateAllServices() {
12744
- const { discoverServices: discoverServices3 } = await Promise.resolve().then(() => (init_discover(), exports_discover));
12745
- const services = discoverServices3();
12746
- const results = [];
13423
+ function summarizeStats(stats) {
13424
+ return {
13425
+ rows: stats.reduce((sum, stat) => sum + stat.synced_rows, 0),
13426
+ errors: stats.flatMap((stat) => stat.errors.map((error) => `${stat.table}: ${error}`))
13427
+ };
13428
+ }
13429
+ function runDaemonPass(config, state = createDefaultDaemonState(), options = {}) {
13430
+ const nextState = cloneState(state);
13431
+ const adapterFactory = options.adapterFactory ?? defaultDaemonAdapterFactory;
13432
+ const services = options.services ?? resolveDaemonServices(config);
13433
+ const now = nowIso();
13434
+ const nowMs = Date.parse(now);
13435
+ const summary = {
13436
+ services,
13437
+ pushed_services: 0,
13438
+ pulled_services: 0,
13439
+ pushed_rows: 0,
13440
+ pulled_rows: 0,
13441
+ errors: []
13442
+ };
13443
+ nextState.updated_at = now;
13444
+ nextState.status = config.paused ? "paused" : "running";
13445
+ if (config.paused) {
13446
+ scanConfiguredFiles(config, nextState);
13447
+ return { state: nextState, summary };
13448
+ }
12747
13449
  for (const service of services) {
13450
+ const dbPath = adapterFactory.getLocalDbPath(service);
13451
+ if (!existsSync7(dbPath))
13452
+ continue;
13453
+ const serviceState = getServiceState(nextState, service);
13454
+ let local = null;
13455
+ let remote = null;
12748
13456
  try {
12749
- const result = await migrateService(service);
12750
- results.push(result);
13457
+ const localStats = statSync4(dbPath);
13458
+ local = adapterFactory.openLocal(service);
13459
+ remote = adapterFactory.openRemote(service);
13460
+ const localTables = listSqliteTables(local).filter((table) => !isSyncExcludedTable(table));
13461
+ const duePushTables = filterDueTables(service, localTables, "push", serviceState, config, nowMs);
13462
+ const pushDebounceDue = !serviceState.last_push_at || nowMs - Date.parse(serviceState.last_push_at) >= config.push_debounce_seconds * 1000;
13463
+ const localChanged = localStats.mtimeMs > serviceState.last_local_db_mtime_ms;
13464
+ const shouldPush = duePushTables.length > 0 && (options.forcePush || localChanged && pushDebounceDue);
13465
+ if (shouldPush) {
13466
+ const pushStats = incrementalSyncPush(local, remote, duePushTables, {
13467
+ conflictStrategy: config.conflict_strategy
13468
+ });
13469
+ const pushSummary = summarizeStats(pushStats);
13470
+ serviceState.last_local_db_mtime_ms = localStats.mtimeMs;
13471
+ serviceState.last_push_at = now;
13472
+ serviceState.last_error = pushSummary.errors[0] ?? null;
13473
+ recordTableRuns(serviceState, duePushTables, "push", now);
13474
+ nextState.last_push_at = now;
13475
+ summary.pushed_services++;
13476
+ summary.pushed_rows += pushSummary.rows;
13477
+ summary.errors.push(...pushSummary.errors.map((error) => `[${service}] ${error}`));
13478
+ } else if (serviceState.last_local_db_mtime_ms === 0) {
13479
+ serviceState.last_local_db_mtime_ms = localStats.mtimeMs;
13480
+ }
13481
+ const remoteTables = adapterFactory.listRemoteTables(remote).filter((table) => !isSyncExcludedTable(table));
13482
+ const duePullTables = filterDueTables(service, remoteTables, "pull", serviceState, config, nowMs);
13483
+ const shouldPull = duePullTables.length > 0 && (options.forcePull || isIntervalDue(serviceState.last_pull_at, config.pull_interval_seconds, nowMs));
13484
+ if (shouldPull) {
13485
+ const pullStats = incrementalSyncPull(remote, local, duePullTables, {
13486
+ conflictStrategy: config.conflict_strategy
13487
+ });
13488
+ const pullSummary = summarizeStats(pullStats);
13489
+ serviceState.last_pull_at = now;
13490
+ serviceState.last_error = pullSummary.errors[0] ?? null;
13491
+ recordTableRuns(serviceState, duePullTables, "pull", now);
13492
+ nextState.last_pull_at = now;
13493
+ summary.pulled_services++;
13494
+ summary.pulled_rows += pullSummary.rows;
13495
+ summary.errors.push(...pullSummary.errors.map((error) => `[${service}] ${error}`));
13496
+ }
12751
13497
  } catch (err) {
12752
- results.push({
12753
- service,
12754
- applied: [],
12755
- alreadyApplied: [],
12756
- errors: [err?.message ?? String(err)],
12757
- totalMigrations: 0
12758
- });
13498
+ const message = err?.message ?? String(err);
13499
+ serviceState.last_error = message;
13500
+ nextState.last_error = message;
13501
+ summary.errors.push(`[${service}] ${message}`);
13502
+ } finally {
13503
+ nextState.services[service] = serviceState;
13504
+ try {
13505
+ local?.close();
13506
+ } catch {}
13507
+ try {
13508
+ remote?.close();
13509
+ } catch {}
12759
13510
  }
12760
13511
  }
12761
- return results;
13512
+ if (summary.errors.length > 0) {
13513
+ nextState.last_error = summary.errors[0];
13514
+ }
13515
+ scanConfiguredFiles(config, nextState);
13516
+ return { state: nextState, summary };
12762
13517
  }
12763
- async function ensurePgDatabase(service) {
12764
- const config = (await Promise.resolve().then(() => (init_config(), exports_config))).getCloudConfig();
12765
- const { host, port, username, password_env, ssl } = config.rds;
12766
- if (!host || !username)
12767
- return false;
12768
- const password = process.env[password_env] ?? "";
12769
- const sslParam = ssl ? "?sslmode=require" : "";
12770
- const adminConnStr = `postgres://${username}:${encodeURIComponent(password)}@${host}:${port}/postgres${sslParam}`;
12771
- const pg2 = new PgAdapterAsync(adminConnStr);
13518
+ function getDaemonStatus() {
13519
+ const config = getDaemonConfig();
13520
+ const state = readDaemonState();
13521
+ const running = isProcessRunning(state.pid);
13522
+ const status = running ? config.paused ? "paused" : state.status : "stopped";
13523
+ return {
13524
+ running,
13525
+ pid: running ? state.pid : null,
13526
+ status,
13527
+ config,
13528
+ state: {
13529
+ ...state,
13530
+ status,
13531
+ pid: running ? state.pid : null
13532
+ },
13533
+ services: resolveDaemonServices(config)
13534
+ };
13535
+ }
13536
+ function getDaemonArgs() {
13537
+ const script = process.argv[1];
13538
+ if (!script) {
13539
+ throw new Error("Unable to determine the current CLI entrypoint.");
13540
+ }
13541
+ return [script, "sync", "daemon", "--run"];
13542
+ }
13543
+ function startDaemon(overrides = {}) {
13544
+ const current = getDaemonConfig();
13545
+ const nextConfig = saveDaemonConfig({
13546
+ ...current,
13547
+ ...overrides,
13548
+ enabled: true,
13549
+ paused: false,
13550
+ services: overrides.services ?? current.services,
13551
+ table_intervals: overrides.table_intervals ?? current.table_intervals,
13552
+ file_rules: overrides.file_rules ?? current.file_rules
13553
+ });
13554
+ const currentStatus = getDaemonStatus();
13555
+ if (currentStatus.running) {
13556
+ return currentStatus;
13557
+ }
13558
+ const child = spawn(process.execPath, getDaemonArgs(), {
13559
+ detached: true,
13560
+ stdio: "ignore",
13561
+ env: {
13562
+ ...process.env,
13563
+ HASNA_CLOUD_DAEMON_CHILD: "1"
13564
+ }
13565
+ });
13566
+ child.unref();
13567
+ const state = readDaemonState();
13568
+ const startedAt = nowIso();
13569
+ state.pid = child.pid ?? null;
13570
+ state.status = "running";
13571
+ state.started_at = startedAt;
13572
+ state.updated_at = startedAt;
13573
+ writeDaemonState(state);
13574
+ return {
13575
+ running: true,
13576
+ pid: child.pid ?? null,
13577
+ status: "running",
13578
+ config: nextConfig,
13579
+ state,
13580
+ services: resolveDaemonServices(nextConfig)
13581
+ };
13582
+ }
13583
+ function stopDaemon() {
13584
+ const current = getDaemonConfig();
13585
+ saveDaemonConfig({ ...current, enabled: false });
13586
+ const status = getDaemonStatus();
13587
+ if (status.pid) {
13588
+ try {
13589
+ process.kill(status.pid, "SIGTERM");
13590
+ } catch {}
13591
+ }
13592
+ const state = readDaemonState();
13593
+ state.pid = null;
13594
+ state.status = "stopped";
13595
+ state.updated_at = nowIso();
13596
+ writeDaemonState(state);
13597
+ return getDaemonStatus();
13598
+ }
13599
+ function pauseDaemon() {
13600
+ const current = getDaemonConfig();
13601
+ saveDaemonConfig({ ...current, paused: true });
13602
+ const state = readDaemonState();
13603
+ if (state.pid) {
13604
+ state.status = "paused";
13605
+ state.updated_at = nowIso();
13606
+ writeDaemonState(state);
13607
+ }
13608
+ return getDaemonStatus();
13609
+ }
13610
+ function resumeDaemon() {
13611
+ const current = getDaemonConfig();
13612
+ saveDaemonConfig({ ...current, enabled: true, paused: false });
13613
+ const state = readDaemonState();
13614
+ if (state.pid) {
13615
+ state.status = "running";
13616
+ state.updated_at = nowIso();
13617
+ writeDaemonState(state);
13618
+ }
13619
+ return getDaemonStatus();
13620
+ }
13621
+ function runDaemonOnce(options = {}) {
13622
+ const config = options.config ?? getDaemonConfig();
13623
+ const state = options.state ?? readDaemonState();
13624
+ const result = runDaemonPass(config, state, {
13625
+ adapterFactory: options.adapterFactory,
13626
+ forcePull: options.forcePull,
13627
+ forcePush: options.forcePush,
13628
+ services: options.services
13629
+ });
13630
+ writeDaemonState(result.state);
13631
+ return result;
13632
+ }
13633
+ function runDaemonLoop(options = {}) {
13634
+ let stopping = false;
13635
+ const stop = () => {
13636
+ stopping = true;
13637
+ };
13638
+ process.on("SIGTERM", stop);
13639
+ process.on("SIGINT", stop);
13640
+ let passes = 0;
13641
+ const startedAt = nowIso();
12772
13642
  try {
12773
- const existing = await pg2.all(`SELECT 1 FROM pg_database WHERE datname = $1`, service);
12774
- if (existing.length === 0) {
12775
- await pg2.exec(`CREATE DATABASE "${service}"`);
12776
- return true;
13643
+ while (!stopping) {
13644
+ const config = getDaemonConfig();
13645
+ const state = readDaemonState();
13646
+ if (!config.enabled) {
13647
+ break;
13648
+ }
13649
+ state.pid = process.pid;
13650
+ state.started_at = state.started_at ?? startedAt;
13651
+ state.updated_at = nowIso();
13652
+ writeDaemonState(state);
13653
+ const result = runDaemonPass(config, state, {
13654
+ adapterFactory: options.adapterFactory
13655
+ });
13656
+ result.state.pid = process.pid;
13657
+ result.state.started_at = state.started_at ?? startedAt;
13658
+ result.state.status = config.paused ? "paused" : "running";
13659
+ result.state.updated_at = nowIso();
13660
+ writeDaemonState(result.state);
13661
+ passes++;
13662
+ if (options.max_passes && passes >= options.max_passes) {
13663
+ break;
13664
+ }
13665
+ sleepSync(config.watch_interval_seconds * 1000);
12777
13666
  }
12778
- return false;
12779
13667
  } finally {
12780
- await pg2.close();
13668
+ const finalState = readDaemonState();
13669
+ finalState.pid = null;
13670
+ finalState.status = "stopped";
13671
+ finalState.updated_at = nowIso();
13672
+ writeDaemonState(finalState);
12781
13673
  }
12782
13674
  }
12783
- async function ensureAllPgDatabases() {
12784
- const { discoverServices: discoverServices3 } = await Promise.resolve().then(() => (init_discover(), exports_discover));
12785
- const services = discoverServices3();
12786
- const results = [];
12787
- for (const service of services) {
12788
- try {
12789
- const created = await ensurePgDatabase(service);
12790
- results.push({ service, created });
12791
- } catch (err) {
12792
- results.push({ service, created: false, error: err?.message ?? String(err) });
12793
- }
13675
+ function normalizeConflictStrategy(strategy) {
13676
+ if (!strategy)
13677
+ return "newest-wins";
13678
+ if (strategy !== "newest-wins" && strategy !== "local-wins" && strategy !== "remote-wins") {
13679
+ throw new Error(`Invalid conflict strategy "${strategy}". Use newest-wins, local-wins, or remote-wins.`);
12794
13680
  }
12795
- return results;
13681
+ return strategy;
12796
13682
  }
12797
13683
 
12798
- // src/cli/index.ts
12799
- import { existsSync as existsSync9, statSync as statSync6 } from "fs";
12800
- import { join as join9 } from "path";
12801
- import { homedir as homedir8 } from "os";
12802
- var program2 = new Command;
13684
+ // src/cli/cmd-sync.ts
12803
13685
  function logSync(direction, service, rows, errors2) {
12804
13686
  try {
12805
- const logDir = join9(homedir8(), ".hasna", "cloud");
12806
- const logPath = join9(logDir, "sync.log");
12807
- const { mkdirSync: mkdirSync6, appendFileSync } = __require("fs");
12808
- mkdirSync6(logDir, { recursive: true });
13687
+ const logDir = join8(homedir7(), ".hasna", "cloud");
13688
+ const logPath = join8(logDir, "sync.log");
13689
+ const { mkdirSync: mkdirSync5, appendFileSync } = __require("fs");
13690
+ mkdirSync5(logDir, { recursive: true });
12809
13691
  const ts = new Date().toISOString();
12810
13692
  appendFileSync(logPath, `${ts} ${direction.padEnd(4)} ${service.padEnd(20)} ${rows} rows, ${errors2} errors
12811
13693
  `);
12812
13694
  } catch {}
12813
13695
  }
12814
- program2.name("cloud").description("Shared cloud infrastructure \u2014 database adapter, sync engine, feedback, dotfile migration").version("0.1.8");
12815
- program2.command("setup").description("Configure cloud settings \u2014 interactive wizard or flags").option("--host <host>", "RDS hostname").option("--port <port>", "RDS port", "5432").option("--username <user>", "RDS username").option("--password-env <env>", "Env var for RDS password", "HASNA_RDS_PASSWORD").option("--ssl", "Enable SSL", true).option("--no-ssl", "Disable SSL").option("--mode <mode>", "Mode: local, cloud, or hybrid").option("--schedule <interval>", "Sync schedule (e.g. 30m, 1h)").option("--migrate", "Run PG migrations after setup").option("--pull", "Pull data from cloud after setup").action(async (opts) => {
12816
- const config = getCloudConfig();
12817
- const isAutoDetect = !opts.host && !opts.username;
12818
- if (isAutoDetect) {
12819
- const envHost = process.env.HASNA_RDS_HOST;
12820
- const envUser = process.env.HASNA_RDS_USERNAME;
12821
- if (envHost && !config.rds.host) {
12822
- config.rds.host = envHost;
12823
- console.log(`Auto-detected RDS host: ${envHost}`);
12824
- }
12825
- if (envUser && !config.rds.username) {
12826
- config.rds.username = envUser;
12827
- console.log(`Auto-detected RDS username: ${envUser}`);
12828
- }
12829
- }
12830
- if (opts.host)
12831
- config.rds.host = opts.host;
12832
- if (opts.port)
12833
- config.rds.port = parseInt(opts.port, 10);
12834
- if (opts.username)
12835
- config.rds.username = opts.username;
12836
- if (opts.passwordEnv)
12837
- config.rds.password_env = opts.passwordEnv;
12838
- config.rds.ssl = opts.ssl;
12839
- if (opts.mode) {
12840
- config.mode = opts.mode;
12841
- } else if (config.mode === "local" && config.rds.host) {
12842
- config.mode = "hybrid";
12843
- console.log("Mode set to: hybrid (auto-upgraded from local)");
13696
+ function registerSyncCommands(syncCmd) {
13697
+ registerPushCommand(syncCmd);
13698
+ registerPullCommand(syncCmd);
13699
+ registerStatusCommand(syncCmd);
13700
+ registerConflictsCommand(syncCmd);
13701
+ registerResolveCommand(syncCmd);
13702
+ registerScheduleCommand(syncCmd);
13703
+ registerDaemonCommand(syncCmd);
13704
+ }
13705
+ function collectValues(value, previous = []) {
13706
+ previous.push(value);
13707
+ return previous;
13708
+ }
13709
+ function parsePositiveSeconds(raw, label) {
13710
+ const value = parseInt(raw, 10);
13711
+ if (!Number.isFinite(value) || value <= 0) {
13712
+ throw new Error(`${label} must be a positive integer.`);
12844
13713
  }
12845
- saveCloudConfig(config);
12846
- console.log(`
12847
- \u2713 Configuration saved
12848
- `);
12849
- const password = process.env[config.rds.password_env];
12850
- if (!password) {
12851
- console.error(`\u2717 ${config.rds.password_env} not set in environment`);
12852
- console.error(` Add it to ~/.secrets/hasna/rds/live.env and source it`);
12853
- return;
13714
+ return value;
13715
+ }
13716
+ function countTableOverrides(tableIntervals) {
13717
+ return Object.values(tableIntervals).reduce((total, serviceRules) => total + Object.keys(serviceRules).length, 0);
13718
+ }
13719
+ function printDaemonStatus() {
13720
+ const status = getDaemonStatus();
13721
+ const fileRuleCount = status.config.file_rules.filter((rule) => rule.enabled).length;
13722
+ console.log(`Daemon: ${status.status}`);
13723
+ console.log(` Running: ${status.running ? "yes" : "no"}`);
13724
+ console.log(` PID: ${status.pid ?? "—"}`);
13725
+ console.log(` Services: ${status.services.length > 0 ? status.services.join(", ") : "(auto-discover)"}`);
13726
+ console.log(` Watch interval: ${status.config.watch_interval_seconds}s`);
13727
+ console.log(` Pull interval: ${status.config.pull_interval_seconds}s`);
13728
+ console.log(` Push debounce: ${status.config.push_debounce_seconds}s`);
13729
+ console.log(` Conflict strategy: ${status.config.conflict_strategy}`);
13730
+ console.log(` Table overrides: ${countTableOverrides(status.config.table_intervals)}`);
13731
+ console.log(` File rules: ${fileRuleCount}`);
13732
+ if (status.state.started_at) {
13733
+ console.log(` Started: ${status.state.started_at}`);
13734
+ }
13735
+ if (status.state.last_push_at) {
13736
+ console.log(` Last push: ${status.state.last_push_at}`);
13737
+ }
13738
+ if (status.state.last_pull_at) {
13739
+ console.log(` Last pull: ${status.state.last_pull_at}`);
13740
+ }
13741
+ if (status.state.last_error) {
13742
+ console.log(` Last error: ${status.state.last_error}`);
12854
13743
  }
12855
- if (config.rds.host) {
12856
- process.stdout.write("Testing PG connection... ");
12857
- try {
12858
- const connStr = getConnectionString("postgres");
12859
- const pg2 = new PgAdapterAsync2(connStr);
12860
- await pg2.all("SELECT 1");
12861
- await pg2.close();
12862
- console.log(`\u2713 Connected
12863
- `);
12864
- } catch (err) {
12865
- console.log(`\u2717 Failed: ${err?.message ?? String(err)}`);
12866
- return;
13744
+ }
13745
+ function registerPushCommand(syncCmd) {
13746
+ syncCmd.command("push").description("Push local data to cloud").option("--service <name>", "Service name").option("--all", "Push all discovered services").option("--tables <tables>", "Comma-separated table names (default: all)").option("--dry-run", "Preview what would be synced without executing").action(async (opts) => {
13747
+ const config = getCloudConfig();
13748
+ if (config.mode === "local") {
13749
+ console.error("Error: mode is 'local'. Run `cloud setup --mode hybrid` or `--mode cloud` first.");
13750
+ process.exit(1);
13751
+ }
13752
+ if (!opts.service && !opts.all) {
13753
+ console.error("Error: specify --service <name> or --all");
13754
+ process.exit(1);
12867
13755
  }
12868
- if (opts.migrate !== false) {
12869
- console.log("Creating databases & running migrations...");
12870
- const dbResults = await ensureAllPgDatabases();
12871
- const created = dbResults.filter((r) => r.created);
12872
- if (created.length > 0) {
12873
- console.log(` Created ${created.length} database(s): ${created.map((r) => r.service).join(", ")}`);
12874
- }
12875
- const migResults = await migrateAllServices();
12876
- const applied = migResults.filter((r) => r.applied.length > 0);
12877
- const totalApplied = migResults.reduce((s, r) => s + r.applied.length, 0);
12878
- if (totalApplied > 0) {
12879
- console.log(` Applied ${totalApplied} migration(s) across ${applied.length} service(s)`);
13756
+ const services = opts.all ? discoverServices() : [opts.service];
13757
+ let grandTotalWritten = 0;
13758
+ let grandTotalErrors = 0;
13759
+ for (const service of services) {
13760
+ const dbPath = getDbPath(service);
13761
+ let local;
13762
+ try {
13763
+ local = new SqliteAdapter(dbPath);
13764
+ } catch {
13765
+ if (opts.all)
13766
+ continue;
13767
+ console.error(`No local database found for service "${service}"`);
13768
+ process.exit(1);
13769
+ return;
13770
+ }
13771
+ let tables;
13772
+ if (opts.tables) {
13773
+ tables = opts.tables.split(",").map((t) => t.trim());
12880
13774
  } else {
12881
- console.log(" All migrations up to date");
13775
+ tables = listSqliteTables(local).filter((t) => !isSyncExcludedTable(t));
12882
13776
  }
12883
- console.log("");
12884
- }
12885
- if (opts.schedule) {
13777
+ if (tables.length === 0) {
13778
+ local.close();
13779
+ continue;
13780
+ }
13781
+ if (opts.dryRun) {
13782
+ const rowCounts = tables.map((t) => {
13783
+ try {
13784
+ const r = local.get(`SELECT COUNT(*) as cnt FROM "${t}"`);
13785
+ return `${t}: ${r?.cnt ?? 0} rows`;
13786
+ } catch {
13787
+ return `${t}: ?`;
13788
+ }
13789
+ });
13790
+ console.log(`[${service}] Would push ${tables.length} table(s): ${rowCounts.join(", ")}`);
13791
+ local.close();
13792
+ continue;
13793
+ }
13794
+ console.log(`[${service}] Pushing ${tables.length} table(s) to cloud...`);
13795
+ let connStr;
12886
13796
  try {
12887
- const minutes = parseInterval(opts.schedule);
12888
- await registerSyncSchedule(minutes);
12889
- console.log(`\u2713 Sync scheduled every ${minutes}m
12890
- `);
13797
+ connStr = getConnectionString(service);
12891
13798
  } catch (err) {
12892
- console.error(`\u2717 Schedule failed: ${err?.message}`);
13799
+ console.error(` [${service}] ${err?.message ?? String(err)}`);
13800
+ local.close();
13801
+ grandTotalErrors++;
13802
+ continue;
12893
13803
  }
12894
- }
12895
- if (opts.pull) {
12896
- console.log("Pulling data from cloud...");
12897
- const services = discoverServices();
12898
- for (const service of services) {
12899
- try {
12900
- const dbPath = getDbPath2(service);
12901
- const local = new SqliteAdapter2(dbPath);
12902
- const connStr = getConnectionString(service);
12903
- const cloud = new PgAdapterAsync2(connStr);
12904
- const tables = (await listPgTables(cloud)).filter((t) => !isSyncExcludedTable(t));
12905
- if (tables.length > 0) {
12906
- const results = await syncPull(cloud, local, { tables });
12907
- const written = results.reduce((s, r) => s + r.rowsWritten, 0);
12908
- if (written > 0)
12909
- console.log(` ${service}: ${written} rows`);
13804
+ const cloud = new PgAdapterAsync(connStr);
13805
+ const results = await syncPush(local, cloud, {
13806
+ tables,
13807
+ onProgress: (p) => {
13808
+ if (p.phase === "done" && !opts.all) {
13809
+ console.log(` [${p.currentTableIndex + 1}/${p.totalTables}] ${p.table}: ${p.rowsWritten} rows synced`);
12910
13810
  }
12911
- local.close();
12912
- await cloud.close();
12913
- } catch {}
13811
+ }
13812
+ });
13813
+ local.close();
13814
+ await cloud.close();
13815
+ const totalWritten = results.reduce((s, r) => s + r.rowsWritten, 0);
13816
+ const totalErrors = results.reduce((s, r) => s + r.errors.length, 0);
13817
+ grandTotalWritten += totalWritten;
13818
+ grandTotalErrors += totalErrors;
13819
+ logSync("push", service, totalWritten, totalErrors);
13820
+ if (opts.all) {
13821
+ console.log(` ${service}: ${totalWritten} rows pushed${totalErrors > 0 ? `, ${totalErrors} errors` : ""}`);
13822
+ } else {
13823
+ console.log(`
13824
+ Done. ${totalWritten} rows pushed, ${totalErrors} errors.`);
13825
+ if (totalErrors > 0) {
13826
+ for (const r of results) {
13827
+ for (const e of r.errors) {
13828
+ console.error(` ${r.table}: ${e}`);
13829
+ }
13830
+ }
13831
+ }
12914
13832
  }
12915
- console.log("");
12916
13833
  }
12917
- }
12918
- console.log("Setup complete. Run `cloud doctor` to verify everything.");
12919
- });
12920
- program2.command("status").description("Show current cloud configuration and connection health").action(async () => {
12921
- const config = getCloudConfig();
12922
- console.log("Mode:", config.mode);
12923
- console.log("RDS Host:", config.rds.host || "(not configured)");
12924
- console.log("RDS Port:", config.rds.port);
12925
- console.log("RDS Username:", config.rds.username || "(not configured)");
12926
- console.log("SSL:", config.rds.ssl);
12927
- console.log("Auto-sync interval:", config.auto_sync_interval_minutes ? `${config.auto_sync_interval_minutes} minutes` : "disabled");
12928
- if (config.rds.host && config.rds.username) {
12929
- console.log(`
12930
- Checking PostgreSQL connection...`);
12931
- try {
12932
- const connStr = getConnectionString("postgres");
12933
- const pg2 = new PgAdapterAsync2(connStr);
12934
- const row = await pg2.get("SELECT 1 as ok");
12935
- if (row?.ok === 1) {
12936
- console.log("PostgreSQL: connected");
12937
- }
12938
- await pg2.close();
12939
- } catch (err) {
12940
- console.log("PostgreSQL: connection failed \u2014", err?.message);
13834
+ if (opts.all) {
13835
+ console.log(`
13836
+ Done. ${services.length} services, ${grandTotalWritten} rows pushed, ${grandTotalErrors} errors.`);
12941
13837
  }
12942
- }
12943
- });
12944
- var syncCmd = program2.command("sync").description("Sync data between local and cloud");
12945
- syncCmd.command("push").description("Push local data to cloud").option("--service <name>", "Service name").option("--all", "Push all discovered services").option("--tables <tables>", "Comma-separated table names (default: all)").option("--dry-run", "Preview what would be synced without executing").action(async (opts) => {
12946
- const config = getCloudConfig();
12947
- if (config.mode === "local") {
12948
- console.error("Error: mode is 'local'. Run `cloud setup --mode hybrid` or `--mode cloud` first.");
12949
- process.exit(1);
12950
- }
12951
- if (!opts.service && !opts.all) {
12952
- console.error("Error: specify --service <name> or --all");
12953
- process.exit(1);
12954
- }
12955
- const services = opts.all ? discoverServices() : [opts.service];
12956
- let grandTotalWritten = 0;
12957
- let grandTotalErrors = 0;
12958
- for (const service of services) {
12959
- const dbPath = getDbPath2(service);
12960
- let local;
12961
- try {
12962
- local = new SqliteAdapter2(dbPath);
12963
- } catch {
12964
- if (opts.all)
12965
- continue;
12966
- console.error(`No local database found for service "${service}"`);
13838
+ });
13839
+ }
13840
+ function registerPullCommand(syncCmd) {
13841
+ syncCmd.command("pull").description("Pull cloud data to local").option("--service <name>", "Service name").option("--all", "Pull all discovered services").option("--tables <tables>", "Comma-separated table names (default: all)").option("--dry-run", "Preview what would be synced without executing").action(async (opts) => {
13842
+ const config = getCloudConfig();
13843
+ if (config.mode === "local") {
13844
+ console.error("Error: mode is 'local'. Run `cloud setup --mode hybrid` or `--mode cloud` first.");
12967
13845
  process.exit(1);
12968
- return;
12969
- }
12970
- let tables;
12971
- if (opts.tables) {
12972
- tables = opts.tables.split(",").map((t) => t.trim());
12973
- } else {
12974
- tables = listSqliteTables(local).filter((t) => !isSyncExcludedTable(t));
12975
13846
  }
12976
- if (tables.length === 0) {
12977
- local.close();
12978
- continue;
13847
+ if (!opts.service && !opts.all) {
13848
+ console.error("Error: specify --service <name> or --all");
13849
+ process.exit(1);
12979
13850
  }
12980
- if (opts.dryRun) {
12981
- const rowCounts = tables.map((t) => {
13851
+ const services = opts.all ? discoverServices() : [opts.service];
13852
+ let grandTotalWritten = 0;
13853
+ let grandTotalErrors = 0;
13854
+ for (const service of services) {
13855
+ const dbPath = getDbPath(service);
13856
+ let local;
13857
+ try {
13858
+ local = new SqliteAdapter(dbPath);
13859
+ } catch {
13860
+ if (opts.all)
13861
+ continue;
13862
+ console.error(`No local database found for service "${service}"`);
13863
+ process.exit(1);
13864
+ return;
13865
+ }
13866
+ let connStr;
13867
+ try {
13868
+ connStr = getConnectionString(service);
13869
+ } catch (err) {
13870
+ console.error(` [${service}] ${err?.message ?? String(err)}`);
13871
+ local.close();
13872
+ grandTotalErrors++;
13873
+ continue;
13874
+ }
13875
+ const cloud = new PgAdapterAsync(connStr);
13876
+ let tables;
13877
+ if (opts.tables) {
13878
+ tables = opts.tables.split(",").map((t) => t.trim());
13879
+ } else {
12982
13880
  try {
12983
- const r = local.get(`SELECT COUNT(*) as cnt FROM "${t}"`);
12984
- return `${t}: ${r?.cnt ?? 0} rows`;
13881
+ tables = (await listPgTables(cloud)).filter((t) => !isSyncExcludedTable(t));
12985
13882
  } catch {
12986
- return `${t}: ?`;
13883
+ if (!opts.all)
13884
+ console.error(`Failed to list tables from cloud for "${service}".`);
13885
+ local.close();
13886
+ await cloud.close();
13887
+ if (!opts.all) {
13888
+ process.exit(1);
13889
+ return;
13890
+ }
13891
+ grandTotalErrors++;
13892
+ continue;
13893
+ }
13894
+ }
13895
+ if (tables.length === 0) {
13896
+ local.close();
13897
+ await cloud.close();
13898
+ continue;
13899
+ }
13900
+ if (opts.dryRun) {
13901
+ console.log(`[${service}] Would pull ${tables.length} table(s): ${tables.join(", ")}`);
13902
+ local.close();
13903
+ await cloud.close();
13904
+ continue;
13905
+ }
13906
+ if (!opts.all)
13907
+ console.log(`Pulling ${tables.length} table(s) from cloud...`);
13908
+ const results = await syncPull(cloud, local, {
13909
+ tables,
13910
+ onProgress: (p) => {
13911
+ if (p.phase === "done" && !opts.all) {
13912
+ console.log(` [${p.currentTableIndex + 1}/${p.totalTables}] ${p.table}: ${p.rowsWritten} rows synced`);
13913
+ }
12987
13914
  }
12988
13915
  });
12989
- console.log(`[${service}] Would push ${tables.length} table(s): ${rowCounts.join(", ")}`);
12990
13916
  local.close();
12991
- continue;
12992
- }
12993
- console.log(`[${service}] Pushing ${tables.length} table(s) to cloud...`);
12994
- let connStr;
12995
- try {
12996
- connStr = getConnectionString(service);
12997
- } catch (err) {
12998
- console.error(` [${service}] ${err?.message ?? String(err)}`);
12999
- local.close();
13000
- grandTotalErrors++;
13001
- continue;
13002
- }
13003
- const cloud = new PgAdapterAsync2(connStr);
13004
- const results = await syncPush(local, cloud, {
13005
- tables,
13006
- onProgress: (p) => {
13007
- if (p.phase === "done" && !opts.all) {
13008
- console.log(` [${p.currentTableIndex + 1}/${p.totalTables}] ${p.table}: ${p.rowsWritten} rows synced`);
13917
+ await cloud.close();
13918
+ const totalWritten = results.reduce((s, r) => s + r.rowsWritten, 0);
13919
+ const totalErrors = results.reduce((s, r) => s + r.errors.length, 0);
13920
+ grandTotalWritten += totalWritten;
13921
+ grandTotalErrors += totalErrors;
13922
+ logSync("pull", service, totalWritten, totalErrors);
13923
+ if (opts.all) {
13924
+ if (totalWritten > 0 || totalErrors > 0) {
13925
+ console.log(` ${service}: ${totalWritten} rows pulled${totalErrors > 0 ? `, ${totalErrors} errors` : ""}`);
13926
+ }
13927
+ } else {
13928
+ console.log(`
13929
+ Done. ${totalWritten} rows pulled, ${totalErrors} errors.`);
13930
+ if (totalErrors > 0) {
13931
+ for (const r of results) {
13932
+ for (const e of r.errors) {
13933
+ console.error(` ${r.table}: ${e}`);
13934
+ }
13935
+ }
13009
13936
  }
13010
13937
  }
13011
- });
13012
- local.close();
13013
- await cloud.close();
13014
- const totalWritten = results.reduce((s, r) => s + r.rowsWritten, 0);
13015
- const totalErrors = results.reduce((s, r) => s + r.errors.length, 0);
13016
- grandTotalWritten += totalWritten;
13017
- grandTotalErrors += totalErrors;
13018
- logSync("push", service, totalWritten, totalErrors);
13938
+ }
13019
13939
  if (opts.all) {
13020
- console.log(` ${service}: ${totalWritten} rows pushed${totalErrors > 0 ? `, ${totalErrors} errors` : ""}`);
13021
- } else {
13022
13940
  console.log(`
13023
- Done. ${totalWritten} rows pushed, ${totalErrors} errors.`);
13024
- if (totalErrors > 0) {
13025
- for (const r of results) {
13026
- for (const e of r.errors) {
13027
- console.error(` ${r.table}: ${e}`);
13028
- }
13029
- }
13941
+ Done. ${services.length} services, ${grandTotalWritten} rows pulled, ${grandTotalErrors} errors.`);
13942
+ }
13943
+ });
13944
+ }
13945
+ function registerStatusCommand(syncCmd) {
13946
+ syncCmd.command("status").description("Show sync status for all discovered services").option("--service <name>", "Show status for a single service").option("--json", "Output as JSON").action(async (opts) => {
13947
+ const services = opts.service ? [opts.service] : discoverServices();
13948
+ const statuses = [];
13949
+ for (const service of services) {
13950
+ const dbPath = getDbPath(service);
13951
+ const localExists = existsSync8(dbPath);
13952
+ let localSize = "—";
13953
+ let tableCount = 0;
13954
+ if (localExists) {
13955
+ try {
13956
+ const stat = statSync5(dbPath);
13957
+ localSize = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(0)}KB`;
13958
+ } catch {}
13959
+ try {
13960
+ const local = new SqliteAdapter(dbPath);
13961
+ const tables = listSqliteTables(local);
13962
+ tableCount = tables.length;
13963
+ local.close();
13964
+ } catch {}
13030
13965
  }
13966
+ let pgReachable = false;
13967
+ try {
13968
+ const connStr = getConnectionString(service);
13969
+ const pg2 = new PgAdapterAsync(connStr);
13970
+ await pg2.all("SELECT 1");
13971
+ pgReachable = true;
13972
+ await pg2.close();
13973
+ } catch {}
13974
+ statuses.push({ service, localDb: localExists ? dbPath : null, localSize, tables: tableCount, pgReachable });
13031
13975
  }
13032
- }
13033
- if (opts.all) {
13034
- console.log(`
13035
- Done. ${services.length} services, ${grandTotalWritten} rows pushed, ${grandTotalErrors} errors.`);
13036
- }
13037
- });
13038
- syncCmd.command("pull").description("Pull cloud data to local").option("--service <name>", "Service name").option("--all", "Pull all discovered services").option("--tables <tables>", "Comma-separated table names (default: all)").option("--dry-run", "Preview what would be synced without executing").action(async (opts) => {
13039
- const config = getCloudConfig();
13040
- if (config.mode === "local") {
13041
- console.error("Error: mode is 'local'. Run `cloud setup --mode hybrid` or `--mode cloud` first.");
13042
- process.exit(1);
13043
- }
13044
- if (!opts.service && !opts.all) {
13045
- console.error("Error: specify --service <name> or --all");
13046
- process.exit(1);
13047
- }
13048
- const services = opts.all ? discoverServices() : [opts.service];
13049
- let grandTotalWritten = 0;
13050
- let grandTotalErrors = 0;
13051
- for (const service of services) {
13052
- const dbPath = getDbPath2(service);
13053
- let local;
13054
- try {
13055
- local = new SqliteAdapter2(dbPath);
13056
- } catch {
13057
- if (opts.all)
13058
- continue;
13059
- console.error(`No local database found for service "${service}"`);
13060
- process.exit(1);
13976
+ if (opts.json) {
13977
+ console.log(JSON.stringify(statuses, null, 2));
13061
13978
  return;
13062
13979
  }
13063
- let connStr;
13064
- try {
13065
- connStr = getConnectionString(service);
13066
- } catch (err) {
13067
- console.error(` [${service}] ${err?.message ?? String(err)}`);
13068
- local.close();
13069
- grandTotalErrors++;
13070
- continue;
13980
+ const config = getCloudConfig();
13981
+ console.log(`Mode: ${config.mode}`);
13982
+ console.log(`Services: ${statuses.length}
13983
+ `);
13984
+ for (const s of statuses) {
13985
+ if (!s.localDb && !s.pgReachable)
13986
+ continue;
13987
+ const pgIcon = s.pgReachable ? "✓" : "✗";
13988
+ console.log(` ${s.service.padEnd(20)} ${s.localSize.padStart(8)} ${String(s.tables).padStart(3)} tables PG: ${pgIcon}`);
13071
13989
  }
13072
- const cloud = new PgAdapterAsync2(connStr);
13073
- let tables;
13074
- if (opts.tables) {
13075
- tables = opts.tables.split(",").map((t) => t.trim());
13076
- } else {
13990
+ const withData = statuses.filter((s) => s.localDb);
13991
+ const pgOk = statuses.filter((s) => s.pgReachable);
13992
+ let totalConflicts = 0;
13993
+ for (const s of statuses) {
13994
+ if (!s.localDb)
13995
+ continue;
13077
13996
  try {
13078
- tables = (await listPgTables(cloud)).filter((t) => !isSyncExcludedTable(t));
13079
- } catch {
13080
- if (!opts.all)
13081
- console.error(`Failed to list tables from cloud for "${service}".`);
13997
+ const local = new SqliteAdapter(getDbPath(s.service));
13998
+ const pending = listConflicts(local, { resolved: false });
13082
13999
  local.close();
13083
- await cloud.close();
13084
- if (!opts.all) {
13085
- process.exit(1);
13086
- return;
14000
+ if (pending.length > 0) {
14001
+ console.log(` ⚠ ${s.service}: ${pending.length} unresolved conflict(s) — run \`cloud sync conflicts --service ${s.service}\``);
14002
+ totalConflicts += pending.length;
13087
14003
  }
13088
- grandTotalErrors++;
14004
+ } catch {}
14005
+ }
14006
+ console.log(`
14007
+ ${withData.length} with local data, ${pgOk.length} with PG connection${totalConflicts > 0 ? `, ${totalConflicts} unresolved conflict(s)` : ""}`);
14008
+ });
14009
+ }
14010
+ function registerConflictsCommand(syncCmd) {
14011
+ syncCmd.command("conflicts").description("List stored sync conflicts").option("--service <name>", "Service name (default: all discovered services)").option("--table <name>", "Filter by table name").option("--resolved", "Show resolved conflicts instead of unresolved").option("--json", "Output as JSON").action((opts) => {
14012
+ const services = opts.service ? [opts.service] : discoverServices();
14013
+ const allConflicts = [];
14014
+ for (const service of services) {
14015
+ let local;
14016
+ try {
14017
+ local = new SqliteAdapter(getDbPath(service));
14018
+ } catch {
13089
14019
  continue;
13090
14020
  }
13091
- }
13092
- if (tables.length === 0) {
14021
+ const conflicts = listConflicts(local, {
14022
+ resolved: opts.resolved ? true : false,
14023
+ table: opts.table
14024
+ });
13093
14025
  local.close();
13094
- await cloud.close();
13095
- continue;
14026
+ for (const c of conflicts) {
14027
+ allConflicts.push({ service, ...c });
14028
+ }
13096
14029
  }
13097
- if (opts.dryRun) {
13098
- console.log(`[${service}] Would pull ${tables.length} table(s): ${tables.join(", ")}`);
13099
- local.close();
13100
- await cloud.close();
13101
- continue;
14030
+ if (opts.json) {
14031
+ console.log(JSON.stringify(allConflicts, null, 2));
14032
+ return;
13102
14033
  }
13103
- if (!opts.all)
13104
- console.log(`Pulling ${tables.length} table(s) from cloud...`);
13105
- const results = await syncPull(cloud, local, {
13106
- tables,
13107
- onProgress: (p) => {
13108
- if (p.phase === "done" && !opts.all) {
13109
- console.log(` [${p.currentTableIndex + 1}/${p.totalTables}] ${p.table}: ${p.rowsWritten} rows synced`);
13110
- }
13111
- }
13112
- });
13113
- local.close();
13114
- await cloud.close();
13115
- const totalWritten = results.reduce((s, r) => s + r.rowsWritten, 0);
13116
- const totalErrors = results.reduce((s, r) => s + r.errors.length, 0);
13117
- grandTotalWritten += totalWritten;
13118
- grandTotalErrors += totalErrors;
13119
- logSync("pull", service, totalWritten, totalErrors);
13120
- if (opts.all) {
13121
- if (totalWritten > 0 || totalErrors > 0) {
13122
- console.log(` ${service}: ${totalWritten} rows pulled${totalErrors > 0 ? `, ${totalErrors} errors` : ""}`);
13123
- }
13124
- } else {
14034
+ if (allConflicts.length === 0) {
14035
+ console.log(opts.resolved ? "No resolved conflicts found." : "No unresolved conflicts.");
14036
+ return;
14037
+ }
14038
+ const label = opts.resolved ? "resolved" : "unresolved";
14039
+ console.log(`${allConflicts.length} ${label} conflict(s):
14040
+ `);
14041
+ for (const c of allConflicts) {
14042
+ const resolution = c.resolution ? ` [${c.resolution}]` : "";
14043
+ console.log(` ${c.service}/${c.table_name} row=${c.row_id}${resolution}`);
14044
+ console.log(` local: ${c.local_updated_at ?? "—"}`);
14045
+ console.log(` remote: ${c.remote_updated_at ?? "—"}`);
14046
+ console.log(` id: ${c.id}`);
14047
+ }
14048
+ if (!opts.resolved) {
13125
14049
  console.log(`
13126
- Done. ${totalWritten} rows pulled, ${totalErrors} errors.`);
13127
- if (totalErrors > 0) {
13128
- for (const r of results) {
13129
- for (const e of r.errors) {
13130
- console.error(` ${r.table}: ${e}`);
14050
+ Resolve with: cloud sync resolve --service <name> --strategy <local-wins|remote-wins|newest-wins>`);
14051
+ }
14052
+ });
14053
+ }
14054
+ function registerResolveCommand(syncCmd) {
14055
+ syncCmd.command("resolve").description("Resolve stored sync conflicts using a strategy").option("--service <name>", "Service name (default: all discovered services)").option("--id <id>", "Resolve a specific conflict by ID").option("--table <name>", "Resolve all conflicts for a specific table").option("--all", "Resolve all unresolved conflicts").requiredOption("--strategy <strategy>", "Resolution strategy: local-wins, remote-wins, newest-wins").option("--purge", "Delete resolved conflicts after resolving").option("--dry-run", "Show what would be resolved without applying").action((opts) => {
14056
+ const validStrategies = ["local-wins", "remote-wins", "newest-wins"];
14057
+ if (!validStrategies.includes(opts.strategy)) {
14058
+ console.error(`Invalid strategy "${opts.strategy}". Use: local-wins, remote-wins, newest-wins`);
14059
+ process.exit(1);
14060
+ }
14061
+ if (!opts.id && !opts.table && !opts.all) {
14062
+ console.error("Specify --id <id>, --table <name>, or --all");
14063
+ process.exit(1);
14064
+ }
14065
+ const services = opts.service ? [opts.service] : discoverServices();
14066
+ let totalResolved = 0;
14067
+ for (const service of services) {
14068
+ let local;
14069
+ try {
14070
+ local = new SqliteAdapter(getDbPath(service));
14071
+ } catch {
14072
+ continue;
14073
+ }
14074
+ if (opts.id) {
14075
+ if (opts.dryRun) {
14076
+ console.log(`[dry-run] Would resolve conflict ${opts.id} using ${opts.strategy}`);
14077
+ local.close();
14078
+ continue;
14079
+ }
14080
+ const updated = resolveConflict(local, opts.id, opts.strategy);
14081
+ if (updated) {
14082
+ console.log(`Resolved: ${updated.id} → ${opts.strategy}`);
14083
+ totalResolved++;
14084
+ } else {
14085
+ console.log(`Conflict not found: ${opts.id}`);
14086
+ }
14087
+ } else {
14088
+ const pending = listConflicts(local, { resolved: false, table: opts.table });
14089
+ if (pending.length === 0) {
14090
+ local.close();
14091
+ continue;
14092
+ }
14093
+ if (opts.dryRun) {
14094
+ console.log(`[dry-run] [${service}] Would resolve ${pending.length} conflict(s) using ${opts.strategy}`);
14095
+ for (const c of pending) {
14096
+ console.log(` ${c.table_name} row=${c.row_id} (id: ${c.id})`);
13131
14097
  }
14098
+ local.close();
14099
+ continue;
14100
+ }
14101
+ let serviceResolved = 0;
14102
+ for (const c of pending) {
14103
+ if (resolveConflict(local, c.id, opts.strategy))
14104
+ serviceResolved++;
13132
14105
  }
14106
+ if (serviceResolved > 0) {
14107
+ console.log(`[${service}] Resolved ${serviceResolved} conflict(s) → ${opts.strategy}`);
14108
+ totalResolved += serviceResolved;
14109
+ }
14110
+ }
14111
+ if (opts.purge && !opts.dryRun) {
14112
+ const purged = purgeResolvedConflicts(local);
14113
+ if (purged > 0)
14114
+ console.log(`[${service}] Purged ${purged} resolved conflict(s)`);
13133
14115
  }
14116
+ local.close();
13134
14117
  }
13135
- }
13136
- if (opts.all) {
13137
- console.log(`
13138
- Done. ${services.length} services, ${grandTotalWritten} rows pulled, ${grandTotalErrors} errors.`);
13139
- }
13140
- });
13141
- program2.command("migrate-pg").description("Apply PG migrations for services").option("--service <name>", "Service name").option("--all", "Migrate all discovered services").option("--create-db", "Create PG databases if they don't exist (default: true)", true).action(async (opts) => {
13142
- if (!opts.service && !opts.all) {
13143
- console.error("Error: specify --service <name> or --all");
13144
- process.exit(1);
13145
- }
13146
- if (opts.all) {
13147
- if (opts.createDb) {
13148
- console.log("Ensuring PG databases exist...");
13149
- const dbResults = await ensureAllPgDatabases();
13150
- for (const r of dbResults) {
13151
- if (r.created)
13152
- console.log(` Created database: ${r.service}`);
13153
- if (r.error)
13154
- console.error(` ${r.service}: ${r.error}`);
14118
+ if (!opts.dryRun) {
14119
+ console.log(`
14120
+ Done. ${totalResolved} conflict(s) resolved.`);
14121
+ }
14122
+ });
14123
+ }
14124
+ function registerScheduleCommand(syncCmd) {
14125
+ syncCmd.command("schedule").description("Manage scheduled background sync").option("--every <interval>", "Set sync interval (e.g. 5m, 10m, 1h)").option("--off", "Disable scheduled sync").option("--now", "Run a one-off sync immediately").action(async (opts) => {
14126
+ if (opts.off) {
14127
+ try {
14128
+ await removeSyncSchedule();
14129
+ console.log("Scheduled sync disabled.");
14130
+ } catch (err) {
14131
+ console.error("Failed to remove schedule:", err?.message);
14132
+ process.exit(1);
13155
14133
  }
14134
+ return;
13156
14135
  }
13157
- console.log(`
13158
- Running PG migrations...`);
13159
- const results = await migrateAllServices();
13160
- let totalApplied = 0;
13161
- let totalErrors = 0;
13162
- for (const r of results) {
13163
- totalApplied += r.applied.length;
13164
- totalErrors += r.errors.length;
13165
- if (r.applied.length > 0 || r.errors.length > 0) {
13166
- console.log(` ${r.service}: ${r.applied.length} applied, ${r.alreadyApplied.length} existing${r.errors.length > 0 ? `, ${r.errors.length} errors` : ""}`);
14136
+ if (opts.now) {
14137
+ const config = getCloudConfig();
14138
+ if (config.mode === "local") {
14139
+ console.error("Error: mode is 'local'. Run `cloud setup --mode hybrid` or `--mode cloud` first.");
14140
+ process.exit(1);
14141
+ }
14142
+ console.log("Running sync now...");
14143
+ const services2 = discoverSyncableServices2();
14144
+ console.log(`Discovered ${services2.length} service(s): ${services2.join(", ") || "(none)"}`);
14145
+ const results = await runScheduledSync();
14146
+ for (const r of results) {
14147
+ const status2 = r.errors.length === 0 ? "ok" : "errors";
14148
+ console.log(` ${r.service}: ${r.tables_synced} table(s), ${r.total_rows_synced} row(s) [${status2}]`);
13167
14149
  for (const e of r.errors) {
13168
14150
  console.error(` ${e}`);
13169
14151
  }
13170
14152
  }
14153
+ if (results.length === 0) {
14154
+ console.log("No services synced (mode may be local or no databases found).");
14155
+ } else {
14156
+ const totalRows = results.reduce((s, r) => s + r.total_rows_synced, 0);
14157
+ const totalErrors = results.reduce((s, r) => s + r.errors.length, 0);
14158
+ console.log(`
14159
+ Done. ${totalRows} rows synced, ${totalErrors} errors.`);
14160
+ }
14161
+ return;
13171
14162
  }
13172
- console.log(`
13173
- Done. ${results.length} services, ${totalApplied} migrations applied, ${totalErrors} errors.`);
13174
- } else {
13175
- if (opts.createDb) {
14163
+ if (opts.every) {
13176
14164
  try {
13177
- const created = await ensurePgDatabase(opts.service);
13178
- if (created)
13179
- console.log(`Created database: ${opts.service}`);
14165
+ const minutes = parseInterval(opts.every);
14166
+ await registerSyncSchedule(minutes);
14167
+ console.log(`Scheduled sync registered: every ${minutes} minute(s).`);
13180
14168
  } catch (err) {
13181
- console.error(`Failed to create database: ${err?.message ?? String(err)}`);
14169
+ console.error("Failed to register schedule:", err?.message);
14170
+ process.exit(1);
13182
14171
  }
14172
+ return;
13183
14173
  }
13184
- const result = await migrateService(opts.service);
13185
- console.log(`${result.service}: ${result.applied.length} applied, ${result.alreadyApplied.length} existing`);
13186
- for (const e of result.errors) {
13187
- console.error(` ${e}`);
14174
+ const status = getSyncScheduleStatus();
14175
+ if (status.registered) {
14176
+ console.log("Scheduled sync: enabled");
14177
+ console.log(` Interval: ${status.schedule_minutes} minute(s)`);
14178
+ console.log(` Cron expression: ${status.cron_expression}`);
14179
+ } else {
14180
+ console.log("Scheduled sync: disabled");
14181
+ console.log(`
14182
+ To enable, run: cloud sync schedule --every 5m`);
13188
14183
  }
13189
- }
13190
- });
13191
- syncCmd.command("status").description("Show sync status for all discovered services").option("--service <name>", "Show status for a single service").option("--json", "Output as JSON").action(async (opts) => {
13192
- const services = opts.service ? [opts.service] : discoverServices();
13193
- const statuses = [];
13194
- for (const service of services) {
13195
- const dbPath = getDbPath2(service);
13196
- const localExists = existsSync9(dbPath);
13197
- let localSize = "\u2014";
13198
- let tableCount = 0;
13199
- if (localExists) {
14184
+ const services = discoverSyncableServices2();
14185
+ if (services.length > 0) {
14186
+ console.log(`
14187
+ Syncable services (${services.length}):`);
14188
+ for (const s of services)
14189
+ console.log(` - ${s}`);
14190
+ } else {
14191
+ console.log(`
14192
+ No syncable services found (no .db files in ~/.hasna/).`);
14193
+ }
14194
+ });
14195
+ }
14196
+ function registerDaemonCommand(syncCmd) {
14197
+ syncCmd.command("daemon").description("Manage continuous background sync daemon").option("--start", "Start the daemon").option("--stop", "Stop the daemon").option("--pause", "Pause the daemon without stopping it").option("--resume", "Resume a paused daemon").option("--status", "Show daemon status").option("--now", "Run one daemon pass immediately").option("--foreground", "Run the daemon in the foreground").option("--watch <seconds>", "Local database scan interval in seconds").option("--pull <seconds>", "Remote pull interval in seconds").option("--push-debounce <seconds>", "Minimum seconds between auto-pushes").option("--conflict-strategy <strategy>", "newest-wins, local-wins, or remote-wins").option("--service <name>", "Restrict daemon to a service (repeatable)", collectValues, []).option("--table-interval <rule>", "Per-table interval, e.g. todos.tasks=30 or todos:tasks=30", collectValues, []).option("--file <rule>", "Track a file path for daemon status, e.g. ~/.claude/agents=30", collectValues, []).addOption(new Option2("--run", "Internal daemon worker").hideHelp()).action((opts) => {
14198
+ const currentConfig = getDaemonConfig();
14199
+ const selectedServices = opts.service.length > 0 ? Array.from(new Set(opts.service)) : currentConfig.services;
14200
+ const tableIntervalRules = opts.tableInterval;
14201
+ const fileRules = opts.file;
14202
+ const actionCount = [
14203
+ opts.start,
14204
+ opts.stop,
14205
+ opts.pause,
14206
+ opts.resume,
14207
+ opts.status,
14208
+ opts.now,
14209
+ opts.run
14210
+ ].filter(Boolean).length;
14211
+ if (actionCount > 1) {
14212
+ console.error("Choose only one daemon action at a time.");
14213
+ process.exit(1);
14214
+ }
14215
+ if (opts.foreground && !opts.start && !opts.run) {
14216
+ console.error("--foreground only works with --start.");
14217
+ process.exit(1);
14218
+ }
14219
+ const nextConfig = {
14220
+ ...currentConfig,
14221
+ watch_interval_seconds: opts.watch ? parsePositiveSeconds(opts.watch, "Watch interval") : currentConfig.watch_interval_seconds,
14222
+ pull_interval_seconds: opts.pull ? parsePositiveSeconds(opts.pull, "Pull interval") : currentConfig.pull_interval_seconds,
14223
+ push_debounce_seconds: opts.pushDebounce ? parsePositiveSeconds(opts.pushDebounce, "Push debounce") : currentConfig.push_debounce_seconds,
14224
+ conflict_strategy: normalizeConflictStrategy(opts.conflictStrategy ?? currentConfig.conflict_strategy),
14225
+ services: selectedServices,
14226
+ table_intervals: tableIntervalRules.length > 0 ? applyTableIntervalRules(currentConfig.table_intervals, tableIntervalRules) : currentConfig.table_intervals,
14227
+ file_rules: fileRules.length > 0 ? applyFileRules(currentConfig.file_rules, fileRules) : currentConfig.file_rules
14228
+ };
14229
+ if (opts.run) {
14230
+ saveDaemonConfig({ ...nextConfig, enabled: true, paused: false });
14231
+ runDaemonLoop();
14232
+ return;
14233
+ }
14234
+ if (opts.stop) {
14235
+ stopDaemon();
14236
+ printDaemonStatus();
14237
+ return;
14238
+ }
14239
+ if (opts.pause) {
14240
+ saveDaemonConfig(nextConfig);
14241
+ pauseDaemon();
14242
+ printDaemonStatus();
14243
+ return;
14244
+ }
14245
+ if (opts.resume) {
14246
+ saveDaemonConfig(nextConfig);
14247
+ resumeDaemon();
14248
+ printDaemonStatus();
14249
+ return;
14250
+ }
14251
+ if (opts.now) {
14252
+ const savedConfig = saveDaemonConfig(nextConfig);
14253
+ const result = runDaemonOnce({
14254
+ config: { ...savedConfig, enabled: true, paused: false },
14255
+ forcePull: true,
14256
+ forcePush: true
14257
+ });
14258
+ console.log("Daemon pass complete.");
14259
+ console.log(` Services: ${result.summary.services.length}`);
14260
+ console.log(` Pushed: ${result.summary.pushed_rows} rows`);
14261
+ console.log(` Pulled: ${result.summary.pulled_rows} rows`);
14262
+ console.log(` Errors: ${result.summary.errors.length}`);
14263
+ for (const error of result.summary.errors) {
14264
+ console.error(` ${error}`);
14265
+ }
14266
+ return;
14267
+ }
14268
+ if (opts.start) {
14269
+ if (opts.foreground) {
14270
+ saveDaemonConfig({ ...nextConfig, enabled: true, paused: false });
14271
+ runDaemonLoop();
14272
+ return;
14273
+ }
14274
+ startDaemon(nextConfig);
14275
+ printDaemonStatus();
14276
+ return;
14277
+ }
14278
+ printDaemonStatus();
14279
+ });
14280
+ }
14281
+
14282
+ // src/cli/cmd-migrate.ts
14283
+ init_dotfile();
14284
+ function registerMigrateCommands(program3) {
14285
+ registerMigratePgCommand(program3);
14286
+ registerMigrateCommand(program3);
14287
+ }
14288
+ function registerMigratePgCommand(program3) {
14289
+ program3.command("migrate-pg").description("Apply PG migrations for services").option("--service <name>", "Service name").option("--all", "Migrate all discovered services").option("--create-db", "Create PG databases if they don't exist (default: true)", true).action(async (opts) => {
14290
+ if (!opts.service && !opts.all) {
14291
+ console.error("Error: specify --service <name> or --all");
14292
+ process.exit(1);
14293
+ }
14294
+ if (opts.all) {
14295
+ if (opts.createDb) {
14296
+ console.log("Ensuring PG databases exist...");
14297
+ const dbResults = await ensureAllPgDatabases();
14298
+ for (const r of dbResults) {
14299
+ if (r.created)
14300
+ console.log(` Created database: ${r.service}`);
14301
+ if (r.error)
14302
+ console.error(` ${r.service}: ${r.error}`);
14303
+ }
14304
+ }
14305
+ console.log(`
14306
+ Running PG migrations...`);
14307
+ const results = await migrateAllServices();
14308
+ let totalApplied = 0;
14309
+ let totalErrors = 0;
14310
+ for (const r of results) {
14311
+ totalApplied += r.applied.length;
14312
+ totalErrors += r.errors.length;
14313
+ if (r.applied.length > 0 || r.errors.length > 0) {
14314
+ console.log(` ${r.service}: ${r.applied.length} applied, ${r.alreadyApplied.length} existing${r.errors.length > 0 ? `, ${r.errors.length} errors` : ""}`);
14315
+ for (const e of r.errors)
14316
+ console.error(` ${e}`);
14317
+ }
14318
+ }
14319
+ console.log(`
14320
+ Done. ${results.length} services, ${totalApplied} migrations applied, ${totalErrors} errors.`);
14321
+ } else {
14322
+ if (opts.createDb) {
14323
+ try {
14324
+ const created = await ensurePgDatabase(opts.service);
14325
+ if (created)
14326
+ console.log(`Created database: ${opts.service}`);
14327
+ } catch (err) {
14328
+ console.error(`Failed to create database: ${err?.message ?? String(err)}`);
14329
+ }
14330
+ }
14331
+ const result = await migrateService(opts.service);
14332
+ console.log(`${result.service}: ${result.applied.length} applied, ${result.alreadyApplied.length} existing`);
14333
+ for (const e of result.errors)
14334
+ console.error(` ${e}`);
14335
+ }
14336
+ });
14337
+ }
14338
+ function registerMigrateCommand(program3) {
14339
+ program3.command("migrate").description("Migrate legacy dotfiles to ~/.hasna/").argument("<service>", "Service name to migrate").action((service) => {
14340
+ const migrated = migrateDotfile(service);
14341
+ if (migrated.length === 0) {
14342
+ console.log(`No migration needed for "${service}" — either no legacy dir or already migrated.`);
14343
+ } else {
14344
+ console.log(`Migrated ${migrated.length} file(s) from ~/.${service}/ to ~/.hasna/${service}/:`);
14345
+ for (const f of migrated)
14346
+ console.log(` ${f}`);
14347
+ }
14348
+ });
14349
+ }
14350
+
14351
+ // src/cli/cmd-feedback.ts
14352
+ init_config();
14353
+
14354
+ // src/feedback.ts
14355
+ init_config();
14356
+ import { hostname as hostname2 } from "os";
14357
+ var FEEDBACK_TABLE_SQL = `
14358
+ CREATE TABLE IF NOT EXISTS feedback (
14359
+ id TEXT PRIMARY KEY,
14360
+ service TEXT NOT NULL,
14361
+ version TEXT DEFAULT '',
14362
+ message TEXT NOT NULL,
14363
+ email TEXT DEFAULT '',
14364
+ machine_id TEXT DEFAULT '',
14365
+ created_at TEXT DEFAULT (datetime('now'))
14366
+ )`;
14367
+ function ensureFeedbackTable(db) {
14368
+ db.exec(FEEDBACK_TABLE_SQL);
14369
+ }
14370
+ function saveFeedback(db, feedback) {
14371
+ ensureFeedbackTable(db);
14372
+ const id = feedback.id ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
14373
+ const now = new Date().toISOString();
14374
+ const machineId = feedback.machine_id ?? hostname2();
14375
+ db.run(`INSERT INTO feedback (id, service, version, message, email, machine_id, created_at)
14376
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, id, feedback.service, feedback.version ?? "", feedback.message, feedback.email ?? "", machineId, feedback.created_at ?? now);
14377
+ return id;
14378
+ }
14379
+ async function sendFeedback(feedback, db) {
14380
+ const config = getCloudConfig();
14381
+ const id = feedback.id ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
14382
+ const machineId = feedback.machine_id ?? hostname2();
14383
+ const now = new Date().toISOString();
14384
+ const payload = {
14385
+ id,
14386
+ service: feedback.service,
14387
+ version: feedback.version ?? "",
14388
+ message: feedback.message,
14389
+ email: feedback.email ?? "",
14390
+ machine_id: machineId,
14391
+ created_at: feedback.created_at ?? now
14392
+ };
14393
+ try {
14394
+ const res = await fetch(config.feedback_endpoint, {
14395
+ method: "POST",
14396
+ headers: { "Content-Type": "application/json" },
14397
+ body: JSON.stringify(payload),
14398
+ signal: AbortSignal.timeout(1e4)
14399
+ });
14400
+ if (!res.ok) {
14401
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
14402
+ }
14403
+ if (db) {
13200
14404
  try {
13201
- const stat = statSync6(dbPath);
13202
- localSize = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(0)}KB`;
14405
+ saveFeedback(db, { ...feedback, id });
13203
14406
  } catch {}
14407
+ }
14408
+ return { sent: true, id };
14409
+ } catch (err) {
14410
+ const errorMsg = err?.message ?? String(err);
14411
+ if (db) {
13204
14412
  try {
13205
- const local = new SqliteAdapter2(dbPath);
13206
- const tables = listSqliteTables(local);
13207
- tableCount = tables.length;
13208
- local.close();
14413
+ saveFeedback(db, { ...feedback, id });
13209
14414
  } catch {}
13210
14415
  }
13211
- let pgReachable = false;
13212
- try {
13213
- const connStr = getConnectionString(service);
13214
- const pg2 = new PgAdapterAsync2(connStr);
13215
- await pg2.all("SELECT 1");
13216
- pgReachable = true;
13217
- await pg2.close();
13218
- } catch {}
13219
- statuses.push({
13220
- service,
13221
- localDb: localExists ? dbPath : null,
13222
- localSize,
13223
- tables: tableCount,
13224
- pgReachable
13225
- });
13226
- }
13227
- if (opts.json) {
13228
- console.log(JSON.stringify(statuses, null, 2));
13229
- } else {
13230
- const config = getCloudConfig();
13231
- console.log(`Mode: ${config.mode}`);
13232
- console.log(`Services: ${statuses.length}
13233
- `);
13234
- for (const s of statuses) {
13235
- if (!s.localDb && !s.pgReachable)
13236
- continue;
13237
- const pgIcon = s.pgReachable ? "\u2713" : "\u2717";
13238
- console.log(` ${s.service.padEnd(20)} ${s.localSize.padStart(8)} ${String(s.tables).padStart(3)} tables PG: ${pgIcon}`);
13239
- }
13240
- const withData = statuses.filter((s) => s.localDb);
13241
- const pgOk = statuses.filter((s) => s.pgReachable);
13242
- console.log(`
13243
- ${withData.length} with local data, ${pgOk.length} with PG connection`);
14416
+ return { sent: false, id, error: errorMsg };
13244
14417
  }
13245
- });
13246
- syncCmd.command("schedule").description("Manage scheduled background sync").option("--every <interval>", "Set sync interval (e.g. 5m, 10m, 1h)").option("--off", "Disable scheduled sync").option("--now", "Run a one-off sync immediately").action(async (opts) => {
13247
- if (opts.off) {
13248
- try {
13249
- await removeSyncSchedule();
13250
- console.log("Scheduled sync disabled.");
13251
- } catch (err) {
13252
- console.error("Failed to remove schedule:", err?.message);
13253
- process.exit(1);
14418
+ }
14419
+
14420
+ // src/cli/cmd-feedback.ts
14421
+ function registerFeedbackCommand(program3) {
14422
+ program3.command("feedback").description("Send feedback").requiredOption("--service <name>", "Service name").requiredOption("--message <msg>", "Feedback message").option("--email <email>", "Contact email").option("--version <ver>", "Service version").action(async (opts) => {
14423
+ const db = createDatabase({ service: "cloud" });
14424
+ const result = await sendFeedback({
14425
+ service: opts.service,
14426
+ version: opts.version,
14427
+ message: opts.message,
14428
+ email: opts.email
14429
+ }, db);
14430
+ if (result.sent) {
14431
+ console.log(`Feedback sent successfully (id: ${result.id})`);
14432
+ } else {
14433
+ console.log(`Feedback saved locally (id: ${result.id}). Remote send failed: ${result.error}`);
13254
14434
  }
13255
- return;
13256
- }
13257
- if (opts.now) {
14435
+ db.close();
14436
+ });
14437
+ }
14438
+
14439
+ // src/cli/cmd-doctor.ts
14440
+ init_config();
14441
+ init_adapter();
14442
+ init_discover();
14443
+ import { existsSync as existsSync9 } from "fs";
14444
+ import { join as join9 } from "path";
14445
+ import { homedir as homedir8 } from "os";
14446
+ function registerDoctorCommand(program3) {
14447
+ program3.command("doctor").description("Comprehensive health check for cloud sync setup").action(async () => {
14448
+ const checks = [];
14449
+ const configPath = join9(homedir8(), ".hasna", "cloud", "config.json");
14450
+ checks.push(existsSync9(configPath) ? { name: "Config file", status: "pass", detail: configPath } : { name: "Config file", status: "fail", detail: "Missing. Run `cloud setup`." });
13258
14451
  const config = getCloudConfig();
13259
- if (config.mode === "local") {
13260
- console.error("Error: mode is 'local'. Run `cloud setup --mode hybrid` or `--mode cloud` first.");
13261
- process.exit(1);
13262
- }
13263
- console.log("Running sync now...");
13264
- const services2 = discoverSyncableServices();
13265
- console.log(`Discovered ${services2.length} service(s): ${services2.join(", ") || "(none)"}`);
13266
- const results = await runScheduledSync();
13267
- for (const r of results) {
13268
- const status2 = r.errors.length === 0 ? "ok" : "errors";
13269
- console.log(` ${r.service}: ${r.tables_synced} table(s), ${r.total_rows_synced} row(s) [${status2}]`);
13270
- for (const e of r.errors) {
13271
- console.error(` ${e}`);
14452
+ checks.push(config.mode === "hybrid" || config.mode === "cloud" ? { name: "Sync mode", status: "pass", detail: config.mode } : { name: "Sync mode", status: "fail", detail: `"${config.mode}" — sync disabled. Run \`cloud setup --mode hybrid\`.` });
14453
+ checks.push(config.rds.host ? { name: "RDS host", status: "pass", detail: config.rds.host } : { name: "RDS host", status: "fail", detail: "Not configured. Run `cloud setup`." });
14454
+ const password = process.env[config.rds.password_env];
14455
+ checks.push(password ? { name: "RDS password", status: "pass", detail: `${config.rds.password_env} is set` } : { name: "RDS password", status: "fail", detail: `${config.rds.password_env} not in environment. Add to ~/.secrets/hasna/rds/live.env` });
14456
+ if (config.rds.host && password) {
14457
+ try {
14458
+ const pg2 = new PgAdapterAsync(getConnectionString("postgres"));
14459
+ await pg2.all("SELECT 1");
14460
+ await pg2.close();
14461
+ checks.push({ name: "PG connection", status: "pass", detail: "Connected" });
14462
+ } catch (err) {
14463
+ checks.push({ name: "PG connection", status: "fail", detail: err?.message ?? String(err) });
13272
14464
  }
14465
+ } else {
14466
+ checks.push({ name: "PG connection", status: "fail", detail: "Skipped — missing host or password" });
13273
14467
  }
13274
- if (results.length === 0) {
13275
- console.log("No services synced (mode may be local or no databases found).");
14468
+ const caPath = process.env.NODE_EXTRA_CA_CERTS;
14469
+ if (caPath && existsSync9(caPath)) {
14470
+ checks.push({ name: "SSL CA cert", status: "pass", detail: caPath });
14471
+ } else if (caPath) {
14472
+ checks.push({ name: "SSL CA cert", status: "warn", detail: `NODE_EXTRA_CA_CERTS set but file missing: ${caPath}` });
13276
14473
  } else {
13277
- const totalRows = results.reduce((s, r) => s + r.total_rows_synced, 0);
13278
- const totalErrors = results.reduce((s, r) => s + r.errors.length, 0);
13279
- console.log(`
13280
- Done. ${totalRows} rows synced, ${totalErrors} errors.`);
14474
+ checks.push({ name: "SSL CA cert", status: "warn", detail: "NODE_EXTRA_CA_CERTS not set. May cause SSL errors on some systems." });
14475
+ }
14476
+ const services = discoverServices();
14477
+ checks.push({ name: "Local services", status: services.length > 0 ? "pass" : "warn", detail: `${services.length} found in ~/.hasna/` });
14478
+ const schedule = getSyncScheduleStatus();
14479
+ checks.push(schedule.registered ? { name: "Sync schedule", status: "pass", detail: `Every ${schedule.schedule_minutes}m (${schedule.mechanism})` } : { name: "Sync schedule", status: "warn", detail: "Not configured. Run `cloud sync schedule --every 30m`." });
14480
+ const daemon = getDaemonStatus();
14481
+ checks.push(daemon.running ? {
14482
+ name: "Sync daemon",
14483
+ status: "pass",
14484
+ detail: `${daemon.status} (watch ${daemon.config.watch_interval_seconds}s, pull ${daemon.config.pull_interval_seconds}s)`
14485
+ } : {
14486
+ name: "Sync daemon",
14487
+ status: daemon.config.enabled ? "warn" : "warn",
14488
+ detail: daemon.config.enabled ? "Enabled in config but not running. Run `cloud sync daemon --start`." : "Not running. Run `cloud sync daemon --start` for continuous sync."
14489
+ });
14490
+ console.log(`Cloud Doctor
14491
+ `);
14492
+ for (const c of checks) {
14493
+ const icon = c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "⚠";
14494
+ console.log(` ${icon} ${c.name.padEnd(20)} ${c.detail}`);
13281
14495
  }
13282
- return;
13283
- }
13284
- if (opts.every) {
13285
- try {
13286
- const minutes = parseInterval(opts.every);
13287
- await registerSyncSchedule(minutes);
13288
- console.log(`Scheduled sync registered: every ${minutes} minute(s).`);
13289
- } catch (err) {
13290
- console.error("Failed to register schedule:", err?.message);
14496
+ const fails = checks.filter((c) => c.status === "fail").length;
14497
+ const warns = checks.filter((c) => c.status === "warn").length;
14498
+ console.log(`
14499
+ ${checks.length} checks: ${checks.length - fails - warns} passed, ${warns} warnings, ${fails} failed`);
14500
+ if (fails > 0)
13291
14501
  process.exit(1);
13292
- }
13293
- return;
14502
+ });
14503
+ }
14504
+
14505
+ // src/config.ts
14506
+ init_zod();
14507
+ init_adapter();
14508
+ init_dotfile();
14509
+ init_machines();
14510
+ import { existsSync as existsSync10, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
14511
+ import { homedir as homedir9 } from "os";
14512
+ import { join as join10 } from "path";
14513
+ var DaemonConfigSchema2 = exports_external.object({
14514
+ enabled: exports_external.boolean().default(false),
14515
+ paused: exports_external.boolean().default(false),
14516
+ watch_interval_seconds: exports_external.number().int().positive().default(5),
14517
+ pull_interval_seconds: exports_external.number().int().positive().default(60),
14518
+ push_debounce_seconds: exports_external.number().int().positive().default(5),
14519
+ conflict_strategy: exports_external.enum(["newest-wins", "local-wins", "remote-wins"]).default("newest-wins"),
14520
+ services: exports_external.array(exports_external.string()).default([]),
14521
+ table_intervals: exports_external.record(exports_external.string(), exports_external.record(exports_external.string(), exports_external.number().int().positive())).default({}),
14522
+ file_rules: exports_external.array(exports_external.object({
14523
+ path: exports_external.string(),
14524
+ interval_seconds: exports_external.number().int().positive().default(30),
14525
+ enabled: exports_external.boolean().default(true)
14526
+ })).default([])
14527
+ }).default({});
14528
+ var CloudConfigSchema2 = exports_external.object({
14529
+ rds: exports_external.object({
14530
+ host: exports_external.string().default(""),
14531
+ port: exports_external.number().default(5432),
14532
+ username: exports_external.string().default(""),
14533
+ password_env: exports_external.string().default("HASNA_RDS_PASSWORD"),
14534
+ ssl: exports_external.boolean().default(true)
14535
+ }).default({}),
14536
+ mode: exports_external.enum(["local", "cloud", "hybrid"]).default("hybrid"),
14537
+ auto_sync_interval_minutes: exports_external.number().default(0),
14538
+ feedback_endpoint: exports_external.string().default("https://feedback.hasna.com/api/v1/feedback"),
14539
+ sync: exports_external.object({
14540
+ schedule_minutes: exports_external.number().default(0)
14541
+ }).default({}),
14542
+ daemon: DaemonConfigSchema2
14543
+ });
14544
+ var CONFIG_DIR3 = join10(homedir9(), ".hasna", "cloud");
14545
+ var CONFIG_PATH2 = join10(CONFIG_DIR3, "config.json");
14546
+ function getCloudConfig2() {
14547
+ if (!existsSync10(CONFIG_PATH2)) {
14548
+ return CloudConfigSchema2.parse({});
13294
14549
  }
13295
- const status = getSyncScheduleStatus();
13296
- if (status.registered) {
13297
- console.log("Scheduled sync: enabled");
13298
- console.log(` Interval: ${status.schedule_minutes} minute(s)`);
13299
- console.log(` Cron expression: ${status.cron_expression}`);
13300
- } else {
13301
- console.log("Scheduled sync: disabled");
13302
- console.log(`
13303
- To enable, run: cloud sync schedule --every 5m`);
14550
+ try {
14551
+ const raw = readFileSync4(CONFIG_PATH2, "utf-8");
14552
+ return CloudConfigSchema2.parse(JSON.parse(raw));
14553
+ } catch {
14554
+ return CloudConfigSchema2.parse({});
13304
14555
  }
13305
- const services = discoverSyncableServices();
13306
- if (services.length > 0) {
13307
- console.log(`
13308
- Syncable services (${services.length}):`);
13309
- for (const s of services) {
13310
- console.log(` - ${s}`);
13311
- }
13312
- } else {
13313
- console.log(`
13314
- No syncable services found (no .db files in ~/.hasna/).`);
14556
+ }
14557
+ function getConnectionString2(dbName) {
14558
+ const config = getCloudConfig2();
14559
+ const { host, port, username, password_env, ssl } = config.rds;
14560
+ if (!host || !username) {
14561
+ throw new Error("Cloud RDS not configured. Run `cloud setup` to configure.");
13315
14562
  }
13316
- });
13317
- program2.command("feedback").description("Send feedback").requiredOption("--service <name>", "Service name").requiredOption("--message <msg>", "Feedback message").option("--email <email>", "Contact email").option("--version <ver>", "Service version").action(async (opts) => {
13318
- const db = createDatabase({ service: "cloud" });
13319
- const result = await sendFeedback({
13320
- service: opts.service,
13321
- version: opts.version,
13322
- message: opts.message,
13323
- email: opts.email
13324
- }, db);
13325
- if (result.sent) {
13326
- console.log(`Feedback sent successfully (id: ${result.id})`);
13327
- } else {
13328
- console.log(`Feedback saved locally (id: ${result.id}). Remote send failed: ${result.error}`);
14563
+ const password = process.env[password_env];
14564
+ if (password === undefined || password === "") {
14565
+ throw new Error(`RDS password not set. Export ${password_env} in your shell or add it to ~/.secrets/hasna/rds/live.env`);
13329
14566
  }
13330
- db.close();
13331
- });
13332
- program2.command("migrate").description("Migrate legacy dotfiles to ~/.hasna/").argument("<service>", "Service name to migrate").action((service) => {
13333
- const migrated = migrateDotfile(service);
13334
- if (migrated.length === 0) {
13335
- console.log(`No migration needed for "${service}" \u2014 either no legacy dir or already migrated.`);
13336
- } else {
13337
- console.log(`Migrated ${migrated.length} file(s) from ~/.${service}/ to ~/.hasna/${service}/:`);
13338
- for (const f of migrated) {
13339
- console.log(` ${f}`);
14567
+ const sslParam = ssl ? "?sslmode=require" : "";
14568
+ return `postgres://${username}:${encodeURIComponent(password)}@${host}:${port}/${dbName}${sslParam}`;
14569
+ }
14570
+
14571
+ // src/adapter.ts
14572
+ init_esm();
14573
+ class PgAdapterAsync2 {
14574
+ pool;
14575
+ constructor(arg) {
14576
+ if (typeof arg === "string") {
14577
+ const sslConfig = arg.includes("sslmode=require") || arg.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
14578
+ this.pool = new esm_default.Pool({ connectionString: arg, ssl: sslConfig });
14579
+ } else {
14580
+ this.pool = arg;
13340
14581
  }
13341
14582
  }
13342
- });
13343
- program2.command("doctor").description("Comprehensive health check for cloud sync setup").action(async () => {
13344
- const checks = [];
13345
- const configPath = join9(homedir8(), ".hasna", "cloud", "config.json");
13346
- if (existsSync9(configPath)) {
13347
- checks.push({ name: "Config file", status: "pass", detail: configPath });
13348
- } else {
13349
- checks.push({ name: "Config file", status: "fail", detail: "Missing. Run `cloud setup`." });
14583
+ async run(sql, ...params) {
14584
+ const pgSql = translateSql(sql, "pg");
14585
+ const pgParams = translateParams(params);
14586
+ const res = await this.pool.query(pgSql, pgParams);
14587
+ return {
14588
+ changes: res.rowCount ?? 0,
14589
+ lastInsertRowid: res.rows?.[0]?.id ?? 0
14590
+ };
13350
14591
  }
13351
- const config = getCloudConfig();
13352
- if (config.mode === "hybrid" || config.mode === "cloud") {
13353
- checks.push({ name: "Sync mode", status: "pass", detail: config.mode });
13354
- } else {
13355
- checks.push({ name: "Sync mode", status: "fail", detail: `"${config.mode}" \u2014 sync disabled. Run \`cloud setup --mode hybrid\`.` });
14592
+ async get(sql, ...params) {
14593
+ const pgSql = translateSql(sql, "pg");
14594
+ const pgParams = translateParams(params);
14595
+ const res = await this.pool.query(pgSql, pgParams);
14596
+ return res.rows[0] ?? null;
13356
14597
  }
13357
- if (config.rds.host) {
13358
- checks.push({ name: "RDS host", status: "pass", detail: config.rds.host });
13359
- } else {
13360
- checks.push({ name: "RDS host", status: "fail", detail: "Not configured. Run `cloud setup`." });
14598
+ async all(sql, ...params) {
14599
+ const pgSql = translateSql(sql, "pg");
14600
+ const pgParams = translateParams(params);
14601
+ const res = await this.pool.query(pgSql, pgParams);
14602
+ return res.rows;
13361
14603
  }
13362
- const password = process.env[config.rds.password_env];
13363
- if (password) {
13364
- checks.push({ name: "RDS password", status: "pass", detail: `${config.rds.password_env} is set` });
13365
- } else {
13366
- checks.push({ name: "RDS password", status: "fail", detail: `${config.rds.password_env} not in environment. Add to ~/.secrets/hasna/rds/live.env` });
14604
+ async exec(sql) {
14605
+ const pgSql = translateSql(sql, "pg");
14606
+ await this.pool.query(pgSql);
13367
14607
  }
13368
- if (config.rds.host && password) {
14608
+ async close() {
14609
+ await this.pool.end();
14610
+ }
14611
+ async transaction(fn) {
14612
+ const client = await this.pool.connect();
13369
14613
  try {
13370
- const connStr = getConnectionString("postgres");
13371
- const pg2 = new PgAdapterAsync2(connStr);
13372
- await pg2.all("SELECT 1");
13373
- await pg2.close();
13374
- checks.push({ name: "PG connection", status: "pass", detail: "Connected" });
14614
+ await client.query("BEGIN");
14615
+ const result = await fn(client);
14616
+ await client.query("COMMIT");
14617
+ return result;
13375
14618
  } catch (err) {
13376
- checks.push({ name: "PG connection", status: "fail", detail: err?.message ?? String(err) });
14619
+ await client.query("ROLLBACK");
14620
+ throw err;
14621
+ } finally {
14622
+ client.release();
13377
14623
  }
13378
- } else {
13379
- checks.push({ name: "PG connection", status: "fail", detail: "Skipped \u2014 missing host or password" });
13380
14624
  }
13381
- const caPath = process.env.NODE_EXTRA_CA_CERTS;
13382
- if (caPath && existsSync9(caPath)) {
13383
- checks.push({ name: "SSL CA cert", status: "pass", detail: caPath });
13384
- } else if (caPath) {
13385
- checks.push({ name: "SSL CA cert", status: "warn", detail: `NODE_EXTRA_CA_CERTS set but file missing: ${caPath}` });
13386
- } else {
13387
- checks.push({ name: "SSL CA cert", status: "warn", detail: "NODE_EXTRA_CA_CERTS not set. May cause SSL errors on some systems." });
13388
- }
13389
- const services = discoverServices();
13390
- checks.push({ name: "Local services", status: services.length > 0 ? "pass" : "warn", detail: `${services.length} found in ~/.hasna/` });
13391
- const schedule = getSyncScheduleStatus();
13392
- if (schedule.registered) {
13393
- checks.push({ name: "Sync schedule", status: "pass", detail: `Every ${schedule.schedule_minutes}m (${schedule.mechanism})` });
13394
- } else {
13395
- checks.push({ name: "Sync schedule", status: "warn", detail: "Not configured. Run `cloud sync schedule --every 30m`." });
14625
+ get raw() {
14626
+ return this.pool;
13396
14627
  }
13397
- console.log(`Cloud Doctor
13398
- `);
13399
- for (const c of checks) {
13400
- const icon = c.status === "pass" ? "\u2713" : c.status === "fail" ? "\u2717" : "\u26A0";
13401
- console.log(` ${icon} ${c.name.padEnd(20)} ${c.detail}`);
14628
+ }
14629
+
14630
+ // src/cli/index.ts
14631
+ var program3 = new Command;
14632
+ program3.name("cloud").description("Shared cloud infrastructure \u2014 database adapter, sync engine, feedback, dotfile migration").version("0.1.13");
14633
+ registerSetupCommand(program3);
14634
+ program3.command("status").description("Show current cloud configuration and connection health").action(async () => {
14635
+ const config = getCloudConfig2();
14636
+ console.log("Mode:", config.mode);
14637
+ console.log("RDS Host:", config.rds.host || "(not configured)");
14638
+ console.log("RDS Port:", config.rds.port);
14639
+ console.log("RDS Username:", config.rds.username || "(not configured)");
14640
+ console.log("SSL:", config.rds.ssl);
14641
+ console.log("Auto-sync interval:", config.auto_sync_interval_minutes ? `${config.auto_sync_interval_minutes} minutes` : "disabled");
14642
+ if (config.rds.host && config.rds.username) {
14643
+ console.log(`
14644
+ Checking PostgreSQL connection...`);
14645
+ try {
14646
+ const pg2 = new PgAdapterAsync2(getConnectionString2("postgres"));
14647
+ const row = await pg2.get("SELECT 1 as ok");
14648
+ if (row?.ok === 1)
14649
+ console.log("PostgreSQL: connected");
14650
+ await pg2.close();
14651
+ } catch (err) {
14652
+ console.log("PostgreSQL: connection failed \u2014", err?.message);
14653
+ }
13402
14654
  }
13403
- const fails = checks.filter((c) => c.status === "fail").length;
13404
- const warns = checks.filter((c) => c.status === "warn").length;
13405
- console.log(`
13406
- ${checks.length} checks: ${checks.length - fails - warns} passed, ${warns} warnings, ${fails} failed`);
13407
- if (fails > 0)
13408
- process.exit(1);
13409
14655
  });
13410
- program2.parse();
14656
+ var syncCmd = program3.command("sync").description("Sync data between local and cloud");
14657
+ registerSyncCommands(syncCmd);
14658
+ registerMigrateCommands(program3);
14659
+ registerFeedbackCommand(program3);
14660
+ registerDoctorCommand(program3);
14661
+ program3.parse();