@hasna/machines 0.0.14 → 0.0.16
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 +87 -0
- package/dist/cli/index.js +1726 -179
- package/dist/commands/heal-daemon.d.ts +36 -0
- package/dist/commands/heal-daemon.d.ts.map +1 -0
- package/dist/commands/heal.d.ts +122 -0
- package/dist/commands/heal.d.ts.map +1 -0
- package/dist/compatibility.d.ts +55 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/db.d.ts +1 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1040 -185
- package/dist/mcp/http.d.ts +12 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.js +954 -75
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/pg-migrations.d.ts +7 -0
- package/dist/pg-migrations.d.ts.map +1 -0
- package/dist/remote-storage.d.ts +10 -0
- package/dist/remote-storage.d.ts.map +1 -0
- package/dist/remote.d.ts +5 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/storage-sync.d.ts +58 -0
- package/dist/storage-sync.d.ts.map +1 -0
- package/dist/storage.d.ts +5 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +557 -0
- package/dist/topology.d.ts +55 -0
- package/dist/topology.d.ts.map +1 -0
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -2
package/dist/cli/index.js
CHANGED
|
@@ -44,6 +44,7 @@ var __export = (target, all) => {
|
|
|
44
44
|
set: __exportSetter.bind(all, name)
|
|
45
45
|
});
|
|
46
46
|
};
|
|
47
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
47
48
|
var __require = import.meta.require;
|
|
48
49
|
|
|
49
50
|
// node_modules/commander/lib/error.js
|
|
@@ -2080,6 +2081,510 @@ var require_commander = __commonJS((exports) => {
|
|
|
2080
2081
|
exports.InvalidOptionArgumentError = InvalidArgumentError;
|
|
2081
2082
|
});
|
|
2082
2083
|
|
|
2084
|
+
// src/paths.ts
|
|
2085
|
+
import { existsSync as existsSync2, mkdirSync } from "fs";
|
|
2086
|
+
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
2087
|
+
function homeDir() {
|
|
2088
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
2089
|
+
}
|
|
2090
|
+
function getDataDir() {
|
|
2091
|
+
return process.env["HASNA_MACHINES_DIR"] || join2(homeDir(), ".hasna", "machines");
|
|
2092
|
+
}
|
|
2093
|
+
function getDbPath() {
|
|
2094
|
+
return process.env["HASNA_MACHINES_DB_PATH"] || join2(getDataDir(), "machines.db");
|
|
2095
|
+
}
|
|
2096
|
+
function getManifestPath() {
|
|
2097
|
+
return process.env["HASNA_MACHINES_MANIFEST_PATH"] || join2(getDataDir(), "machines.json");
|
|
2098
|
+
}
|
|
2099
|
+
function getNotificationsPath() {
|
|
2100
|
+
return process.env["HASNA_MACHINES_NOTIFICATIONS_PATH"] || join2(getDataDir(), "notifications.json");
|
|
2101
|
+
}
|
|
2102
|
+
function getClipboardKeyPath() {
|
|
2103
|
+
return process.env["HASNA_MACHINES_CLIPBOARD_KEY_PATH"] || join2(getDataDir(), "clipboard.key");
|
|
2104
|
+
}
|
|
2105
|
+
function getClipboardHistoryPath() {
|
|
2106
|
+
return process.env["HASNA_MACHINES_CLIPBOARD_HISTORY_PATH"] || join2(getDataDir(), "clipboard-history.json");
|
|
2107
|
+
}
|
|
2108
|
+
function ensureParentDir(filePath) {
|
|
2109
|
+
if (filePath === ":memory:")
|
|
2110
|
+
return;
|
|
2111
|
+
const dir = dirname2(resolve(filePath));
|
|
2112
|
+
if (!existsSync2(dir)) {
|
|
2113
|
+
mkdirSync(dir, { recursive: true });
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
var init_paths = () => {};
|
|
2117
|
+
|
|
2118
|
+
// src/db.ts
|
|
2119
|
+
import { Database } from "bun:sqlite";
|
|
2120
|
+
import { hostname as hostname2 } from "os";
|
|
2121
|
+
|
|
2122
|
+
class SqliteAdapter {
|
|
2123
|
+
raw;
|
|
2124
|
+
constructor(path) {
|
|
2125
|
+
this.raw = new Database(path);
|
|
2126
|
+
}
|
|
2127
|
+
close() {
|
|
2128
|
+
this.raw.close();
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
function createTables(db) {
|
|
2132
|
+
db.exec(`
|
|
2133
|
+
CREATE TABLE IF NOT EXISTS agent_heartbeats (
|
|
2134
|
+
machine_id TEXT NOT NULL,
|
|
2135
|
+
pid INTEGER NOT NULL,
|
|
2136
|
+
status TEXT NOT NULL,
|
|
2137
|
+
updated_at TEXT NOT NULL,
|
|
2138
|
+
PRIMARY KEY (machine_id, pid)
|
|
2139
|
+
)
|
|
2140
|
+
`);
|
|
2141
|
+
db.exec(`
|
|
2142
|
+
CREATE TABLE IF NOT EXISTS setup_runs (
|
|
2143
|
+
id TEXT PRIMARY KEY,
|
|
2144
|
+
machine_id TEXT NOT NULL,
|
|
2145
|
+
status TEXT NOT NULL,
|
|
2146
|
+
details_json TEXT NOT NULL DEFAULT '[]',
|
|
2147
|
+
created_at TEXT NOT NULL,
|
|
2148
|
+
updated_at TEXT NOT NULL
|
|
2149
|
+
)
|
|
2150
|
+
`);
|
|
2151
|
+
db.exec(`
|
|
2152
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
2153
|
+
id TEXT PRIMARY KEY,
|
|
2154
|
+
machine_id TEXT NOT NULL,
|
|
2155
|
+
status TEXT NOT NULL,
|
|
2156
|
+
actions_json TEXT NOT NULL DEFAULT '[]',
|
|
2157
|
+
created_at TEXT NOT NULL,
|
|
2158
|
+
updated_at TEXT NOT NULL
|
|
2159
|
+
)
|
|
2160
|
+
`);
|
|
2161
|
+
}
|
|
2162
|
+
function getAdapter(path = getDbPath()) {
|
|
2163
|
+
if (path === ":memory:") {
|
|
2164
|
+
const memoryAdapter = new SqliteAdapter(path);
|
|
2165
|
+
createTables(memoryAdapter.raw);
|
|
2166
|
+
return memoryAdapter;
|
|
2167
|
+
}
|
|
2168
|
+
if (adapter && adapter.raw.filename !== path) {
|
|
2169
|
+
adapter.close();
|
|
2170
|
+
adapter = null;
|
|
2171
|
+
}
|
|
2172
|
+
if (!adapter) {
|
|
2173
|
+
ensureParentDir(path);
|
|
2174
|
+
adapter = new SqliteAdapter(path);
|
|
2175
|
+
adapter.raw.exec("PRAGMA journal_mode = WAL");
|
|
2176
|
+
adapter.raw.exec("PRAGMA foreign_keys = ON");
|
|
2177
|
+
createTables(adapter.raw);
|
|
2178
|
+
}
|
|
2179
|
+
return adapter;
|
|
2180
|
+
}
|
|
2181
|
+
function getDb(path = getDbPath()) {
|
|
2182
|
+
return getAdapter(path).raw;
|
|
2183
|
+
}
|
|
2184
|
+
function getLocalMachineId() {
|
|
2185
|
+
return process.env["HASNA_MACHINES_MACHINE_ID"] || hostname2();
|
|
2186
|
+
}
|
|
2187
|
+
function listHeartbeats(machineId) {
|
|
2188
|
+
const db = getDb();
|
|
2189
|
+
if (machineId) {
|
|
2190
|
+
return db.query(`SELECT machine_id, pid, status, updated_at
|
|
2191
|
+
FROM agent_heartbeats
|
|
2192
|
+
WHERE machine_id = ?
|
|
2193
|
+
ORDER BY updated_at DESC`).all(machineId);
|
|
2194
|
+
}
|
|
2195
|
+
return db.query(`SELECT machine_id, pid, status, updated_at
|
|
2196
|
+
FROM agent_heartbeats
|
|
2197
|
+
ORDER BY updated_at DESC`).all();
|
|
2198
|
+
}
|
|
2199
|
+
function countRuns(table) {
|
|
2200
|
+
const db = getDb();
|
|
2201
|
+
const row = db.query(`SELECT COUNT(*) as count FROM ${table}`).get();
|
|
2202
|
+
return row.count;
|
|
2203
|
+
}
|
|
2204
|
+
function recordSetupRun(machineId, status, details) {
|
|
2205
|
+
const db = getDb();
|
|
2206
|
+
const now = new Date().toISOString();
|
|
2207
|
+
db.query(`INSERT INTO setup_runs (id, machine_id, status, details_json, created_at, updated_at)
|
|
2208
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(details), now, now);
|
|
2209
|
+
}
|
|
2210
|
+
function recordSyncRun(machineId, status, actions) {
|
|
2211
|
+
const db = getDb();
|
|
2212
|
+
const now = new Date().toISOString();
|
|
2213
|
+
db.query(`INSERT INTO sync_runs (id, machine_id, status, actions_json, created_at, updated_at)
|
|
2214
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(actions), now, now);
|
|
2215
|
+
}
|
|
2216
|
+
var adapter = null;
|
|
2217
|
+
var init_db = __esm(() => {
|
|
2218
|
+
init_paths();
|
|
2219
|
+
});
|
|
2220
|
+
|
|
2221
|
+
// src/pg-migrations.ts
|
|
2222
|
+
var PG_MIGRATIONS;
|
|
2223
|
+
var init_pg_migrations = __esm(() => {
|
|
2224
|
+
PG_MIGRATIONS = [
|
|
2225
|
+
`
|
|
2226
|
+
CREATE TABLE IF NOT EXISTS agent_heartbeats (
|
|
2227
|
+
machine_id TEXT NOT NULL,
|
|
2228
|
+
pid INTEGER NOT NULL,
|
|
2229
|
+
status TEXT NOT NULL,
|
|
2230
|
+
updated_at TIMESTAMPTZ NOT NULL,
|
|
2231
|
+
PRIMARY KEY (machine_id, pid)
|
|
2232
|
+
);
|
|
2233
|
+
|
|
2234
|
+
CREATE TABLE IF NOT EXISTS setup_runs (
|
|
2235
|
+
id TEXT PRIMARY KEY,
|
|
2236
|
+
machine_id TEXT NOT NULL,
|
|
2237
|
+
status TEXT NOT NULL,
|
|
2238
|
+
details_json TEXT NOT NULL DEFAULT '[]',
|
|
2239
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
2240
|
+
updated_at TIMESTAMPTZ NOT NULL
|
|
2241
|
+
);
|
|
2242
|
+
|
|
2243
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
2244
|
+
id TEXT PRIMARY KEY,
|
|
2245
|
+
machine_id TEXT NOT NULL,
|
|
2246
|
+
status TEXT NOT NULL,
|
|
2247
|
+
actions_json TEXT NOT NULL DEFAULT '[]',
|
|
2248
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
2249
|
+
updated_at TIMESTAMPTZ NOT NULL
|
|
2250
|
+
);
|
|
2251
|
+
`
|
|
2252
|
+
];
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
// src/remote-storage.ts
|
|
2256
|
+
import pg from "pg";
|
|
2257
|
+
function translatePlaceholders(sql) {
|
|
2258
|
+
let index = 0;
|
|
2259
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
2260
|
+
}
|
|
2261
|
+
function normalizeParams(params) {
|
|
2262
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
2263
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
2264
|
+
}
|
|
2265
|
+
function sslConfigFor(connectionString) {
|
|
2266
|
+
return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
class PgAdapterAsync {
|
|
2270
|
+
pool;
|
|
2271
|
+
constructor(connectionString) {
|
|
2272
|
+
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
2273
|
+
}
|
|
2274
|
+
async run(sql, ...params) {
|
|
2275
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
2276
|
+
return { changes: result.rowCount ?? 0 };
|
|
2277
|
+
}
|
|
2278
|
+
async all(sql, ...params) {
|
|
2279
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
2280
|
+
return result.rows;
|
|
2281
|
+
}
|
|
2282
|
+
async close() {
|
|
2283
|
+
await this.pool.end();
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
var init_remote_storage = () => {};
|
|
2287
|
+
|
|
2288
|
+
// src/storage-sync.ts
|
|
2289
|
+
function readEnv(name) {
|
|
2290
|
+
const value = process.env[name]?.trim();
|
|
2291
|
+
return value || undefined;
|
|
2292
|
+
}
|
|
2293
|
+
function normalizeStorageMode(value) {
|
|
2294
|
+
const normalized = value?.trim().toLowerCase();
|
|
2295
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
2296
|
+
return normalized;
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
function getStorageDatabaseEnvName() {
|
|
2300
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
2301
|
+
if (readEnv(name))
|
|
2302
|
+
return name;
|
|
2303
|
+
}
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
function getStorageDatabaseEnv() {
|
|
2307
|
+
const name = getStorageDatabaseEnvName();
|
|
2308
|
+
return name ? { name } : null;
|
|
2309
|
+
}
|
|
2310
|
+
function getStorageDatabaseUrl() {
|
|
2311
|
+
const env2 = getStorageDatabaseEnv();
|
|
2312
|
+
return env2 ? readEnv(env2.name) ?? null : null;
|
|
2313
|
+
}
|
|
2314
|
+
function getStorageMode() {
|
|
2315
|
+
const mode = normalizeStorageMode(readEnv(MACHINES_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv(MACHINES_STORAGE_MODE_FALLBACK_ENV));
|
|
2316
|
+
if (mode)
|
|
2317
|
+
return mode;
|
|
2318
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
2319
|
+
}
|
|
2320
|
+
async function getStoragePg() {
|
|
2321
|
+
const url = getStorageDatabaseUrl();
|
|
2322
|
+
if (!url) {
|
|
2323
|
+
throw new Error("Missing HASNA_MACHINES_DATABASE_URL or MACHINES_DATABASE_URL");
|
|
2324
|
+
}
|
|
2325
|
+
return new PgAdapterAsync(url);
|
|
2326
|
+
}
|
|
2327
|
+
async function runStorageMigrations(remote) {
|
|
2328
|
+
for (const sql of PG_MIGRATIONS)
|
|
2329
|
+
await remote.run(sql);
|
|
2330
|
+
}
|
|
2331
|
+
async function storagePush(options) {
|
|
2332
|
+
const remote = await getStoragePg();
|
|
2333
|
+
const db = getDb();
|
|
2334
|
+
try {
|
|
2335
|
+
await runStorageMigrations(remote);
|
|
2336
|
+
const results = [];
|
|
2337
|
+
for (const table of resolveTables(options?.tables)) {
|
|
2338
|
+
results.push(await pushTable(db, remote, table));
|
|
2339
|
+
}
|
|
2340
|
+
recordSyncMeta(db, "push", results);
|
|
2341
|
+
return results;
|
|
2342
|
+
} finally {
|
|
2343
|
+
await remote.close();
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
async function storagePull(options) {
|
|
2347
|
+
const remote = await getStoragePg();
|
|
2348
|
+
const db = getDb();
|
|
2349
|
+
try {
|
|
2350
|
+
await runStorageMigrations(remote);
|
|
2351
|
+
const results = [];
|
|
2352
|
+
for (const table of resolveTables(options?.tables)) {
|
|
2353
|
+
results.push(await pullTable(remote, db, table));
|
|
2354
|
+
}
|
|
2355
|
+
recordSyncMeta(db, "pull", results);
|
|
2356
|
+
return results;
|
|
2357
|
+
} finally {
|
|
2358
|
+
await remote.close();
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
async function storageSync(options) {
|
|
2362
|
+
const pull = await storagePull(options);
|
|
2363
|
+
const push = await storagePush(options);
|
|
2364
|
+
return { pull, push };
|
|
2365
|
+
}
|
|
2366
|
+
function getSyncMetaAll() {
|
|
2367
|
+
const db = getDb();
|
|
2368
|
+
ensureSyncMetaTable(db);
|
|
2369
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _machines_sync_meta ORDER BY table_name, direction").all();
|
|
2370
|
+
}
|
|
2371
|
+
function getStorageStatus() {
|
|
2372
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
2373
|
+
return {
|
|
2374
|
+
configured: Boolean(activeEnv),
|
|
2375
|
+
mode: getStorageMode(),
|
|
2376
|
+
env: STORAGE_DATABASE_ENV,
|
|
2377
|
+
activeEnv: activeEnv?.name ?? null,
|
|
2378
|
+
service: "machines",
|
|
2379
|
+
tables: STORAGE_TABLES,
|
|
2380
|
+
sync: getSyncMetaAll()
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
function resolveTables(tables) {
|
|
2384
|
+
if (!tables || tables.length === 0)
|
|
2385
|
+
return [...STORAGE_TABLES];
|
|
2386
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
2387
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
2388
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
2389
|
+
if (invalid.length > 0)
|
|
2390
|
+
throw new Error(`Unknown machines storage table(s): ${invalid.join(", ")}`);
|
|
2391
|
+
return requested;
|
|
2392
|
+
}
|
|
2393
|
+
function parseStorageTables(value) {
|
|
2394
|
+
if (!value)
|
|
2395
|
+
return;
|
|
2396
|
+
return resolveTables(Array.isArray(value) ? value : value.split(","));
|
|
2397
|
+
}
|
|
2398
|
+
async function pushTable(db, remote, table) {
|
|
2399
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
2400
|
+
try {
|
|
2401
|
+
if (!tableExists(db, table))
|
|
2402
|
+
return result;
|
|
2403
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
2404
|
+
result.rowsRead = rows.length;
|
|
2405
|
+
if (rows.length === 0)
|
|
2406
|
+
return result;
|
|
2407
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
2408
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
2409
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows);
|
|
2410
|
+
} catch (error) {
|
|
2411
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
2412
|
+
}
|
|
2413
|
+
return result;
|
|
2414
|
+
}
|
|
2415
|
+
async function pullTable(remote, db, table) {
|
|
2416
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
2417
|
+
try {
|
|
2418
|
+
if (!tableExists(db, table))
|
|
2419
|
+
return result;
|
|
2420
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
2421
|
+
result.rowsRead = rows.length;
|
|
2422
|
+
if (rows.length === 0)
|
|
2423
|
+
return result;
|
|
2424
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
2425
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
2426
|
+
} catch (error) {
|
|
2427
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
2428
|
+
}
|
|
2429
|
+
return result;
|
|
2430
|
+
}
|
|
2431
|
+
async function getRemoteColumns(remote, table) {
|
|
2432
|
+
const rows = await remote.all("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
2433
|
+
return new Set(rows.map((row) => row.column_name));
|
|
2434
|
+
}
|
|
2435
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
2436
|
+
if (remoteColumns.size === 0)
|
|
2437
|
+
return columns;
|
|
2438
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
2439
|
+
}
|
|
2440
|
+
function filterLocalColumns(db, table, columns) {
|
|
2441
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
2442
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
2443
|
+
return columns.filter((column) => allowed.has(column));
|
|
2444
|
+
}
|
|
2445
|
+
async function upsertPg(remote, table, columns, rows) {
|
|
2446
|
+
if (columns.length === 0)
|
|
2447
|
+
return 0;
|
|
2448
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
2449
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
2450
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
2451
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
2452
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
2453
|
+
const fallbackKey = primaryKeys[0];
|
|
2454
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
2455
|
+
for (const row of rows) {
|
|
2456
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
2457
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column])));
|
|
2458
|
+
}
|
|
2459
|
+
return rows.length;
|
|
2460
|
+
}
|
|
2461
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
2462
|
+
if (columns.length === 0)
|
|
2463
|
+
return 0;
|
|
2464
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
2465
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
2466
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
2467
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
2468
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
2469
|
+
const fallbackKey = primaryKeys[0];
|
|
2470
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
2471
|
+
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
2472
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
2473
|
+
const insert = db.transaction((batch) => {
|
|
2474
|
+
for (const row of batch)
|
|
2475
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
2476
|
+
});
|
|
2477
|
+
insert(rows);
|
|
2478
|
+
return rows.length;
|
|
2479
|
+
}
|
|
2480
|
+
function recordSyncMeta(db, direction, results) {
|
|
2481
|
+
ensureSyncMetaTable(db);
|
|
2482
|
+
const now = new Date().toISOString();
|
|
2483
|
+
const statement = db.query(`
|
|
2484
|
+
INSERT INTO _machines_sync_meta (table_name, last_synced_at, direction)
|
|
2485
|
+
VALUES (?, ?, ?)
|
|
2486
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
2487
|
+
`);
|
|
2488
|
+
for (const result of results) {
|
|
2489
|
+
if (result.errors.length > 0)
|
|
2490
|
+
continue;
|
|
2491
|
+
statement.run(result.table, now, direction);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
function ensureSyncMetaTable(db) {
|
|
2495
|
+
db.exec(`
|
|
2496
|
+
CREATE TABLE IF NOT EXISTS _machines_sync_meta (
|
|
2497
|
+
table_name TEXT NOT NULL,
|
|
2498
|
+
last_synced_at TEXT,
|
|
2499
|
+
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
2500
|
+
PRIMARY KEY (table_name, direction)
|
|
2501
|
+
)
|
|
2502
|
+
`);
|
|
2503
|
+
}
|
|
2504
|
+
function tableExists(db, table) {
|
|
2505
|
+
const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
2506
|
+
return Boolean(row);
|
|
2507
|
+
}
|
|
2508
|
+
function quoteIdent(identifier) {
|
|
2509
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
2510
|
+
}
|
|
2511
|
+
function coerceForPg(value) {
|
|
2512
|
+
if (value === undefined || value === null)
|
|
2513
|
+
return null;
|
|
2514
|
+
if (value instanceof Date)
|
|
2515
|
+
return value.toISOString();
|
|
2516
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
2517
|
+
return value;
|
|
2518
|
+
if (typeof value === "object")
|
|
2519
|
+
return JSON.stringify(value);
|
|
2520
|
+
return value;
|
|
2521
|
+
}
|
|
2522
|
+
function coerceForSqlite(value) {
|
|
2523
|
+
if (value === undefined || value === null)
|
|
2524
|
+
return null;
|
|
2525
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
2526
|
+
return value;
|
|
2527
|
+
if (value instanceof Date)
|
|
2528
|
+
return value.toISOString();
|
|
2529
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
2530
|
+
return value;
|
|
2531
|
+
if (typeof value === "object")
|
|
2532
|
+
return JSON.stringify(value);
|
|
2533
|
+
return String(value);
|
|
2534
|
+
}
|
|
2535
|
+
var STORAGE_TABLES, MACHINES_STORAGE_TABLES, MACHINES_STORAGE_ENV = "HASNA_MACHINES_DATABASE_URL", MACHINES_STORAGE_FALLBACK_ENV = "MACHINES_DATABASE_URL", MACHINES_STORAGE_MODE_ENV = "HASNA_MACHINES_STORAGE_MODE", MACHINES_STORAGE_MODE_FALLBACK_ENV = "MACHINES_STORAGE_MODE", STORAGE_DATABASE_ENV, STORAGE_MODE_ENV, PRIMARY_KEYS;
|
|
2536
|
+
var init_storage_sync = __esm(() => {
|
|
2537
|
+
init_db();
|
|
2538
|
+
init_pg_migrations();
|
|
2539
|
+
init_remote_storage();
|
|
2540
|
+
STORAGE_TABLES = [
|
|
2541
|
+
"agent_heartbeats",
|
|
2542
|
+
"setup_runs",
|
|
2543
|
+
"sync_runs"
|
|
2544
|
+
];
|
|
2545
|
+
MACHINES_STORAGE_TABLES = STORAGE_TABLES;
|
|
2546
|
+
STORAGE_DATABASE_ENV = [MACHINES_STORAGE_ENV, MACHINES_STORAGE_FALLBACK_ENV];
|
|
2547
|
+
STORAGE_MODE_ENV = [MACHINES_STORAGE_MODE_ENV, MACHINES_STORAGE_MODE_FALLBACK_ENV];
|
|
2548
|
+
PRIMARY_KEYS = {
|
|
2549
|
+
agent_heartbeats: ["machine_id", "pid"],
|
|
2550
|
+
setup_runs: ["id"],
|
|
2551
|
+
sync_runs: ["id"]
|
|
2552
|
+
};
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
// src/storage.ts
|
|
2556
|
+
var exports_storage = {};
|
|
2557
|
+
__export(exports_storage, {
|
|
2558
|
+
storageSync: () => storageSync,
|
|
2559
|
+
storagePush: () => storagePush,
|
|
2560
|
+
storagePull: () => storagePull,
|
|
2561
|
+
runStorageMigrations: () => runStorageMigrations,
|
|
2562
|
+
resolveTables: () => resolveTables,
|
|
2563
|
+
parseStorageTables: () => parseStorageTables,
|
|
2564
|
+
getSyncMetaAll: () => getSyncMetaAll,
|
|
2565
|
+
getStorageStatus: () => getStorageStatus,
|
|
2566
|
+
getStoragePg: () => getStoragePg,
|
|
2567
|
+
getStorageMode: () => getStorageMode,
|
|
2568
|
+
getStorageDatabaseUrl: () => getStorageDatabaseUrl,
|
|
2569
|
+
getStorageDatabaseEnvName: () => getStorageDatabaseEnvName,
|
|
2570
|
+
getStorageDatabaseEnv: () => getStorageDatabaseEnv,
|
|
2571
|
+
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
2572
|
+
STORAGE_MODE_ENV: () => STORAGE_MODE_ENV,
|
|
2573
|
+
STORAGE_DATABASE_ENV: () => STORAGE_DATABASE_ENV,
|
|
2574
|
+
PgAdapterAsync: () => PgAdapterAsync,
|
|
2575
|
+
PG_MIGRATIONS: () => PG_MIGRATIONS,
|
|
2576
|
+
MACHINES_STORAGE_TABLES: () => MACHINES_STORAGE_TABLES,
|
|
2577
|
+
MACHINES_STORAGE_MODE_FALLBACK_ENV: () => MACHINES_STORAGE_MODE_FALLBACK_ENV,
|
|
2578
|
+
MACHINES_STORAGE_MODE_ENV: () => MACHINES_STORAGE_MODE_ENV,
|
|
2579
|
+
MACHINES_STORAGE_FALLBACK_ENV: () => MACHINES_STORAGE_FALLBACK_ENV,
|
|
2580
|
+
MACHINES_STORAGE_ENV: () => MACHINES_STORAGE_ENV
|
|
2581
|
+
});
|
|
2582
|
+
var init_storage = __esm(() => {
|
|
2583
|
+
init_storage_sync();
|
|
2584
|
+
init_pg_migrations();
|
|
2585
|
+
init_remote_storage();
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2083
2588
|
// node_modules/commander/esm.mjs
|
|
2084
2589
|
var import__ = __toESM(require_commander(), 1);
|
|
2085
2590
|
var {
|
|
@@ -6581,40 +7086,8 @@ var coerce = {
|
|
|
6581
7086
|
date: (arg) => ZodDate.create({ ...arg, coerce: true })
|
|
6582
7087
|
};
|
|
6583
7088
|
var NEVER = INVALID;
|
|
6584
|
-
// src/paths.ts
|
|
6585
|
-
import { existsSync as existsSync2, mkdirSync } from "fs";
|
|
6586
|
-
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
6587
|
-
function homeDir() {
|
|
6588
|
-
return process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
6589
|
-
}
|
|
6590
|
-
function getDataDir() {
|
|
6591
|
-
return process.env["HASNA_MACHINES_DIR"] || join2(homeDir(), ".hasna", "machines");
|
|
6592
|
-
}
|
|
6593
|
-
function getDbPath() {
|
|
6594
|
-
return process.env["HASNA_MACHINES_DB_PATH"] || join2(getDataDir(), "machines.db");
|
|
6595
|
-
}
|
|
6596
|
-
function getManifestPath() {
|
|
6597
|
-
return process.env["HASNA_MACHINES_MANIFEST_PATH"] || join2(getDataDir(), "machines.json");
|
|
6598
|
-
}
|
|
6599
|
-
function getNotificationsPath() {
|
|
6600
|
-
return process.env["HASNA_MACHINES_NOTIFICATIONS_PATH"] || join2(getDataDir(), "notifications.json");
|
|
6601
|
-
}
|
|
6602
|
-
function getClipboardKeyPath() {
|
|
6603
|
-
return process.env["HASNA_MACHINES_CLIPBOARD_KEY_PATH"] || join2(getDataDir(), "clipboard.key");
|
|
6604
|
-
}
|
|
6605
|
-
function getClipboardHistoryPath() {
|
|
6606
|
-
return process.env["HASNA_MACHINES_CLIPBOARD_HISTORY_PATH"] || join2(getDataDir(), "clipboard-history.json");
|
|
6607
|
-
}
|
|
6608
|
-
function ensureParentDir(filePath) {
|
|
6609
|
-
if (filePath === ":memory:")
|
|
6610
|
-
return;
|
|
6611
|
-
const dir = dirname2(resolve(filePath));
|
|
6612
|
-
if (!existsSync2(dir)) {
|
|
6613
|
-
mkdirSync(dir, { recursive: true });
|
|
6614
|
-
}
|
|
6615
|
-
}
|
|
6616
|
-
|
|
6617
7089
|
// src/manifests.ts
|
|
7090
|
+
init_paths();
|
|
6618
7091
|
var packageSchema = exports_external.object({
|
|
6619
7092
|
name: exports_external.string(),
|
|
6620
7093
|
manager: exports_external.enum(["bun", "brew", "apt", "custom"]).optional(),
|
|
@@ -6718,6 +7191,7 @@ function detectCurrentMachineManifest() {
|
|
|
6718
7191
|
}
|
|
6719
7192
|
|
|
6720
7193
|
// src/commands/manifest.ts
|
|
7194
|
+
init_paths();
|
|
6721
7195
|
function manifestInit() {
|
|
6722
7196
|
return writeManifest(getDefaultManifest(), getManifestPath());
|
|
6723
7197
|
}
|
|
@@ -6751,108 +7225,10 @@ function manifestValidate() {
|
|
|
6751
7225
|
return validateManifest(getManifestPath());
|
|
6752
7226
|
}
|
|
6753
7227
|
|
|
6754
|
-
// src/
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
raw;
|
|
6759
|
-
constructor(path) {
|
|
6760
|
-
this.raw = new Database(path);
|
|
6761
|
-
}
|
|
6762
|
-
close() {
|
|
6763
|
-
this.raw.close();
|
|
6764
|
-
}
|
|
6765
|
-
}
|
|
6766
|
-
var adapter = null;
|
|
6767
|
-
function createTables(db) {
|
|
6768
|
-
db.exec(`
|
|
6769
|
-
CREATE TABLE IF NOT EXISTS agent_heartbeats (
|
|
6770
|
-
machine_id TEXT NOT NULL,
|
|
6771
|
-
pid INTEGER NOT NULL,
|
|
6772
|
-
status TEXT NOT NULL,
|
|
6773
|
-
updated_at TEXT NOT NULL,
|
|
6774
|
-
PRIMARY KEY (machine_id, pid)
|
|
6775
|
-
)
|
|
6776
|
-
`);
|
|
6777
|
-
db.exec(`
|
|
6778
|
-
CREATE TABLE IF NOT EXISTS setup_runs (
|
|
6779
|
-
id TEXT PRIMARY KEY,
|
|
6780
|
-
machine_id TEXT NOT NULL,
|
|
6781
|
-
status TEXT NOT NULL,
|
|
6782
|
-
details_json TEXT NOT NULL DEFAULT '[]',
|
|
6783
|
-
created_at TEXT NOT NULL,
|
|
6784
|
-
updated_at TEXT NOT NULL
|
|
6785
|
-
)
|
|
6786
|
-
`);
|
|
6787
|
-
db.exec(`
|
|
6788
|
-
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
6789
|
-
id TEXT PRIMARY KEY,
|
|
6790
|
-
machine_id TEXT NOT NULL,
|
|
6791
|
-
status TEXT NOT NULL,
|
|
6792
|
-
actions_json TEXT NOT NULL DEFAULT '[]',
|
|
6793
|
-
created_at TEXT NOT NULL,
|
|
6794
|
-
updated_at TEXT NOT NULL
|
|
6795
|
-
)
|
|
6796
|
-
`);
|
|
6797
|
-
}
|
|
6798
|
-
function getAdapter(path = getDbPath()) {
|
|
6799
|
-
if (path === ":memory:") {
|
|
6800
|
-
const memoryAdapter = new SqliteAdapter(path);
|
|
6801
|
-
createTables(memoryAdapter.raw);
|
|
6802
|
-
return memoryAdapter;
|
|
6803
|
-
}
|
|
6804
|
-
if (adapter && adapter.raw.filename !== path) {
|
|
6805
|
-
adapter.close();
|
|
6806
|
-
adapter = null;
|
|
6807
|
-
}
|
|
6808
|
-
if (!adapter) {
|
|
6809
|
-
ensureParentDir(path);
|
|
6810
|
-
adapter = new SqliteAdapter(path);
|
|
6811
|
-
adapter.raw.exec("PRAGMA journal_mode = WAL");
|
|
6812
|
-
adapter.raw.exec("PRAGMA foreign_keys = ON");
|
|
6813
|
-
createTables(adapter.raw);
|
|
6814
|
-
}
|
|
6815
|
-
return adapter;
|
|
6816
|
-
}
|
|
6817
|
-
function getDb(path = getDbPath()) {
|
|
6818
|
-
return getAdapter(path).raw;
|
|
6819
|
-
}
|
|
6820
|
-
function getLocalMachineId() {
|
|
6821
|
-
return process.env["HASNA_MACHINES_MACHINE_ID"] || hostname2();
|
|
6822
|
-
}
|
|
6823
|
-
function listHeartbeats(machineId) {
|
|
6824
|
-
const db = getDb();
|
|
6825
|
-
if (machineId) {
|
|
6826
|
-
return db.query(`SELECT machine_id, pid, status, updated_at
|
|
6827
|
-
FROM agent_heartbeats
|
|
6828
|
-
WHERE machine_id = ?
|
|
6829
|
-
ORDER BY updated_at DESC`).all(machineId);
|
|
6830
|
-
}
|
|
6831
|
-
return db.query(`SELECT machine_id, pid, status, updated_at
|
|
6832
|
-
FROM agent_heartbeats
|
|
6833
|
-
ORDER BY updated_at DESC`).all();
|
|
6834
|
-
}
|
|
6835
|
-
function countRuns(table) {
|
|
6836
|
-
const db = getDb();
|
|
6837
|
-
const row = db.query(`SELECT COUNT(*) as count FROM ${table}`).get();
|
|
6838
|
-
return row.count;
|
|
6839
|
-
}
|
|
6840
|
-
function recordSetupRun(machineId, status, details) {
|
|
6841
|
-
const db = getDb();
|
|
6842
|
-
const now = new Date().toISOString();
|
|
6843
|
-
db.query(`INSERT INTO setup_runs (id, machine_id, status, details_json, created_at, updated_at)
|
|
6844
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(details), now, now);
|
|
6845
|
-
}
|
|
6846
|
-
function recordSyncRun(machineId, status, actions) {
|
|
6847
|
-
const db = getDb();
|
|
6848
|
-
const now = new Date().toISOString();
|
|
6849
|
-
db.query(`INSERT INTO sync_runs (id, machine_id, status, actions_json, created_at, updated_at)
|
|
6850
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(actions), now, now);
|
|
6851
|
-
}
|
|
6852
|
-
|
|
6853
|
-
// src/commands/setup.ts
|
|
6854
|
-
function quote(value) {
|
|
6855
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7228
|
+
// src/commands/setup.ts
|
|
7229
|
+
init_db();
|
|
7230
|
+
function quote(value) {
|
|
7231
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
6856
7232
|
}
|
|
6857
7233
|
function buildBaseSteps(machine) {
|
|
6858
7234
|
const steps = [
|
|
@@ -7109,6 +7485,7 @@ function runCertPlan(domains, options = {}) {
|
|
|
7109
7485
|
}
|
|
7110
7486
|
|
|
7111
7487
|
// src/commands/dns.ts
|
|
7488
|
+
init_paths();
|
|
7112
7489
|
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
7113
7490
|
import { join as join5 } from "path";
|
|
7114
7491
|
function getDnsPath() {
|
|
@@ -7194,7 +7571,9 @@ function diffMachines(leftMachineId, rightMachineId) {
|
|
|
7194
7571
|
}
|
|
7195
7572
|
|
|
7196
7573
|
// src/remote.ts
|
|
7574
|
+
init_db();
|
|
7197
7575
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
7576
|
+
import { hostname as hostname3 } from "os";
|
|
7198
7577
|
|
|
7199
7578
|
// src/commands/ssh.ts
|
|
7200
7579
|
import { spawnSync } from "child_process";
|
|
@@ -7240,18 +7619,37 @@ function buildSshCommand(machineId, remoteCommand) {
|
|
|
7240
7619
|
}
|
|
7241
7620
|
|
|
7242
7621
|
// src/remote.ts
|
|
7622
|
+
function shellQuote(value) {
|
|
7623
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
7624
|
+
}
|
|
7625
|
+
function machineIsLocal(machineId, localMachineId) {
|
|
7626
|
+
return machineId === "local" || machineId === "localhost" || machineId === localMachineId || machineId === hostname3();
|
|
7627
|
+
}
|
|
7628
|
+
function resolveMachineCommand(machineId, command, localMachineId = getLocalMachineId()) {
|
|
7629
|
+
if (machineIsLocal(machineId, localMachineId)) {
|
|
7630
|
+
return { source: "local", shellCommand: command };
|
|
7631
|
+
}
|
|
7632
|
+
try {
|
|
7633
|
+
return {
|
|
7634
|
+
source: resolveSshTarget(machineId).route,
|
|
7635
|
+
shellCommand: buildSshCommand(machineId, command)
|
|
7636
|
+
};
|
|
7637
|
+
} catch (error) {
|
|
7638
|
+
if (String(error.message ?? error).includes("Machine not found in manifest")) {
|
|
7639
|
+
return { source: "ssh", shellCommand: `ssh ${shellQuote(machineId)} ${shellQuote(command)}` };
|
|
7640
|
+
}
|
|
7641
|
+
throw error;
|
|
7642
|
+
}
|
|
7643
|
+
}
|
|
7243
7644
|
function runMachineCommand(machineId, command) {
|
|
7244
|
-
const
|
|
7245
|
-
const
|
|
7246
|
-
const route = isLocal ? "local" : resolveSshTarget(machineId).route;
|
|
7247
|
-
const shellCommand = isLocal ? command : buildSshCommand(machineId, command);
|
|
7248
|
-
const result = spawnSync2("bash", ["-lc", shellCommand], {
|
|
7645
|
+
const resolved = resolveMachineCommand(machineId, command);
|
|
7646
|
+
const result = spawnSync2("bash", ["-c", resolved.shellCommand], {
|
|
7249
7647
|
encoding: "utf8",
|
|
7250
7648
|
env: process.env
|
|
7251
7649
|
});
|
|
7252
7650
|
return {
|
|
7253
7651
|
machineId,
|
|
7254
|
-
source:
|
|
7652
|
+
source: resolved.source,
|
|
7255
7653
|
stdout: result.stdout || "",
|
|
7256
7654
|
stderr: result.stderr || "",
|
|
7257
7655
|
exitCode: result.status ?? 1
|
|
@@ -7271,7 +7669,7 @@ function getAppManager(machine, app) {
|
|
|
7271
7669
|
return "winget";
|
|
7272
7670
|
return "apt";
|
|
7273
7671
|
}
|
|
7274
|
-
function
|
|
7672
|
+
function shellQuote2(value) {
|
|
7275
7673
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7276
7674
|
}
|
|
7277
7675
|
function buildAppCommand(machine, app) {
|
|
@@ -7292,7 +7690,7 @@ function buildAppCommand(machine, app) {
|
|
|
7292
7690
|
return `sudo apt-get install -y ${packageName}`;
|
|
7293
7691
|
}
|
|
7294
7692
|
function buildAppProbeCommand(machine, app) {
|
|
7295
|
-
const packageName =
|
|
7693
|
+
const packageName = shellQuote2(getPackageName(app));
|
|
7296
7694
|
const manager = getAppManager(machine, app);
|
|
7297
7695
|
if (manager === "custom") {
|
|
7298
7696
|
return `if command -v ${packageName} >/dev/null 2>&1; then printf 'installed=1\\nversion=custom\\n'; else printf 'installed=0\\n'; fi`;
|
|
@@ -7571,6 +7969,7 @@ function runTailscaleInstall(machineId, options = {}) {
|
|
|
7571
7969
|
|
|
7572
7970
|
// src/commands/notifications.ts
|
|
7573
7971
|
import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
7972
|
+
init_paths();
|
|
7574
7973
|
var notificationChannelSchema = exports_external.object({
|
|
7575
7974
|
id: exports_external.string(),
|
|
7576
7975
|
type: exports_external.enum(["email", "webhook", "command"]),
|
|
@@ -7586,7 +7985,7 @@ var notificationConfigSchema = exports_external.object({
|
|
|
7586
7985
|
function sortChannels(channels) {
|
|
7587
7986
|
return [...channels].sort((left, right) => left.id.localeCompare(right.id));
|
|
7588
7987
|
}
|
|
7589
|
-
function
|
|
7988
|
+
function shellQuote3(value) {
|
|
7590
7989
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7591
7990
|
}
|
|
7592
7991
|
function hasCommand(binary) {
|
|
@@ -7633,7 +8032,7 @@ ${message}
|
|
|
7633
8032
|
};
|
|
7634
8033
|
}
|
|
7635
8034
|
if (hasCommand("mail")) {
|
|
7636
|
-
const command = `printf %s ${
|
|
8035
|
+
const command = `printf %s ${shellQuote3(message)} | mail -s ${shellQuote3(subject)} ${shellQuote3(channel.target)}`;
|
|
7637
8036
|
const result = Bun.spawnSync(["bash", "-lc", command], {
|
|
7638
8037
|
stdout: "pipe",
|
|
7639
8038
|
stderr: "pipe",
|
|
@@ -7817,6 +8216,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
|
|
|
7817
8216
|
}
|
|
7818
8217
|
|
|
7819
8218
|
// src/commands/ports.ts
|
|
8219
|
+
init_db();
|
|
7820
8220
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
7821
8221
|
function parseSsOutput(output) {
|
|
7822
8222
|
return output.trim().split(`
|
|
@@ -7872,6 +8272,8 @@ function listPorts(machineId) {
|
|
|
7872
8272
|
|
|
7873
8273
|
// src/commands/sync.ts
|
|
7874
8274
|
import { existsSync as existsSync6, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
|
|
8275
|
+
init_paths();
|
|
8276
|
+
init_db();
|
|
7875
8277
|
function quote4(value) {
|
|
7876
8278
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7877
8279
|
}
|
|
@@ -8023,6 +8425,8 @@ function runSync(machineId, options = {}) {
|
|
|
8023
8425
|
}
|
|
8024
8426
|
|
|
8025
8427
|
// src/commands/status.ts
|
|
8428
|
+
init_db();
|
|
8429
|
+
init_paths();
|
|
8026
8430
|
function getStatus() {
|
|
8027
8431
|
const manifest = readManifest();
|
|
8028
8432
|
const heartbeats = listHeartbeats();
|
|
@@ -8054,8 +8458,424 @@ function getStatus() {
|
|
|
8054
8458
|
};
|
|
8055
8459
|
}
|
|
8056
8460
|
|
|
8461
|
+
// src/topology.ts
|
|
8462
|
+
init_db();
|
|
8463
|
+
import { existsSync as existsSync7 } from "fs";
|
|
8464
|
+
import { arch as arch2, hostname as hostname4, platform as platform3, userInfo as userInfo2 } from "os";
|
|
8465
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
8466
|
+
init_paths();
|
|
8467
|
+
function normalizePlatform2(value = platform3()) {
|
|
8468
|
+
const normalized = value.toLowerCase();
|
|
8469
|
+
if (normalized === "darwin" || normalized === "macos")
|
|
8470
|
+
return "macos";
|
|
8471
|
+
if (normalized === "win32" || normalized === "windows")
|
|
8472
|
+
return "windows";
|
|
8473
|
+
if (normalized === "linux")
|
|
8474
|
+
return "linux";
|
|
8475
|
+
return value;
|
|
8476
|
+
}
|
|
8477
|
+
function defaultRunner(command) {
|
|
8478
|
+
const result = spawnSync4("bash", ["-c", command], {
|
|
8479
|
+
encoding: "utf8",
|
|
8480
|
+
env: process.env
|
|
8481
|
+
});
|
|
8482
|
+
return {
|
|
8483
|
+
stdout: result.stdout || "",
|
|
8484
|
+
stderr: result.stderr || "",
|
|
8485
|
+
exitCode: result.status ?? 1
|
|
8486
|
+
};
|
|
8487
|
+
}
|
|
8488
|
+
function hasCommand2(command, runner) {
|
|
8489
|
+
return runner(`command -v ${command} >/dev/null 2>&1`).exitCode === 0;
|
|
8490
|
+
}
|
|
8491
|
+
function parseTailscaleStatus(raw) {
|
|
8492
|
+
try {
|
|
8493
|
+
const parsed = JSON.parse(raw);
|
|
8494
|
+
if (!parsed || typeof parsed !== "object")
|
|
8495
|
+
return null;
|
|
8496
|
+
return parsed;
|
|
8497
|
+
} catch {
|
|
8498
|
+
return null;
|
|
8499
|
+
}
|
|
8500
|
+
}
|
|
8501
|
+
function loadTailscalePeers(runner, warnings) {
|
|
8502
|
+
const peers = new Map;
|
|
8503
|
+
if (!hasCommand2("tailscale", runner)) {
|
|
8504
|
+
warnings.push("tailscale_not_available");
|
|
8505
|
+
return peers;
|
|
8506
|
+
}
|
|
8507
|
+
const result = runner("tailscale status --json");
|
|
8508
|
+
if (result.exitCode !== 0) {
|
|
8509
|
+
warnings.push(`tailscale_status_failed:${result.stderr.trim() || result.exitCode}`);
|
|
8510
|
+
return peers;
|
|
8511
|
+
}
|
|
8512
|
+
const status = parseTailscaleStatus(result.stdout);
|
|
8513
|
+
if (!status) {
|
|
8514
|
+
warnings.push("tailscale_status_invalid_json");
|
|
8515
|
+
return peers;
|
|
8516
|
+
}
|
|
8517
|
+
const addPeer = (peer) => {
|
|
8518
|
+
if (!peer)
|
|
8519
|
+
return;
|
|
8520
|
+
const id = peer.HostName || peer.DNSName?.split(".")[0];
|
|
8521
|
+
if (id)
|
|
8522
|
+
peers.set(id, peer);
|
|
8523
|
+
};
|
|
8524
|
+
addPeer(status.Self);
|
|
8525
|
+
for (const peer of Object.values(status.Peer ?? {}))
|
|
8526
|
+
addPeer(peer);
|
|
8527
|
+
return peers;
|
|
8528
|
+
}
|
|
8529
|
+
function machineKeys(machine) {
|
|
8530
|
+
return [
|
|
8531
|
+
machine.id,
|
|
8532
|
+
machine.hostname,
|
|
8533
|
+
machine.tailscaleName?.split(".")[0],
|
|
8534
|
+
machine.tailscaleName,
|
|
8535
|
+
machine.sshAddress?.split("@").pop()
|
|
8536
|
+
].filter((value) => Boolean(value));
|
|
8537
|
+
}
|
|
8538
|
+
function findTailscalePeer(machine, machineId, peers) {
|
|
8539
|
+
if (machine) {
|
|
8540
|
+
for (const key of machineKeys(machine)) {
|
|
8541
|
+
const peer = peers.get(key) ?? peers.get(key.replace(/\.$/, ""));
|
|
8542
|
+
if (peer)
|
|
8543
|
+
return peer;
|
|
8544
|
+
}
|
|
8545
|
+
}
|
|
8546
|
+
return peers.get(machineId) ?? null;
|
|
8547
|
+
}
|
|
8548
|
+
function routeHints(input) {
|
|
8549
|
+
const hints = [];
|
|
8550
|
+
if (input.machineId === input.localMachineId) {
|
|
8551
|
+
hints.push({ kind: "local", target: "localhost", reachable: true });
|
|
8552
|
+
}
|
|
8553
|
+
if (input.manifest?.sshAddress) {
|
|
8554
|
+
hints.push({ kind: "ssh", target: input.manifest.sshAddress, reachable: null });
|
|
8555
|
+
}
|
|
8556
|
+
if (input.manifest?.hostname) {
|
|
8557
|
+
hints.push({ kind: "lan", target: input.manifest.hostname, reachable: null });
|
|
8558
|
+
}
|
|
8559
|
+
const tailscaleTarget = input.manifest?.tailscaleName ?? input.peer?.DNSName ?? input.peer?.TailscaleIPs?.[0];
|
|
8560
|
+
if (tailscaleTarget) {
|
|
8561
|
+
hints.push({ kind: "tailscale", target: tailscaleTarget.replace(/\.$/, ""), reachable: input.peer?.Online ?? null });
|
|
8562
|
+
}
|
|
8563
|
+
return hints;
|
|
8564
|
+
}
|
|
8565
|
+
function buildEntry(input) {
|
|
8566
|
+
const manifest = input.manifest;
|
|
8567
|
+
const peer = input.peer;
|
|
8568
|
+
const hints = routeHints({
|
|
8569
|
+
machineId: input.machineId,
|
|
8570
|
+
localMachineId: input.localMachineId,
|
|
8571
|
+
manifest,
|
|
8572
|
+
peer
|
|
8573
|
+
});
|
|
8574
|
+
const selectedRoute = hints.find((hint) => hint.kind === "local") ?? hints.find((hint) => hint.kind === "ssh") ?? hints.find((hint) => hint.kind === "lan") ?? hints.find((hint) => hint.kind === "tailscale");
|
|
8575
|
+
const route = selectedRoute?.kind === "ssh" ? "lan" : selectedRoute?.kind ?? "unknown";
|
|
8576
|
+
return {
|
|
8577
|
+
machine_id: input.machineId,
|
|
8578
|
+
hostname: manifest?.hostname ?? peer?.HostName ?? null,
|
|
8579
|
+
platform: manifest?.platform ?? (peer?.OS ? normalizePlatform2(peer.OS) : null),
|
|
8580
|
+
os: peer?.OS ?? null,
|
|
8581
|
+
user: typeof manifest?.metadata?.user === "string" ? manifest.metadata.user : null,
|
|
8582
|
+
workspace_path: manifest?.workspacePath ?? null,
|
|
8583
|
+
manifest_declared: Boolean(manifest),
|
|
8584
|
+
heartbeat_status: input.heartbeat?.status ?? "unknown",
|
|
8585
|
+
last_heartbeat_at: input.heartbeat?.updated_at ?? null,
|
|
8586
|
+
tailscale: {
|
|
8587
|
+
dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
|
|
8588
|
+
ips: peer?.TailscaleIPs ?? [],
|
|
8589
|
+
online: peer?.Online ?? null,
|
|
8590
|
+
active: peer?.Active ?? null,
|
|
8591
|
+
last_seen: peer?.LastSeen ?? null
|
|
8592
|
+
},
|
|
8593
|
+
ssh: {
|
|
8594
|
+
address: manifest?.sshAddress ?? null,
|
|
8595
|
+
route,
|
|
8596
|
+
command_target: selectedRoute?.target ?? null
|
|
8597
|
+
},
|
|
8598
|
+
route_hints: hints,
|
|
8599
|
+
tags: manifest?.tags ?? [],
|
|
8600
|
+
metadata: manifest?.metadata ?? {}
|
|
8601
|
+
};
|
|
8602
|
+
}
|
|
8603
|
+
function discoverMachineTopology(options = {}) {
|
|
8604
|
+
const now = options.now ?? new Date;
|
|
8605
|
+
const runner = options.runner ?? defaultRunner;
|
|
8606
|
+
const warnings = [];
|
|
8607
|
+
const manifest = readManifest();
|
|
8608
|
+
const heartbeats = listHeartbeats();
|
|
8609
|
+
const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
|
|
8610
|
+
const localMachineId = getLocalMachineId();
|
|
8611
|
+
const peers = options.includeTailscale === false ? new Map : loadTailscalePeers(runner, warnings);
|
|
8612
|
+
const machineIds = new Set([
|
|
8613
|
+
localMachineId,
|
|
8614
|
+
...manifest.machines.map((machine) => machine.id),
|
|
8615
|
+
...heartbeats.map((heartbeat) => heartbeat.machine_id),
|
|
8616
|
+
...peers.keys()
|
|
8617
|
+
]);
|
|
8618
|
+
const manifestById = new Map(manifest.machines.map((machine) => [machine.id, machine]));
|
|
8619
|
+
const machines = [...machineIds].sort().map((machineId) => {
|
|
8620
|
+
const manifestMachine = manifestById.get(machineId);
|
|
8621
|
+
return buildEntry({
|
|
8622
|
+
machineId,
|
|
8623
|
+
localMachineId,
|
|
8624
|
+
manifest: manifestMachine,
|
|
8625
|
+
peer: findTailscalePeer(manifestMachine ?? null, machineId, peers),
|
|
8626
|
+
heartbeat: heartbeatByMachine.get(machineId)
|
|
8627
|
+
});
|
|
8628
|
+
});
|
|
8629
|
+
return {
|
|
8630
|
+
generated_at: now.toISOString(),
|
|
8631
|
+
local_machine_id: localMachineId,
|
|
8632
|
+
local_hostname: hostname4(),
|
|
8633
|
+
current_platform: normalizePlatform2(),
|
|
8634
|
+
manifest_path_known: existsSync7(getManifestPath()),
|
|
8635
|
+
machines,
|
|
8636
|
+
warnings
|
|
8637
|
+
};
|
|
8638
|
+
}
|
|
8639
|
+
|
|
8640
|
+
// src/compatibility.ts
|
|
8641
|
+
init_db();
|
|
8642
|
+
var DEFAULT_COMMANDS = [
|
|
8643
|
+
{ command: "bun", required: true },
|
|
8644
|
+
{ command: "machines", required: true }
|
|
8645
|
+
];
|
|
8646
|
+
function defaultPackages() {
|
|
8647
|
+
return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
|
|
8648
|
+
}
|
|
8649
|
+
function shellQuote4(value) {
|
|
8650
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
8651
|
+
}
|
|
8652
|
+
function commandId(value) {
|
|
8653
|
+
return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8654
|
+
}
|
|
8655
|
+
function packageCommand(name) {
|
|
8656
|
+
if (name === "@hasna/knowledge")
|
|
8657
|
+
return "knowledge";
|
|
8658
|
+
if (name === "@hasna/machines")
|
|
8659
|
+
return "machines";
|
|
8660
|
+
return name.split("/").pop() ?? name;
|
|
8661
|
+
}
|
|
8662
|
+
function firstLine(value) {
|
|
8663
|
+
return value.trim().split(/\r?\n/).find(Boolean) ?? "";
|
|
8664
|
+
}
|
|
8665
|
+
function extractVersion(value) {
|
|
8666
|
+
const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
|
|
8667
|
+
return match?.[0] ?? null;
|
|
8668
|
+
}
|
|
8669
|
+
function statusFor(required, ok) {
|
|
8670
|
+
if (ok)
|
|
8671
|
+
return "ok";
|
|
8672
|
+
return required === false ? "warn" : "fail";
|
|
8673
|
+
}
|
|
8674
|
+
function makeCheck(input) {
|
|
8675
|
+
return {
|
|
8676
|
+
id: input.id,
|
|
8677
|
+
kind: input.kind,
|
|
8678
|
+
status: input.status,
|
|
8679
|
+
target: input.target,
|
|
8680
|
+
expected: input.expected ?? null,
|
|
8681
|
+
actual: input.actual ?? null,
|
|
8682
|
+
detail: input.detail,
|
|
8683
|
+
source: input.source
|
|
8684
|
+
};
|
|
8685
|
+
}
|
|
8686
|
+
function parseKeyValue(stdout) {
|
|
8687
|
+
const result = {};
|
|
8688
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
8689
|
+
const idx = line.indexOf("=");
|
|
8690
|
+
if (idx <= 0)
|
|
8691
|
+
continue;
|
|
8692
|
+
result[line.slice(0, idx)] = line.slice(idx + 1);
|
|
8693
|
+
}
|
|
8694
|
+
return result;
|
|
8695
|
+
}
|
|
8696
|
+
function defaultRunner2(machineId, command) {
|
|
8697
|
+
return runMachineCommand(machineId, command);
|
|
8698
|
+
}
|
|
8699
|
+
function inspectCommand(machineId, spec, runner) {
|
|
8700
|
+
const command = shellQuote4(spec.command);
|
|
8701
|
+
const versionArgs = spec.versionArgs ?? "--version";
|
|
8702
|
+
const script = [
|
|
8703
|
+
`cmd=${command}`,
|
|
8704
|
+
'path="$(command -v "$cmd" 2>/dev/null || true)"',
|
|
8705
|
+
'printf "path=%s\\n" "$path"',
|
|
8706
|
+
'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
|
|
8707
|
+
].join("; ");
|
|
8708
|
+
const result = runner(machineId, script);
|
|
8709
|
+
const parsed = parseKeyValue(result.stdout);
|
|
8710
|
+
return {
|
|
8711
|
+
path: parsed.path || null,
|
|
8712
|
+
version: parsed.version ? firstLine(parsed.version) : null,
|
|
8713
|
+
exitCode: result.exitCode,
|
|
8714
|
+
source: result.source,
|
|
8715
|
+
stderr: result.stderr
|
|
8716
|
+
};
|
|
8717
|
+
}
|
|
8718
|
+
function fieldCommand(field) {
|
|
8719
|
+
const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
8720
|
+
return [
|
|
8721
|
+
`if command -v bun >/dev/null 2>&1; then bun -e "const p=JSON.parse(await Bun.file(process.argv[1]).text()); console.log(p.${field} ?? '')" "$pkg" 2>/dev/null`,
|
|
8722
|
+
`elif command -v node >/dev/null 2>&1; then node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(p.${field} || '')" "$pkg" 2>/dev/null`,
|
|
8723
|
+
`else sed -n '${regex}' "$pkg" | head -n 1`,
|
|
8724
|
+
"fi"
|
|
8725
|
+
].join("; ");
|
|
8726
|
+
}
|
|
8727
|
+
function inspectWorkspace(machineId, spec, runner) {
|
|
8728
|
+
const script = [
|
|
8729
|
+
`path=${shellQuote4(spec.path)}`,
|
|
8730
|
+
'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
|
|
8731
|
+
'pkg="$path/package.json"',
|
|
8732
|
+
'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
|
|
8733
|
+
`if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
|
|
8734
|
+
].join("; ");
|
|
8735
|
+
const result = runner(machineId, script);
|
|
8736
|
+
const parsed = parseKeyValue(result.stdout);
|
|
8737
|
+
return {
|
|
8738
|
+
exists: parsed.exists === "yes",
|
|
8739
|
+
packageJson: parsed.package_json === "yes",
|
|
8740
|
+
packageName: parsed.package_name || null,
|
|
8741
|
+
version: parsed.version || null,
|
|
8742
|
+
source: result.source,
|
|
8743
|
+
stderr: result.stderr
|
|
8744
|
+
};
|
|
8745
|
+
}
|
|
8746
|
+
function commandCheck(machineId, spec, runner) {
|
|
8747
|
+
const inspection = inspectCommand(machineId, spec, runner);
|
|
8748
|
+
const found = Boolean(inspection.path);
|
|
8749
|
+
const checks = [
|
|
8750
|
+
makeCheck({
|
|
8751
|
+
id: `command:${commandId(spec.command)}:path`,
|
|
8752
|
+
kind: "command",
|
|
8753
|
+
status: statusFor(spec.required, found),
|
|
8754
|
+
target: spec.command,
|
|
8755
|
+
expected: "available",
|
|
8756
|
+
actual: inspection.path ?? "missing",
|
|
8757
|
+
detail: found ? `found at ${inspection.path}` : inspection.stderr || "command missing",
|
|
8758
|
+
source: inspection.source
|
|
8759
|
+
})
|
|
8760
|
+
];
|
|
8761
|
+
if (spec.expectedVersion) {
|
|
8762
|
+
const actualVersion = extractVersion(inspection.version ?? "");
|
|
8763
|
+
checks.push(makeCheck({
|
|
8764
|
+
id: `command:${commandId(spec.command)}:version`,
|
|
8765
|
+
kind: "command",
|
|
8766
|
+
status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8767
|
+
target: spec.command,
|
|
8768
|
+
expected: spec.expectedVersion,
|
|
8769
|
+
actual: actualVersion ?? inspection.version ?? "missing",
|
|
8770
|
+
detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
|
|
8771
|
+
source: inspection.source
|
|
8772
|
+
}));
|
|
8773
|
+
}
|
|
8774
|
+
return checks;
|
|
8775
|
+
}
|
|
8776
|
+
function packageCheck(machineId, spec, runner) {
|
|
8777
|
+
const command = spec.command ?? packageCommand(spec.name);
|
|
8778
|
+
const inspection = inspectCommand(machineId, { command, expectedVersion: spec.expectedVersion, required: spec.required }, runner);
|
|
8779
|
+
const found = Boolean(inspection.path);
|
|
8780
|
+
const checks = [
|
|
8781
|
+
makeCheck({
|
|
8782
|
+
id: `package:${commandId(spec.name)}:command`,
|
|
8783
|
+
kind: "package",
|
|
8784
|
+
status: statusFor(spec.required, found),
|
|
8785
|
+
target: spec.name,
|
|
8786
|
+
expected: command,
|
|
8787
|
+
actual: inspection.path ?? "missing",
|
|
8788
|
+
detail: found ? `${command} found at ${inspection.path}` : `${command} command missing`,
|
|
8789
|
+
source: inspection.source
|
|
8790
|
+
})
|
|
8791
|
+
];
|
|
8792
|
+
if (spec.expectedVersion) {
|
|
8793
|
+
const actualVersion = extractVersion(inspection.version ?? "");
|
|
8794
|
+
checks.push(makeCheck({
|
|
8795
|
+
id: `package:${commandId(spec.name)}:version`,
|
|
8796
|
+
kind: "package",
|
|
8797
|
+
status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8798
|
+
target: spec.name,
|
|
8799
|
+
expected: spec.expectedVersion,
|
|
8800
|
+
actual: actualVersion ?? inspection.version ?? "missing",
|
|
8801
|
+
detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
|
|
8802
|
+
source: inspection.source
|
|
8803
|
+
}));
|
|
8804
|
+
}
|
|
8805
|
+
return checks;
|
|
8806
|
+
}
|
|
8807
|
+
function workspaceCheck(machineId, spec, runner) {
|
|
8808
|
+
const inspection = inspectWorkspace(machineId, spec, runner);
|
|
8809
|
+
const target = spec.label ?? spec.path;
|
|
8810
|
+
const checks = [
|
|
8811
|
+
makeCheck({
|
|
8812
|
+
id: `workspace:${commandId(target)}:path`,
|
|
8813
|
+
kind: "workspace",
|
|
8814
|
+
status: statusFor(spec.required, inspection.exists),
|
|
8815
|
+
target,
|
|
8816
|
+
expected: spec.path,
|
|
8817
|
+
actual: inspection.exists ? "exists" : "missing",
|
|
8818
|
+
detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
|
|
8819
|
+
source: inspection.source
|
|
8820
|
+
})
|
|
8821
|
+
];
|
|
8822
|
+
if (spec.expectedPackageName) {
|
|
8823
|
+
checks.push(makeCheck({
|
|
8824
|
+
id: `workspace:${commandId(target)}:package-name`,
|
|
8825
|
+
kind: "workspace",
|
|
8826
|
+
status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
|
|
8827
|
+
target,
|
|
8828
|
+
expected: spec.expectedPackageName,
|
|
8829
|
+
actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
|
|
8830
|
+
detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
|
|
8831
|
+
source: inspection.source
|
|
8832
|
+
}));
|
|
8833
|
+
}
|
|
8834
|
+
if (spec.expectedVersion) {
|
|
8835
|
+
checks.push(makeCheck({
|
|
8836
|
+
id: `workspace:${commandId(target)}:version`,
|
|
8837
|
+
kind: "workspace",
|
|
8838
|
+
status: inspection.version === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8839
|
+
target,
|
|
8840
|
+
expected: spec.expectedVersion,
|
|
8841
|
+
actual: inspection.version ?? (inspection.packageJson ? "missing-version" : "missing-package-json"),
|
|
8842
|
+
detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
|
|
8843
|
+
source: inspection.source
|
|
8844
|
+
}));
|
|
8845
|
+
}
|
|
8846
|
+
return checks;
|
|
8847
|
+
}
|
|
8848
|
+
function checkMachineCompatibility(options = {}) {
|
|
8849
|
+
const machineId = options.machineId ?? getLocalMachineId();
|
|
8850
|
+
const runner = options.runner ?? defaultRunner2;
|
|
8851
|
+
const commands = options.commands ?? DEFAULT_COMMANDS;
|
|
8852
|
+
const packages = options.packages ?? defaultPackages();
|
|
8853
|
+
const workspaces = options.workspaces ?? [];
|
|
8854
|
+
const checks = [];
|
|
8855
|
+
for (const spec of commands)
|
|
8856
|
+
checks.push(...commandCheck(machineId, spec, runner));
|
|
8857
|
+
for (const spec of packages)
|
|
8858
|
+
checks.push(...packageCheck(machineId, spec, runner));
|
|
8859
|
+
for (const spec of workspaces)
|
|
8860
|
+
checks.push(...workspaceCheck(machineId, spec, runner));
|
|
8861
|
+
const summary = {
|
|
8862
|
+
ok: checks.filter((check) => check.status === "ok").length,
|
|
8863
|
+
warn: checks.filter((check) => check.status === "warn").length,
|
|
8864
|
+
fail: checks.filter((check) => check.status === "fail").length
|
|
8865
|
+
};
|
|
8866
|
+
return {
|
|
8867
|
+
ok: summary.fail === 0,
|
|
8868
|
+
machine_id: machineId,
|
|
8869
|
+
source: checks[0]?.source ?? "local",
|
|
8870
|
+
generated_at: (options.now ?? new Date).toISOString(),
|
|
8871
|
+
checks,
|
|
8872
|
+
summary
|
|
8873
|
+
};
|
|
8874
|
+
}
|
|
8875
|
+
|
|
8057
8876
|
// src/commands/doctor.ts
|
|
8058
|
-
|
|
8877
|
+
init_db();
|
|
8878
|
+
function makeCheck2(id, status, summary, detail) {
|
|
8059
8879
|
return { id, status, summary, detail };
|
|
8060
8880
|
}
|
|
8061
8881
|
function parseKeyValueOutput(stdout) {
|
|
@@ -8087,15 +8907,15 @@ function runDoctor(machineId = getLocalMachineId()) {
|
|
|
8087
8907
|
const details = parseKeyValueOutput(commandChecks.stdout);
|
|
8088
8908
|
const machineInManifest = manifest.machines.find((machine) => machine.id === machineId);
|
|
8089
8909
|
const checks = [
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8910
|
+
makeCheck2("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", machineInManifest ? JSON.stringify(machineInManifest) : `No manifest entry for ${machineId}`),
|
|
8911
|
+
makeCheck2("manifest-path", details["manifest_exists"] === "yes" ? "ok" : "warn", "Manifest path check", `${details["manifest_path"] || "unknown"} ${details["manifest_exists"] === "yes" ? "exists" : "missing"}`),
|
|
8912
|
+
makeCheck2("db-path", details["db_exists"] === "yes" ? "ok" : "warn", "DB path check", `${details["db_path"] || "unknown"} ${details["db_exists"] === "yes" ? "exists" : "missing"}`),
|
|
8913
|
+
makeCheck2("notifications-path", details["notifications_exists"] === "yes" ? "ok" : "warn", "Notifications path check", `${details["notifications_path"] || "unknown"} ${details["notifications_exists"] === "yes" ? "exists" : "missing"}`),
|
|
8914
|
+
makeCheck2("bun", details["bun"] && details["bun"] !== "missing" ? "ok" : "fail", "Bun availability", details["bun"] || "missing"),
|
|
8915
|
+
makeCheck2("machines-cli", details["machines"] && details["machines"] !== "missing" ? "ok" : "warn", "machines CLI availability", details["machines"] || "missing"),
|
|
8916
|
+
makeCheck2("machines-agent-cli", details["machines_agent"] && details["machines_agent"] !== "missing" ? "ok" : "warn", "machines-agent availability", details["machines_agent"] || "missing"),
|
|
8917
|
+
makeCheck2("machines-mcp-cli", details["machines_mcp"] && details["machines_mcp"] !== "missing" ? "ok" : "warn", "machines-mcp availability", details["machines_mcp"] || "missing"),
|
|
8918
|
+
makeCheck2("ssh", details["ssh"] === "ok" ? "ok" : "warn", "SSH availability", details["ssh"] || "missing")
|
|
8099
8919
|
];
|
|
8100
8920
|
return {
|
|
8101
8921
|
machineId,
|
|
@@ -8107,6 +8927,9 @@ function runDoctor(machineId = getLocalMachineId()) {
|
|
|
8107
8927
|
};
|
|
8108
8928
|
}
|
|
8109
8929
|
|
|
8930
|
+
// src/commands/self-test.ts
|
|
8931
|
+
init_db();
|
|
8932
|
+
|
|
8110
8933
|
// src/commands/serve.ts
|
|
8111
8934
|
function escapeHtml(value) {
|
|
8112
8935
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
@@ -8393,8 +9216,9 @@ function runSelfTest() {
|
|
|
8393
9216
|
}
|
|
8394
9217
|
|
|
8395
9218
|
// src/commands/clipboard.ts
|
|
9219
|
+
init_paths();
|
|
8396
9220
|
import { createHash } from "crypto";
|
|
8397
|
-
import { existsSync as
|
|
9221
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
|
|
8398
9222
|
import { join as join6 } from "path";
|
|
8399
9223
|
var DEFAULT_CONFIG = {
|
|
8400
9224
|
version: 1,
|
|
@@ -8425,7 +9249,7 @@ function getDefaultConfig() {
|
|
|
8425
9249
|
}
|
|
8426
9250
|
function readConfig(configPath) {
|
|
8427
9251
|
const path = resolveConfigPath(configPath);
|
|
8428
|
-
if (!
|
|
9252
|
+
if (!existsSync8(path)) {
|
|
8429
9253
|
return getDefaultConfig();
|
|
8430
9254
|
}
|
|
8431
9255
|
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
@@ -8439,7 +9263,7 @@ function writeConfig(config, configPath) {
|
|
|
8439
9263
|
}
|
|
8440
9264
|
function readHistory(historyPath) {
|
|
8441
9265
|
const path = resolveHistoryPath(historyPath);
|
|
8442
|
-
if (!
|
|
9266
|
+
if (!existsSync8(path)) {
|
|
8443
9267
|
return [];
|
|
8444
9268
|
}
|
|
8445
9269
|
try {
|
|
@@ -8472,7 +9296,7 @@ function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
|
|
|
8472
9296
|
}
|
|
8473
9297
|
function getOrCreateClipboardKey() {
|
|
8474
9298
|
const keyPath = getClipboardKeyPath();
|
|
8475
|
-
if (
|
|
9299
|
+
if (existsSync8(keyPath)) {
|
|
8476
9300
|
return readFileSync6(keyPath, "utf8").trim();
|
|
8477
9301
|
}
|
|
8478
9302
|
const key = createHash("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
|
|
@@ -8512,7 +9336,7 @@ function addClipboardEntry(entry, historyPath) {
|
|
|
8512
9336
|
}
|
|
8513
9337
|
function clearClipboardHistory(historyPath) {
|
|
8514
9338
|
const path = resolveHistoryPath(historyPath);
|
|
8515
|
-
if (
|
|
9339
|
+
if (existsSync8(path)) {
|
|
8516
9340
|
rmSync(path);
|
|
8517
9341
|
}
|
|
8518
9342
|
}
|
|
@@ -8527,26 +9351,28 @@ function getClipboardStatus(historyPath) {
|
|
|
8527
9351
|
}
|
|
8528
9352
|
|
|
8529
9353
|
// src/commands/clipboard-daemon.ts
|
|
9354
|
+
init_paths();
|
|
8530
9355
|
import { readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
8531
9356
|
import { join as join7 } from "path";
|
|
8532
9357
|
import { createHash as createHash3 } from "crypto";
|
|
8533
9358
|
|
|
8534
9359
|
// src/commands/clipboard-server.ts
|
|
9360
|
+
init_paths();
|
|
8535
9361
|
import { createServer } from "http";
|
|
8536
9362
|
import { createHash as createHash2 } from "crypto";
|
|
8537
9363
|
import { readFileSync as readFileSync7 } from "fs";
|
|
8538
9364
|
function readLocalClipboardSync() {
|
|
8539
|
-
const
|
|
8540
|
-
if (
|
|
9365
|
+
const platform4 = process.platform;
|
|
9366
|
+
if (platform4 === "darwin") {
|
|
8541
9367
|
const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
|
|
8542
9368
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8543
9369
|
}
|
|
8544
|
-
if (
|
|
8545
|
-
if (
|
|
9370
|
+
if (platform4 === "linux") {
|
|
9371
|
+
if (hasCommand3("wl-paste")) {
|
|
8546
9372
|
const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
|
|
8547
9373
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8548
9374
|
}
|
|
8549
|
-
if (
|
|
9375
|
+
if (hasCommand3("xclip")) {
|
|
8550
9376
|
const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
|
|
8551
9377
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8552
9378
|
}
|
|
@@ -8555,17 +9381,17 @@ function readLocalClipboardSync() {
|
|
|
8555
9381
|
return "";
|
|
8556
9382
|
}
|
|
8557
9383
|
function writeLocalClipboardSync(content) {
|
|
8558
|
-
const
|
|
8559
|
-
if (
|
|
9384
|
+
const platform4 = process.platform;
|
|
9385
|
+
if (platform4 === "darwin") {
|
|
8560
9386
|
const result = Bun.spawnSync(["pbcopy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
8561
9387
|
return result.exitCode === 0;
|
|
8562
9388
|
}
|
|
8563
|
-
if (
|
|
8564
|
-
if (
|
|
9389
|
+
if (platform4 === "linux") {
|
|
9390
|
+
if (hasCommand3("wl-copy")) {
|
|
8565
9391
|
const result = Bun.spawnSync(["wl-copy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
8566
9392
|
return result.exitCode === 0;
|
|
8567
9393
|
}
|
|
8568
|
-
if (
|
|
9394
|
+
if (hasCommand3("xclip")) {
|
|
8569
9395
|
const result = Bun.spawnSync(["xclip", "-selection", "clipboard"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
8570
9396
|
return result.exitCode === 0;
|
|
8571
9397
|
}
|
|
@@ -8573,7 +9399,7 @@ function writeLocalClipboardSync(content) {
|
|
|
8573
9399
|
}
|
|
8574
9400
|
return false;
|
|
8575
9401
|
}
|
|
8576
|
-
function
|
|
9402
|
+
function hasCommand3(binary) {
|
|
8577
9403
|
const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
|
|
8578
9404
|
return result.exitCode === 0;
|
|
8579
9405
|
}
|
|
@@ -8687,17 +9513,17 @@ function handleGetClipboard(response, config) {
|
|
|
8687
9513
|
// src/commands/clipboard-daemon.ts
|
|
8688
9514
|
var DAEMON_PID_PATH = join7(getDataDir(), "clipboard-daemon.pid");
|
|
8689
9515
|
function readLocalClipboardSync2() {
|
|
8690
|
-
const
|
|
8691
|
-
if (
|
|
9516
|
+
const platform4 = process.platform;
|
|
9517
|
+
if (platform4 === "darwin") {
|
|
8692
9518
|
const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
|
|
8693
9519
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8694
9520
|
}
|
|
8695
|
-
if (
|
|
8696
|
-
if (
|
|
9521
|
+
if (platform4 === "linux") {
|
|
9522
|
+
if (hasCommand4("wl-paste")) {
|
|
8697
9523
|
const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
|
|
8698
9524
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8699
9525
|
}
|
|
8700
|
-
if (
|
|
9526
|
+
if (hasCommand4("xclip")) {
|
|
8701
9527
|
const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
|
|
8702
9528
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8703
9529
|
}
|
|
@@ -8705,7 +9531,7 @@ function readLocalClipboardSync2() {
|
|
|
8705
9531
|
}
|
|
8706
9532
|
return "";
|
|
8707
9533
|
}
|
|
8708
|
-
function
|
|
9534
|
+
function hasCommand4(binary) {
|
|
8709
9535
|
const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
|
|
8710
9536
|
return result.exitCode === 0;
|
|
8711
9537
|
}
|
|
@@ -8857,6 +9683,500 @@ async function discoverPeers() {
|
|
|
8857
9683
|
return peers;
|
|
8858
9684
|
}
|
|
8859
9685
|
|
|
9686
|
+
// src/commands/heal.ts
|
|
9687
|
+
init_paths();
|
|
9688
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
9689
|
+
import { join as join8 } from "path";
|
|
9690
|
+
var DEFAULT_THRESHOLDS = {
|
|
9691
|
+
reconnect: 3,
|
|
9692
|
+
nmRestart: 7,
|
|
9693
|
+
fallback: 12,
|
|
9694
|
+
reboot: 15
|
|
9695
|
+
};
|
|
9696
|
+
var DEFAULT_HEAL_CONFIG = {
|
|
9697
|
+
version: 1,
|
|
9698
|
+
enabled: true,
|
|
9699
|
+
wifiInterface: "",
|
|
9700
|
+
preferredSsid: "",
|
|
9701
|
+
fallbackSsid: "",
|
|
9702
|
+
internetUrl: "https://1.1.1.1",
|
|
9703
|
+
tailscaleAnchors: [],
|
|
9704
|
+
quorumRequired: 2,
|
|
9705
|
+
intervalSec: 60,
|
|
9706
|
+
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
9707
|
+
rebootMinIntervalSec: 1800,
|
|
9708
|
+
nmRestartMinIntervalSec: 1800,
|
|
9709
|
+
reconnectMinIntervalSec: 120,
|
|
9710
|
+
healthyWindowSec: 300,
|
|
9711
|
+
maxFailedBootRecoveries: 2,
|
|
9712
|
+
bootBackoffSec: 21600,
|
|
9713
|
+
fallbackWindowSec: 600,
|
|
9714
|
+
gpuJobGuard: true,
|
|
9715
|
+
allowReboot: true
|
|
9716
|
+
};
|
|
9717
|
+
function defaultHealState() {
|
|
9718
|
+
return {
|
|
9719
|
+
failCount: 0,
|
|
9720
|
+
bootId: "",
|
|
9721
|
+
bootHealthySince: null,
|
|
9722
|
+
lastRebootAttempt: 0,
|
|
9723
|
+
lastNmRestart: 0,
|
|
9724
|
+
lastReconnect: 0,
|
|
9725
|
+
lastFallback: 0,
|
|
9726
|
+
degradedUntil: 0,
|
|
9727
|
+
pendingRebootRecovery: false,
|
|
9728
|
+
failedBootRecoveries: 0,
|
|
9729
|
+
rebootSuppressUntil: 0
|
|
9730
|
+
};
|
|
9731
|
+
}
|
|
9732
|
+
function getHealConfigPath() {
|
|
9733
|
+
return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] || join8(getDataDir(), "heal-config.json");
|
|
9734
|
+
}
|
|
9735
|
+
function getHealStatePath() {
|
|
9736
|
+
return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] || join8(getDataDir(), "heal-state.json");
|
|
9737
|
+
}
|
|
9738
|
+
function readHealConfig(path) {
|
|
9739
|
+
const p = path || getHealConfigPath();
|
|
9740
|
+
if (!existsSync9(p))
|
|
9741
|
+
return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
|
|
9742
|
+
const parsed = JSON.parse(readFileSync9(p, "utf8"));
|
|
9743
|
+
return {
|
|
9744
|
+
...DEFAULT_HEAL_CONFIG,
|
|
9745
|
+
...parsed,
|
|
9746
|
+
thresholds: { ...DEFAULT_THRESHOLDS, ...parsed.thresholds || {} },
|
|
9747
|
+
tailscaleAnchors: parsed.tailscaleAnchors ?? []
|
|
9748
|
+
};
|
|
9749
|
+
}
|
|
9750
|
+
function writeHealConfig(config, path) {
|
|
9751
|
+
const p = path || getHealConfigPath();
|
|
9752
|
+
ensureParentDir(p);
|
|
9753
|
+
writeFileSync6(p, `${JSON.stringify(config, null, 2)}
|
|
9754
|
+
`, "utf8");
|
|
9755
|
+
}
|
|
9756
|
+
function readHealState(path) {
|
|
9757
|
+
const p = path || getHealStatePath();
|
|
9758
|
+
if (!existsSync9(p))
|
|
9759
|
+
return defaultHealState();
|
|
9760
|
+
try {
|
|
9761
|
+
return { ...defaultHealState(), ...JSON.parse(readFileSync9(p, "utf8")) };
|
|
9762
|
+
} catch {
|
|
9763
|
+
return defaultHealState();
|
|
9764
|
+
}
|
|
9765
|
+
}
|
|
9766
|
+
function writeHealState(state, path) {
|
|
9767
|
+
const p = path || getHealStatePath();
|
|
9768
|
+
ensureParentDir(p);
|
|
9769
|
+
writeFileSync6(p, `${JSON.stringify(state, null, 2)}
|
|
9770
|
+
`, "utf8");
|
|
9771
|
+
}
|
|
9772
|
+
function evaluateHealth(probe, config, state) {
|
|
9773
|
+
const reasons = [];
|
|
9774
|
+
const inDegraded = state.degradedUntil > 0;
|
|
9775
|
+
const acceptableSsid = probe.associatedSsid === config.preferredSsid || config.fallbackSsid !== "" && inDegraded && probe.associatedSsid === config.fallbackSsid;
|
|
9776
|
+
if (!acceptableSsid)
|
|
9777
|
+
reasons.push(`wrong-ssid:${probe.associatedSsid ?? "none"}`);
|
|
9778
|
+
if (!probe.gatewayReachable)
|
|
9779
|
+
reasons.push("gateway-unreachable");
|
|
9780
|
+
let remoteScore = 0;
|
|
9781
|
+
for (const [anchor, ok] of Object.entries(probe.anchorsReachable)) {
|
|
9782
|
+
if (ok)
|
|
9783
|
+
remoteScore += 1;
|
|
9784
|
+
else
|
|
9785
|
+
reasons.push(`anchor-down:${anchor}`);
|
|
9786
|
+
}
|
|
9787
|
+
if (probe.internetReachable)
|
|
9788
|
+
remoteScore += 1;
|
|
9789
|
+
else
|
|
9790
|
+
reasons.push("internet-down");
|
|
9791
|
+
const localOk = acceptableSsid && probe.gatewayReachable;
|
|
9792
|
+
const quorumOk = remoteScore >= config.quorumRequired;
|
|
9793
|
+
if (!quorumOk)
|
|
9794
|
+
reasons.push(`quorum:${remoteScore}/${config.quorumRequired}`);
|
|
9795
|
+
return { healthy: localOk && quorumOk, remoteScore, reasons };
|
|
9796
|
+
}
|
|
9797
|
+
function decideAction(input) {
|
|
9798
|
+
const { healthy, now, gpuBusy, config, currentBootId } = input;
|
|
9799
|
+
const s = { ...input.state };
|
|
9800
|
+
const t = config.thresholds;
|
|
9801
|
+
if (s.bootId !== currentBootId) {
|
|
9802
|
+
s.bootId = currentBootId;
|
|
9803
|
+
s.bootHealthySince = null;
|
|
9804
|
+
s.failCount = 0;
|
|
9805
|
+
}
|
|
9806
|
+
if (healthy) {
|
|
9807
|
+
s.failCount = 0;
|
|
9808
|
+
if (s.bootHealthySince === null)
|
|
9809
|
+
s.bootHealthySince = now;
|
|
9810
|
+
if (now - s.bootHealthySince >= config.healthyWindowSec) {
|
|
9811
|
+
s.failedBootRecoveries = 0;
|
|
9812
|
+
s.rebootSuppressUntil = 0;
|
|
9813
|
+
s.pendingRebootRecovery = false;
|
|
9814
|
+
}
|
|
9815
|
+
if (s.degradedUntil > 0 && now >= s.degradedUntil) {
|
|
9816
|
+
s.degradedUntil = 0;
|
|
9817
|
+
return { action: "restore_preferred", state: s };
|
|
9818
|
+
}
|
|
9819
|
+
return { action: "none", state: s };
|
|
9820
|
+
}
|
|
9821
|
+
s.failCount += 1;
|
|
9822
|
+
s.bootHealthySince = null;
|
|
9823
|
+
let tier = "none";
|
|
9824
|
+
if (s.failCount >= t.reboot)
|
|
9825
|
+
tier = "reboot";
|
|
9826
|
+
else if (s.failCount >= t.fallback && config.fallbackSsid !== "")
|
|
9827
|
+
tier = "fallback";
|
|
9828
|
+
else if (s.failCount >= t.nmRestart)
|
|
9829
|
+
tier = "nmRestart";
|
|
9830
|
+
else if (s.failCount >= t.reconnect)
|
|
9831
|
+
tier = "reconnect";
|
|
9832
|
+
const tryReconnect = (reason) => {
|
|
9833
|
+
if (now - s.lastReconnect >= config.reconnectMinIntervalSec) {
|
|
9834
|
+
s.lastReconnect = now;
|
|
9835
|
+
return { action: "reconnect_wifi", suppressedReason: reason, state: s };
|
|
9836
|
+
}
|
|
9837
|
+
return { action: "none", suppressedReason: reason, state: s };
|
|
9838
|
+
};
|
|
9839
|
+
switch (tier) {
|
|
9840
|
+
case "reconnect":
|
|
9841
|
+
return tryReconnect();
|
|
9842
|
+
case "nmRestart":
|
|
9843
|
+
if (now - s.lastNmRestart >= config.nmRestartMinIntervalSec) {
|
|
9844
|
+
s.lastNmRestart = now;
|
|
9845
|
+
return { action: "restart_nm", state: s };
|
|
9846
|
+
}
|
|
9847
|
+
return tryReconnect();
|
|
9848
|
+
case "fallback":
|
|
9849
|
+
if (now - s.lastFallback >= config.fallbackWindowSec) {
|
|
9850
|
+
s.lastFallback = now;
|
|
9851
|
+
s.degradedUntil = now + config.fallbackWindowSec;
|
|
9852
|
+
return { action: "fallback_ssid", state: s };
|
|
9853
|
+
}
|
|
9854
|
+
return tryReconnect();
|
|
9855
|
+
case "reboot": {
|
|
9856
|
+
let reason = null;
|
|
9857
|
+
if (!config.allowReboot)
|
|
9858
|
+
reason = "disabled";
|
|
9859
|
+
else if (now < s.rebootSuppressUntil)
|
|
9860
|
+
reason = "loop";
|
|
9861
|
+
else if (config.gpuJobGuard && gpuBusy)
|
|
9862
|
+
reason = "gpu";
|
|
9863
|
+
else if (now - s.lastRebootAttempt < config.rebootMinIntervalSec)
|
|
9864
|
+
reason = "rate";
|
|
9865
|
+
if (reason)
|
|
9866
|
+
return tryReconnect(reason);
|
|
9867
|
+
if (s.pendingRebootRecovery) {
|
|
9868
|
+
s.failedBootRecoveries += 1;
|
|
9869
|
+
if (s.failedBootRecoveries >= config.maxFailedBootRecoveries) {
|
|
9870
|
+
s.rebootSuppressUntil = now + config.bootBackoffSec;
|
|
9871
|
+
return tryReconnect("loop");
|
|
9872
|
+
}
|
|
9873
|
+
}
|
|
9874
|
+
s.lastRebootAttempt = now;
|
|
9875
|
+
s.pendingRebootRecovery = true;
|
|
9876
|
+
return { action: "reboot", state: s };
|
|
9877
|
+
}
|
|
9878
|
+
default:
|
|
9879
|
+
return { action: "none", state: s };
|
|
9880
|
+
}
|
|
9881
|
+
}
|
|
9882
|
+
function sh(cmd, timeoutMs = 8000) {
|
|
9883
|
+
const r = Bun.spawnSync(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
|
|
9884
|
+
return { ok: r.exitCode === 0, out: r.stdout.toString("utf8").trim() };
|
|
9885
|
+
}
|
|
9886
|
+
function getCurrentBootId() {
|
|
9887
|
+
try {
|
|
9888
|
+
return readFileSync9("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
9889
|
+
} catch {
|
|
9890
|
+
return "";
|
|
9891
|
+
}
|
|
9892
|
+
}
|
|
9893
|
+
function detectWifiInterface() {
|
|
9894
|
+
const r = sh(`nmcli -t -f DEVICE,TYPE device status 2>/dev/null | awk -F: '$2=="wifi"{print $1; exit}'`);
|
|
9895
|
+
return r.ok ? r.out : "";
|
|
9896
|
+
}
|
|
9897
|
+
function detectGateway() {
|
|
9898
|
+
const r = sh(`ip route 2>/dev/null | awk '/^default/{print $3; exit}'`);
|
|
9899
|
+
return r.ok ? r.out : "";
|
|
9900
|
+
}
|
|
9901
|
+
function getAssociatedSsid() {
|
|
9902
|
+
const r = sh(`iwgetid -r 2>/dev/null || nmcli -t -f active,ssid dev wifi 2>/dev/null | awk -F: '/^yes/{print $2; exit}'`);
|
|
9903
|
+
return r.ok && r.out ? r.out : null;
|
|
9904
|
+
}
|
|
9905
|
+
function pingHost(host) {
|
|
9906
|
+
if (!host)
|
|
9907
|
+
return false;
|
|
9908
|
+
return sh(`ping -c1 -W2 ${host} >/dev/null 2>&1`, 5000).ok;
|
|
9909
|
+
}
|
|
9910
|
+
function internetReachable(url) {
|
|
9911
|
+
return sh(`curl -sf -m5 -o /dev/null ${url}`, 8000).ok;
|
|
9912
|
+
}
|
|
9913
|
+
function tailscalePing(host) {
|
|
9914
|
+
return sh(`timeout 8 tailscale ping --until-direct=false ${host} 2>/dev/null | grep -q pong`, 1e4).ok;
|
|
9915
|
+
}
|
|
9916
|
+
function gpuBusy() {
|
|
9917
|
+
return sh(`command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid --format=csv,noheader 2>/dev/null | grep -q .`, 6000).ok;
|
|
9918
|
+
}
|
|
9919
|
+
function discoverAnchors() {
|
|
9920
|
+
const r = sh(`tailscale status --json 2>/dev/null`);
|
|
9921
|
+
if (!r.ok)
|
|
9922
|
+
return [];
|
|
9923
|
+
try {
|
|
9924
|
+
const status = JSON.parse(r.out);
|
|
9925
|
+
const anchors = [];
|
|
9926
|
+
for (const peer of Object.values(status.Peer || {})) {
|
|
9927
|
+
const name = peer.HostName || (peer.DNSName || "").split(".")[0];
|
|
9928
|
+
if (name)
|
|
9929
|
+
anchors.push(name);
|
|
9930
|
+
}
|
|
9931
|
+
return anchors;
|
|
9932
|
+
} catch {
|
|
9933
|
+
return [];
|
|
9934
|
+
}
|
|
9935
|
+
}
|
|
9936
|
+
function probeHealth(config) {
|
|
9937
|
+
const gw = config.wifiInterface ? detectGateway() : detectGateway();
|
|
9938
|
+
const anchors = config.tailscaleAnchors.length > 0 ? config.tailscaleAnchors : discoverAnchors().slice(0, 3);
|
|
9939
|
+
const anchorsReachable = {};
|
|
9940
|
+
for (const a of anchors)
|
|
9941
|
+
anchorsReachable[a] = tailscalePing(a);
|
|
9942
|
+
return {
|
|
9943
|
+
associatedSsid: getAssociatedSsid(),
|
|
9944
|
+
gatewayReachable: pingHost(gw),
|
|
9945
|
+
anchorsReachable,
|
|
9946
|
+
internetReachable: internetReachable(config.internetUrl)
|
|
9947
|
+
};
|
|
9948
|
+
}
|
|
9949
|
+
function executeAction(action, config) {
|
|
9950
|
+
const iface = config.wifiInterface || detectWifiInterface();
|
|
9951
|
+
switch (action) {
|
|
9952
|
+
case "reconnect_wifi":
|
|
9953
|
+
sh(`nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
9954
|
+
return `reconnected wifi to ${config.preferredSsid}`;
|
|
9955
|
+
case "restart_nm":
|
|
9956
|
+
sh(`systemctl restart NetworkManager 2>&1; sleep 5; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 40000);
|
|
9957
|
+
return "restarted NetworkManager";
|
|
9958
|
+
case "fallback_ssid":
|
|
9959
|
+
sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect yes 2>&1; nmcli connection up "${config.fallbackSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
9960
|
+
return `switched to degraded fallback ${config.fallbackSsid}`;
|
|
9961
|
+
case "restore_preferred":
|
|
9962
|
+
sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect no 2>&1; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
9963
|
+
return `restored preferred ${config.preferredSsid}`;
|
|
9964
|
+
case "reboot":
|
|
9965
|
+
sh(`systemctl reboot 2>&1 || reboot 2>&1`, 1e4);
|
|
9966
|
+
return "reboot issued";
|
|
9967
|
+
default:
|
|
9968
|
+
return "no action";
|
|
9969
|
+
}
|
|
9970
|
+
}
|
|
9971
|
+
|
|
9972
|
+
// src/commands/heal-daemon.ts
|
|
9973
|
+
init_paths();
|
|
9974
|
+
import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
|
|
9975
|
+
import { join as join9 } from "path";
|
|
9976
|
+
var DAEMON_PID_PATH2 = join9(getDataDir(), "heal-daemon.pid");
|
|
9977
|
+
var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
|
|
9978
|
+
var SYSTEM_CONF = "/etc/systemd/system.conf";
|
|
9979
|
+
function log(msg) {
|
|
9980
|
+
console.log(`${new Date().toISOString()} [machines-heal] ${msg}`);
|
|
9981
|
+
}
|
|
9982
|
+
function runHealOnce(config, opts = {}) {
|
|
9983
|
+
const state = readHealState();
|
|
9984
|
+
const probe = probeHealth(config);
|
|
9985
|
+
const health = evaluateHealth(probe, config, state);
|
|
9986
|
+
const busy = config.gpuJobGuard ? gpuBusy() : false;
|
|
9987
|
+
const decision = decideAction({
|
|
9988
|
+
state,
|
|
9989
|
+
healthy: health.healthy,
|
|
9990
|
+
now: Math.floor(Date.now() / 1000),
|
|
9991
|
+
gpuBusy: busy,
|
|
9992
|
+
config,
|
|
9993
|
+
currentBootId: getCurrentBootId()
|
|
9994
|
+
});
|
|
9995
|
+
let executed = "skipped (dry-run)";
|
|
9996
|
+
if (!opts.dryRun) {
|
|
9997
|
+
writeHealState(decision.state);
|
|
9998
|
+
if (decision.action !== "none")
|
|
9999
|
+
executed = executeAction(decision.action, config);
|
|
10000
|
+
else
|
|
10001
|
+
executed = "no action";
|
|
10002
|
+
}
|
|
10003
|
+
const result = {
|
|
10004
|
+
healthy: health.healthy,
|
|
10005
|
+
action: decision.action,
|
|
10006
|
+
suppressedReason: decision.suppressedReason,
|
|
10007
|
+
reasons: health.reasons,
|
|
10008
|
+
remoteScore: health.remoteScore,
|
|
10009
|
+
failCount: decision.state.failCount,
|
|
10010
|
+
executed
|
|
10011
|
+
};
|
|
10012
|
+
const sup = decision.suppressedReason ? ` suppressed=${decision.suppressedReason}` : "";
|
|
10013
|
+
log(health.healthy ? `healthy (quorum ${health.remoteScore}) action=${decision.action} ${executed}` : `UNHEALTHY [${health.reasons.join(",")}] fails=${decision.state.failCount} action=${decision.action}${sup} -> ${executed}`);
|
|
10014
|
+
return result;
|
|
10015
|
+
}
|
|
10016
|
+
function writePid2(pid) {
|
|
10017
|
+
writeFileSync7(DAEMON_PID_PATH2, `${pid}
|
|
10018
|
+
`);
|
|
10019
|
+
}
|
|
10020
|
+
function readPid2() {
|
|
10021
|
+
try {
|
|
10022
|
+
const pid = Number.parseInt(readFileSync10(DAEMON_PID_PATH2, "utf8").trim());
|
|
10023
|
+
return Number.isFinite(pid) ? pid : null;
|
|
10024
|
+
} catch {
|
|
10025
|
+
return null;
|
|
10026
|
+
}
|
|
10027
|
+
}
|
|
10028
|
+
function isProcessRunning2(pid) {
|
|
10029
|
+
try {
|
|
10030
|
+
process.kill(pid, 0);
|
|
10031
|
+
return true;
|
|
10032
|
+
} catch {
|
|
10033
|
+
return false;
|
|
10034
|
+
}
|
|
10035
|
+
}
|
|
10036
|
+
function stopHealDaemon() {
|
|
10037
|
+
const pid = readPid2();
|
|
10038
|
+
if (pid && isProcessRunning2(pid)) {
|
|
10039
|
+
process.kill(pid, "SIGTERM");
|
|
10040
|
+
return { stopped: true, pid };
|
|
10041
|
+
}
|
|
10042
|
+
return { stopped: false, pid };
|
|
10043
|
+
}
|
|
10044
|
+
function startHealDaemon() {
|
|
10045
|
+
const config = readHealConfig();
|
|
10046
|
+
if (!config.preferredSsid) {
|
|
10047
|
+
log("refusing to start: preferredSsid is not configured (run `machines heal config --set ...`)");
|
|
10048
|
+
process.exit(1);
|
|
10049
|
+
}
|
|
10050
|
+
writePid2(process.pid);
|
|
10051
|
+
log(`daemon started (pid ${process.pid}) interval=${config.intervalSec}s preferred=${config.preferredSsid}`);
|
|
10052
|
+
const tick = () => {
|
|
10053
|
+
try {
|
|
10054
|
+
runHealOnce(config);
|
|
10055
|
+
} catch (err) {
|
|
10056
|
+
log(`tick error: ${err.message}`);
|
|
10057
|
+
}
|
|
10058
|
+
};
|
|
10059
|
+
tick();
|
|
10060
|
+
setInterval(tick, Math.max(10, config.intervalSec) * 1000);
|
|
10061
|
+
}
|
|
10062
|
+
function sh2(cmd, timeoutMs = 15000) {
|
|
10063
|
+
const r = Bun.spawnSync(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
|
|
10064
|
+
return { ok: r.exitCode === 0, out: `${r.stdout.toString("utf8")}${r.stderr.toString("utf8")}`.trim() };
|
|
10065
|
+
}
|
|
10066
|
+
function applyDeterminism(config) {
|
|
10067
|
+
const iface = config.wifiInterface || detectWifiInterface();
|
|
10068
|
+
const log2 = [];
|
|
10069
|
+
if (!config.preferredSsid)
|
|
10070
|
+
return ["no preferredSsid configured; skipping determinism"];
|
|
10071
|
+
sh2(`nmcli connection modify "${config.preferredSsid}" connection.autoconnect yes connection.autoconnect-priority 10 802-11-wireless.powersave 2`);
|
|
10072
|
+
log2.push(`pinned ${config.preferredSsid} (autoconnect, priority 10, powersave off)`);
|
|
10073
|
+
const profiles = sh2(`nmcli -t -f NAME,TYPE connection show 2>/dev/null | awk -F: '$2 ~ /wireless/{print $1}'`).out.split(`
|
|
10074
|
+
`).filter(Boolean);
|
|
10075
|
+
for (const p of profiles) {
|
|
10076
|
+
if (p === config.preferredSsid)
|
|
10077
|
+
continue;
|
|
10078
|
+
if (p === config.fallbackSsid) {
|
|
10079
|
+
sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
|
|
10080
|
+
log2.push(`disabled autoconnect on fallback ${p}`);
|
|
10081
|
+
continue;
|
|
10082
|
+
}
|
|
10083
|
+
sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
|
|
10084
|
+
log2.push(`disabled autoconnect on ${p}`);
|
|
10085
|
+
}
|
|
10086
|
+
if (iface) {
|
|
10087
|
+
sh2(`iw dev ${iface} set power_save off 2>/dev/null || true`);
|
|
10088
|
+
log2.push(`power_save off on ${iface}`);
|
|
10089
|
+
}
|
|
10090
|
+
return log2;
|
|
10091
|
+
}
|
|
10092
|
+
function enableHardwareWatchdog() {
|
|
10093
|
+
const log2 = [];
|
|
10094
|
+
if (!existsSync10(SYSTEM_CONF))
|
|
10095
|
+
return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
|
|
10096
|
+
let conf = readFileSync10(SYSTEM_CONF, "utf8");
|
|
10097
|
+
const set = (key, value) => {
|
|
10098
|
+
const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
|
|
10099
|
+
if (re.test(conf))
|
|
10100
|
+
conf = conf.replace(re, `${key}=${value}`);
|
|
10101
|
+
else
|
|
10102
|
+
conf += `
|
|
10103
|
+
${key}=${value}
|
|
10104
|
+
`;
|
|
10105
|
+
};
|
|
10106
|
+
set("RuntimeWatchdogSec", "20s");
|
|
10107
|
+
set("RebootWatchdogSec", "2min");
|
|
10108
|
+
writeFileSync7(SYSTEM_CONF, conf);
|
|
10109
|
+
sh2("systemctl daemon-reexec");
|
|
10110
|
+
log2.push("hardware watchdog: RuntimeWatchdogSec=20s RebootWatchdogSec=2min");
|
|
10111
|
+
return log2;
|
|
10112
|
+
}
|
|
10113
|
+
function binPath() {
|
|
10114
|
+
const candidates = [];
|
|
10115
|
+
const which = sh2("command -v machines").out.split(`
|
|
10116
|
+
`)[0]?.trim();
|
|
10117
|
+
if (which)
|
|
10118
|
+
candidates.push(which);
|
|
10119
|
+
if (process.argv[1])
|
|
10120
|
+
candidates.push(process.argv[1]);
|
|
10121
|
+
const home = process.env["HOME"] || "/home/hasna";
|
|
10122
|
+
candidates.push(`${home}/.bun/bin/machines`, "/home/hasna/.bun/bin/machines", "/root/.bun/bin/machines", "/usr/local/bin/machines");
|
|
10123
|
+
for (const c of candidates) {
|
|
10124
|
+
if (c && existsSync10(c))
|
|
10125
|
+
return c;
|
|
10126
|
+
}
|
|
10127
|
+
return "machines";
|
|
10128
|
+
}
|
|
10129
|
+
var ROOT_DATA_DIR = "/etc/machines-heal";
|
|
10130
|
+
function installHealService() {
|
|
10131
|
+
const log2 = [];
|
|
10132
|
+
const exec = binPath();
|
|
10133
|
+
const binDir = exec.includes("/") ? exec.slice(0, exec.lastIndexOf("/")) : "/usr/local/bin";
|
|
10134
|
+
const unit = `[Unit]
|
|
10135
|
+
Description=Hasna machines self-healing network watchdog
|
|
10136
|
+
After=network.target NetworkManager.service tailscaled.service
|
|
10137
|
+
Wants=network.target
|
|
10138
|
+
|
|
10139
|
+
[Service]
|
|
10140
|
+
Type=simple
|
|
10141
|
+
ExecStart=${exec} heal daemon
|
|
10142
|
+
Restart=always
|
|
10143
|
+
RestartSec=10
|
|
10144
|
+
Environment=HOME=/root
|
|
10145
|
+
Environment=HASNA_MACHINES_DIR=${ROOT_DATA_DIR}
|
|
10146
|
+
Environment=PATH=${binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
10147
|
+
|
|
10148
|
+
[Install]
|
|
10149
|
+
WantedBy=multi-user.target
|
|
10150
|
+
`;
|
|
10151
|
+
writeFileSync7(SERVICE_PATH, unit);
|
|
10152
|
+
sh2("systemctl daemon-reload");
|
|
10153
|
+
sh2("systemctl enable --now machines-heal.service");
|
|
10154
|
+
log2.push(`installed + enabled ${SERVICE_PATH} (ExecStart=${exec} heal daemon)`);
|
|
10155
|
+
return log2;
|
|
10156
|
+
}
|
|
10157
|
+
function uninstallHealService() {
|
|
10158
|
+
const log2 = [];
|
|
10159
|
+
sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
|
|
10160
|
+
if (existsSync10(SERVICE_PATH)) {
|
|
10161
|
+
sh2(`rm -f ${SERVICE_PATH}`);
|
|
10162
|
+
sh2("systemctl daemon-reload");
|
|
10163
|
+
log2.push(`removed ${SERVICE_PATH}`);
|
|
10164
|
+
} else {
|
|
10165
|
+
log2.push("service not installed");
|
|
10166
|
+
}
|
|
10167
|
+
return log2;
|
|
10168
|
+
}
|
|
10169
|
+
function healServiceStatus() {
|
|
10170
|
+
return {
|
|
10171
|
+
installed: existsSync10(SERVICE_PATH),
|
|
10172
|
+
active: sh2("systemctl is-active machines-heal.service").out === "active",
|
|
10173
|
+
enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
|
|
10174
|
+
};
|
|
10175
|
+
}
|
|
10176
|
+
|
|
10177
|
+
// src/cli/index.ts
|
|
10178
|
+
init_paths();
|
|
10179
|
+
|
|
8860
10180
|
// src/cli-utils.ts
|
|
8861
10181
|
function parseIntegerOption(value, label, constraints = {}) {
|
|
8862
10182
|
const parsed = Number.parseInt(value, 10);
|
|
@@ -8888,7 +10208,7 @@ ${items.map((item) => `- ${item}`).join(`
|
|
|
8888
10208
|
|
|
8889
10209
|
// src/cli/index.ts
|
|
8890
10210
|
import { rmSync as rmSync2 } from "fs";
|
|
8891
|
-
import { readFileSync as
|
|
10211
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
8892
10212
|
var program2 = new Command;
|
|
8893
10213
|
function printJsonOrText(data, text, json = false) {
|
|
8894
10214
|
if (json || program2.opts().quiet) {
|
|
@@ -8897,6 +10217,22 @@ function printJsonOrText(data, text, json = false) {
|
|
|
8897
10217
|
}
|
|
8898
10218
|
console.log(text);
|
|
8899
10219
|
}
|
|
10220
|
+
function printStorageResults(results, json) {
|
|
10221
|
+
if (json) {
|
|
10222
|
+
console.log(JSON.stringify(results, null, 2));
|
|
10223
|
+
return;
|
|
10224
|
+
}
|
|
10225
|
+
for (const result of results) {
|
|
10226
|
+
const marker = result.errors.length > 0 ? source_default.red("!") : source_default.green("\u2713");
|
|
10227
|
+
const suffix = result.errors.length > 0 ? ` ${source_default.red(result.errors.join("; "))}` : "";
|
|
10228
|
+
console.log(`${marker} ${result.table}: read ${result.rowsRead}, wrote ${result.rowsWritten}${suffix}`);
|
|
10229
|
+
}
|
|
10230
|
+
}
|
|
10231
|
+
function printStorageError(error) {
|
|
10232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
10233
|
+
console.error(source_default.red(message));
|
|
10234
|
+
process.exit(1);
|
|
10235
|
+
}
|
|
8900
10236
|
function renderAppsListResult(result) {
|
|
8901
10237
|
return [
|
|
8902
10238
|
`machine: ${result.machineId}`,
|
|
@@ -8979,6 +10315,49 @@ function renderSelfTestResult(result) {
|
|
|
8979
10315
|
].join(`
|
|
8980
10316
|
`);
|
|
8981
10317
|
}
|
|
10318
|
+
function parseCommandSpec(value) {
|
|
10319
|
+
const [command, expectedVersion] = value.split(":");
|
|
10320
|
+
return {
|
|
10321
|
+
command,
|
|
10322
|
+
expectedVersion: expectedVersion || undefined,
|
|
10323
|
+
required: true
|
|
10324
|
+
};
|
|
10325
|
+
}
|
|
10326
|
+
function parsePackageSpec(value) {
|
|
10327
|
+
const [name, command, expectedVersion] = value.split(":");
|
|
10328
|
+
return {
|
|
10329
|
+
name,
|
|
10330
|
+
command: command || undefined,
|
|
10331
|
+
expectedVersion: expectedVersion || undefined,
|
|
10332
|
+
required: true
|
|
10333
|
+
};
|
|
10334
|
+
}
|
|
10335
|
+
function parseWorkspaceSpec(value) {
|
|
10336
|
+
const [label, path] = value.includes("=") ? value.split(/=(.*)/s).filter(Boolean) : ["workspace", value];
|
|
10337
|
+
return {
|
|
10338
|
+
label,
|
|
10339
|
+
path,
|
|
10340
|
+
required: true
|
|
10341
|
+
};
|
|
10342
|
+
}
|
|
10343
|
+
function renderCompatibilityCheck(check2) {
|
|
10344
|
+
const marker = check2.status === "ok" ? source_default.green("\u2713") : check2.status === "warn" ? source_default.yellow("!") : source_default.red("\u2717");
|
|
10345
|
+
const expected = check2.expected ? ` expected=${check2.expected}` : "";
|
|
10346
|
+
return `${marker} ${check2.id} ${check2.actual ?? "unknown"}${expected}`;
|
|
10347
|
+
}
|
|
10348
|
+
function renderCompatibilityResult(result) {
|
|
10349
|
+
return [
|
|
10350
|
+
renderKeyValueTable([
|
|
10351
|
+
["machine", result.machine_id],
|
|
10352
|
+
["source", result.source],
|
|
10353
|
+
["ok", String(result.ok)],
|
|
10354
|
+
["checks", `${result.summary.ok} ok, ${result.summary.warn} warn, ${result.summary.fail} fail`]
|
|
10355
|
+
]),
|
|
10356
|
+
"",
|
|
10357
|
+
...result.checks.map(renderCompatibilityCheck)
|
|
10358
|
+
].join(`
|
|
10359
|
+
`);
|
|
10360
|
+
}
|
|
8982
10361
|
function renderFleetStatus(status) {
|
|
8983
10362
|
return [
|
|
8984
10363
|
renderKeyValueTable([
|
|
@@ -9035,7 +10414,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
|
|
|
9035
10414
|
console.error("error: --from-stdin requires piped input");
|
|
9036
10415
|
process.exit(1);
|
|
9037
10416
|
}
|
|
9038
|
-
const input =
|
|
10417
|
+
const input = readFileSync11(0, "utf8");
|
|
9039
10418
|
const machine2 = JSON.parse(input);
|
|
9040
10419
|
console.log(JSON.stringify(manifestAdd(machine2), null, 2));
|
|
9041
10420
|
return;
|
|
@@ -9100,6 +10479,35 @@ program2.command("sync").description("Reconcile a machine against the fleet mani
|
|
|
9100
10479
|
const result = options.apply ? runSync(options.machine, { apply: true, yes: options.yes }) : buildSyncPlan(options.machine);
|
|
9101
10480
|
console.log(JSON.stringify(result, null, 2));
|
|
9102
10481
|
});
|
|
10482
|
+
program2.command("topology").description("Discover local, manifest, heartbeat, SSH, and Tailscale machine topology").option("--no-tailscale", "Skip tailscale status probing").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
10483
|
+
const topology = discoverMachineTopology({ includeTailscale: options.tailscale !== false });
|
|
10484
|
+
if (options.json) {
|
|
10485
|
+
console.log(JSON.stringify(topology, null, 2));
|
|
10486
|
+
return;
|
|
10487
|
+
}
|
|
10488
|
+
console.log(renderKeyValueTable([
|
|
10489
|
+
["local machine", topology.local_machine_id],
|
|
10490
|
+
["hostname", topology.local_hostname],
|
|
10491
|
+
["platform", String(topology.current_platform)],
|
|
10492
|
+
["machines", String(topology.machines.length)],
|
|
10493
|
+
["warnings", topology.warnings.join(", ") || "none"]
|
|
10494
|
+
]));
|
|
10495
|
+
for (const machine of topology.machines) {
|
|
10496
|
+
const route = machine.ssh.command_target ? `${machine.ssh.route}:${machine.ssh.command_target}` : machine.ssh.route;
|
|
10497
|
+
console.log(`${machine.machine_id.padEnd(18)} ${String(machine.platform || "unknown").padEnd(8)} ${machine.heartbeat_status.padEnd(8)} ${route}`);
|
|
10498
|
+
}
|
|
10499
|
+
});
|
|
10500
|
+
program2.command("compatibility").description("Check remote package, command, and workspace compatibility for open-* consumers").option("--machine <id>", "Machine identifier").option("--command <command...>", "Required command or command:expectedVersion").option("--package <spec...>", "Required package as name[:command[:expectedVersion]]").option("--workspace <spec...>", "Required workspace as label=/path or /path").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
10501
|
+
const result = checkMachineCompatibility({
|
|
10502
|
+
machineId: options.machine,
|
|
10503
|
+
commands: options.command?.map(parseCommandSpec),
|
|
10504
|
+
packages: options.package?.map(parsePackageSpec),
|
|
10505
|
+
workspaces: options.workspace?.map(parseWorkspaceSpec)
|
|
10506
|
+
});
|
|
10507
|
+
printJsonOrText(result, renderCompatibilityResult(result), options.json);
|
|
10508
|
+
if (!result.ok && !options.json)
|
|
10509
|
+
process.exitCode = 1;
|
|
10510
|
+
});
|
|
9103
10511
|
program2.command("diff").description("Show manifest differences between two machines").requiredOption("--left <id>", "Left machine identifier").option("--right <id>", "Right machine identifier (defaults to current machine)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
9104
10512
|
const result = diffMachines(options.left, options.right);
|
|
9105
10513
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -9253,6 +10661,51 @@ program2.command("ports").description("List listening ports on a machine").optio
|
|
|
9253
10661
|
const result = listPorts(options.machine);
|
|
9254
10662
|
console.log(JSON.stringify(result, null, 2));
|
|
9255
10663
|
});
|
|
10664
|
+
var storageCommand = program2.command("storage").description("Sync local machine runtime data with storage PostgreSQL");
|
|
10665
|
+
storageCommand.command("status").description("Show storage sync status").option("-j, --json", "Print JSON output", false).action(async (options) => {
|
|
10666
|
+
const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10667
|
+
const status = getStorageStatus2();
|
|
10668
|
+
printJsonOrText(status, renderKeyValueTable([
|
|
10669
|
+
["mode", status.mode],
|
|
10670
|
+
["configured", status.configured ? "yes" : "no"],
|
|
10671
|
+
["active env", status.activeEnv || "none"],
|
|
10672
|
+
["tables", status.tables.join(", ")]
|
|
10673
|
+
]), options.json);
|
|
10674
|
+
});
|
|
10675
|
+
storageCommand.command("push").description("Push local machine runtime data to storage PostgreSQL").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
|
|
10676
|
+
try {
|
|
10677
|
+
const { parseStorageTables: parseStorageTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10678
|
+
const results = await storagePush2({ tables: parseStorageTables2(options.tables) });
|
|
10679
|
+
printStorageResults(results, options.json);
|
|
10680
|
+
} catch (error) {
|
|
10681
|
+
printStorageError(error);
|
|
10682
|
+
}
|
|
10683
|
+
});
|
|
10684
|
+
storageCommand.command("pull").description("Pull machine runtime data from storage PostgreSQL to local SQLite").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
|
|
10685
|
+
try {
|
|
10686
|
+
const { parseStorageTables: parseStorageTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10687
|
+
const results = await storagePull2({ tables: parseStorageTables2(options.tables) });
|
|
10688
|
+
printStorageResults(results, options.json);
|
|
10689
|
+
} catch (error) {
|
|
10690
|
+
printStorageError(error);
|
|
10691
|
+
}
|
|
10692
|
+
});
|
|
10693
|
+
storageCommand.command("sync").description("Bidirectional storage sync: pull then push").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
|
|
10694
|
+
try {
|
|
10695
|
+
const { parseStorageTables: parseStorageTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10696
|
+
const result = await storageSync2({ tables: parseStorageTables2(options.tables) });
|
|
10697
|
+
if (options.json) {
|
|
10698
|
+
console.log(JSON.stringify(result, null, 2));
|
|
10699
|
+
return;
|
|
10700
|
+
}
|
|
10701
|
+
console.log(source_default.bold("Pull"));
|
|
10702
|
+
printStorageResults(result.pull);
|
|
10703
|
+
console.log(source_default.bold("Push"));
|
|
10704
|
+
printStorageResults(result.push);
|
|
10705
|
+
} catch (error) {
|
|
10706
|
+
printStorageError(error);
|
|
10707
|
+
}
|
|
10708
|
+
});
|
|
9256
10709
|
program2.command("status").description("Print local machine and storage status").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
9257
10710
|
const status = getStatus();
|
|
9258
10711
|
printJsonOrText(status, renderFleetStatus(status), options.json);
|
|
@@ -9274,4 +10727,98 @@ program2.command("serve").description("Serve a local fleet dashboard and JSON AP
|
|
|
9274
10727
|
const server = startDashboardServer({ host: info.host, port: info.port });
|
|
9275
10728
|
console.log(source_default.green(`machines dashboard listening on http://${server.hostname}:${server.port}`));
|
|
9276
10729
|
});
|
|
10730
|
+
var healCommand = program2.command("heal").description("Self-healing network watchdog: keeps a Wi-Fi node reachable (SSID pinning + peer-reachability + gated reboot)");
|
|
10731
|
+
function requireRoot() {
|
|
10732
|
+
const uid = process.getuid ? process.getuid() : 1;
|
|
10733
|
+
if (uid !== 0) {
|
|
10734
|
+
console.error(source_default.red("error: this command must run as root (try: sudo machines heal install)"));
|
|
10735
|
+
return false;
|
|
10736
|
+
}
|
|
10737
|
+
return true;
|
|
10738
|
+
}
|
|
10739
|
+
healCommand.command("config").description(`View or update self-healing config (e.g. --set '{"preferredSsid":"X81ND","fallbackSsid":"DIGI-s2N5"}')`).option("--set <json>", "Merge a JSON object into the config").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
10740
|
+
if (options.set) {
|
|
10741
|
+
const current = readHealConfig();
|
|
10742
|
+
const partial = JSON.parse(options.set);
|
|
10743
|
+
writeHealConfig({
|
|
10744
|
+
...current,
|
|
10745
|
+
...partial,
|
|
10746
|
+
thresholds: { ...current.thresholds, ...partial.thresholds || {} }
|
|
10747
|
+
});
|
|
10748
|
+
}
|
|
10749
|
+
const config = readHealConfig();
|
|
10750
|
+
printJsonOrText(config, renderKeyValueTable([
|
|
10751
|
+
["enabled", String(config.enabled)],
|
|
10752
|
+
["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
|
|
10753
|
+
["fallbackSsid", config.fallbackSsid || "(none)"],
|
|
10754
|
+
["anchors", config.tailscaleAnchors.length ? config.tailscaleAnchors.join(", ") : "(auto-discover)"],
|
|
10755
|
+
["quorumRequired", String(config.quorumRequired)],
|
|
10756
|
+
["intervalSec", String(config.intervalSec)],
|
|
10757
|
+
["thresholds", `reconnect=${config.thresholds.reconnect} nm=${config.thresholds.nmRestart} fallback=${config.thresholds.fallback} reboot=${config.thresholds.reboot}`],
|
|
10758
|
+
["allowReboot", String(config.allowReboot)],
|
|
10759
|
+
["gpuJobGuard", String(config.gpuJobGuard)]
|
|
10760
|
+
]), options.json);
|
|
10761
|
+
});
|
|
10762
|
+
healCommand.command("check").description("Run one health + decision tick read-only (no side effects)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
10763
|
+
const result = runHealOnce(readHealConfig(), { dryRun: true });
|
|
10764
|
+
printJsonOrText(result, renderList("heal check", [
|
|
10765
|
+
`health: ${result.healthy ? source_default.green("HEALTHY") : source_default.red("UNHEALTHY")} (remote quorum ${result.remoteScore})`,
|
|
10766
|
+
`reasons: ${result.reasons.length ? result.reasons.join(", ") : "none"}`,
|
|
10767
|
+
`would do: ${result.action}${result.suppressedReason ? ` (reboot suppressed: ${result.suppressedReason})` : ""}`,
|
|
10768
|
+
`consecutive fails: ${result.failCount}`
|
|
10769
|
+
]), options.json);
|
|
10770
|
+
});
|
|
10771
|
+
healCommand.command("status").description("Show watchdog service status and last persisted state").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
10772
|
+
const svc = healServiceStatus();
|
|
10773
|
+
const state = readHealState();
|
|
10774
|
+
const config = readHealConfig();
|
|
10775
|
+
printJsonOrText({ service: svc, state, config }, renderKeyValueTable([
|
|
10776
|
+
["service installed", svc.installed ? source_default.green("yes") : "no"],
|
|
10777
|
+
["service active", svc.active ? source_default.green("yes") : source_default.yellow("no")],
|
|
10778
|
+
["service enabled", svc.enabled ? "yes" : "no"],
|
|
10779
|
+
["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
|
|
10780
|
+
["consecutive fails", String(state.failCount)],
|
|
10781
|
+
["pending reboot recovery", String(state.pendingRebootRecovery)],
|
|
10782
|
+
["failed boot recoveries", String(state.failedBootRecoveries)]
|
|
10783
|
+
]), options.json);
|
|
10784
|
+
});
|
|
10785
|
+
healCommand.command("daemon").description("Run the watchdog loop in the foreground (used by systemd)").action(() => {
|
|
10786
|
+
startHealDaemon();
|
|
10787
|
+
});
|
|
10788
|
+
healCommand.command("stop").description("Stop a foreground daemon started via `heal daemon`").action(() => {
|
|
10789
|
+
const r = stopHealDaemon();
|
|
10790
|
+
console.log(r.stopped ? `stopped heal daemon (pid ${r.pid})` : "heal daemon not running");
|
|
10791
|
+
});
|
|
10792
|
+
healCommand.command("determinism").description("Pin the preferred SSID, disable other autoconnects, turn off Wi-Fi power save").action(() => {
|
|
10793
|
+
const log2 = applyDeterminism(readHealConfig());
|
|
10794
|
+
console.log(renderList("determinism", log2));
|
|
10795
|
+
});
|
|
10796
|
+
healCommand.command("install").description("Install the watchdog: determinism + hardware watchdog + systemd service (requires root)").option("--no-determinism", "Skip SSID pinning / power-save changes").option("--no-watchdog", "Skip enabling the systemd hardware watchdog").option("--no-service", "Skip installing the systemd service").action((options) => {
|
|
10797
|
+
if (!requireRoot()) {
|
|
10798
|
+
process.exitCode = 1;
|
|
10799
|
+
return;
|
|
10800
|
+
}
|
|
10801
|
+
const config = readHealConfig();
|
|
10802
|
+
if (!config.preferredSsid) {
|
|
10803
|
+
console.error(source_default.red(`error: set preferredSsid first: machines heal config --set '{"preferredSsid":"X81ND"}'`));
|
|
10804
|
+
process.exitCode = 1;
|
|
10805
|
+
return;
|
|
10806
|
+
}
|
|
10807
|
+
const out = [];
|
|
10808
|
+
if (options.determinism !== false)
|
|
10809
|
+
out.push(...applyDeterminism(config));
|
|
10810
|
+
if (options.watchdog !== false)
|
|
10811
|
+
out.push(...enableHardwareWatchdog());
|
|
10812
|
+
if (options.service !== false)
|
|
10813
|
+
out.push(...installHealService());
|
|
10814
|
+
console.log(renderList("install", out));
|
|
10815
|
+
console.log(source_default.green("self-healing watchdog installed"));
|
|
10816
|
+
});
|
|
10817
|
+
healCommand.command("uninstall").description("Remove the systemd watchdog service (requires root)").action(() => {
|
|
10818
|
+
if (!requireRoot()) {
|
|
10819
|
+
process.exitCode = 1;
|
|
10820
|
+
return;
|
|
10821
|
+
}
|
|
10822
|
+
console.log(renderList("uninstall", uninstallHealService()));
|
|
10823
|
+
});
|
|
9277
10824
|
await program2.parseAsync(process.argv);
|