@hasna/machines 0.0.14 → 0.0.15
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 +1697 -170
- 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 +1020 -185
- package/dist/mcp/http.d.ts +12 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.js +925 -66
- 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/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/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,6 +7571,7 @@ 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";
|
|
7198
7576
|
|
|
7199
7577
|
// src/commands/ssh.ts
|
|
@@ -7245,7 +7623,7 @@ function runMachineCommand(machineId, command) {
|
|
|
7245
7623
|
const isLocal = machineId === localMachineId;
|
|
7246
7624
|
const route = isLocal ? "local" : resolveSshTarget(machineId).route;
|
|
7247
7625
|
const shellCommand = isLocal ? command : buildSshCommand(machineId, command);
|
|
7248
|
-
const result = spawnSync2("bash", ["-
|
|
7626
|
+
const result = spawnSync2("bash", ["-c", shellCommand], {
|
|
7249
7627
|
encoding: "utf8",
|
|
7250
7628
|
env: process.env
|
|
7251
7629
|
});
|
|
@@ -7571,6 +7949,7 @@ function runTailscaleInstall(machineId, options = {}) {
|
|
|
7571
7949
|
|
|
7572
7950
|
// src/commands/notifications.ts
|
|
7573
7951
|
import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
7952
|
+
init_paths();
|
|
7574
7953
|
var notificationChannelSchema = exports_external.object({
|
|
7575
7954
|
id: exports_external.string(),
|
|
7576
7955
|
type: exports_external.enum(["email", "webhook", "command"]),
|
|
@@ -7817,6 +8196,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
|
|
|
7817
8196
|
}
|
|
7818
8197
|
|
|
7819
8198
|
// src/commands/ports.ts
|
|
8199
|
+
init_db();
|
|
7820
8200
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
7821
8201
|
function parseSsOutput(output) {
|
|
7822
8202
|
return output.trim().split(`
|
|
@@ -7872,6 +8252,8 @@ function listPorts(machineId) {
|
|
|
7872
8252
|
|
|
7873
8253
|
// src/commands/sync.ts
|
|
7874
8254
|
import { existsSync as existsSync6, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
|
|
8255
|
+
init_paths();
|
|
8256
|
+
init_db();
|
|
7875
8257
|
function quote4(value) {
|
|
7876
8258
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7877
8259
|
}
|
|
@@ -8023,6 +8405,8 @@ function runSync(machineId, options = {}) {
|
|
|
8023
8405
|
}
|
|
8024
8406
|
|
|
8025
8407
|
// src/commands/status.ts
|
|
8408
|
+
init_db();
|
|
8409
|
+
init_paths();
|
|
8026
8410
|
function getStatus() {
|
|
8027
8411
|
const manifest = readManifest();
|
|
8028
8412
|
const heartbeats = listHeartbeats();
|
|
@@ -8054,8 +8438,424 @@ function getStatus() {
|
|
|
8054
8438
|
};
|
|
8055
8439
|
}
|
|
8056
8440
|
|
|
8441
|
+
// src/topology.ts
|
|
8442
|
+
init_db();
|
|
8443
|
+
import { existsSync as existsSync7 } from "fs";
|
|
8444
|
+
import { arch as arch2, hostname as hostname3, platform as platform3, userInfo as userInfo2 } from "os";
|
|
8445
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
8446
|
+
init_paths();
|
|
8447
|
+
function normalizePlatform2(value = platform3()) {
|
|
8448
|
+
const normalized = value.toLowerCase();
|
|
8449
|
+
if (normalized === "darwin" || normalized === "macos")
|
|
8450
|
+
return "macos";
|
|
8451
|
+
if (normalized === "win32" || normalized === "windows")
|
|
8452
|
+
return "windows";
|
|
8453
|
+
if (normalized === "linux")
|
|
8454
|
+
return "linux";
|
|
8455
|
+
return value;
|
|
8456
|
+
}
|
|
8457
|
+
function defaultRunner(command) {
|
|
8458
|
+
const result = spawnSync4("bash", ["-c", command], {
|
|
8459
|
+
encoding: "utf8",
|
|
8460
|
+
env: process.env
|
|
8461
|
+
});
|
|
8462
|
+
return {
|
|
8463
|
+
stdout: result.stdout || "",
|
|
8464
|
+
stderr: result.stderr || "",
|
|
8465
|
+
exitCode: result.status ?? 1
|
|
8466
|
+
};
|
|
8467
|
+
}
|
|
8468
|
+
function hasCommand2(command, runner) {
|
|
8469
|
+
return runner(`command -v ${command} >/dev/null 2>&1`).exitCode === 0;
|
|
8470
|
+
}
|
|
8471
|
+
function parseTailscaleStatus(raw) {
|
|
8472
|
+
try {
|
|
8473
|
+
const parsed = JSON.parse(raw);
|
|
8474
|
+
if (!parsed || typeof parsed !== "object")
|
|
8475
|
+
return null;
|
|
8476
|
+
return parsed;
|
|
8477
|
+
} catch {
|
|
8478
|
+
return null;
|
|
8479
|
+
}
|
|
8480
|
+
}
|
|
8481
|
+
function loadTailscalePeers(runner, warnings) {
|
|
8482
|
+
const peers = new Map;
|
|
8483
|
+
if (!hasCommand2("tailscale", runner)) {
|
|
8484
|
+
warnings.push("tailscale_not_available");
|
|
8485
|
+
return peers;
|
|
8486
|
+
}
|
|
8487
|
+
const result = runner("tailscale status --json");
|
|
8488
|
+
if (result.exitCode !== 0) {
|
|
8489
|
+
warnings.push(`tailscale_status_failed:${result.stderr.trim() || result.exitCode}`);
|
|
8490
|
+
return peers;
|
|
8491
|
+
}
|
|
8492
|
+
const status = parseTailscaleStatus(result.stdout);
|
|
8493
|
+
if (!status) {
|
|
8494
|
+
warnings.push("tailscale_status_invalid_json");
|
|
8495
|
+
return peers;
|
|
8496
|
+
}
|
|
8497
|
+
const addPeer = (peer) => {
|
|
8498
|
+
if (!peer)
|
|
8499
|
+
return;
|
|
8500
|
+
const id = peer.HostName || peer.DNSName?.split(".")[0];
|
|
8501
|
+
if (id)
|
|
8502
|
+
peers.set(id, peer);
|
|
8503
|
+
};
|
|
8504
|
+
addPeer(status.Self);
|
|
8505
|
+
for (const peer of Object.values(status.Peer ?? {}))
|
|
8506
|
+
addPeer(peer);
|
|
8507
|
+
return peers;
|
|
8508
|
+
}
|
|
8509
|
+
function machineKeys(machine) {
|
|
8510
|
+
return [
|
|
8511
|
+
machine.id,
|
|
8512
|
+
machine.hostname,
|
|
8513
|
+
machine.tailscaleName?.split(".")[0],
|
|
8514
|
+
machine.tailscaleName,
|
|
8515
|
+
machine.sshAddress?.split("@").pop()
|
|
8516
|
+
].filter((value) => Boolean(value));
|
|
8517
|
+
}
|
|
8518
|
+
function findTailscalePeer(machine, machineId, peers) {
|
|
8519
|
+
if (machine) {
|
|
8520
|
+
for (const key of machineKeys(machine)) {
|
|
8521
|
+
const peer = peers.get(key) ?? peers.get(key.replace(/\.$/, ""));
|
|
8522
|
+
if (peer)
|
|
8523
|
+
return peer;
|
|
8524
|
+
}
|
|
8525
|
+
}
|
|
8526
|
+
return peers.get(machineId) ?? null;
|
|
8527
|
+
}
|
|
8528
|
+
function routeHints(input) {
|
|
8529
|
+
const hints = [];
|
|
8530
|
+
if (input.machineId === input.localMachineId) {
|
|
8531
|
+
hints.push({ kind: "local", target: "localhost", reachable: true });
|
|
8532
|
+
}
|
|
8533
|
+
if (input.manifest?.sshAddress) {
|
|
8534
|
+
hints.push({ kind: "ssh", target: input.manifest.sshAddress, reachable: null });
|
|
8535
|
+
}
|
|
8536
|
+
if (input.manifest?.hostname) {
|
|
8537
|
+
hints.push({ kind: "lan", target: input.manifest.hostname, reachable: null });
|
|
8538
|
+
}
|
|
8539
|
+
const tailscaleTarget = input.manifest?.tailscaleName ?? input.peer?.DNSName ?? input.peer?.TailscaleIPs?.[0];
|
|
8540
|
+
if (tailscaleTarget) {
|
|
8541
|
+
hints.push({ kind: "tailscale", target: tailscaleTarget.replace(/\.$/, ""), reachable: input.peer?.Online ?? null });
|
|
8542
|
+
}
|
|
8543
|
+
return hints;
|
|
8544
|
+
}
|
|
8545
|
+
function buildEntry(input) {
|
|
8546
|
+
const manifest = input.manifest;
|
|
8547
|
+
const peer = input.peer;
|
|
8548
|
+
const hints = routeHints({
|
|
8549
|
+
machineId: input.machineId,
|
|
8550
|
+
localMachineId: input.localMachineId,
|
|
8551
|
+
manifest,
|
|
8552
|
+
peer
|
|
8553
|
+
});
|
|
8554
|
+
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");
|
|
8555
|
+
const route = selectedRoute?.kind === "ssh" ? "lan" : selectedRoute?.kind ?? "unknown";
|
|
8556
|
+
return {
|
|
8557
|
+
machine_id: input.machineId,
|
|
8558
|
+
hostname: manifest?.hostname ?? peer?.HostName ?? null,
|
|
8559
|
+
platform: manifest?.platform ?? (peer?.OS ? normalizePlatform2(peer.OS) : null),
|
|
8560
|
+
os: peer?.OS ?? null,
|
|
8561
|
+
user: typeof manifest?.metadata?.user === "string" ? manifest.metadata.user : null,
|
|
8562
|
+
workspace_path: manifest?.workspacePath ?? null,
|
|
8563
|
+
manifest_declared: Boolean(manifest),
|
|
8564
|
+
heartbeat_status: input.heartbeat?.status ?? "unknown",
|
|
8565
|
+
last_heartbeat_at: input.heartbeat?.updated_at ?? null,
|
|
8566
|
+
tailscale: {
|
|
8567
|
+
dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
|
|
8568
|
+
ips: peer?.TailscaleIPs ?? [],
|
|
8569
|
+
online: peer?.Online ?? null,
|
|
8570
|
+
active: peer?.Active ?? null,
|
|
8571
|
+
last_seen: peer?.LastSeen ?? null
|
|
8572
|
+
},
|
|
8573
|
+
ssh: {
|
|
8574
|
+
address: manifest?.sshAddress ?? null,
|
|
8575
|
+
route,
|
|
8576
|
+
command_target: selectedRoute?.target ?? null
|
|
8577
|
+
},
|
|
8578
|
+
route_hints: hints,
|
|
8579
|
+
tags: manifest?.tags ?? [],
|
|
8580
|
+
metadata: manifest?.metadata ?? {}
|
|
8581
|
+
};
|
|
8582
|
+
}
|
|
8583
|
+
function discoverMachineTopology(options = {}) {
|
|
8584
|
+
const now = options.now ?? new Date;
|
|
8585
|
+
const runner = options.runner ?? defaultRunner;
|
|
8586
|
+
const warnings = [];
|
|
8587
|
+
const manifest = readManifest();
|
|
8588
|
+
const heartbeats = listHeartbeats();
|
|
8589
|
+
const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
|
|
8590
|
+
const localMachineId = getLocalMachineId();
|
|
8591
|
+
const peers = options.includeTailscale === false ? new Map : loadTailscalePeers(runner, warnings);
|
|
8592
|
+
const machineIds = new Set([
|
|
8593
|
+
localMachineId,
|
|
8594
|
+
...manifest.machines.map((machine) => machine.id),
|
|
8595
|
+
...heartbeats.map((heartbeat) => heartbeat.machine_id),
|
|
8596
|
+
...peers.keys()
|
|
8597
|
+
]);
|
|
8598
|
+
const manifestById = new Map(manifest.machines.map((machine) => [machine.id, machine]));
|
|
8599
|
+
const machines = [...machineIds].sort().map((machineId) => {
|
|
8600
|
+
const manifestMachine = manifestById.get(machineId);
|
|
8601
|
+
return buildEntry({
|
|
8602
|
+
machineId,
|
|
8603
|
+
localMachineId,
|
|
8604
|
+
manifest: manifestMachine,
|
|
8605
|
+
peer: findTailscalePeer(manifestMachine ?? null, machineId, peers),
|
|
8606
|
+
heartbeat: heartbeatByMachine.get(machineId)
|
|
8607
|
+
});
|
|
8608
|
+
});
|
|
8609
|
+
return {
|
|
8610
|
+
generated_at: now.toISOString(),
|
|
8611
|
+
local_machine_id: localMachineId,
|
|
8612
|
+
local_hostname: hostname3(),
|
|
8613
|
+
current_platform: normalizePlatform2(),
|
|
8614
|
+
manifest_path_known: existsSync7(getManifestPath()),
|
|
8615
|
+
machines,
|
|
8616
|
+
warnings
|
|
8617
|
+
};
|
|
8618
|
+
}
|
|
8619
|
+
|
|
8620
|
+
// src/compatibility.ts
|
|
8621
|
+
init_db();
|
|
8622
|
+
var DEFAULT_COMMANDS = [
|
|
8623
|
+
{ command: "bun", required: true },
|
|
8624
|
+
{ command: "machines", required: true }
|
|
8625
|
+
];
|
|
8626
|
+
function defaultPackages() {
|
|
8627
|
+
return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
|
|
8628
|
+
}
|
|
8629
|
+
function shellQuote3(value) {
|
|
8630
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
8631
|
+
}
|
|
8632
|
+
function commandId(value) {
|
|
8633
|
+
return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8634
|
+
}
|
|
8635
|
+
function packageCommand(name) {
|
|
8636
|
+
if (name === "@hasna/knowledge")
|
|
8637
|
+
return "knowledge";
|
|
8638
|
+
if (name === "@hasna/machines")
|
|
8639
|
+
return "machines";
|
|
8640
|
+
return name.split("/").pop() ?? name;
|
|
8641
|
+
}
|
|
8642
|
+
function firstLine(value) {
|
|
8643
|
+
return value.trim().split(/\r?\n/).find(Boolean) ?? "";
|
|
8644
|
+
}
|
|
8645
|
+
function extractVersion(value) {
|
|
8646
|
+
const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
|
|
8647
|
+
return match?.[0] ?? null;
|
|
8648
|
+
}
|
|
8649
|
+
function statusFor(required, ok) {
|
|
8650
|
+
if (ok)
|
|
8651
|
+
return "ok";
|
|
8652
|
+
return required === false ? "warn" : "fail";
|
|
8653
|
+
}
|
|
8654
|
+
function makeCheck(input) {
|
|
8655
|
+
return {
|
|
8656
|
+
id: input.id,
|
|
8657
|
+
kind: input.kind,
|
|
8658
|
+
status: input.status,
|
|
8659
|
+
target: input.target,
|
|
8660
|
+
expected: input.expected ?? null,
|
|
8661
|
+
actual: input.actual ?? null,
|
|
8662
|
+
detail: input.detail,
|
|
8663
|
+
source: input.source
|
|
8664
|
+
};
|
|
8665
|
+
}
|
|
8666
|
+
function parseKeyValue(stdout) {
|
|
8667
|
+
const result = {};
|
|
8668
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
8669
|
+
const idx = line.indexOf("=");
|
|
8670
|
+
if (idx <= 0)
|
|
8671
|
+
continue;
|
|
8672
|
+
result[line.slice(0, idx)] = line.slice(idx + 1);
|
|
8673
|
+
}
|
|
8674
|
+
return result;
|
|
8675
|
+
}
|
|
8676
|
+
function defaultRunner2(machineId, command) {
|
|
8677
|
+
return runMachineCommand(machineId, command);
|
|
8678
|
+
}
|
|
8679
|
+
function inspectCommand(machineId, spec, runner) {
|
|
8680
|
+
const command = shellQuote3(spec.command);
|
|
8681
|
+
const versionArgs = spec.versionArgs ?? "--version";
|
|
8682
|
+
const script = [
|
|
8683
|
+
`cmd=${command}`,
|
|
8684
|
+
'path="$(command -v "$cmd" 2>/dev/null || true)"',
|
|
8685
|
+
'printf "path=%s\\n" "$path"',
|
|
8686
|
+
'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
|
|
8687
|
+
].join("; ");
|
|
8688
|
+
const result = runner(machineId, script);
|
|
8689
|
+
const parsed = parseKeyValue(result.stdout);
|
|
8690
|
+
return {
|
|
8691
|
+
path: parsed.path || null,
|
|
8692
|
+
version: parsed.version ? firstLine(parsed.version) : null,
|
|
8693
|
+
exitCode: result.exitCode,
|
|
8694
|
+
source: result.source,
|
|
8695
|
+
stderr: result.stderr
|
|
8696
|
+
};
|
|
8697
|
+
}
|
|
8698
|
+
function fieldCommand(field) {
|
|
8699
|
+
const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
8700
|
+
return [
|
|
8701
|
+
`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`,
|
|
8702
|
+
`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`,
|
|
8703
|
+
`else sed -n '${regex}' "$pkg" | head -n 1`,
|
|
8704
|
+
"fi"
|
|
8705
|
+
].join("; ");
|
|
8706
|
+
}
|
|
8707
|
+
function inspectWorkspace(machineId, spec, runner) {
|
|
8708
|
+
const script = [
|
|
8709
|
+
`path=${shellQuote3(spec.path)}`,
|
|
8710
|
+
'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
|
|
8711
|
+
'pkg="$path/package.json"',
|
|
8712
|
+
'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
|
|
8713
|
+
`if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
|
|
8714
|
+
].join("; ");
|
|
8715
|
+
const result = runner(machineId, script);
|
|
8716
|
+
const parsed = parseKeyValue(result.stdout);
|
|
8717
|
+
return {
|
|
8718
|
+
exists: parsed.exists === "yes",
|
|
8719
|
+
packageJson: parsed.package_json === "yes",
|
|
8720
|
+
packageName: parsed.package_name || null,
|
|
8721
|
+
version: parsed.version || null,
|
|
8722
|
+
source: result.source,
|
|
8723
|
+
stderr: result.stderr
|
|
8724
|
+
};
|
|
8725
|
+
}
|
|
8726
|
+
function commandCheck(machineId, spec, runner) {
|
|
8727
|
+
const inspection = inspectCommand(machineId, spec, runner);
|
|
8728
|
+
const found = Boolean(inspection.path);
|
|
8729
|
+
const checks = [
|
|
8730
|
+
makeCheck({
|
|
8731
|
+
id: `command:${commandId(spec.command)}:path`,
|
|
8732
|
+
kind: "command",
|
|
8733
|
+
status: statusFor(spec.required, found),
|
|
8734
|
+
target: spec.command,
|
|
8735
|
+
expected: "available",
|
|
8736
|
+
actual: inspection.path ?? "missing",
|
|
8737
|
+
detail: found ? `found at ${inspection.path}` : inspection.stderr || "command missing",
|
|
8738
|
+
source: inspection.source
|
|
8739
|
+
})
|
|
8740
|
+
];
|
|
8741
|
+
if (spec.expectedVersion) {
|
|
8742
|
+
const actualVersion = extractVersion(inspection.version ?? "");
|
|
8743
|
+
checks.push(makeCheck({
|
|
8744
|
+
id: `command:${commandId(spec.command)}:version`,
|
|
8745
|
+
kind: "command",
|
|
8746
|
+
status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8747
|
+
target: spec.command,
|
|
8748
|
+
expected: spec.expectedVersion,
|
|
8749
|
+
actual: actualVersion ?? inspection.version ?? "missing",
|
|
8750
|
+
detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
|
|
8751
|
+
source: inspection.source
|
|
8752
|
+
}));
|
|
8753
|
+
}
|
|
8754
|
+
return checks;
|
|
8755
|
+
}
|
|
8756
|
+
function packageCheck(machineId, spec, runner) {
|
|
8757
|
+
const command = spec.command ?? packageCommand(spec.name);
|
|
8758
|
+
const inspection = inspectCommand(machineId, { command, expectedVersion: spec.expectedVersion, required: spec.required }, runner);
|
|
8759
|
+
const found = Boolean(inspection.path);
|
|
8760
|
+
const checks = [
|
|
8761
|
+
makeCheck({
|
|
8762
|
+
id: `package:${commandId(spec.name)}:command`,
|
|
8763
|
+
kind: "package",
|
|
8764
|
+
status: statusFor(spec.required, found),
|
|
8765
|
+
target: spec.name,
|
|
8766
|
+
expected: command,
|
|
8767
|
+
actual: inspection.path ?? "missing",
|
|
8768
|
+
detail: found ? `${command} found at ${inspection.path}` : `${command} command missing`,
|
|
8769
|
+
source: inspection.source
|
|
8770
|
+
})
|
|
8771
|
+
];
|
|
8772
|
+
if (spec.expectedVersion) {
|
|
8773
|
+
const actualVersion = extractVersion(inspection.version ?? "");
|
|
8774
|
+
checks.push(makeCheck({
|
|
8775
|
+
id: `package:${commandId(spec.name)}:version`,
|
|
8776
|
+
kind: "package",
|
|
8777
|
+
status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8778
|
+
target: spec.name,
|
|
8779
|
+
expected: spec.expectedVersion,
|
|
8780
|
+
actual: actualVersion ?? inspection.version ?? "missing",
|
|
8781
|
+
detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
|
|
8782
|
+
source: inspection.source
|
|
8783
|
+
}));
|
|
8784
|
+
}
|
|
8785
|
+
return checks;
|
|
8786
|
+
}
|
|
8787
|
+
function workspaceCheck(machineId, spec, runner) {
|
|
8788
|
+
const inspection = inspectWorkspace(machineId, spec, runner);
|
|
8789
|
+
const target = spec.label ?? spec.path;
|
|
8790
|
+
const checks = [
|
|
8791
|
+
makeCheck({
|
|
8792
|
+
id: `workspace:${commandId(target)}:path`,
|
|
8793
|
+
kind: "workspace",
|
|
8794
|
+
status: statusFor(spec.required, inspection.exists),
|
|
8795
|
+
target,
|
|
8796
|
+
expected: spec.path,
|
|
8797
|
+
actual: inspection.exists ? "exists" : "missing",
|
|
8798
|
+
detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
|
|
8799
|
+
source: inspection.source
|
|
8800
|
+
})
|
|
8801
|
+
];
|
|
8802
|
+
if (spec.expectedPackageName) {
|
|
8803
|
+
checks.push(makeCheck({
|
|
8804
|
+
id: `workspace:${commandId(target)}:package-name`,
|
|
8805
|
+
kind: "workspace",
|
|
8806
|
+
status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
|
|
8807
|
+
target,
|
|
8808
|
+
expected: spec.expectedPackageName,
|
|
8809
|
+
actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
|
|
8810
|
+
detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
|
|
8811
|
+
source: inspection.source
|
|
8812
|
+
}));
|
|
8813
|
+
}
|
|
8814
|
+
if (spec.expectedVersion) {
|
|
8815
|
+
checks.push(makeCheck({
|
|
8816
|
+
id: `workspace:${commandId(target)}:version`,
|
|
8817
|
+
kind: "workspace",
|
|
8818
|
+
status: inspection.version === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8819
|
+
target,
|
|
8820
|
+
expected: spec.expectedVersion,
|
|
8821
|
+
actual: inspection.version ?? (inspection.packageJson ? "missing-version" : "missing-package-json"),
|
|
8822
|
+
detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
|
|
8823
|
+
source: inspection.source
|
|
8824
|
+
}));
|
|
8825
|
+
}
|
|
8826
|
+
return checks;
|
|
8827
|
+
}
|
|
8828
|
+
function checkMachineCompatibility(options = {}) {
|
|
8829
|
+
const machineId = options.machineId ?? getLocalMachineId();
|
|
8830
|
+
const runner = options.runner ?? defaultRunner2;
|
|
8831
|
+
const commands = options.commands ?? DEFAULT_COMMANDS;
|
|
8832
|
+
const packages = options.packages ?? defaultPackages();
|
|
8833
|
+
const workspaces = options.workspaces ?? [];
|
|
8834
|
+
const checks = [];
|
|
8835
|
+
for (const spec of commands)
|
|
8836
|
+
checks.push(...commandCheck(machineId, spec, runner));
|
|
8837
|
+
for (const spec of packages)
|
|
8838
|
+
checks.push(...packageCheck(machineId, spec, runner));
|
|
8839
|
+
for (const spec of workspaces)
|
|
8840
|
+
checks.push(...workspaceCheck(machineId, spec, runner));
|
|
8841
|
+
const summary = {
|
|
8842
|
+
ok: checks.filter((check) => check.status === "ok").length,
|
|
8843
|
+
warn: checks.filter((check) => check.status === "warn").length,
|
|
8844
|
+
fail: checks.filter((check) => check.status === "fail").length
|
|
8845
|
+
};
|
|
8846
|
+
return {
|
|
8847
|
+
ok: summary.fail === 0,
|
|
8848
|
+
machine_id: machineId,
|
|
8849
|
+
source: checks[0]?.source ?? "local",
|
|
8850
|
+
generated_at: (options.now ?? new Date).toISOString(),
|
|
8851
|
+
checks,
|
|
8852
|
+
summary
|
|
8853
|
+
};
|
|
8854
|
+
}
|
|
8855
|
+
|
|
8057
8856
|
// src/commands/doctor.ts
|
|
8058
|
-
|
|
8857
|
+
init_db();
|
|
8858
|
+
function makeCheck2(id, status, summary, detail) {
|
|
8059
8859
|
return { id, status, summary, detail };
|
|
8060
8860
|
}
|
|
8061
8861
|
function parseKeyValueOutput(stdout) {
|
|
@@ -8087,15 +8887,15 @@ function runDoctor(machineId = getLocalMachineId()) {
|
|
|
8087
8887
|
const details = parseKeyValueOutput(commandChecks.stdout);
|
|
8088
8888
|
const machineInManifest = manifest.machines.find((machine) => machine.id === machineId);
|
|
8089
8889
|
const checks = [
|
|
8090
|
-
|
|
8091
|
-
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8890
|
+
makeCheck2("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", machineInManifest ? JSON.stringify(machineInManifest) : `No manifest entry for ${machineId}`),
|
|
8891
|
+
makeCheck2("manifest-path", details["manifest_exists"] === "yes" ? "ok" : "warn", "Manifest path check", `${details["manifest_path"] || "unknown"} ${details["manifest_exists"] === "yes" ? "exists" : "missing"}`),
|
|
8892
|
+
makeCheck2("db-path", details["db_exists"] === "yes" ? "ok" : "warn", "DB path check", `${details["db_path"] || "unknown"} ${details["db_exists"] === "yes" ? "exists" : "missing"}`),
|
|
8893
|
+
makeCheck2("notifications-path", details["notifications_exists"] === "yes" ? "ok" : "warn", "Notifications path check", `${details["notifications_path"] || "unknown"} ${details["notifications_exists"] === "yes" ? "exists" : "missing"}`),
|
|
8894
|
+
makeCheck2("bun", details["bun"] && details["bun"] !== "missing" ? "ok" : "fail", "Bun availability", details["bun"] || "missing"),
|
|
8895
|
+
makeCheck2("machines-cli", details["machines"] && details["machines"] !== "missing" ? "ok" : "warn", "machines CLI availability", details["machines"] || "missing"),
|
|
8896
|
+
makeCheck2("machines-agent-cli", details["machines_agent"] && details["machines_agent"] !== "missing" ? "ok" : "warn", "machines-agent availability", details["machines_agent"] || "missing"),
|
|
8897
|
+
makeCheck2("machines-mcp-cli", details["machines_mcp"] && details["machines_mcp"] !== "missing" ? "ok" : "warn", "machines-mcp availability", details["machines_mcp"] || "missing"),
|
|
8898
|
+
makeCheck2("ssh", details["ssh"] === "ok" ? "ok" : "warn", "SSH availability", details["ssh"] || "missing")
|
|
8099
8899
|
];
|
|
8100
8900
|
return {
|
|
8101
8901
|
machineId,
|
|
@@ -8107,6 +8907,9 @@ function runDoctor(machineId = getLocalMachineId()) {
|
|
|
8107
8907
|
};
|
|
8108
8908
|
}
|
|
8109
8909
|
|
|
8910
|
+
// src/commands/self-test.ts
|
|
8911
|
+
init_db();
|
|
8912
|
+
|
|
8110
8913
|
// src/commands/serve.ts
|
|
8111
8914
|
function escapeHtml(value) {
|
|
8112
8915
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
@@ -8393,8 +9196,9 @@ function runSelfTest() {
|
|
|
8393
9196
|
}
|
|
8394
9197
|
|
|
8395
9198
|
// src/commands/clipboard.ts
|
|
9199
|
+
init_paths();
|
|
8396
9200
|
import { createHash } from "crypto";
|
|
8397
|
-
import { existsSync as
|
|
9201
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
|
|
8398
9202
|
import { join as join6 } from "path";
|
|
8399
9203
|
var DEFAULT_CONFIG = {
|
|
8400
9204
|
version: 1,
|
|
@@ -8425,7 +9229,7 @@ function getDefaultConfig() {
|
|
|
8425
9229
|
}
|
|
8426
9230
|
function readConfig(configPath) {
|
|
8427
9231
|
const path = resolveConfigPath(configPath);
|
|
8428
|
-
if (!
|
|
9232
|
+
if (!existsSync8(path)) {
|
|
8429
9233
|
return getDefaultConfig();
|
|
8430
9234
|
}
|
|
8431
9235
|
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
@@ -8439,7 +9243,7 @@ function writeConfig(config, configPath) {
|
|
|
8439
9243
|
}
|
|
8440
9244
|
function readHistory(historyPath) {
|
|
8441
9245
|
const path = resolveHistoryPath(historyPath);
|
|
8442
|
-
if (!
|
|
9246
|
+
if (!existsSync8(path)) {
|
|
8443
9247
|
return [];
|
|
8444
9248
|
}
|
|
8445
9249
|
try {
|
|
@@ -8472,7 +9276,7 @@ function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
|
|
|
8472
9276
|
}
|
|
8473
9277
|
function getOrCreateClipboardKey() {
|
|
8474
9278
|
const keyPath = getClipboardKeyPath();
|
|
8475
|
-
if (
|
|
9279
|
+
if (existsSync8(keyPath)) {
|
|
8476
9280
|
return readFileSync6(keyPath, "utf8").trim();
|
|
8477
9281
|
}
|
|
8478
9282
|
const key = createHash("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
|
|
@@ -8512,7 +9316,7 @@ function addClipboardEntry(entry, historyPath) {
|
|
|
8512
9316
|
}
|
|
8513
9317
|
function clearClipboardHistory(historyPath) {
|
|
8514
9318
|
const path = resolveHistoryPath(historyPath);
|
|
8515
|
-
if (
|
|
9319
|
+
if (existsSync8(path)) {
|
|
8516
9320
|
rmSync(path);
|
|
8517
9321
|
}
|
|
8518
9322
|
}
|
|
@@ -8527,26 +9331,28 @@ function getClipboardStatus(historyPath) {
|
|
|
8527
9331
|
}
|
|
8528
9332
|
|
|
8529
9333
|
// src/commands/clipboard-daemon.ts
|
|
9334
|
+
init_paths();
|
|
8530
9335
|
import { readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
8531
9336
|
import { join as join7 } from "path";
|
|
8532
9337
|
import { createHash as createHash3 } from "crypto";
|
|
8533
9338
|
|
|
8534
9339
|
// src/commands/clipboard-server.ts
|
|
9340
|
+
init_paths();
|
|
8535
9341
|
import { createServer } from "http";
|
|
8536
9342
|
import { createHash as createHash2 } from "crypto";
|
|
8537
9343
|
import { readFileSync as readFileSync7 } from "fs";
|
|
8538
9344
|
function readLocalClipboardSync() {
|
|
8539
|
-
const
|
|
8540
|
-
if (
|
|
9345
|
+
const platform4 = process.platform;
|
|
9346
|
+
if (platform4 === "darwin") {
|
|
8541
9347
|
const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
|
|
8542
9348
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8543
9349
|
}
|
|
8544
|
-
if (
|
|
8545
|
-
if (
|
|
9350
|
+
if (platform4 === "linux") {
|
|
9351
|
+
if (hasCommand3("wl-paste")) {
|
|
8546
9352
|
const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
|
|
8547
9353
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8548
9354
|
}
|
|
8549
|
-
if (
|
|
9355
|
+
if (hasCommand3("xclip")) {
|
|
8550
9356
|
const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
|
|
8551
9357
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8552
9358
|
}
|
|
@@ -8555,17 +9361,17 @@ function readLocalClipboardSync() {
|
|
|
8555
9361
|
return "";
|
|
8556
9362
|
}
|
|
8557
9363
|
function writeLocalClipboardSync(content) {
|
|
8558
|
-
const
|
|
8559
|
-
if (
|
|
9364
|
+
const platform4 = process.platform;
|
|
9365
|
+
if (platform4 === "darwin") {
|
|
8560
9366
|
const result = Bun.spawnSync(["pbcopy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
8561
9367
|
return result.exitCode === 0;
|
|
8562
9368
|
}
|
|
8563
|
-
if (
|
|
8564
|
-
if (
|
|
9369
|
+
if (platform4 === "linux") {
|
|
9370
|
+
if (hasCommand3("wl-copy")) {
|
|
8565
9371
|
const result = Bun.spawnSync(["wl-copy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
8566
9372
|
return result.exitCode === 0;
|
|
8567
9373
|
}
|
|
8568
|
-
if (
|
|
9374
|
+
if (hasCommand3("xclip")) {
|
|
8569
9375
|
const result = Bun.spawnSync(["xclip", "-selection", "clipboard"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
8570
9376
|
return result.exitCode === 0;
|
|
8571
9377
|
}
|
|
@@ -8573,7 +9379,7 @@ function writeLocalClipboardSync(content) {
|
|
|
8573
9379
|
}
|
|
8574
9380
|
return false;
|
|
8575
9381
|
}
|
|
8576
|
-
function
|
|
9382
|
+
function hasCommand3(binary) {
|
|
8577
9383
|
const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
|
|
8578
9384
|
return result.exitCode === 0;
|
|
8579
9385
|
}
|
|
@@ -8687,17 +9493,17 @@ function handleGetClipboard(response, config) {
|
|
|
8687
9493
|
// src/commands/clipboard-daemon.ts
|
|
8688
9494
|
var DAEMON_PID_PATH = join7(getDataDir(), "clipboard-daemon.pid");
|
|
8689
9495
|
function readLocalClipboardSync2() {
|
|
8690
|
-
const
|
|
8691
|
-
if (
|
|
9496
|
+
const platform4 = process.platform;
|
|
9497
|
+
if (platform4 === "darwin") {
|
|
8692
9498
|
const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
|
|
8693
9499
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8694
9500
|
}
|
|
8695
|
-
if (
|
|
8696
|
-
if (
|
|
9501
|
+
if (platform4 === "linux") {
|
|
9502
|
+
if (hasCommand4("wl-paste")) {
|
|
8697
9503
|
const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
|
|
8698
9504
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8699
9505
|
}
|
|
8700
|
-
if (
|
|
9506
|
+
if (hasCommand4("xclip")) {
|
|
8701
9507
|
const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
|
|
8702
9508
|
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
8703
9509
|
}
|
|
@@ -8705,7 +9511,7 @@ function readLocalClipboardSync2() {
|
|
|
8705
9511
|
}
|
|
8706
9512
|
return "";
|
|
8707
9513
|
}
|
|
8708
|
-
function
|
|
9514
|
+
function hasCommand4(binary) {
|
|
8709
9515
|
const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
|
|
8710
9516
|
return result.exitCode === 0;
|
|
8711
9517
|
}
|
|
@@ -8857,6 +9663,500 @@ async function discoverPeers() {
|
|
|
8857
9663
|
return peers;
|
|
8858
9664
|
}
|
|
8859
9665
|
|
|
9666
|
+
// src/commands/heal.ts
|
|
9667
|
+
init_paths();
|
|
9668
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
9669
|
+
import { join as join8 } from "path";
|
|
9670
|
+
var DEFAULT_THRESHOLDS = {
|
|
9671
|
+
reconnect: 3,
|
|
9672
|
+
nmRestart: 7,
|
|
9673
|
+
fallback: 12,
|
|
9674
|
+
reboot: 15
|
|
9675
|
+
};
|
|
9676
|
+
var DEFAULT_HEAL_CONFIG = {
|
|
9677
|
+
version: 1,
|
|
9678
|
+
enabled: true,
|
|
9679
|
+
wifiInterface: "",
|
|
9680
|
+
preferredSsid: "",
|
|
9681
|
+
fallbackSsid: "",
|
|
9682
|
+
internetUrl: "https://1.1.1.1",
|
|
9683
|
+
tailscaleAnchors: [],
|
|
9684
|
+
quorumRequired: 2,
|
|
9685
|
+
intervalSec: 60,
|
|
9686
|
+
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
9687
|
+
rebootMinIntervalSec: 1800,
|
|
9688
|
+
nmRestartMinIntervalSec: 1800,
|
|
9689
|
+
reconnectMinIntervalSec: 120,
|
|
9690
|
+
healthyWindowSec: 300,
|
|
9691
|
+
maxFailedBootRecoveries: 2,
|
|
9692
|
+
bootBackoffSec: 21600,
|
|
9693
|
+
fallbackWindowSec: 600,
|
|
9694
|
+
gpuJobGuard: true,
|
|
9695
|
+
allowReboot: true
|
|
9696
|
+
};
|
|
9697
|
+
function defaultHealState() {
|
|
9698
|
+
return {
|
|
9699
|
+
failCount: 0,
|
|
9700
|
+
bootId: "",
|
|
9701
|
+
bootHealthySince: null,
|
|
9702
|
+
lastRebootAttempt: 0,
|
|
9703
|
+
lastNmRestart: 0,
|
|
9704
|
+
lastReconnect: 0,
|
|
9705
|
+
lastFallback: 0,
|
|
9706
|
+
degradedUntil: 0,
|
|
9707
|
+
pendingRebootRecovery: false,
|
|
9708
|
+
failedBootRecoveries: 0,
|
|
9709
|
+
rebootSuppressUntil: 0
|
|
9710
|
+
};
|
|
9711
|
+
}
|
|
9712
|
+
function getHealConfigPath() {
|
|
9713
|
+
return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] || join8(getDataDir(), "heal-config.json");
|
|
9714
|
+
}
|
|
9715
|
+
function getHealStatePath() {
|
|
9716
|
+
return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] || join8(getDataDir(), "heal-state.json");
|
|
9717
|
+
}
|
|
9718
|
+
function readHealConfig(path) {
|
|
9719
|
+
const p = path || getHealConfigPath();
|
|
9720
|
+
if (!existsSync9(p))
|
|
9721
|
+
return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
|
|
9722
|
+
const parsed = JSON.parse(readFileSync9(p, "utf8"));
|
|
9723
|
+
return {
|
|
9724
|
+
...DEFAULT_HEAL_CONFIG,
|
|
9725
|
+
...parsed,
|
|
9726
|
+
thresholds: { ...DEFAULT_THRESHOLDS, ...parsed.thresholds || {} },
|
|
9727
|
+
tailscaleAnchors: parsed.tailscaleAnchors ?? []
|
|
9728
|
+
};
|
|
9729
|
+
}
|
|
9730
|
+
function writeHealConfig(config, path) {
|
|
9731
|
+
const p = path || getHealConfigPath();
|
|
9732
|
+
ensureParentDir(p);
|
|
9733
|
+
writeFileSync6(p, `${JSON.stringify(config, null, 2)}
|
|
9734
|
+
`, "utf8");
|
|
9735
|
+
}
|
|
9736
|
+
function readHealState(path) {
|
|
9737
|
+
const p = path || getHealStatePath();
|
|
9738
|
+
if (!existsSync9(p))
|
|
9739
|
+
return defaultHealState();
|
|
9740
|
+
try {
|
|
9741
|
+
return { ...defaultHealState(), ...JSON.parse(readFileSync9(p, "utf8")) };
|
|
9742
|
+
} catch {
|
|
9743
|
+
return defaultHealState();
|
|
9744
|
+
}
|
|
9745
|
+
}
|
|
9746
|
+
function writeHealState(state, path) {
|
|
9747
|
+
const p = path || getHealStatePath();
|
|
9748
|
+
ensureParentDir(p);
|
|
9749
|
+
writeFileSync6(p, `${JSON.stringify(state, null, 2)}
|
|
9750
|
+
`, "utf8");
|
|
9751
|
+
}
|
|
9752
|
+
function evaluateHealth(probe, config, state) {
|
|
9753
|
+
const reasons = [];
|
|
9754
|
+
const inDegraded = state.degradedUntil > 0;
|
|
9755
|
+
const acceptableSsid = probe.associatedSsid === config.preferredSsid || config.fallbackSsid !== "" && inDegraded && probe.associatedSsid === config.fallbackSsid;
|
|
9756
|
+
if (!acceptableSsid)
|
|
9757
|
+
reasons.push(`wrong-ssid:${probe.associatedSsid ?? "none"}`);
|
|
9758
|
+
if (!probe.gatewayReachable)
|
|
9759
|
+
reasons.push("gateway-unreachable");
|
|
9760
|
+
let remoteScore = 0;
|
|
9761
|
+
for (const [anchor, ok] of Object.entries(probe.anchorsReachable)) {
|
|
9762
|
+
if (ok)
|
|
9763
|
+
remoteScore += 1;
|
|
9764
|
+
else
|
|
9765
|
+
reasons.push(`anchor-down:${anchor}`);
|
|
9766
|
+
}
|
|
9767
|
+
if (probe.internetReachable)
|
|
9768
|
+
remoteScore += 1;
|
|
9769
|
+
else
|
|
9770
|
+
reasons.push("internet-down");
|
|
9771
|
+
const localOk = acceptableSsid && probe.gatewayReachable;
|
|
9772
|
+
const quorumOk = remoteScore >= config.quorumRequired;
|
|
9773
|
+
if (!quorumOk)
|
|
9774
|
+
reasons.push(`quorum:${remoteScore}/${config.quorumRequired}`);
|
|
9775
|
+
return { healthy: localOk && quorumOk, remoteScore, reasons };
|
|
9776
|
+
}
|
|
9777
|
+
function decideAction(input) {
|
|
9778
|
+
const { healthy, now, gpuBusy, config, currentBootId } = input;
|
|
9779
|
+
const s = { ...input.state };
|
|
9780
|
+
const t = config.thresholds;
|
|
9781
|
+
if (s.bootId !== currentBootId) {
|
|
9782
|
+
s.bootId = currentBootId;
|
|
9783
|
+
s.bootHealthySince = null;
|
|
9784
|
+
s.failCount = 0;
|
|
9785
|
+
}
|
|
9786
|
+
if (healthy) {
|
|
9787
|
+
s.failCount = 0;
|
|
9788
|
+
if (s.bootHealthySince === null)
|
|
9789
|
+
s.bootHealthySince = now;
|
|
9790
|
+
if (now - s.bootHealthySince >= config.healthyWindowSec) {
|
|
9791
|
+
s.failedBootRecoveries = 0;
|
|
9792
|
+
s.rebootSuppressUntil = 0;
|
|
9793
|
+
s.pendingRebootRecovery = false;
|
|
9794
|
+
}
|
|
9795
|
+
if (s.degradedUntil > 0 && now >= s.degradedUntil) {
|
|
9796
|
+
s.degradedUntil = 0;
|
|
9797
|
+
return { action: "restore_preferred", state: s };
|
|
9798
|
+
}
|
|
9799
|
+
return { action: "none", state: s };
|
|
9800
|
+
}
|
|
9801
|
+
s.failCount += 1;
|
|
9802
|
+
s.bootHealthySince = null;
|
|
9803
|
+
let tier = "none";
|
|
9804
|
+
if (s.failCount >= t.reboot)
|
|
9805
|
+
tier = "reboot";
|
|
9806
|
+
else if (s.failCount >= t.fallback && config.fallbackSsid !== "")
|
|
9807
|
+
tier = "fallback";
|
|
9808
|
+
else if (s.failCount >= t.nmRestart)
|
|
9809
|
+
tier = "nmRestart";
|
|
9810
|
+
else if (s.failCount >= t.reconnect)
|
|
9811
|
+
tier = "reconnect";
|
|
9812
|
+
const tryReconnect = (reason) => {
|
|
9813
|
+
if (now - s.lastReconnect >= config.reconnectMinIntervalSec) {
|
|
9814
|
+
s.lastReconnect = now;
|
|
9815
|
+
return { action: "reconnect_wifi", suppressedReason: reason, state: s };
|
|
9816
|
+
}
|
|
9817
|
+
return { action: "none", suppressedReason: reason, state: s };
|
|
9818
|
+
};
|
|
9819
|
+
switch (tier) {
|
|
9820
|
+
case "reconnect":
|
|
9821
|
+
return tryReconnect();
|
|
9822
|
+
case "nmRestart":
|
|
9823
|
+
if (now - s.lastNmRestart >= config.nmRestartMinIntervalSec) {
|
|
9824
|
+
s.lastNmRestart = now;
|
|
9825
|
+
return { action: "restart_nm", state: s };
|
|
9826
|
+
}
|
|
9827
|
+
return tryReconnect();
|
|
9828
|
+
case "fallback":
|
|
9829
|
+
if (now - s.lastFallback >= config.fallbackWindowSec) {
|
|
9830
|
+
s.lastFallback = now;
|
|
9831
|
+
s.degradedUntil = now + config.fallbackWindowSec;
|
|
9832
|
+
return { action: "fallback_ssid", state: s };
|
|
9833
|
+
}
|
|
9834
|
+
return tryReconnect();
|
|
9835
|
+
case "reboot": {
|
|
9836
|
+
let reason = null;
|
|
9837
|
+
if (!config.allowReboot)
|
|
9838
|
+
reason = "disabled";
|
|
9839
|
+
else if (now < s.rebootSuppressUntil)
|
|
9840
|
+
reason = "loop";
|
|
9841
|
+
else if (config.gpuJobGuard && gpuBusy)
|
|
9842
|
+
reason = "gpu";
|
|
9843
|
+
else if (now - s.lastRebootAttempt < config.rebootMinIntervalSec)
|
|
9844
|
+
reason = "rate";
|
|
9845
|
+
if (reason)
|
|
9846
|
+
return tryReconnect(reason);
|
|
9847
|
+
if (s.pendingRebootRecovery) {
|
|
9848
|
+
s.failedBootRecoveries += 1;
|
|
9849
|
+
if (s.failedBootRecoveries >= config.maxFailedBootRecoveries) {
|
|
9850
|
+
s.rebootSuppressUntil = now + config.bootBackoffSec;
|
|
9851
|
+
return tryReconnect("loop");
|
|
9852
|
+
}
|
|
9853
|
+
}
|
|
9854
|
+
s.lastRebootAttempt = now;
|
|
9855
|
+
s.pendingRebootRecovery = true;
|
|
9856
|
+
return { action: "reboot", state: s };
|
|
9857
|
+
}
|
|
9858
|
+
default:
|
|
9859
|
+
return { action: "none", state: s };
|
|
9860
|
+
}
|
|
9861
|
+
}
|
|
9862
|
+
function sh(cmd, timeoutMs = 8000) {
|
|
9863
|
+
const r = Bun.spawnSync(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
|
|
9864
|
+
return { ok: r.exitCode === 0, out: r.stdout.toString("utf8").trim() };
|
|
9865
|
+
}
|
|
9866
|
+
function getCurrentBootId() {
|
|
9867
|
+
try {
|
|
9868
|
+
return readFileSync9("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
9869
|
+
} catch {
|
|
9870
|
+
return "";
|
|
9871
|
+
}
|
|
9872
|
+
}
|
|
9873
|
+
function detectWifiInterface() {
|
|
9874
|
+
const r = sh(`nmcli -t -f DEVICE,TYPE device status 2>/dev/null | awk -F: '$2=="wifi"{print $1; exit}'`);
|
|
9875
|
+
return r.ok ? r.out : "";
|
|
9876
|
+
}
|
|
9877
|
+
function detectGateway() {
|
|
9878
|
+
const r = sh(`ip route 2>/dev/null | awk '/^default/{print $3; exit}'`);
|
|
9879
|
+
return r.ok ? r.out : "";
|
|
9880
|
+
}
|
|
9881
|
+
function getAssociatedSsid() {
|
|
9882
|
+
const r = sh(`iwgetid -r 2>/dev/null || nmcli -t -f active,ssid dev wifi 2>/dev/null | awk -F: '/^yes/{print $2; exit}'`);
|
|
9883
|
+
return r.ok && r.out ? r.out : null;
|
|
9884
|
+
}
|
|
9885
|
+
function pingHost(host) {
|
|
9886
|
+
if (!host)
|
|
9887
|
+
return false;
|
|
9888
|
+
return sh(`ping -c1 -W2 ${host} >/dev/null 2>&1`, 5000).ok;
|
|
9889
|
+
}
|
|
9890
|
+
function internetReachable(url) {
|
|
9891
|
+
return sh(`curl -sf -m5 -o /dev/null ${url}`, 8000).ok;
|
|
9892
|
+
}
|
|
9893
|
+
function tailscalePing(host) {
|
|
9894
|
+
return sh(`timeout 8 tailscale ping --until-direct=false ${host} 2>/dev/null | grep -q pong`, 1e4).ok;
|
|
9895
|
+
}
|
|
9896
|
+
function gpuBusy() {
|
|
9897
|
+
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;
|
|
9898
|
+
}
|
|
9899
|
+
function discoverAnchors() {
|
|
9900
|
+
const r = sh(`tailscale status --json 2>/dev/null`);
|
|
9901
|
+
if (!r.ok)
|
|
9902
|
+
return [];
|
|
9903
|
+
try {
|
|
9904
|
+
const status = JSON.parse(r.out);
|
|
9905
|
+
const anchors = [];
|
|
9906
|
+
for (const peer of Object.values(status.Peer || {})) {
|
|
9907
|
+
const name = peer.HostName || (peer.DNSName || "").split(".")[0];
|
|
9908
|
+
if (name)
|
|
9909
|
+
anchors.push(name);
|
|
9910
|
+
}
|
|
9911
|
+
return anchors;
|
|
9912
|
+
} catch {
|
|
9913
|
+
return [];
|
|
9914
|
+
}
|
|
9915
|
+
}
|
|
9916
|
+
function probeHealth(config) {
|
|
9917
|
+
const gw = config.wifiInterface ? detectGateway() : detectGateway();
|
|
9918
|
+
const anchors = config.tailscaleAnchors.length > 0 ? config.tailscaleAnchors : discoverAnchors().slice(0, 3);
|
|
9919
|
+
const anchorsReachable = {};
|
|
9920
|
+
for (const a of anchors)
|
|
9921
|
+
anchorsReachable[a] = tailscalePing(a);
|
|
9922
|
+
return {
|
|
9923
|
+
associatedSsid: getAssociatedSsid(),
|
|
9924
|
+
gatewayReachable: pingHost(gw),
|
|
9925
|
+
anchorsReachable,
|
|
9926
|
+
internetReachable: internetReachable(config.internetUrl)
|
|
9927
|
+
};
|
|
9928
|
+
}
|
|
9929
|
+
function executeAction(action, config) {
|
|
9930
|
+
const iface = config.wifiInterface || detectWifiInterface();
|
|
9931
|
+
switch (action) {
|
|
9932
|
+
case "reconnect_wifi":
|
|
9933
|
+
sh(`nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
9934
|
+
return `reconnected wifi to ${config.preferredSsid}`;
|
|
9935
|
+
case "restart_nm":
|
|
9936
|
+
sh(`systemctl restart NetworkManager 2>&1; sleep 5; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 40000);
|
|
9937
|
+
return "restarted NetworkManager";
|
|
9938
|
+
case "fallback_ssid":
|
|
9939
|
+
sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect yes 2>&1; nmcli connection up "${config.fallbackSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
9940
|
+
return `switched to degraded fallback ${config.fallbackSsid}`;
|
|
9941
|
+
case "restore_preferred":
|
|
9942
|
+
sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect no 2>&1; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
9943
|
+
return `restored preferred ${config.preferredSsid}`;
|
|
9944
|
+
case "reboot":
|
|
9945
|
+
sh(`systemctl reboot 2>&1 || reboot 2>&1`, 1e4);
|
|
9946
|
+
return "reboot issued";
|
|
9947
|
+
default:
|
|
9948
|
+
return "no action";
|
|
9949
|
+
}
|
|
9950
|
+
}
|
|
9951
|
+
|
|
9952
|
+
// src/commands/heal-daemon.ts
|
|
9953
|
+
init_paths();
|
|
9954
|
+
import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
|
|
9955
|
+
import { join as join9 } from "path";
|
|
9956
|
+
var DAEMON_PID_PATH2 = join9(getDataDir(), "heal-daemon.pid");
|
|
9957
|
+
var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
|
|
9958
|
+
var SYSTEM_CONF = "/etc/systemd/system.conf";
|
|
9959
|
+
function log(msg) {
|
|
9960
|
+
console.log(`${new Date().toISOString()} [machines-heal] ${msg}`);
|
|
9961
|
+
}
|
|
9962
|
+
function runHealOnce(config, opts = {}) {
|
|
9963
|
+
const state = readHealState();
|
|
9964
|
+
const probe = probeHealth(config);
|
|
9965
|
+
const health = evaluateHealth(probe, config, state);
|
|
9966
|
+
const busy = config.gpuJobGuard ? gpuBusy() : false;
|
|
9967
|
+
const decision = decideAction({
|
|
9968
|
+
state,
|
|
9969
|
+
healthy: health.healthy,
|
|
9970
|
+
now: Math.floor(Date.now() / 1000),
|
|
9971
|
+
gpuBusy: busy,
|
|
9972
|
+
config,
|
|
9973
|
+
currentBootId: getCurrentBootId()
|
|
9974
|
+
});
|
|
9975
|
+
let executed = "skipped (dry-run)";
|
|
9976
|
+
if (!opts.dryRun) {
|
|
9977
|
+
writeHealState(decision.state);
|
|
9978
|
+
if (decision.action !== "none")
|
|
9979
|
+
executed = executeAction(decision.action, config);
|
|
9980
|
+
else
|
|
9981
|
+
executed = "no action";
|
|
9982
|
+
}
|
|
9983
|
+
const result = {
|
|
9984
|
+
healthy: health.healthy,
|
|
9985
|
+
action: decision.action,
|
|
9986
|
+
suppressedReason: decision.suppressedReason,
|
|
9987
|
+
reasons: health.reasons,
|
|
9988
|
+
remoteScore: health.remoteScore,
|
|
9989
|
+
failCount: decision.state.failCount,
|
|
9990
|
+
executed
|
|
9991
|
+
};
|
|
9992
|
+
const sup = decision.suppressedReason ? ` suppressed=${decision.suppressedReason}` : "";
|
|
9993
|
+
log(health.healthy ? `healthy (quorum ${health.remoteScore}) action=${decision.action} ${executed}` : `UNHEALTHY [${health.reasons.join(",")}] fails=${decision.state.failCount} action=${decision.action}${sup} -> ${executed}`);
|
|
9994
|
+
return result;
|
|
9995
|
+
}
|
|
9996
|
+
function writePid2(pid) {
|
|
9997
|
+
writeFileSync7(DAEMON_PID_PATH2, `${pid}
|
|
9998
|
+
`);
|
|
9999
|
+
}
|
|
10000
|
+
function readPid2() {
|
|
10001
|
+
try {
|
|
10002
|
+
const pid = Number.parseInt(readFileSync10(DAEMON_PID_PATH2, "utf8").trim());
|
|
10003
|
+
return Number.isFinite(pid) ? pid : null;
|
|
10004
|
+
} catch {
|
|
10005
|
+
return null;
|
|
10006
|
+
}
|
|
10007
|
+
}
|
|
10008
|
+
function isProcessRunning2(pid) {
|
|
10009
|
+
try {
|
|
10010
|
+
process.kill(pid, 0);
|
|
10011
|
+
return true;
|
|
10012
|
+
} catch {
|
|
10013
|
+
return false;
|
|
10014
|
+
}
|
|
10015
|
+
}
|
|
10016
|
+
function stopHealDaemon() {
|
|
10017
|
+
const pid = readPid2();
|
|
10018
|
+
if (pid && isProcessRunning2(pid)) {
|
|
10019
|
+
process.kill(pid, "SIGTERM");
|
|
10020
|
+
return { stopped: true, pid };
|
|
10021
|
+
}
|
|
10022
|
+
return { stopped: false, pid };
|
|
10023
|
+
}
|
|
10024
|
+
function startHealDaemon() {
|
|
10025
|
+
const config = readHealConfig();
|
|
10026
|
+
if (!config.preferredSsid) {
|
|
10027
|
+
log("refusing to start: preferredSsid is not configured (run `machines heal config --set ...`)");
|
|
10028
|
+
process.exit(1);
|
|
10029
|
+
}
|
|
10030
|
+
writePid2(process.pid);
|
|
10031
|
+
log(`daemon started (pid ${process.pid}) interval=${config.intervalSec}s preferred=${config.preferredSsid}`);
|
|
10032
|
+
const tick = () => {
|
|
10033
|
+
try {
|
|
10034
|
+
runHealOnce(config);
|
|
10035
|
+
} catch (err) {
|
|
10036
|
+
log(`tick error: ${err.message}`);
|
|
10037
|
+
}
|
|
10038
|
+
};
|
|
10039
|
+
tick();
|
|
10040
|
+
setInterval(tick, Math.max(10, config.intervalSec) * 1000);
|
|
10041
|
+
}
|
|
10042
|
+
function sh2(cmd, timeoutMs = 15000) {
|
|
10043
|
+
const r = Bun.spawnSync(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
|
|
10044
|
+
return { ok: r.exitCode === 0, out: `${r.stdout.toString("utf8")}${r.stderr.toString("utf8")}`.trim() };
|
|
10045
|
+
}
|
|
10046
|
+
function applyDeterminism(config) {
|
|
10047
|
+
const iface = config.wifiInterface || detectWifiInterface();
|
|
10048
|
+
const log2 = [];
|
|
10049
|
+
if (!config.preferredSsid)
|
|
10050
|
+
return ["no preferredSsid configured; skipping determinism"];
|
|
10051
|
+
sh2(`nmcli connection modify "${config.preferredSsid}" connection.autoconnect yes connection.autoconnect-priority 10 802-11-wireless.powersave 2`);
|
|
10052
|
+
log2.push(`pinned ${config.preferredSsid} (autoconnect, priority 10, powersave off)`);
|
|
10053
|
+
const profiles = sh2(`nmcli -t -f NAME,TYPE connection show 2>/dev/null | awk -F: '$2 ~ /wireless/{print $1}'`).out.split(`
|
|
10054
|
+
`).filter(Boolean);
|
|
10055
|
+
for (const p of profiles) {
|
|
10056
|
+
if (p === config.preferredSsid)
|
|
10057
|
+
continue;
|
|
10058
|
+
if (p === config.fallbackSsid) {
|
|
10059
|
+
sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
|
|
10060
|
+
log2.push(`disabled autoconnect on fallback ${p}`);
|
|
10061
|
+
continue;
|
|
10062
|
+
}
|
|
10063
|
+
sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
|
|
10064
|
+
log2.push(`disabled autoconnect on ${p}`);
|
|
10065
|
+
}
|
|
10066
|
+
if (iface) {
|
|
10067
|
+
sh2(`iw dev ${iface} set power_save off 2>/dev/null || true`);
|
|
10068
|
+
log2.push(`power_save off on ${iface}`);
|
|
10069
|
+
}
|
|
10070
|
+
return log2;
|
|
10071
|
+
}
|
|
10072
|
+
function enableHardwareWatchdog() {
|
|
10073
|
+
const log2 = [];
|
|
10074
|
+
if (!existsSync10(SYSTEM_CONF))
|
|
10075
|
+
return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
|
|
10076
|
+
let conf = readFileSync10(SYSTEM_CONF, "utf8");
|
|
10077
|
+
const set = (key, value) => {
|
|
10078
|
+
const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
|
|
10079
|
+
if (re.test(conf))
|
|
10080
|
+
conf = conf.replace(re, `${key}=${value}`);
|
|
10081
|
+
else
|
|
10082
|
+
conf += `
|
|
10083
|
+
${key}=${value}
|
|
10084
|
+
`;
|
|
10085
|
+
};
|
|
10086
|
+
set("RuntimeWatchdogSec", "20s");
|
|
10087
|
+
set("RebootWatchdogSec", "2min");
|
|
10088
|
+
writeFileSync7(SYSTEM_CONF, conf);
|
|
10089
|
+
sh2("systemctl daemon-reexec");
|
|
10090
|
+
log2.push("hardware watchdog: RuntimeWatchdogSec=20s RebootWatchdogSec=2min");
|
|
10091
|
+
return log2;
|
|
10092
|
+
}
|
|
10093
|
+
function binPath() {
|
|
10094
|
+
const candidates = [];
|
|
10095
|
+
const which = sh2("command -v machines").out.split(`
|
|
10096
|
+
`)[0]?.trim();
|
|
10097
|
+
if (which)
|
|
10098
|
+
candidates.push(which);
|
|
10099
|
+
if (process.argv[1])
|
|
10100
|
+
candidates.push(process.argv[1]);
|
|
10101
|
+
const home = process.env["HOME"] || "/home/hasna";
|
|
10102
|
+
candidates.push(`${home}/.bun/bin/machines`, "/home/hasna/.bun/bin/machines", "/root/.bun/bin/machines", "/usr/local/bin/machines");
|
|
10103
|
+
for (const c of candidates) {
|
|
10104
|
+
if (c && existsSync10(c))
|
|
10105
|
+
return c;
|
|
10106
|
+
}
|
|
10107
|
+
return "machines";
|
|
10108
|
+
}
|
|
10109
|
+
var ROOT_DATA_DIR = "/etc/machines-heal";
|
|
10110
|
+
function installHealService() {
|
|
10111
|
+
const log2 = [];
|
|
10112
|
+
const exec = binPath();
|
|
10113
|
+
const binDir = exec.includes("/") ? exec.slice(0, exec.lastIndexOf("/")) : "/usr/local/bin";
|
|
10114
|
+
const unit = `[Unit]
|
|
10115
|
+
Description=Hasna machines self-healing network watchdog
|
|
10116
|
+
After=network.target NetworkManager.service tailscaled.service
|
|
10117
|
+
Wants=network.target
|
|
10118
|
+
|
|
10119
|
+
[Service]
|
|
10120
|
+
Type=simple
|
|
10121
|
+
ExecStart=${exec} heal daemon
|
|
10122
|
+
Restart=always
|
|
10123
|
+
RestartSec=10
|
|
10124
|
+
Environment=HOME=/root
|
|
10125
|
+
Environment=HASNA_MACHINES_DIR=${ROOT_DATA_DIR}
|
|
10126
|
+
Environment=PATH=${binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
10127
|
+
|
|
10128
|
+
[Install]
|
|
10129
|
+
WantedBy=multi-user.target
|
|
10130
|
+
`;
|
|
10131
|
+
writeFileSync7(SERVICE_PATH, unit);
|
|
10132
|
+
sh2("systemctl daemon-reload");
|
|
10133
|
+
sh2("systemctl enable --now machines-heal.service");
|
|
10134
|
+
log2.push(`installed + enabled ${SERVICE_PATH} (ExecStart=${exec} heal daemon)`);
|
|
10135
|
+
return log2;
|
|
10136
|
+
}
|
|
10137
|
+
function uninstallHealService() {
|
|
10138
|
+
const log2 = [];
|
|
10139
|
+
sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
|
|
10140
|
+
if (existsSync10(SERVICE_PATH)) {
|
|
10141
|
+
sh2(`rm -f ${SERVICE_PATH}`);
|
|
10142
|
+
sh2("systemctl daemon-reload");
|
|
10143
|
+
log2.push(`removed ${SERVICE_PATH}`);
|
|
10144
|
+
} else {
|
|
10145
|
+
log2.push("service not installed");
|
|
10146
|
+
}
|
|
10147
|
+
return log2;
|
|
10148
|
+
}
|
|
10149
|
+
function healServiceStatus() {
|
|
10150
|
+
return {
|
|
10151
|
+
installed: existsSync10(SERVICE_PATH),
|
|
10152
|
+
active: sh2("systemctl is-active machines-heal.service").out === "active",
|
|
10153
|
+
enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
|
|
10154
|
+
};
|
|
10155
|
+
}
|
|
10156
|
+
|
|
10157
|
+
// src/cli/index.ts
|
|
10158
|
+
init_paths();
|
|
10159
|
+
|
|
8860
10160
|
// src/cli-utils.ts
|
|
8861
10161
|
function parseIntegerOption(value, label, constraints = {}) {
|
|
8862
10162
|
const parsed = Number.parseInt(value, 10);
|
|
@@ -8888,7 +10188,7 @@ ${items.map((item) => `- ${item}`).join(`
|
|
|
8888
10188
|
|
|
8889
10189
|
// src/cli/index.ts
|
|
8890
10190
|
import { rmSync as rmSync2 } from "fs";
|
|
8891
|
-
import { readFileSync as
|
|
10191
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
8892
10192
|
var program2 = new Command;
|
|
8893
10193
|
function printJsonOrText(data, text, json = false) {
|
|
8894
10194
|
if (json || program2.opts().quiet) {
|
|
@@ -8897,6 +10197,22 @@ function printJsonOrText(data, text, json = false) {
|
|
|
8897
10197
|
}
|
|
8898
10198
|
console.log(text);
|
|
8899
10199
|
}
|
|
10200
|
+
function printStorageResults(results, json) {
|
|
10201
|
+
if (json) {
|
|
10202
|
+
console.log(JSON.stringify(results, null, 2));
|
|
10203
|
+
return;
|
|
10204
|
+
}
|
|
10205
|
+
for (const result of results) {
|
|
10206
|
+
const marker = result.errors.length > 0 ? source_default.red("!") : source_default.green("\u2713");
|
|
10207
|
+
const suffix = result.errors.length > 0 ? ` ${source_default.red(result.errors.join("; "))}` : "";
|
|
10208
|
+
console.log(`${marker} ${result.table}: read ${result.rowsRead}, wrote ${result.rowsWritten}${suffix}`);
|
|
10209
|
+
}
|
|
10210
|
+
}
|
|
10211
|
+
function printStorageError(error) {
|
|
10212
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
10213
|
+
console.error(source_default.red(message));
|
|
10214
|
+
process.exit(1);
|
|
10215
|
+
}
|
|
8900
10216
|
function renderAppsListResult(result) {
|
|
8901
10217
|
return [
|
|
8902
10218
|
`machine: ${result.machineId}`,
|
|
@@ -8979,6 +10295,49 @@ function renderSelfTestResult(result) {
|
|
|
8979
10295
|
].join(`
|
|
8980
10296
|
`);
|
|
8981
10297
|
}
|
|
10298
|
+
function parseCommandSpec(value) {
|
|
10299
|
+
const [command, expectedVersion] = value.split(":");
|
|
10300
|
+
return {
|
|
10301
|
+
command,
|
|
10302
|
+
expectedVersion: expectedVersion || undefined,
|
|
10303
|
+
required: true
|
|
10304
|
+
};
|
|
10305
|
+
}
|
|
10306
|
+
function parsePackageSpec(value) {
|
|
10307
|
+
const [name, command, expectedVersion] = value.split(":");
|
|
10308
|
+
return {
|
|
10309
|
+
name,
|
|
10310
|
+
command: command || undefined,
|
|
10311
|
+
expectedVersion: expectedVersion || undefined,
|
|
10312
|
+
required: true
|
|
10313
|
+
};
|
|
10314
|
+
}
|
|
10315
|
+
function parseWorkspaceSpec(value) {
|
|
10316
|
+
const [label, path] = value.includes("=") ? value.split(/=(.*)/s).filter(Boolean) : ["workspace", value];
|
|
10317
|
+
return {
|
|
10318
|
+
label,
|
|
10319
|
+
path,
|
|
10320
|
+
required: true
|
|
10321
|
+
};
|
|
10322
|
+
}
|
|
10323
|
+
function renderCompatibilityCheck(check2) {
|
|
10324
|
+
const marker = check2.status === "ok" ? source_default.green("\u2713") : check2.status === "warn" ? source_default.yellow("!") : source_default.red("\u2717");
|
|
10325
|
+
const expected = check2.expected ? ` expected=${check2.expected}` : "";
|
|
10326
|
+
return `${marker} ${check2.id} ${check2.actual ?? "unknown"}${expected}`;
|
|
10327
|
+
}
|
|
10328
|
+
function renderCompatibilityResult(result) {
|
|
10329
|
+
return [
|
|
10330
|
+
renderKeyValueTable([
|
|
10331
|
+
["machine", result.machine_id],
|
|
10332
|
+
["source", result.source],
|
|
10333
|
+
["ok", String(result.ok)],
|
|
10334
|
+
["checks", `${result.summary.ok} ok, ${result.summary.warn} warn, ${result.summary.fail} fail`]
|
|
10335
|
+
]),
|
|
10336
|
+
"",
|
|
10337
|
+
...result.checks.map(renderCompatibilityCheck)
|
|
10338
|
+
].join(`
|
|
10339
|
+
`);
|
|
10340
|
+
}
|
|
8982
10341
|
function renderFleetStatus(status) {
|
|
8983
10342
|
return [
|
|
8984
10343
|
renderKeyValueTable([
|
|
@@ -9035,7 +10394,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
|
|
|
9035
10394
|
console.error("error: --from-stdin requires piped input");
|
|
9036
10395
|
process.exit(1);
|
|
9037
10396
|
}
|
|
9038
|
-
const input =
|
|
10397
|
+
const input = readFileSync11(0, "utf8");
|
|
9039
10398
|
const machine2 = JSON.parse(input);
|
|
9040
10399
|
console.log(JSON.stringify(manifestAdd(machine2), null, 2));
|
|
9041
10400
|
return;
|
|
@@ -9100,6 +10459,35 @@ program2.command("sync").description("Reconcile a machine against the fleet mani
|
|
|
9100
10459
|
const result = options.apply ? runSync(options.machine, { apply: true, yes: options.yes }) : buildSyncPlan(options.machine);
|
|
9101
10460
|
console.log(JSON.stringify(result, null, 2));
|
|
9102
10461
|
});
|
|
10462
|
+
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) => {
|
|
10463
|
+
const topology = discoverMachineTopology({ includeTailscale: options.tailscale !== false });
|
|
10464
|
+
if (options.json) {
|
|
10465
|
+
console.log(JSON.stringify(topology, null, 2));
|
|
10466
|
+
return;
|
|
10467
|
+
}
|
|
10468
|
+
console.log(renderKeyValueTable([
|
|
10469
|
+
["local machine", topology.local_machine_id],
|
|
10470
|
+
["hostname", topology.local_hostname],
|
|
10471
|
+
["platform", String(topology.current_platform)],
|
|
10472
|
+
["machines", String(topology.machines.length)],
|
|
10473
|
+
["warnings", topology.warnings.join(", ") || "none"]
|
|
10474
|
+
]));
|
|
10475
|
+
for (const machine of topology.machines) {
|
|
10476
|
+
const route = machine.ssh.command_target ? `${machine.ssh.route}:${machine.ssh.command_target}` : machine.ssh.route;
|
|
10477
|
+
console.log(`${machine.machine_id.padEnd(18)} ${String(machine.platform || "unknown").padEnd(8)} ${machine.heartbeat_status.padEnd(8)} ${route}`);
|
|
10478
|
+
}
|
|
10479
|
+
});
|
|
10480
|
+
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) => {
|
|
10481
|
+
const result = checkMachineCompatibility({
|
|
10482
|
+
machineId: options.machine,
|
|
10483
|
+
commands: options.command?.map(parseCommandSpec),
|
|
10484
|
+
packages: options.package?.map(parsePackageSpec),
|
|
10485
|
+
workspaces: options.workspace?.map(parseWorkspaceSpec)
|
|
10486
|
+
});
|
|
10487
|
+
printJsonOrText(result, renderCompatibilityResult(result), options.json);
|
|
10488
|
+
if (!result.ok && !options.json)
|
|
10489
|
+
process.exitCode = 1;
|
|
10490
|
+
});
|
|
9103
10491
|
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
10492
|
const result = diffMachines(options.left, options.right);
|
|
9105
10493
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -9253,6 +10641,51 @@ program2.command("ports").description("List listening ports on a machine").optio
|
|
|
9253
10641
|
const result = listPorts(options.machine);
|
|
9254
10642
|
console.log(JSON.stringify(result, null, 2));
|
|
9255
10643
|
});
|
|
10644
|
+
var storageCommand = program2.command("storage").description("Sync local machine runtime data with storage PostgreSQL");
|
|
10645
|
+
storageCommand.command("status").description("Show storage sync status").option("-j, --json", "Print JSON output", false).action(async (options) => {
|
|
10646
|
+
const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10647
|
+
const status = getStorageStatus2();
|
|
10648
|
+
printJsonOrText(status, renderKeyValueTable([
|
|
10649
|
+
["mode", status.mode],
|
|
10650
|
+
["configured", status.configured ? "yes" : "no"],
|
|
10651
|
+
["active env", status.activeEnv || "none"],
|
|
10652
|
+
["tables", status.tables.join(", ")]
|
|
10653
|
+
]), options.json);
|
|
10654
|
+
});
|
|
10655
|
+
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) => {
|
|
10656
|
+
try {
|
|
10657
|
+
const { parseStorageTables: parseStorageTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10658
|
+
const results = await storagePush2({ tables: parseStorageTables2(options.tables) });
|
|
10659
|
+
printStorageResults(results, options.json);
|
|
10660
|
+
} catch (error) {
|
|
10661
|
+
printStorageError(error);
|
|
10662
|
+
}
|
|
10663
|
+
});
|
|
10664
|
+
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) => {
|
|
10665
|
+
try {
|
|
10666
|
+
const { parseStorageTables: parseStorageTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10667
|
+
const results = await storagePull2({ tables: parseStorageTables2(options.tables) });
|
|
10668
|
+
printStorageResults(results, options.json);
|
|
10669
|
+
} catch (error) {
|
|
10670
|
+
printStorageError(error);
|
|
10671
|
+
}
|
|
10672
|
+
});
|
|
10673
|
+
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) => {
|
|
10674
|
+
try {
|
|
10675
|
+
const { parseStorageTables: parseStorageTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
|
|
10676
|
+
const result = await storageSync2({ tables: parseStorageTables2(options.tables) });
|
|
10677
|
+
if (options.json) {
|
|
10678
|
+
console.log(JSON.stringify(result, null, 2));
|
|
10679
|
+
return;
|
|
10680
|
+
}
|
|
10681
|
+
console.log(source_default.bold("Pull"));
|
|
10682
|
+
printStorageResults(result.pull);
|
|
10683
|
+
console.log(source_default.bold("Push"));
|
|
10684
|
+
printStorageResults(result.push);
|
|
10685
|
+
} catch (error) {
|
|
10686
|
+
printStorageError(error);
|
|
10687
|
+
}
|
|
10688
|
+
});
|
|
9256
10689
|
program2.command("status").description("Print local machine and storage status").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
9257
10690
|
const status = getStatus();
|
|
9258
10691
|
printJsonOrText(status, renderFleetStatus(status), options.json);
|
|
@@ -9274,4 +10707,98 @@ program2.command("serve").description("Serve a local fleet dashboard and JSON AP
|
|
|
9274
10707
|
const server = startDashboardServer({ host: info.host, port: info.port });
|
|
9275
10708
|
console.log(source_default.green(`machines dashboard listening on http://${server.hostname}:${server.port}`));
|
|
9276
10709
|
});
|
|
10710
|
+
var healCommand = program2.command("heal").description("Self-healing network watchdog: keeps a Wi-Fi node reachable (SSID pinning + peer-reachability + gated reboot)");
|
|
10711
|
+
function requireRoot() {
|
|
10712
|
+
const uid = process.getuid ? process.getuid() : 1;
|
|
10713
|
+
if (uid !== 0) {
|
|
10714
|
+
console.error(source_default.red("error: this command must run as root (try: sudo machines heal install)"));
|
|
10715
|
+
return false;
|
|
10716
|
+
}
|
|
10717
|
+
return true;
|
|
10718
|
+
}
|
|
10719
|
+
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) => {
|
|
10720
|
+
if (options.set) {
|
|
10721
|
+
const current = readHealConfig();
|
|
10722
|
+
const partial = JSON.parse(options.set);
|
|
10723
|
+
writeHealConfig({
|
|
10724
|
+
...current,
|
|
10725
|
+
...partial,
|
|
10726
|
+
thresholds: { ...current.thresholds, ...partial.thresholds || {} }
|
|
10727
|
+
});
|
|
10728
|
+
}
|
|
10729
|
+
const config = readHealConfig();
|
|
10730
|
+
printJsonOrText(config, renderKeyValueTable([
|
|
10731
|
+
["enabled", String(config.enabled)],
|
|
10732
|
+
["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
|
|
10733
|
+
["fallbackSsid", config.fallbackSsid || "(none)"],
|
|
10734
|
+
["anchors", config.tailscaleAnchors.length ? config.tailscaleAnchors.join(", ") : "(auto-discover)"],
|
|
10735
|
+
["quorumRequired", String(config.quorumRequired)],
|
|
10736
|
+
["intervalSec", String(config.intervalSec)],
|
|
10737
|
+
["thresholds", `reconnect=${config.thresholds.reconnect} nm=${config.thresholds.nmRestart} fallback=${config.thresholds.fallback} reboot=${config.thresholds.reboot}`],
|
|
10738
|
+
["allowReboot", String(config.allowReboot)],
|
|
10739
|
+
["gpuJobGuard", String(config.gpuJobGuard)]
|
|
10740
|
+
]), options.json);
|
|
10741
|
+
});
|
|
10742
|
+
healCommand.command("check").description("Run one health + decision tick read-only (no side effects)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
10743
|
+
const result = runHealOnce(readHealConfig(), { dryRun: true });
|
|
10744
|
+
printJsonOrText(result, renderList("heal check", [
|
|
10745
|
+
`health: ${result.healthy ? source_default.green("HEALTHY") : source_default.red("UNHEALTHY")} (remote quorum ${result.remoteScore})`,
|
|
10746
|
+
`reasons: ${result.reasons.length ? result.reasons.join(", ") : "none"}`,
|
|
10747
|
+
`would do: ${result.action}${result.suppressedReason ? ` (reboot suppressed: ${result.suppressedReason})` : ""}`,
|
|
10748
|
+
`consecutive fails: ${result.failCount}`
|
|
10749
|
+
]), options.json);
|
|
10750
|
+
});
|
|
10751
|
+
healCommand.command("status").description("Show watchdog service status and last persisted state").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
10752
|
+
const svc = healServiceStatus();
|
|
10753
|
+
const state = readHealState();
|
|
10754
|
+
const config = readHealConfig();
|
|
10755
|
+
printJsonOrText({ service: svc, state, config }, renderKeyValueTable([
|
|
10756
|
+
["service installed", svc.installed ? source_default.green("yes") : "no"],
|
|
10757
|
+
["service active", svc.active ? source_default.green("yes") : source_default.yellow("no")],
|
|
10758
|
+
["service enabled", svc.enabled ? "yes" : "no"],
|
|
10759
|
+
["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
|
|
10760
|
+
["consecutive fails", String(state.failCount)],
|
|
10761
|
+
["pending reboot recovery", String(state.pendingRebootRecovery)],
|
|
10762
|
+
["failed boot recoveries", String(state.failedBootRecoveries)]
|
|
10763
|
+
]), options.json);
|
|
10764
|
+
});
|
|
10765
|
+
healCommand.command("daemon").description("Run the watchdog loop in the foreground (used by systemd)").action(() => {
|
|
10766
|
+
startHealDaemon();
|
|
10767
|
+
});
|
|
10768
|
+
healCommand.command("stop").description("Stop a foreground daemon started via `heal daemon`").action(() => {
|
|
10769
|
+
const r = stopHealDaemon();
|
|
10770
|
+
console.log(r.stopped ? `stopped heal daemon (pid ${r.pid})` : "heal daemon not running");
|
|
10771
|
+
});
|
|
10772
|
+
healCommand.command("determinism").description("Pin the preferred SSID, disable other autoconnects, turn off Wi-Fi power save").action(() => {
|
|
10773
|
+
const log2 = applyDeterminism(readHealConfig());
|
|
10774
|
+
console.log(renderList("determinism", log2));
|
|
10775
|
+
});
|
|
10776
|
+
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) => {
|
|
10777
|
+
if (!requireRoot()) {
|
|
10778
|
+
process.exitCode = 1;
|
|
10779
|
+
return;
|
|
10780
|
+
}
|
|
10781
|
+
const config = readHealConfig();
|
|
10782
|
+
if (!config.preferredSsid) {
|
|
10783
|
+
console.error(source_default.red(`error: set preferredSsid first: machines heal config --set '{"preferredSsid":"X81ND"}'`));
|
|
10784
|
+
process.exitCode = 1;
|
|
10785
|
+
return;
|
|
10786
|
+
}
|
|
10787
|
+
const out = [];
|
|
10788
|
+
if (options.determinism !== false)
|
|
10789
|
+
out.push(...applyDeterminism(config));
|
|
10790
|
+
if (options.watchdog !== false)
|
|
10791
|
+
out.push(...enableHardwareWatchdog());
|
|
10792
|
+
if (options.service !== false)
|
|
10793
|
+
out.push(...installHealService());
|
|
10794
|
+
console.log(renderList("install", out));
|
|
10795
|
+
console.log(source_default.green("self-healing watchdog installed"));
|
|
10796
|
+
});
|
|
10797
|
+
healCommand.command("uninstall").description("Remove the systemd watchdog service (requires root)").action(() => {
|
|
10798
|
+
if (!requireRoot()) {
|
|
10799
|
+
process.exitCode = 1;
|
|
10800
|
+
return;
|
|
10801
|
+
}
|
|
10802
|
+
console.log(renderList("uninstall", uninstallHealService()));
|
|
10803
|
+
});
|
|
9277
10804
|
await program2.parseAsync(process.argv);
|