@hasna/machines 0.0.36 → 0.0.38
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/README.md +54 -0
- package/dist/agent/index.js +10554 -166
- package/dist/agent/runtime.d.ts +33 -3
- package/dist/agent/runtime.d.ts.map +1 -1
- package/dist/cli/index.js +886 -58
- package/dist/commands/daemon.d.ts +76 -0
- package/dist/commands/daemon.d.ts.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/consumer.js +225 -6
- package/dist/db.d.ts +29 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1330 -228
- package/dist/manifests.d.ts +6 -6
- package/dist/mcp/index.js +1755 -1083
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/pg-migrations.d.ts.map +1 -1
- package/dist/redaction.d.ts +9 -0
- package/dist/redaction.d.ts.map +1 -1
- package/dist/remote-storage.d.ts +3 -0
- package/dist/remote-storage.d.ts.map +1 -1
- package/dist/storage.js +116 -7
- package/dist/topology.d.ts +21 -0
- package/dist/topology.d.ts.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/mcp/index.js
CHANGED
|
@@ -4160,6 +4160,9 @@ function ensureParentDir(filePath) {
|
|
|
4160
4160
|
|
|
4161
4161
|
// src/redaction.ts
|
|
4162
4162
|
var REDACTED_VALUE = "[redacted]";
|
|
4163
|
+
var PRIVATE_OUTPUT_ENV = "HASNA_MACHINES_ALLOW_PRIVATE_OUTPUT";
|
|
4164
|
+
var PRIVATE_OUTPUT_FALLBACK_ENV = "MACHINES_ALLOW_PRIVATE_OUTPUT";
|
|
4165
|
+
var PRIVATE_OUTPUT_DENIED_WARNING = `private_output_denied:set ${PRIVATE_OUTPUT_ENV}=1 to allow private metadata output`;
|
|
4163
4166
|
var SENSITIVE_KEY_PATTERN = /(password|passwd|token|credential|private[_-]?key|privateKey|api[_-]?key|github.*key|pem|secret)/i;
|
|
4164
4167
|
var SECRET_REFERENCE_KEY_PATTERN = /(secret(ref(erence)?|key)?|secretRef|secretKey)$/i;
|
|
4165
4168
|
var SENSITIVE_VALUE_PATTERNS = [
|
|
@@ -4170,9 +4173,17 @@ var SENSITIVE_VALUE_PATTERNS = [
|
|
|
4170
4173
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
4171
4174
|
/\bsk-[A-Za-z0-9_-]{20,}\b/
|
|
4172
4175
|
];
|
|
4176
|
+
var IPV4_PATTERN = /\b(?:10|127|169\.254|172\.(?:1[6-9]|2\d|3[0-1])|192\.168|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7]))(?:\.\d{1,3}){2}\b/g;
|
|
4177
|
+
var IPV6_PATTERN = /\b(?:fc|fd|fe80)[0-9a-f:]*:[0-9a-f:]+\b/gi;
|
|
4178
|
+
var DATABASE_URL_PATTERN = /\b(?:postgres(?:ql)?|mysql|mariadb|redis|mongodb|s3):\/\/[^\s"'<>]+/gi;
|
|
4179
|
+
var PRIVATE_HOST_PATTERN = /\b(?:[A-Za-z0-9._%+-]+@)?[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.tailnet(?:\.[A-Za-z0-9-]+)*|\.ts\.net|\.private(?:\.[A-Za-z0-9-]+)*|\.internal|\.local)\b/gi;
|
|
4173
4180
|
function isSensitiveKey(key) {
|
|
4174
4181
|
return SENSITIVE_KEY_PATTERN.test(key);
|
|
4175
4182
|
}
|
|
4183
|
+
function isPrivateOutputEnabled(env = process.env) {
|
|
4184
|
+
const value = env[PRIVATE_OUTPUT_ENV] ?? env[PRIVATE_OUTPUT_FALLBACK_ENV];
|
|
4185
|
+
return ["1", "true", "yes", "on", "private"].includes(String(value ?? "").trim().toLowerCase());
|
|
4186
|
+
}
|
|
4176
4187
|
function isSecretReferenceKey(key) {
|
|
4177
4188
|
return SECRET_REFERENCE_KEY_PATTERN.test(key);
|
|
4178
4189
|
}
|
|
@@ -4185,6 +4196,12 @@ function isRecord(value) {
|
|
|
4185
4196
|
function redactPath(value) {
|
|
4186
4197
|
return value.replace(/\/home\/[^/\s]+/g, "/home/<user>").replace(/\/Users\/[^/\s]+/g, "/Users/<user>").replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, "C:\\Users\\<user>");
|
|
4187
4198
|
}
|
|
4199
|
+
function redactErrorMessage(value) {
|
|
4200
|
+
return redactPath(value).replace(DATABASE_URL_PATTERN, (match) => {
|
|
4201
|
+
const scheme = match.match(/^([a-z][a-z0-9+.-]*:\/\/)/i)?.[1] ?? "";
|
|
4202
|
+
return `${scheme}${REDACTED_VALUE}`;
|
|
4203
|
+
}).replace(IPV4_PATTERN, REDACTED_VALUE).replace(IPV6_PATTERN, REDACTED_VALUE).replace(PRIVATE_HOST_PATTERN, REDACTED_VALUE);
|
|
4204
|
+
}
|
|
4188
4205
|
function redactPrivateRef(value) {
|
|
4189
4206
|
const trimmed = value.trim();
|
|
4190
4207
|
const scheme = trimmed.match(/^([a-z][a-z0-9+.-]*:\/\/)/i);
|
|
@@ -4459,6 +4476,21 @@ class SqliteAdapter {
|
|
|
4459
4476
|
}
|
|
4460
4477
|
}
|
|
4461
4478
|
var adapter = null;
|
|
4479
|
+
var AGENT_HEARTBEAT_COLUMNS = [
|
|
4480
|
+
{ name: "daemon_version", definition: "TEXT" },
|
|
4481
|
+
{ name: "agent_mode", definition: "TEXT" },
|
|
4482
|
+
{ name: "platform", definition: "TEXT" },
|
|
4483
|
+
{ name: "os_version", definition: "TEXT" },
|
|
4484
|
+
{ name: "os_build", definition: "TEXT" },
|
|
4485
|
+
{ name: "arch", definition: "TEXT" },
|
|
4486
|
+
{ name: "uptime_seconds", definition: "INTEGER" },
|
|
4487
|
+
{ name: "tool_versions_json", definition: "TEXT" },
|
|
4488
|
+
{ name: "tailscale_json", definition: "TEXT" },
|
|
4489
|
+
{ name: "storage_sync_status", definition: "TEXT" },
|
|
4490
|
+
{ name: "storage_sync_last_error", definition: "TEXT" },
|
|
4491
|
+
{ name: "doctor_summary_json", definition: "TEXT" },
|
|
4492
|
+
{ name: "private_metadata", definition: "INTEGER NOT NULL DEFAULT 0" }
|
|
4493
|
+
];
|
|
4462
4494
|
function createTables(db) {
|
|
4463
4495
|
db.exec(`
|
|
4464
4496
|
CREATE TABLE IF NOT EXISTS agent_heartbeats (
|
|
@@ -4466,9 +4498,23 @@ function createTables(db) {
|
|
|
4466
4498
|
pid INTEGER NOT NULL,
|
|
4467
4499
|
status TEXT NOT NULL,
|
|
4468
4500
|
updated_at TEXT NOT NULL,
|
|
4501
|
+
daemon_version TEXT,
|
|
4502
|
+
agent_mode TEXT,
|
|
4503
|
+
platform TEXT,
|
|
4504
|
+
os_version TEXT,
|
|
4505
|
+
os_build TEXT,
|
|
4506
|
+
arch TEXT,
|
|
4507
|
+
uptime_seconds INTEGER,
|
|
4508
|
+
tool_versions_json TEXT,
|
|
4509
|
+
tailscale_json TEXT,
|
|
4510
|
+
storage_sync_status TEXT,
|
|
4511
|
+
storage_sync_last_error TEXT,
|
|
4512
|
+
doctor_summary_json TEXT,
|
|
4513
|
+
private_metadata INTEGER NOT NULL DEFAULT 0,
|
|
4469
4514
|
PRIMARY KEY (machine_id, pid)
|
|
4470
4515
|
)
|
|
4471
4516
|
`);
|
|
4517
|
+
migrateAgentHeartbeats(db);
|
|
4472
4518
|
db.exec(`
|
|
4473
4519
|
CREATE TABLE IF NOT EXISTS setup_runs (
|
|
4474
4520
|
id TEXT PRIMARY KEY,
|
|
@@ -4490,6 +4536,15 @@ function createTables(db) {
|
|
|
4490
4536
|
)
|
|
4491
4537
|
`);
|
|
4492
4538
|
}
|
|
4539
|
+
function migrateAgentHeartbeats(db) {
|
|
4540
|
+
const columns = db.query("PRAGMA table_info(agent_heartbeats)").all();
|
|
4541
|
+
const existing = new Set(columns.map((column) => column.name));
|
|
4542
|
+
for (const column of AGENT_HEARTBEAT_COLUMNS) {
|
|
4543
|
+
if (existing.has(column.name))
|
|
4544
|
+
continue;
|
|
4545
|
+
db.exec(`ALTER TABLE agent_heartbeats ADD COLUMN ${column.name} ${column.definition}`);
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4493
4548
|
function getAdapter(path = getDbPath()) {
|
|
4494
4549
|
if (path === ":memory:") {
|
|
4495
4550
|
const memoryAdapter = new SqliteAdapter(path);
|
|
@@ -4518,12 +4573,12 @@ function getLocalMachineId() {
|
|
|
4518
4573
|
function listHeartbeats(machineId) {
|
|
4519
4574
|
const db = getDb();
|
|
4520
4575
|
if (machineId) {
|
|
4521
|
-
return db.query(`SELECT
|
|
4576
|
+
return db.query(`SELECT *
|
|
4522
4577
|
FROM agent_heartbeats
|
|
4523
4578
|
WHERE machine_id = ?
|
|
4524
4579
|
ORDER BY updated_at DESC`).all(machineId);
|
|
4525
4580
|
}
|
|
4526
|
-
return db.query(`SELECT
|
|
4581
|
+
return db.query(`SELECT *
|
|
4527
4582
|
FROM agent_heartbeats
|
|
4528
4583
|
ORDER BY updated_at DESC`).all();
|
|
4529
4584
|
}
|
|
@@ -4710,6 +4765,16 @@ function routeRank(hint) {
|
|
|
4710
4765
|
function selectRouteHint(hints) {
|
|
4711
4766
|
return [...hints].sort((left, right) => routeRank(left) - routeRank(right))[0] ?? null;
|
|
4712
4767
|
}
|
|
4768
|
+
function parseHeartbeatJson(value) {
|
|
4769
|
+
if (!value)
|
|
4770
|
+
return null;
|
|
4771
|
+
try {
|
|
4772
|
+
const parsed = JSON.parse(value);
|
|
4773
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
4774
|
+
} catch {
|
|
4775
|
+
return null;
|
|
4776
|
+
}
|
|
4777
|
+
}
|
|
4713
4778
|
function buildEntry(input) {
|
|
4714
4779
|
const manifest = input.manifest;
|
|
4715
4780
|
const peer = input.peer;
|
|
@@ -4732,6 +4797,22 @@ function buildEntry(input) {
|
|
|
4732
4797
|
manifest_declared: Boolean(manifest),
|
|
4733
4798
|
heartbeat_status: input.heartbeat?.status ?? "unknown",
|
|
4734
4799
|
last_heartbeat_at: input.heartbeat?.updated_at ?? null,
|
|
4800
|
+
agent: {
|
|
4801
|
+
pid: input.heartbeat?.pid ?? null,
|
|
4802
|
+
daemon_version: input.heartbeat?.daemon_version ?? null,
|
|
4803
|
+
mode: input.heartbeat?.agent_mode ?? null,
|
|
4804
|
+
private_metadata: Boolean(input.heartbeat?.private_metadata),
|
|
4805
|
+
platform: input.heartbeat?.platform ?? null,
|
|
4806
|
+
os_version: input.heartbeat?.os_version ?? null,
|
|
4807
|
+
os_build: input.heartbeat?.os_build ?? null,
|
|
4808
|
+
arch: input.heartbeat?.arch ?? null,
|
|
4809
|
+
uptime_seconds: input.heartbeat?.uptime_seconds ?? null,
|
|
4810
|
+
tool_versions: parseHeartbeatJson(input.heartbeat?.tool_versions_json),
|
|
4811
|
+
tailscale: parseHeartbeatJson(input.heartbeat?.tailscale_json),
|
|
4812
|
+
storage_sync_status: input.heartbeat?.storage_sync_status ?? null,
|
|
4813
|
+
storage_sync_last_error: input.heartbeat?.storage_sync_last_error ?? null,
|
|
4814
|
+
doctor_summary: parseHeartbeatJson(input.heartbeat?.doctor_summary_json)
|
|
4815
|
+
},
|
|
4735
4816
|
tailscale: {
|
|
4736
4817
|
dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
|
|
4737
4818
|
ips: peer?.TailscaleIPs ?? [],
|
|
@@ -4791,6 +4872,72 @@ function discoverMachineTopology(options = {}) {
|
|
|
4791
4872
|
warnings
|
|
4792
4873
|
};
|
|
4793
4874
|
}
|
|
4875
|
+
function redactFleetString(value) {
|
|
4876
|
+
if (!value)
|
|
4877
|
+
return value;
|
|
4878
|
+
return redactErrorMessage(value);
|
|
4879
|
+
}
|
|
4880
|
+
function redactPublicRecord(value) {
|
|
4881
|
+
if (!value)
|
|
4882
|
+
return null;
|
|
4883
|
+
const redacted = {};
|
|
4884
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
4885
|
+
if (/(host|hostname|dns|ip|ips|user|username|serial|address|target|url|token|secret|password|credential)/i.test(key)) {
|
|
4886
|
+
redacted[key] = REDACTED_VALUE;
|
|
4887
|
+
continue;
|
|
4888
|
+
}
|
|
4889
|
+
if (typeof entry === "string") {
|
|
4890
|
+
redacted[key] = redactFleetString(entry);
|
|
4891
|
+
} else if (Array.isArray(entry)) {
|
|
4892
|
+
redacted[key] = entry.map((item) => {
|
|
4893
|
+
if (typeof item === "string")
|
|
4894
|
+
return redactFleetString(item);
|
|
4895
|
+
if (item && typeof item === "object")
|
|
4896
|
+
return redactPublicRecord(item);
|
|
4897
|
+
return item;
|
|
4898
|
+
});
|
|
4899
|
+
} else if (entry && typeof entry === "object") {
|
|
4900
|
+
redacted[key] = redactPublicRecord(entry);
|
|
4901
|
+
} else {
|
|
4902
|
+
redacted[key] = entry;
|
|
4903
|
+
}
|
|
4904
|
+
}
|
|
4905
|
+
return redactSensitiveValue(redacted);
|
|
4906
|
+
}
|
|
4907
|
+
function redactTopologyForOutput(topology, options = {}) {
|
|
4908
|
+
if (options.privateMetadata)
|
|
4909
|
+
return topology;
|
|
4910
|
+
return {
|
|
4911
|
+
...topology,
|
|
4912
|
+
local_hostname: REDACTED_VALUE,
|
|
4913
|
+
warnings: topology.warnings.map(redactFleetString),
|
|
4914
|
+
machines: topology.machines.map((machine) => ({
|
|
4915
|
+
...machine,
|
|
4916
|
+
hostname: machine.hostname ? REDACTED_VALUE : null,
|
|
4917
|
+
user: machine.user ? REDACTED_VALUE : null,
|
|
4918
|
+
tailscale: {
|
|
4919
|
+
...machine.tailscale,
|
|
4920
|
+
dns_name: machine.tailscale.dns_name ? REDACTED_VALUE : null,
|
|
4921
|
+
ips: machine.tailscale.ips.map(() => REDACTED_VALUE)
|
|
4922
|
+
},
|
|
4923
|
+
ssh: {
|
|
4924
|
+
...machine.ssh,
|
|
4925
|
+
address: machine.ssh.address ? REDACTED_VALUE : null,
|
|
4926
|
+
command_target: machine.ssh.command_target ? REDACTED_VALUE : null
|
|
4927
|
+
},
|
|
4928
|
+
route_hints: machine.route_hints.map((hint) => ({
|
|
4929
|
+
...hint,
|
|
4930
|
+
target: REDACTED_VALUE
|
|
4931
|
+
})),
|
|
4932
|
+
agent: {
|
|
4933
|
+
...machine.agent,
|
|
4934
|
+
tailscale: redactPublicRecord(machine.agent.tailscale),
|
|
4935
|
+
storage_sync_last_error: machine.agent.storage_sync_last_error ? redactFleetString(machine.agent.storage_sync_last_error) : null,
|
|
4936
|
+
doctor_summary: redactPublicRecord(machine.agent.doctor_summary)
|
|
4937
|
+
}
|
|
4938
|
+
}))
|
|
4939
|
+
};
|
|
4940
|
+
}
|
|
4794
4941
|
function normalizeMachineAlias(value) {
|
|
4795
4942
|
return value.trim().replace(/\.$/, "").toLowerCase();
|
|
4796
4943
|
}
|
|
@@ -4984,6 +5131,20 @@ function resolveMachineRoute(machineId, options = {}) {
|
|
|
4984
5131
|
warnings
|
|
4985
5132
|
};
|
|
4986
5133
|
}
|
|
5134
|
+
function redactRouteForOutput(route, options = {}) {
|
|
5135
|
+
if (options.privateMetadata)
|
|
5136
|
+
return route;
|
|
5137
|
+
return {
|
|
5138
|
+
...route,
|
|
5139
|
+
target: route.target ? REDACTED_VALUE : null,
|
|
5140
|
+
command_target: route.command_target ? REDACTED_VALUE : null,
|
|
5141
|
+
warnings: route.warnings.map(redactFleetString),
|
|
5142
|
+
evidence: {
|
|
5143
|
+
...route.evidence,
|
|
5144
|
+
selected_hint: route.evidence.selected_hint ? { ...route.evidence.selected_hint, target: REDACTED_VALUE } : null
|
|
5145
|
+
}
|
|
5146
|
+
};
|
|
5147
|
+
}
|
|
4987
5148
|
function isRecord2(value) {
|
|
4988
5149
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
4989
5150
|
}
|
|
@@ -5783,6 +5944,298 @@ function diffMachines(leftMachineId, rightMachineId) {
|
|
|
5783
5944
|
};
|
|
5784
5945
|
}
|
|
5785
5946
|
|
|
5947
|
+
// src/commands/daemon.ts
|
|
5948
|
+
import { platform as osPlatform } from "os";
|
|
5949
|
+
var DEFAULT_SERVICE_NAME = "machines-agent";
|
|
5950
|
+
var DEFAULT_EXECUTABLE = "/usr/local/bin/machines-agent";
|
|
5951
|
+
var DEFAULT_INTERVAL_MS = 30000;
|
|
5952
|
+
var ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
5953
|
+
var SERVICE_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
|
5954
|
+
function buildDaemonServicePlan(options) {
|
|
5955
|
+
const resolved = resolveDaemonServiceOptions(options);
|
|
5956
|
+
const files = resolved.action === "install" ? [buildServiceFile(resolved)] : [];
|
|
5957
|
+
return {
|
|
5958
|
+
platform: resolved.platform,
|
|
5959
|
+
mode: resolved.mode,
|
|
5960
|
+
action: resolved.action,
|
|
5961
|
+
serviceName: resolved.serviceName,
|
|
5962
|
+
serviceId: resolved.serviceId,
|
|
5963
|
+
executable: resolved.executable,
|
|
5964
|
+
intervalMs: resolved.intervalMs,
|
|
5965
|
+
commands: buildActionCommands(resolved),
|
|
5966
|
+
files,
|
|
5967
|
+
warnings: resolved.warnings,
|
|
5968
|
+
manualSteps: buildManualSteps(resolved, files)
|
|
5969
|
+
};
|
|
5970
|
+
}
|
|
5971
|
+
function resolveDaemonServiceOptions(options) {
|
|
5972
|
+
const warnings = [];
|
|
5973
|
+
const serviceName = normalizeServiceName(options.serviceName, warnings);
|
|
5974
|
+
const platform4 = normalizePlatform3(options.platform, warnings);
|
|
5975
|
+
const mode = options.mode ?? "user";
|
|
5976
|
+
const intervalMs = normalizeIntervalMs(options.intervalMs, warnings);
|
|
5977
|
+
const executable = options.executable?.trim() || DEFAULT_EXECUTABLE;
|
|
5978
|
+
if (platform4 === "linux" && !executable.startsWith("/")) {
|
|
5979
|
+
warnings.push("systemd units should use an absolute executable path; install plan keeps the provided path unchanged.");
|
|
5980
|
+
}
|
|
5981
|
+
return {
|
|
5982
|
+
action: options.action,
|
|
5983
|
+
platform: platform4,
|
|
5984
|
+
mode,
|
|
5985
|
+
serviceName,
|
|
5986
|
+
serviceId: serviceName,
|
|
5987
|
+
executable,
|
|
5988
|
+
intervalMs,
|
|
5989
|
+
env: buildEnvironment(serviceName, options, warnings),
|
|
5990
|
+
warnings
|
|
5991
|
+
};
|
|
5992
|
+
}
|
|
5993
|
+
function normalizeServiceName(value, warnings) {
|
|
5994
|
+
const serviceName = value?.trim() || DEFAULT_SERVICE_NAME;
|
|
5995
|
+
if (SERVICE_NAME_PATTERN.test(serviceName))
|
|
5996
|
+
return serviceName;
|
|
5997
|
+
warnings.push(`Invalid serviceName "${serviceName}"; using ${DEFAULT_SERVICE_NAME}.`);
|
|
5998
|
+
return DEFAULT_SERVICE_NAME;
|
|
5999
|
+
}
|
|
6000
|
+
function normalizePlatform3(value, warnings) {
|
|
6001
|
+
const raw = value ?? osPlatform();
|
|
6002
|
+
if (raw === "darwin" || raw === "macos")
|
|
6003
|
+
return "macos";
|
|
6004
|
+
if (raw === "linux")
|
|
6005
|
+
return "linux";
|
|
6006
|
+
warnings.push(`Unsupported platform "${raw}"; using linux service planning.`);
|
|
6007
|
+
return "linux";
|
|
6008
|
+
}
|
|
6009
|
+
function normalizeIntervalMs(value, warnings) {
|
|
6010
|
+
if (value === undefined)
|
|
6011
|
+
return DEFAULT_INTERVAL_MS;
|
|
6012
|
+
if (Number.isInteger(value) && value > 0)
|
|
6013
|
+
return value;
|
|
6014
|
+
warnings.push(`Invalid intervalMs "${String(value)}"; using ${DEFAULT_INTERVAL_MS}.`);
|
|
6015
|
+
return DEFAULT_INTERVAL_MS;
|
|
6016
|
+
}
|
|
6017
|
+
function buildEnvironment(serviceName, options, warnings) {
|
|
6018
|
+
const env = {
|
|
6019
|
+
HASNA_MACHINES_AGENT_MODE: "daemon",
|
|
6020
|
+
HASNA_MACHINES_AGENT_SERVICE: serviceName
|
|
6021
|
+
};
|
|
6022
|
+
if (options.storagePush) {
|
|
6023
|
+
env["HASNA_MACHINES_AGENT_STORAGE_PUSH"] = "1";
|
|
6024
|
+
env["HASNA_MACHINES_AGENT_STORAGE_PUSH_BACKOFF_MS"] = "250";
|
|
6025
|
+
env["HASNA_MACHINES_AGENT_STORAGE_PUSH_RETRIES"] = "2";
|
|
6026
|
+
env["HASNA_MACHINES_STORAGE_MODE"] = "hybrid";
|
|
6027
|
+
env["HASNA_MACHINES_DATABASE_URL"] = placeholderForEnv("HASNA_MACHINES_DATABASE_URL");
|
|
6028
|
+
warnings.push("storagePush is represented with env placeholders; no database URL is embedded in the plan.");
|
|
6029
|
+
}
|
|
6030
|
+
if (options.doctorSummary) {
|
|
6031
|
+
env["HASNA_MACHINES_AGENT_DOCTOR_SUMMARY"] = "1";
|
|
6032
|
+
}
|
|
6033
|
+
if (options.privateMetadata === true) {
|
|
6034
|
+
env["HASNA_MACHINES_PRIVATE_METADATA"] = "1";
|
|
6035
|
+
warnings.push("privateMetadata=true enables private host/network facts in heartbeat rows; do not share private-mode output publicly.");
|
|
6036
|
+
} else if (Array.isArray(options.privateMetadata)) {
|
|
6037
|
+
addEnvPlaceholders(env, options.privateMetadata, warnings);
|
|
6038
|
+
}
|
|
6039
|
+
addEnvPlaceholders(env, options.env ?? [], warnings);
|
|
6040
|
+
return Object.fromEntries(Object.entries(env).sort(([left], [right]) => left.localeCompare(right)));
|
|
6041
|
+
}
|
|
6042
|
+
function addEnvPlaceholders(env, names, warnings) {
|
|
6043
|
+
for (const rawName of names) {
|
|
6044
|
+
const name = rawName.trim();
|
|
6045
|
+
if (!ENV_NAME_PATTERN.test(name)) {
|
|
6046
|
+
warnings.push(`Invalid environment variable name "${rawName}"; skipped.`);
|
|
6047
|
+
continue;
|
|
6048
|
+
}
|
|
6049
|
+
env[name] = placeholderForEnv(name);
|
|
6050
|
+
}
|
|
6051
|
+
}
|
|
6052
|
+
function placeholderForEnv(name) {
|
|
6053
|
+
return `<set:${name}>`;
|
|
6054
|
+
}
|
|
6055
|
+
function buildServiceFile(options) {
|
|
6056
|
+
if (options.platform === "macos") {
|
|
6057
|
+
return {
|
|
6058
|
+
id: "launchd-plist",
|
|
6059
|
+
description: "launchd property list for machines-agent",
|
|
6060
|
+
path: launchdPlistPath(options),
|
|
6061
|
+
mode: "0644",
|
|
6062
|
+
content: launchdPlist(options)
|
|
6063
|
+
};
|
|
6064
|
+
}
|
|
6065
|
+
return {
|
|
6066
|
+
id: "systemd-unit",
|
|
6067
|
+
description: "systemd unit for machines-agent",
|
|
6068
|
+
path: systemdUnitPath(options),
|
|
6069
|
+
mode: "0644",
|
|
6070
|
+
content: systemdUnit(options)
|
|
6071
|
+
};
|
|
6072
|
+
}
|
|
6073
|
+
function buildActionCommands(options) {
|
|
6074
|
+
if (options.platform === "macos")
|
|
6075
|
+
return buildLaunchdCommands(options);
|
|
6076
|
+
return buildSystemdCommands(options);
|
|
6077
|
+
}
|
|
6078
|
+
function buildLaunchdCommands(options) {
|
|
6079
|
+
const domain = launchdDomain(options);
|
|
6080
|
+
const serviceTarget = `${domain}/${options.serviceId}`;
|
|
6081
|
+
const plistPath = launchdPlistPath(options);
|
|
6082
|
+
const sudo = options.mode === "system";
|
|
6083
|
+
if (options.action === "install") {
|
|
6084
|
+
return [
|
|
6085
|
+
command("launchd-bootout-existing", "Unload any existing launchd job before bootstrap.", "launchctl", ["bootout", domain, plistPath], sudo, true, true),
|
|
6086
|
+
command("launchd-bootstrap", "Load the planned launchd plist.", "launchctl", ["bootstrap", domain, plistPath], sudo, true),
|
|
6087
|
+
command("launchd-enable", "Enable the launchd service.", "launchctl", ["enable", serviceTarget], sudo, true),
|
|
6088
|
+
command("launchd-kickstart", "Start or restart the launchd service.", "launchctl", ["kickstart", "-k", serviceTarget], sudo, true)
|
|
6089
|
+
];
|
|
6090
|
+
}
|
|
6091
|
+
if (options.action === "uninstall") {
|
|
6092
|
+
return [
|
|
6093
|
+
command("launchd-bootout", "Unload the launchd job.", "launchctl", ["bootout", domain, plistPath], sudo, true),
|
|
6094
|
+
command("remove-launchd-plist", "Remove the planned launchd plist file.", "rm", ["-f", plistPath], sudo, true)
|
|
6095
|
+
];
|
|
6096
|
+
}
|
|
6097
|
+
if (options.action === "restart") {
|
|
6098
|
+
return [command("launchd-kickstart", "Restart the launchd service.", "launchctl", ["kickstart", "-k", serviceTarget], sudo, true)];
|
|
6099
|
+
}
|
|
6100
|
+
if (options.action === "status") {
|
|
6101
|
+
return [command("launchd-print", "Print launchd service status.", "launchctl", ["print", serviceTarget], sudo, false)];
|
|
6102
|
+
}
|
|
6103
|
+
return [
|
|
6104
|
+
command("launchd-logs", "Stream logs for machines-agent.", "log", ["stream", "--style", "compact", "--predicate", `process == "${basename(options.executable)}" OR eventMessage CONTAINS "${options.serviceId}"`], false, false)
|
|
6105
|
+
];
|
|
6106
|
+
}
|
|
6107
|
+
function buildSystemdCommands(options) {
|
|
6108
|
+
const userFlag = options.mode === "user" ? ["--user"] : [];
|
|
6109
|
+
const sudo = options.mode === "system";
|
|
6110
|
+
const unitName = systemdUnitName(options);
|
|
6111
|
+
const daemonReload = command("systemd-daemon-reload", "Reload systemd unit metadata.", "systemctl", [...userFlag, "daemon-reload"], sudo, true);
|
|
6112
|
+
if (options.action === "install") {
|
|
6113
|
+
return [
|
|
6114
|
+
daemonReload,
|
|
6115
|
+
command("systemd-enable-now", "Enable and start the systemd service.", "systemctl", [...userFlag, "enable", "--now", unitName], sudo, true)
|
|
6116
|
+
];
|
|
6117
|
+
}
|
|
6118
|
+
if (options.action === "uninstall") {
|
|
6119
|
+
return [
|
|
6120
|
+
command("systemd-disable-now", "Stop and disable the systemd service.", "systemctl", [...userFlag, "disable", "--now", unitName], sudo, true),
|
|
6121
|
+
command("remove-systemd-unit", "Remove the planned systemd unit file.", "rm", ["-f", systemdUnitPath(options)], sudo, true),
|
|
6122
|
+
daemonReload
|
|
6123
|
+
];
|
|
6124
|
+
}
|
|
6125
|
+
if (options.action === "restart") {
|
|
6126
|
+
return [command("systemd-restart", "Restart the systemd service.", "systemctl", [...userFlag, "restart", unitName], sudo, true)];
|
|
6127
|
+
}
|
|
6128
|
+
if (options.action === "status") {
|
|
6129
|
+
return [command("systemd-status", "Show systemd service status.", "systemctl", [...userFlag, "status", unitName, "--no-pager"], sudo, false)];
|
|
6130
|
+
}
|
|
6131
|
+
return [
|
|
6132
|
+
command("systemd-logs", "Follow journal logs for the service.", "journalctl", [...userFlag, "-u", unitName, "-f", "--no-pager"], sudo, false)
|
|
6133
|
+
];
|
|
6134
|
+
}
|
|
6135
|
+
function buildManualSteps(options, files) {
|
|
6136
|
+
const steps = [];
|
|
6137
|
+
if (files[0])
|
|
6138
|
+
steps.push(`Write ${files[0].id} content to ${files[0].path} with mode ${files[0].mode}.`);
|
|
6139
|
+
if (options.mode === "system")
|
|
6140
|
+
steps.push("Run commands marked sudo with root privileges.");
|
|
6141
|
+
if (options.platform === "linux" && options.mode === "user") {
|
|
6142
|
+
steps.push("Run commands as the target user; enable lingering separately if the service must survive logout.");
|
|
6143
|
+
}
|
|
6144
|
+
if (options.action === "logs")
|
|
6145
|
+
steps.push("Stop the log command manually when finished.");
|
|
6146
|
+
return steps;
|
|
6147
|
+
}
|
|
6148
|
+
function command(id, description, program, args, sudo, mutates, allowFailure = false) {
|
|
6149
|
+
return { id, description, program, args, sudo, mutates, ...allowFailure ? { allowFailure: true } : {} };
|
|
6150
|
+
}
|
|
6151
|
+
function launchdPlist(options) {
|
|
6152
|
+
const env = Object.entries(options.env).map(([name, value]) => ` <key>${xmlEscape(name)}</key>
|
|
6153
|
+
<string>${xmlEscape(value)}</string>`).join(`
|
|
6154
|
+
`);
|
|
6155
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
6156
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
6157
|
+
<plist version="1.0">
|
|
6158
|
+
<dict>
|
|
6159
|
+
<key>Label</key>
|
|
6160
|
+
<string>${xmlEscape(options.serviceId)}</string>
|
|
6161
|
+
<key>ProgramArguments</key>
|
|
6162
|
+
<array>
|
|
6163
|
+
<string>${xmlEscape(options.executable)}</string>
|
|
6164
|
+
<string>--interval-ms</string>
|
|
6165
|
+
<string>${options.intervalMs}</string>
|
|
6166
|
+
</array>
|
|
6167
|
+
<key>EnvironmentVariables</key>
|
|
6168
|
+
<dict>
|
|
6169
|
+
${env}
|
|
6170
|
+
</dict>
|
|
6171
|
+
<key>KeepAlive</key>
|
|
6172
|
+
<true/>
|
|
6173
|
+
<key>RunAtLoad</key>
|
|
6174
|
+
<true/>
|
|
6175
|
+
<key>StandardOutPath</key>
|
|
6176
|
+
<string>${xmlEscape(launchdLogPath(options, "out"))}</string>
|
|
6177
|
+
<key>StandardErrorPath</key>
|
|
6178
|
+
<string>${xmlEscape(launchdLogPath(options, "err"))}</string>
|
|
6179
|
+
</dict>
|
|
6180
|
+
</plist>
|
|
6181
|
+
`;
|
|
6182
|
+
}
|
|
6183
|
+
function systemdUnit(options) {
|
|
6184
|
+
const env = Object.entries(options.env).map(([name, value]) => `Environment=${quoteSystemdEnvironment(name, value)}`).join(`
|
|
6185
|
+
`);
|
|
6186
|
+
return `[Unit]
|
|
6187
|
+
Description=Hasna machines agent
|
|
6188
|
+
After=network-online.target
|
|
6189
|
+
Wants=network-online.target
|
|
6190
|
+
|
|
6191
|
+
[Service]
|
|
6192
|
+
Type=simple
|
|
6193
|
+
ExecStart=${quoteSystemdExecArg(options.executable)} --interval-ms ${options.intervalMs}
|
|
6194
|
+
Restart=always
|
|
6195
|
+
RestartSec=10
|
|
6196
|
+
${env}
|
|
6197
|
+
|
|
6198
|
+
[Install]
|
|
6199
|
+
WantedBy=${options.mode === "system" ? "multi-user.target" : "default.target"}
|
|
6200
|
+
`;
|
|
6201
|
+
}
|
|
6202
|
+
function launchdDomain(options) {
|
|
6203
|
+
return options.mode === "system" ? "system" : "gui/$UID";
|
|
6204
|
+
}
|
|
6205
|
+
function launchdPlistPath(options) {
|
|
6206
|
+
if (options.mode === "system")
|
|
6207
|
+
return `/Library/LaunchDaemons/${options.serviceId}.plist`;
|
|
6208
|
+
return `$HOME/Library/LaunchAgents/${options.serviceId}.plist`;
|
|
6209
|
+
}
|
|
6210
|
+
function launchdLogPath(options, stream) {
|
|
6211
|
+
const fileName = `${options.serviceId}.${stream}.log`;
|
|
6212
|
+
if (options.mode === "system")
|
|
6213
|
+
return `/var/log/${fileName}`;
|
|
6214
|
+
return `$HOME/Library/Logs/${fileName}`;
|
|
6215
|
+
}
|
|
6216
|
+
function systemdUnitName(options) {
|
|
6217
|
+
return `${options.serviceId}.service`;
|
|
6218
|
+
}
|
|
6219
|
+
function systemdUnitPath(options) {
|
|
6220
|
+
if (options.mode === "system")
|
|
6221
|
+
return `/etc/systemd/system/${systemdUnitName(options)}`;
|
|
6222
|
+
return `$HOME/.config/systemd/user/${systemdUnitName(options)}`;
|
|
6223
|
+
}
|
|
6224
|
+
function xmlEscape(value) {
|
|
6225
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6226
|
+
}
|
|
6227
|
+
function quoteSystemdExecArg(value) {
|
|
6228
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value) && !value.includes("%"))
|
|
6229
|
+
return value;
|
|
6230
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%")}"`;
|
|
6231
|
+
}
|
|
6232
|
+
function quoteSystemdEnvironment(name, value) {
|
|
6233
|
+
return `"${name}=${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%")}"`;
|
|
6234
|
+
}
|
|
6235
|
+
function basename(path) {
|
|
6236
|
+
return path.split("/").filter(Boolean).at(-1) || path;
|
|
6237
|
+
}
|
|
6238
|
+
|
|
5786
6239
|
// src/commands/doctor.ts
|
|
5787
6240
|
var DOCTOR_OPTIONAL_ADAPTER_DOMAINS = ["secrets", "configs", "monitor", "repos", "mcps", "shield"];
|
|
5788
6241
|
function makeCheck(id, status, summary, detail, extra = {}) {
|
|
@@ -5873,14 +6326,20 @@ function runOptionalAdapterChecks(context, adapters) {
|
|
|
5873
6326
|
}
|
|
5874
6327
|
return checks;
|
|
5875
6328
|
}
|
|
5876
|
-
function runDoctor(machineId
|
|
6329
|
+
function runDoctor(machineId, options = {}) {
|
|
6330
|
+
const implicitLocalMachine = !machineId;
|
|
6331
|
+
const requestedMachineId = machineId ?? getLocalMachineId();
|
|
6332
|
+
const reportedMachineId = implicitLocalMachine ? "local" : requestedMachineId;
|
|
5877
6333
|
const now = options.now ?? new Date;
|
|
5878
6334
|
const { manifest, info: manifestSource } = readManifestWithSource({ adapter: options.manifestAdapter ?? null });
|
|
5879
|
-
const commandChecks = runMachineCommand(
|
|
6335
|
+
const commandChecks = runMachineCommand(requestedMachineId, buildDoctorCommand());
|
|
5880
6336
|
const details = parseKeyValueOutput(commandChecks.stdout);
|
|
5881
|
-
const machineInManifest = manifest.machines.find((machine) => machine.id ===
|
|
6337
|
+
const machineInManifest = manifest.machines.find((machine) => machine.id === requestedMachineId);
|
|
6338
|
+
const diagnosticMachine = machineInManifest ? redactManifestForDiagnostics(machineInManifest) : null;
|
|
6339
|
+
if (implicitLocalMachine && diagnosticMachine)
|
|
6340
|
+
diagnosticMachine.id = reportedMachineId;
|
|
5882
6341
|
const optionalAdapterChecks = options.includeOptionalAdapters === false ? [] : runOptionalAdapterChecks({
|
|
5883
|
-
machineId,
|
|
6342
|
+
machineId: requestedMachineId,
|
|
5884
6343
|
manifest,
|
|
5885
6344
|
manifestSource,
|
|
5886
6345
|
commandDetails: details,
|
|
@@ -5896,10 +6355,10 @@ function runDoctor(machineId = getLocalMachineId(), options = {}) {
|
|
|
5896
6355
|
},
|
|
5897
6356
|
remediation: manifestSource.warnings.length > 0 ? ["Provide a private manifest adapter or unset the private manifest ref to use the local manifest only."] : undefined
|
|
5898
6357
|
}),
|
|
5899
|
-
makeCheck("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest",
|
|
6358
|
+
makeCheck("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", diagnosticMachine ? JSON.stringify(diagnosticMachine) : `No manifest entry for ${reportedMachineId}`, {
|
|
5900
6359
|
data: {
|
|
5901
6360
|
declared: Boolean(machineInManifest),
|
|
5902
|
-
machine:
|
|
6361
|
+
machine: diagnosticMachine
|
|
5903
6362
|
}
|
|
5904
6363
|
}),
|
|
5905
6364
|
makeCheck("data-dir", details["data_dir_exists"] === "yes" ? "ok" : "warn", "Data directory check", `${redactPath(details["data_dir"] || "unknown")} ${details["data_dir_exists"] === "yes" ? "exists" : "missing"}`, {
|
|
@@ -5950,7 +6409,7 @@ function runDoctor(machineId = getLocalMachineId(), options = {}) {
|
|
|
5950
6409
|
...optionalAdapterChecks
|
|
5951
6410
|
];
|
|
5952
6411
|
return {
|
|
5953
|
-
machineId,
|
|
6412
|
+
machineId: reportedMachineId,
|
|
5954
6413
|
source: commandChecks.source,
|
|
5955
6414
|
schemaVersion: 1,
|
|
5956
6415
|
generatedAt: now.toISOString(),
|
|
@@ -6197,8 +6656,8 @@ ${message}
|
|
|
6197
6656
|
};
|
|
6198
6657
|
}
|
|
6199
6658
|
if (hasCommand2("mail")) {
|
|
6200
|
-
const
|
|
6201
|
-
const result = Bun.spawnSync(["bash", "-lc",
|
|
6659
|
+
const command2 = `printf %s ${shellQuote5(message)} | mail -s ${shellQuote5(subject)} ${shellQuote5(channel.target)}`;
|
|
6660
|
+
const result = Bun.spawnSync(["bash", "-lc", command2], {
|
|
6202
6661
|
stdout: "pipe",
|
|
6203
6662
|
stderr: "pipe",
|
|
6204
6663
|
env: process.env
|
|
@@ -6422,8 +6881,8 @@ function listPorts(machineId) {
|
|
|
6422
6881
|
const targetMachineId = machineId || getLocalMachineId();
|
|
6423
6882
|
const isLocal = targetMachineId === getLocalMachineId();
|
|
6424
6883
|
const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
|
|
6425
|
-
const
|
|
6426
|
-
const result = spawnSync3("bash", ["-lc",
|
|
6884
|
+
const command2 = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
|
|
6885
|
+
const result = spawnSync3("bash", ["-lc", command2], { encoding: "utf8" });
|
|
6427
6886
|
if (result.status !== 0) {
|
|
6428
6887
|
throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
|
|
6429
6888
|
}
|
|
@@ -6437,1160 +6896,1313 @@ function listPorts(machineId) {
|
|
|
6437
6896
|
// src/commands/serve.ts
|
|
6438
6897
|
import { EventsClient, sanitizeChannelsForOutput } from "@hasna/events";
|
|
6439
6898
|
|
|
6440
|
-
// src/
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6899
|
+
// src/agent/runtime.ts
|
|
6900
|
+
import { arch as arch3, hostname as hostname6, platform as platform4, release, uptime, version as osVersion } from "os";
|
|
6901
|
+
|
|
6902
|
+
// src/pg-migrations.ts
|
|
6903
|
+
var PG_MIGRATIONS = [
|
|
6904
|
+
`
|
|
6905
|
+
CREATE TABLE IF NOT EXISTS agent_heartbeats (
|
|
6906
|
+
machine_id TEXT NOT NULL,
|
|
6907
|
+
pid INTEGER NOT NULL,
|
|
6908
|
+
status TEXT NOT NULL,
|
|
6909
|
+
updated_at TIMESTAMPTZ NOT NULL,
|
|
6910
|
+
daemon_version TEXT,
|
|
6911
|
+
agent_mode TEXT,
|
|
6912
|
+
platform TEXT,
|
|
6913
|
+
os_version TEXT,
|
|
6914
|
+
os_build TEXT,
|
|
6915
|
+
arch TEXT,
|
|
6916
|
+
uptime_seconds INTEGER,
|
|
6917
|
+
tool_versions_json TEXT,
|
|
6918
|
+
tailscale_json TEXT,
|
|
6919
|
+
storage_sync_status TEXT,
|
|
6920
|
+
storage_sync_last_error TEXT,
|
|
6921
|
+
doctor_summary_json TEXT,
|
|
6922
|
+
private_metadata INTEGER NOT NULL DEFAULT 0,
|
|
6923
|
+
PRIMARY KEY (machine_id, pid)
|
|
6924
|
+
);
|
|
6925
|
+
|
|
6926
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS daemon_version TEXT;
|
|
6927
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS agent_mode TEXT;
|
|
6928
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS platform TEXT;
|
|
6929
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS os_version TEXT;
|
|
6930
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS os_build TEXT;
|
|
6931
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS arch TEXT;
|
|
6932
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS uptime_seconds INTEGER;
|
|
6933
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS tool_versions_json TEXT;
|
|
6934
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS tailscale_json TEXT;
|
|
6935
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS storage_sync_status TEXT;
|
|
6936
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS storage_sync_last_error TEXT;
|
|
6937
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS doctor_summary_json TEXT;
|
|
6938
|
+
ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS private_metadata INTEGER NOT NULL DEFAULT 0;
|
|
6939
|
+
|
|
6940
|
+
CREATE TABLE IF NOT EXISTS setup_runs (
|
|
6941
|
+
id TEXT PRIMARY KEY,
|
|
6942
|
+
machine_id TEXT NOT NULL,
|
|
6943
|
+
status TEXT NOT NULL,
|
|
6944
|
+
details_json TEXT NOT NULL DEFAULT '[]',
|
|
6945
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
6946
|
+
updated_at TIMESTAMPTZ NOT NULL
|
|
6947
|
+
);
|
|
6948
|
+
|
|
6949
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
6950
|
+
id TEXT PRIMARY KEY,
|
|
6951
|
+
machine_id TEXT NOT NULL,
|
|
6952
|
+
status TEXT NOT NULL,
|
|
6953
|
+
actions_json TEXT NOT NULL DEFAULT '[]',
|
|
6954
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
6955
|
+
updated_at TIMESTAMPTZ NOT NULL
|
|
6956
|
+
);
|
|
6957
|
+
`
|
|
6958
|
+
];
|
|
6959
|
+
|
|
6960
|
+
// src/remote-storage.ts
|
|
6961
|
+
import pg from "pg";
|
|
6962
|
+
function translatePlaceholders(sql) {
|
|
6963
|
+
let index = 0;
|
|
6964
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
6458
6965
|
}
|
|
6459
|
-
function
|
|
6460
|
-
const
|
|
6461
|
-
|
|
6462
|
-
...manifest,
|
|
6463
|
-
machines: manifest.machines.filter((machine) => machine.id !== machineId)
|
|
6464
|
-
};
|
|
6465
|
-
writeManifest(nextManifest);
|
|
6466
|
-
return nextManifest;
|
|
6966
|
+
function normalizeParams(params) {
|
|
6967
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
6968
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
6467
6969
|
}
|
|
6468
|
-
function
|
|
6469
|
-
|
|
6970
|
+
function sslConfigFor(connectionString) {
|
|
6971
|
+
let url;
|
|
6972
|
+
try {
|
|
6973
|
+
url = new URL(connectionString);
|
|
6974
|
+
} catch {
|
|
6975
|
+
return;
|
|
6976
|
+
}
|
|
6977
|
+
const sslMode = url.searchParams.get("sslmode")?.toLowerCase();
|
|
6978
|
+
const ssl = url.searchParams.get("ssl")?.toLowerCase();
|
|
6979
|
+
if (sslMode === "disable" || ssl === "false")
|
|
6980
|
+
return;
|
|
6981
|
+
if (sslMode === "no-verify" || process.env["HASNA_MACHINES_DATABASE_SSL_REJECT_UNAUTHORIZED"] === "0") {
|
|
6982
|
+
return { rejectUnauthorized: false };
|
|
6983
|
+
}
|
|
6984
|
+
return sslMode || ssl === "true" ? { rejectUnauthorized: true } : undefined;
|
|
6470
6985
|
}
|
|
6471
6986
|
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
machines: [...machineIds].sort().map((machineId) => {
|
|
6489
|
-
const declared = manifest.machines.find((machine) => machine.id === machineId);
|
|
6490
|
-
const heartbeat = heartbeatByMachine.get(machineId);
|
|
6491
|
-
return {
|
|
6492
|
-
machineId,
|
|
6493
|
-
platform: declared?.platform,
|
|
6494
|
-
manifestDeclared: Boolean(declared),
|
|
6495
|
-
heartbeatStatus: heartbeat?.status || "unknown",
|
|
6496
|
-
lastHeartbeatAt: heartbeat?.updated_at
|
|
6497
|
-
};
|
|
6498
|
-
}),
|
|
6499
|
-
recentSetupRuns: countRuns("setup_runs"),
|
|
6500
|
-
recentSyncRuns: countRuns("sync_runs")
|
|
6501
|
-
};
|
|
6987
|
+
class PgAdapterAsync {
|
|
6988
|
+
pool;
|
|
6989
|
+
constructor(connectionString) {
|
|
6990
|
+
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
6991
|
+
}
|
|
6992
|
+
async run(sql, ...params) {
|
|
6993
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
6994
|
+
return { changes: result.rowCount ?? 0 };
|
|
6995
|
+
}
|
|
6996
|
+
async all(sql, ...params) {
|
|
6997
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
6998
|
+
return result.rows;
|
|
6999
|
+
}
|
|
7000
|
+
async close() {
|
|
7001
|
+
await this.pool.end();
|
|
7002
|
+
}
|
|
6502
7003
|
}
|
|
6503
7004
|
|
|
6504
|
-
// src/
|
|
6505
|
-
|
|
6506
|
-
|
|
7005
|
+
// src/storage-sync.ts
|
|
7006
|
+
var STORAGE_TABLES = [
|
|
7007
|
+
"agent_heartbeats",
|
|
7008
|
+
"setup_runs",
|
|
7009
|
+
"sync_runs"
|
|
7010
|
+
];
|
|
7011
|
+
var MACHINES_STORAGE_ENV = "HASNA_MACHINES_DATABASE_URL";
|
|
7012
|
+
var MACHINES_STORAGE_FALLBACK_ENV = "MACHINES_DATABASE_URL";
|
|
7013
|
+
var MACHINES_STORAGE_MODE_ENV = "HASNA_MACHINES_STORAGE_MODE";
|
|
7014
|
+
var MACHINES_STORAGE_MODE_FALLBACK_ENV = "MACHINES_STORAGE_MODE";
|
|
7015
|
+
var STORAGE_DATABASE_ENV = [MACHINES_STORAGE_ENV, MACHINES_STORAGE_FALLBACK_ENV];
|
|
7016
|
+
var PRIMARY_KEYS = {
|
|
7017
|
+
agent_heartbeats: ["machine_id", "pid"],
|
|
7018
|
+
setup_runs: ["id"],
|
|
7019
|
+
sync_runs: ["id"]
|
|
7020
|
+
};
|
|
7021
|
+
function readEnv2(name) {
|
|
7022
|
+
const value = process.env[name]?.trim();
|
|
7023
|
+
return value || undefined;
|
|
6507
7024
|
}
|
|
6508
|
-
function
|
|
6509
|
-
const
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
const html = renderDashboardHtml();
|
|
6514
|
-
const notifications = listNotificationChannels();
|
|
6515
|
-
const apps = listApps(status.machineId);
|
|
6516
|
-
const appsDiff = diffApps(status.machineId);
|
|
6517
|
-
const cliPlan = buildClaudeInstallPlan(status.machineId);
|
|
6518
|
-
return {
|
|
6519
|
-
machineId: getLocalMachineId(),
|
|
6520
|
-
checks: [
|
|
6521
|
-
check("package-version", version === "0.0.0" ? "fail" : "ok", "Package version resolves", version),
|
|
6522
|
-
check("status", "ok", "Status loads", JSON.stringify({ machines: status.manifestMachineCount, heartbeats: status.heartbeatCount })),
|
|
6523
|
-
check("doctor", doctor.checks.some((entry) => entry.status === "fail") ? "warn" : "ok", "Doctor completed", `${doctor.checks.length} checks`),
|
|
6524
|
-
check("serve-info", "ok", "Dashboard info renders", `${serveInfo.url} routes=${serveInfo.routes.length}`),
|
|
6525
|
-
check("dashboard-html", html.includes("Machines Dashboard") ? "ok" : "fail", "Dashboard HTML renders", html.slice(0, 80)),
|
|
6526
|
-
check("notifications", "ok", "Notifications config loads", `${notifications.channels.length} channels`),
|
|
6527
|
-
check("apps", "ok", "Apps manifest loads", `${apps.apps.length} apps`),
|
|
6528
|
-
check("apps-diff", appsDiff.missing.length === 0 ? "ok" : "warn", "Apps diff computed", `missing=${appsDiff.missing.length} installed=${appsDiff.installed.length}`),
|
|
6529
|
-
check("install-claude-plan", cliPlan.steps.length > 0 ? "ok" : "warn", "Install plan renders", `${cliPlan.steps.length} steps`)
|
|
6530
|
-
]
|
|
6531
|
-
};
|
|
7025
|
+
function normalizeStorageMode(value) {
|
|
7026
|
+
const normalized = value?.trim().toLowerCase();
|
|
7027
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
7028
|
+
return normalized;
|
|
7029
|
+
return;
|
|
6532
7030
|
}
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
7031
|
+
function getStorageDatabaseEnvName() {
|
|
7032
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
7033
|
+
if (readEnv2(name))
|
|
7034
|
+
return name;
|
|
7035
|
+
}
|
|
7036
|
+
return null;
|
|
6537
7037
|
}
|
|
6538
|
-
function
|
|
6539
|
-
const
|
|
6540
|
-
|
|
7038
|
+
function getStorageDatabaseEnv() {
|
|
7039
|
+
const name = getStorageDatabaseEnvName();
|
|
7040
|
+
return name ? { name } : null;
|
|
7041
|
+
}
|
|
7042
|
+
function getStorageDatabaseUrl() {
|
|
7043
|
+
const env = getStorageDatabaseEnv();
|
|
7044
|
+
return env ? readEnv2(env.name) ?? null : null;
|
|
7045
|
+
}
|
|
7046
|
+
function getStorageMode() {
|
|
7047
|
+
const mode = normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_FALLBACK_ENV));
|
|
7048
|
+
if (mode)
|
|
7049
|
+
return mode;
|
|
7050
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
7051
|
+
}
|
|
7052
|
+
async function getStoragePg() {
|
|
7053
|
+
const url = getStorageDatabaseUrl();
|
|
7054
|
+
if (!url) {
|
|
7055
|
+
throw new Error("Missing HASNA_MACHINES_DATABASE_URL or MACHINES_DATABASE_URL");
|
|
7056
|
+
}
|
|
7057
|
+
return new PgAdapterAsync(url);
|
|
7058
|
+
}
|
|
7059
|
+
async function runStorageMigrations(remote) {
|
|
7060
|
+
for (const sql of PG_MIGRATIONS)
|
|
7061
|
+
await remote.run(sql);
|
|
7062
|
+
}
|
|
7063
|
+
async function storagePush(options) {
|
|
7064
|
+
const remote = await getStoragePg();
|
|
7065
|
+
const db = getDb();
|
|
7066
|
+
try {
|
|
7067
|
+
await runStorageMigrations(remote);
|
|
7068
|
+
const results = [];
|
|
7069
|
+
for (const table of resolveTables(options?.tables)) {
|
|
7070
|
+
results.push(await pushTable(db, remote, table));
|
|
7071
|
+
}
|
|
7072
|
+
recordSyncMeta(db, "push", results);
|
|
7073
|
+
return results;
|
|
7074
|
+
} finally {
|
|
7075
|
+
await remote.close();
|
|
7076
|
+
}
|
|
7077
|
+
}
|
|
7078
|
+
async function storagePull(options) {
|
|
7079
|
+
const remote = await getStoragePg();
|
|
7080
|
+
const db = getDb();
|
|
7081
|
+
try {
|
|
7082
|
+
await runStorageMigrations(remote);
|
|
7083
|
+
const results = [];
|
|
7084
|
+
for (const table of resolveTables(options?.tables)) {
|
|
7085
|
+
results.push(await pullTable(remote, db, table));
|
|
7086
|
+
}
|
|
7087
|
+
recordSyncMeta(db, "pull", results);
|
|
7088
|
+
return results;
|
|
7089
|
+
} finally {
|
|
7090
|
+
await remote.close();
|
|
7091
|
+
}
|
|
7092
|
+
}
|
|
7093
|
+
async function storageSync(options) {
|
|
7094
|
+
const pull = await storagePull(options);
|
|
7095
|
+
const push = await storagePush(options);
|
|
7096
|
+
return { pull, push };
|
|
7097
|
+
}
|
|
7098
|
+
function getSyncMetaAll() {
|
|
7099
|
+
const db = getDb();
|
|
7100
|
+
ensureSyncMetaTable(db);
|
|
7101
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _machines_sync_meta ORDER BY table_name, direction").all();
|
|
7102
|
+
}
|
|
7103
|
+
function getStorageStatus() {
|
|
7104
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
6541
7105
|
return {
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
"/api/manifest",
|
|
6550
|
-
"/api/notifications",
|
|
6551
|
-
"/api/webhooks",
|
|
6552
|
-
"/api/events",
|
|
6553
|
-
"/api/doctor",
|
|
6554
|
-
"/api/self-test",
|
|
6555
|
-
"/api/apps/status",
|
|
6556
|
-
"/api/apps/diff",
|
|
6557
|
-
"/api/install-claude/status",
|
|
6558
|
-
"/api/install-claude/diff",
|
|
6559
|
-
"/api/notifications/test",
|
|
6560
|
-
"/api/webhooks/test"
|
|
6561
|
-
]
|
|
7106
|
+
configured: Boolean(activeEnv),
|
|
7107
|
+
mode: getStorageMode(),
|
|
7108
|
+
env: STORAGE_DATABASE_ENV,
|
|
7109
|
+
activeEnv: activeEnv?.name ?? null,
|
|
7110
|
+
service: "machines",
|
|
7111
|
+
tables: STORAGE_TABLES,
|
|
7112
|
+
sync: getSyncMetaAll()
|
|
6562
7113
|
};
|
|
6563
7114
|
}
|
|
6564
|
-
function
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
const
|
|
6568
|
-
const
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6574
|
-
<title>Machines Dashboard</title>
|
|
6575
|
-
<style>
|
|
6576
|
-
:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
|
|
6577
|
-
body { margin: 0; background: #0b1020; color: #e5ecff; }
|
|
6578
|
-
main { max-width: 1120px; margin: 0 auto; padding: 32px 20px 48px; }
|
|
6579
|
-
h1, h2 { margin: 0 0 16px; }
|
|
6580
|
-
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
|
6581
|
-
.card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
|
|
6582
|
-
.stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
|
|
6583
|
-
table { width: 100%; border-collapse: collapse; }
|
|
6584
|
-
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; vertical-align: top; }
|
|
6585
|
-
code { color: #9ed0ff; }
|
|
6586
|
-
.badge { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
|
|
6587
|
-
.online, .ok { background: #12351f; color: #74f0a7; }
|
|
6588
|
-
.offline, .fail { background: #3b1a1a; color: #ff8c8c; }
|
|
6589
|
-
.unknown, .warn { background: #2f2b16; color: #ffd76a; }
|
|
6590
|
-
ul { margin: 8px 0 0; padding-left: 18px; }
|
|
6591
|
-
.muted { color: #9fb0d9; }
|
|
6592
|
-
.refresh { font-size: 12px; color: #6b7fa3; margin-left: auto; }
|
|
6593
|
-
.updated { transition: opacity 0.3s; }
|
|
6594
|
-
</style>
|
|
6595
|
-
</head>
|
|
6596
|
-
<body>
|
|
6597
|
-
<main>
|
|
6598
|
-
<h1>Machines Dashboard <span class="refresh" id="last-updated"></span></h1>
|
|
6599
|
-
<div class="grid">
|
|
6600
|
-
<section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
|
|
6601
|
-
<section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
|
|
6602
|
-
<section class="card"><div>Notification channels</div><div class="stat">${notifications.channels.length}</div></section>
|
|
6603
|
-
<section class="card"><div>Doctor warnings</div><div class="stat">${doctor.checks.filter((entry) => entry.status !== "ok").length}</div></section>
|
|
6604
|
-
</div>
|
|
6605
|
-
|
|
6606
|
-
<section class="card" style="margin-top:16px">
|
|
6607
|
-
<h2>Machines</h2>
|
|
6608
|
-
<table>
|
|
6609
|
-
<thead><tr><th>ID</th><th>Platform</th><th>Status</th><th>Last heartbeat</th></tr></thead>
|
|
6610
|
-
<tbody>
|
|
6611
|
-
${status.machines.map((machine) => `<tr>
|
|
6612
|
-
<td><code>${escapeHtml(machine.machineId)}</code></td>
|
|
6613
|
-
<td>${escapeHtml(machine.platform || "unknown")}</td>
|
|
6614
|
-
<td><span class="badge ${escapeHtml(machine.heartbeatStatus)}">${escapeHtml(machine.heartbeatStatus)}</span></td>
|
|
6615
|
-
<td>${escapeHtml(machine.lastHeartbeatAt || "\u2014")}</td>
|
|
6616
|
-
</tr>`).join("")}
|
|
6617
|
-
</tbody>
|
|
6618
|
-
</table>
|
|
6619
|
-
</section>
|
|
6620
|
-
|
|
6621
|
-
<section class="card" style="margin-top:16px">
|
|
6622
|
-
<h2>Doctor</h2>
|
|
6623
|
-
<table>
|
|
6624
|
-
<thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
|
|
6625
|
-
<tbody id="doctor-tbody">
|
|
6626
|
-
${doctor.checks.map((entry) => `<tr>
|
|
6627
|
-
<td>${escapeHtml(entry.summary)}</td>
|
|
6628
|
-
<td><span class="badge ${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</span></td>
|
|
6629
|
-
<td class="muted">${escapeHtml(entry.detail)}</td>
|
|
6630
|
-
</tr>`).join("")}
|
|
6631
|
-
</tbody>
|
|
6632
|
-
</table>
|
|
6633
|
-
</section>
|
|
6634
|
-
|
|
6635
|
-
<section class="card" style="margin-top:16px">
|
|
6636
|
-
<h2>Apps</h2>
|
|
6637
|
-
<p class="muted">Use <code>/api/apps/status</code> for the full app inventory payload.</p>
|
|
6638
|
-
</section>
|
|
6639
|
-
|
|
6640
|
-
<section class="card" style="margin-top:16px">
|
|
6641
|
-
<h2>AI CLIs</h2>
|
|
6642
|
-
<p class="muted">Use <code>/api/install-claude/status</code> for the full CLI inventory payload.</p>
|
|
6643
|
-
</section>
|
|
6644
|
-
|
|
6645
|
-
<section class="card" style="margin-top:16px">
|
|
6646
|
-
<h2>Self Test</h2>
|
|
6647
|
-
<p class="muted">Use <code>/api/self-test</code> for the full smoke-check payload.</p>
|
|
6648
|
-
</section>
|
|
6649
|
-
|
|
6650
|
-
<section class="card" style="margin-top:16px">
|
|
6651
|
-
<h2>Manifest</h2>
|
|
6652
|
-
<pre id="manifest-json">${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
|
|
6653
|
-
</section>
|
|
6654
|
-
</main>
|
|
6655
|
-
<script>
|
|
6656
|
-
// Auto-refresh dashboard data every 15s
|
|
6657
|
-
const REFRESH_INTERVAL = 15000;
|
|
6658
|
-
async function refreshData() {
|
|
6659
|
-
try {
|
|
6660
|
-
const [statusRes, doctorRes] = await Promise.all([
|
|
6661
|
-
fetch("/api/status"),
|
|
6662
|
-
fetch("/api/doctor"),
|
|
6663
|
-
]);
|
|
6664
|
-
const status = await statusRes.json();
|
|
6665
|
-
const doctor = await doctorRes.json();
|
|
6666
|
-
|
|
6667
|
-
// Update stat cards
|
|
6668
|
-
const stats = document.querySelectorAll(".stat");
|
|
6669
|
-
if (stats[0]) stats[0].textContent = status.manifestMachineCount;
|
|
6670
|
-
if (stats[1]) stats[1].textContent = status.heartbeatCount;
|
|
6671
|
-
|
|
6672
|
-
// Update machine table
|
|
6673
|
-
const tbody = document.querySelector("tbody");
|
|
6674
|
-
if (tbody && status.machines) {
|
|
6675
|
-
tbody.innerHTML = status.machines
|
|
6676
|
-
.map((m) =>
|
|
6677
|
-
"<tr>" +
|
|
6678
|
-
"<td><code>" + m.machineId + "</code></td>" +
|
|
6679
|
-
"<td>" + (m.platform || "unknown") + "</td>" +
|
|
6680
|
-
'<td><span class="badge ' + m.heartbeatStatus + '">' + m.heartbeatStatus + '</span></td>' +
|
|
6681
|
-
"<td>" + (m.lastHeartbeatAt || "\\u2014") + "</td>" +
|
|
6682
|
-
"</tr>"
|
|
6683
|
-
)
|
|
6684
|
-
.join("");
|
|
6685
|
-
}
|
|
6686
|
-
|
|
6687
|
-
// Update doctor table
|
|
6688
|
-
const doctorTbody = document.getElementById("doctor-tbody");
|
|
6689
|
-
if (doctorTbody && doctor.checks) {
|
|
6690
|
-
doctorTbody.innerHTML = doctor.checks
|
|
6691
|
-
.map((c) =>
|
|
6692
|
-
"<tr>" +
|
|
6693
|
-
"<td>" + c.summary + "</td>" +
|
|
6694
|
-
'<td><span class="badge ' + c.status + '">' + c.status + '</span></td>' +
|
|
6695
|
-
'<td class="muted">' + c.detail + "</td>" +
|
|
6696
|
-
"</tr>"
|
|
6697
|
-
)
|
|
6698
|
-
.join("");
|
|
6699
|
-
}
|
|
6700
|
-
|
|
6701
|
-
// Update timestamp
|
|
6702
|
-
document.getElementById("last-updated").textContent =
|
|
6703
|
-
"updated " + new Date().toLocaleTimeString();
|
|
6704
|
-
} catch (e) {
|
|
6705
|
-
// Silently ignore fetch errors during page unload
|
|
6706
|
-
}
|
|
6707
|
-
}
|
|
6708
|
-
document.getElementById("last-updated").textContent =
|
|
6709
|
-
"updated " + new Date().toLocaleTimeString();
|
|
6710
|
-
setInterval(refreshData, REFRESH_INTERVAL);
|
|
6711
|
-
</script>
|
|
6712
|
-
</body>
|
|
6713
|
-
</html>`;
|
|
7115
|
+
function resolveTables(tables) {
|
|
7116
|
+
if (!tables || tables.length === 0)
|
|
7117
|
+
return [...STORAGE_TABLES];
|
|
7118
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
7119
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
7120
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
7121
|
+
if (invalid.length > 0)
|
|
7122
|
+
throw new Error(`Unknown machines storage table(s): ${invalid.join(", ")}`);
|
|
7123
|
+
return requested;
|
|
6714
7124
|
}
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
6719
|
-
|
|
7125
|
+
async function pushTable(db, remote, table) {
|
|
7126
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
7127
|
+
try {
|
|
7128
|
+
if (!tableExists(db, table))
|
|
7129
|
+
return result;
|
|
7130
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
7131
|
+
result.rowsRead = rows.length;
|
|
7132
|
+
if (rows.length === 0)
|
|
7133
|
+
return result;
|
|
7134
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
7135
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
7136
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows);
|
|
7137
|
+
} catch (error) {
|
|
7138
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
7139
|
+
}
|
|
7140
|
+
return result;
|
|
6720
7141
|
}
|
|
6721
|
-
function
|
|
6722
|
-
const
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
}
|
|
6735
|
-
];
|
|
6736
|
-
if (machine.platform === "linux") {
|
|
6737
|
-
steps.push({
|
|
6738
|
-
id: "apt-base",
|
|
6739
|
-
title: "Install core Linux tooling",
|
|
6740
|
-
command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
|
|
6741
|
-
manager: "apt",
|
|
6742
|
-
privileged: true
|
|
6743
|
-
});
|
|
6744
|
-
steps.push({
|
|
6745
|
-
id: "linux-update-downloads",
|
|
6746
|
-
title: "Enable Linux package list refresh and download-only upgrades",
|
|
6747
|
-
command: `printf '%s\\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Download-Upgradeable-Packages "1";' 'APT::Periodic::Unattended-Upgrade "0";' | sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null`,
|
|
6748
|
-
manager: "apt",
|
|
6749
|
-
privileged: true
|
|
6750
|
-
});
|
|
6751
|
-
} else if (machine.platform === "macos") {
|
|
6752
|
-
steps.push({
|
|
6753
|
-
id: "brew-base",
|
|
6754
|
-
title: "Install Homebrew if missing",
|
|
6755
|
-
command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
6756
|
-
manager: "brew"
|
|
6757
|
-
});
|
|
6758
|
-
steps.push({
|
|
6759
|
-
id: "brew-core",
|
|
6760
|
-
title: "Install core macOS tooling",
|
|
6761
|
-
command: "brew install git coreutils",
|
|
6762
|
-
manager: "brew"
|
|
6763
|
-
});
|
|
6764
|
-
steps.push({
|
|
6765
|
-
id: "macos-update-downloads",
|
|
6766
|
-
title: "Enable macOS update checks and downloads without automatic install",
|
|
6767
|
-
command: "sudo softwareupdate --schedule on && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool true && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -int 1 && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -int 0",
|
|
6768
|
-
manager: "custom",
|
|
6769
|
-
privileged: true
|
|
6770
|
-
});
|
|
6771
|
-
steps.push({
|
|
6772
|
-
id: "macos-management-readiness",
|
|
6773
|
-
title: "Report Apple management readiness without enrolling devices",
|
|
6774
|
-
command: "profiles status -type enrollment 2>/dev/null || true",
|
|
6775
|
-
manager: "custom"
|
|
6776
|
-
});
|
|
7142
|
+
async function pullTable(remote, db, table) {
|
|
7143
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
7144
|
+
try {
|
|
7145
|
+
if (!tableExists(db, table))
|
|
7146
|
+
return result;
|
|
7147
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
7148
|
+
result.rowsRead = rows.length;
|
|
7149
|
+
if (rows.length === 0)
|
|
7150
|
+
return result;
|
|
7151
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
7152
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
7153
|
+
} catch (error) {
|
|
7154
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
6777
7155
|
}
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
7156
|
+
return result;
|
|
7157
|
+
}
|
|
7158
|
+
async function getRemoteColumns(remote, table) {
|
|
7159
|
+
const rows = await remote.all("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
7160
|
+
return new Set(rows.map((row) => row.column_name));
|
|
7161
|
+
}
|
|
7162
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
7163
|
+
if (remoteColumns.size === 0)
|
|
7164
|
+
return columns;
|
|
7165
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
7166
|
+
}
|
|
7167
|
+
function filterLocalColumns(db, table, columns) {
|
|
7168
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
7169
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
7170
|
+
return columns.filter((column) => allowed.has(column));
|
|
7171
|
+
}
|
|
7172
|
+
async function upsertPg(remote, table, columns, rows) {
|
|
7173
|
+
if (columns.length === 0)
|
|
7174
|
+
return 0;
|
|
7175
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
7176
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
7177
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
7178
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
7179
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
7180
|
+
const fallbackKey = primaryKeys[0];
|
|
7181
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
7182
|
+
for (const row of rows) {
|
|
7183
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
7184
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column])));
|
|
7185
|
+
}
|
|
7186
|
+
return rows.length;
|
|
7187
|
+
}
|
|
7188
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
7189
|
+
if (columns.length === 0)
|
|
7190
|
+
return 0;
|
|
7191
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
7192
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
7193
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
7194
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
7195
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
7196
|
+
const fallbackKey = primaryKeys[0];
|
|
7197
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
7198
|
+
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
7199
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
7200
|
+
const insert = db.transaction((batch) => {
|
|
7201
|
+
for (const row of batch)
|
|
7202
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
6783
7203
|
});
|
|
6784
|
-
|
|
7204
|
+
insert(rows);
|
|
7205
|
+
return rows.length;
|
|
6785
7206
|
}
|
|
6786
|
-
function
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
title: `Install package ${pkg.name}`,
|
|
6800
|
-
command,
|
|
6801
|
-
manager,
|
|
6802
|
-
privileged: manager === "apt"
|
|
6803
|
-
};
|
|
6804
|
-
});
|
|
7207
|
+
function recordSyncMeta(db, direction, results) {
|
|
7208
|
+
ensureSyncMetaTable(db);
|
|
7209
|
+
const now = new Date().toISOString();
|
|
7210
|
+
const statement = db.query(`
|
|
7211
|
+
INSERT INTO _machines_sync_meta (table_name, last_synced_at, direction)
|
|
7212
|
+
VALUES (?, ?, ?)
|
|
7213
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
7214
|
+
`);
|
|
7215
|
+
for (const result of results) {
|
|
7216
|
+
if (result.errors.length > 0)
|
|
7217
|
+
continue;
|
|
7218
|
+
statement.run(result.table, now, direction);
|
|
7219
|
+
}
|
|
6805
7220
|
}
|
|
6806
|
-
function
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
7221
|
+
function ensureSyncMetaTable(db) {
|
|
7222
|
+
db.exec(`
|
|
7223
|
+
CREATE TABLE IF NOT EXISTS _machines_sync_meta (
|
|
7224
|
+
table_name TEXT NOT NULL,
|
|
7225
|
+
last_synced_at TEXT,
|
|
7226
|
+
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
7227
|
+
PRIMARY KEY (table_name, direction)
|
|
7228
|
+
)
|
|
7229
|
+
`);
|
|
7230
|
+
}
|
|
7231
|
+
function tableExists(db, table) {
|
|
7232
|
+
const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
7233
|
+
return Boolean(row);
|
|
7234
|
+
}
|
|
7235
|
+
function quoteIdent(identifier) {
|
|
7236
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
7237
|
+
}
|
|
7238
|
+
function coerceForPg(value) {
|
|
7239
|
+
if (value === undefined || value === null)
|
|
7240
|
+
return null;
|
|
7241
|
+
if (value instanceof Date)
|
|
7242
|
+
return value.toISOString();
|
|
7243
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
7244
|
+
return value;
|
|
7245
|
+
if (typeof value === "object")
|
|
7246
|
+
return JSON.stringify(value);
|
|
7247
|
+
return value;
|
|
7248
|
+
}
|
|
7249
|
+
function coerceForSqlite(value) {
|
|
7250
|
+
if (value === undefined || value === null)
|
|
7251
|
+
return null;
|
|
7252
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
7253
|
+
return value;
|
|
7254
|
+
if (value instanceof Date)
|
|
7255
|
+
return value.toISOString();
|
|
7256
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
7257
|
+
return value;
|
|
7258
|
+
if (typeof value === "object")
|
|
7259
|
+
return JSON.stringify(value);
|
|
7260
|
+
return String(value);
|
|
7261
|
+
}
|
|
7262
|
+
|
|
7263
|
+
// src/agent/runtime.ts
|
|
7264
|
+
function parseJsonObject(value) {
|
|
7265
|
+
if (!value)
|
|
7266
|
+
return null;
|
|
7267
|
+
try {
|
|
7268
|
+
const parsed = JSON.parse(value);
|
|
7269
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
7270
|
+
} catch {
|
|
7271
|
+
return null;
|
|
6812
7272
|
}
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
|
|
7273
|
+
}
|
|
7274
|
+
function heartbeatToStatus(heartbeat, options = {}) {
|
|
7275
|
+
const privateMetadata = options.privateMetadata === true;
|
|
7276
|
+
const tailscale = parseJsonObject(heartbeat.tailscale_json);
|
|
7277
|
+
const doctorSummary = parseJsonObject(heartbeat.doctor_summary_json);
|
|
6819
7278
|
return {
|
|
6820
|
-
machineId:
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
7279
|
+
machineId: heartbeat.machine_id,
|
|
7280
|
+
pid: heartbeat.pid,
|
|
7281
|
+
status: heartbeat.status,
|
|
7282
|
+
updatedAt: heartbeat.updated_at,
|
|
7283
|
+
daemonVersion: heartbeat.daemon_version,
|
|
7284
|
+
agentMode: heartbeat.agent_mode,
|
|
7285
|
+
platform: heartbeat.platform,
|
|
7286
|
+
osVersion: heartbeat.os_version,
|
|
7287
|
+
osBuild: heartbeat.os_build,
|
|
7288
|
+
arch: heartbeat.arch,
|
|
7289
|
+
uptimeSeconds: heartbeat.uptime_seconds,
|
|
7290
|
+
toolVersions: sanitizeRecord(parseJsonObject(heartbeat.tool_versions_json) ?? {}, privateMetadata),
|
|
7291
|
+
tailscale: tailscale ? sanitizeRecord(tailscale, privateMetadata) : null,
|
|
7292
|
+
storageSyncStatus: heartbeat.storage_sync_status,
|
|
7293
|
+
storageSyncLastError: heartbeat.storage_sync_last_error ? sanitizeStorageError(heartbeat.storage_sync_last_error, privateMetadata) : null,
|
|
7294
|
+
doctorSummary: doctorSummary ? sanitizeRecord(doctorSummary, privateMetadata) : null,
|
|
7295
|
+
privateMetadata: Boolean(heartbeat.private_metadata)
|
|
6824
7296
|
};
|
|
6825
7297
|
}
|
|
6826
|
-
function
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
7298
|
+
function sanitizePublicString(value, privateMetadata = false) {
|
|
7299
|
+
if (privateMetadata)
|
|
7300
|
+
return value;
|
|
7301
|
+
let redacted = value;
|
|
7302
|
+
const localHostname = hostname6();
|
|
7303
|
+
const localUser = process.env["USER"] || process.env["LOGNAME"] || process.env["USERNAME"];
|
|
7304
|
+
if (localHostname)
|
|
7305
|
+
redacted = redacted.replaceAll(localHostname, "[redacted-host]");
|
|
7306
|
+
if (localUser)
|
|
7307
|
+
redacted = redacted.replaceAll(localUser, "[redacted-user]");
|
|
7308
|
+
return redactErrorMessage(redacted.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s'")]+/gi, (match) => {
|
|
7309
|
+
const scheme = match.match(/^([a-z][a-z0-9+.-]*:\/\/)/i)?.[1] ?? "";
|
|
7310
|
+
return `${scheme}[redacted]`;
|
|
7311
|
+
}).replace(/\b10(?:\.\d{1,3}){3}\b/g, "[redacted-ip]").replace(/\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b/g, "[redacted-ip]").replace(/\b192\.168(?:\.\d{1,3}){2}\b/g, "[redacted-ip]").replace(/\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\b/g, "[redacted-ip]").replace(/\b(password|passwd|token|secret|api[_-]?key)=([^&\s]+)/gi, "$1=[redacted]"));
|
|
7312
|
+
}
|
|
7313
|
+
function sanitizeValue(value, privateMetadata) {
|
|
7314
|
+
if (typeof value === "string")
|
|
7315
|
+
return sanitizePublicString(value, privateMetadata);
|
|
7316
|
+
if (Array.isArray(value))
|
|
7317
|
+
return value.map((entry) => sanitizeValue(entry, privateMetadata));
|
|
7318
|
+
if (value && typeof value === "object")
|
|
7319
|
+
return sanitizeRecord(value, privateMetadata);
|
|
7320
|
+
return value;
|
|
7321
|
+
}
|
|
7322
|
+
function sanitizeRecord(value, privateMetadata) {
|
|
7323
|
+
const sanitized = {};
|
|
7324
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
7325
|
+
if (!privateMetadata && /(hostname|hostName|user|username|serial|dnsName|ip|ips|databaseUrl|url|token|secret|password|credential)/i.test(key)) {
|
|
7326
|
+
sanitized[key] = "[redacted]";
|
|
7327
|
+
continue;
|
|
6847
7328
|
}
|
|
6848
|
-
|
|
7329
|
+
sanitized[key] = sanitizeValue(entry, privateMetadata);
|
|
6849
7330
|
}
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
return summary;
|
|
7331
|
+
return sanitized;
|
|
7332
|
+
}
|
|
7333
|
+
function sanitizeStorageError(message, privateMetadata) {
|
|
7334
|
+
return privateMetadata ? message : redactErrorMessage(message);
|
|
7335
|
+
}
|
|
7336
|
+
function getAgentStatus(machineId, options = {}) {
|
|
7337
|
+
return listHeartbeats(machineId).map((heartbeat) => heartbeatToStatus(heartbeat, options));
|
|
6858
7338
|
}
|
|
6859
7339
|
|
|
6860
|
-
// src/commands/
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
function quote4(value) {
|
|
6864
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7340
|
+
// src/commands/manifest.ts
|
|
7341
|
+
function manifestList() {
|
|
7342
|
+
return readManifest();
|
|
6865
7343
|
}
|
|
6866
|
-
function
|
|
6867
|
-
const
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
if (manager === "apt") {
|
|
6875
|
-
return `if dpkg -s ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
|
|
6876
|
-
}
|
|
6877
|
-
return `if command -v ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
|
|
7344
|
+
function manifestAdd(machine) {
|
|
7345
|
+
const validatedMachine = machineSchema.parse(machine);
|
|
7346
|
+
const manifest = readManifest();
|
|
7347
|
+
const nextMachines = manifest.machines.filter((entry) => entry.id !== validatedMachine.id);
|
|
7348
|
+
nextMachines.push(validatedMachine);
|
|
7349
|
+
const nextManifest = { ...manifest, machines: nextMachines };
|
|
7350
|
+
writeManifest(nextManifest);
|
|
7351
|
+
return nextManifest;
|
|
6878
7352
|
}
|
|
6879
|
-
function
|
|
6880
|
-
|
|
6881
|
-
return `bun install -g ${quote4(packageName)}`;
|
|
6882
|
-
}
|
|
6883
|
-
if (manager === "brew") {
|
|
6884
|
-
return `brew install ${quote4(packageName)}`;
|
|
6885
|
-
}
|
|
6886
|
-
if (manager === "apt") {
|
|
6887
|
-
return `sudo apt-get install -y ${quote4(packageName)}`;
|
|
6888
|
-
}
|
|
6889
|
-
return packageName;
|
|
7353
|
+
function manifestBootstrapCurrentMachine() {
|
|
7354
|
+
return manifestAdd(detectCurrentMachineManifest());
|
|
6890
7355
|
}
|
|
6891
|
-
function
|
|
6892
|
-
return (
|
|
6893
|
-
const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
|
|
6894
|
-
const check2 = runner(machine.id, packageCheckCommand(machine, pkg.name, manager));
|
|
6895
|
-
if (check2.exitCode !== 0) {
|
|
6896
|
-
throw new Error(describeMachineCommandFailure(`Sync package probe ${pkg.name}`, check2));
|
|
6897
|
-
}
|
|
6898
|
-
const installed = check2.stdout.split(`
|
|
6899
|
-
`).some((line) => line.trim() === "installed=1");
|
|
6900
|
-
return {
|
|
6901
|
-
id: `package-${index + 1}`,
|
|
6902
|
-
title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
|
|
6903
|
-
command: packageInstallCommand(machine, pkg.name, manager),
|
|
6904
|
-
status: installed ? "ok" : "missing",
|
|
6905
|
-
kind: "package"
|
|
6906
|
-
};
|
|
6907
|
-
});
|
|
7356
|
+
function manifestGet(machineId) {
|
|
7357
|
+
return getManifestMachine(machineId);
|
|
6908
7358
|
}
|
|
6909
|
-
function
|
|
6910
|
-
|
|
6911
|
-
|
|
7359
|
+
function manifestRemove(machineId) {
|
|
7360
|
+
const manifest = readManifest();
|
|
7361
|
+
const nextManifest = {
|
|
7362
|
+
...manifest,
|
|
7363
|
+
machines: manifest.machines.filter((machine) => machine.id !== machineId)
|
|
7364
|
+
};
|
|
7365
|
+
writeManifest(nextManifest);
|
|
7366
|
+
return nextManifest;
|
|
7367
|
+
}
|
|
7368
|
+
function manifestValidate() {
|
|
7369
|
+
return validateManifest(getManifestPath());
|
|
7370
|
+
}
|
|
7371
|
+
|
|
7372
|
+
// src/commands/status.ts
|
|
7373
|
+
function parseJsonObject2(value) {
|
|
7374
|
+
if (!value)
|
|
7375
|
+
return null;
|
|
7376
|
+
try {
|
|
7377
|
+
const parsed = JSON.parse(value);
|
|
7378
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
7379
|
+
} catch {
|
|
7380
|
+
return null;
|
|
6912
7381
|
}
|
|
6913
|
-
return (machine.files || []).map((file, index) => {
|
|
6914
|
-
const sourceExists = existsSync7(file.source);
|
|
6915
|
-
const targetExists = existsSync7(file.target);
|
|
6916
|
-
let status = "missing";
|
|
6917
|
-
if (sourceExists && targetExists) {
|
|
6918
|
-
if (file.mode === "symlink") {
|
|
6919
|
-
status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
|
|
6920
|
-
} else {
|
|
6921
|
-
const source = readFileSync5(file.source, "utf8");
|
|
6922
|
-
const target = readFileSync5(file.target, "utf8");
|
|
6923
|
-
status = source === target ? "ok" : "drifted";
|
|
6924
|
-
}
|
|
6925
|
-
}
|
|
6926
|
-
const command = file.mode === "symlink" ? `ln -sfn ${quote4(file.source)} ${quote4(file.target)}` : `cp ${quote4(file.source)} ${quote4(file.target)}`;
|
|
6927
|
-
return {
|
|
6928
|
-
id: `file-${index + 1}`,
|
|
6929
|
-
title: `${status === "ok" ? "File in sync" : "Reconcile file"} ${file.target}`,
|
|
6930
|
-
command,
|
|
6931
|
-
status,
|
|
6932
|
-
kind: "file"
|
|
6933
|
-
};
|
|
6934
|
-
});
|
|
6935
7382
|
}
|
|
6936
|
-
function
|
|
7383
|
+
function getStatus() {
|
|
6937
7384
|
const manifest = readManifest();
|
|
6938
|
-
const
|
|
6939
|
-
const
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
7385
|
+
const heartbeats = listHeartbeats();
|
|
7386
|
+
const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
|
|
7387
|
+
const machineIds = new Set([
|
|
7388
|
+
...manifest.machines.map((machine) => machine.id),
|
|
7389
|
+
...heartbeats.map((heartbeat) => heartbeat.machine_id)
|
|
7390
|
+
]);
|
|
7391
|
+
return {
|
|
7392
|
+
machineId: getLocalMachineId(),
|
|
7393
|
+
manifestPath: getManifestPath(),
|
|
7394
|
+
dbPath: getDbPath(),
|
|
7395
|
+
notificationsPath: getNotificationsPath(),
|
|
7396
|
+
manifestMachineCount: manifest.machines.length,
|
|
7397
|
+
heartbeatCount: heartbeats.length,
|
|
7398
|
+
machines: [...machineIds].sort().map((machineId) => {
|
|
7399
|
+
const declared = manifest.machines.find((machine) => machine.id === machineId);
|
|
7400
|
+
const heartbeat = heartbeatByMachine.get(machineId);
|
|
7401
|
+
return {
|
|
7402
|
+
machineId,
|
|
7403
|
+
platform: declared?.platform,
|
|
7404
|
+
manifestDeclared: Boolean(declared),
|
|
7405
|
+
heartbeatStatus: heartbeat?.status || "unknown",
|
|
7406
|
+
lastHeartbeatAt: heartbeat?.updated_at,
|
|
7407
|
+
daemonVersion: heartbeat?.daemon_version ?? null,
|
|
7408
|
+
agentMode: heartbeat?.agent_mode ?? null,
|
|
7409
|
+
storageSyncStatus: heartbeat?.storage_sync_status ?? null,
|
|
7410
|
+
doctorSummary: parseJsonObject2(heartbeat?.doctor_summary_json),
|
|
7411
|
+
privateMetadata: Boolean(heartbeat?.private_metadata)
|
|
7412
|
+
};
|
|
7413
|
+
}),
|
|
7414
|
+
recentSetupRuns: countRuns("setup_runs"),
|
|
7415
|
+
recentSyncRuns: countRuns("sync_runs")
|
|
6947
7416
|
};
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
7417
|
+
}
|
|
7418
|
+
|
|
7419
|
+
// src/commands/self-test.ts
|
|
7420
|
+
function check(id, status, summary, detail) {
|
|
7421
|
+
return { id, status, summary, detail };
|
|
7422
|
+
}
|
|
7423
|
+
function runSelfTest() {
|
|
7424
|
+
const version = getPackageVersion();
|
|
7425
|
+
const status = getStatus();
|
|
7426
|
+
const doctor = runDoctor();
|
|
7427
|
+
const serveInfo = getServeInfo();
|
|
7428
|
+
const html = renderDashboardHtml();
|
|
7429
|
+
const notifications = listNotificationChannels();
|
|
7430
|
+
const apps = listApps(status.machineId);
|
|
7431
|
+
const appsDiff = diffApps(status.machineId);
|
|
7432
|
+
const cliPlan = buildClaudeInstallPlan(status.machineId);
|
|
6952
7433
|
return {
|
|
6953
|
-
machineId:
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
7434
|
+
machineId: getLocalMachineId(),
|
|
7435
|
+
checks: [
|
|
7436
|
+
check("package-version", version === "0.0.0" ? "fail" : "ok", "Package version resolves", version),
|
|
7437
|
+
check("status", "ok", "Status loads", JSON.stringify({ machines: status.manifestMachineCount, heartbeats: status.heartbeatCount })),
|
|
7438
|
+
check("doctor", doctor.checks.some((entry) => entry.status === "fail") ? "warn" : "ok", "Doctor completed", `${doctor.checks.length} checks`),
|
|
7439
|
+
check("serve-info", "ok", "Dashboard info renders", `${serveInfo.url} routes=${serveInfo.routes.length}`),
|
|
7440
|
+
check("dashboard-html", html.includes("Machines Dashboard") ? "ok" : "fail", "Dashboard HTML renders", html.slice(0, 80)),
|
|
7441
|
+
check("notifications", "ok", "Notifications config loads", `${notifications.channels.length} channels`),
|
|
7442
|
+
check("apps", "ok", "Apps manifest loads", `${apps.apps.length} apps`),
|
|
7443
|
+
check("apps-diff", appsDiff.missing.length === 0 ? "ok" : "warn", "Apps diff computed", `missing=${appsDiff.missing.length} installed=${appsDiff.installed.length}`),
|
|
7444
|
+
check("install-claude-plan", cliPlan.steps.length > 0 ? "ok" : "warn", "Install plan renders", `${cliPlan.steps.length} steps`)
|
|
7445
|
+
]
|
|
6957
7446
|
};
|
|
6958
7447
|
}
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
copyFileSync(source.slice(1, -1), target.slice(1, -1));
|
|
6964
|
-
return;
|
|
6965
|
-
}
|
|
6966
|
-
if (verb === "ln" && source && target) {
|
|
6967
|
-
const sourcePath = command.match(/ln -sfn '(.+)' '(.+)'/)?.[1];
|
|
6968
|
-
const targetPath = command.match(/ln -sfn '(.+)' '(.+)'/)?.[2];
|
|
6969
|
-
if (!sourcePath || !targetPath) {
|
|
6970
|
-
throw new Error(`Unable to parse symlink command: ${command}`);
|
|
6971
|
-
}
|
|
6972
|
-
ensureParentDir(targetPath);
|
|
6973
|
-
try {
|
|
6974
|
-
Bun.file(targetPath).delete();
|
|
6975
|
-
} catch {}
|
|
6976
|
-
symlinkSync(sourcePath, targetPath);
|
|
6977
|
-
}
|
|
7448
|
+
|
|
7449
|
+
// src/commands/serve.ts
|
|
7450
|
+
function escapeHtml(value) {
|
|
7451
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
6978
7452
|
}
|
|
6979
|
-
function
|
|
6980
|
-
const
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
}
|
|
7007
|
-
executed += 1;
|
|
7008
|
-
}
|
|
7009
|
-
const summary = {
|
|
7010
|
-
machineId: plan.machineId,
|
|
7011
|
-
mode: "apply",
|
|
7012
|
-
actions: plan.actions,
|
|
7013
|
-
executed
|
|
7453
|
+
function getServeInfo(options = {}) {
|
|
7454
|
+
const host = options.host || "0.0.0.0";
|
|
7455
|
+
const port = options.port || 7676;
|
|
7456
|
+
return {
|
|
7457
|
+
host,
|
|
7458
|
+
port,
|
|
7459
|
+
url: `http://${host}:${port}`,
|
|
7460
|
+
routes: [
|
|
7461
|
+
"/",
|
|
7462
|
+
"/health",
|
|
7463
|
+
"/api/status",
|
|
7464
|
+
"/api/topology",
|
|
7465
|
+
"/api/routes",
|
|
7466
|
+
"/api/daemon/status",
|
|
7467
|
+
"/api/manifest",
|
|
7468
|
+
"/api/notifications",
|
|
7469
|
+
"/api/webhooks",
|
|
7470
|
+
"/api/events",
|
|
7471
|
+
"/api/doctor",
|
|
7472
|
+
"/api/self-test",
|
|
7473
|
+
"/api/apps/status",
|
|
7474
|
+
"/api/apps/diff",
|
|
7475
|
+
"/api/install-claude/status",
|
|
7476
|
+
"/api/install-claude/diff",
|
|
7477
|
+
"/api/notifications/test",
|
|
7478
|
+
"/api/webhooks/test"
|
|
7479
|
+
]
|
|
7014
7480
|
};
|
|
7015
|
-
recordSyncRun(plan.machineId, "completed", summary);
|
|
7016
|
-
return summary;
|
|
7017
7481
|
}
|
|
7482
|
+
function renderDashboardHtml() {
|
|
7483
|
+
const status = getStatus();
|
|
7484
|
+
const topology = discoverMachineTopology();
|
|
7485
|
+
const manifest = manifestList();
|
|
7486
|
+
const notifications = listNotificationChannels();
|
|
7487
|
+
const doctor = runDoctor();
|
|
7488
|
+
return `<!doctype html>
|
|
7489
|
+
<html lang="en">
|
|
7490
|
+
<head>
|
|
7491
|
+
<meta charset="utf-8" />
|
|
7492
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7493
|
+
<title>Machines Dashboard</title>
|
|
7494
|
+
<style>
|
|
7495
|
+
:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
|
|
7496
|
+
body { margin: 0; background: #0b1020; color: #e5ecff; }
|
|
7497
|
+
main { max-width: 1120px; margin: 0 auto; padding: 32px 20px 48px; }
|
|
7498
|
+
h1, h2 { margin: 0 0 16px; }
|
|
7499
|
+
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
|
7500
|
+
.card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
|
|
7501
|
+
.stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
|
|
7502
|
+
table { width: 100%; border-collapse: collapse; }
|
|
7503
|
+
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; vertical-align: top; }
|
|
7504
|
+
code { color: #9ed0ff; }
|
|
7505
|
+
.badge { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
|
|
7506
|
+
.online, .ok { background: #12351f; color: #74f0a7; }
|
|
7507
|
+
.offline, .fail { background: #3b1a1a; color: #ff8c8c; }
|
|
7508
|
+
.unknown, .warn { background: #2f2b16; color: #ffd76a; }
|
|
7509
|
+
ul { margin: 8px 0 0; padding-left: 18px; }
|
|
7510
|
+
.muted { color: #9fb0d9; }
|
|
7511
|
+
.refresh { font-size: 12px; color: #6b7fa3; margin-left: auto; }
|
|
7512
|
+
.updated { transition: opacity 0.3s; }
|
|
7513
|
+
</style>
|
|
7514
|
+
</head>
|
|
7515
|
+
<body>
|
|
7516
|
+
<main>
|
|
7517
|
+
<h1>Machines Dashboard <span class="refresh" id="last-updated"></span></h1>
|
|
7518
|
+
<div class="grid">
|
|
7519
|
+
<section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
|
|
7520
|
+
<section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
|
|
7521
|
+
<section class="card"><div>Notification channels</div><div class="stat">${notifications.channels.length}</div></section>
|
|
7522
|
+
<section class="card"><div>Doctor warnings</div><div class="stat">${doctor.checks.filter((entry) => entry.status !== "ok").length}</div></section>
|
|
7523
|
+
<section class="card"><div>Tailscale routes</div><div class="stat">${topology.machines.filter((machine) => machine.ssh.route === "tailscale").length}</div></section>
|
|
7524
|
+
</div>
|
|
7525
|
+
|
|
7526
|
+
<section class="card" style="margin-top:16px">
|
|
7527
|
+
<h2>Machines</h2>
|
|
7528
|
+
<table>
|
|
7529
|
+
<thead><tr><th>ID</th><th>Platform</th><th>Status</th><th>Agent</th><th>Storage</th><th>Last heartbeat</th></tr></thead>
|
|
7530
|
+
<tbody>
|
|
7531
|
+
${status.machines.map((machine) => `<tr>
|
|
7532
|
+
<td><code>${escapeHtml(machine.machineId)}</code></td>
|
|
7533
|
+
<td>${escapeHtml(machine.platform || "unknown")}</td>
|
|
7534
|
+
<td><span class="badge ${escapeHtml(machine.heartbeatStatus)}">${escapeHtml(machine.heartbeatStatus)}</span></td>
|
|
7535
|
+
<td>${escapeHtml(machine.agentMode || "unknown")} ${escapeHtml(machine.daemonVersion || "")}</td>
|
|
7536
|
+
<td>${escapeHtml(machine.storageSyncStatus || "unknown")}</td>
|
|
7537
|
+
<td>${escapeHtml(machine.lastHeartbeatAt || "\u2014")}</td>
|
|
7538
|
+
</tr>`).join("")}
|
|
7539
|
+
</tbody>
|
|
7540
|
+
</table>
|
|
7541
|
+
</section>
|
|
7542
|
+
|
|
7543
|
+
<section class="card" style="margin-top:16px">
|
|
7544
|
+
<h2>Doctor</h2>
|
|
7545
|
+
<table>
|
|
7546
|
+
<thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
|
|
7547
|
+
<tbody id="doctor-tbody">
|
|
7548
|
+
${doctor.checks.map((entry) => `<tr>
|
|
7549
|
+
<td>${escapeHtml(entry.summary)}</td>
|
|
7550
|
+
<td><span class="badge ${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</span></td>
|
|
7551
|
+
<td class="muted">${escapeHtml(entry.detail)}</td>
|
|
7552
|
+
</tr>`).join("")}
|
|
7553
|
+
</tbody>
|
|
7554
|
+
</table>
|
|
7555
|
+
</section>
|
|
7556
|
+
|
|
7557
|
+
<section class="card" style="margin-top:16px">
|
|
7558
|
+
<h2>Apps</h2>
|
|
7559
|
+
<p class="muted">Use <code>/api/apps/status</code> for the full app inventory payload.</p>
|
|
7560
|
+
</section>
|
|
7561
|
+
|
|
7562
|
+
<section class="card" style="margin-top:16px">
|
|
7563
|
+
<h2>AI CLIs</h2>
|
|
7564
|
+
<p class="muted">Use <code>/api/install-claude/status</code> for the full CLI inventory payload.</p>
|
|
7565
|
+
</section>
|
|
7566
|
+
|
|
7567
|
+
<section class="card" style="margin-top:16px">
|
|
7568
|
+
<h2>Self Test</h2>
|
|
7569
|
+
<p class="muted">Use <code>/api/self-test</code> for the full smoke-check payload.</p>
|
|
7570
|
+
</section>
|
|
7571
|
+
|
|
7572
|
+
<section class="card" style="margin-top:16px">
|
|
7573
|
+
<h2>Manifest</h2>
|
|
7574
|
+
<pre id="manifest-json">${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
|
|
7575
|
+
</section>
|
|
7576
|
+
</main>
|
|
7577
|
+
<script>
|
|
7578
|
+
// Auto-refresh dashboard data every 15s
|
|
7579
|
+
const REFRESH_INTERVAL = 15000;
|
|
7580
|
+
function escapeHtml(value) {
|
|
7581
|
+
return String(value ?? "")
|
|
7582
|
+
.replaceAll("&", "&")
|
|
7583
|
+
.replaceAll("<", "<")
|
|
7584
|
+
.replaceAll(">", ">")
|
|
7585
|
+
.replaceAll('"', """)
|
|
7586
|
+
.replaceAll("'", "'");
|
|
7587
|
+
}
|
|
7588
|
+
async function refreshData() {
|
|
7589
|
+
try {
|
|
7590
|
+
const [statusRes, doctorRes] = await Promise.all([
|
|
7591
|
+
fetch("/api/status"),
|
|
7592
|
+
fetch("/api/doctor"),
|
|
7593
|
+
]);
|
|
7594
|
+
const status = await statusRes.json();
|
|
7595
|
+
const doctor = await doctorRes.json();
|
|
7596
|
+
|
|
7597
|
+
// Update stat cards
|
|
7598
|
+
const stats = document.querySelectorAll(".stat");
|
|
7599
|
+
if (stats[0]) stats[0].textContent = status.manifestMachineCount;
|
|
7600
|
+
if (stats[1]) stats[1].textContent = status.heartbeatCount;
|
|
7601
|
+
|
|
7602
|
+
// Update machine table
|
|
7603
|
+
const tbody = document.querySelector("tbody");
|
|
7604
|
+
if (tbody && status.machines) {
|
|
7605
|
+
tbody.innerHTML = status.machines
|
|
7606
|
+
.map((m) =>
|
|
7607
|
+
"<tr>" +
|
|
7608
|
+
"<td><code>" + escapeHtml(m.machineId) + "</code></td>" +
|
|
7609
|
+
"<td>" + escapeHtml(m.platform || "unknown") + "</td>" +
|
|
7610
|
+
'<td><span class="badge ' + escapeHtml(m.heartbeatStatus) + '">' + escapeHtml(m.heartbeatStatus) + '</span></td>' +
|
|
7611
|
+
"<td>" + escapeHtml(m.agentMode || "unknown") + " " + escapeHtml(m.daemonVersion || "") + "</td>" +
|
|
7612
|
+
"<td>" + escapeHtml(m.storageSyncStatus || "unknown") + "</td>" +
|
|
7613
|
+
"<td>" + escapeHtml(m.lastHeartbeatAt || "\\u2014") + "</td>" +
|
|
7614
|
+
"</tr>"
|
|
7615
|
+
)
|
|
7616
|
+
.join("");
|
|
7617
|
+
}
|
|
7618
|
+
|
|
7619
|
+
// Update doctor table
|
|
7620
|
+
const doctorTbody = document.getElementById("doctor-tbody");
|
|
7621
|
+
if (doctorTbody && doctor.checks) {
|
|
7622
|
+
doctorTbody.innerHTML = doctor.checks
|
|
7623
|
+
.map((c) =>
|
|
7624
|
+
"<tr>" +
|
|
7625
|
+
"<td>" + escapeHtml(c.summary) + "</td>" +
|
|
7626
|
+
'<td><span class="badge ' + escapeHtml(c.status) + '">' + escapeHtml(c.status) + '</span></td>' +
|
|
7627
|
+
'<td class="muted">' + escapeHtml(c.detail) + "</td>" +
|
|
7628
|
+
"</tr>"
|
|
7629
|
+
)
|
|
7630
|
+
.join("");
|
|
7631
|
+
}
|
|
7018
7632
|
|
|
7019
|
-
//
|
|
7020
|
-
|
|
7021
|
-
|
|
7022
|
-
|
|
7023
|
-
|
|
7024
|
-
|
|
7025
|
-
|
|
7026
|
-
|
|
7633
|
+
// Update timestamp
|
|
7634
|
+
document.getElementById("last-updated").textContent =
|
|
7635
|
+
"updated " + new Date().toLocaleTimeString();
|
|
7636
|
+
} catch (e) {
|
|
7637
|
+
// Silently ignore fetch errors during page unload
|
|
7638
|
+
}
|
|
7639
|
+
}
|
|
7640
|
+
document.getElementById("last-updated").textContent =
|
|
7641
|
+
"updated " + new Date().toLocaleTimeString();
|
|
7642
|
+
setInterval(refreshData, REFRESH_INTERVAL);
|
|
7643
|
+
</script>
|
|
7644
|
+
</body>
|
|
7645
|
+
</html>`;
|
|
7027
7646
|
}
|
|
7028
7647
|
|
|
7029
|
-
// src/
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
{
|
|
7033
|
-
];
|
|
7034
|
-
function defaultPackages() {
|
|
7035
|
-
return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
|
|
7036
|
-
}
|
|
7037
|
-
function shellQuote6(value) {
|
|
7038
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
7039
|
-
}
|
|
7040
|
-
function commandId(value) {
|
|
7041
|
-
return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
7042
|
-
}
|
|
7043
|
-
function packageCommand(name) {
|
|
7044
|
-
if (name === "@hasna/knowledge")
|
|
7045
|
-
return "knowledge";
|
|
7046
|
-
if (name === "@hasna/machines")
|
|
7047
|
-
return "machines";
|
|
7048
|
-
return name.split("/").pop() ?? name;
|
|
7049
|
-
}
|
|
7050
|
-
function firstLine(value) {
|
|
7051
|
-
return value.trim().split(/\r?\n/).find(Boolean) ?? "";
|
|
7052
|
-
}
|
|
7053
|
-
function extractVersion(value) {
|
|
7054
|
-
const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
|
|
7055
|
-
return match?.[0] ?? null;
|
|
7056
|
-
}
|
|
7057
|
-
function statusFor(required, ok) {
|
|
7058
|
-
if (ok)
|
|
7059
|
-
return "ok";
|
|
7060
|
-
return required === false ? "warn" : "fail";
|
|
7061
|
-
}
|
|
7062
|
-
function makeCheck2(input) {
|
|
7063
|
-
return {
|
|
7064
|
-
id: input.id,
|
|
7065
|
-
kind: input.kind,
|
|
7066
|
-
status: input.status,
|
|
7067
|
-
target: input.target,
|
|
7068
|
-
expected: input.expected ?? null,
|
|
7069
|
-
actual: input.actual ?? null,
|
|
7070
|
-
detail: input.detail,
|
|
7071
|
-
source: input.source
|
|
7072
|
-
};
|
|
7073
|
-
}
|
|
7074
|
-
function parseKeyValue(stdout) {
|
|
7075
|
-
const result = {};
|
|
7076
|
-
for (const line of stdout.split(/\r?\n/)) {
|
|
7077
|
-
const idx = line.indexOf("=");
|
|
7078
|
-
if (idx <= 0)
|
|
7079
|
-
continue;
|
|
7080
|
-
result[line.slice(0, idx)] = line.slice(idx + 1);
|
|
7081
|
-
}
|
|
7082
|
-
return result;
|
|
7083
|
-
}
|
|
7084
|
-
function defaultRunner2(machineId, command) {
|
|
7085
|
-
return runMachineCommand(machineId, command);
|
|
7086
|
-
}
|
|
7087
|
-
function inspectCommand(machineId, spec, runner) {
|
|
7088
|
-
const command = shellQuote6(spec.command);
|
|
7089
|
-
const versionArgs = spec.versionArgs ?? "--version";
|
|
7090
|
-
const script = [
|
|
7091
|
-
`cmd=${command}`,
|
|
7092
|
-
'path="$(command -v "$cmd" 2>/dev/null || true)"',
|
|
7093
|
-
'printf "path=%s\\n" "$path"',
|
|
7094
|
-
'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
|
|
7095
|
-
].join("; ");
|
|
7096
|
-
const result = runner(machineId, script);
|
|
7097
|
-
const parsed = parseKeyValue(result.stdout);
|
|
7098
|
-
return {
|
|
7099
|
-
path: parsed.path || null,
|
|
7100
|
-
version: parsed.version ? firstLine(parsed.version) : null,
|
|
7101
|
-
exitCode: result.exitCode,
|
|
7102
|
-
source: result.source,
|
|
7103
|
-
stderr: result.stderr
|
|
7104
|
-
};
|
|
7105
|
-
}
|
|
7106
|
-
function fieldCommand(field) {
|
|
7107
|
-
const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
7108
|
-
return [
|
|
7109
|
-
`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`,
|
|
7110
|
-
`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`,
|
|
7111
|
-
`else sed -n '${regex}' "$pkg" | head -n 1`,
|
|
7112
|
-
"fi"
|
|
7113
|
-
].join("; ");
|
|
7114
|
-
}
|
|
7115
|
-
function inspectWorkspace(machineId, spec, runner) {
|
|
7116
|
-
const script = [
|
|
7117
|
-
`path=${shellQuote6(spec.path)}`,
|
|
7118
|
-
'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
|
|
7119
|
-
'pkg="$path/package.json"',
|
|
7120
|
-
'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
|
|
7121
|
-
`if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
|
|
7122
|
-
].join("; ");
|
|
7123
|
-
const result = runner(machineId, script);
|
|
7124
|
-
const parsed = parseKeyValue(result.stdout);
|
|
7125
|
-
return {
|
|
7126
|
-
exists: parsed.exists === "yes",
|
|
7127
|
-
packageJson: parsed.package_json === "yes",
|
|
7128
|
-
packageName: parsed.package_name || null,
|
|
7129
|
-
version: parsed.version || null,
|
|
7130
|
-
source: result.source,
|
|
7131
|
-
stderr: result.stderr
|
|
7132
|
-
};
|
|
7648
|
+
// src/commands/setup.ts
|
|
7649
|
+
import { homedir as homedir4 } from "os";
|
|
7650
|
+
function quote3(value) {
|
|
7651
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7133
7652
|
}
|
|
7134
|
-
function
|
|
7135
|
-
const
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
}
|
|
7653
|
+
function buildBaseSteps(machine) {
|
|
7654
|
+
const steps = [
|
|
7655
|
+
{
|
|
7656
|
+
id: "workspace",
|
|
7657
|
+
title: "Ensure workspace directory exists",
|
|
7658
|
+
command: `mkdir -p ${quote3(machine.workspacePath)}`,
|
|
7659
|
+
manager: "shell"
|
|
7660
|
+
},
|
|
7661
|
+
{
|
|
7662
|
+
id: "bun",
|
|
7663
|
+
title: "Install Bun if missing",
|
|
7664
|
+
command: "command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash",
|
|
7665
|
+
manager: "shell"
|
|
7666
|
+
}
|
|
7148
7667
|
];
|
|
7149
|
-
if (
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7668
|
+
if (machine.platform === "linux") {
|
|
7669
|
+
steps.push({
|
|
7670
|
+
id: "apt-base",
|
|
7671
|
+
title: "Install core Linux tooling",
|
|
7672
|
+
command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
|
|
7673
|
+
manager: "apt",
|
|
7674
|
+
privileged: true
|
|
7675
|
+
});
|
|
7676
|
+
steps.push({
|
|
7677
|
+
id: "linux-update-downloads",
|
|
7678
|
+
title: "Enable Linux package list refresh and download-only upgrades",
|
|
7679
|
+
command: `printf '%s\\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Download-Upgradeable-Packages "1";' 'APT::Periodic::Unattended-Upgrade "0";' | sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null`,
|
|
7680
|
+
manager: "apt",
|
|
7681
|
+
privileged: true
|
|
7682
|
+
});
|
|
7683
|
+
} else if (machine.platform === "macos") {
|
|
7684
|
+
steps.push({
|
|
7685
|
+
id: "brew-base",
|
|
7686
|
+
title: "Install Homebrew if missing",
|
|
7687
|
+
command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
|
7688
|
+
manager: "brew"
|
|
7689
|
+
});
|
|
7690
|
+
steps.push({
|
|
7691
|
+
id: "brew-core",
|
|
7692
|
+
title: "Install core macOS tooling",
|
|
7693
|
+
command: "brew install git coreutils",
|
|
7694
|
+
manager: "brew"
|
|
7695
|
+
});
|
|
7696
|
+
steps.push({
|
|
7697
|
+
id: "macos-update-downloads",
|
|
7698
|
+
title: "Enable macOS update checks and downloads without automatic install",
|
|
7699
|
+
command: "sudo softwareupdate --schedule on && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool true && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -int 1 && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -int 0",
|
|
7700
|
+
manager: "custom",
|
|
7701
|
+
privileged: true
|
|
7702
|
+
});
|
|
7703
|
+
steps.push({
|
|
7704
|
+
id: "macos-management-readiness",
|
|
7705
|
+
title: "Report Apple management readiness without enrolling devices",
|
|
7706
|
+
command: "profiles status -type enrollment 2>/dev/null || true",
|
|
7707
|
+
manager: "custom"
|
|
7708
|
+
});
|
|
7161
7709
|
}
|
|
7162
|
-
|
|
7710
|
+
steps.push({
|
|
7711
|
+
id: "github-app-auth-readiness",
|
|
7712
|
+
title: "Check GitHub CLI/App auth readiness without printing credentials",
|
|
7713
|
+
command: "command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 || true",
|
|
7714
|
+
manager: "custom"
|
|
7715
|
+
});
|
|
7716
|
+
return steps;
|
|
7163
7717
|
}
|
|
7164
|
-
function
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
source: inspection.source
|
|
7191
|
-
}));
|
|
7718
|
+
function buildPackageSteps(machine) {
|
|
7719
|
+
return (machine.packages || []).map((pkg, index) => {
|
|
7720
|
+
const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
|
|
7721
|
+
let command2 = pkg.name;
|
|
7722
|
+
if (manager === "bun") {
|
|
7723
|
+
command2 = `bun install -g ${quote3(pkg.name)}`;
|
|
7724
|
+
} else if (manager === "brew") {
|
|
7725
|
+
command2 = `brew install ${quote3(pkg.name)}`;
|
|
7726
|
+
} else if (manager === "apt") {
|
|
7727
|
+
command2 = `sudo apt-get install -y ${quote3(pkg.name)}`;
|
|
7728
|
+
}
|
|
7729
|
+
return {
|
|
7730
|
+
id: `package-${index + 1}`,
|
|
7731
|
+
title: `Install package ${pkg.name}`,
|
|
7732
|
+
command: command2,
|
|
7733
|
+
manager,
|
|
7734
|
+
privileged: manager === "apt"
|
|
7735
|
+
};
|
|
7736
|
+
});
|
|
7737
|
+
}
|
|
7738
|
+
function buildSetupPlan(machineId) {
|
|
7739
|
+
const manifest = readManifest();
|
|
7740
|
+
const currentMachineId = getLocalMachineId();
|
|
7741
|
+
const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
|
|
7742
|
+
if (machineId && !selected) {
|
|
7743
|
+
throw new Error(`Machine not found in manifest: ${machineId}`);
|
|
7192
7744
|
}
|
|
7193
|
-
|
|
7745
|
+
const target = selected || {
|
|
7746
|
+
id: currentMachineId,
|
|
7747
|
+
platform: "linux",
|
|
7748
|
+
workspacePath: `${homedir4()}/workspace`
|
|
7749
|
+
};
|
|
7750
|
+
const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
|
|
7751
|
+
return {
|
|
7752
|
+
machineId: target.id,
|
|
7753
|
+
mode: "plan",
|
|
7754
|
+
steps,
|
|
7755
|
+
executed: 0
|
|
7756
|
+
};
|
|
7194
7757
|
}
|
|
7195
|
-
function
|
|
7196
|
-
const
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
makeCheck2({
|
|
7200
|
-
id: `workspace:${commandId(target)}:path`,
|
|
7201
|
-
kind: "workspace",
|
|
7202
|
-
status: statusFor(spec.required, inspection.exists),
|
|
7203
|
-
target,
|
|
7204
|
-
expected: spec.path,
|
|
7205
|
-
actual: inspection.exists ? "exists" : "missing",
|
|
7206
|
-
detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
|
|
7207
|
-
source: inspection.source
|
|
7208
|
-
})
|
|
7209
|
-
];
|
|
7210
|
-
if (spec.expectedPackageName) {
|
|
7211
|
-
checks.push(makeCheck2({
|
|
7212
|
-
id: `workspace:${commandId(target)}:package-name`,
|
|
7213
|
-
kind: "workspace",
|
|
7214
|
-
status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
|
|
7215
|
-
target,
|
|
7216
|
-
expected: spec.expectedPackageName,
|
|
7217
|
-
actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
|
|
7218
|
-
detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
|
|
7219
|
-
source: inspection.source
|
|
7220
|
-
}));
|
|
7758
|
+
function runSetup(machineId, options = {}, runner = runMachineCommand) {
|
|
7759
|
+
const plan = buildSetupPlan(machineId);
|
|
7760
|
+
if (!options.apply) {
|
|
7761
|
+
return plan;
|
|
7221
7762
|
}
|
|
7222
|
-
if (
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7763
|
+
if (!options.yes) {
|
|
7764
|
+
throw new Error("Setup execution requires --yes.");
|
|
7765
|
+
}
|
|
7766
|
+
let executed = 0;
|
|
7767
|
+
for (const step of plan.steps) {
|
|
7768
|
+
const result = runner(plan.machineId, step.command);
|
|
7769
|
+
if (result.exitCode !== 0) {
|
|
7770
|
+
recordSetupRun(plan.machineId, "failed", {
|
|
7771
|
+
executed,
|
|
7772
|
+
failedStep: step,
|
|
7773
|
+
stderr: result.stderr,
|
|
7774
|
+
stdout: result.stdout,
|
|
7775
|
+
exitCode: result.exitCode,
|
|
7776
|
+
source: result.source
|
|
7777
|
+
});
|
|
7778
|
+
throw new Error(describeMachineCommandFailure(`Setup step ${step.id}`, result));
|
|
7779
|
+
}
|
|
7780
|
+
executed += 1;
|
|
7233
7781
|
}
|
|
7234
|
-
return checks;
|
|
7235
|
-
}
|
|
7236
|
-
function checkMachineCompatibility(options = {}) {
|
|
7237
|
-
const machineId = options.machineId ?? getLocalMachineId();
|
|
7238
|
-
const runner = options.runner ?? defaultRunner2;
|
|
7239
|
-
const commands = options.commands ?? DEFAULT_COMMANDS;
|
|
7240
|
-
const packages = options.packages ?? defaultPackages();
|
|
7241
|
-
const workspaces = options.workspaces ?? [];
|
|
7242
|
-
const checks = [];
|
|
7243
|
-
for (const spec of commands)
|
|
7244
|
-
checks.push(...commandCheck(machineId, spec, runner));
|
|
7245
|
-
for (const spec of packages)
|
|
7246
|
-
checks.push(...packageCheck(machineId, spec, runner));
|
|
7247
|
-
for (const spec of workspaces)
|
|
7248
|
-
checks.push(...workspaceCheck(machineId, spec, runner));
|
|
7249
7782
|
const summary = {
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
return {
|
|
7255
|
-
schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
|
|
7256
|
-
package: {
|
|
7257
|
-
name: MACHINES_PACKAGE_NAME,
|
|
7258
|
-
version: getPackageVersion()
|
|
7259
|
-
},
|
|
7260
|
-
capabilities: getMachinesConsumerCapabilities(),
|
|
7261
|
-
ok: summary.fail === 0,
|
|
7262
|
-
machine_id: machineId,
|
|
7263
|
-
source: checks[0]?.source ?? "local",
|
|
7264
|
-
generated_at: (options.now ?? new Date).toISOString(),
|
|
7265
|
-
checks,
|
|
7266
|
-
summary
|
|
7783
|
+
machineId: plan.machineId,
|
|
7784
|
+
mode: "apply",
|
|
7785
|
+
steps: plan.steps,
|
|
7786
|
+
executed
|
|
7267
7787
|
};
|
|
7788
|
+
recordSetupRun(plan.machineId, "completed", summary);
|
|
7789
|
+
return summary;
|
|
7268
7790
|
}
|
|
7269
7791
|
|
|
7270
|
-
// src/
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
pid INTEGER NOT NULL,
|
|
7276
|
-
status TEXT NOT NULL,
|
|
7277
|
-
updated_at TIMESTAMPTZ NOT NULL,
|
|
7278
|
-
PRIMARY KEY (machine_id, pid)
|
|
7279
|
-
);
|
|
7280
|
-
|
|
7281
|
-
CREATE TABLE IF NOT EXISTS setup_runs (
|
|
7282
|
-
id TEXT PRIMARY KEY,
|
|
7283
|
-
machine_id TEXT NOT NULL,
|
|
7284
|
-
status TEXT NOT NULL,
|
|
7285
|
-
details_json TEXT NOT NULL DEFAULT '[]',
|
|
7286
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
7287
|
-
updated_at TIMESTAMPTZ NOT NULL
|
|
7288
|
-
);
|
|
7289
|
-
|
|
7290
|
-
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
7291
|
-
id TEXT PRIMARY KEY,
|
|
7292
|
-
machine_id TEXT NOT NULL,
|
|
7293
|
-
status TEXT NOT NULL,
|
|
7294
|
-
actions_json TEXT NOT NULL DEFAULT '[]',
|
|
7295
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
7296
|
-
updated_at TIMESTAMPTZ NOT NULL
|
|
7297
|
-
);
|
|
7298
|
-
`
|
|
7299
|
-
];
|
|
7300
|
-
|
|
7301
|
-
// src/remote-storage.ts
|
|
7302
|
-
import pg from "pg";
|
|
7303
|
-
function translatePlaceholders(sql) {
|
|
7304
|
-
let index = 0;
|
|
7305
|
-
return sql.replace(/\?/g, () => `$${++index}`);
|
|
7306
|
-
}
|
|
7307
|
-
function normalizeParams(params) {
|
|
7308
|
-
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
7309
|
-
return flat.map((value) => value === undefined ? null : value);
|
|
7310
|
-
}
|
|
7311
|
-
function sslConfigFor(connectionString) {
|
|
7312
|
-
return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
7792
|
+
// src/commands/sync.ts
|
|
7793
|
+
import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
|
|
7794
|
+
import { homedir as homedir5 } from "os";
|
|
7795
|
+
function quote4(value) {
|
|
7796
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7313
7797
|
}
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
7798
|
+
function packageCheckCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
|
|
7799
|
+
const quotedPackageName = quote4(packageName);
|
|
7800
|
+
if (manager === "bun") {
|
|
7801
|
+
return `if bun pm ls -g --all 2>/dev/null | grep -F ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
|
|
7319
7802
|
}
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
return { changes: result.rowCount ?? 0 };
|
|
7803
|
+
if (manager === "brew") {
|
|
7804
|
+
return `if brew list --versions ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
|
|
7323
7805
|
}
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
return result.rows;
|
|
7806
|
+
if (manager === "apt") {
|
|
7807
|
+
return `if dpkg -s ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
|
|
7327
7808
|
}
|
|
7328
|
-
|
|
7329
|
-
|
|
7809
|
+
return `if command -v ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
|
|
7810
|
+
}
|
|
7811
|
+
function packageInstallCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
|
|
7812
|
+
if (manager === "bun") {
|
|
7813
|
+
return `bun install -g ${quote4(packageName)}`;
|
|
7814
|
+
}
|
|
7815
|
+
if (manager === "brew") {
|
|
7816
|
+
return `brew install ${quote4(packageName)}`;
|
|
7817
|
+
}
|
|
7818
|
+
if (manager === "apt") {
|
|
7819
|
+
return `sudo apt-get install -y ${quote4(packageName)}`;
|
|
7330
7820
|
}
|
|
7821
|
+
return packageName;
|
|
7331
7822
|
}
|
|
7332
|
-
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
};
|
|
7349
|
-
function readEnv2(name) {
|
|
7350
|
-
const value = process.env[name]?.trim();
|
|
7351
|
-
return value || undefined;
|
|
7823
|
+
function detectPackageActions(machine, runner) {
|
|
7824
|
+
return (machine.packages || []).map((pkg, index) => {
|
|
7825
|
+
const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
|
|
7826
|
+
const check2 = runner(machine.id, packageCheckCommand(machine, pkg.name, manager));
|
|
7827
|
+
if (check2.exitCode !== 0) {
|
|
7828
|
+
throw new Error(describeMachineCommandFailure(`Sync package probe ${pkg.name}`, check2));
|
|
7829
|
+
}
|
|
7830
|
+
const installed = check2.stdout.split(`
|
|
7831
|
+
`).some((line) => line.trim() === "installed=1");
|
|
7832
|
+
return {
|
|
7833
|
+
id: `package-${index + 1}`,
|
|
7834
|
+
title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
|
|
7835
|
+
command: packageInstallCommand(machine, pkg.name, manager),
|
|
7836
|
+
status: installed ? "ok" : "missing",
|
|
7837
|
+
kind: "package"
|
|
7838
|
+
};
|
|
7839
|
+
});
|
|
7352
7840
|
}
|
|
7353
|
-
function
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
return
|
|
7841
|
+
function detectFileActions(machine) {
|
|
7842
|
+
if ((machine.files || []).length > 0 && resolveMachineCommand(machine.id, "true").source !== "local") {
|
|
7843
|
+
throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
|
|
7844
|
+
}
|
|
7845
|
+
return (machine.files || []).map((file, index) => {
|
|
7846
|
+
const sourceExists = existsSync7(file.source);
|
|
7847
|
+
const targetExists = existsSync7(file.target);
|
|
7848
|
+
let status = "missing";
|
|
7849
|
+
if (sourceExists && targetExists) {
|
|
7850
|
+
if (file.mode === "symlink") {
|
|
7851
|
+
status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
|
|
7852
|
+
} else {
|
|
7853
|
+
const source = readFileSync5(file.source, "utf8");
|
|
7854
|
+
const target = readFileSync5(file.target, "utf8");
|
|
7855
|
+
status = source === target ? "ok" : "drifted";
|
|
7856
|
+
}
|
|
7857
|
+
}
|
|
7858
|
+
const command2 = file.mode === "symlink" ? `ln -sfn ${quote4(file.source)} ${quote4(file.target)}` : `cp ${quote4(file.source)} ${quote4(file.target)}`;
|
|
7859
|
+
return {
|
|
7860
|
+
id: `file-${index + 1}`,
|
|
7861
|
+
title: `${status === "ok" ? "File in sync" : "Reconcile file"} ${file.target}`,
|
|
7862
|
+
command: command2,
|
|
7863
|
+
status,
|
|
7864
|
+
kind: "file"
|
|
7865
|
+
};
|
|
7866
|
+
});
|
|
7358
7867
|
}
|
|
7359
|
-
function
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7868
|
+
function buildSyncPlan(machineId, runner = runMachineCommand) {
|
|
7869
|
+
const manifest = readManifest();
|
|
7870
|
+
const currentMachineId = getLocalMachineId();
|
|
7871
|
+
const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
|
|
7872
|
+
if (machineId && !selected) {
|
|
7873
|
+
throw new Error(`Machine not found in manifest: ${machineId}`);
|
|
7363
7874
|
}
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7875
|
+
const target = selected || {
|
|
7876
|
+
id: currentMachineId,
|
|
7877
|
+
platform: "linux",
|
|
7878
|
+
workspacePath: `${homedir5()}/workspace`
|
|
7879
|
+
};
|
|
7880
|
+
const actions = [
|
|
7881
|
+
...detectPackageActions(target, runner),
|
|
7882
|
+
...detectFileActions(target)
|
|
7883
|
+
];
|
|
7884
|
+
return {
|
|
7885
|
+
machineId: target.id,
|
|
7886
|
+
mode: "plan",
|
|
7887
|
+
actions,
|
|
7888
|
+
executed: 0
|
|
7889
|
+
};
|
|
7379
7890
|
}
|
|
7380
|
-
|
|
7381
|
-
const
|
|
7382
|
-
if (
|
|
7383
|
-
|
|
7891
|
+
function applyFileAction(command2) {
|
|
7892
|
+
const [verb, source, target] = command2.split(" ");
|
|
7893
|
+
if (verb === "cp" && source && target) {
|
|
7894
|
+
ensureParentDir(target);
|
|
7895
|
+
copyFileSync(source.slice(1, -1), target.slice(1, -1));
|
|
7896
|
+
return;
|
|
7384
7897
|
}
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
}
|
|
7391
|
-
async function storagePush(options) {
|
|
7392
|
-
const remote = await getStoragePg();
|
|
7393
|
-
const db = getDb();
|
|
7394
|
-
try {
|
|
7395
|
-
await runStorageMigrations(remote);
|
|
7396
|
-
const results = [];
|
|
7397
|
-
for (const table of resolveTables(options?.tables)) {
|
|
7398
|
-
results.push(await pushTable(db, remote, table));
|
|
7898
|
+
if (verb === "ln" && source && target) {
|
|
7899
|
+
const sourcePath = command2.match(/ln -sfn '(.+)' '(.+)'/)?.[1];
|
|
7900
|
+
const targetPath = command2.match(/ln -sfn '(.+)' '(.+)'/)?.[2];
|
|
7901
|
+
if (!sourcePath || !targetPath) {
|
|
7902
|
+
throw new Error(`Unable to parse symlink command: ${command2}`);
|
|
7399
7903
|
}
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7904
|
+
ensureParentDir(targetPath);
|
|
7905
|
+
try {
|
|
7906
|
+
Bun.file(targetPath).delete();
|
|
7907
|
+
} catch {}
|
|
7908
|
+
symlinkSync(sourcePath, targetPath);
|
|
7404
7909
|
}
|
|
7405
7910
|
}
|
|
7406
|
-
|
|
7407
|
-
const
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7911
|
+
function runSync(machineId, options = {}, runner = runMachineCommand) {
|
|
7912
|
+
const plan = buildSyncPlan(machineId, runner);
|
|
7913
|
+
if (!options.apply) {
|
|
7914
|
+
return plan;
|
|
7915
|
+
}
|
|
7916
|
+
if (!options.yes) {
|
|
7917
|
+
throw new Error("Sync execution requires --yes.");
|
|
7918
|
+
}
|
|
7919
|
+
let executed = 0;
|
|
7920
|
+
for (const action of plan.actions) {
|
|
7921
|
+
if (action.status === "ok")
|
|
7922
|
+
continue;
|
|
7923
|
+
if (action.kind === "file") {
|
|
7924
|
+
applyFileAction(action.command);
|
|
7925
|
+
} else {
|
|
7926
|
+
const result = runner(plan.machineId, action.command);
|
|
7927
|
+
if (result.exitCode !== 0) {
|
|
7928
|
+
recordSyncRun(plan.machineId, "failed", {
|
|
7929
|
+
executed,
|
|
7930
|
+
failedAction: action,
|
|
7931
|
+
stderr: result.stderr,
|
|
7932
|
+
stdout: result.stdout,
|
|
7933
|
+
exitCode: result.exitCode,
|
|
7934
|
+
source: result.source
|
|
7935
|
+
});
|
|
7936
|
+
throw new Error(describeMachineCommandFailure(`Sync action ${action.id}`, result));
|
|
7937
|
+
}
|
|
7414
7938
|
}
|
|
7415
|
-
|
|
7416
|
-
return results;
|
|
7417
|
-
} finally {
|
|
7418
|
-
await remote.close();
|
|
7939
|
+
executed += 1;
|
|
7419
7940
|
}
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
}
|
|
7426
|
-
function getSyncMetaAll() {
|
|
7427
|
-
const db = getDb();
|
|
7428
|
-
ensureSyncMetaTable(db);
|
|
7429
|
-
return db.query("SELECT table_name, last_synced_at, direction FROM _machines_sync_meta ORDER BY table_name, direction").all();
|
|
7430
|
-
}
|
|
7431
|
-
function getStorageStatus() {
|
|
7432
|
-
const activeEnv = getStorageDatabaseEnv();
|
|
7433
|
-
return {
|
|
7434
|
-
configured: Boolean(activeEnv),
|
|
7435
|
-
mode: getStorageMode(),
|
|
7436
|
-
env: STORAGE_DATABASE_ENV,
|
|
7437
|
-
activeEnv: activeEnv?.name ?? null,
|
|
7438
|
-
service: "machines",
|
|
7439
|
-
tables: STORAGE_TABLES,
|
|
7440
|
-
sync: getSyncMetaAll()
|
|
7941
|
+
const summary = {
|
|
7942
|
+
machineId: plan.machineId,
|
|
7943
|
+
mode: "apply",
|
|
7944
|
+
actions: plan.actions,
|
|
7945
|
+
executed
|
|
7441
7946
|
};
|
|
7947
|
+
recordSyncRun(plan.machineId, "completed", summary);
|
|
7948
|
+
return summary;
|
|
7442
7949
|
}
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
return requested;
|
|
7452
|
-
}
|
|
7453
|
-
async function pushTable(db, remote, table) {
|
|
7454
|
-
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
7455
|
-
try {
|
|
7456
|
-
if (!tableExists(db, table))
|
|
7457
|
-
return result;
|
|
7458
|
-
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
7459
|
-
result.rowsRead = rows.length;
|
|
7460
|
-
if (rows.length === 0)
|
|
7461
|
-
return result;
|
|
7462
|
-
const remoteColumns = await getRemoteColumns(remote, table);
|
|
7463
|
-
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
7464
|
-
result.rowsWritten = await upsertPg(remote, table, columns, rows);
|
|
7465
|
-
} catch (error) {
|
|
7466
|
-
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
7467
|
-
}
|
|
7468
|
-
return result;
|
|
7950
|
+
|
|
7951
|
+
// src/compatibility.ts
|
|
7952
|
+
var DEFAULT_COMMANDS = [
|
|
7953
|
+
{ command: "bun", required: true },
|
|
7954
|
+
{ command: "machines", required: true }
|
|
7955
|
+
];
|
|
7956
|
+
function defaultPackages() {
|
|
7957
|
+
return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
|
|
7469
7958
|
}
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
try {
|
|
7473
|
-
if (!tableExists(db, table))
|
|
7474
|
-
return result;
|
|
7475
|
-
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
7476
|
-
result.rowsRead = rows.length;
|
|
7477
|
-
if (rows.length === 0)
|
|
7478
|
-
return result;
|
|
7479
|
-
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
7480
|
-
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
7481
|
-
} catch (error) {
|
|
7482
|
-
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
7483
|
-
}
|
|
7484
|
-
return result;
|
|
7959
|
+
function shellQuote6(value) {
|
|
7960
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
7485
7961
|
}
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
return new Set(rows.map((row) => row.column_name));
|
|
7962
|
+
function commandId(value) {
|
|
7963
|
+
return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
7489
7964
|
}
|
|
7490
|
-
function
|
|
7491
|
-
if (
|
|
7492
|
-
return
|
|
7493
|
-
|
|
7965
|
+
function packageCommand(name) {
|
|
7966
|
+
if (name === "@hasna/knowledge")
|
|
7967
|
+
return "knowledge";
|
|
7968
|
+
if (name === "@hasna/machines")
|
|
7969
|
+
return "machines";
|
|
7970
|
+
return name.split("/").pop() ?? name;
|
|
7494
7971
|
}
|
|
7495
|
-
function
|
|
7496
|
-
|
|
7497
|
-
const allowed = new Set(rows.map((row) => row.name));
|
|
7498
|
-
return columns.filter((column) => allowed.has(column));
|
|
7972
|
+
function firstLine(value) {
|
|
7973
|
+
return value.trim().split(/\r?\n/).find(Boolean) ?? "";
|
|
7499
7974
|
}
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
|
|
7503
|
-
const primaryKeys = PRIMARY_KEYS[table];
|
|
7504
|
-
const columnList = columns.map(quoteIdent).join(", ");
|
|
7505
|
-
const placeholders = columns.map(() => "?").join(", ");
|
|
7506
|
-
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
7507
|
-
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
7508
|
-
const fallbackKey = primaryKeys[0];
|
|
7509
|
-
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
7510
|
-
for (const row of rows) {
|
|
7511
|
-
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
7512
|
-
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column])));
|
|
7513
|
-
}
|
|
7514
|
-
return rows.length;
|
|
7975
|
+
function extractVersion(value) {
|
|
7976
|
+
const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
|
|
7977
|
+
return match?.[0] ?? null;
|
|
7515
7978
|
}
|
|
7516
|
-
function
|
|
7517
|
-
if (
|
|
7518
|
-
return
|
|
7519
|
-
|
|
7520
|
-
const columnList = columns.map(quoteIdent).join(", ");
|
|
7521
|
-
const placeholders = columns.map(() => "?").join(", ");
|
|
7522
|
-
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
7523
|
-
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
7524
|
-
const fallbackKey = primaryKeys[0];
|
|
7525
|
-
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
7526
|
-
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
7527
|
-
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
7528
|
-
const insert = db.transaction((batch) => {
|
|
7529
|
-
for (const row of batch)
|
|
7530
|
-
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
7531
|
-
});
|
|
7532
|
-
insert(rows);
|
|
7533
|
-
return rows.length;
|
|
7979
|
+
function statusFor(required, ok) {
|
|
7980
|
+
if (ok)
|
|
7981
|
+
return "ok";
|
|
7982
|
+
return required === false ? "warn" : "fail";
|
|
7534
7983
|
}
|
|
7535
|
-
function
|
|
7536
|
-
|
|
7537
|
-
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
|
|
7984
|
+
function makeCheck2(input) {
|
|
7985
|
+
return {
|
|
7986
|
+
id: input.id,
|
|
7987
|
+
kind: input.kind,
|
|
7988
|
+
status: input.status,
|
|
7989
|
+
target: input.target,
|
|
7990
|
+
expected: input.expected ?? null,
|
|
7991
|
+
actual: input.actual ?? null,
|
|
7992
|
+
detail: input.detail,
|
|
7993
|
+
source: input.source
|
|
7994
|
+
};
|
|
7995
|
+
}
|
|
7996
|
+
function parseKeyValue(stdout) {
|
|
7997
|
+
const result = {};
|
|
7998
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
7999
|
+
const idx = line.indexOf("=");
|
|
8000
|
+
if (idx <= 0)
|
|
7545
8001
|
continue;
|
|
7546
|
-
|
|
8002
|
+
result[line.slice(0, idx)] = line.slice(idx + 1);
|
|
7547
8003
|
}
|
|
8004
|
+
return result;
|
|
7548
8005
|
}
|
|
7549
|
-
function
|
|
7550
|
-
|
|
7551
|
-
CREATE TABLE IF NOT EXISTS _machines_sync_meta (
|
|
7552
|
-
table_name TEXT NOT NULL,
|
|
7553
|
-
last_synced_at TEXT,
|
|
7554
|
-
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
7555
|
-
PRIMARY KEY (table_name, direction)
|
|
7556
|
-
)
|
|
7557
|
-
`);
|
|
8006
|
+
function defaultRunner2(machineId, command2) {
|
|
8007
|
+
return runMachineCommand(machineId, command2);
|
|
7558
8008
|
}
|
|
7559
|
-
function
|
|
7560
|
-
const
|
|
7561
|
-
|
|
8009
|
+
function inspectCommand(machineId, spec, runner) {
|
|
8010
|
+
const command2 = shellQuote6(spec.command);
|
|
8011
|
+
const versionArgs = spec.versionArgs ?? "--version";
|
|
8012
|
+
const script = [
|
|
8013
|
+
`cmd=${command2}`,
|
|
8014
|
+
'path="$(command -v "$cmd" 2>/dev/null || true)"',
|
|
8015
|
+
'printf "path=%s\\n" "$path"',
|
|
8016
|
+
'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
|
|
8017
|
+
].join("; ");
|
|
8018
|
+
const result = runner(machineId, script);
|
|
8019
|
+
const parsed = parseKeyValue(result.stdout);
|
|
8020
|
+
return {
|
|
8021
|
+
path: parsed.path || null,
|
|
8022
|
+
version: parsed.version ? firstLine(parsed.version) : null,
|
|
8023
|
+
exitCode: result.exitCode,
|
|
8024
|
+
source: result.source,
|
|
8025
|
+
stderr: result.stderr
|
|
8026
|
+
};
|
|
7562
8027
|
}
|
|
7563
|
-
function
|
|
7564
|
-
|
|
8028
|
+
function fieldCommand(field) {
|
|
8029
|
+
const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
|
|
8030
|
+
return [
|
|
8031
|
+
`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`,
|
|
8032
|
+
`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`,
|
|
8033
|
+
`else sed -n '${regex}' "$pkg" | head -n 1`,
|
|
8034
|
+
"fi"
|
|
8035
|
+
].join("; ");
|
|
7565
8036
|
}
|
|
7566
|
-
function
|
|
7567
|
-
|
|
7568
|
-
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
7572
|
-
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
8037
|
+
function inspectWorkspace(machineId, spec, runner) {
|
|
8038
|
+
const script = [
|
|
8039
|
+
`path=${shellQuote6(spec.path)}`,
|
|
8040
|
+
'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
|
|
8041
|
+
'pkg="$path/package.json"',
|
|
8042
|
+
'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
|
|
8043
|
+
`if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
|
|
8044
|
+
].join("; ");
|
|
8045
|
+
const result = runner(machineId, script);
|
|
8046
|
+
const parsed = parseKeyValue(result.stdout);
|
|
8047
|
+
return {
|
|
8048
|
+
exists: parsed.exists === "yes",
|
|
8049
|
+
packageJson: parsed.package_json === "yes",
|
|
8050
|
+
packageName: parsed.package_name || null,
|
|
8051
|
+
version: parsed.version || null,
|
|
8052
|
+
source: result.source,
|
|
8053
|
+
stderr: result.stderr
|
|
8054
|
+
};
|
|
7576
8055
|
}
|
|
7577
|
-
function
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
7583
|
-
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
8056
|
+
function commandCheck(machineId, spec, runner) {
|
|
8057
|
+
const inspection = inspectCommand(machineId, spec, runner);
|
|
8058
|
+
const found = Boolean(inspection.path);
|
|
8059
|
+
const checks = [
|
|
8060
|
+
makeCheck2({
|
|
8061
|
+
id: `command:${commandId(spec.command)}:path`,
|
|
8062
|
+
kind: "command",
|
|
8063
|
+
status: statusFor(spec.required, found),
|
|
8064
|
+
target: spec.command,
|
|
8065
|
+
expected: "available",
|
|
8066
|
+
actual: inspection.path ?? "missing",
|
|
8067
|
+
detail: found ? `found at ${inspection.path}` : inspection.stderr || "command missing",
|
|
8068
|
+
source: inspection.source
|
|
8069
|
+
})
|
|
8070
|
+
];
|
|
8071
|
+
if (spec.expectedVersion) {
|
|
8072
|
+
const actualVersion = extractVersion(inspection.version ?? "");
|
|
8073
|
+
checks.push(makeCheck2({
|
|
8074
|
+
id: `command:${commandId(spec.command)}:version`,
|
|
8075
|
+
kind: "command",
|
|
8076
|
+
status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8077
|
+
target: spec.command,
|
|
8078
|
+
expected: spec.expectedVersion,
|
|
8079
|
+
actual: actualVersion ?? inspection.version ?? "missing",
|
|
8080
|
+
detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
|
|
8081
|
+
source: inspection.source
|
|
8082
|
+
}));
|
|
8083
|
+
}
|
|
8084
|
+
return checks;
|
|
8085
|
+
}
|
|
8086
|
+
function packageCheck(machineId, spec, runner) {
|
|
8087
|
+
const command2 = spec.command ?? packageCommand(spec.name);
|
|
8088
|
+
const inspection = inspectCommand(machineId, { command: command2, expectedVersion: spec.expectedVersion, required: spec.required }, runner);
|
|
8089
|
+
const found = Boolean(inspection.path);
|
|
8090
|
+
const checks = [
|
|
8091
|
+
makeCheck2({
|
|
8092
|
+
id: `package:${commandId(spec.name)}:command`,
|
|
8093
|
+
kind: "package",
|
|
8094
|
+
status: statusFor(spec.required, found),
|
|
8095
|
+
target: spec.name,
|
|
8096
|
+
expected: command2,
|
|
8097
|
+
actual: inspection.path ?? "missing",
|
|
8098
|
+
detail: found ? `${command2} found at ${inspection.path}` : `${command2} command missing`,
|
|
8099
|
+
source: inspection.source
|
|
8100
|
+
})
|
|
8101
|
+
];
|
|
8102
|
+
if (spec.expectedVersion) {
|
|
8103
|
+
const actualVersion = extractVersion(inspection.version ?? "");
|
|
8104
|
+
checks.push(makeCheck2({
|
|
8105
|
+
id: `package:${commandId(spec.name)}:version`,
|
|
8106
|
+
kind: "package",
|
|
8107
|
+
status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8108
|
+
target: spec.name,
|
|
8109
|
+
expected: spec.expectedVersion,
|
|
8110
|
+
actual: actualVersion ?? inspection.version ?? "missing",
|
|
8111
|
+
detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
|
|
8112
|
+
source: inspection.source
|
|
8113
|
+
}));
|
|
8114
|
+
}
|
|
8115
|
+
return checks;
|
|
8116
|
+
}
|
|
8117
|
+
function workspaceCheck(machineId, spec, runner) {
|
|
8118
|
+
const inspection = inspectWorkspace(machineId, spec, runner);
|
|
8119
|
+
const target = spec.label ?? spec.path;
|
|
8120
|
+
const checks = [
|
|
8121
|
+
makeCheck2({
|
|
8122
|
+
id: `workspace:${commandId(target)}:path`,
|
|
8123
|
+
kind: "workspace",
|
|
8124
|
+
status: statusFor(spec.required, inspection.exists),
|
|
8125
|
+
target,
|
|
8126
|
+
expected: spec.path,
|
|
8127
|
+
actual: inspection.exists ? "exists" : "missing",
|
|
8128
|
+
detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
|
|
8129
|
+
source: inspection.source
|
|
8130
|
+
})
|
|
8131
|
+
];
|
|
8132
|
+
if (spec.expectedPackageName) {
|
|
8133
|
+
checks.push(makeCheck2({
|
|
8134
|
+
id: `workspace:${commandId(target)}:package-name`,
|
|
8135
|
+
kind: "workspace",
|
|
8136
|
+
status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
|
|
8137
|
+
target,
|
|
8138
|
+
expected: spec.expectedPackageName,
|
|
8139
|
+
actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
|
|
8140
|
+
detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
|
|
8141
|
+
source: inspection.source
|
|
8142
|
+
}));
|
|
8143
|
+
}
|
|
8144
|
+
if (spec.expectedVersion) {
|
|
8145
|
+
checks.push(makeCheck2({
|
|
8146
|
+
id: `workspace:${commandId(target)}:version`,
|
|
8147
|
+
kind: "workspace",
|
|
8148
|
+
status: inspection.version === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
|
|
8149
|
+
target,
|
|
8150
|
+
expected: spec.expectedVersion,
|
|
8151
|
+
actual: inspection.version ?? (inspection.packageJson ? "missing-version" : "missing-package-json"),
|
|
8152
|
+
detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
|
|
8153
|
+
source: inspection.source
|
|
8154
|
+
}));
|
|
8155
|
+
}
|
|
8156
|
+
return checks;
|
|
8157
|
+
}
|
|
8158
|
+
function checkMachineCompatibility(options = {}) {
|
|
8159
|
+
const machineId = options.machineId ?? getLocalMachineId();
|
|
8160
|
+
const runner = options.runner ?? defaultRunner2;
|
|
8161
|
+
const commands = options.commands ?? DEFAULT_COMMANDS;
|
|
8162
|
+
const packages = options.packages ?? defaultPackages();
|
|
8163
|
+
const workspaces = options.workspaces ?? [];
|
|
8164
|
+
const checks = [];
|
|
8165
|
+
for (const spec of commands)
|
|
8166
|
+
checks.push(...commandCheck(machineId, spec, runner));
|
|
8167
|
+
for (const spec of packages)
|
|
8168
|
+
checks.push(...packageCheck(machineId, spec, runner));
|
|
8169
|
+
for (const spec of workspaces)
|
|
8170
|
+
checks.push(...workspaceCheck(machineId, spec, runner));
|
|
8171
|
+
const summary = {
|
|
8172
|
+
ok: checks.filter((check2) => check2.status === "ok").length,
|
|
8173
|
+
warn: checks.filter((check2) => check2.status === "warn").length,
|
|
8174
|
+
fail: checks.filter((check2) => check2.status === "fail").length
|
|
8175
|
+
};
|
|
8176
|
+
return {
|
|
8177
|
+
schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
|
|
8178
|
+
package: {
|
|
8179
|
+
name: MACHINES_PACKAGE_NAME,
|
|
8180
|
+
version: getPackageVersion()
|
|
8181
|
+
},
|
|
8182
|
+
capabilities: getMachinesConsumerCapabilities(),
|
|
8183
|
+
ok: summary.fail === 0,
|
|
8184
|
+
machine_id: machineId,
|
|
8185
|
+
source: checks[0]?.source ?? "local",
|
|
8186
|
+
generated_at: (options.now ?? new Date).toISOString(),
|
|
8187
|
+
checks,
|
|
8188
|
+
summary
|
|
8189
|
+
};
|
|
7589
8190
|
}
|
|
7590
8191
|
// src/mcp/server.ts
|
|
7591
8192
|
function buildServer(version = getPackageVersion()) {
|
|
7592
8193
|
return createMcpServer(version);
|
|
7593
8194
|
}
|
|
8195
|
+
function privateMetadataAllowed(requested) {
|
|
8196
|
+
return requested === true && isPrivateOutputEnabled();
|
|
8197
|
+
}
|
|
8198
|
+
function privateOutputWarnings(requested, allowed) {
|
|
8199
|
+
return requested === true && !allowed ? [PRIVATE_OUTPUT_DENIED_WARNING] : [];
|
|
8200
|
+
}
|
|
8201
|
+
function appendWarnings(payload, warnings) {
|
|
8202
|
+
if (warnings.length === 0)
|
|
8203
|
+
return payload;
|
|
8204
|
+
return { ...payload, warnings: [...payload.warnings ?? [], ...warnings] };
|
|
8205
|
+
}
|
|
7594
8206
|
function createMcpServer(version) {
|
|
7595
8207
|
const server = new McpServer({ name: "machines", version });
|
|
7596
8208
|
const events = new EventsClient2;
|
|
@@ -7617,16 +8229,69 @@ function createMcpServer(version) {
|
|
|
7617
8229
|
}));
|
|
7618
8230
|
server.tool("machines_manifest_get", "Read a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(manifestGet(machine_id), null, 2) }] }));
|
|
7619
8231
|
server.tool("machines_manifest_remove", "Remove a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(manifestRemove(machine_id), null, 2) }] }));
|
|
7620
|
-
server.tool("machines_agent_status", "List current machine agent heartbeats.", {}, async () =>
|
|
7621
|
-
|
|
8232
|
+
server.tool("machines_agent_status", "List current machine agent heartbeats.", { private_metadata: exports_external.boolean().optional().describe("Include private heartbeat metadata") }, async ({ private_metadata }) => {
|
|
8233
|
+
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8234
|
+
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
8235
|
+
const agents = getAgentStatus(undefined, { privateMetadata });
|
|
8236
|
+
return {
|
|
8237
|
+
content: [{ type: "text", text: JSON.stringify(warnings.length > 0 ? { agents, warnings } : agents, null, 2) }]
|
|
8238
|
+
};
|
|
8239
|
+
});
|
|
8240
|
+
server.tool("machines_daemon_status", "List fleet daemon heartbeat status rows.", { private_metadata: exports_external.boolean().optional().describe("Include private heartbeat metadata") }, async ({ private_metadata }) => {
|
|
8241
|
+
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8242
|
+
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
8243
|
+
return {
|
|
8244
|
+
content: [{
|
|
8245
|
+
type: "text",
|
|
8246
|
+
text: JSON.stringify({
|
|
8247
|
+
generated_at: new Date().toISOString(),
|
|
8248
|
+
agents: getAgentStatus(undefined, { privateMetadata }),
|
|
8249
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
8250
|
+
}, null, 2)
|
|
8251
|
+
}]
|
|
8252
|
+
};
|
|
8253
|
+
});
|
|
8254
|
+
server.tool("machines_daemon_service_plan", "Plan launchd/systemd lifecycle commands for the machines-agent daemon.", {
|
|
8255
|
+
action: exports_external.enum(["install", "uninstall", "restart", "status", "logs"]).describe("Daemon lifecycle action"),
|
|
8256
|
+
platform: exports_external.enum(["macos", "linux"]).optional().describe("Target service platform"),
|
|
8257
|
+
mode: exports_external.enum(["user", "system"]).optional().describe("Service mode"),
|
|
8258
|
+
service_name: exports_external.string().optional().describe("Service name/label"),
|
|
8259
|
+
executable: exports_external.string().optional().describe("machines-agent executable path"),
|
|
8260
|
+
interval_ms: exports_external.number().optional().describe("Heartbeat interval in milliseconds"),
|
|
8261
|
+
storage_push: exports_external.boolean().optional().describe("Configure heartbeat storage push"),
|
|
8262
|
+
doctor_summary: exports_external.boolean().optional().describe("Configure lightweight doctor summaries in heartbeat metadata"),
|
|
8263
|
+
private_metadata: exports_external.boolean().optional().describe("Opt in to private heartbeat metadata"),
|
|
8264
|
+
env: exports_external.array(exports_external.string()).optional().describe("Environment variable names to include as placeholders")
|
|
8265
|
+
}, async ({ action, platform: platform5, mode, service_name, executable, interval_ms, storage_push, doctor_summary, private_metadata, env }) => ({
|
|
8266
|
+
content: [{
|
|
8267
|
+
type: "text",
|
|
8268
|
+
text: JSON.stringify(buildDaemonServicePlan({
|
|
8269
|
+
action,
|
|
8270
|
+
platform: platform5,
|
|
8271
|
+
mode,
|
|
8272
|
+
serviceName: service_name,
|
|
8273
|
+
executable,
|
|
8274
|
+
intervalMs: interval_ms,
|
|
8275
|
+
storagePush: storage_push,
|
|
8276
|
+
doctorSummary: doctor_summary,
|
|
8277
|
+
privateMetadata: private_metadata,
|
|
8278
|
+
env
|
|
8279
|
+
}), null, 2)
|
|
8280
|
+
}]
|
|
7622
8281
|
}));
|
|
7623
8282
|
server.tool("machines_setup_preview", "Preview setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSetupPlan(machine_id), null, 2) }] }));
|
|
7624
8283
|
server.tool("machines_setup_apply", "Execute setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runSetup(machine_id, { apply: true, yes }), null, 2) }] }));
|
|
7625
8284
|
server.tool("machines_sync_preview", "Preview sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSyncPlan(machine_id), null, 2) }] }));
|
|
7626
8285
|
server.tool("machines_sync_apply", "Execute sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runSync(machine_id, { apply: true, yes }), null, 2) }] }));
|
|
7627
|
-
server.tool("machines_topology", "Discover local, manifest, heartbeat, SSH, and Tailscale machine topology.", {
|
|
7628
|
-
|
|
7629
|
-
|
|
8286
|
+
server.tool("machines_topology", "Discover local, manifest, heartbeat, SSH, and Tailscale machine topology.", {
|
|
8287
|
+
include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
|
|
8288
|
+
private_metadata: exports_external.boolean().optional().describe("Include private host/network route fields")
|
|
8289
|
+
}, async ({ include_tailscale, private_metadata }) => {
|
|
8290
|
+
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8291
|
+
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
8292
|
+
const topology = redactTopologyForOutput(discoverMachineTopology({ includeTailscale: include_tailscale !== false }), { privateMetadata });
|
|
8293
|
+
return { content: [{ type: "text", text: JSON.stringify(appendWarnings(topology, warnings), null, 2) }] };
|
|
8294
|
+
});
|
|
7630
8295
|
server.tool("machines_compatibility", "Check remote package, command, and workspace compatibility for open-* consumers.", {
|
|
7631
8296
|
machine_id: exports_external.string().optional().describe("Machine identifier"),
|
|
7632
8297
|
commands: exports_external.array(exports_external.object({
|
|
@@ -7678,9 +8343,16 @@ function createMcpServer(version) {
|
|
|
7678
8343
|
}));
|
|
7679
8344
|
server.tool("machines_install_tailscale_preview", "Preview Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildTailscaleInstallPlan(machine_id), null, 2) }] }));
|
|
7680
8345
|
server.tool("machines_install_tailscale_apply", "Execute Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runTailscaleInstall(machine_id, { apply: true, yes }), null, 2) }] }));
|
|
7681
|
-
server.tool("machines_route_resolve", "Resolve the best route for a machine using manifest, heartbeat, SSH, LAN, and Tailscale topology.", {
|
|
7682
|
-
|
|
7683
|
-
|
|
8346
|
+
server.tool("machines_route_resolve", "Resolve the best route for a machine using manifest, heartbeat, SSH, LAN, and Tailscale topology.", {
|
|
8347
|
+
machine_id: exports_external.string().describe("Machine identifier"),
|
|
8348
|
+
include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
|
|
8349
|
+
private_metadata: exports_external.boolean().optional().describe("Include private route targets")
|
|
8350
|
+
}, async ({ machine_id, include_tailscale, private_metadata }) => {
|
|
8351
|
+
const privateMetadata = privateMetadataAllowed(private_metadata);
|
|
8352
|
+
const warnings = privateOutputWarnings(private_metadata, privateMetadata);
|
|
8353
|
+
const route = redactRouteForOutput(resolveMachineRoute(machine_id, { includeTailscale: include_tailscale !== false }), { privateMetadata });
|
|
8354
|
+
return { content: [{ type: "text", text: JSON.stringify(appendWarnings(route, warnings), null, 2) }] };
|
|
8355
|
+
});
|
|
7684
8356
|
server.tool("machines_workspace_resolve", "Resolve sync-safe repo and open-files roots for an open-* consumer.", {
|
|
7685
8357
|
machine_id: exports_external.string().describe("Machine identifier"),
|
|
7686
8358
|
project_id: exports_external.string().describe("Canonical project id"),
|