@hasna/cloud 0.1.34 → 0.1.36
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/dist/cli/index.js +801 -116
- package/dist/index.js +3 -3
- package/dist/mcp/index.js +4 -4
- package/dist/scheduled-sync.js +3 -3
- package/package.json +3 -2
package/dist/cli/index.js
CHANGED
|
@@ -994,7 +994,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
994
994
|
this._exitCallback = (err) => {
|
|
995
995
|
if (err.code !== "commander.executeSubCommandAsync") {
|
|
996
996
|
throw err;
|
|
997
|
-
}
|
|
997
|
+
}
|
|
998
998
|
};
|
|
999
999
|
}
|
|
1000
1000
|
return this;
|
|
@@ -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) {
|
|
@@ -11238,28 +11238,28 @@ var init_adapter = __esm(() => {
|
|
|
11238
11238
|
|
|
11239
11239
|
// src/dotfile.ts
|
|
11240
11240
|
import {
|
|
11241
|
-
existsSync,
|
|
11241
|
+
existsSync as existsSync2,
|
|
11242
11242
|
mkdirSync,
|
|
11243
11243
|
readdirSync,
|
|
11244
11244
|
copyFileSync
|
|
11245
11245
|
} from "fs";
|
|
11246
|
-
import { homedir } from "os";
|
|
11247
|
-
import { join, relative } from "path";
|
|
11246
|
+
import { homedir as homedir2 } from "os";
|
|
11247
|
+
import { join as join2, relative } from "path";
|
|
11248
11248
|
function getDataDir(serviceName) {
|
|
11249
|
-
const dir =
|
|
11249
|
+
const dir = join2(HASNA_DIR, serviceName);
|
|
11250
11250
|
mkdirSync(dir, { recursive: true });
|
|
11251
11251
|
return dir;
|
|
11252
11252
|
}
|
|
11253
11253
|
function getDbPath(serviceName) {
|
|
11254
11254
|
const dir = getDataDir(serviceName);
|
|
11255
|
-
return
|
|
11255
|
+
return join2(dir, `${serviceName}.db`);
|
|
11256
11256
|
}
|
|
11257
11257
|
function migrateDotfile(serviceName) {
|
|
11258
|
-
const legacyDir =
|
|
11259
|
-
const newDir =
|
|
11260
|
-
if (!
|
|
11258
|
+
const legacyDir = join2(homedir2(), `.${serviceName}`);
|
|
11259
|
+
const newDir = join2(HASNA_DIR, serviceName);
|
|
11260
|
+
if (!existsSync2(legacyDir))
|
|
11261
11261
|
return [];
|
|
11262
|
-
if (
|
|
11262
|
+
if (existsSync2(newDir))
|
|
11263
11263
|
return [];
|
|
11264
11264
|
mkdirSync(newDir, { recursive: true });
|
|
11265
11265
|
const migrated = [];
|
|
@@ -11269,8 +11269,8 @@ function migrateDotfile(serviceName) {
|
|
|
11269
11269
|
function copyDirRecursive(src, dest, root, migrated) {
|
|
11270
11270
|
const entries = readdirSync(src, { withFileTypes: true });
|
|
11271
11271
|
for (const entry of entries) {
|
|
11272
|
-
const srcPath =
|
|
11273
|
-
const destPath =
|
|
11272
|
+
const srcPath = join2(src, entry.name);
|
|
11273
|
+
const destPath = join2(dest, entry.name);
|
|
11274
11274
|
if (entry.isDirectory()) {
|
|
11275
11275
|
mkdirSync(destPath, { recursive: true });
|
|
11276
11276
|
copyDirRecursive(srcPath, destPath, root, migrated);
|
|
@@ -11286,7 +11286,7 @@ function getHasnaDir() {
|
|
|
11286
11286
|
}
|
|
11287
11287
|
var HASNA_DIR;
|
|
11288
11288
|
var init_dotfile = __esm(() => {
|
|
11289
|
-
HASNA_DIR =
|
|
11289
|
+
HASNA_DIR = join2(homedir2(), ".hasna");
|
|
11290
11290
|
});
|
|
11291
11291
|
|
|
11292
11292
|
// src/discover.ts
|
|
@@ -11299,15 +11299,15 @@ __export(exports_discover, {
|
|
|
11299
11299
|
SYNC_EXCLUDED_TABLE_PATTERNS: () => SYNC_EXCLUDED_TABLE_PATTERNS,
|
|
11300
11300
|
KNOWN_PG_SERVICES: () => KNOWN_PG_SERVICES
|
|
11301
11301
|
});
|
|
11302
|
-
import { readdirSync as readdirSync2, existsSync as
|
|
11303
|
-
import { join as
|
|
11304
|
-
import { homedir as
|
|
11302
|
+
import { readdirSync as readdirSync2, existsSync as existsSync3 } from "fs";
|
|
11303
|
+
import { join as join3 } from "path";
|
|
11304
|
+
import { homedir as homedir3 } from "os";
|
|
11305
11305
|
function isSyncExcludedTable(table) {
|
|
11306
11306
|
return SYNC_EXCLUDED_TABLE_PATTERNS.some((p) => p.test(table));
|
|
11307
11307
|
}
|
|
11308
11308
|
function discoverServices() {
|
|
11309
|
-
const dataDir =
|
|
11310
|
-
if (!
|
|
11309
|
+
const dataDir = join3(homedir3(), ".hasna");
|
|
11310
|
+
if (!existsSync3(dataDir))
|
|
11311
11311
|
return [];
|
|
11312
11312
|
try {
|
|
11313
11313
|
const entries = readdirSync2(dataDir, { withFileTypes: true });
|
|
@@ -11328,24 +11328,24 @@ function discoverSyncableServices() {
|
|
|
11328
11328
|
return local.filter((s) => pgSet.has(s));
|
|
11329
11329
|
}
|
|
11330
11330
|
function getServiceDbPath(service) {
|
|
11331
|
-
const dataDir =
|
|
11332
|
-
if (!
|
|
11331
|
+
const dataDir = join3(homedir3(), ".hasna", service);
|
|
11332
|
+
if (!existsSync3(dataDir))
|
|
11333
11333
|
return null;
|
|
11334
11334
|
const candidates = [
|
|
11335
|
-
|
|
11336
|
-
|
|
11337
|
-
|
|
11335
|
+
join3(dataDir, `${service}.db`),
|
|
11336
|
+
join3(dataDir, "data.db"),
|
|
11337
|
+
join3(dataDir, "database.db")
|
|
11338
11338
|
];
|
|
11339
11339
|
try {
|
|
11340
11340
|
const files = readdirSync2(dataDir);
|
|
11341
11341
|
for (const f of files) {
|
|
11342
11342
|
if (f.endsWith(".db") && !f.endsWith("-wal") && !f.endsWith("-shm")) {
|
|
11343
|
-
candidates.push(
|
|
11343
|
+
candidates.push(join3(dataDir, f));
|
|
11344
11344
|
}
|
|
11345
11345
|
}
|
|
11346
11346
|
} catch {}
|
|
11347
11347
|
for (const p of candidates) {
|
|
11348
|
-
if (
|
|
11348
|
+
if (existsSync3(p))
|
|
11349
11349
|
return p;
|
|
11350
11350
|
}
|
|
11351
11351
|
return null;
|
|
@@ -11400,9 +11400,9 @@ var init_discover = __esm(() => {
|
|
|
11400
11400
|
|
|
11401
11401
|
// src/machines.ts
|
|
11402
11402
|
import { spawnSync } from "child_process";
|
|
11403
|
-
import { existsSync as
|
|
11404
|
-
import { homedir as
|
|
11405
|
-
import { dirname, join as
|
|
11403
|
+
import { existsSync as existsSync4 } from "fs";
|
|
11404
|
+
import { homedir as homedir4, hostname, platform, arch, userInfo } from "os";
|
|
11405
|
+
import { dirname, join as join4 } from "path";
|
|
11406
11406
|
function quoteSqlString(value) {
|
|
11407
11407
|
return `'${value.replace(/'/g, "''")}'`;
|
|
11408
11408
|
}
|
|
@@ -11414,10 +11414,10 @@ function normalizePlatform(value) {
|
|
|
11414
11414
|
return value;
|
|
11415
11415
|
}
|
|
11416
11416
|
function detectWorkspacePath() {
|
|
11417
|
-
const home =
|
|
11418
|
-
const candidates = [
|
|
11417
|
+
const home = homedir4();
|
|
11418
|
+
const candidates = [join4(home, "workspace"), join4(home, "Workspace")];
|
|
11419
11419
|
for (const candidate of candidates) {
|
|
11420
|
-
if (
|
|
11420
|
+
if (existsSync4(candidate))
|
|
11421
11421
|
return candidate;
|
|
11422
11422
|
}
|
|
11423
11423
|
const cwd = process.cwd();
|
|
@@ -11470,7 +11470,7 @@ function registerMachine(db, opts = {}) {
|
|
|
11470
11470
|
ensureMachinesTable(db);
|
|
11471
11471
|
const detected = detectCurrentMachine(opts);
|
|
11472
11472
|
const id = detected.id ?? getCurrentMachineId();
|
|
11473
|
-
const
|
|
11473
|
+
const now2 = new Date().toISOString();
|
|
11474
11474
|
const existing = getMachineRecord(db, id);
|
|
11475
11475
|
const isPrimary = toFlag(detected.is_primary, existing?.is_primary ?? 0);
|
|
11476
11476
|
const archived = toFlag(detected.archived, existing?.archived ?? 0);
|
|
@@ -11484,8 +11484,8 @@ function registerMachine(db, opts = {}) {
|
|
|
11484
11484
|
workspace_path: detected.workspace_path ?? existing?.workspace_path ?? "",
|
|
11485
11485
|
bun_path: detected.bun_path ?? existing?.bun_path ?? "",
|
|
11486
11486
|
is_primary: isPrimary,
|
|
11487
|
-
last_seen_at: detected.last_seen_at ??
|
|
11488
|
-
registered_at: existing?.registered_at ?? detected.registered_at ??
|
|
11487
|
+
last_seen_at: detected.last_seen_at ?? now2,
|
|
11488
|
+
registered_at: existing?.registered_at ?? detected.registered_at ?? now2,
|
|
11489
11489
|
archived
|
|
11490
11490
|
};
|
|
11491
11491
|
db.run(`INSERT INTO machines (
|
|
@@ -11814,9 +11814,9 @@ __export(exports_config, {
|
|
|
11814
11814
|
createDatabase: () => createDatabase,
|
|
11815
11815
|
CloudConfigSchema: () => CloudConfigSchema
|
|
11816
11816
|
});
|
|
11817
|
-
import { existsSync as
|
|
11818
|
-
import { homedir as
|
|
11819
|
-
import { join as
|
|
11817
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
|
|
11818
|
+
import { homedir as homedir5 } from "os";
|
|
11819
|
+
import { join as join5 } from "path";
|
|
11820
11820
|
function getConfigDir() {
|
|
11821
11821
|
return CONFIG_DIR;
|
|
11822
11822
|
}
|
|
@@ -11824,7 +11824,7 @@ function getConfigPath() {
|
|
|
11824
11824
|
return CONFIG_PATH;
|
|
11825
11825
|
}
|
|
11826
11826
|
function getCloudConfig() {
|
|
11827
|
-
if (!
|
|
11827
|
+
if (!existsSync5(CONFIG_PATH)) {
|
|
11828
11828
|
return CloudConfigSchema.parse({});
|
|
11829
11829
|
}
|
|
11830
11830
|
try {
|
|
@@ -11899,8 +11899,8 @@ var init_config = __esm(() => {
|
|
|
11899
11899
|
}).default({}),
|
|
11900
11900
|
daemon: DaemonConfigSchema
|
|
11901
11901
|
});
|
|
11902
|
-
CONFIG_DIR =
|
|
11903
|
-
CONFIG_PATH =
|
|
11902
|
+
CONFIG_DIR = join5(homedir5(), ".hasna", "cloud");
|
|
11903
|
+
CONFIG_PATH = join5(CONFIG_DIR, "config.json");
|
|
11904
11904
|
});
|
|
11905
11905
|
|
|
11906
11906
|
// node_modules/commander/esm.mjs
|
|
@@ -11919,6 +11919,690 @@ var {
|
|
|
11919
11919
|
Help
|
|
11920
11920
|
} = import__.default;
|
|
11921
11921
|
|
|
11922
|
+
// node_modules/@hasna/events/dist/commander.js
|
|
11923
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
11924
|
+
import { existsSync } from "fs";
|
|
11925
|
+
import { homedir } from "os";
|
|
11926
|
+
import { join } from "path";
|
|
11927
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
11928
|
+
import { randomUUID } from "crypto";
|
|
11929
|
+
import { spawn } from "child_process";
|
|
11930
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
11931
|
+
function getPathValue(input, path) {
|
|
11932
|
+
return path.split(".").reduce((value, part) => {
|
|
11933
|
+
if (value && typeof value === "object" && part in value) {
|
|
11934
|
+
return value[part];
|
|
11935
|
+
}
|
|
11936
|
+
return;
|
|
11937
|
+
}, input);
|
|
11938
|
+
}
|
|
11939
|
+
function wildcardToRegExp(pattern) {
|
|
11940
|
+
const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
|
|
11941
|
+
return new RegExp(`^${escaped}$`);
|
|
11942
|
+
}
|
|
11943
|
+
function matchString(value, matcher) {
|
|
11944
|
+
if (matcher === undefined)
|
|
11945
|
+
return true;
|
|
11946
|
+
if (value === undefined)
|
|
11947
|
+
return false;
|
|
11948
|
+
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
11949
|
+
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
11950
|
+
}
|
|
11951
|
+
function matchRecord(input, matcher) {
|
|
11952
|
+
if (!matcher)
|
|
11953
|
+
return true;
|
|
11954
|
+
return Object.entries(matcher).every(([path, expected]) => {
|
|
11955
|
+
const actual = getPathValue(input, path);
|
|
11956
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
11957
|
+
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
11958
|
+
}
|
|
11959
|
+
return actual === expected;
|
|
11960
|
+
});
|
|
11961
|
+
}
|
|
11962
|
+
function eventMatchesFilter(event, filter) {
|
|
11963
|
+
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
11964
|
+
}
|
|
11965
|
+
function channelMatchesEvent(channel, event) {
|
|
11966
|
+
if (!channel.enabled)
|
|
11967
|
+
return false;
|
|
11968
|
+
if (!channel.filters || channel.filters.length === 0)
|
|
11969
|
+
return true;
|
|
11970
|
+
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
11971
|
+
}
|
|
11972
|
+
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
11973
|
+
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
11974
|
+
function getEventsDataDir(override) {
|
|
11975
|
+
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
11976
|
+
}
|
|
11977
|
+
|
|
11978
|
+
class JsonEventsStore {
|
|
11979
|
+
dataDir;
|
|
11980
|
+
channelsPath;
|
|
11981
|
+
eventsPath;
|
|
11982
|
+
deliveriesPath;
|
|
11983
|
+
constructor(dataDir = getEventsDataDir()) {
|
|
11984
|
+
this.dataDir = dataDir;
|
|
11985
|
+
this.channelsPath = join(dataDir, "channels.json");
|
|
11986
|
+
this.eventsPath = join(dataDir, "events.json");
|
|
11987
|
+
this.deliveriesPath = join(dataDir, "deliveries.json");
|
|
11988
|
+
}
|
|
11989
|
+
async init() {
|
|
11990
|
+
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
11991
|
+
await chmod(this.dataDir, 448).catch(() => {
|
|
11992
|
+
return;
|
|
11993
|
+
});
|
|
11994
|
+
await this.ensureArrayFile(this.channelsPath);
|
|
11995
|
+
await this.ensureArrayFile(this.eventsPath);
|
|
11996
|
+
await this.ensureArrayFile(this.deliveriesPath);
|
|
11997
|
+
}
|
|
11998
|
+
async addChannel(channel) {
|
|
11999
|
+
await this.init();
|
|
12000
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
12001
|
+
const index = channels.findIndex((item) => item.id === channel.id);
|
|
12002
|
+
if (index >= 0) {
|
|
12003
|
+
channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
|
|
12004
|
+
} else {
|
|
12005
|
+
channels.push(channel);
|
|
12006
|
+
}
|
|
12007
|
+
await this.writeJson(this.channelsPath, channels);
|
|
12008
|
+
return index >= 0 ? channels[index] : channel;
|
|
12009
|
+
}
|
|
12010
|
+
async listChannels() {
|
|
12011
|
+
await this.init();
|
|
12012
|
+
return this.readJson(this.channelsPath, []);
|
|
12013
|
+
}
|
|
12014
|
+
async getChannel(id) {
|
|
12015
|
+
const channels = await this.listChannels();
|
|
12016
|
+
return channels.find((channel) => channel.id === id);
|
|
12017
|
+
}
|
|
12018
|
+
async removeChannel(id) {
|
|
12019
|
+
await this.init();
|
|
12020
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
12021
|
+
const next = channels.filter((channel) => channel.id !== id);
|
|
12022
|
+
await this.writeJson(this.channelsPath, next);
|
|
12023
|
+
return next.length !== channels.length;
|
|
12024
|
+
}
|
|
12025
|
+
async appendEvent(event) {
|
|
12026
|
+
await this.init();
|
|
12027
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
12028
|
+
events.push(event);
|
|
12029
|
+
await this.writeJson(this.eventsPath, events);
|
|
12030
|
+
return event;
|
|
12031
|
+
}
|
|
12032
|
+
async listEvents() {
|
|
12033
|
+
await this.init();
|
|
12034
|
+
return this.readJson(this.eventsPath, []);
|
|
12035
|
+
}
|
|
12036
|
+
async findEventByIdentity(identity) {
|
|
12037
|
+
const events = await this.listEvents();
|
|
12038
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
12039
|
+
}
|
|
12040
|
+
async appendDelivery(result) {
|
|
12041
|
+
await this.init();
|
|
12042
|
+
const deliveries = await this.readJson(this.deliveriesPath, []);
|
|
12043
|
+
deliveries.push(result);
|
|
12044
|
+
await this.writeJson(this.deliveriesPath, deliveries);
|
|
12045
|
+
return result;
|
|
12046
|
+
}
|
|
12047
|
+
async listDeliveries() {
|
|
12048
|
+
await this.init();
|
|
12049
|
+
return this.readJson(this.deliveriesPath, []);
|
|
12050
|
+
}
|
|
12051
|
+
async exportData() {
|
|
12052
|
+
return {
|
|
12053
|
+
channels: await this.listChannels(),
|
|
12054
|
+
events: await this.listEvents(),
|
|
12055
|
+
deliveries: await this.listDeliveries()
|
|
12056
|
+
};
|
|
12057
|
+
}
|
|
12058
|
+
async ensureArrayFile(path) {
|
|
12059
|
+
if (!existsSync(path)) {
|
|
12060
|
+
await writeFile(path, `[]
|
|
12061
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
12062
|
+
}
|
|
12063
|
+
await chmod(path, 384).catch(() => {
|
|
12064
|
+
return;
|
|
12065
|
+
});
|
|
12066
|
+
}
|
|
12067
|
+
async readJson(path, fallback) {
|
|
12068
|
+
try {
|
|
12069
|
+
const raw = await readFile(path, "utf-8");
|
|
12070
|
+
if (!raw.trim())
|
|
12071
|
+
return fallback;
|
|
12072
|
+
return JSON.parse(raw);
|
|
12073
|
+
} catch (error) {
|
|
12074
|
+
if (error.code === "ENOENT")
|
|
12075
|
+
return fallback;
|
|
12076
|
+
throw error;
|
|
12077
|
+
}
|
|
12078
|
+
}
|
|
12079
|
+
async writeJson(path, value) {
|
|
12080
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
12081
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
12082
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
12083
|
+
await rename(tempPath, path);
|
|
12084
|
+
await chmod(path, 384).catch(() => {
|
|
12085
|
+
return;
|
|
12086
|
+
});
|
|
12087
|
+
}
|
|
12088
|
+
}
|
|
12089
|
+
var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
12090
|
+
function buildSignatureBase(timestamp, body) {
|
|
12091
|
+
return `${timestamp}.${body}`;
|
|
12092
|
+
}
|
|
12093
|
+
function signPayload(secret, timestamp, body) {
|
|
12094
|
+
const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
|
|
12095
|
+
return `sha256=${digest}`;
|
|
12096
|
+
}
|
|
12097
|
+
function now() {
|
|
12098
|
+
return new Date().toISOString();
|
|
12099
|
+
}
|
|
12100
|
+
function truncate(value, max = 4096) {
|
|
12101
|
+
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
12102
|
+
}
|
|
12103
|
+
function buildWebhookRequest(event, channel) {
|
|
12104
|
+
if (!channel.webhook)
|
|
12105
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
12106
|
+
const body = JSON.stringify(event);
|
|
12107
|
+
const timestamp = event.time;
|
|
12108
|
+
const headers = {
|
|
12109
|
+
"Content-Type": "application/json",
|
|
12110
|
+
"User-Agent": "@hasna/events",
|
|
12111
|
+
"X-Hasna-Event-Id": event.id,
|
|
12112
|
+
"X-Hasna-Event-Type": event.type,
|
|
12113
|
+
"X-Hasna-Timestamp": timestamp,
|
|
12114
|
+
...channel.webhook.headers
|
|
12115
|
+
};
|
|
12116
|
+
if (channel.webhook.secret) {
|
|
12117
|
+
headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
|
|
12118
|
+
}
|
|
12119
|
+
return { body, headers };
|
|
12120
|
+
}
|
|
12121
|
+
async function dispatchWebhook(event, channel, options = {}) {
|
|
12122
|
+
if (!channel.webhook)
|
|
12123
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
12124
|
+
const startedAt = now();
|
|
12125
|
+
const { body, headers } = buildWebhookRequest(event, channel);
|
|
12126
|
+
const controller = new AbortController;
|
|
12127
|
+
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
12128
|
+
try {
|
|
12129
|
+
const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
|
|
12130
|
+
method: "POST",
|
|
12131
|
+
headers,
|
|
12132
|
+
body,
|
|
12133
|
+
signal: controller.signal
|
|
12134
|
+
});
|
|
12135
|
+
const responseBody = truncate(await response.text());
|
|
12136
|
+
return {
|
|
12137
|
+
attempt: 1,
|
|
12138
|
+
status: response.ok ? "success" : "failed",
|
|
12139
|
+
startedAt,
|
|
12140
|
+
completedAt: now(),
|
|
12141
|
+
responseStatus: response.status,
|
|
12142
|
+
responseBody,
|
|
12143
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
12144
|
+
};
|
|
12145
|
+
} catch (error) {
|
|
12146
|
+
return {
|
|
12147
|
+
attempt: 1,
|
|
12148
|
+
status: "failed",
|
|
12149
|
+
startedAt,
|
|
12150
|
+
completedAt: now(),
|
|
12151
|
+
error: error instanceof Error ? error.message : String(error)
|
|
12152
|
+
};
|
|
12153
|
+
} finally {
|
|
12154
|
+
clearTimeout(timeout);
|
|
12155
|
+
}
|
|
12156
|
+
}
|
|
12157
|
+
async function dispatchCommand(event, channel) {
|
|
12158
|
+
if (!channel.command)
|
|
12159
|
+
throw new Error(`Channel ${channel.id} has no command config`);
|
|
12160
|
+
const startedAt = now();
|
|
12161
|
+
const eventJson = JSON.stringify(event);
|
|
12162
|
+
const env = {
|
|
12163
|
+
...process.env,
|
|
12164
|
+
...channel.command.env,
|
|
12165
|
+
HASNA_CHANNEL_ID: channel.id,
|
|
12166
|
+
HASNA_EVENT_ID: event.id,
|
|
12167
|
+
HASNA_EVENT_TYPE: event.type,
|
|
12168
|
+
HASNA_EVENT_SOURCE: event.source,
|
|
12169
|
+
HASNA_EVENT_SUBJECT: event.subject ?? "",
|
|
12170
|
+
HASNA_EVENT_SEVERITY: event.severity,
|
|
12171
|
+
HASNA_EVENT_TIME: event.time,
|
|
12172
|
+
HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
|
|
12173
|
+
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
12174
|
+
HASNA_EVENT_JSON: eventJson
|
|
12175
|
+
};
|
|
12176
|
+
return new Promise((resolve) => {
|
|
12177
|
+
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
12178
|
+
cwd: channel.command.cwd,
|
|
12179
|
+
env,
|
|
12180
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
12181
|
+
});
|
|
12182
|
+
let stdout = "";
|
|
12183
|
+
let stderr = "";
|
|
12184
|
+
const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
|
|
12185
|
+
child.stdin.end(eventJson);
|
|
12186
|
+
child.stdout.on("data", (chunk) => {
|
|
12187
|
+
stdout += chunk.toString();
|
|
12188
|
+
});
|
|
12189
|
+
child.stderr.on("data", (chunk) => {
|
|
12190
|
+
stderr += chunk.toString();
|
|
12191
|
+
});
|
|
12192
|
+
child.on("error", (error) => {
|
|
12193
|
+
clearTimeout(timeout);
|
|
12194
|
+
resolve({
|
|
12195
|
+
attempt: 1,
|
|
12196
|
+
status: "failed",
|
|
12197
|
+
startedAt,
|
|
12198
|
+
completedAt: now(),
|
|
12199
|
+
stdout: truncate(stdout),
|
|
12200
|
+
stderr: truncate(stderr),
|
|
12201
|
+
error: error.message
|
|
12202
|
+
});
|
|
12203
|
+
});
|
|
12204
|
+
child.on("close", (code, signal) => {
|
|
12205
|
+
clearTimeout(timeout);
|
|
12206
|
+
const success = code === 0;
|
|
12207
|
+
resolve({
|
|
12208
|
+
attempt: 1,
|
|
12209
|
+
status: success ? "success" : "failed",
|
|
12210
|
+
startedAt,
|
|
12211
|
+
completedAt: now(),
|
|
12212
|
+
stdout: truncate(stdout),
|
|
12213
|
+
stderr: truncate(stderr),
|
|
12214
|
+
error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
|
12215
|
+
});
|
|
12216
|
+
});
|
|
12217
|
+
});
|
|
12218
|
+
}
|
|
12219
|
+
async function dispatchChannel(event, channel, options = {}) {
|
|
12220
|
+
if (channel.transport === "webhook")
|
|
12221
|
+
return dispatchWebhook(event, channel, options);
|
|
12222
|
+
if (channel.transport === "command")
|
|
12223
|
+
return dispatchCommand(event, channel);
|
|
12224
|
+
return {
|
|
12225
|
+
attempt: 1,
|
|
12226
|
+
status: "skipped",
|
|
12227
|
+
startedAt: now(),
|
|
12228
|
+
completedAt: now(),
|
|
12229
|
+
error: `Unsupported transport: ${channel.transport}`
|
|
12230
|
+
};
|
|
12231
|
+
}
|
|
12232
|
+
function createDeliveryResult(event, channel, attempts) {
|
|
12233
|
+
const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
|
|
12234
|
+
return {
|
|
12235
|
+
id: randomUUID(),
|
|
12236
|
+
eventId: event.id,
|
|
12237
|
+
channelId: channel.id,
|
|
12238
|
+
transport: channel.transport,
|
|
12239
|
+
status,
|
|
12240
|
+
attempts,
|
|
12241
|
+
createdAt: attempts[0]?.startedAt ?? now(),
|
|
12242
|
+
completedAt: attempts.at(-1)?.completedAt ?? now()
|
|
12243
|
+
};
|
|
12244
|
+
}
|
|
12245
|
+
function createEvent(input) {
|
|
12246
|
+
return {
|
|
12247
|
+
id: input.id ?? randomUUID2(),
|
|
12248
|
+
source: input.source,
|
|
12249
|
+
type: input.type,
|
|
12250
|
+
time: normalizeTime(input.time),
|
|
12251
|
+
subject: input.subject,
|
|
12252
|
+
severity: input.severity ?? "info",
|
|
12253
|
+
data: input.data ?? {},
|
|
12254
|
+
message: input.message,
|
|
12255
|
+
dedupeKey: input.dedupeKey,
|
|
12256
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
12257
|
+
metadata: input.metadata ?? {}
|
|
12258
|
+
};
|
|
12259
|
+
}
|
|
12260
|
+
|
|
12261
|
+
class EventsClient {
|
|
12262
|
+
store;
|
|
12263
|
+
redactors;
|
|
12264
|
+
transportOptions;
|
|
12265
|
+
constructor(options = {}) {
|
|
12266
|
+
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
12267
|
+
this.redactors = options.redactors ?? [];
|
|
12268
|
+
this.transportOptions = { fetchImpl: options.fetchImpl };
|
|
12269
|
+
}
|
|
12270
|
+
async addChannel(input) {
|
|
12271
|
+
const timestamp = new Date().toISOString();
|
|
12272
|
+
return this.store.addChannel({
|
|
12273
|
+
...input,
|
|
12274
|
+
createdAt: input.createdAt ?? timestamp,
|
|
12275
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
12276
|
+
});
|
|
12277
|
+
}
|
|
12278
|
+
async listChannels() {
|
|
12279
|
+
return this.store.listChannels();
|
|
12280
|
+
}
|
|
12281
|
+
async removeChannel(id) {
|
|
12282
|
+
return this.store.removeChannel(id);
|
|
12283
|
+
}
|
|
12284
|
+
async emit(input, options = {}) {
|
|
12285
|
+
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
12286
|
+
if (options.dedupe !== false) {
|
|
12287
|
+
const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
|
|
12288
|
+
if (existing) {
|
|
12289
|
+
return { event: existing, deliveries: [], deduped: true };
|
|
12290
|
+
}
|
|
12291
|
+
}
|
|
12292
|
+
await this.store.appendEvent(event);
|
|
12293
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
12294
|
+
return { event, deliveries, deduped: false };
|
|
12295
|
+
}
|
|
12296
|
+
async listEvents() {
|
|
12297
|
+
return this.store.listEvents();
|
|
12298
|
+
}
|
|
12299
|
+
async listDeliveries() {
|
|
12300
|
+
return this.store.listDeliveries();
|
|
12301
|
+
}
|
|
12302
|
+
async deliver(event) {
|
|
12303
|
+
const channels = await this.store.listChannels();
|
|
12304
|
+
const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
|
|
12305
|
+
const deliveries = [];
|
|
12306
|
+
for (const channel of selected) {
|
|
12307
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
12308
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
12309
|
+
await this.store.appendDelivery(result);
|
|
12310
|
+
deliveries.push(result);
|
|
12311
|
+
}
|
|
12312
|
+
return deliveries;
|
|
12313
|
+
}
|
|
12314
|
+
async testChannel(id, input = {}) {
|
|
12315
|
+
const channel = await this.store.getChannel(id);
|
|
12316
|
+
if (!channel)
|
|
12317
|
+
throw new Error(`Channel not found: ${id}`);
|
|
12318
|
+
const event = createEvent({
|
|
12319
|
+
source: input.source ?? "hasna.events",
|
|
12320
|
+
type: input.type ?? "events.test",
|
|
12321
|
+
subject: input.subject ?? id,
|
|
12322
|
+
severity: input.severity ?? "info",
|
|
12323
|
+
data: input.data ?? { test: true },
|
|
12324
|
+
message: input.message ?? "Hasna events test delivery",
|
|
12325
|
+
dedupeKey: input.dedupeKey,
|
|
12326
|
+
schemaVersion: input.schemaVersion,
|
|
12327
|
+
metadata: input.metadata,
|
|
12328
|
+
time: input.time,
|
|
12329
|
+
id: input.id
|
|
12330
|
+
});
|
|
12331
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
12332
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
12333
|
+
await this.store.appendDelivery(result);
|
|
12334
|
+
return result;
|
|
12335
|
+
}
|
|
12336
|
+
async replay(options = {}) {
|
|
12337
|
+
const events = (await this.store.listEvents()).filter((event) => {
|
|
12338
|
+
if (options.eventId && event.id !== options.eventId)
|
|
12339
|
+
return false;
|
|
12340
|
+
if (options.source && event.source !== options.source)
|
|
12341
|
+
return false;
|
|
12342
|
+
if (options.type && event.type !== options.type)
|
|
12343
|
+
return false;
|
|
12344
|
+
return true;
|
|
12345
|
+
});
|
|
12346
|
+
if (options.dryRun)
|
|
12347
|
+
return { events, deliveries: [] };
|
|
12348
|
+
const deliveries = [];
|
|
12349
|
+
for (const event of events) {
|
|
12350
|
+
deliveries.push(...await this.deliver(event));
|
|
12351
|
+
}
|
|
12352
|
+
return { events, deliveries };
|
|
12353
|
+
}
|
|
12354
|
+
async applyRedaction(event, channel) {
|
|
12355
|
+
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
12356
|
+
for (const redactor of this.redactors) {
|
|
12357
|
+
next = await redactor(next, channel);
|
|
12358
|
+
}
|
|
12359
|
+
return next;
|
|
12360
|
+
}
|
|
12361
|
+
async deliverWithRetry(event, channel) {
|
|
12362
|
+
const policy = normalizeRetryPolicy(channel.retry);
|
|
12363
|
+
const attempts = [];
|
|
12364
|
+
for (let index = 0;index < policy.maxAttempts; index += 1) {
|
|
12365
|
+
const attempt = await dispatchChannel(event, channel, this.transportOptions);
|
|
12366
|
+
attempt.attempt = index + 1;
|
|
12367
|
+
if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
|
|
12368
|
+
attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
|
|
12369
|
+
}
|
|
12370
|
+
attempts.push(attempt);
|
|
12371
|
+
if (attempt.status !== "failed")
|
|
12372
|
+
break;
|
|
12373
|
+
if (attempt.nextBackoffMs)
|
|
12374
|
+
await Bun.sleep(attempt.nextBackoffMs);
|
|
12375
|
+
}
|
|
12376
|
+
return createDeliveryResult(event, channel, attempts);
|
|
12377
|
+
}
|
|
12378
|
+
}
|
|
12379
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
12380
|
+
if (paths.length === 0)
|
|
12381
|
+
return event;
|
|
12382
|
+
const copy = structuredClone(event);
|
|
12383
|
+
for (const path of paths) {
|
|
12384
|
+
setPath(copy, path, replacement);
|
|
12385
|
+
}
|
|
12386
|
+
return copy;
|
|
12387
|
+
}
|
|
12388
|
+
function sanitizeChannelForOutput(channel) {
|
|
12389
|
+
const copy = structuredClone(channel);
|
|
12390
|
+
if (copy.webhook?.secret)
|
|
12391
|
+
copy.webhook.secret = "[REDACTED]";
|
|
12392
|
+
if (copy.command?.env) {
|
|
12393
|
+
copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
|
|
12394
|
+
}
|
|
12395
|
+
return copy;
|
|
12396
|
+
}
|
|
12397
|
+
function sanitizeChannelsForOutput(channels) {
|
|
12398
|
+
return channels.map(sanitizeChannelForOutput);
|
|
12399
|
+
}
|
|
12400
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
12401
|
+
return redactValue(event, replacement);
|
|
12402
|
+
}
|
|
12403
|
+
function shouldRedactKey(key) {
|
|
12404
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
12405
|
+
}
|
|
12406
|
+
function redactValue(value, replacement) {
|
|
12407
|
+
if (Array.isArray(value))
|
|
12408
|
+
return value.map((item) => redactValue(item, replacement));
|
|
12409
|
+
if (!value || typeof value !== "object")
|
|
12410
|
+
return value;
|
|
12411
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
12412
|
+
key,
|
|
12413
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
12414
|
+
]));
|
|
12415
|
+
}
|
|
12416
|
+
function setPath(input, path, replacement) {
|
|
12417
|
+
const parts = path.split(".");
|
|
12418
|
+
let cursor = input;
|
|
12419
|
+
for (const part of parts.slice(0, -1)) {
|
|
12420
|
+
const next = cursor[part];
|
|
12421
|
+
if (!next || typeof next !== "object")
|
|
12422
|
+
return;
|
|
12423
|
+
cursor = next;
|
|
12424
|
+
}
|
|
12425
|
+
const last = parts.at(-1);
|
|
12426
|
+
if (last && last in cursor)
|
|
12427
|
+
cursor[last] = replacement;
|
|
12428
|
+
}
|
|
12429
|
+
function normalizeTime(value) {
|
|
12430
|
+
if (!value)
|
|
12431
|
+
return new Date().toISOString();
|
|
12432
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
12433
|
+
}
|
|
12434
|
+
function normalizeRetryPolicy(policy) {
|
|
12435
|
+
return {
|
|
12436
|
+
maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
|
|
12437
|
+
backoffMs: Math.max(0, policy?.backoffMs ?? 250),
|
|
12438
|
+
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
12439
|
+
};
|
|
12440
|
+
}
|
|
12441
|
+
function parseJsonObject(value, fallback) {
|
|
12442
|
+
if (!value)
|
|
12443
|
+
return fallback;
|
|
12444
|
+
const parsed = JSON.parse(value);
|
|
12445
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
12446
|
+
throw new Error("Expected a JSON object");
|
|
12447
|
+
}
|
|
12448
|
+
return parsed;
|
|
12449
|
+
}
|
|
12450
|
+
function parseHeaders(values) {
|
|
12451
|
+
if (!values?.length)
|
|
12452
|
+
return;
|
|
12453
|
+
const headers = {};
|
|
12454
|
+
for (const value of values) {
|
|
12455
|
+
const separator = value.indexOf("=");
|
|
12456
|
+
if (separator === -1)
|
|
12457
|
+
throw new Error(`Invalid header, expected name=value: ${value}`);
|
|
12458
|
+
headers[value.slice(0, separator)] = value.slice(separator + 1);
|
|
12459
|
+
}
|
|
12460
|
+
return headers;
|
|
12461
|
+
}
|
|
12462
|
+
function parseFilter(options) {
|
|
12463
|
+
const filter2 = {};
|
|
12464
|
+
if (options.source)
|
|
12465
|
+
filter2.source = options.source;
|
|
12466
|
+
if (options.type)
|
|
12467
|
+
filter2.type = options.type;
|
|
12468
|
+
if (options.subject)
|
|
12469
|
+
filter2.subject = options.subject;
|
|
12470
|
+
if (options.severity)
|
|
12471
|
+
filter2.severity = options.severity;
|
|
12472
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
12473
|
+
}
|
|
12474
|
+
function createClient(options) {
|
|
12475
|
+
if (options.createClient)
|
|
12476
|
+
return options.createClient();
|
|
12477
|
+
return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
|
|
12478
|
+
}
|
|
12479
|
+
function print(value, json, text) {
|
|
12480
|
+
if (json)
|
|
12481
|
+
console.log(JSON.stringify(value, null, 2));
|
|
12482
|
+
else
|
|
12483
|
+
console.log(text);
|
|
12484
|
+
}
|
|
12485
|
+
function hasJsonOption(options) {
|
|
12486
|
+
return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
|
|
12487
|
+
}
|
|
12488
|
+
function wantsJson(actionOptions, command) {
|
|
12489
|
+
return hasJsonOption(actionOptions) || hasJsonOption(command);
|
|
12490
|
+
}
|
|
12491
|
+
function registerWebhookCommands(program2, options) {
|
|
12492
|
+
const webhooks = program2.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
|
|
12493
|
+
webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
|
|
12494
|
+
const timestamp = new Date().toISOString();
|
|
12495
|
+
const channel = {
|
|
12496
|
+
id: actionOptions.id,
|
|
12497
|
+
name: actionOptions.name,
|
|
12498
|
+
enabled: !actionOptions.disabled,
|
|
12499
|
+
transport: actionOptions.transport,
|
|
12500
|
+
filters: parseFilter(actionOptions),
|
|
12501
|
+
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
12502
|
+
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
12503
|
+
createdAt: timestamp,
|
|
12504
|
+
updatedAt: timestamp
|
|
12505
|
+
};
|
|
12506
|
+
if (actionOptions.transport === "webhook") {
|
|
12507
|
+
channel.webhook = { url: target, secret: actionOptions.secret, headers: parseHeaders(actionOptions.header), timeoutMs: actionOptions.timeoutMs };
|
|
12508
|
+
} else if (actionOptions.transport === "command") {
|
|
12509
|
+
channel.command = { command: target, args: actionOptions.arg ?? [], timeoutMs: actionOptions.timeoutMs };
|
|
12510
|
+
} else {
|
|
12511
|
+
throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
|
|
12512
|
+
}
|
|
12513
|
+
const saved = await createClient(options).addChannel(channel);
|
|
12514
|
+
print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
|
|
12515
|
+
});
|
|
12516
|
+
webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
12517
|
+
const channels = await createClient(options).listChannels();
|
|
12518
|
+
if (wantsJson(actionOptions, command)) {
|
|
12519
|
+
console.log(JSON.stringify(sanitizeChannelsForOutput(channels), null, 2));
|
|
12520
|
+
return;
|
|
12521
|
+
}
|
|
12522
|
+
if (!channels.length) {
|
|
12523
|
+
console.log("No channels configured.");
|
|
12524
|
+
return;
|
|
12525
|
+
}
|
|
12526
|
+
for (const channel of channels) {
|
|
12527
|
+
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
12528
|
+
}
|
|
12529
|
+
});
|
|
12530
|
+
webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
12531
|
+
const removed = await createClient(options).removeChannel(id);
|
|
12532
|
+
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
12533
|
+
});
|
|
12534
|
+
webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
12535
|
+
const result = await createClient(options).testChannel(id, {
|
|
12536
|
+
source: options.source,
|
|
12537
|
+
type: actionOptions.type,
|
|
12538
|
+
subject: actionOptions.subject ?? id,
|
|
12539
|
+
message: actionOptions.message,
|
|
12540
|
+
data: parseJsonObject(actionOptions.data, { test: true })
|
|
12541
|
+
});
|
|
12542
|
+
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
12543
|
+
});
|
|
12544
|
+
return webhooks;
|
|
12545
|
+
}
|
|
12546
|
+
function registerEventCommands(program2, options) {
|
|
12547
|
+
const events = program2.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
|
|
12548
|
+
events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions, command) => {
|
|
12549
|
+
const result = await createClient(options).emit({
|
|
12550
|
+
source: actionOptions.source ?? options.source,
|
|
12551
|
+
type,
|
|
12552
|
+
subject: actionOptions.subject,
|
|
12553
|
+
severity: actionOptions.severity,
|
|
12554
|
+
message: actionOptions.message,
|
|
12555
|
+
dedupeKey: actionOptions.dedupeKey,
|
|
12556
|
+
data: parseJsonObject(actionOptions.data, {}),
|
|
12557
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
12558
|
+
}, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
|
|
12559
|
+
print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
|
|
12560
|
+
});
|
|
12561
|
+
events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
12562
|
+
let rows = await createClient(options).listEvents();
|
|
12563
|
+
if (actionOptions.source)
|
|
12564
|
+
rows = rows.filter((event) => event.source === actionOptions.source);
|
|
12565
|
+
if (actionOptions.type)
|
|
12566
|
+
rows = rows.filter((event) => event.type === actionOptions.type);
|
|
12567
|
+
if (actionOptions.limit)
|
|
12568
|
+
rows = rows.slice(-actionOptions.limit);
|
|
12569
|
+
if (wantsJson(actionOptions, command)) {
|
|
12570
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
12571
|
+
return;
|
|
12572
|
+
}
|
|
12573
|
+
if (!rows.length) {
|
|
12574
|
+
console.log("No events recorded.");
|
|
12575
|
+
return;
|
|
12576
|
+
}
|
|
12577
|
+
for (const event of rows)
|
|
12578
|
+
console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
|
|
12579
|
+
});
|
|
12580
|
+
events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
12581
|
+
const result = await createClient(options).replay({
|
|
12582
|
+
eventId: actionOptions.id,
|
|
12583
|
+
source: actionOptions.source,
|
|
12584
|
+
type: actionOptions.type,
|
|
12585
|
+
dryRun: actionOptions.dryRun
|
|
12586
|
+
});
|
|
12587
|
+
print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
|
|
12588
|
+
});
|
|
12589
|
+
return events;
|
|
12590
|
+
}
|
|
12591
|
+
function registerEventsCommands(program2, options) {
|
|
12592
|
+
registerWebhookCommands(program2, options);
|
|
12593
|
+
registerEventCommands(program2, options);
|
|
12594
|
+
}
|
|
12595
|
+
function parseNumber(value) {
|
|
12596
|
+
const parsed = Number(value);
|
|
12597
|
+
if (!Number.isFinite(parsed))
|
|
12598
|
+
throw new Error(`Expected a number, got ${value}`);
|
|
12599
|
+
return parsed;
|
|
12600
|
+
}
|
|
12601
|
+
function collectValues(value, previous) {
|
|
12602
|
+
previous.push(value);
|
|
12603
|
+
return previous;
|
|
12604
|
+
}
|
|
12605
|
+
|
|
11922
12606
|
// src/cli/cmd-setup.ts
|
|
11923
12607
|
init_config();
|
|
11924
12608
|
|
|
@@ -12510,11 +13194,11 @@ async function ensureAllPgDatabases() {
|
|
|
12510
13194
|
|
|
12511
13195
|
// src/sync-schedule.ts
|
|
12512
13196
|
init_config();
|
|
12513
|
-
import { join as
|
|
12514
|
-
import { existsSync as
|
|
12515
|
-
import { homedir as
|
|
13197
|
+
import { join as join6, dirname as dirname2 } from "path";
|
|
13198
|
+
import { existsSync as existsSync6, writeFileSync as writeFileSync2, unlinkSync, mkdirSync as mkdirSync3 } from "fs";
|
|
13199
|
+
import { homedir as homedir6, platform as platform2 } from "os";
|
|
12516
13200
|
var SERVICE_NAME = "hasna-cloud-sync";
|
|
12517
|
-
var CONFIG_DIR2 =
|
|
13201
|
+
var CONFIG_DIR2 = join6(homedir6(), ".hasna", "cloud");
|
|
12518
13202
|
function parseInterval(input) {
|
|
12519
13203
|
const trimmed = input.trim().toLowerCase();
|
|
12520
13204
|
const hourMatch = trimmed.match(/^(\d+)\s*h$/);
|
|
@@ -12555,34 +13239,34 @@ function minutesToCron(minutes) {
|
|
|
12555
13239
|
}
|
|
12556
13240
|
function getWorkerPath() {
|
|
12557
13241
|
const dir = typeof import.meta.dir === "string" ? import.meta.dir : dirname2(import.meta.url.replace("file://", ""));
|
|
12558
|
-
const tsPath =
|
|
12559
|
-
const jsPath =
|
|
13242
|
+
const tsPath = join6(dir, "scheduled-sync.ts");
|
|
13243
|
+
const jsPath = join6(dir, "scheduled-sync.js");
|
|
12560
13244
|
try {
|
|
12561
|
-
if (
|
|
13245
|
+
if (existsSync6(tsPath))
|
|
12562
13246
|
return tsPath;
|
|
12563
13247
|
} catch {}
|
|
12564
13248
|
return jsPath;
|
|
12565
13249
|
}
|
|
12566
13250
|
function getBunPath() {
|
|
12567
13251
|
const candidates = [
|
|
12568
|
-
|
|
13252
|
+
join6(homedir6(), ".bun", "bin", "bun"),
|
|
12569
13253
|
"/usr/local/bin/bun",
|
|
12570
13254
|
"/usr/bin/bun"
|
|
12571
13255
|
];
|
|
12572
13256
|
for (const p of candidates) {
|
|
12573
|
-
if (
|
|
13257
|
+
if (existsSync6(p))
|
|
12574
13258
|
return p;
|
|
12575
13259
|
}
|
|
12576
13260
|
return "bun";
|
|
12577
13261
|
}
|
|
12578
13262
|
function getLaunchdPlistPath() {
|
|
12579
|
-
return
|
|
13263
|
+
return join6(homedir6(), "Library", "LaunchAgents", `com.hasna.cloud-sync.plist`);
|
|
12580
13264
|
}
|
|
12581
13265
|
function createLaunchdPlist(intervalMinutes) {
|
|
12582
13266
|
const workerPath = getWorkerPath();
|
|
12583
13267
|
const bunPath = getBunPath();
|
|
12584
|
-
const logPath =
|
|
12585
|
-
const errorLogPath =
|
|
13268
|
+
const logPath = join6(CONFIG_DIR2, "sync.log");
|
|
13269
|
+
const errorLogPath = join6(CONFIG_DIR2, "sync-error.log");
|
|
12586
13270
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
12587
13271
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
12588
13272
|
<plist version="1.0">
|
|
@@ -12608,7 +13292,7 @@ function createLaunchdPlist(intervalMinutes) {
|
|
|
12608
13292
|
<key>PATH</key>
|
|
12609
13293
|
<string>${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}</string>
|
|
12610
13294
|
<key>HOME</key>
|
|
12611
|
-
<string>${
|
|
13295
|
+
<string>${homedir6()}</string>
|
|
12612
13296
|
</dict>
|
|
12613
13297
|
</dict>
|
|
12614
13298
|
</plist>`;
|
|
@@ -12633,7 +13317,7 @@ async function removeLaunchd() {
|
|
|
12633
13317
|
} catch {}
|
|
12634
13318
|
}
|
|
12635
13319
|
function getSystemdDir() {
|
|
12636
|
-
return
|
|
13320
|
+
return join6(homedir6(), ".config", "systemd", "user");
|
|
12637
13321
|
}
|
|
12638
13322
|
function createSystemdService() {
|
|
12639
13323
|
const workerPath = getWorkerPath();
|
|
@@ -12645,7 +13329,7 @@ After=network.target
|
|
|
12645
13329
|
[Service]
|
|
12646
13330
|
Type=oneshot
|
|
12647
13331
|
ExecStart=${bunPath} run ${workerPath}
|
|
12648
|
-
Environment=HOME=${
|
|
13332
|
+
Environment=HOME=${homedir6()}
|
|
12649
13333
|
Environment=PATH=${process.env.PATH || "/usr/local/bin:/usr/bin:/bin"}
|
|
12650
13334
|
|
|
12651
13335
|
[Install]
|
|
@@ -12668,8 +13352,8 @@ WantedBy=timers.target
|
|
|
12668
13352
|
async function registerSystemd(intervalMinutes) {
|
|
12669
13353
|
const dir = getSystemdDir();
|
|
12670
13354
|
mkdirSync3(dir, { recursive: true });
|
|
12671
|
-
writeFileSync2(
|
|
12672
|
-
writeFileSync2(
|
|
13355
|
+
writeFileSync2(join6(dir, `${SERVICE_NAME}.service`), createSystemdService());
|
|
13356
|
+
writeFileSync2(join6(dir, `${SERVICE_NAME}.timer`), createSystemdTimer(intervalMinutes));
|
|
12673
13357
|
await Bun.spawn(["systemctl", "--user", "daemon-reload"]).exited;
|
|
12674
13358
|
await Bun.spawn(["systemctl", "--user", "enable", "--now", `${SERVICE_NAME}.timer`]).exited;
|
|
12675
13359
|
}
|
|
@@ -12679,10 +13363,10 @@ async function removeSystemd() {
|
|
|
12679
13363
|
} catch {}
|
|
12680
13364
|
const dir = getSystemdDir();
|
|
12681
13365
|
try {
|
|
12682
|
-
unlinkSync(
|
|
13366
|
+
unlinkSync(join6(dir, `${SERVICE_NAME}.service`));
|
|
12683
13367
|
} catch {}
|
|
12684
13368
|
try {
|
|
12685
|
-
unlinkSync(
|
|
13369
|
+
unlinkSync(join6(dir, `${SERVICE_NAME}.timer`));
|
|
12686
13370
|
} catch {}
|
|
12687
13371
|
try {
|
|
12688
13372
|
await Bun.spawn(["systemctl", "--user", "daemon-reload"]).exited;
|
|
@@ -12719,9 +13403,9 @@ function getSyncScheduleStatus() {
|
|
|
12719
13403
|
let mechanism = "none";
|
|
12720
13404
|
if (registered) {
|
|
12721
13405
|
if (platform2() === "darwin") {
|
|
12722
|
-
mechanism =
|
|
13406
|
+
mechanism = existsSync6(getLaunchdPlistPath()) ? "launchd" : "none";
|
|
12723
13407
|
} else {
|
|
12724
|
-
mechanism =
|
|
13408
|
+
mechanism = existsSync6(join6(getSystemdDir(), `${SERVICE_NAME}.timer`)) ? "systemd" : "none";
|
|
12725
13409
|
}
|
|
12726
13410
|
}
|
|
12727
13411
|
return {
|
|
@@ -12913,8 +13597,8 @@ function purgeResolvedConflicts(db) {
|
|
|
12913
13597
|
// src/scheduled-sync.ts
|
|
12914
13598
|
init_config();
|
|
12915
13599
|
init_adapter();
|
|
12916
|
-
import { existsSync as
|
|
12917
|
-
import { join as
|
|
13600
|
+
import { existsSync as existsSync7, readdirSync as readdirSync3 } from "fs";
|
|
13601
|
+
import { join as join7 } from "path";
|
|
12918
13602
|
|
|
12919
13603
|
// src/sync-incremental.ts
|
|
12920
13604
|
init_machines();
|
|
@@ -13040,10 +13724,10 @@ function incrementalSyncPush(local, remote, tables, options = {}) {
|
|
|
13040
13724
|
if (rows.length === 0) {
|
|
13041
13725
|
stat.skipped_rows = stat.total_rows;
|
|
13042
13726
|
}
|
|
13043
|
-
const
|
|
13727
|
+
const now2 = new Date().toISOString();
|
|
13044
13728
|
upsertSyncMeta(local, {
|
|
13045
13729
|
table_name: table,
|
|
13046
|
-
last_synced_at:
|
|
13730
|
+
last_synced_at: now2,
|
|
13047
13731
|
last_synced_row_count: stat.synced_rows,
|
|
13048
13732
|
direction: "push"
|
|
13049
13733
|
});
|
|
@@ -13096,10 +13780,10 @@ function incrementalSyncPull(remote, local, tables, options = {}) {
|
|
|
13096
13780
|
if (rows.length === 0) {
|
|
13097
13781
|
stat.skipped_rows = stat.total_rows;
|
|
13098
13782
|
}
|
|
13099
|
-
const
|
|
13783
|
+
const now2 = new Date().toISOString();
|
|
13100
13784
|
upsertSyncMeta(local, {
|
|
13101
13785
|
table_name: table,
|
|
13102
|
-
last_synced_at:
|
|
13786
|
+
last_synced_at: now2,
|
|
13103
13787
|
last_synced_row_count: stat.synced_rows,
|
|
13104
13788
|
direction: "pull"
|
|
13105
13789
|
});
|
|
@@ -13121,8 +13805,8 @@ function discoverSyncableServices2() {
|
|
|
13121
13805
|
for (const entry of entries) {
|
|
13122
13806
|
if (!entry.isDirectory())
|
|
13123
13807
|
continue;
|
|
13124
|
-
const dbPath =
|
|
13125
|
-
if (
|
|
13808
|
+
const dbPath = join7(hasnaDir, entry.name, `${entry.name}.db`);
|
|
13809
|
+
if (existsSync7(dbPath)) {
|
|
13126
13810
|
services.push(entry.name);
|
|
13127
13811
|
}
|
|
13128
13812
|
}
|
|
@@ -13144,8 +13828,8 @@ async function runScheduledSync() {
|
|
|
13144
13828
|
errors: []
|
|
13145
13829
|
};
|
|
13146
13830
|
try {
|
|
13147
|
-
const dbPath =
|
|
13148
|
-
if (!
|
|
13831
|
+
const dbPath = join7(getDataDir(service), `${service}.db`);
|
|
13832
|
+
if (!existsSync7(dbPath)) {
|
|
13149
13833
|
continue;
|
|
13150
13834
|
}
|
|
13151
13835
|
const local = new SqliteAdapter(dbPath);
|
|
@@ -13188,26 +13872,26 @@ async function runScheduledSync() {
|
|
|
13188
13872
|
}
|
|
13189
13873
|
|
|
13190
13874
|
// src/cli/cmd-sync.ts
|
|
13191
|
-
import { existsSync as
|
|
13192
|
-
import { join as
|
|
13193
|
-
import { homedir as
|
|
13875
|
+
import { existsSync as existsSync9, statSync as statSync5 } from "fs";
|
|
13876
|
+
import { join as join9 } from "path";
|
|
13877
|
+
import { homedir as homedir8 } from "os";
|
|
13194
13878
|
|
|
13195
13879
|
// src/daemon-sync.ts
|
|
13196
13880
|
init_adapter();
|
|
13197
13881
|
init_config();
|
|
13198
13882
|
init_discover();
|
|
13199
13883
|
init_dotfile();
|
|
13200
|
-
import { spawn } from "child_process";
|
|
13884
|
+
import { spawn as spawn2 } from "child_process";
|
|
13201
13885
|
import {
|
|
13202
|
-
existsSync as
|
|
13886
|
+
existsSync as existsSync8,
|
|
13203
13887
|
mkdirSync as mkdirSync4,
|
|
13204
13888
|
readFileSync as readFileSync3,
|
|
13205
13889
|
statSync as statSync4,
|
|
13206
13890
|
writeFileSync as writeFileSync3
|
|
13207
13891
|
} from "fs";
|
|
13208
|
-
import { homedir as
|
|
13209
|
-
import { join as
|
|
13210
|
-
var DAEMON_STATE_PATH =
|
|
13892
|
+
import { homedir as homedir7 } from "os";
|
|
13893
|
+
import { join as join8 } from "path";
|
|
13894
|
+
var DAEMON_STATE_PATH = join8(homedir7(), ".hasna", "cloud", "daemon-state.json");
|
|
13211
13895
|
var defaultDaemonAdapterFactory = {
|
|
13212
13896
|
getLocalDbPath: (service) => getDbPath(service),
|
|
13213
13897
|
openLocal: (service) => new SqliteAdapter(getDbPath(service)),
|
|
@@ -13275,7 +13959,7 @@ function createDefaultDaemonState() {
|
|
|
13275
13959
|
};
|
|
13276
13960
|
}
|
|
13277
13961
|
function readDaemonState() {
|
|
13278
|
-
if (!
|
|
13962
|
+
if (!existsSync8(DAEMON_STATE_PATH)) {
|
|
13279
13963
|
return createDefaultDaemonState();
|
|
13280
13964
|
}
|
|
13281
13965
|
try {
|
|
@@ -13292,7 +13976,7 @@ function readDaemonState() {
|
|
|
13292
13976
|
}
|
|
13293
13977
|
}
|
|
13294
13978
|
function writeDaemonState(state) {
|
|
13295
|
-
mkdirSync4(
|
|
13979
|
+
mkdirSync4(join8(homedir7(), ".hasna", "cloud"), { recursive: true });
|
|
13296
13980
|
writeFileSync3(DAEMON_STATE_PATH, JSON.stringify(state, null, 2) + `
|
|
13297
13981
|
`, "utf-8");
|
|
13298
13982
|
}
|
|
@@ -13430,8 +14114,8 @@ function runDaemonPass(config, state = createDefaultDaemonState(), options = {})
|
|
|
13430
14114
|
const nextState = cloneState(state);
|
|
13431
14115
|
const adapterFactory = options.adapterFactory ?? defaultDaemonAdapterFactory;
|
|
13432
14116
|
const services = options.services ?? resolveDaemonServices(config);
|
|
13433
|
-
const
|
|
13434
|
-
const nowMs = Date.parse(
|
|
14117
|
+
const now2 = nowIso();
|
|
14118
|
+
const nowMs = Date.parse(now2);
|
|
13435
14119
|
const summary = {
|
|
13436
14120
|
services,
|
|
13437
14121
|
pushed_services: 0,
|
|
@@ -13440,7 +14124,7 @@ function runDaemonPass(config, state = createDefaultDaemonState(), options = {})
|
|
|
13440
14124
|
pulled_rows: 0,
|
|
13441
14125
|
errors: []
|
|
13442
14126
|
};
|
|
13443
|
-
nextState.updated_at =
|
|
14127
|
+
nextState.updated_at = now2;
|
|
13444
14128
|
nextState.status = config.paused ? "paused" : "running";
|
|
13445
14129
|
if (config.paused) {
|
|
13446
14130
|
scanConfiguredFiles(config, nextState);
|
|
@@ -13448,7 +14132,7 @@ function runDaemonPass(config, state = createDefaultDaemonState(), options = {})
|
|
|
13448
14132
|
}
|
|
13449
14133
|
for (const service of services) {
|
|
13450
14134
|
const dbPath = adapterFactory.getLocalDbPath(service);
|
|
13451
|
-
if (!
|
|
14135
|
+
if (!existsSync8(dbPath))
|
|
13452
14136
|
continue;
|
|
13453
14137
|
const serviceState = getServiceState(nextState, service);
|
|
13454
14138
|
let local = null;
|
|
@@ -13468,10 +14152,10 @@ function runDaemonPass(config, state = createDefaultDaemonState(), options = {})
|
|
|
13468
14152
|
});
|
|
13469
14153
|
const pushSummary = summarizeStats(pushStats);
|
|
13470
14154
|
serviceState.last_local_db_mtime_ms = localStats.mtimeMs;
|
|
13471
|
-
serviceState.last_push_at =
|
|
14155
|
+
serviceState.last_push_at = now2;
|
|
13472
14156
|
serviceState.last_error = pushSummary.errors[0] ?? null;
|
|
13473
|
-
recordTableRuns(serviceState, duePushTables, "push",
|
|
13474
|
-
nextState.last_push_at =
|
|
14157
|
+
recordTableRuns(serviceState, duePushTables, "push", now2);
|
|
14158
|
+
nextState.last_push_at = now2;
|
|
13475
14159
|
summary.pushed_services++;
|
|
13476
14160
|
summary.pushed_rows += pushSummary.rows;
|
|
13477
14161
|
summary.errors.push(...pushSummary.errors.map((error) => `[${service}] ${error}`));
|
|
@@ -13486,10 +14170,10 @@ function runDaemonPass(config, state = createDefaultDaemonState(), options = {})
|
|
|
13486
14170
|
conflictStrategy: config.conflict_strategy
|
|
13487
14171
|
});
|
|
13488
14172
|
const pullSummary = summarizeStats(pullStats);
|
|
13489
|
-
serviceState.last_pull_at =
|
|
14173
|
+
serviceState.last_pull_at = now2;
|
|
13490
14174
|
serviceState.last_error = pullSummary.errors[0] ?? null;
|
|
13491
|
-
recordTableRuns(serviceState, duePullTables, "pull",
|
|
13492
|
-
nextState.last_pull_at =
|
|
14175
|
+
recordTableRuns(serviceState, duePullTables, "pull", now2);
|
|
14176
|
+
nextState.last_pull_at = now2;
|
|
13493
14177
|
summary.pulled_services++;
|
|
13494
14178
|
summary.pulled_rows += pullSummary.rows;
|
|
13495
14179
|
summary.errors.push(...pullSummary.errors.map((error) => `[${service}] ${error}`));
|
|
@@ -13555,7 +14239,7 @@ function startDaemon(overrides = {}) {
|
|
|
13555
14239
|
if (currentStatus.running) {
|
|
13556
14240
|
return currentStatus;
|
|
13557
14241
|
}
|
|
13558
|
-
const child =
|
|
14242
|
+
const child = spawn2(process.execPath, getDaemonArgs(), {
|
|
13559
14243
|
detached: true,
|
|
13560
14244
|
stdio: "ignore",
|
|
13561
14245
|
env: {
|
|
@@ -13684,8 +14368,8 @@ function normalizeConflictStrategy(strategy) {
|
|
|
13684
14368
|
// src/cli/cmd-sync.ts
|
|
13685
14369
|
function logSync(direction, service, rows, errors2) {
|
|
13686
14370
|
try {
|
|
13687
|
-
const logDir =
|
|
13688
|
-
const logPath =
|
|
14371
|
+
const logDir = join9(homedir8(), ".hasna", "cloud");
|
|
14372
|
+
const logPath = join9(logDir, "sync.log");
|
|
13689
14373
|
const { mkdirSync: mkdirSync5, appendFileSync } = __require("fs");
|
|
13690
14374
|
mkdirSync5(logDir, { recursive: true });
|
|
13691
14375
|
const ts = new Date().toISOString();
|
|
@@ -13702,7 +14386,7 @@ function registerSyncCommands(syncCmd) {
|
|
|
13702
14386
|
registerScheduleCommand(syncCmd);
|
|
13703
14387
|
registerDaemonCommand(syncCmd);
|
|
13704
14388
|
}
|
|
13705
|
-
function
|
|
14389
|
+
function collectValues2(value, previous = []) {
|
|
13706
14390
|
previous.push(value);
|
|
13707
14391
|
return previous;
|
|
13708
14392
|
}
|
|
@@ -13948,7 +14632,7 @@ function registerStatusCommand(syncCmd) {
|
|
|
13948
14632
|
const statuses = [];
|
|
13949
14633
|
for (const service of services) {
|
|
13950
14634
|
const dbPath = getDbPath(service);
|
|
13951
|
-
const localExists =
|
|
14635
|
+
const localExists = existsSync9(dbPath);
|
|
13952
14636
|
let localSize = "—";
|
|
13953
14637
|
let tableCount = 0;
|
|
13954
14638
|
if (localExists) {
|
|
@@ -14194,7 +14878,7 @@ No syncable services found (no .db files in ~/.hasna/).`);
|
|
|
14194
14878
|
});
|
|
14195
14879
|
}
|
|
14196
14880
|
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)",
|
|
14881
|
+
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)", collectValues2, []).option("--table-interval <rule>", "Per-table interval, e.g. todos.tasks=30 or todos:tasks=30", collectValues2, []).option("--file <rule>", "Track a file path for daemon status, e.g. ~/.claude/agents=30", collectValues2, []).addOption(new Option2("--run", "Internal daemon worker").hideHelp()).action((opts) => {
|
|
14198
14882
|
const currentConfig = getDaemonConfig();
|
|
14199
14883
|
const selectedServices = opts.service.length > 0 ? Array.from(new Set(opts.service)) : currentConfig.services;
|
|
14200
14884
|
const tableIntervalRules = opts.tableInterval;
|
|
@@ -14370,17 +15054,17 @@ function ensureFeedbackTable(db) {
|
|
|
14370
15054
|
function saveFeedback(db, feedback) {
|
|
14371
15055
|
ensureFeedbackTable(db);
|
|
14372
15056
|
const id = feedback.id ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
14373
|
-
const
|
|
15057
|
+
const now2 = new Date().toISOString();
|
|
14374
15058
|
const machineId = feedback.machine_id ?? hostname2();
|
|
14375
15059
|
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 ??
|
|
15060
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, id, feedback.service, feedback.version ?? "", feedback.message, feedback.email ?? "", machineId, feedback.created_at ?? now2);
|
|
14377
15061
|
return id;
|
|
14378
15062
|
}
|
|
14379
15063
|
async function sendFeedback(feedback, db) {
|
|
14380
15064
|
const config = getCloudConfig();
|
|
14381
15065
|
const id = feedback.id ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
14382
15066
|
const machineId = feedback.machine_id ?? hostname2();
|
|
14383
|
-
const
|
|
15067
|
+
const now2 = new Date().toISOString();
|
|
14384
15068
|
const payload = {
|
|
14385
15069
|
id,
|
|
14386
15070
|
service: feedback.service,
|
|
@@ -14388,7 +15072,7 @@ async function sendFeedback(feedback, db) {
|
|
|
14388
15072
|
message: feedback.message,
|
|
14389
15073
|
email: feedback.email ?? "",
|
|
14390
15074
|
machine_id: machineId,
|
|
14391
|
-
created_at: feedback.created_at ??
|
|
15075
|
+
created_at: feedback.created_at ?? now2
|
|
14392
15076
|
};
|
|
14393
15077
|
try {
|
|
14394
15078
|
const res = await fetch(config.feedback_endpoint, {
|
|
@@ -14440,14 +15124,14 @@ function registerFeedbackCommand(program3) {
|
|
|
14440
15124
|
init_config();
|
|
14441
15125
|
init_adapter();
|
|
14442
15126
|
init_discover();
|
|
14443
|
-
import { existsSync as
|
|
14444
|
-
import { join as
|
|
14445
|
-
import { homedir as
|
|
15127
|
+
import { existsSync as existsSync10 } from "fs";
|
|
15128
|
+
import { join as join10 } from "path";
|
|
15129
|
+
import { homedir as homedir9 } from "os";
|
|
14446
15130
|
function registerDoctorCommand(program3) {
|
|
14447
15131
|
program3.command("doctor").description("Comprehensive health check for cloud sync setup").action(async () => {
|
|
14448
15132
|
const checks = [];
|
|
14449
|
-
const configPath =
|
|
14450
|
-
checks.push(
|
|
15133
|
+
const configPath = join10(homedir9(), ".hasna", "cloud", "config.json");
|
|
15134
|
+
checks.push(existsSync10(configPath) ? { name: "Config file", status: "pass", detail: configPath } : { name: "Config file", status: "fail", detail: "Missing. Run `cloud setup`." });
|
|
14451
15135
|
const config = getCloudConfig();
|
|
14452
15136
|
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
15137
|
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`." });
|
|
@@ -14466,7 +15150,7 @@ function registerDoctorCommand(program3) {
|
|
|
14466
15150
|
checks.push({ name: "PG connection", status: "fail", detail: "Skipped — missing host or password" });
|
|
14467
15151
|
}
|
|
14468
15152
|
const caPath = process.env.NODE_EXTRA_CA_CERTS;
|
|
14469
|
-
if (caPath &&
|
|
15153
|
+
if (caPath && existsSync10(caPath)) {
|
|
14470
15154
|
checks.push({ name: "SSL CA cert", status: "pass", detail: caPath });
|
|
14471
15155
|
} else if (caPath) {
|
|
14472
15156
|
checks.push({ name: "SSL CA cert", status: "warn", detail: `NODE_EXTRA_CA_CERTS set but file missing: ${caPath}` });
|
|
@@ -14507,9 +15191,9 @@ init_zod();
|
|
|
14507
15191
|
init_adapter();
|
|
14508
15192
|
init_dotfile();
|
|
14509
15193
|
init_machines();
|
|
14510
|
-
import { existsSync as
|
|
14511
|
-
import { homedir as
|
|
14512
|
-
import { join as
|
|
15194
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
15195
|
+
import { homedir as homedir10 } from "os";
|
|
15196
|
+
import { join as join11 } from "path";
|
|
14513
15197
|
var DaemonConfigSchema2 = exports_external.object({
|
|
14514
15198
|
enabled: exports_external.boolean().default(false),
|
|
14515
15199
|
paused: exports_external.boolean().default(false),
|
|
@@ -14541,10 +15225,10 @@ var CloudConfigSchema2 = exports_external.object({
|
|
|
14541
15225
|
}).default({}),
|
|
14542
15226
|
daemon: DaemonConfigSchema2
|
|
14543
15227
|
});
|
|
14544
|
-
var CONFIG_DIR3 =
|
|
14545
|
-
var CONFIG_PATH2 =
|
|
15228
|
+
var CONFIG_DIR3 = join11(homedir10(), ".hasna", "cloud");
|
|
15229
|
+
var CONFIG_PATH2 = join11(CONFIG_DIR3, "config.json");
|
|
14546
15230
|
function getCloudConfig2() {
|
|
14547
|
-
if (!
|
|
15231
|
+
if (!existsSync11(CONFIG_PATH2)) {
|
|
14548
15232
|
return CloudConfigSchema2.parse({});
|
|
14549
15233
|
}
|
|
14550
15234
|
try {
|
|
@@ -14630,6 +15314,7 @@ class PgAdapterAsync2 {
|
|
|
14630
15314
|
// src/cli/index.ts
|
|
14631
15315
|
var program3 = new Command;
|
|
14632
15316
|
program3.name("cloud").description("Shared cloud infrastructure \u2014 database adapter, sync engine, feedback, dotfile migration").version("0.1.13");
|
|
15317
|
+
registerEventsCommands(program3, { source: "cloud" });
|
|
14633
15318
|
registerSetupCommand(program3);
|
|
14634
15319
|
program3.command("status").description("Show current cloud configuration and connection health").action(async () => {
|
|
14635
15320
|
const config = getCloudConfig2();
|