@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/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/db.ts
6755
- import { Database } from "bun:sqlite";
6756
- import { hostname as hostname2 } from "os";
6757
- class SqliteAdapter {
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", ["-lc", shellCommand], {
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
- function makeCheck(id, status, summary, detail) {
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
- makeCheck("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", machineInManifest ? JSON.stringify(machineInManifest) : `No manifest entry for ${machineId}`),
8091
- makeCheck("manifest-path", details["manifest_exists"] === "yes" ? "ok" : "warn", "Manifest path check", `${details["manifest_path"] || "unknown"} ${details["manifest_exists"] === "yes" ? "exists" : "missing"}`),
8092
- makeCheck("db-path", details["db_exists"] === "yes" ? "ok" : "warn", "DB path check", `${details["db_path"] || "unknown"} ${details["db_exists"] === "yes" ? "exists" : "missing"}`),
8093
- makeCheck("notifications-path", details["notifications_exists"] === "yes" ? "ok" : "warn", "Notifications path check", `${details["notifications_path"] || "unknown"} ${details["notifications_exists"] === "yes" ? "exists" : "missing"}`),
8094
- makeCheck("bun", details["bun"] && details["bun"] !== "missing" ? "ok" : "fail", "Bun availability", details["bun"] || "missing"),
8095
- makeCheck("machines-cli", details["machines"] && details["machines"] !== "missing" ? "ok" : "warn", "machines CLI availability", details["machines"] || "missing"),
8096
- makeCheck("machines-agent-cli", details["machines_agent"] && details["machines_agent"] !== "missing" ? "ok" : "warn", "machines-agent availability", details["machines_agent"] || "missing"),
8097
- makeCheck("machines-mcp-cli", details["machines_mcp"] && details["machines_mcp"] !== "missing" ? "ok" : "warn", "machines-mcp availability", details["machines_mcp"] || "missing"),
8098
- makeCheck("ssh", details["ssh"] === "ok" ? "ok" : "warn", "SSH availability", details["ssh"] || "missing")
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -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 existsSync7, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
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 (!existsSync7(path)) {
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 (!existsSync7(path)) {
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 (existsSync7(keyPath)) {
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 (existsSync7(path)) {
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 platform3 = process.platform;
8540
- if (platform3 === "darwin") {
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 (platform3 === "linux") {
8545
- if (hasCommand2("wl-paste")) {
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 (hasCommand2("xclip")) {
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 platform3 = process.platform;
8559
- if (platform3 === "darwin") {
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 (platform3 === "linux") {
8564
- if (hasCommand2("wl-copy")) {
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 (hasCommand2("xclip")) {
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 hasCommand2(binary) {
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 platform3 = process.platform;
8691
- if (platform3 === "darwin") {
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 (platform3 === "linux") {
8696
- if (hasCommand3("wl-paste")) {
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 (hasCommand3("xclip")) {
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 hasCommand3(binary) {
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 readFileSync9 } from "fs";
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 = readFileSync9(0, "utf8");
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);