@hasna/machines 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,7 +7571,9 @@ function diffMachines(leftMachineId, rightMachineId) {
7194
7571
  }
7195
7572
 
7196
7573
  // src/remote.ts
7574
+ init_db();
7197
7575
  import { spawnSync as spawnSync2 } from "child_process";
7576
+ import { hostname as hostname3 } from "os";
7198
7577
 
7199
7578
  // src/commands/ssh.ts
7200
7579
  import { spawnSync } from "child_process";
@@ -7240,18 +7619,37 @@ function buildSshCommand(machineId, remoteCommand) {
7240
7619
  }
7241
7620
 
7242
7621
  // src/remote.ts
7622
+ function shellQuote(value) {
7623
+ return `'${value.replace(/'/g, "'\\''")}'`;
7624
+ }
7625
+ function machineIsLocal(machineId, localMachineId) {
7626
+ return machineId === "local" || machineId === "localhost" || machineId === localMachineId || machineId === hostname3();
7627
+ }
7628
+ function resolveMachineCommand(machineId, command, localMachineId = getLocalMachineId()) {
7629
+ if (machineIsLocal(machineId, localMachineId)) {
7630
+ return { source: "local", shellCommand: command };
7631
+ }
7632
+ try {
7633
+ return {
7634
+ source: resolveSshTarget(machineId).route,
7635
+ shellCommand: buildSshCommand(machineId, command)
7636
+ };
7637
+ } catch (error) {
7638
+ if (String(error.message ?? error).includes("Machine not found in manifest")) {
7639
+ return { source: "ssh", shellCommand: `ssh ${shellQuote(machineId)} ${shellQuote(command)}` };
7640
+ }
7641
+ throw error;
7642
+ }
7643
+ }
7243
7644
  function runMachineCommand(machineId, command) {
7244
- const localMachineId = getLocalMachineId();
7245
- const isLocal = machineId === localMachineId;
7246
- const route = isLocal ? "local" : resolveSshTarget(machineId).route;
7247
- const shellCommand = isLocal ? command : buildSshCommand(machineId, command);
7248
- const result = spawnSync2("bash", ["-lc", shellCommand], {
7645
+ const resolved = resolveMachineCommand(machineId, command);
7646
+ const result = spawnSync2("bash", ["-c", resolved.shellCommand], {
7249
7647
  encoding: "utf8",
7250
7648
  env: process.env
7251
7649
  });
7252
7650
  return {
7253
7651
  machineId,
7254
- source: route,
7652
+ source: resolved.source,
7255
7653
  stdout: result.stdout || "",
7256
7654
  stderr: result.stderr || "",
7257
7655
  exitCode: result.status ?? 1
@@ -7271,7 +7669,7 @@ function getAppManager(machine, app) {
7271
7669
  return "winget";
7272
7670
  return "apt";
7273
7671
  }
7274
- function shellQuote(value) {
7672
+ function shellQuote2(value) {
7275
7673
  return `'${value.replace(/'/g, `'\\''`)}'`;
7276
7674
  }
7277
7675
  function buildAppCommand(machine, app) {
@@ -7292,7 +7690,7 @@ function buildAppCommand(machine, app) {
7292
7690
  return `sudo apt-get install -y ${packageName}`;
7293
7691
  }
7294
7692
  function buildAppProbeCommand(machine, app) {
7295
- const packageName = shellQuote(getPackageName(app));
7693
+ const packageName = shellQuote2(getPackageName(app));
7296
7694
  const manager = getAppManager(machine, app);
7297
7695
  if (manager === "custom") {
7298
7696
  return `if command -v ${packageName} >/dev/null 2>&1; then printf 'installed=1\\nversion=custom\\n'; else printf 'installed=0\\n'; fi`;
@@ -7571,6 +7969,7 @@ function runTailscaleInstall(machineId, options = {}) {
7571
7969
 
7572
7970
  // src/commands/notifications.ts
7573
7971
  import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
7972
+ init_paths();
7574
7973
  var notificationChannelSchema = exports_external.object({
7575
7974
  id: exports_external.string(),
7576
7975
  type: exports_external.enum(["email", "webhook", "command"]),
@@ -7586,7 +7985,7 @@ var notificationConfigSchema = exports_external.object({
7586
7985
  function sortChannels(channels) {
7587
7986
  return [...channels].sort((left, right) => left.id.localeCompare(right.id));
7588
7987
  }
7589
- function shellQuote2(value) {
7988
+ function shellQuote3(value) {
7590
7989
  return `'${value.replace(/'/g, `'\\''`)}'`;
7591
7990
  }
7592
7991
  function hasCommand(binary) {
@@ -7633,7 +8032,7 @@ ${message}
7633
8032
  };
7634
8033
  }
7635
8034
  if (hasCommand("mail")) {
7636
- const command = `printf %s ${shellQuote2(message)} | mail -s ${shellQuote2(subject)} ${shellQuote2(channel.target)}`;
8035
+ const command = `printf %s ${shellQuote3(message)} | mail -s ${shellQuote3(subject)} ${shellQuote3(channel.target)}`;
7637
8036
  const result = Bun.spawnSync(["bash", "-lc", command], {
7638
8037
  stdout: "pipe",
7639
8038
  stderr: "pipe",
@@ -7817,6 +8216,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
7817
8216
  }
7818
8217
 
7819
8218
  // src/commands/ports.ts
8219
+ init_db();
7820
8220
  import { spawnSync as spawnSync3 } from "child_process";
7821
8221
  function parseSsOutput(output) {
7822
8222
  return output.trim().split(`
@@ -7872,6 +8272,8 @@ function listPorts(machineId) {
7872
8272
 
7873
8273
  // src/commands/sync.ts
7874
8274
  import { existsSync as existsSync6, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
8275
+ init_paths();
8276
+ init_db();
7875
8277
  function quote4(value) {
7876
8278
  return `'${value.replace(/'/g, `'\\''`)}'`;
7877
8279
  }
@@ -8023,6 +8425,8 @@ function runSync(machineId, options = {}) {
8023
8425
  }
8024
8426
 
8025
8427
  // src/commands/status.ts
8428
+ init_db();
8429
+ init_paths();
8026
8430
  function getStatus() {
8027
8431
  const manifest = readManifest();
8028
8432
  const heartbeats = listHeartbeats();
@@ -8054,8 +8458,424 @@ function getStatus() {
8054
8458
  };
8055
8459
  }
8056
8460
 
8461
+ // src/topology.ts
8462
+ init_db();
8463
+ import { existsSync as existsSync7 } from "fs";
8464
+ import { arch as arch2, hostname as hostname4, platform as platform3, userInfo as userInfo2 } from "os";
8465
+ import { spawnSync as spawnSync4 } from "child_process";
8466
+ init_paths();
8467
+ function normalizePlatform2(value = platform3()) {
8468
+ const normalized = value.toLowerCase();
8469
+ if (normalized === "darwin" || normalized === "macos")
8470
+ return "macos";
8471
+ if (normalized === "win32" || normalized === "windows")
8472
+ return "windows";
8473
+ if (normalized === "linux")
8474
+ return "linux";
8475
+ return value;
8476
+ }
8477
+ function defaultRunner(command) {
8478
+ const result = spawnSync4("bash", ["-c", command], {
8479
+ encoding: "utf8",
8480
+ env: process.env
8481
+ });
8482
+ return {
8483
+ stdout: result.stdout || "",
8484
+ stderr: result.stderr || "",
8485
+ exitCode: result.status ?? 1
8486
+ };
8487
+ }
8488
+ function hasCommand2(command, runner) {
8489
+ return runner(`command -v ${command} >/dev/null 2>&1`).exitCode === 0;
8490
+ }
8491
+ function parseTailscaleStatus(raw) {
8492
+ try {
8493
+ const parsed = JSON.parse(raw);
8494
+ if (!parsed || typeof parsed !== "object")
8495
+ return null;
8496
+ return parsed;
8497
+ } catch {
8498
+ return null;
8499
+ }
8500
+ }
8501
+ function loadTailscalePeers(runner, warnings) {
8502
+ const peers = new Map;
8503
+ if (!hasCommand2("tailscale", runner)) {
8504
+ warnings.push("tailscale_not_available");
8505
+ return peers;
8506
+ }
8507
+ const result = runner("tailscale status --json");
8508
+ if (result.exitCode !== 0) {
8509
+ warnings.push(`tailscale_status_failed:${result.stderr.trim() || result.exitCode}`);
8510
+ return peers;
8511
+ }
8512
+ const status = parseTailscaleStatus(result.stdout);
8513
+ if (!status) {
8514
+ warnings.push("tailscale_status_invalid_json");
8515
+ return peers;
8516
+ }
8517
+ const addPeer = (peer) => {
8518
+ if (!peer)
8519
+ return;
8520
+ const id = peer.HostName || peer.DNSName?.split(".")[0];
8521
+ if (id)
8522
+ peers.set(id, peer);
8523
+ };
8524
+ addPeer(status.Self);
8525
+ for (const peer of Object.values(status.Peer ?? {}))
8526
+ addPeer(peer);
8527
+ return peers;
8528
+ }
8529
+ function machineKeys(machine) {
8530
+ return [
8531
+ machine.id,
8532
+ machine.hostname,
8533
+ machine.tailscaleName?.split(".")[0],
8534
+ machine.tailscaleName,
8535
+ machine.sshAddress?.split("@").pop()
8536
+ ].filter((value) => Boolean(value));
8537
+ }
8538
+ function findTailscalePeer(machine, machineId, peers) {
8539
+ if (machine) {
8540
+ for (const key of machineKeys(machine)) {
8541
+ const peer = peers.get(key) ?? peers.get(key.replace(/\.$/, ""));
8542
+ if (peer)
8543
+ return peer;
8544
+ }
8545
+ }
8546
+ return peers.get(machineId) ?? null;
8547
+ }
8548
+ function routeHints(input) {
8549
+ const hints = [];
8550
+ if (input.machineId === input.localMachineId) {
8551
+ hints.push({ kind: "local", target: "localhost", reachable: true });
8552
+ }
8553
+ if (input.manifest?.sshAddress) {
8554
+ hints.push({ kind: "ssh", target: input.manifest.sshAddress, reachable: null });
8555
+ }
8556
+ if (input.manifest?.hostname) {
8557
+ hints.push({ kind: "lan", target: input.manifest.hostname, reachable: null });
8558
+ }
8559
+ const tailscaleTarget = input.manifest?.tailscaleName ?? input.peer?.DNSName ?? input.peer?.TailscaleIPs?.[0];
8560
+ if (tailscaleTarget) {
8561
+ hints.push({ kind: "tailscale", target: tailscaleTarget.replace(/\.$/, ""), reachable: input.peer?.Online ?? null });
8562
+ }
8563
+ return hints;
8564
+ }
8565
+ function buildEntry(input) {
8566
+ const manifest = input.manifest;
8567
+ const peer = input.peer;
8568
+ const hints = routeHints({
8569
+ machineId: input.machineId,
8570
+ localMachineId: input.localMachineId,
8571
+ manifest,
8572
+ peer
8573
+ });
8574
+ const selectedRoute = hints.find((hint) => hint.kind === "local") ?? hints.find((hint) => hint.kind === "ssh") ?? hints.find((hint) => hint.kind === "lan") ?? hints.find((hint) => hint.kind === "tailscale");
8575
+ const route = selectedRoute?.kind === "ssh" ? "lan" : selectedRoute?.kind ?? "unknown";
8576
+ return {
8577
+ machine_id: input.machineId,
8578
+ hostname: manifest?.hostname ?? peer?.HostName ?? null,
8579
+ platform: manifest?.platform ?? (peer?.OS ? normalizePlatform2(peer.OS) : null),
8580
+ os: peer?.OS ?? null,
8581
+ user: typeof manifest?.metadata?.user === "string" ? manifest.metadata.user : null,
8582
+ workspace_path: manifest?.workspacePath ?? null,
8583
+ manifest_declared: Boolean(manifest),
8584
+ heartbeat_status: input.heartbeat?.status ?? "unknown",
8585
+ last_heartbeat_at: input.heartbeat?.updated_at ?? null,
8586
+ tailscale: {
8587
+ dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
8588
+ ips: peer?.TailscaleIPs ?? [],
8589
+ online: peer?.Online ?? null,
8590
+ active: peer?.Active ?? null,
8591
+ last_seen: peer?.LastSeen ?? null
8592
+ },
8593
+ ssh: {
8594
+ address: manifest?.sshAddress ?? null,
8595
+ route,
8596
+ command_target: selectedRoute?.target ?? null
8597
+ },
8598
+ route_hints: hints,
8599
+ tags: manifest?.tags ?? [],
8600
+ metadata: manifest?.metadata ?? {}
8601
+ };
8602
+ }
8603
+ function discoverMachineTopology(options = {}) {
8604
+ const now = options.now ?? new Date;
8605
+ const runner = options.runner ?? defaultRunner;
8606
+ const warnings = [];
8607
+ const manifest = readManifest();
8608
+ const heartbeats = listHeartbeats();
8609
+ const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
8610
+ const localMachineId = getLocalMachineId();
8611
+ const peers = options.includeTailscale === false ? new Map : loadTailscalePeers(runner, warnings);
8612
+ const machineIds = new Set([
8613
+ localMachineId,
8614
+ ...manifest.machines.map((machine) => machine.id),
8615
+ ...heartbeats.map((heartbeat) => heartbeat.machine_id),
8616
+ ...peers.keys()
8617
+ ]);
8618
+ const manifestById = new Map(manifest.machines.map((machine) => [machine.id, machine]));
8619
+ const machines = [...machineIds].sort().map((machineId) => {
8620
+ const manifestMachine = manifestById.get(machineId);
8621
+ return buildEntry({
8622
+ machineId,
8623
+ localMachineId,
8624
+ manifest: manifestMachine,
8625
+ peer: findTailscalePeer(manifestMachine ?? null, machineId, peers),
8626
+ heartbeat: heartbeatByMachine.get(machineId)
8627
+ });
8628
+ });
8629
+ return {
8630
+ generated_at: now.toISOString(),
8631
+ local_machine_id: localMachineId,
8632
+ local_hostname: hostname4(),
8633
+ current_platform: normalizePlatform2(),
8634
+ manifest_path_known: existsSync7(getManifestPath()),
8635
+ machines,
8636
+ warnings
8637
+ };
8638
+ }
8639
+
8640
+ // src/compatibility.ts
8641
+ init_db();
8642
+ var DEFAULT_COMMANDS = [
8643
+ { command: "bun", required: true },
8644
+ { command: "machines", required: true }
8645
+ ];
8646
+ function defaultPackages() {
8647
+ return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
8648
+ }
8649
+ function shellQuote4(value) {
8650
+ return `'${value.replace(/'/g, "'\\''")}'`;
8651
+ }
8652
+ function commandId(value) {
8653
+ return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
8654
+ }
8655
+ function packageCommand(name) {
8656
+ if (name === "@hasna/knowledge")
8657
+ return "knowledge";
8658
+ if (name === "@hasna/machines")
8659
+ return "machines";
8660
+ return name.split("/").pop() ?? name;
8661
+ }
8662
+ function firstLine(value) {
8663
+ return value.trim().split(/\r?\n/).find(Boolean) ?? "";
8664
+ }
8665
+ function extractVersion(value) {
8666
+ const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
8667
+ return match?.[0] ?? null;
8668
+ }
8669
+ function statusFor(required, ok) {
8670
+ if (ok)
8671
+ return "ok";
8672
+ return required === false ? "warn" : "fail";
8673
+ }
8674
+ function makeCheck(input) {
8675
+ return {
8676
+ id: input.id,
8677
+ kind: input.kind,
8678
+ status: input.status,
8679
+ target: input.target,
8680
+ expected: input.expected ?? null,
8681
+ actual: input.actual ?? null,
8682
+ detail: input.detail,
8683
+ source: input.source
8684
+ };
8685
+ }
8686
+ function parseKeyValue(stdout) {
8687
+ const result = {};
8688
+ for (const line of stdout.split(/\r?\n/)) {
8689
+ const idx = line.indexOf("=");
8690
+ if (idx <= 0)
8691
+ continue;
8692
+ result[line.slice(0, idx)] = line.slice(idx + 1);
8693
+ }
8694
+ return result;
8695
+ }
8696
+ function defaultRunner2(machineId, command) {
8697
+ return runMachineCommand(machineId, command);
8698
+ }
8699
+ function inspectCommand(machineId, spec, runner) {
8700
+ const command = shellQuote4(spec.command);
8701
+ const versionArgs = spec.versionArgs ?? "--version";
8702
+ const script = [
8703
+ `cmd=${command}`,
8704
+ 'path="$(command -v "$cmd" 2>/dev/null || true)"',
8705
+ 'printf "path=%s\\n" "$path"',
8706
+ 'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
8707
+ ].join("; ");
8708
+ const result = runner(machineId, script);
8709
+ const parsed = parseKeyValue(result.stdout);
8710
+ return {
8711
+ path: parsed.path || null,
8712
+ version: parsed.version ? firstLine(parsed.version) : null,
8713
+ exitCode: result.exitCode,
8714
+ source: result.source,
8715
+ stderr: result.stderr
8716
+ };
8717
+ }
8718
+ function fieldCommand(field) {
8719
+ const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
8720
+ return [
8721
+ `if command -v bun >/dev/null 2>&1; then bun -e "const p=JSON.parse(await Bun.file(process.argv[1]).text()); console.log(p.${field} ?? '')" "$pkg" 2>/dev/null`,
8722
+ `elif command -v node >/dev/null 2>&1; then node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(p.${field} || '')" "$pkg" 2>/dev/null`,
8723
+ `else sed -n '${regex}' "$pkg" | head -n 1`,
8724
+ "fi"
8725
+ ].join("; ");
8726
+ }
8727
+ function inspectWorkspace(machineId, spec, runner) {
8728
+ const script = [
8729
+ `path=${shellQuote4(spec.path)}`,
8730
+ 'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
8731
+ 'pkg="$path/package.json"',
8732
+ 'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
8733
+ `if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
8734
+ ].join("; ");
8735
+ const result = runner(machineId, script);
8736
+ const parsed = parseKeyValue(result.stdout);
8737
+ return {
8738
+ exists: parsed.exists === "yes",
8739
+ packageJson: parsed.package_json === "yes",
8740
+ packageName: parsed.package_name || null,
8741
+ version: parsed.version || null,
8742
+ source: result.source,
8743
+ stderr: result.stderr
8744
+ };
8745
+ }
8746
+ function commandCheck(machineId, spec, runner) {
8747
+ const inspection = inspectCommand(machineId, spec, runner);
8748
+ const found = Boolean(inspection.path);
8749
+ const checks = [
8750
+ makeCheck({
8751
+ id: `command:${commandId(spec.command)}:path`,
8752
+ kind: "command",
8753
+ status: statusFor(spec.required, found),
8754
+ target: spec.command,
8755
+ expected: "available",
8756
+ actual: inspection.path ?? "missing",
8757
+ detail: found ? `found at ${inspection.path}` : inspection.stderr || "command missing",
8758
+ source: inspection.source
8759
+ })
8760
+ ];
8761
+ if (spec.expectedVersion) {
8762
+ const actualVersion = extractVersion(inspection.version ?? "");
8763
+ checks.push(makeCheck({
8764
+ id: `command:${commandId(spec.command)}:version`,
8765
+ kind: "command",
8766
+ status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
8767
+ target: spec.command,
8768
+ expected: spec.expectedVersion,
8769
+ actual: actualVersion ?? inspection.version ?? "missing",
8770
+ detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
8771
+ source: inspection.source
8772
+ }));
8773
+ }
8774
+ return checks;
8775
+ }
8776
+ function packageCheck(machineId, spec, runner) {
8777
+ const command = spec.command ?? packageCommand(spec.name);
8778
+ const inspection = inspectCommand(machineId, { command, expectedVersion: spec.expectedVersion, required: spec.required }, runner);
8779
+ const found = Boolean(inspection.path);
8780
+ const checks = [
8781
+ makeCheck({
8782
+ id: `package:${commandId(spec.name)}:command`,
8783
+ kind: "package",
8784
+ status: statusFor(spec.required, found),
8785
+ target: spec.name,
8786
+ expected: command,
8787
+ actual: inspection.path ?? "missing",
8788
+ detail: found ? `${command} found at ${inspection.path}` : `${command} command missing`,
8789
+ source: inspection.source
8790
+ })
8791
+ ];
8792
+ if (spec.expectedVersion) {
8793
+ const actualVersion = extractVersion(inspection.version ?? "");
8794
+ checks.push(makeCheck({
8795
+ id: `package:${commandId(spec.name)}:version`,
8796
+ kind: "package",
8797
+ status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
8798
+ target: spec.name,
8799
+ expected: spec.expectedVersion,
8800
+ actual: actualVersion ?? inspection.version ?? "missing",
8801
+ detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
8802
+ source: inspection.source
8803
+ }));
8804
+ }
8805
+ return checks;
8806
+ }
8807
+ function workspaceCheck(machineId, spec, runner) {
8808
+ const inspection = inspectWorkspace(machineId, spec, runner);
8809
+ const target = spec.label ?? spec.path;
8810
+ const checks = [
8811
+ makeCheck({
8812
+ id: `workspace:${commandId(target)}:path`,
8813
+ kind: "workspace",
8814
+ status: statusFor(spec.required, inspection.exists),
8815
+ target,
8816
+ expected: spec.path,
8817
+ actual: inspection.exists ? "exists" : "missing",
8818
+ detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
8819
+ source: inspection.source
8820
+ })
8821
+ ];
8822
+ if (spec.expectedPackageName) {
8823
+ checks.push(makeCheck({
8824
+ id: `workspace:${commandId(target)}:package-name`,
8825
+ kind: "workspace",
8826
+ status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
8827
+ target,
8828
+ expected: spec.expectedPackageName,
8829
+ actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
8830
+ detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
8831
+ source: inspection.source
8832
+ }));
8833
+ }
8834
+ if (spec.expectedVersion) {
8835
+ checks.push(makeCheck({
8836
+ id: `workspace:${commandId(target)}:version`,
8837
+ kind: "workspace",
8838
+ status: inspection.version === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
8839
+ target,
8840
+ expected: spec.expectedVersion,
8841
+ actual: inspection.version ?? (inspection.packageJson ? "missing-version" : "missing-package-json"),
8842
+ detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
8843
+ source: inspection.source
8844
+ }));
8845
+ }
8846
+ return checks;
8847
+ }
8848
+ function checkMachineCompatibility(options = {}) {
8849
+ const machineId = options.machineId ?? getLocalMachineId();
8850
+ const runner = options.runner ?? defaultRunner2;
8851
+ const commands = options.commands ?? DEFAULT_COMMANDS;
8852
+ const packages = options.packages ?? defaultPackages();
8853
+ const workspaces = options.workspaces ?? [];
8854
+ const checks = [];
8855
+ for (const spec of commands)
8856
+ checks.push(...commandCheck(machineId, spec, runner));
8857
+ for (const spec of packages)
8858
+ checks.push(...packageCheck(machineId, spec, runner));
8859
+ for (const spec of workspaces)
8860
+ checks.push(...workspaceCheck(machineId, spec, runner));
8861
+ const summary = {
8862
+ ok: checks.filter((check) => check.status === "ok").length,
8863
+ warn: checks.filter((check) => check.status === "warn").length,
8864
+ fail: checks.filter((check) => check.status === "fail").length
8865
+ };
8866
+ return {
8867
+ ok: summary.fail === 0,
8868
+ machine_id: machineId,
8869
+ source: checks[0]?.source ?? "local",
8870
+ generated_at: (options.now ?? new Date).toISOString(),
8871
+ checks,
8872
+ summary
8873
+ };
8874
+ }
8875
+
8057
8876
  // src/commands/doctor.ts
8058
- function makeCheck(id, status, summary, detail) {
8877
+ init_db();
8878
+ function makeCheck2(id, status, summary, detail) {
8059
8879
  return { id, status, summary, detail };
8060
8880
  }
8061
8881
  function parseKeyValueOutput(stdout) {
@@ -8087,15 +8907,15 @@ function runDoctor(machineId = getLocalMachineId()) {
8087
8907
  const details = parseKeyValueOutput(commandChecks.stdout);
8088
8908
  const machineInManifest = manifest.machines.find((machine) => machine.id === machineId);
8089
8909
  const checks = [
8090
- 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")
8910
+ makeCheck2("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", machineInManifest ? JSON.stringify(machineInManifest) : `No manifest entry for ${machineId}`),
8911
+ makeCheck2("manifest-path", details["manifest_exists"] === "yes" ? "ok" : "warn", "Manifest path check", `${details["manifest_path"] || "unknown"} ${details["manifest_exists"] === "yes" ? "exists" : "missing"}`),
8912
+ makeCheck2("db-path", details["db_exists"] === "yes" ? "ok" : "warn", "DB path check", `${details["db_path"] || "unknown"} ${details["db_exists"] === "yes" ? "exists" : "missing"}`),
8913
+ makeCheck2("notifications-path", details["notifications_exists"] === "yes" ? "ok" : "warn", "Notifications path check", `${details["notifications_path"] || "unknown"} ${details["notifications_exists"] === "yes" ? "exists" : "missing"}`),
8914
+ makeCheck2("bun", details["bun"] && details["bun"] !== "missing" ? "ok" : "fail", "Bun availability", details["bun"] || "missing"),
8915
+ makeCheck2("machines-cli", details["machines"] && details["machines"] !== "missing" ? "ok" : "warn", "machines CLI availability", details["machines"] || "missing"),
8916
+ makeCheck2("machines-agent-cli", details["machines_agent"] && details["machines_agent"] !== "missing" ? "ok" : "warn", "machines-agent availability", details["machines_agent"] || "missing"),
8917
+ makeCheck2("machines-mcp-cli", details["machines_mcp"] && details["machines_mcp"] !== "missing" ? "ok" : "warn", "machines-mcp availability", details["machines_mcp"] || "missing"),
8918
+ makeCheck2("ssh", details["ssh"] === "ok" ? "ok" : "warn", "SSH availability", details["ssh"] || "missing")
8099
8919
  ];
8100
8920
  return {
8101
8921
  machineId,
@@ -8107,6 +8927,9 @@ function runDoctor(machineId = getLocalMachineId()) {
8107
8927
  };
8108
8928
  }
8109
8929
 
8930
+ // src/commands/self-test.ts
8931
+ init_db();
8932
+
8110
8933
  // src/commands/serve.ts
8111
8934
  function escapeHtml(value) {
8112
8935
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -8393,8 +9216,9 @@ function runSelfTest() {
8393
9216
  }
8394
9217
 
8395
9218
  // src/commands/clipboard.ts
9219
+ init_paths();
8396
9220
  import { createHash } from "crypto";
8397
- import { existsSync as existsSync7, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
9221
+ import { existsSync as existsSync8, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
8398
9222
  import { join as join6 } from "path";
8399
9223
  var DEFAULT_CONFIG = {
8400
9224
  version: 1,
@@ -8425,7 +9249,7 @@ function getDefaultConfig() {
8425
9249
  }
8426
9250
  function readConfig(configPath) {
8427
9251
  const path = resolveConfigPath(configPath);
8428
- if (!existsSync7(path)) {
9252
+ if (!existsSync8(path)) {
8429
9253
  return getDefaultConfig();
8430
9254
  }
8431
9255
  const parsed = JSON.parse(readFileSync6(path, "utf8"));
@@ -8439,7 +9263,7 @@ function writeConfig(config, configPath) {
8439
9263
  }
8440
9264
  function readHistory(historyPath) {
8441
9265
  const path = resolveHistoryPath(historyPath);
8442
- if (!existsSync7(path)) {
9266
+ if (!existsSync8(path)) {
8443
9267
  return [];
8444
9268
  }
8445
9269
  try {
@@ -8472,7 +9296,7 @@ function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
8472
9296
  }
8473
9297
  function getOrCreateClipboardKey() {
8474
9298
  const keyPath = getClipboardKeyPath();
8475
- if (existsSync7(keyPath)) {
9299
+ if (existsSync8(keyPath)) {
8476
9300
  return readFileSync6(keyPath, "utf8").trim();
8477
9301
  }
8478
9302
  const key = createHash("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
@@ -8512,7 +9336,7 @@ function addClipboardEntry(entry, historyPath) {
8512
9336
  }
8513
9337
  function clearClipboardHistory(historyPath) {
8514
9338
  const path = resolveHistoryPath(historyPath);
8515
- if (existsSync7(path)) {
9339
+ if (existsSync8(path)) {
8516
9340
  rmSync(path);
8517
9341
  }
8518
9342
  }
@@ -8527,26 +9351,28 @@ function getClipboardStatus(historyPath) {
8527
9351
  }
8528
9352
 
8529
9353
  // src/commands/clipboard-daemon.ts
9354
+ init_paths();
8530
9355
  import { readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
8531
9356
  import { join as join7 } from "path";
8532
9357
  import { createHash as createHash3 } from "crypto";
8533
9358
 
8534
9359
  // src/commands/clipboard-server.ts
9360
+ init_paths();
8535
9361
  import { createServer } from "http";
8536
9362
  import { createHash as createHash2 } from "crypto";
8537
9363
  import { readFileSync as readFileSync7 } from "fs";
8538
9364
  function readLocalClipboardSync() {
8539
- const platform3 = process.platform;
8540
- if (platform3 === "darwin") {
9365
+ const platform4 = process.platform;
9366
+ if (platform4 === "darwin") {
8541
9367
  const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
8542
9368
  return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
8543
9369
  }
8544
- if (platform3 === "linux") {
8545
- if (hasCommand2("wl-paste")) {
9370
+ if (platform4 === "linux") {
9371
+ if (hasCommand3("wl-paste")) {
8546
9372
  const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
8547
9373
  return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
8548
9374
  }
8549
- if (hasCommand2("xclip")) {
9375
+ if (hasCommand3("xclip")) {
8550
9376
  const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
8551
9377
  return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
8552
9378
  }
@@ -8555,17 +9381,17 @@ function readLocalClipboardSync() {
8555
9381
  return "";
8556
9382
  }
8557
9383
  function writeLocalClipboardSync(content) {
8558
- const platform3 = process.platform;
8559
- if (platform3 === "darwin") {
9384
+ const platform4 = process.platform;
9385
+ if (platform4 === "darwin") {
8560
9386
  const result = Bun.spawnSync(["pbcopy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
8561
9387
  return result.exitCode === 0;
8562
9388
  }
8563
- if (platform3 === "linux") {
8564
- if (hasCommand2("wl-copy")) {
9389
+ if (platform4 === "linux") {
9390
+ if (hasCommand3("wl-copy")) {
8565
9391
  const result = Bun.spawnSync(["wl-copy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
8566
9392
  return result.exitCode === 0;
8567
9393
  }
8568
- if (hasCommand2("xclip")) {
9394
+ if (hasCommand3("xclip")) {
8569
9395
  const result = Bun.spawnSync(["xclip", "-selection", "clipboard"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
8570
9396
  return result.exitCode === 0;
8571
9397
  }
@@ -8573,7 +9399,7 @@ function writeLocalClipboardSync(content) {
8573
9399
  }
8574
9400
  return false;
8575
9401
  }
8576
- function hasCommand2(binary) {
9402
+ function hasCommand3(binary) {
8577
9403
  const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
8578
9404
  return result.exitCode === 0;
8579
9405
  }
@@ -8687,17 +9513,17 @@ function handleGetClipboard(response, config) {
8687
9513
  // src/commands/clipboard-daemon.ts
8688
9514
  var DAEMON_PID_PATH = join7(getDataDir(), "clipboard-daemon.pid");
8689
9515
  function readLocalClipboardSync2() {
8690
- const platform3 = process.platform;
8691
- if (platform3 === "darwin") {
9516
+ const platform4 = process.platform;
9517
+ if (platform4 === "darwin") {
8692
9518
  const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
8693
9519
  return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
8694
9520
  }
8695
- if (platform3 === "linux") {
8696
- if (hasCommand3("wl-paste")) {
9521
+ if (platform4 === "linux") {
9522
+ if (hasCommand4("wl-paste")) {
8697
9523
  const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
8698
9524
  return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
8699
9525
  }
8700
- if (hasCommand3("xclip")) {
9526
+ if (hasCommand4("xclip")) {
8701
9527
  const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
8702
9528
  return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
8703
9529
  }
@@ -8705,7 +9531,7 @@ function readLocalClipboardSync2() {
8705
9531
  }
8706
9532
  return "";
8707
9533
  }
8708
- function hasCommand3(binary) {
9534
+ function hasCommand4(binary) {
8709
9535
  const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
8710
9536
  return result.exitCode === 0;
8711
9537
  }
@@ -8857,6 +9683,500 @@ async function discoverPeers() {
8857
9683
  return peers;
8858
9684
  }
8859
9685
 
9686
+ // src/commands/heal.ts
9687
+ init_paths();
9688
+ import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
9689
+ import { join as join8 } from "path";
9690
+ var DEFAULT_THRESHOLDS = {
9691
+ reconnect: 3,
9692
+ nmRestart: 7,
9693
+ fallback: 12,
9694
+ reboot: 15
9695
+ };
9696
+ var DEFAULT_HEAL_CONFIG = {
9697
+ version: 1,
9698
+ enabled: true,
9699
+ wifiInterface: "",
9700
+ preferredSsid: "",
9701
+ fallbackSsid: "",
9702
+ internetUrl: "https://1.1.1.1",
9703
+ tailscaleAnchors: [],
9704
+ quorumRequired: 2,
9705
+ intervalSec: 60,
9706
+ thresholds: { ...DEFAULT_THRESHOLDS },
9707
+ rebootMinIntervalSec: 1800,
9708
+ nmRestartMinIntervalSec: 1800,
9709
+ reconnectMinIntervalSec: 120,
9710
+ healthyWindowSec: 300,
9711
+ maxFailedBootRecoveries: 2,
9712
+ bootBackoffSec: 21600,
9713
+ fallbackWindowSec: 600,
9714
+ gpuJobGuard: true,
9715
+ allowReboot: true
9716
+ };
9717
+ function defaultHealState() {
9718
+ return {
9719
+ failCount: 0,
9720
+ bootId: "",
9721
+ bootHealthySince: null,
9722
+ lastRebootAttempt: 0,
9723
+ lastNmRestart: 0,
9724
+ lastReconnect: 0,
9725
+ lastFallback: 0,
9726
+ degradedUntil: 0,
9727
+ pendingRebootRecovery: false,
9728
+ failedBootRecoveries: 0,
9729
+ rebootSuppressUntil: 0
9730
+ };
9731
+ }
9732
+ function getHealConfigPath() {
9733
+ return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] || join8(getDataDir(), "heal-config.json");
9734
+ }
9735
+ function getHealStatePath() {
9736
+ return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] || join8(getDataDir(), "heal-state.json");
9737
+ }
9738
+ function readHealConfig(path) {
9739
+ const p = path || getHealConfigPath();
9740
+ if (!existsSync9(p))
9741
+ return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
9742
+ const parsed = JSON.parse(readFileSync9(p, "utf8"));
9743
+ return {
9744
+ ...DEFAULT_HEAL_CONFIG,
9745
+ ...parsed,
9746
+ thresholds: { ...DEFAULT_THRESHOLDS, ...parsed.thresholds || {} },
9747
+ tailscaleAnchors: parsed.tailscaleAnchors ?? []
9748
+ };
9749
+ }
9750
+ function writeHealConfig(config, path) {
9751
+ const p = path || getHealConfigPath();
9752
+ ensureParentDir(p);
9753
+ writeFileSync6(p, `${JSON.stringify(config, null, 2)}
9754
+ `, "utf8");
9755
+ }
9756
+ function readHealState(path) {
9757
+ const p = path || getHealStatePath();
9758
+ if (!existsSync9(p))
9759
+ return defaultHealState();
9760
+ try {
9761
+ return { ...defaultHealState(), ...JSON.parse(readFileSync9(p, "utf8")) };
9762
+ } catch {
9763
+ return defaultHealState();
9764
+ }
9765
+ }
9766
+ function writeHealState(state, path) {
9767
+ const p = path || getHealStatePath();
9768
+ ensureParentDir(p);
9769
+ writeFileSync6(p, `${JSON.stringify(state, null, 2)}
9770
+ `, "utf8");
9771
+ }
9772
+ function evaluateHealth(probe, config, state) {
9773
+ const reasons = [];
9774
+ const inDegraded = state.degradedUntil > 0;
9775
+ const acceptableSsid = probe.associatedSsid === config.preferredSsid || config.fallbackSsid !== "" && inDegraded && probe.associatedSsid === config.fallbackSsid;
9776
+ if (!acceptableSsid)
9777
+ reasons.push(`wrong-ssid:${probe.associatedSsid ?? "none"}`);
9778
+ if (!probe.gatewayReachable)
9779
+ reasons.push("gateway-unreachable");
9780
+ let remoteScore = 0;
9781
+ for (const [anchor, ok] of Object.entries(probe.anchorsReachable)) {
9782
+ if (ok)
9783
+ remoteScore += 1;
9784
+ else
9785
+ reasons.push(`anchor-down:${anchor}`);
9786
+ }
9787
+ if (probe.internetReachable)
9788
+ remoteScore += 1;
9789
+ else
9790
+ reasons.push("internet-down");
9791
+ const localOk = acceptableSsid && probe.gatewayReachable;
9792
+ const quorumOk = remoteScore >= config.quorumRequired;
9793
+ if (!quorumOk)
9794
+ reasons.push(`quorum:${remoteScore}/${config.quorumRequired}`);
9795
+ return { healthy: localOk && quorumOk, remoteScore, reasons };
9796
+ }
9797
+ function decideAction(input) {
9798
+ const { healthy, now, gpuBusy, config, currentBootId } = input;
9799
+ const s = { ...input.state };
9800
+ const t = config.thresholds;
9801
+ if (s.bootId !== currentBootId) {
9802
+ s.bootId = currentBootId;
9803
+ s.bootHealthySince = null;
9804
+ s.failCount = 0;
9805
+ }
9806
+ if (healthy) {
9807
+ s.failCount = 0;
9808
+ if (s.bootHealthySince === null)
9809
+ s.bootHealthySince = now;
9810
+ if (now - s.bootHealthySince >= config.healthyWindowSec) {
9811
+ s.failedBootRecoveries = 0;
9812
+ s.rebootSuppressUntil = 0;
9813
+ s.pendingRebootRecovery = false;
9814
+ }
9815
+ if (s.degradedUntil > 0 && now >= s.degradedUntil) {
9816
+ s.degradedUntil = 0;
9817
+ return { action: "restore_preferred", state: s };
9818
+ }
9819
+ return { action: "none", state: s };
9820
+ }
9821
+ s.failCount += 1;
9822
+ s.bootHealthySince = null;
9823
+ let tier = "none";
9824
+ if (s.failCount >= t.reboot)
9825
+ tier = "reboot";
9826
+ else if (s.failCount >= t.fallback && config.fallbackSsid !== "")
9827
+ tier = "fallback";
9828
+ else if (s.failCount >= t.nmRestart)
9829
+ tier = "nmRestart";
9830
+ else if (s.failCount >= t.reconnect)
9831
+ tier = "reconnect";
9832
+ const tryReconnect = (reason) => {
9833
+ if (now - s.lastReconnect >= config.reconnectMinIntervalSec) {
9834
+ s.lastReconnect = now;
9835
+ return { action: "reconnect_wifi", suppressedReason: reason, state: s };
9836
+ }
9837
+ return { action: "none", suppressedReason: reason, state: s };
9838
+ };
9839
+ switch (tier) {
9840
+ case "reconnect":
9841
+ return tryReconnect();
9842
+ case "nmRestart":
9843
+ if (now - s.lastNmRestart >= config.nmRestartMinIntervalSec) {
9844
+ s.lastNmRestart = now;
9845
+ return { action: "restart_nm", state: s };
9846
+ }
9847
+ return tryReconnect();
9848
+ case "fallback":
9849
+ if (now - s.lastFallback >= config.fallbackWindowSec) {
9850
+ s.lastFallback = now;
9851
+ s.degradedUntil = now + config.fallbackWindowSec;
9852
+ return { action: "fallback_ssid", state: s };
9853
+ }
9854
+ return tryReconnect();
9855
+ case "reboot": {
9856
+ let reason = null;
9857
+ if (!config.allowReboot)
9858
+ reason = "disabled";
9859
+ else if (now < s.rebootSuppressUntil)
9860
+ reason = "loop";
9861
+ else if (config.gpuJobGuard && gpuBusy)
9862
+ reason = "gpu";
9863
+ else if (now - s.lastRebootAttempt < config.rebootMinIntervalSec)
9864
+ reason = "rate";
9865
+ if (reason)
9866
+ return tryReconnect(reason);
9867
+ if (s.pendingRebootRecovery) {
9868
+ s.failedBootRecoveries += 1;
9869
+ if (s.failedBootRecoveries >= config.maxFailedBootRecoveries) {
9870
+ s.rebootSuppressUntil = now + config.bootBackoffSec;
9871
+ return tryReconnect("loop");
9872
+ }
9873
+ }
9874
+ s.lastRebootAttempt = now;
9875
+ s.pendingRebootRecovery = true;
9876
+ return { action: "reboot", state: s };
9877
+ }
9878
+ default:
9879
+ return { action: "none", state: s };
9880
+ }
9881
+ }
9882
+ function sh(cmd, timeoutMs = 8000) {
9883
+ const r = Bun.spawnSync(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
9884
+ return { ok: r.exitCode === 0, out: r.stdout.toString("utf8").trim() };
9885
+ }
9886
+ function getCurrentBootId() {
9887
+ try {
9888
+ return readFileSync9("/proc/sys/kernel/random/boot_id", "utf8").trim();
9889
+ } catch {
9890
+ return "";
9891
+ }
9892
+ }
9893
+ function detectWifiInterface() {
9894
+ const r = sh(`nmcli -t -f DEVICE,TYPE device status 2>/dev/null | awk -F: '$2=="wifi"{print $1; exit}'`);
9895
+ return r.ok ? r.out : "";
9896
+ }
9897
+ function detectGateway() {
9898
+ const r = sh(`ip route 2>/dev/null | awk '/^default/{print $3; exit}'`);
9899
+ return r.ok ? r.out : "";
9900
+ }
9901
+ function getAssociatedSsid() {
9902
+ const r = sh(`iwgetid -r 2>/dev/null || nmcli -t -f active,ssid dev wifi 2>/dev/null | awk -F: '/^yes/{print $2; exit}'`);
9903
+ return r.ok && r.out ? r.out : null;
9904
+ }
9905
+ function pingHost(host) {
9906
+ if (!host)
9907
+ return false;
9908
+ return sh(`ping -c1 -W2 ${host} >/dev/null 2>&1`, 5000).ok;
9909
+ }
9910
+ function internetReachable(url) {
9911
+ return sh(`curl -sf -m5 -o /dev/null ${url}`, 8000).ok;
9912
+ }
9913
+ function tailscalePing(host) {
9914
+ return sh(`timeout 8 tailscale ping --until-direct=false ${host} 2>/dev/null | grep -q pong`, 1e4).ok;
9915
+ }
9916
+ function gpuBusy() {
9917
+ return sh(`command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid --format=csv,noheader 2>/dev/null | grep -q .`, 6000).ok;
9918
+ }
9919
+ function discoverAnchors() {
9920
+ const r = sh(`tailscale status --json 2>/dev/null`);
9921
+ if (!r.ok)
9922
+ return [];
9923
+ try {
9924
+ const status = JSON.parse(r.out);
9925
+ const anchors = [];
9926
+ for (const peer of Object.values(status.Peer || {})) {
9927
+ const name = peer.HostName || (peer.DNSName || "").split(".")[0];
9928
+ if (name)
9929
+ anchors.push(name);
9930
+ }
9931
+ return anchors;
9932
+ } catch {
9933
+ return [];
9934
+ }
9935
+ }
9936
+ function probeHealth(config) {
9937
+ const gw = config.wifiInterface ? detectGateway() : detectGateway();
9938
+ const anchors = config.tailscaleAnchors.length > 0 ? config.tailscaleAnchors : discoverAnchors().slice(0, 3);
9939
+ const anchorsReachable = {};
9940
+ for (const a of anchors)
9941
+ anchorsReachable[a] = tailscalePing(a);
9942
+ return {
9943
+ associatedSsid: getAssociatedSsid(),
9944
+ gatewayReachable: pingHost(gw),
9945
+ anchorsReachable,
9946
+ internetReachable: internetReachable(config.internetUrl)
9947
+ };
9948
+ }
9949
+ function executeAction(action, config) {
9950
+ const iface = config.wifiInterface || detectWifiInterface();
9951
+ switch (action) {
9952
+ case "reconnect_wifi":
9953
+ sh(`nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
9954
+ return `reconnected wifi to ${config.preferredSsid}`;
9955
+ case "restart_nm":
9956
+ sh(`systemctl restart NetworkManager 2>&1; sleep 5; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 40000);
9957
+ return "restarted NetworkManager";
9958
+ case "fallback_ssid":
9959
+ sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect yes 2>&1; nmcli connection up "${config.fallbackSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
9960
+ return `switched to degraded fallback ${config.fallbackSsid}`;
9961
+ case "restore_preferred":
9962
+ sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect no 2>&1; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
9963
+ return `restored preferred ${config.preferredSsid}`;
9964
+ case "reboot":
9965
+ sh(`systemctl reboot 2>&1 || reboot 2>&1`, 1e4);
9966
+ return "reboot issued";
9967
+ default:
9968
+ return "no action";
9969
+ }
9970
+ }
9971
+
9972
+ // src/commands/heal-daemon.ts
9973
+ init_paths();
9974
+ import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
9975
+ import { join as join9 } from "path";
9976
+ var DAEMON_PID_PATH2 = join9(getDataDir(), "heal-daemon.pid");
9977
+ var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
9978
+ var SYSTEM_CONF = "/etc/systemd/system.conf";
9979
+ function log(msg) {
9980
+ console.log(`${new Date().toISOString()} [machines-heal] ${msg}`);
9981
+ }
9982
+ function runHealOnce(config, opts = {}) {
9983
+ const state = readHealState();
9984
+ const probe = probeHealth(config);
9985
+ const health = evaluateHealth(probe, config, state);
9986
+ const busy = config.gpuJobGuard ? gpuBusy() : false;
9987
+ const decision = decideAction({
9988
+ state,
9989
+ healthy: health.healthy,
9990
+ now: Math.floor(Date.now() / 1000),
9991
+ gpuBusy: busy,
9992
+ config,
9993
+ currentBootId: getCurrentBootId()
9994
+ });
9995
+ let executed = "skipped (dry-run)";
9996
+ if (!opts.dryRun) {
9997
+ writeHealState(decision.state);
9998
+ if (decision.action !== "none")
9999
+ executed = executeAction(decision.action, config);
10000
+ else
10001
+ executed = "no action";
10002
+ }
10003
+ const result = {
10004
+ healthy: health.healthy,
10005
+ action: decision.action,
10006
+ suppressedReason: decision.suppressedReason,
10007
+ reasons: health.reasons,
10008
+ remoteScore: health.remoteScore,
10009
+ failCount: decision.state.failCount,
10010
+ executed
10011
+ };
10012
+ const sup = decision.suppressedReason ? ` suppressed=${decision.suppressedReason}` : "";
10013
+ log(health.healthy ? `healthy (quorum ${health.remoteScore}) action=${decision.action} ${executed}` : `UNHEALTHY [${health.reasons.join(",")}] fails=${decision.state.failCount} action=${decision.action}${sup} -> ${executed}`);
10014
+ return result;
10015
+ }
10016
+ function writePid2(pid) {
10017
+ writeFileSync7(DAEMON_PID_PATH2, `${pid}
10018
+ `);
10019
+ }
10020
+ function readPid2() {
10021
+ try {
10022
+ const pid = Number.parseInt(readFileSync10(DAEMON_PID_PATH2, "utf8").trim());
10023
+ return Number.isFinite(pid) ? pid : null;
10024
+ } catch {
10025
+ return null;
10026
+ }
10027
+ }
10028
+ function isProcessRunning2(pid) {
10029
+ try {
10030
+ process.kill(pid, 0);
10031
+ return true;
10032
+ } catch {
10033
+ return false;
10034
+ }
10035
+ }
10036
+ function stopHealDaemon() {
10037
+ const pid = readPid2();
10038
+ if (pid && isProcessRunning2(pid)) {
10039
+ process.kill(pid, "SIGTERM");
10040
+ return { stopped: true, pid };
10041
+ }
10042
+ return { stopped: false, pid };
10043
+ }
10044
+ function startHealDaemon() {
10045
+ const config = readHealConfig();
10046
+ if (!config.preferredSsid) {
10047
+ log("refusing to start: preferredSsid is not configured (run `machines heal config --set ...`)");
10048
+ process.exit(1);
10049
+ }
10050
+ writePid2(process.pid);
10051
+ log(`daemon started (pid ${process.pid}) interval=${config.intervalSec}s preferred=${config.preferredSsid}`);
10052
+ const tick = () => {
10053
+ try {
10054
+ runHealOnce(config);
10055
+ } catch (err) {
10056
+ log(`tick error: ${err.message}`);
10057
+ }
10058
+ };
10059
+ tick();
10060
+ setInterval(tick, Math.max(10, config.intervalSec) * 1000);
10061
+ }
10062
+ function sh2(cmd, timeoutMs = 15000) {
10063
+ const r = Bun.spawnSync(["bash", "-c", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
10064
+ return { ok: r.exitCode === 0, out: `${r.stdout.toString("utf8")}${r.stderr.toString("utf8")}`.trim() };
10065
+ }
10066
+ function applyDeterminism(config) {
10067
+ const iface = config.wifiInterface || detectWifiInterface();
10068
+ const log2 = [];
10069
+ if (!config.preferredSsid)
10070
+ return ["no preferredSsid configured; skipping determinism"];
10071
+ sh2(`nmcli connection modify "${config.preferredSsid}" connection.autoconnect yes connection.autoconnect-priority 10 802-11-wireless.powersave 2`);
10072
+ log2.push(`pinned ${config.preferredSsid} (autoconnect, priority 10, powersave off)`);
10073
+ const profiles = sh2(`nmcli -t -f NAME,TYPE connection show 2>/dev/null | awk -F: '$2 ~ /wireless/{print $1}'`).out.split(`
10074
+ `).filter(Boolean);
10075
+ for (const p of profiles) {
10076
+ if (p === config.preferredSsid)
10077
+ continue;
10078
+ if (p === config.fallbackSsid) {
10079
+ sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
10080
+ log2.push(`disabled autoconnect on fallback ${p}`);
10081
+ continue;
10082
+ }
10083
+ sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
10084
+ log2.push(`disabled autoconnect on ${p}`);
10085
+ }
10086
+ if (iface) {
10087
+ sh2(`iw dev ${iface} set power_save off 2>/dev/null || true`);
10088
+ log2.push(`power_save off on ${iface}`);
10089
+ }
10090
+ return log2;
10091
+ }
10092
+ function enableHardwareWatchdog() {
10093
+ const log2 = [];
10094
+ if (!existsSync10(SYSTEM_CONF))
10095
+ return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
10096
+ let conf = readFileSync10(SYSTEM_CONF, "utf8");
10097
+ const set = (key, value) => {
10098
+ const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
10099
+ if (re.test(conf))
10100
+ conf = conf.replace(re, `${key}=${value}`);
10101
+ else
10102
+ conf += `
10103
+ ${key}=${value}
10104
+ `;
10105
+ };
10106
+ set("RuntimeWatchdogSec", "20s");
10107
+ set("RebootWatchdogSec", "2min");
10108
+ writeFileSync7(SYSTEM_CONF, conf);
10109
+ sh2("systemctl daemon-reexec");
10110
+ log2.push("hardware watchdog: RuntimeWatchdogSec=20s RebootWatchdogSec=2min");
10111
+ return log2;
10112
+ }
10113
+ function binPath() {
10114
+ const candidates = [];
10115
+ const which = sh2("command -v machines").out.split(`
10116
+ `)[0]?.trim();
10117
+ if (which)
10118
+ candidates.push(which);
10119
+ if (process.argv[1])
10120
+ candidates.push(process.argv[1]);
10121
+ const home = process.env["HOME"] || "/home/hasna";
10122
+ candidates.push(`${home}/.bun/bin/machines`, "/home/hasna/.bun/bin/machines", "/root/.bun/bin/machines", "/usr/local/bin/machines");
10123
+ for (const c of candidates) {
10124
+ if (c && existsSync10(c))
10125
+ return c;
10126
+ }
10127
+ return "machines";
10128
+ }
10129
+ var ROOT_DATA_DIR = "/etc/machines-heal";
10130
+ function installHealService() {
10131
+ const log2 = [];
10132
+ const exec = binPath();
10133
+ const binDir = exec.includes("/") ? exec.slice(0, exec.lastIndexOf("/")) : "/usr/local/bin";
10134
+ const unit = `[Unit]
10135
+ Description=Hasna machines self-healing network watchdog
10136
+ After=network.target NetworkManager.service tailscaled.service
10137
+ Wants=network.target
10138
+
10139
+ [Service]
10140
+ Type=simple
10141
+ ExecStart=${exec} heal daemon
10142
+ Restart=always
10143
+ RestartSec=10
10144
+ Environment=HOME=/root
10145
+ Environment=HASNA_MACHINES_DIR=${ROOT_DATA_DIR}
10146
+ Environment=PATH=${binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
10147
+
10148
+ [Install]
10149
+ WantedBy=multi-user.target
10150
+ `;
10151
+ writeFileSync7(SERVICE_PATH, unit);
10152
+ sh2("systemctl daemon-reload");
10153
+ sh2("systemctl enable --now machines-heal.service");
10154
+ log2.push(`installed + enabled ${SERVICE_PATH} (ExecStart=${exec} heal daemon)`);
10155
+ return log2;
10156
+ }
10157
+ function uninstallHealService() {
10158
+ const log2 = [];
10159
+ sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
10160
+ if (existsSync10(SERVICE_PATH)) {
10161
+ sh2(`rm -f ${SERVICE_PATH}`);
10162
+ sh2("systemctl daemon-reload");
10163
+ log2.push(`removed ${SERVICE_PATH}`);
10164
+ } else {
10165
+ log2.push("service not installed");
10166
+ }
10167
+ return log2;
10168
+ }
10169
+ function healServiceStatus() {
10170
+ return {
10171
+ installed: existsSync10(SERVICE_PATH),
10172
+ active: sh2("systemctl is-active machines-heal.service").out === "active",
10173
+ enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
10174
+ };
10175
+ }
10176
+
10177
+ // src/cli/index.ts
10178
+ init_paths();
10179
+
8860
10180
  // src/cli-utils.ts
8861
10181
  function parseIntegerOption(value, label, constraints = {}) {
8862
10182
  const parsed = Number.parseInt(value, 10);
@@ -8888,7 +10208,7 @@ ${items.map((item) => `- ${item}`).join(`
8888
10208
 
8889
10209
  // src/cli/index.ts
8890
10210
  import { rmSync as rmSync2 } from "fs";
8891
- import { readFileSync as readFileSync9 } from "fs";
10211
+ import { readFileSync as readFileSync11 } from "fs";
8892
10212
  var program2 = new Command;
8893
10213
  function printJsonOrText(data, text, json = false) {
8894
10214
  if (json || program2.opts().quiet) {
@@ -8897,6 +10217,22 @@ function printJsonOrText(data, text, json = false) {
8897
10217
  }
8898
10218
  console.log(text);
8899
10219
  }
10220
+ function printStorageResults(results, json) {
10221
+ if (json) {
10222
+ console.log(JSON.stringify(results, null, 2));
10223
+ return;
10224
+ }
10225
+ for (const result of results) {
10226
+ const marker = result.errors.length > 0 ? source_default.red("!") : source_default.green("\u2713");
10227
+ const suffix = result.errors.length > 0 ? ` ${source_default.red(result.errors.join("; "))}` : "";
10228
+ console.log(`${marker} ${result.table}: read ${result.rowsRead}, wrote ${result.rowsWritten}${suffix}`);
10229
+ }
10230
+ }
10231
+ function printStorageError(error) {
10232
+ const message = error instanceof Error ? error.message : String(error);
10233
+ console.error(source_default.red(message));
10234
+ process.exit(1);
10235
+ }
8900
10236
  function renderAppsListResult(result) {
8901
10237
  return [
8902
10238
  `machine: ${result.machineId}`,
@@ -8979,6 +10315,49 @@ function renderSelfTestResult(result) {
8979
10315
  ].join(`
8980
10316
  `);
8981
10317
  }
10318
+ function parseCommandSpec(value) {
10319
+ const [command, expectedVersion] = value.split(":");
10320
+ return {
10321
+ command,
10322
+ expectedVersion: expectedVersion || undefined,
10323
+ required: true
10324
+ };
10325
+ }
10326
+ function parsePackageSpec(value) {
10327
+ const [name, command, expectedVersion] = value.split(":");
10328
+ return {
10329
+ name,
10330
+ command: command || undefined,
10331
+ expectedVersion: expectedVersion || undefined,
10332
+ required: true
10333
+ };
10334
+ }
10335
+ function parseWorkspaceSpec(value) {
10336
+ const [label, path] = value.includes("=") ? value.split(/=(.*)/s).filter(Boolean) : ["workspace", value];
10337
+ return {
10338
+ label,
10339
+ path,
10340
+ required: true
10341
+ };
10342
+ }
10343
+ function renderCompatibilityCheck(check2) {
10344
+ const marker = check2.status === "ok" ? source_default.green("\u2713") : check2.status === "warn" ? source_default.yellow("!") : source_default.red("\u2717");
10345
+ const expected = check2.expected ? ` expected=${check2.expected}` : "";
10346
+ return `${marker} ${check2.id} ${check2.actual ?? "unknown"}${expected}`;
10347
+ }
10348
+ function renderCompatibilityResult(result) {
10349
+ return [
10350
+ renderKeyValueTable([
10351
+ ["machine", result.machine_id],
10352
+ ["source", result.source],
10353
+ ["ok", String(result.ok)],
10354
+ ["checks", `${result.summary.ok} ok, ${result.summary.warn} warn, ${result.summary.fail} fail`]
10355
+ ]),
10356
+ "",
10357
+ ...result.checks.map(renderCompatibilityCheck)
10358
+ ].join(`
10359
+ `);
10360
+ }
8982
10361
  function renderFleetStatus(status) {
8983
10362
  return [
8984
10363
  renderKeyValueTable([
@@ -9035,7 +10414,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
9035
10414
  console.error("error: --from-stdin requires piped input");
9036
10415
  process.exit(1);
9037
10416
  }
9038
- const input = readFileSync9(0, "utf8");
10417
+ const input = readFileSync11(0, "utf8");
9039
10418
  const machine2 = JSON.parse(input);
9040
10419
  console.log(JSON.stringify(manifestAdd(machine2), null, 2));
9041
10420
  return;
@@ -9100,6 +10479,35 @@ program2.command("sync").description("Reconcile a machine against the fleet mani
9100
10479
  const result = options.apply ? runSync(options.machine, { apply: true, yes: options.yes }) : buildSyncPlan(options.machine);
9101
10480
  console.log(JSON.stringify(result, null, 2));
9102
10481
  });
10482
+ program2.command("topology").description("Discover local, manifest, heartbeat, SSH, and Tailscale machine topology").option("--no-tailscale", "Skip tailscale status probing").option("-j, --json", "Print JSON output", false).action((options) => {
10483
+ const topology = discoverMachineTopology({ includeTailscale: options.tailscale !== false });
10484
+ if (options.json) {
10485
+ console.log(JSON.stringify(topology, null, 2));
10486
+ return;
10487
+ }
10488
+ console.log(renderKeyValueTable([
10489
+ ["local machine", topology.local_machine_id],
10490
+ ["hostname", topology.local_hostname],
10491
+ ["platform", String(topology.current_platform)],
10492
+ ["machines", String(topology.machines.length)],
10493
+ ["warnings", topology.warnings.join(", ") || "none"]
10494
+ ]));
10495
+ for (const machine of topology.machines) {
10496
+ const route = machine.ssh.command_target ? `${machine.ssh.route}:${machine.ssh.command_target}` : machine.ssh.route;
10497
+ console.log(`${machine.machine_id.padEnd(18)} ${String(machine.platform || "unknown").padEnd(8)} ${machine.heartbeat_status.padEnd(8)} ${route}`);
10498
+ }
10499
+ });
10500
+ program2.command("compatibility").description("Check remote package, command, and workspace compatibility for open-* consumers").option("--machine <id>", "Machine identifier").option("--command <command...>", "Required command or command:expectedVersion").option("--package <spec...>", "Required package as name[:command[:expectedVersion]]").option("--workspace <spec...>", "Required workspace as label=/path or /path").option("-j, --json", "Print JSON output", false).action((options) => {
10501
+ const result = checkMachineCompatibility({
10502
+ machineId: options.machine,
10503
+ commands: options.command?.map(parseCommandSpec),
10504
+ packages: options.package?.map(parsePackageSpec),
10505
+ workspaces: options.workspace?.map(parseWorkspaceSpec)
10506
+ });
10507
+ printJsonOrText(result, renderCompatibilityResult(result), options.json);
10508
+ if (!result.ok && !options.json)
10509
+ process.exitCode = 1;
10510
+ });
9103
10511
  program2.command("diff").description("Show manifest differences between two machines").requiredOption("--left <id>", "Left machine identifier").option("--right <id>", "Right machine identifier (defaults to current machine)").option("-j, --json", "Print JSON output", false).action((options) => {
9104
10512
  const result = diffMachines(options.left, options.right);
9105
10513
  console.log(JSON.stringify(result, null, 2));
@@ -9253,6 +10661,51 @@ program2.command("ports").description("List listening ports on a machine").optio
9253
10661
  const result = listPorts(options.machine);
9254
10662
  console.log(JSON.stringify(result, null, 2));
9255
10663
  });
10664
+ var storageCommand = program2.command("storage").description("Sync local machine runtime data with storage PostgreSQL");
10665
+ storageCommand.command("status").description("Show storage sync status").option("-j, --json", "Print JSON output", false).action(async (options) => {
10666
+ const { getStorageStatus: getStorageStatus2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
10667
+ const status = getStorageStatus2();
10668
+ printJsonOrText(status, renderKeyValueTable([
10669
+ ["mode", status.mode],
10670
+ ["configured", status.configured ? "yes" : "no"],
10671
+ ["active env", status.activeEnv || "none"],
10672
+ ["tables", status.tables.join(", ")]
10673
+ ]), options.json);
10674
+ });
10675
+ storageCommand.command("push").description("Push local machine runtime data to storage PostgreSQL").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
10676
+ try {
10677
+ const { parseStorageTables: parseStorageTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
10678
+ const results = await storagePush2({ tables: parseStorageTables2(options.tables) });
10679
+ printStorageResults(results, options.json);
10680
+ } catch (error) {
10681
+ printStorageError(error);
10682
+ }
10683
+ });
10684
+ storageCommand.command("pull").description("Pull machine runtime data from storage PostgreSQL to local SQLite").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
10685
+ try {
10686
+ const { parseStorageTables: parseStorageTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
10687
+ const results = await storagePull2({ tables: parseStorageTables2(options.tables) });
10688
+ printStorageResults(results, options.json);
10689
+ } catch (error) {
10690
+ printStorageError(error);
10691
+ }
10692
+ });
10693
+ storageCommand.command("sync").description("Bidirectional storage sync: pull then push").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
10694
+ try {
10695
+ const { parseStorageTables: parseStorageTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
10696
+ const result = await storageSync2({ tables: parseStorageTables2(options.tables) });
10697
+ if (options.json) {
10698
+ console.log(JSON.stringify(result, null, 2));
10699
+ return;
10700
+ }
10701
+ console.log(source_default.bold("Pull"));
10702
+ printStorageResults(result.pull);
10703
+ console.log(source_default.bold("Push"));
10704
+ printStorageResults(result.push);
10705
+ } catch (error) {
10706
+ printStorageError(error);
10707
+ }
10708
+ });
9256
10709
  program2.command("status").description("Print local machine and storage status").option("-j, --json", "Print JSON output", false).action((options) => {
9257
10710
  const status = getStatus();
9258
10711
  printJsonOrText(status, renderFleetStatus(status), options.json);
@@ -9274,4 +10727,98 @@ program2.command("serve").description("Serve a local fleet dashboard and JSON AP
9274
10727
  const server = startDashboardServer({ host: info.host, port: info.port });
9275
10728
  console.log(source_default.green(`machines dashboard listening on http://${server.hostname}:${server.port}`));
9276
10729
  });
10730
+ var healCommand = program2.command("heal").description("Self-healing network watchdog: keeps a Wi-Fi node reachable (SSID pinning + peer-reachability + gated reboot)");
10731
+ function requireRoot() {
10732
+ const uid = process.getuid ? process.getuid() : 1;
10733
+ if (uid !== 0) {
10734
+ console.error(source_default.red("error: this command must run as root (try: sudo machines heal install)"));
10735
+ return false;
10736
+ }
10737
+ return true;
10738
+ }
10739
+ healCommand.command("config").description(`View or update self-healing config (e.g. --set '{"preferredSsid":"X81ND","fallbackSsid":"DIGI-s2N5"}')`).option("--set <json>", "Merge a JSON object into the config").option("-j, --json", "Print JSON output", false).action((options) => {
10740
+ if (options.set) {
10741
+ const current = readHealConfig();
10742
+ const partial = JSON.parse(options.set);
10743
+ writeHealConfig({
10744
+ ...current,
10745
+ ...partial,
10746
+ thresholds: { ...current.thresholds, ...partial.thresholds || {} }
10747
+ });
10748
+ }
10749
+ const config = readHealConfig();
10750
+ printJsonOrText(config, renderKeyValueTable([
10751
+ ["enabled", String(config.enabled)],
10752
+ ["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
10753
+ ["fallbackSsid", config.fallbackSsid || "(none)"],
10754
+ ["anchors", config.tailscaleAnchors.length ? config.tailscaleAnchors.join(", ") : "(auto-discover)"],
10755
+ ["quorumRequired", String(config.quorumRequired)],
10756
+ ["intervalSec", String(config.intervalSec)],
10757
+ ["thresholds", `reconnect=${config.thresholds.reconnect} nm=${config.thresholds.nmRestart} fallback=${config.thresholds.fallback} reboot=${config.thresholds.reboot}`],
10758
+ ["allowReboot", String(config.allowReboot)],
10759
+ ["gpuJobGuard", String(config.gpuJobGuard)]
10760
+ ]), options.json);
10761
+ });
10762
+ healCommand.command("check").description("Run one health + decision tick read-only (no side effects)").option("-j, --json", "Print JSON output", false).action((options) => {
10763
+ const result = runHealOnce(readHealConfig(), { dryRun: true });
10764
+ printJsonOrText(result, renderList("heal check", [
10765
+ `health: ${result.healthy ? source_default.green("HEALTHY") : source_default.red("UNHEALTHY")} (remote quorum ${result.remoteScore})`,
10766
+ `reasons: ${result.reasons.length ? result.reasons.join(", ") : "none"}`,
10767
+ `would do: ${result.action}${result.suppressedReason ? ` (reboot suppressed: ${result.suppressedReason})` : ""}`,
10768
+ `consecutive fails: ${result.failCount}`
10769
+ ]), options.json);
10770
+ });
10771
+ healCommand.command("status").description("Show watchdog service status and last persisted state").option("-j, --json", "Print JSON output", false).action((options) => {
10772
+ const svc = healServiceStatus();
10773
+ const state = readHealState();
10774
+ const config = readHealConfig();
10775
+ printJsonOrText({ service: svc, state, config }, renderKeyValueTable([
10776
+ ["service installed", svc.installed ? source_default.green("yes") : "no"],
10777
+ ["service active", svc.active ? source_default.green("yes") : source_default.yellow("no")],
10778
+ ["service enabled", svc.enabled ? "yes" : "no"],
10779
+ ["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
10780
+ ["consecutive fails", String(state.failCount)],
10781
+ ["pending reboot recovery", String(state.pendingRebootRecovery)],
10782
+ ["failed boot recoveries", String(state.failedBootRecoveries)]
10783
+ ]), options.json);
10784
+ });
10785
+ healCommand.command("daemon").description("Run the watchdog loop in the foreground (used by systemd)").action(() => {
10786
+ startHealDaemon();
10787
+ });
10788
+ healCommand.command("stop").description("Stop a foreground daemon started via `heal daemon`").action(() => {
10789
+ const r = stopHealDaemon();
10790
+ console.log(r.stopped ? `stopped heal daemon (pid ${r.pid})` : "heal daemon not running");
10791
+ });
10792
+ healCommand.command("determinism").description("Pin the preferred SSID, disable other autoconnects, turn off Wi-Fi power save").action(() => {
10793
+ const log2 = applyDeterminism(readHealConfig());
10794
+ console.log(renderList("determinism", log2));
10795
+ });
10796
+ healCommand.command("install").description("Install the watchdog: determinism + hardware watchdog + systemd service (requires root)").option("--no-determinism", "Skip SSID pinning / power-save changes").option("--no-watchdog", "Skip enabling the systemd hardware watchdog").option("--no-service", "Skip installing the systemd service").action((options) => {
10797
+ if (!requireRoot()) {
10798
+ process.exitCode = 1;
10799
+ return;
10800
+ }
10801
+ const config = readHealConfig();
10802
+ if (!config.preferredSsid) {
10803
+ console.error(source_default.red(`error: set preferredSsid first: machines heal config --set '{"preferredSsid":"X81ND"}'`));
10804
+ process.exitCode = 1;
10805
+ return;
10806
+ }
10807
+ const out = [];
10808
+ if (options.determinism !== false)
10809
+ out.push(...applyDeterminism(config));
10810
+ if (options.watchdog !== false)
10811
+ out.push(...enableHardwareWatchdog());
10812
+ if (options.service !== false)
10813
+ out.push(...installHealService());
10814
+ console.log(renderList("install", out));
10815
+ console.log(source_default.green("self-healing watchdog installed"));
10816
+ });
10817
+ healCommand.command("uninstall").description("Remove the systemd watchdog service (requires root)").action(() => {
10818
+ if (!requireRoot()) {
10819
+ process.exitCode = 1;
10820
+ return;
10821
+ }
10822
+ console.log(renderList("uninstall", uninstallHealService()));
10823
+ });
9277
10824
  await program2.parseAsync(process.argv);