@hasna/cloud 0.1.31 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +2 -1
- package/README.md +17 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/auto-sync.d.ts.map +1 -1
- package/dist/cli/cmd-doctor.d.ts +3 -0
- package/dist/cli/cmd-doctor.d.ts.map +1 -0
- package/dist/cli/cmd-feedback.d.ts +3 -0
- package/dist/cli/cmd-feedback.d.ts.map +1 -0
- package/dist/cli/cmd-migrate.d.ts +3 -0
- package/dist/cli/cmd-migrate.d.ts.map +1 -0
- package/dist/cli/cmd-setup.d.ts +3 -0
- package/dist/cli/cmd-setup.d.ts.map +1 -0
- package/dist/cli/cmd-sync.d.ts +4 -0
- package/dist/cli/cmd-sync.d.ts.map +1 -0
- package/dist/cli/index.js +2330 -1079
- package/dist/config.d.ts +138 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/daemon-sync.d.ts +108 -0
- package/dist/daemon-sync.d.ts.map +1 -0
- package/dist/dialect.test.d.ts +2 -0
- package/dist/dialect.test.d.ts.map +1 -0
- package/dist/discover.test.d.ts +2 -0
- package/dist/discover.test.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1143 -153
- package/dist/machines.d.ts +63 -0
- package/dist/machines.d.ts.map +1 -0
- package/dist/mcp/bin.js +2 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +2 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +2125 -438
- package/dist/scheduled-sync.js +205 -44
- package/dist/sync-conflicts.test.d.ts +2 -0
- package/dist/sync-conflicts.test.d.ts.map +1 -0
- package/dist/sync-incremental.d.ts +5 -0
- package/dist/sync-incremental.d.ts.map +1 -1
- package/dist/sync-schedule.test.d.ts +2 -0
- package/dist/sync-schedule.test.d.ts.map +1 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.test.d.ts +2 -0
- package/dist/sync.test.d.ts.map +1 -0
- package/package.json +3 -3
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/
|
|
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/
|
|
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/
|
|
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: () =>
|
|
11295
|
+
isSyncExcludedTable: () => isSyncExcludedTable,
|
|
11353
11296
|
getServiceDbPath: () => getServiceDbPath,
|
|
11354
|
-
discoverSyncableServices: () =>
|
|
11355
|
-
discoverServices: () =>
|
|
11356
|
-
SYNC_EXCLUDED_TABLE_PATTERNS: () =>
|
|
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
|
|
11360
|
-
import { join as
|
|
11361
|
-
import { homedir as
|
|
11362
|
-
function
|
|
11363
|
-
return
|
|
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
|
|
11366
|
-
const dataDir =
|
|
11367
|
-
if (!
|
|
11308
|
+
function discoverServices() {
|
|
11309
|
+
const dataDir = join2(homedir2(), ".hasna");
|
|
11310
|
+
if (!existsSync2(dataDir))
|
|
11368
11311
|
return [];
|
|
11369
11312
|
try {
|
|
11370
|
-
const entries =
|
|
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
|
|
11383
|
-
const local =
|
|
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 =
|
|
11389
|
-
if (!
|
|
11331
|
+
const dataDir = join2(homedir2(), ".hasna", service);
|
|
11332
|
+
if (!existsSync2(dataDir))
|
|
11390
11333
|
return null;
|
|
11391
11334
|
const candidates = [
|
|
11392
|
-
|
|
11393
|
-
|
|
11394
|
-
|
|
11335
|
+
join2(dataDir, `${service}.db`),
|
|
11336
|
+
join2(dataDir, "data.db"),
|
|
11337
|
+
join2(dataDir, "database.db")
|
|
11395
11338
|
];
|
|
11396
11339
|
try {
|
|
11397
|
-
const files =
|
|
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(
|
|
11343
|
+
candidates.push(join2(dataDir, f));
|
|
11401
11344
|
}
|
|
11402
11345
|
}
|
|
11403
11346
|
} catch {}
|
|
11404
11347
|
for (const p of candidates) {
|
|
11405
|
-
if (
|
|
11348
|
+
if (existsSync2(p))
|
|
11406
11349
|
return p;
|
|
11407
11350
|
}
|
|
11408
11351
|
return null;
|
|
11409
11352
|
}
|
|
11410
|
-
var KNOWN_PG_SERVICES,
|
|
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
|
-
|
|
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
|
-
//
|
|
11459
|
-
|
|
11460
|
-
|
|
11461
|
-
|
|
11462
|
-
|
|
11463
|
-
|
|
11464
|
-
|
|
11465
|
-
|
|
11466
|
-
|
|
11467
|
-
|
|
11468
|
-
|
|
11469
|
-
|
|
11470
|
-
|
|
11471
|
-
|
|
11472
|
-
}
|
|
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
|
-
|
|
11476
|
-
|
|
11477
|
-
|
|
11478
|
-
|
|
11479
|
-
|
|
11480
|
-
|
|
11481
|
-
|
|
11482
|
-
|
|
11483
|
-
|
|
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
|
-
|
|
11497
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
11539
|
-
|
|
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/
|
|
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
|
-
|
|
11972
|
-
|
|
11973
|
-
|
|
11974
|
-
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
|
|
11978
|
-
|
|
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
|
-
|
|
12010
|
-
|
|
12011
|
-
|
|
12012
|
-
|
|
12013
|
-
|
|
12014
|
-
|
|
12015
|
-
|
|
12016
|
-
|
|
12017
|
-
|
|
12018
|
-
|
|
12019
|
-
|
|
12020
|
-
|
|
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
|
-
|
|
12029
|
-
|
|
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
|
-
|
|
12414
|
+
} finally {
|
|
12415
|
+
await pg2.close();
|
|
12032
12416
|
}
|
|
12417
|
+
return result;
|
|
12033
12418
|
}
|
|
12034
|
-
|
|
12035
|
-
|
|
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
|
|
12067
|
-
const
|
|
12068
|
-
|
|
12069
|
-
|
|
12070
|
-
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
|
|
12076
|
-
|
|
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
|
-
|
|
12082
|
-
|
|
12083
|
-
|
|
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
|
-
|
|
12097
|
-
|
|
12447
|
+
service,
|
|
12448
|
+
applied: [],
|
|
12449
|
+
alreadyApplied: [],
|
|
12450
|
+
errors: [`No PG migrations found for service "${service}"`],
|
|
12451
|
+
totalMigrations: 0
|
|
12098
12452
|
};
|
|
12099
12453
|
}
|
|
12100
|
-
|
|
12101
|
-
|
|
12102
|
-
|
|
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
|
-
|
|
12105
|
-
|
|
12106
|
-
|
|
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
|
-
|
|
12109
|
-
|
|
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
|
-
|
|
12112
|
-
|
|
12113
|
-
|
|
12114
|
-
|
|
12115
|
-
|
|
12116
|
-
|
|
12117
|
-
|
|
12118
|
-
|
|
12119
|
-
|
|
12120
|
-
|
|
12121
|
-
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
|
|
12125
|
-
|
|
12126
|
-
|
|
12127
|
-
|
|
12128
|
-
|
|
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 :
|
|
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(
|
|
12274
|
-
const errorLogPath = join5(
|
|
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 =
|
|
12308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12360
|
-
|
|
12361
|
-
|
|
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
|
-
|
|
12385
|
-
if (
|
|
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 =
|
|
12701
|
+
const config = getCloudConfig();
|
|
12391
12702
|
config.sync.schedule_minutes = intervalMinutes;
|
|
12392
|
-
|
|
12703
|
+
saveCloudConfig(config);
|
|
12393
12704
|
}
|
|
12394
12705
|
async function removeSyncSchedule() {
|
|
12395
|
-
if (
|
|
12706
|
+
if (platform2() === "darwin") {
|
|
12396
12707
|
await removeLaunchd();
|
|
12397
12708
|
} else {
|
|
12398
12709
|
await removeSystemd();
|
|
12399
12710
|
}
|
|
12400
|
-
const config =
|
|
12711
|
+
const config = getCloudConfig();
|
|
12401
12712
|
config.sync.schedule_minutes = 0;
|
|
12402
|
-
|
|
12713
|
+
saveCloudConfig(config);
|
|
12403
12714
|
}
|
|
12404
12715
|
function getSyncScheduleStatus() {
|
|
12405
|
-
const config =
|
|
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 (
|
|
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
|
-
|
|
12473
|
-
|
|
12474
|
-
|
|
12475
|
-
|
|
12476
|
-
|
|
12477
|
-
|
|
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,
|
|
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
|
-
|
|
12552
|
-
|
|
12553
|
-
|
|
12554
|
-
|
|
12555
|
-
|
|
12556
|
-
|
|
12557
|
-
|
|
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
|
-
|
|
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 =
|
|
13133
|
+
const config = getCloudConfig();
|
|
12579
13134
|
if (config.mode === "local")
|
|
12580
13135
|
return [];
|
|
12581
|
-
const services =
|
|
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 =
|
|
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 =
|
|
12604
|
-
remote = new
|
|
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
|
-
|
|
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
|
-
|
|
13184
|
+
remote.close();
|
|
12630
13185
|
} catch {}
|
|
12631
13186
|
}
|
|
12632
13187
|
return results;
|
|
12633
13188
|
}
|
|
12634
13189
|
|
|
12635
|
-
// src/
|
|
12636
|
-
import {
|
|
12637
|
-
import { join as
|
|
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
|
-
|
|
12640
|
-
|
|
12641
|
-
|
|
12642
|
-
|
|
12643
|
-
|
|
12644
|
-
|
|
12645
|
-
|
|
12646
|
-
|
|
12647
|
-
|
|
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
|
|
12650
|
-
|
|
12651
|
-
|
|
12652
|
-
|
|
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
|
|
12655
|
-
|
|
12656
|
-
|
|
12657
|
-
|
|
12658
|
-
|
|
12659
|
-
|
|
12660
|
-
|
|
12661
|
-
}
|
|
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
|
-
|
|
12668
|
-
|
|
12669
|
-
|
|
12670
|
-
|
|
12671
|
-
|
|
12672
|
-
|
|
12673
|
-
|
|
12674
|
-
|
|
12675
|
-
|
|
12676
|
-
|
|
12677
|
-
|
|
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
|
-
|
|
12681
|
-
|
|
12682
|
-
|
|
12683
|
-
|
|
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
|
|
12707
|
-
|
|
13318
|
+
function resolveDaemonServices(config) {
|
|
13319
|
+
if (config.services.length > 0) {
|
|
13320
|
+
return [...new Set(config.services)].sort();
|
|
13321
|
+
}
|
|
13322
|
+
return discoverSyncableServices();
|
|
12708
13323
|
}
|
|
12709
|
-
|
|
12710
|
-
const
|
|
12711
|
-
|
|
12712
|
-
|
|
12713
|
-
|
|
12714
|
-
|
|
12715
|
-
|
|
12716
|
-
|
|
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
|
|
12719
|
-
|
|
12720
|
-
|
|
12721
|
-
|
|
12722
|
-
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
|
|
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
|
-
|
|
12730
|
-
const
|
|
12731
|
-
|
|
12732
|
-
|
|
12733
|
-
|
|
12734
|
-
|
|
12735
|
-
|
|
12736
|
-
|
|
12737
|
-
|
|
12738
|
-
|
|
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
|
-
|
|
12744
|
-
|
|
12745
|
-
|
|
12746
|
-
|
|
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
|
|
12750
|
-
|
|
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
|
-
|
|
12753
|
-
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
12757
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12764
|
-
const config = (
|
|
12765
|
-
const
|
|
12766
|
-
|
|
12767
|
-
|
|
12768
|
-
|
|
12769
|
-
|
|
12770
|
-
|
|
12771
|
-
|
|
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
|
-
|
|
12774
|
-
|
|
12775
|
-
|
|
12776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12784
|
-
|
|
12785
|
-
|
|
12786
|
-
|
|
12787
|
-
|
|
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
|
|
13681
|
+
return strategy;
|
|
12796
13682
|
}
|
|
12797
13683
|
|
|
12798
|
-
// src/cli/
|
|
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 =
|
|
12806
|
-
const logPath =
|
|
12807
|
-
const { mkdirSync:
|
|
12808
|
-
|
|
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
|
-
|
|
12815
|
-
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
12822
|
-
|
|
12823
|
-
|
|
12824
|
-
|
|
12825
|
-
|
|
12826
|
-
|
|
12827
|
-
|
|
12828
|
-
|
|
12829
|
-
|
|
12830
|
-
|
|
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
|
-
|
|
12846
|
-
|
|
12847
|
-
|
|
12848
|
-
|
|
12849
|
-
|
|
12850
|
-
|
|
12851
|
-
|
|
12852
|
-
|
|
12853
|
-
|
|
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
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
|
|
12861
|
-
|
|
12862
|
-
|
|
12863
|
-
|
|
12864
|
-
|
|
12865
|
-
|
|
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
|
-
|
|
12869
|
-
|
|
12870
|
-
|
|
12871
|
-
|
|
12872
|
-
|
|
12873
|
-
|
|
12874
|
-
|
|
12875
|
-
|
|
12876
|
-
|
|
12877
|
-
|
|
12878
|
-
|
|
12879
|
-
console.
|
|
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
|
-
|
|
13775
|
+
tables = listSqliteTables(local).filter((t) => !isSyncExcludedTable(t));
|
|
12882
13776
|
}
|
|
12883
|
-
|
|
12884
|
-
|
|
12885
|
-
|
|
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
|
-
|
|
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(
|
|
13799
|
+
console.error(` [${service}] ${err?.message ?? String(err)}`);
|
|
13800
|
+
local.close();
|
|
13801
|
+
grandTotalErrors++;
|
|
13802
|
+
continue;
|
|
12893
13803
|
}
|
|
12894
|
-
|
|
12895
|
-
|
|
12896
|
-
|
|
12897
|
-
|
|
12898
|
-
|
|
12899
|
-
|
|
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
|
-
|
|
12912
|
-
|
|
12913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12945
|
-
syncCmd.command("
|
|
12946
|
-
|
|
12947
|
-
|
|
12948
|
-
|
|
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 (
|
|
12977
|
-
|
|
12978
|
-
|
|
13847
|
+
if (!opts.service && !opts.all) {
|
|
13848
|
+
console.error("Error: specify --service <name> or --all");
|
|
13849
|
+
process.exit(1);
|
|
12979
13850
|
}
|
|
12980
|
-
|
|
12981
|
-
|
|
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
|
-
|
|
12984
|
-
return `${t}: ${r?.cnt ?? 0} rows`;
|
|
13881
|
+
tables = (await listPgTables(cloud)).filter((t) => !isSyncExcludedTable(t));
|
|
12985
13882
|
} catch {
|
|
12986
|
-
|
|
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
|
-
|
|
12992
|
-
|
|
12993
|
-
|
|
12994
|
-
|
|
12995
|
-
|
|
12996
|
-
|
|
12997
|
-
|
|
12998
|
-
|
|
12999
|
-
|
|
13000
|
-
|
|
13001
|
-
|
|
13002
|
-
|
|
13003
|
-
|
|
13004
|
-
|
|
13005
|
-
|
|
13006
|
-
|
|
13007
|
-
|
|
13008
|
-
|
|
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. ${
|
|
13024
|
-
|
|
13025
|
-
|
|
13026
|
-
|
|
13027
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13064
|
-
|
|
13065
|
-
|
|
13066
|
-
|
|
13067
|
-
|
|
13068
|
-
|
|
13069
|
-
|
|
13070
|
-
|
|
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
|
|
13073
|
-
|
|
13074
|
-
|
|
13075
|
-
|
|
13076
|
-
|
|
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
|
-
|
|
13079
|
-
|
|
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
|
-
|
|
13084
|
-
|
|
13085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14021
|
+
const conflicts = listConflicts(local, {
|
|
14022
|
+
resolved: opts.resolved ? true : false,
|
|
14023
|
+
table: opts.table
|
|
14024
|
+
});
|
|
13093
14025
|
local.close();
|
|
13094
|
-
|
|
13095
|
-
|
|
14026
|
+
for (const c of conflicts) {
|
|
14027
|
+
allConflicts.push({ service, ...c });
|
|
14028
|
+
}
|
|
13096
14029
|
}
|
|
13097
|
-
if (opts.
|
|
13098
|
-
console.log(
|
|
13099
|
-
|
|
13100
|
-
await cloud.close();
|
|
13101
|
-
continue;
|
|
14030
|
+
if (opts.json) {
|
|
14031
|
+
console.log(JSON.stringify(allConflicts, null, 2));
|
|
14032
|
+
return;
|
|
13102
14033
|
}
|
|
13103
|
-
if (
|
|
13104
|
-
console.log(
|
|
13105
|
-
|
|
13106
|
-
|
|
13107
|
-
|
|
13108
|
-
|
|
13109
|
-
|
|
13110
|
-
|
|
13111
|
-
}
|
|
13112
|
-
|
|
13113
|
-
local.
|
|
13114
|
-
|
|
13115
|
-
|
|
13116
|
-
|
|
13117
|
-
|
|
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
|
-
|
|
13127
|
-
|
|
13128
|
-
|
|
13129
|
-
|
|
13130
|
-
|
|
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
|
-
|
|
13137
|
-
|
|
13138
|
-
|
|
13139
|
-
}
|
|
13140
|
-
}
|
|
13141
|
-
|
|
13142
|
-
|
|
13143
|
-
|
|
13144
|
-
|
|
13145
|
-
|
|
13146
|
-
|
|
13147
|
-
|
|
13148
|
-
|
|
13149
|
-
|
|
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
|
-
|
|
13158
|
-
|
|
13159
|
-
|
|
13160
|
-
|
|
13161
|
-
|
|
13162
|
-
|
|
13163
|
-
|
|
13164
|
-
|
|
13165
|
-
|
|
13166
|
-
|
|
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
|
-
|
|
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
|
|
13178
|
-
|
|
13179
|
-
|
|
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(
|
|
14169
|
+
console.error("Failed to register schedule:", err?.message);
|
|
14170
|
+
process.exit(1);
|
|
13182
14171
|
}
|
|
14172
|
+
return;
|
|
13183
14173
|
}
|
|
13184
|
-
const
|
|
13185
|
-
|
|
13186
|
-
|
|
13187
|
-
console.
|
|
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
|
-
|
|
13192
|
-
|
|
13193
|
-
|
|
13194
|
-
|
|
13195
|
-
|
|
13196
|
-
|
|
13197
|
-
|
|
13198
|
-
|
|
13199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13206
|
-
const tables = listSqliteTables(local);
|
|
13207
|
-
tableCount = tables.length;
|
|
13208
|
-
local.close();
|
|
14413
|
+
saveFeedback(db, { ...feedback, id });
|
|
13209
14414
|
} catch {}
|
|
13210
14415
|
}
|
|
13211
|
-
|
|
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
|
-
|
|
13247
|
-
|
|
13248
|
-
|
|
13249
|
-
|
|
13250
|
-
|
|
13251
|
-
|
|
13252
|
-
|
|
13253
|
-
|
|
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
|
-
|
|
13256
|
-
}
|
|
13257
|
-
|
|
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
|
-
|
|
13260
|
-
|
|
13261
|
-
|
|
13262
|
-
}
|
|
13263
|
-
|
|
13264
|
-
|
|
13265
|
-
|
|
13266
|
-
|
|
13267
|
-
|
|
13268
|
-
|
|
13269
|
-
|
|
13270
|
-
|
|
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
|
-
|
|
13275
|
-
|
|
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
|
-
|
|
13278
|
-
|
|
13279
|
-
|
|
13280
|
-
|
|
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
|
-
|
|
13283
|
-
|
|
13284
|
-
|
|
13285
|
-
|
|
13286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13296
|
-
|
|
13297
|
-
|
|
13298
|
-
|
|
13299
|
-
|
|
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
|
-
|
|
13306
|
-
|
|
13307
|
-
|
|
13308
|
-
|
|
13309
|
-
|
|
13310
|
-
|
|
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
|
-
|
|
13318
|
-
|
|
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
|
-
|
|
13331
|
-
})
|
|
13332
|
-
|
|
13333
|
-
|
|
13334
|
-
|
|
13335
|
-
|
|
13336
|
-
|
|
13337
|
-
|
|
13338
|
-
|
|
13339
|
-
|
|
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
|
-
|
|
13344
|
-
|
|
13345
|
-
|
|
13346
|
-
|
|
13347
|
-
|
|
13348
|
-
|
|
13349
|
-
|
|
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
|
-
|
|
13352
|
-
|
|
13353
|
-
|
|
13354
|
-
|
|
13355
|
-
|
|
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
|
-
|
|
13358
|
-
|
|
13359
|
-
|
|
13360
|
-
|
|
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
|
-
|
|
13363
|
-
|
|
13364
|
-
|
|
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
|
-
|
|
14608
|
+
async close() {
|
|
14609
|
+
await this.pool.end();
|
|
14610
|
+
}
|
|
14611
|
+
async transaction(fn) {
|
|
14612
|
+
const client = await this.pool.connect();
|
|
13369
14613
|
try {
|
|
13370
|
-
|
|
13371
|
-
const
|
|
13372
|
-
await
|
|
13373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13382
|
-
|
|
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
|
-
|
|
13398
|
-
|
|
13399
|
-
|
|
13400
|
-
|
|
13401
|
-
|
|
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
|
-
|
|
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();
|