@hasna/machines 0.0.44 → 0.0.46

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.
Files changed (109) hide show
  1. package/README.md +27 -4
  2. package/dist/agent/index.d.ts +0 -1
  3. package/dist/agent/index.js +249 -14
  4. package/dist/agent/runtime.d.ts +0 -1
  5. package/dist/cli/index.d.ts +0 -1
  6. package/dist/cli/index.js +1316 -213
  7. package/dist/cli-utils.d.ts +0 -1
  8. package/dist/commands/apps.d.ts +7 -5
  9. package/dist/commands/backup.d.ts +0 -1
  10. package/dist/commands/cert.d.ts +0 -1
  11. package/dist/commands/clipboard-daemon.d.ts +0 -1
  12. package/dist/commands/clipboard-server.d.ts +0 -1
  13. package/dist/commands/clipboard.d.ts +0 -1
  14. package/dist/commands/daemon.d.ts +0 -1
  15. package/dist/commands/diff.d.ts +0 -1
  16. package/dist/commands/dns.d.ts +0 -1
  17. package/dist/commands/doctor.d.ts +0 -1
  18. package/dist/commands/heal-daemon.d.ts +0 -1
  19. package/dist/commands/heal.d.ts +0 -1
  20. package/dist/commands/install-claude.d.ts +5 -3
  21. package/dist/commands/install-tailscale.d.ts +5 -3
  22. package/dist/commands/manifest.d.ts +0 -1
  23. package/dist/commands/mutation-approval.d.ts +54 -0
  24. package/dist/commands/notifications.d.ts +14 -2
  25. package/dist/commands/ports.d.ts +0 -1
  26. package/dist/commands/runtime.d.ts +15 -1
  27. package/dist/commands/screen.d.ts +4 -1
  28. package/dist/commands/self-test.d.ts +0 -1
  29. package/dist/commands/serve.d.ts +0 -1
  30. package/dist/commands/setup.d.ts +5 -3
  31. package/dist/commands/ssh.d.ts +8 -1
  32. package/dist/commands/status.d.ts +0 -1
  33. package/dist/commands/sync.d.ts +5 -3
  34. package/dist/commands/workspace.d.ts +0 -1
  35. package/dist/compatibility.d.ts +0 -1
  36. package/dist/consumer-schema.d.ts +0 -1
  37. package/dist/consumer.d.ts +0 -1
  38. package/dist/consumer.js +253 -12
  39. package/dist/cross-project-types.d.ts +0 -1
  40. package/dist/db.d.ts +0 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +1108 -189
  43. package/dist/manifests.d.ts +0 -1
  44. package/dist/mcp/http.d.ts +26 -2
  45. package/dist/mcp/index.d.ts +0 -1
  46. package/dist/mcp/index.js +1021 -167
  47. package/dist/mcp/server.d.ts +5 -3
  48. package/dist/paths.d.ts +0 -1
  49. package/dist/pg-migrations.d.ts +0 -1
  50. package/dist/redaction.d.ts +0 -1
  51. package/dist/remote-storage.d.ts +0 -1
  52. package/dist/remote.d.ts +14 -5
  53. package/dist/storage-sync.d.ts +0 -1
  54. package/dist/storage.d.ts +0 -1
  55. package/dist/storage.js +18 -0
  56. package/dist/topology.d.ts +0 -1
  57. package/dist/types.d.ts +3 -1
  58. package/dist/version.d.ts +0 -1
  59. package/package.json +5 -3
  60. package/dist/agent/index.d.ts.map +0 -1
  61. package/dist/agent/runtime.d.ts.map +0 -1
  62. package/dist/cli/index.d.ts.map +0 -1
  63. package/dist/cli-utils.d.ts.map +0 -1
  64. package/dist/commands/apps.d.ts.map +0 -1
  65. package/dist/commands/backup.d.ts.map +0 -1
  66. package/dist/commands/cert.d.ts.map +0 -1
  67. package/dist/commands/clipboard-daemon.d.ts.map +0 -1
  68. package/dist/commands/clipboard-server.d.ts.map +0 -1
  69. package/dist/commands/clipboard.d.ts.map +0 -1
  70. package/dist/commands/daemon.d.ts.map +0 -1
  71. package/dist/commands/diff.d.ts.map +0 -1
  72. package/dist/commands/dns.d.ts.map +0 -1
  73. package/dist/commands/doctor.d.ts.map +0 -1
  74. package/dist/commands/heal-daemon.d.ts.map +0 -1
  75. package/dist/commands/heal.d.ts.map +0 -1
  76. package/dist/commands/install-claude.d.ts.map +0 -1
  77. package/dist/commands/install-tailscale.d.ts.map +0 -1
  78. package/dist/commands/manifest.d.ts.map +0 -1
  79. package/dist/commands/notifications.d.ts.map +0 -1
  80. package/dist/commands/ports.d.ts.map +0 -1
  81. package/dist/commands/runtime.d.ts.map +0 -1
  82. package/dist/commands/screen.d.ts.map +0 -1
  83. package/dist/commands/self-test.d.ts.map +0 -1
  84. package/dist/commands/serve.d.ts.map +0 -1
  85. package/dist/commands/setup.d.ts.map +0 -1
  86. package/dist/commands/ssh.d.ts.map +0 -1
  87. package/dist/commands/status.d.ts.map +0 -1
  88. package/dist/commands/sync.d.ts.map +0 -1
  89. package/dist/commands/workspace.d.ts.map +0 -1
  90. package/dist/compatibility.d.ts.map +0 -1
  91. package/dist/consumer-schema.d.ts.map +0 -1
  92. package/dist/consumer.d.ts.map +0 -1
  93. package/dist/cross-project-types.d.ts.map +0 -1
  94. package/dist/db.d.ts.map +0 -1
  95. package/dist/index.d.ts.map +0 -1
  96. package/dist/manifests.d.ts.map +0 -1
  97. package/dist/mcp/http.d.ts.map +0 -1
  98. package/dist/mcp/index.d.ts.map +0 -1
  99. package/dist/mcp/server.d.ts.map +0 -1
  100. package/dist/paths.d.ts.map +0 -1
  101. package/dist/pg-migrations.d.ts.map +0 -1
  102. package/dist/redaction.d.ts.map +0 -1
  103. package/dist/remote-storage.d.ts.map +0 -1
  104. package/dist/remote.d.ts.map +0 -1
  105. package/dist/storage-sync.d.ts.map +0 -1
  106. package/dist/storage.d.ts.map +0 -1
  107. package/dist/topology.d.ts.map +0 -1
  108. package/dist/types.d.ts.map +0 -1
  109. package/dist/version.d.ts.map +0 -1
package/dist/cli/index.js CHANGED
@@ -2123,6 +2123,7 @@ class SqliteAdapter {
2123
2123
  raw;
2124
2124
  constructor(path) {
2125
2125
  this.raw = new Database(path);
2126
+ this.raw.exec("PRAGMA busy_timeout = 5000");
2126
2127
  }
2127
2128
  close() {
2128
2129
  this.raw.close();
@@ -2172,6 +2173,23 @@ function createTables(db) {
2172
2173
  updated_at TEXT NOT NULL
2173
2174
  )
2174
2175
  `);
2176
+ db.exec(`
2177
+ CREATE TABLE IF NOT EXISTS mutation_approval_nonces (
2178
+ nonce_sha256 TEXT PRIMARY KEY,
2179
+ token_sha256 TEXT NOT NULL,
2180
+ surface TEXT NOT NULL,
2181
+ operation TEXT NOT NULL,
2182
+ caller_id TEXT NOT NULL,
2183
+ run_id TEXT NOT NULL,
2184
+ transport TEXT NOT NULL,
2185
+ expires_at INTEGER NOT NULL,
2186
+ used_at INTEGER NOT NULL
2187
+ )
2188
+ `);
2189
+ db.exec(`
2190
+ CREATE INDEX IF NOT EXISTS mutation_approval_nonces_expires_at_idx
2191
+ ON mutation_approval_nonces (expires_at)
2192
+ `);
2175
2193
  }
2176
2194
  function migrateAgentHeartbeats(db) {
2177
2195
  const columns = db.query("PRAGMA table_info(agent_heartbeats)").all();
@@ -2689,8 +2707,15 @@ var {
2689
2707
  } = import__.default;
2690
2708
 
2691
2709
  // src/cli/index.ts
2692
- import { registerEventCommands, registerWebhookCommands } from "@hasna/events/commander";
2710
+ import {
2711
+ EventsClient as EventsClient3,
2712
+ getEventsDataDir as getEventsDataDir2,
2713
+ sanitizeChannelForOutput,
2714
+ sanitizeChannelsForOutput as sanitizeChannelsForOutput2
2715
+ } from "@hasna/events";
2693
2716
  import { execFileSync as execFileSync2 } from "child_process";
2717
+ import { existsSync as existsSync13, readFileSync as readFileSync13, rmSync as rmSync3 } from "fs";
2718
+ import { join as join12, resolve as resolve4 } from "path";
2694
2719
 
2695
2720
  // node_modules/chalk/source/vendor/ansi-styles/index.js
2696
2721
  var ANSI_BACKGROUND_OFFSET = 10;
@@ -7521,10 +7546,271 @@ function manifestValidate() {
7521
7546
  import { homedir as homedir2 } from "os";
7522
7547
  init_db();
7523
7548
 
7549
+ // src/commands/mutation-approval.ts
7550
+ init_db();
7551
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "crypto";
7552
+ import { resolve as resolve2 } from "path";
7553
+ var MUTATION_APPROVAL_FLAG_ENV = "HASNA_MACHINES_ALLOW_MUTATIONS";
7554
+ var LEGACY_MUTATION_APPROVAL_FLAG_ENV = "HASNA_MACHINES_MUTATION_APPROVAL";
7555
+ var MUTATION_APPROVAL_TOKEN_ENV = "HASNA_MACHINES_MUTATION_TOKEN";
7556
+ var MUTATION_APPROVAL_CALLER_ENV = "HASNA_MACHINES_MUTATION_CALLER_ID";
7557
+ var MUTATION_APPROVAL_RUN_ENV = "HASNA_MACHINES_MUTATION_RUN_ID";
7558
+ var MUTATION_APPROVAL_REPLAY_PATH_ENV = "HASNA_MACHINES_MUTATION_REPLAY_PATH";
7559
+ var TOKEN_PREFIX = "machines-mut-v1";
7560
+ var DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000;
7561
+ var MAX_TOKEN_TTL_MS = 5 * 60 * 1000;
7562
+ var MAX_CLOCK_SKEW_MS = 30000;
7563
+ function isTruthy(value) {
7564
+ return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
7565
+ }
7566
+ function nowMs(now) {
7567
+ if (typeof now === "number")
7568
+ return now;
7569
+ if (now instanceof Date)
7570
+ return now.getTime();
7571
+ return Date.now();
7572
+ }
7573
+ function signingSecret(env2, explicitSecret) {
7574
+ return explicitSecret?.trim() || env2[MUTATION_APPROVAL_TOKEN_ENV]?.trim();
7575
+ }
7576
+ function hmac(payload, secret) {
7577
+ return createHmac("sha256", secret).update(payload).digest("base64url");
7578
+ }
7579
+ function sha256Hex(payload) {
7580
+ return createHash("sha256").update(payload).digest("hex");
7581
+ }
7582
+ function replayDbPath(env2) {
7583
+ const configured = env2[MUTATION_APPROVAL_REPLAY_PATH_ENV]?.trim();
7584
+ return configured ? resolve2(configured) : undefined;
7585
+ }
7586
+ function replayNonceKey(claims) {
7587
+ return sha256Hex(JSON.stringify({ nonce: claims.nonce }));
7588
+ }
7589
+ function recordReplayNonce(env2, claims, tokenPayload, now) {
7590
+ const dbPath = replayDbPath(env2);
7591
+ if (!dbPath)
7592
+ return;
7593
+ if (!claims.nonce) {
7594
+ return { approved: false, reason: "approval_token nonce claim is required for replay protection." };
7595
+ }
7596
+ try {
7597
+ const db = getDb(dbPath);
7598
+ db.query("DELETE FROM mutation_approval_nonces WHERE expires_at <= ?").run(now);
7599
+ const result = db.query(`
7600
+ INSERT OR IGNORE INTO mutation_approval_nonces (
7601
+ nonce_sha256,
7602
+ token_sha256,
7603
+ surface,
7604
+ operation,
7605
+ caller_id,
7606
+ run_id,
7607
+ transport,
7608
+ expires_at,
7609
+ used_at
7610
+ )
7611
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
7612
+ `).run(replayNonceKey(claims), sha256Hex(tokenPayload), claims.surface, claims.operation, claims.callerId ?? "", claims.runId ?? "", claims.transport ?? "", claims.expiresAt, now);
7613
+ if (result.changes !== 1) {
7614
+ return { approved: false, reason: "approval_token nonce has already been used." };
7615
+ }
7616
+ return;
7617
+ } catch (error) {
7618
+ const message = error instanceof Error ? error.message : String(error);
7619
+ return { approved: false, reason: `approval_token replay store is unavailable: ${message}` };
7620
+ }
7621
+ }
7622
+ function safeEqual(left, right) {
7623
+ const leftBuffer = Buffer.from(left);
7624
+ const rightBuffer = Buffer.from(right);
7625
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
7626
+ }
7627
+ function canonicalizeMutationArg(value, inArray = false) {
7628
+ if (value === undefined)
7629
+ return inArray ? null : undefined;
7630
+ if (value === null || typeof value === "boolean" || typeof value === "string")
7631
+ return value;
7632
+ if (typeof value === "number")
7633
+ return Number.isFinite(value) ? value : null;
7634
+ if (Array.isArray(value)) {
7635
+ return value.map((entry) => canonicalizeMutationArg(entry, true) ?? null);
7636
+ }
7637
+ if (value instanceof Date)
7638
+ return value.toISOString();
7639
+ if (typeof value === "object") {
7640
+ const result = {};
7641
+ for (const key of Object.keys(value).sort()) {
7642
+ if (key === "approval_token" || key === "approvalToken")
7643
+ continue;
7644
+ const canonicalValue = canonicalizeMutationArg(value[key]);
7645
+ if (canonicalValue !== undefined)
7646
+ result[key] = canonicalValue;
7647
+ }
7648
+ return result;
7649
+ }
7650
+ return inArray ? null : undefined;
7651
+ }
7652
+ function canonicalMutationArgs(value) {
7653
+ return JSON.stringify(canonicalizeMutationArg(value) ?? {});
7654
+ }
7655
+ function mutationArgsSha256(value) {
7656
+ return sha256Hex(canonicalMutationArgs(value));
7657
+ }
7658
+ function stripPlanRuntimeFields(value) {
7659
+ if (Array.isArray(value))
7660
+ return value.map(stripPlanRuntimeFields);
7661
+ if (value instanceof Date)
7662
+ return value;
7663
+ if (value && typeof value === "object") {
7664
+ const result = {};
7665
+ for (const [key, entry] of Object.entries(value)) {
7666
+ if (key === "planDigest" || key === "plan_digest" || key === "mode" || key === "executed")
7667
+ continue;
7668
+ result[key] = stripPlanRuntimeFields(entry);
7669
+ }
7670
+ return result;
7671
+ }
7672
+ return value;
7673
+ }
7674
+ function mutationPlanDigest(plan) {
7675
+ return mutationArgsSha256(stripPlanRuntimeFields(plan));
7676
+ }
7677
+ function attachMutationPlanDigest(plan) {
7678
+ return {
7679
+ ...plan,
7680
+ planDigest: mutationPlanDigest(plan)
7681
+ };
7682
+ }
7683
+ function assertMutationPlanDigest(plan, expectedPlanDigest) {
7684
+ if (expectedPlanDigest && mutationPlanDigest(plan) !== expectedPlanDigest) {
7685
+ throw new Error("Approved plan digest does not match the current execution plan.");
7686
+ }
7687
+ }
7688
+ function parseToken(token) {
7689
+ if (!token)
7690
+ return null;
7691
+ const parts = token.split(".");
7692
+ if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX)
7693
+ return null;
7694
+ try {
7695
+ const claims = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8"));
7696
+ return { payload: parts[1] ?? "", signature: parts[2] ?? "", claims };
7697
+ } catch {
7698
+ return null;
7699
+ }
7700
+ }
7701
+ function claimMatches(expected, actual) {
7702
+ if (expected === undefined)
7703
+ return actual === undefined;
7704
+ return actual === expected;
7705
+ }
7706
+ function verifyMutationApprovalToken(options) {
7707
+ const env2 = options.env ?? process.env;
7708
+ const secret = signingSecret(env2);
7709
+ if (!secret)
7710
+ return { approved: false, reason: `${MUTATION_APPROVAL_TOKEN_ENV} is not configured.` };
7711
+ const parsed = parseToken(options.approvalToken);
7712
+ if (!parsed)
7713
+ return { approved: false, reason: "approval_token is not a scoped mutation token." };
7714
+ if (!safeEqual(hmac(parsed.payload, secret), parsed.signature)) {
7715
+ return { approved: false, reason: "approval_token signature is invalid." };
7716
+ }
7717
+ const claims = parsed.claims;
7718
+ if (claims.version !== 1)
7719
+ return { approved: false, reason: "approval_token version is unsupported." };
7720
+ if (!claims.callerId || !claims.runId) {
7721
+ return { approved: false, reason: "approval_token must include caller and run claims." };
7722
+ }
7723
+ if (!claims.transport) {
7724
+ return { approved: false, reason: "approval_token must include a transport claim." };
7725
+ }
7726
+ if (!Number.isFinite(claims.expiresAt) || claims.expiresAt <= nowMs(options.now)) {
7727
+ return { approved: false, reason: "approval_token is expired." };
7728
+ }
7729
+ const now = nowMs(options.now);
7730
+ if (!Number.isFinite(claims.issuedAt) || claims.issuedAt > now + MAX_CLOCK_SKEW_MS) {
7731
+ return { approved: false, reason: "approval_token issue time is invalid." };
7732
+ }
7733
+ if (claims.expiresAt - claims.issuedAt > MAX_TOKEN_TTL_MS) {
7734
+ return { approved: false, reason: "approval_token TTL is too long." };
7735
+ }
7736
+ for (const key of ["surface", "operation", "machineId", "resourceId", "transport"]) {
7737
+ if (!claimMatches(options[key], claims[key])) {
7738
+ return { approved: false, reason: `approval_token ${key} claim does not match this mutation.` };
7739
+ }
7740
+ }
7741
+ for (const key of ["callerId", "runId"]) {
7742
+ if (options[key] !== undefined && options[key] !== claims[key]) {
7743
+ return { approved: false, reason: `approval_token ${key} claim does not match this mutation.` };
7744
+ }
7745
+ }
7746
+ const expectedArgsSha256 = options.argsSha256 || (options.args === undefined ? undefined : mutationArgsSha256(options.args));
7747
+ if (expectedArgsSha256 !== undefined && claims.args_sha256 !== expectedArgsSha256) {
7748
+ return { approved: false, reason: "approval_token args_sha256 claim does not match this mutation." };
7749
+ }
7750
+ const replayDecision = recordReplayNonce(env2, claims, parsed.payload, now);
7751
+ if (replayDecision)
7752
+ return replayDecision;
7753
+ return { approved: true, claims };
7754
+ }
7755
+ function isMutationApproved(options = {}) {
7756
+ const env2 = options.env ?? process.env;
7757
+ const surface = options.surface ?? "cli";
7758
+ if (surface === "mcp") {
7759
+ if (!options.operation)
7760
+ return false;
7761
+ return verifyMutationApprovalToken({
7762
+ surface,
7763
+ operation: options.operation,
7764
+ machineId: options.machineId,
7765
+ resourceId: options.resourceId,
7766
+ callerId: options.callerId,
7767
+ runId: options.runId,
7768
+ transport: options.transport ?? "mcp",
7769
+ args: options.args,
7770
+ argsSha256: options.argsSha256,
7771
+ approvalToken: options.approvalToken,
7772
+ env: env2,
7773
+ now: options.now
7774
+ }).approved;
7775
+ }
7776
+ if (options.approvalToken) {
7777
+ const decision = options.operation ? verifyMutationApprovalToken({
7778
+ surface,
7779
+ operation: options.operation,
7780
+ machineId: options.machineId,
7781
+ resourceId: options.resourceId,
7782
+ callerId: options.callerId,
7783
+ runId: options.runId,
7784
+ transport: options.transport ?? surface,
7785
+ args: options.args,
7786
+ argsSha256: options.argsSha256,
7787
+ approvalToken: options.approvalToken,
7788
+ env: env2,
7789
+ now: options.now
7790
+ }) : { approved: false };
7791
+ if (decision.approved)
7792
+ return true;
7793
+ if (env2[MUTATION_APPROVAL_TOKEN_ENV]?.trim())
7794
+ return false;
7795
+ }
7796
+ return isTruthy(env2[MUTATION_APPROVAL_FLAG_ENV]) || isTruthy(env2[LEGACY_MUTATION_APPROVAL_FLAG_ENV]);
7797
+ }
7798
+ function assertMutationApproved(options) {
7799
+ if (isMutationApproved(options)) {
7800
+ return;
7801
+ }
7802
+ const env2 = options.env ?? process.env;
7803
+ const tokenConfigured = Boolean(env2[MUTATION_APPROVAL_TOKEN_ENV]?.trim());
7804
+ const approvalHint = options.surface === "mcp" ? `pass a scoped approval_token signed with ${MUTATION_APPROVAL_TOKEN_ENV}` : tokenConfigured ? `pass a scoped approval_token signed with ${MUTATION_APPROVAL_TOKEN_ENV} or set ${MUTATION_APPROVAL_FLAG_ENV}=1 for a trusted local session` : `set ${MUTATION_APPROVAL_FLAG_ENV}=1 for a trusted local session or configure ${MUTATION_APPROVAL_TOKEN_ENV}`;
7805
+ throw new Error(`Fleet mutation blocked: ${options.surface}.${options.operation} requires operator approval; ${approvalHint}.`);
7806
+ }
7807
+
7524
7808
  // src/remote.ts
7525
7809
  init_db();
7526
7810
  import { spawnSync as spawnSync2 } from "child_process";
7527
- import { hostname as hostname4 } from "os";
7811
+ import { existsSync as existsSync5, mkdtempSync, readFileSync as readFileSync3, rmSync } from "fs";
7812
+ import { hostname as hostname4, tmpdir } from "os";
7813
+ import { join as join3 } from "path";
7528
7814
 
7529
7815
  // src/topology.ts
7530
7816
  init_db();
@@ -8498,6 +8784,16 @@ function resolveMachineWorkspace(options) {
8498
8784
  function shellQuote2(value) {
8499
8785
  return `'${value.replace(/'/g, "'\\''")}'`;
8500
8786
  }
8787
+ function validateSshTarget(target) {
8788
+ const trimmed = target.trim();
8789
+ if (!trimmed || trimmed.startsWith("-") || /[\s"'`$\\;&|<>()[\]{}]/.test(trimmed)) {
8790
+ throw new Error(`Unsafe SSH target: ${target}`);
8791
+ }
8792
+ if (!/^(?:[A-Za-z0-9._%+-]+@)?[A-Za-z0-9._:-]+$/.test(trimmed)) {
8793
+ throw new Error(`Unsafe SSH target: ${target}`);
8794
+ }
8795
+ return trimmed;
8796
+ }
8501
8797
  function resolveSshTarget(machineId, options = {}) {
8502
8798
  const resolved = resolveMachineRoute(machineId, options);
8503
8799
  if (!resolved.ok || !resolved.target) {
@@ -8508,15 +8804,25 @@ function resolveSshTarget(machineId, options = {}) {
8508
8804
  }
8509
8805
  return {
8510
8806
  machineId: resolved.machine_id ?? machineId,
8511
- target: resolved.command_target ?? resolved.target,
8807
+ target: validateSshTarget(resolved.command_target ?? resolved.target),
8512
8808
  route: resolved.route,
8513
8809
  confidence: resolved.confidence,
8514
8810
  warnings: resolved.warnings
8515
8811
  };
8516
8812
  }
8517
8813
  function buildSshCommand(machineId, remoteCommand, options = {}) {
8814
+ return buildSshCommandPlan(machineId, remoteCommand, options).shellCommand;
8815
+ }
8816
+ function buildSshCommandPlan(machineId, remoteCommand, options = {}) {
8518
8817
  const resolved = resolveSshTarget(machineId, options);
8519
- return remoteCommand ? `ssh ${resolved.target} ${shellQuote2(remoteCommand)}` : `ssh ${resolved.target}`;
8818
+ const args = remoteCommand ? [resolved.target, remoteCommand] : [resolved.target];
8819
+ const shellCommand2 = `ssh ${args.map(shellQuote2).join(" ")}`;
8820
+ return {
8821
+ ...resolved,
8822
+ command: "ssh",
8823
+ args,
8824
+ shellCommand: shellCommand2
8825
+ };
8520
8826
  }
8521
8827
 
8522
8828
  // src/remote.ts
@@ -8528,35 +8834,233 @@ function machineIsLocal(machineId, localMachineId) {
8528
8834
  }
8529
8835
  function resolveMachineCommand(machineId, command, localMachineId = getLocalMachineId()) {
8530
8836
  if (machineIsLocal(machineId, localMachineId)) {
8531
- return { source: "local", shellCommand: command };
8837
+ return { source: "local", command: "bash", args: ["-c", command], shellCommand: command, usesShell: true };
8532
8838
  }
8533
8839
  try {
8840
+ const plan = buildSshCommandPlan(machineId, command);
8534
8841
  return {
8535
- source: resolveSshTarget(machineId).route,
8536
- shellCommand: buildSshCommand(machineId, command)
8842
+ source: plan.route,
8843
+ command: plan.command,
8844
+ args: plan.args,
8845
+ shellCommand: plan.shellCommand,
8846
+ usesShell: false
8537
8847
  };
8538
8848
  } catch (error) {
8539
8849
  const message = String(error.message ?? error);
8540
8850
  if (message.includes("Machine route not found") || message.includes("Machine not found in manifest")) {
8541
- return { source: "ssh", shellCommand: `ssh ${shellQuote3(machineId)} ${shellQuote3(command)}` };
8851
+ const target = validateSshTarget(machineId);
8852
+ return {
8853
+ source: "ssh",
8854
+ command: "ssh",
8855
+ args: [target, command],
8856
+ shellCommand: `ssh ${shellQuote3(target)} ${shellQuote3(command)}`,
8857
+ usesShell: false
8858
+ };
8542
8859
  }
8543
8860
  throw error;
8544
8861
  }
8545
8862
  }
8546
- function runMachineCommand(machineId, command) {
8863
+ function runMachineCommand(machineId, command, options = {}) {
8547
8864
  const resolved = resolveMachineCommand(machineId, command);
8548
- const result = spawnSync2("bash", ["-c", resolved.shellCommand], {
8865
+ if (options.timeoutMs && options.timeoutMs > 0 && process.platform !== "win32") {
8866
+ return runMachineCommandWithProcessGroupTimeout(machineId, resolved, options);
8867
+ }
8868
+ const result = spawnSync2(resolved.command, resolved.args, {
8549
8869
  encoding: "utf8",
8550
- env: process.env
8870
+ env: process.env,
8871
+ timeout: options.timeoutMs,
8872
+ killSignal: "SIGTERM"
8551
8873
  });
8874
+ const timedOut = Boolean(result.error && "code" in result.error && result.error.code === "ETIMEDOUT");
8875
+ const timeoutMessage = timedOut ? `Command timed out after ${options.timeoutMs}ms.` : "";
8876
+ const stderr = [result.stderr || "", timeoutMessage].filter(Boolean).join(result.stderr ? `
8877
+ ` : "");
8552
8878
  return {
8553
8879
  machineId,
8554
8880
  source: resolved.source,
8555
8881
  stdout: result.stdout || "",
8556
- stderr: result.stderr || "",
8557
- exitCode: result.status ?? 1
8882
+ stderr,
8883
+ exitCode: timedOut ? 124 : result.status ?? 1,
8884
+ timedOut,
8885
+ signal: result.signal
8558
8886
  };
8559
8887
  }
8888
+ function runMachineCommandWithProcessGroupTimeout(machineId, resolved, options) {
8889
+ const timeoutMs = Math.max(1, options.timeoutMs ?? 1);
8890
+ const killGraceMs = Math.max(1, options.killGraceMs ?? 1000);
8891
+ const helperDir = mkdtempSync(join3(tmpdir(), "machines-timeout-helper-"));
8892
+ const pgidFile = join3(helperDir, "pgid");
8893
+ const helper = spawnSync2(process.execPath, ["--eval", PROCESS_GROUP_TIMEOUT_HELPER], {
8894
+ input: JSON.stringify({ command: resolved.command, args: resolved.args }),
8895
+ encoding: "utf8",
8896
+ env: {
8897
+ ...process.env,
8898
+ HASNA_MACHINES_COMMAND_TIMEOUT_MS: String(timeoutMs),
8899
+ HASNA_MACHINES_COMMAND_KILL_GRACE_MS: String(killGraceMs),
8900
+ HASNA_MACHINES_COMMAND_PGID_FILE: pgidFile
8901
+ },
8902
+ timeout: timeoutMs + killGraceMs + 2000,
8903
+ killSignal: "SIGKILL",
8904
+ maxBuffer: 64 * 1024 * 1024
8905
+ });
8906
+ try {
8907
+ const parsed = parseHelperResult(helper.stdout);
8908
+ if (parsed) {
8909
+ return {
8910
+ machineId,
8911
+ source: resolved.source,
8912
+ stdout: parsed.stdout,
8913
+ stderr: parsed.stderr,
8914
+ exitCode: parsed.exitCode,
8915
+ timedOut: parsed.timedOut,
8916
+ signal: parsed.signal
8917
+ };
8918
+ }
8919
+ const helperTimedOut = Boolean(helper.error && "code" in helper.error && helper.error.code === "ETIMEDOUT");
8920
+ if (helperTimedOut)
8921
+ killPublishedProcessGroup(pgidFile);
8922
+ const timeoutMessage = helperTimedOut ? `Command timed out after ${timeoutMs}ms; timeout helper exceeded cleanup grace ${killGraceMs}ms.` : "";
8923
+ const stderr = [helper.stderr || "", timeoutMessage].filter(Boolean).join(helper.stderr ? `
8924
+ ` : "");
8925
+ return {
8926
+ machineId,
8927
+ source: resolved.source,
8928
+ stdout: "",
8929
+ stderr,
8930
+ exitCode: helperTimedOut ? 124 : helper.status ?? 1,
8931
+ timedOut: helperTimedOut,
8932
+ signal: helper.signal
8933
+ };
8934
+ } finally {
8935
+ rmSync(helperDir, { recursive: true, force: true });
8936
+ }
8937
+ }
8938
+ function killPublishedProcessGroup(pgidFile) {
8939
+ if (!existsSync5(pgidFile))
8940
+ return;
8941
+ try {
8942
+ const pid = Number.parseInt(readFileSync3(pgidFile, "utf8").trim(), 10);
8943
+ if (!Number.isInteger(pid) || pid <= 1)
8944
+ return;
8945
+ process.kill(-pid, "SIGKILL");
8946
+ } catch {}
8947
+ }
8948
+ function parseHelperResult(stdout) {
8949
+ if (!stdout)
8950
+ return null;
8951
+ try {
8952
+ const parsed = JSON.parse(stdout);
8953
+ if (typeof parsed.stdout !== "string" || typeof parsed.stderr !== "string" || typeof parsed.exitCode !== "number")
8954
+ return null;
8955
+ return {
8956
+ machineId: "",
8957
+ source: "local",
8958
+ stdout: parsed.stdout,
8959
+ stderr: parsed.stderr,
8960
+ exitCode: parsed.exitCode,
8961
+ timedOut: parsed.timedOut === true,
8962
+ signal: typeof parsed.signal === "string" ? parsed.signal : null
8963
+ };
8964
+ } catch {
8965
+ return null;
8966
+ }
8967
+ }
8968
+ var PROCESS_GROUP_TIMEOUT_HELPER = `
8969
+ const { spawn } = require("node:child_process");
8970
+ const { readFileSync, writeFileSync } = require("node:fs");
8971
+
8972
+ const plan = JSON.parse(readFileSync(0, "utf8"));
8973
+ const command = String(plan.command || "");
8974
+ const args = Array.isArray(plan.args) ? plan.args.map(String) : [];
8975
+ const timeoutMs = Math.max(1, Number.parseInt(process.env.HASNA_MACHINES_COMMAND_TIMEOUT_MS || "1", 10));
8976
+ const killGraceMs = Math.max(1, Number.parseInt(process.env.HASNA_MACHINES_COMMAND_KILL_GRACE_MS || "1000", 10));
8977
+ const pgidFile = process.env.HASNA_MACHINES_COMMAND_PGID_FILE || "";
8978
+ let stdout = "";
8979
+ let stderr = "";
8980
+ let timedOut = false;
8981
+ let finished = false;
8982
+ let timeoutTimer;
8983
+ let killTimer;
8984
+ let sigkillSent = false;
8985
+ let pendingExit = null;
8986
+
8987
+ const child = spawn(command, args, {
8988
+ detached: true,
8989
+ stdio: ["ignore", "pipe", "pipe"],
8990
+ env: process.env,
8991
+ });
8992
+
8993
+ if (pgidFile && child.pid) {
8994
+ try {
8995
+ writeFileSync(pgidFile, String(child.pid), { mode: 0o600 });
8996
+ } catch {}
8997
+ }
8998
+
8999
+ function appendText(target, chunk) {
9000
+ return target + String(chunk);
9001
+ }
9002
+
9003
+ function killTarget(signal) {
9004
+ if (!child.pid) return;
9005
+ if (process.platform === "win32") {
9006
+ try {
9007
+ process.kill(child.pid, signal);
9008
+ } catch {}
9009
+ return;
9010
+ }
9011
+ try {
9012
+ process.kill(-child.pid, signal);
9013
+ } catch {}
9014
+ }
9015
+
9016
+ function finish(code, signal) {
9017
+ if (finished) return;
9018
+ if (timedOut && !sigkillSent) {
9019
+ pendingExit = { code, signal };
9020
+ return;
9021
+ }
9022
+ finished = true;
9023
+ if (timeoutTimer) clearTimeout(timeoutTimer);
9024
+ if (killTimer) clearTimeout(killTimer);
9025
+ if (timedOut) {
9026
+ stderr = [stderr, "Command timed out after " + timeoutMs + "ms."].filter(Boolean).join(stderr ? "\\n" : "");
9027
+ }
9028
+ const exitCode = timedOut ? 124 : code ?? 1;
9029
+ process.stdout.write(JSON.stringify({
9030
+ stdout,
9031
+ stderr,
9032
+ exitCode,
9033
+ timedOut,
9034
+ signal: signal ?? null,
9035
+ }), () => process.exit(exitCode));
9036
+ }
9037
+
9038
+ child.stdout.setEncoding("utf8");
9039
+ child.stderr.setEncoding("utf8");
9040
+ child.stdout.on("data", (chunk) => { stdout = appendText(stdout, chunk); });
9041
+ child.stderr.on("data", (chunk) => { stderr = appendText(stderr, chunk); });
9042
+ let childExit = { code: null, signal: null };
9043
+ child.on("error", (error) => {
9044
+ stderr = [stderr, error instanceof Error ? error.message : String(error)].filter(Boolean).join(stderr ? "\\n" : "");
9045
+ finish(1, null);
9046
+ });
9047
+ child.on("exit", (code, signal) => {
9048
+ childExit = { code, signal };
9049
+ });
9050
+ child.on("close", (code, signal) => {
9051
+ finish(code ?? childExit.code, signal ?? childExit.signal);
9052
+ });
9053
+
9054
+ timeoutTimer = setTimeout(() => {
9055
+ timedOut = true;
9056
+ killTarget("SIGTERM");
9057
+ killTimer = setTimeout(() => {
9058
+ sigkillSent = true;
9059
+ killTarget("SIGKILL");
9060
+ if (pendingExit) finish(pendingExit.code, pendingExit.signal);
9061
+ }, killGraceMs);
9062
+ }, timeoutMs);
9063
+ `;
8560
9064
  function describeMachineCommandFailure(operation, result) {
8561
9065
  const detail = (result.stderr || result.stdout || "").trim();
8562
9066
  const suffix = detail ? `: ${detail}` : "";
@@ -8671,17 +9175,17 @@ function buildSetupPlan(machineId) {
8671
9175
  workspacePath: `${homedir2()}/workspace`
8672
9176
  };
8673
9177
  const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
8674
- return {
9178
+ return attachMutationPlanDigest({
8675
9179
  machineId: target.id,
8676
9180
  mode: "plan",
8677
9181
  steps,
8678
9182
  executed: 0
8679
- };
9183
+ });
8680
9184
  }
8681
- function runSetup(machineId, options = {}, runner = runMachineCommand) {
8682
- const plan = buildSetupPlan(machineId);
9185
+ function runSetupPlan(plan, options = {}, runner = runMachineCommand) {
9186
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
8683
9187
  if (!options.apply) {
8684
- return plan;
9188
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
8685
9189
  }
8686
9190
  if (!options.yes) {
8687
9191
  throw new Error("Setup execution requires --yes.");
@@ -8702,19 +9206,19 @@ function runSetup(machineId, options = {}, runner = runMachineCommand) {
8702
9206
  }
8703
9207
  executed += 1;
8704
9208
  }
8705
- const summary = {
9209
+ const summary = attachMutationPlanDigest({
8706
9210
  machineId: plan.machineId,
8707
9211
  mode: "apply",
8708
9212
  steps: plan.steps,
8709
9213
  executed
8710
- };
9214
+ });
8711
9215
  recordSetupRun(plan.machineId, "completed", summary);
8712
9216
  return summary;
8713
9217
  }
8714
9218
 
8715
9219
  // src/commands/backup.ts
8716
9220
  import { homedir as homedir3, hostname as hostname5 } from "os";
8717
- import { join as join3 } from "path";
9221
+ import { join as join4 } from "path";
8718
9222
  var MACHINES_BACKUP_BUCKET_ENV = "HASNA_MACHINES_S3_BUCKET";
8719
9223
  var MACHINES_BACKUP_BUCKET_FALLBACK_ENV = "MACHINES_S3_BUCKET";
8720
9224
  var MACHINES_BACKUP_PREFIX_ENV = "HASNA_MACHINES_S3_PREFIX";
@@ -8764,14 +9268,14 @@ function resolveBackupTarget(options = {}) {
8764
9268
  function defaultBackupSources() {
8765
9269
  const home = homedir3();
8766
9270
  return [
8767
- join3(home, ".hasna"),
8768
- join3(home, ".ssh"),
8769
- join3(home, ".secrets")
9271
+ join4(home, ".hasna"),
9272
+ join4(home, ".ssh"),
9273
+ join4(home, ".secrets")
8770
9274
  ];
8771
9275
  }
8772
9276
  function buildBackupPlan(bucket, prefix) {
8773
9277
  const target = resolveBackupTarget({ bucket, prefix });
8774
- const archivePath = join3(homedir3(), ".hasna", "machines", "backup.tgz");
9278
+ const archivePath = join4(homedir3(), ".hasna", "machines", "backup.tgz");
8775
9279
  const sources = defaultBackupSources();
8776
9280
  const steps = [
8777
9281
  {
@@ -8823,20 +9327,20 @@ function runBackup(bucket, prefix, options = {}) {
8823
9327
 
8824
9328
  // src/commands/cert.ts
8825
9329
  import { homedir as homedir4, platform as platform3 } from "os";
8826
- import { join as join4 } from "path";
9330
+ import { join as join5 } from "path";
8827
9331
  function quote3(value) {
8828
9332
  return `'${value.replace(/'/g, `'\\''`)}'`;
8829
9333
  }
8830
9334
  function certDir() {
8831
- return join4(homedir4(), ".hasna", "machines", "certs");
9335
+ return join5(homedir4(), ".hasna", "machines", "certs");
8832
9336
  }
8833
9337
  function buildCertPlan(domains) {
8834
9338
  if (domains.length === 0) {
8835
9339
  throw new Error("At least one domain is required.");
8836
9340
  }
8837
9341
  const primary = domains[0];
8838
- const certPath = join4(certDir(), `${primary}.pem`);
8839
- const keyPath = join4(certDir(), `${primary}-key.pem`);
9342
+ const certPath = join5(certDir(), `${primary}.pem`);
9343
+ const keyPath = join5(certDir(), `${primary}-key.pem`);
8840
9344
  const steps = [];
8841
9345
  if (platform3() === "darwin") {
8842
9346
  steps.push({
@@ -8901,16 +9405,16 @@ function runCertPlan(domains, options = {}) {
8901
9405
 
8902
9406
  // src/commands/dns.ts
8903
9407
  init_paths();
8904
- import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
8905
- import { join as join5 } from "path";
9408
+ import { existsSync as existsSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
9409
+ import { join as join6 } from "path";
8906
9410
  function getDnsPath() {
8907
- return join5(getDataDir(), "dns.json");
9411
+ return join6(getDataDir(), "dns.json");
8908
9412
  }
8909
9413
  function readMappings() {
8910
9414
  const path = getDnsPath();
8911
- if (!existsSync5(path))
9415
+ if (!existsSync6(path))
8912
9416
  return [];
8913
- return JSON.parse(readFileSync3(path, "utf8"));
9417
+ return JSON.parse(readFileSync4(path, "utf8"));
8914
9418
  }
8915
9419
  function writeMappings(mappings) {
8916
9420
  const path = getDnsPath();
@@ -8937,10 +9441,10 @@ function renderDomainMapping(domain) {
8937
9441
  hostsEntry: `${entry.targetHost} ${entry.domain}`,
8938
9442
  caddySnippet: `${entry.domain} {
8939
9443
  reverse_proxy 127.0.0.1:${entry.port}
8940
- tls ${join5(getDataDir(), "certs", `${entry.domain}.pem`)} ${join5(getDataDir(), "certs", `${entry.domain}-key.pem`)}
9444
+ tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
8941
9445
  }`,
8942
- certPath: join5(getDataDir(), "certs", `${entry.domain}.pem`),
8943
- keyPath: join5(getDataDir(), "certs", `${entry.domain}-key.pem`)
9446
+ certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
9447
+ keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
8944
9448
  };
8945
9449
  }
8946
9450
 
@@ -9077,12 +9581,12 @@ function listApps(machineId) {
9077
9581
  }
9078
9582
  function buildAppsPlan(machineId) {
9079
9583
  const machine = resolveMachine(machineId);
9080
- return {
9584
+ return attachMutationPlanDigest({
9081
9585
  machineId: machine.id,
9082
9586
  mode: "plan",
9083
9587
  steps: buildAppSteps(machine),
9084
9588
  executed: 0
9085
- };
9589
+ });
9086
9590
  }
9087
9591
  function getAppsStatus(machineId, runner = runMachineCommand) {
9088
9592
  const machine = resolveMachine(machineId);
@@ -9105,10 +9609,10 @@ function diffApps(machineId, runner = runMachineCommand) {
9105
9609
  installed: status.apps.filter((app) => app.installed).map((app) => app.name)
9106
9610
  };
9107
9611
  }
9108
- function runAppsInstall(machineId, options = {}, runner = runMachineCommand) {
9109
- const plan = buildAppsPlan(machineId);
9612
+ function runAppsPlan(plan, options = {}, runner = runMachineCommand) {
9613
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
9110
9614
  if (!options.apply)
9111
- return plan;
9615
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
9112
9616
  if (!options.yes) {
9113
9617
  throw new Error("App installation requires --yes.");
9114
9618
  }
@@ -9117,12 +9621,12 @@ function runAppsInstall(machineId, options = {}, runner = runMachineCommand) {
9117
9621
  requireMachineCommandSuccess(`App install ${step.id}`, runner(plan.machineId, step.command));
9118
9622
  executed += 1;
9119
9623
  }
9120
- return {
9624
+ return attachMutationPlanDigest({
9121
9625
  machineId: plan.machineId,
9122
9626
  mode: "apply",
9123
9627
  steps: plan.steps,
9124
9628
  executed
9125
- };
9629
+ });
9126
9630
  }
9127
9631
 
9128
9632
  // src/commands/install-claude.ts
@@ -9184,12 +9688,12 @@ function parseProbe(tool, stdout) {
9184
9688
  }
9185
9689
  function buildClaudeInstallPlan(machineId, tools) {
9186
9690
  const machine = resolveMachine2(machineId);
9187
- return {
9691
+ return attachMutationPlanDigest({
9188
9692
  machineId: machine.id,
9189
9693
  mode: "plan",
9190
9694
  steps: buildInstallSteps(machine, tools),
9191
9695
  executed: 0
9192
- };
9696
+ });
9193
9697
  }
9194
9698
  function getClaudeCliStatus(machineId, tools, runner = runMachineCommand) {
9195
9699
  const machine = resolveMachine2(machineId);
@@ -9212,10 +9716,10 @@ function diffClaudeCli(machineId, tools, runner = runMachineCommand) {
9212
9716
  installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
9213
9717
  };
9214
9718
  }
9215
- function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCommand) {
9216
- const plan = buildClaudeInstallPlan(machineId, tools);
9719
+ function runClaudeInstallPlan(plan, options = {}, runner = runMachineCommand) {
9720
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
9217
9721
  if (!options.apply)
9218
- return plan;
9722
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
9219
9723
  if (!options.yes) {
9220
9724
  throw new Error("Claude CLI installation requires --yes.");
9221
9725
  }
@@ -9224,12 +9728,12 @@ function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCom
9224
9728
  requireMachineCommandSuccess(`AI CLI install ${step.id}`, runner(plan.machineId, step.command));
9225
9729
  executed += 1;
9226
9730
  }
9227
- return {
9731
+ return attachMutationPlanDigest({
9228
9732
  machineId: plan.machineId,
9229
9733
  mode: "apply",
9230
9734
  steps: plan.steps,
9231
9735
  executed
9232
- };
9736
+ });
9233
9737
  }
9234
9738
 
9235
9739
  // src/commands/install-tailscale.ts
@@ -9269,17 +9773,17 @@ function buildTailscaleInstallPlan(machineId) {
9269
9773
  if (!machine) {
9270
9774
  throw new Error(`Machine not found in manifest: ${machineId}`);
9271
9775
  }
9272
- return {
9776
+ return attachMutationPlanDigest({
9273
9777
  machineId: machine.id,
9274
9778
  mode: "plan",
9275
9779
  steps: buildInstallSteps2(machine),
9276
9780
  executed: 0
9277
- };
9781
+ });
9278
9782
  }
9279
- function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand) {
9280
- const plan = buildTailscaleInstallPlan(machineId);
9783
+ function runTailscaleInstallPlan(plan, options = {}, runner = runMachineCommand) {
9784
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
9281
9785
  if (!options.apply)
9282
- return plan;
9786
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
9283
9787
  if (!options.yes) {
9284
9788
  throw new Error("Tailscale install requires --yes.");
9285
9789
  }
@@ -9288,21 +9792,23 @@ function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand
9288
9792
  requireMachineCommandSuccess(`Tailscale install ${step.id}`, runner(plan.machineId, step.command));
9289
9793
  executed += 1;
9290
9794
  }
9291
- return {
9795
+ return attachMutationPlanDigest({
9292
9796
  machineId: plan.machineId,
9293
9797
  mode: "apply",
9294
9798
  steps: plan.steps,
9295
9799
  executed
9296
- };
9800
+ });
9297
9801
  }
9298
9802
 
9299
9803
  // src/commands/notifications.ts
9300
- import { existsSync as existsSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
9804
+ import { accessSync, constants, existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
9805
+ import { delimiter, isAbsolute, join as join7 } from "path";
9301
9806
  init_paths();
9302
9807
  var notificationChannelSchema = exports_external.object({
9303
9808
  id: exports_external.string(),
9304
9809
  type: exports_external.enum(["email", "webhook", "command"]),
9305
9810
  target: exports_external.string(),
9811
+ commandArgs: exports_external.array(exports_external.string()).optional(),
9306
9812
  events: exports_external.array(exports_external.string()),
9307
9813
  enabled: exports_external.boolean()
9308
9814
  });
@@ -9311,19 +9817,31 @@ var notificationConfigSchema = exports_external.object({
9311
9817
  updatedAt: exports_external.string().optional(),
9312
9818
  channels: exports_external.array(notificationChannelSchema)
9313
9819
  });
9820
+ var trustedNotificationApproval = Symbol("trustedNotificationApproval");
9821
+ function createTrustedNotificationApproval() {
9822
+ return { [trustedNotificationApproval]: true };
9823
+ }
9824
+ function isTrustedNotificationApproval(approval) {
9825
+ return approval?.[trustedNotificationApproval] === true;
9826
+ }
9314
9827
  function sortChannels(channels) {
9315
9828
  return [...channels].sort((left, right) => left.id.localeCompare(right.id));
9316
9829
  }
9317
- function shellQuote5(value) {
9318
- return `'${value.replace(/'/g, `'\\''`)}'`;
9319
- }
9320
9830
  function hasCommand2(binary) {
9321
- const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], {
9322
- stdout: "ignore",
9323
- stderr: "ignore",
9324
- env: process.env
9325
- });
9326
- return result.exitCode === 0;
9831
+ return Boolean(resolveExecutable(binary));
9832
+ }
9833
+ function resolveExecutable(binary) {
9834
+ const trimmed = binary.trim();
9835
+ if (!trimmed)
9836
+ return null;
9837
+ const candidates = isAbsolute(trimmed) ? [trimmed] : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join7(dir, trimmed));
9838
+ for (const candidate of candidates) {
9839
+ try {
9840
+ accessSync(candidate, constants.X_OK);
9841
+ return candidate;
9842
+ } catch {}
9843
+ }
9844
+ return null;
9327
9845
  }
9328
9846
  function buildNotificationPreview(channel, event, message) {
9329
9847
  if (channel.type === "email") {
@@ -9332,7 +9850,8 @@ function buildNotificationPreview(channel, event, message) {
9332
9850
  if (channel.type === "webhook") {
9333
9851
  return `POST ${channel.target} with payload {"event":"${event}","message":"${message}"}`;
9334
9852
  }
9335
- return `${channel.target} --event ${event} --message ${JSON.stringify(message)}`;
9853
+ const args = channel.commandArgs?.length ? ` ${channel.commandArgs.join(" ")}` : "";
9854
+ return `${channel.target}${args} with HASNA_MACHINES_NOTIFICATION_* environment`;
9336
9855
  }
9337
9856
  async function dispatchEmail(channel, event, message) {
9338
9857
  const subject = `[${event}] machines notification`;
@@ -9343,7 +9862,7 @@ Content-Type: text/plain; charset=utf-8
9343
9862
  ${message}
9344
9863
  `;
9345
9864
  if (hasCommand2("sendmail")) {
9346
- const result = Bun.spawnSync(["bash", "-lc", "sendmail -t"], {
9865
+ const result = Bun.spawnSync(["sendmail", "-t"], {
9347
9866
  stdin: new TextEncoder().encode(body),
9348
9867
  stdout: "pipe",
9349
9868
  stderr: "pipe",
@@ -9361,8 +9880,9 @@ ${message}
9361
9880
  };
9362
9881
  }
9363
9882
  if (hasCommand2("mail")) {
9364
- const command = `printf %s ${shellQuote5(message)} | mail -s ${shellQuote5(subject)} ${shellQuote5(channel.target)}`;
9365
- const result = Bun.spawnSync(["bash", "-lc", command], {
9883
+ const result = Bun.spawnSync(["mail", "-s", subject, channel.target], {
9884
+ stdin: new TextEncoder().encode(`${message}
9885
+ `),
9366
9886
  stdout: "pipe",
9367
9887
  stderr: "pipe",
9368
9888
  env: process.env
@@ -9405,8 +9925,20 @@ async function dispatchWebhook(channel, event, message) {
9405
9925
  detail: `Webhook accepted with HTTP ${response.status}`
9406
9926
  };
9407
9927
  }
9408
- async function dispatchCommand(channel, event, message) {
9409
- const result = Bun.spawnSync(["bash", "-lc", channel.target], {
9928
+ async function dispatchCommand(channel, event, message, options = {}) {
9929
+ if (!isTrustedNotificationApproval(options.trustedApproval)) {
9930
+ assertMutationApproved({
9931
+ surface: "notifications",
9932
+ operation: "dispatch_command",
9933
+ resourceId: channel.id,
9934
+ approvalToken: options.approvalToken
9935
+ });
9936
+ }
9937
+ const executable = resolveExecutable(channel.target);
9938
+ if (!executable) {
9939
+ throw new Error(`Command executable not found or not executable: ${channel.target}`);
9940
+ }
9941
+ const result = Bun.spawnSync([executable, ...channel.commandArgs ?? []], {
9410
9942
  stdout: "pipe",
9411
9943
  stderr: "pipe",
9412
9944
  env: {
@@ -9428,7 +9960,7 @@ async function dispatchCommand(channel, event, message) {
9428
9960
  detail: stdout || "Command completed successfully"
9429
9961
  };
9430
9962
  }
9431
- async function dispatchChannel(channel, event, message) {
9963
+ async function dispatchChannel(channel, event, message, options = {}) {
9432
9964
  if (!channel.enabled) {
9433
9965
  return {
9434
9966
  channelId: channel.id,
@@ -9444,7 +9976,7 @@ async function dispatchChannel(channel, event, message) {
9444
9976
  if (channel.type === "webhook") {
9445
9977
  return dispatchWebhook(channel, event, message);
9446
9978
  }
9447
- return dispatchCommand(channel, event, message);
9979
+ return dispatchCommand(channel, event, message, options);
9448
9980
  }
9449
9981
  function getDefaultNotificationConfig() {
9450
9982
  return {
@@ -9454,10 +9986,10 @@ function getDefaultNotificationConfig() {
9454
9986
  };
9455
9987
  }
9456
9988
  function readNotificationConfig(path = getNotificationsPath()) {
9457
- if (!existsSync6(path)) {
9989
+ if (!existsSync7(path)) {
9458
9990
  return getDefaultNotificationConfig();
9459
9991
  }
9460
- return notificationConfigSchema.parse(JSON.parse(readFileSync4(path, "utf8")));
9992
+ return notificationConfigSchema.parse(JSON.parse(readFileSync5(path, "utf8")));
9461
9993
  }
9462
9994
  function writeNotificationConfig(config, path = getNotificationsPath()) {
9463
9995
  ensureParentDir(path);
@@ -9473,11 +10005,20 @@ function writeNotificationConfig(config, path = getNotificationsPath()) {
9473
10005
  function listNotificationChannels() {
9474
10006
  return readNotificationConfig();
9475
10007
  }
9476
- function addNotificationChannel(channel) {
10008
+ function addNotificationChannel(channel, options = {}) {
10009
+ if (channel.type === "command" && !isTrustedNotificationApproval(options.trustedApproval)) {
10010
+ assertMutationApproved({
10011
+ surface: "notifications",
10012
+ operation: "add_command_channel",
10013
+ resourceId: channel.id,
10014
+ approvalToken: options.approvalToken
10015
+ });
10016
+ }
9477
10017
  const config = readNotificationConfig();
9478
10018
  const channels = config.channels.filter((entry) => entry.id !== channel.id);
9479
10019
  channels.push({
9480
10020
  ...channel,
10021
+ commandArgs: channel.commandArgs?.map(String),
9481
10022
  events: [...new Set(channel.events)]
9482
10023
  });
9483
10024
  return writeNotificationConfig({ ...config, channels });
@@ -9499,7 +10040,7 @@ async function dispatchNotificationEvent(event, message, options = {}) {
9499
10040
  const deliveries = [];
9500
10041
  for (const channel of channels) {
9501
10042
  try {
9502
- deliveries.push(await dispatchChannel(channel, event, message));
10043
+ deliveries.push(await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval }));
9503
10044
  } catch (error) {
9504
10045
  deliveries.push({
9505
10046
  channelId: channel.id,
@@ -9534,7 +10075,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
9534
10075
  if (!options.yes) {
9535
10076
  throw new Error("Notification test execution requires --yes.");
9536
10077
  }
9537
- const delivery = await dispatchChannel(channel, event, message);
10078
+ const delivery = await dispatchChannel(channel, event, message, { approvalToken: options.approvalToken, trustedApproval: options.trustedApproval });
9538
10079
  return {
9539
10080
  channelId,
9540
10081
  mode: "apply",
@@ -9603,6 +10144,46 @@ function listPorts(machineId) {
9603
10144
  import { spawnSync as spawnSync4 } from "child_process";
9604
10145
  import { setTimeout as sleep } from "timers/promises";
9605
10146
  import { EventsClient } from "@hasna/events";
10147
+ function shellQuote5(value) {
10148
+ return `'${value.replace(/'/g, "'\\''")}'`;
10149
+ }
10150
+ function buildTmuxPaneDiedHookPlan(options = {}) {
10151
+ const tmuxCommand = options.tmuxCommand ?? process.env["HASNA_MACHINES_TMUX_BIN"] ?? "tmux";
10152
+ const machinesCommand = options.machinesCommand ?? "machines";
10153
+ const deliver = options.deliver === true;
10154
+ const approvalToken = options.approvalToken?.trim();
10155
+ const trustedLocalMutation = approvalToken ? false : options.trustedLocalMutation === true;
10156
+ const emitArgs = [
10157
+ "events",
10158
+ "emit",
10159
+ "machines.tmux.pane_died",
10160
+ "--source",
10161
+ "machines",
10162
+ "--subject",
10163
+ "tmux:#{hook_pane}",
10164
+ "--severity",
10165
+ "warning",
10166
+ "--message",
10167
+ "tmux pane died: #{hook_pane}",
10168
+ "--data",
10169
+ '{"target":"#{hook_pane}","session":"#{session_name}","window":"#{window_index}"}'
10170
+ ];
10171
+ if (!deliver)
10172
+ emitArgs.push("--no-deliver");
10173
+ if (approvalToken)
10174
+ emitArgs.push("--approval-token", approvalToken);
10175
+ const command = [machinesCommand, ...emitArgs].map(shellQuote5).join(" ");
10176
+ const runShell = trustedLocalMutation ? `HASNA_MACHINES_ALLOW_MUTATIONS=1 ${command}` : command;
10177
+ const args = ["set-hook", "-g", "pane-died", `run-shell ${shellQuote5(runShell)}`];
10178
+ return {
10179
+ tmuxCommand,
10180
+ args,
10181
+ shellCommand: [tmuxCommand, ...args].map(shellQuote5).join(" "),
10182
+ eventType: "machines.tmux.pane_died",
10183
+ deliver,
10184
+ trustedLocalMutation
10185
+ };
10186
+ }
9606
10187
  function probeTmuxPane(target, tmuxCommand = process.env["HASNA_MACHINES_TMUX_BIN"] || "tmux") {
9607
10188
  const checkedAt = new Date().toISOString();
9608
10189
  const result = spawnSync4(tmuxCommand, ["display-message", "-p", "-t", target, "#{pane_id}"], {
@@ -9789,17 +10370,23 @@ function buildScreenEnableCommand(machineId, options = {}) {
9789
10370
  }
9790
10371
  const secretsCommand = options.secretsCommand || "secrets";
9791
10372
  const remoteCommand = buildScreenEnableRemoteCommandFromStdin(credentials.user);
10373
+ const secretsCommandArgs = [secretsCommand, "get", credentials.passwordSecretKey];
10374
+ const sshPlan = buildSshCommandPlan(machineId, remoteCommand, options);
9792
10375
  return {
9793
10376
  machineId: credentials.machineId,
9794
10377
  user: credentials.user,
9795
10378
  passwordSecretKey: credentials.passwordSecretKey,
9796
10379
  remoteCommand,
9797
- command: `${shellCommand2([secretsCommand, "get", credentials.passwordSecretKey])} | ${buildSshCommand(machineId, remoteCommand, options)}`
10380
+ secretsCommand,
10381
+ secretsCommandArgs,
10382
+ sshCommand: sshPlan.command,
10383
+ sshCommandArgs: sshPlan.args,
10384
+ command: `${shellCommand2(secretsCommandArgs)} | ${sshPlan.shellCommand}`
9798
10385
  };
9799
10386
  }
9800
10387
 
9801
10388
  // src/commands/sync.ts
9802
- import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
10389
+ import { existsSync as existsSync8, lstatSync, readFileSync as readFileSync6, symlinkSync, copyFileSync } from "fs";
9803
10390
  import { homedir as homedir5 } from "os";
9804
10391
  init_paths();
9805
10392
  init_db();
@@ -9854,15 +10441,15 @@ function detectFileActions(machine) {
9854
10441
  throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
9855
10442
  }
9856
10443
  return (machine.files || []).map((file, index) => {
9857
- const sourceExists = existsSync7(file.source);
9858
- const targetExists = existsSync7(file.target);
10444
+ const sourceExists = existsSync8(file.source);
10445
+ const targetExists = existsSync8(file.target);
9859
10446
  let status = "missing";
9860
10447
  if (sourceExists && targetExists) {
9861
10448
  if (file.mode === "symlink") {
9862
10449
  status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
9863
10450
  } else {
9864
- const source = readFileSync5(file.source, "utf8");
9865
- const target = readFileSync5(file.target, "utf8");
10451
+ const source = readFileSync6(file.source, "utf8");
10452
+ const target = readFileSync6(file.target, "utf8");
9866
10453
  status = source === target ? "ok" : "drifted";
9867
10454
  }
9868
10455
  }
@@ -9892,12 +10479,12 @@ function buildSyncPlan(machineId, runner = runMachineCommand) {
9892
10479
  ...detectPackageActions(target, runner),
9893
10480
  ...detectFileActions(target)
9894
10481
  ];
9895
- return {
10482
+ return attachMutationPlanDigest({
9896
10483
  machineId: target.id,
9897
10484
  mode: "plan",
9898
10485
  actions,
9899
10486
  executed: 0
9900
- };
10487
+ });
9901
10488
  }
9902
10489
  function applyFileAction(command) {
9903
10490
  const [verb, source, target] = command.split(" ");
@@ -9919,10 +10506,10 @@ function applyFileAction(command) {
9919
10506
  symlinkSync(sourcePath, targetPath);
9920
10507
  }
9921
10508
  }
9922
- function runSync(machineId, options = {}, runner = runMachineCommand) {
9923
- const plan = buildSyncPlan(machineId, runner);
10509
+ function runSyncPlan(plan, options = {}, runner = runMachineCommand) {
10510
+ assertMutationPlanDigest(plan, options.expectedPlanDigest);
9924
10511
  if (!options.apply) {
9925
- return plan;
10512
+ return attachMutationPlanDigest({ ...plan, mode: "plan", executed: 0 });
9926
10513
  }
9927
10514
  if (!options.yes) {
9928
10515
  throw new Error("Sync execution requires --yes.");
@@ -9949,12 +10536,12 @@ function runSync(machineId, options = {}, runner = runMachineCommand) {
9949
10536
  }
9950
10537
  executed += 1;
9951
10538
  }
9952
- const summary = {
10539
+ const summary = attachMutationPlanDigest({
9953
10540
  machineId: plan.machineId,
9954
10541
  mode: "apply",
9955
10542
  actions: plan.actions,
9956
10543
  executed
9957
- };
10544
+ });
9958
10545
  recordSyncRun(plan.machineId, "completed", summary);
9959
10546
  return summary;
9960
10547
  }
@@ -10589,8 +11176,8 @@ function runDoctor(machineId, options = {}) {
10589
11176
 
10590
11177
  // src/commands/daemon.ts
10591
11178
  import { execFileSync } from "child_process";
10592
- import { chmodSync, existsSync as existsSync8, readFileSync as readFileSync6, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
10593
- import { dirname as dirname4 } from "path";
11179
+ import { chmodSync, existsSync as existsSync9, readFileSync as readFileSync7, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
11180
+ import { delimiter as delimiter2, dirname as dirname4 } from "path";
10594
11181
  import { platform as osPlatform } from "os";
10595
11182
  var DEFAULT_SERVICE_NAME = "machines-agent";
10596
11183
  var DEFAULT_EXECUTABLE = "/usr/local/bin/machines-agent";
@@ -10980,19 +11567,31 @@ WantedBy=${options.mode === "system" ? "multi-user.target" : "default.target"}
10980
11567
  `;
10981
11568
  }
10982
11569
  function daemonProgramArguments(options) {
10983
- const bunRuntime = siblingBunRuntime(options.executable);
11570
+ const bunRuntime = bunRuntimeForExecutable(options.executable);
10984
11571
  const base = bunRuntime ? [bunRuntime, options.executable] : [options.executable];
10985
11572
  return [...base, "--interval-ms", String(options.intervalMs)];
10986
11573
  }
10987
- function siblingBunRuntime(executable) {
11574
+ function bunRuntimeForExecutable(executable) {
10988
11575
  if (!isBunShebangScript(executable))
10989
11576
  return null;
10990
- const candidate = `${dirname4(executable)}/bun`;
10991
- return isExecutableFile(candidate) ? candidate : null;
11577
+ for (const candidate of bunRuntimeCandidates(executable)) {
11578
+ if (isExecutableFile(candidate))
11579
+ return candidate;
11580
+ }
11581
+ return null;
11582
+ }
11583
+ function bunRuntimeCandidates(executable) {
11584
+ const candidates = [
11585
+ `${dirname4(executable)}/bun`,
11586
+ process.env["BUN_INSTALL"] ? `${process.env["BUN_INSTALL"]}/bin/bun` : null,
11587
+ process.env["HOME"] ? `${process.env["HOME"]}/.bun/bin/bun` : null,
11588
+ ...(process.env["PATH"] ?? "").split(delimiter2).filter(Boolean).map((entry) => `${entry}/bun`)
11589
+ ].filter((value) => Boolean(value));
11590
+ return [...new Set(candidates)];
10992
11591
  }
10993
11592
  function isBunShebangScript(executable) {
10994
11593
  try {
10995
- const content = readFileSync6(executable, "utf8").slice(0, 256);
11594
+ const content = readFileSync7(executable, "utf8").slice(0, 256);
10996
11595
  const firstLine2 = content.split(/\r?\n/, 1)[0] ?? "";
10997
11596
  return /^#!.*\bbun\b/.test(firstLine2);
10998
11597
  } catch {
@@ -11000,7 +11599,7 @@ function isBunShebangScript(executable) {
11000
11599
  }
11001
11600
  }
11002
11601
  function isExecutableFile(path) {
11003
- if (!existsSync8(path))
11602
+ if (!existsSync9(path))
11004
11603
  return false;
11005
11604
  try {
11006
11605
  const stats = statSync(path);
@@ -11050,7 +11649,8 @@ function basename(path) {
11050
11649
  init_db();
11051
11650
 
11052
11651
  // src/commands/serve.ts
11053
- import { EventsClient as EventsClient2, sanitizeChannelsForOutput } from "@hasna/events";
11652
+ import { EventsClient as EventsClient2, getEventsDataDir, sanitizeChannelsForOutput } from "@hasna/events";
11653
+ import { resolve as resolve3 } from "path";
11054
11654
 
11055
11655
  // src/agent/runtime.ts
11056
11656
  import { arch as arch3, hostname as hostname6, platform as platform4, release, uptime, version as osVersion } from "os";
@@ -11137,7 +11737,7 @@ function escapeHtml(value) {
11137
11737
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
11138
11738
  }
11139
11739
  function getServeInfo(options = {}) {
11140
- const host = options.host || "0.0.0.0";
11740
+ const host = options.host || "127.0.0.1";
11141
11741
  const port = options.port || 7676;
11142
11742
  return {
11143
11743
  host,
@@ -11340,6 +11940,59 @@ async function parseJsonBody(request) {
11340
11940
  function jsonError(message, status = 400) {
11341
11941
  return Response.json({ error: message }, { status });
11342
11942
  }
11943
+ function dashboardResourceId(kind, ...parts) {
11944
+ const values = parts.map((part) => String(part ?? "*").trim()).filter(Boolean).join(":");
11945
+ return values ? `${kind}:${values}` : kind;
11946
+ }
11947
+ function eventStoreDir() {
11948
+ return resolve3(getEventsDataDir());
11949
+ }
11950
+ function eventStoreScope() {
11951
+ return { event_store_dir: eventStoreDir() };
11952
+ }
11953
+ function eventStoreResourceId(kind, ...parts) {
11954
+ return dashboardResourceId(kind, mutationArgsSha256(eventStoreScope()), ...parts);
11955
+ }
11956
+ function withEventStoreScope(args) {
11957
+ return { event_store_dir: eventStoreDir(), ...args };
11958
+ }
11959
+ function dashboardMutationCallerId() {
11960
+ return process.env[MUTATION_APPROVAL_CALLER_ENV]?.trim() || "dashboard";
11961
+ }
11962
+ function dashboardMutationRunId() {
11963
+ return process.env[MUTATION_APPROVAL_RUN_ENV]?.trim() || "dashboard";
11964
+ }
11965
+ function approvalTokenFromRequest(request, body) {
11966
+ const bodyToken = typeof body["approval_token"] === "string" ? body["approval_token"] : typeof body["approvalToken"] === "string" ? body["approvalToken"] : undefined;
11967
+ if (bodyToken?.trim())
11968
+ return bodyToken;
11969
+ const headerToken = request.headers.get("x-hasna-approval-token")?.trim();
11970
+ if (headerToken)
11971
+ return headerToken;
11972
+ const authorization = request.headers.get("authorization")?.trim();
11973
+ if (authorization?.toLowerCase().startsWith("bearer ")) {
11974
+ return authorization.slice("bearer ".length).trim();
11975
+ }
11976
+ return;
11977
+ }
11978
+ function requireDashboardMutation(operation, request, body, scope = {}) {
11979
+ const decision = verifyMutationApprovalToken({
11980
+ surface: "dashboard",
11981
+ operation,
11982
+ transport: "dashboard:http",
11983
+ callerId: dashboardMutationCallerId(),
11984
+ runId: dashboardMutationRunId(),
11985
+ resourceId: scope.resourceId,
11986
+ args: scope.args,
11987
+ approvalToken: approvalTokenFromRequest(request, body)
11988
+ });
11989
+ if (decision.approved)
11990
+ return;
11991
+ return jsonError(`Mutation approval denied: ${decision.reason ?? "approval_token is invalid."}`, 403);
11992
+ }
11993
+ function objectBodyValue(value) {
11994
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
11995
+ }
11343
11996
  function privateOutputWarnings(requested, allowed) {
11344
11997
  return requested && !allowed ? [PRIVATE_OUTPUT_DENIED_WARNING] : [];
11345
11998
  }
@@ -11351,6 +12004,7 @@ function appendWarnings(payload, warnings) {
11351
12004
  function startDashboardServer(options = {}) {
11352
12005
  const info = getServeInfo(options);
11353
12006
  const events = new EventsClient2;
12007
+ const trustedNotificationApproval2 = createTrustedNotificationApproval();
11354
12008
  return Bun.serve({
11355
12009
  hostname: info.host,
11356
12010
  port: info.port,
@@ -11415,8 +12069,25 @@ function startDashboardServer(options = {}) {
11415
12069
  const severity = typeof body["severity"] === "string" ? body["severity"] : undefined;
11416
12070
  const message = typeof body["message"] === "string" ? body["message"] : undefined;
11417
12071
  const dedupeKey = typeof body["dedupeKey"] === "string" ? body["dedupeKey"] : undefined;
11418
- const data = body["data"] && typeof body["data"] === "object" && !Array.isArray(body["data"]) ? body["data"] : {};
11419
- const metadata = body["metadata"] && typeof body["metadata"] === "object" && !Array.isArray(body["metadata"]) ? body["metadata"] : {};
12072
+ const data = objectBodyValue(body["data"]);
12073
+ const metadata = objectBodyValue(body["metadata"]);
12074
+ const denied = requireDashboardMutation("machines_events_emit", request, body, {
12075
+ resourceId: eventStoreResourceId("event", type, subject, dedupeKey),
12076
+ args: withEventStoreScope({
12077
+ event_type: type,
12078
+ source,
12079
+ subject,
12080
+ severity,
12081
+ message,
12082
+ data,
12083
+ metadata,
12084
+ dedupe_key: dedupeKey,
12085
+ deliver: true,
12086
+ dedupe: true
12087
+ })
12088
+ });
12089
+ if (denied)
12090
+ return denied;
11420
12091
  return Response.json(await events.emit({
11421
12092
  source,
11422
12093
  type,
@@ -11459,8 +12130,20 @@ function startDashboardServer(options = {}) {
11459
12130
  const message = typeof body["message"] === "string" ? body["message"] : undefined;
11460
12131
  const apply = body["apply"] === true;
11461
12132
  const yes = body["yes"] === true;
12133
+ const resolvedEvent = event ?? "manual.test";
12134
+ const resolvedMessage = message ?? "machines notification test";
12135
+ const denied = requireDashboardMutation("machines_notifications_test", request, body, {
12136
+ resourceId: dashboardResourceId("notification-test", channelId, resolvedEvent),
12137
+ args: { channel_id: channelId, event: resolvedEvent, message: resolvedMessage, apply, yes }
12138
+ });
12139
+ if (denied)
12140
+ return denied;
11462
12141
  try {
11463
- return Response.json(await testNotificationChannel(channelId, event, message, { apply, yes }));
12142
+ return Response.json(await testNotificationChannel(channelId, resolvedEvent, resolvedMessage, {
12143
+ apply,
12144
+ yes,
12145
+ trustedApproval: apply ? trustedNotificationApproval2 : undefined
12146
+ }));
11464
12147
  } catch (error) {
11465
12148
  return jsonError(error instanceof Error ? error.message : String(error));
11466
12149
  }
@@ -11475,13 +12158,22 @@ function startDashboardServer(options = {}) {
11475
12158
  return jsonError("channelId is required.");
11476
12159
  }
11477
12160
  const type = typeof body["type"] === "string" ? body["type"] : "events.test";
11478
- const message = typeof body["message"] === "string" ? body["message"] : undefined;
12161
+ const subject = channelId;
12162
+ const message = typeof body["message"] === "string" ? body["message"] : "Hasna events test delivery";
12163
+ const data = objectBodyValue(body["data"]);
12164
+ const denied = requireDashboardMutation("machines_webhooks_test", request, body, {
12165
+ resourceId: eventStoreResourceId("webhook-test", channelId, type),
12166
+ args: withEventStoreScope({ channel_id: channelId, event_type: type, subject, message, data })
12167
+ });
12168
+ if (denied)
12169
+ return denied;
11479
12170
  try {
11480
12171
  return Response.json(await events.testChannel(channelId, {
11481
12172
  source: "machines",
11482
12173
  type,
11483
- subject: channelId,
11484
- message
12174
+ subject,
12175
+ message,
12176
+ data
11485
12177
  }));
11486
12178
  } catch (error) {
11487
12179
  return jsonError(error instanceof Error ? error.message : String(error));
@@ -11529,9 +12221,9 @@ function runSelfTest() {
11529
12221
 
11530
12222
  // src/commands/clipboard.ts
11531
12223
  init_paths();
11532
- import { createHash } from "crypto";
11533
- import { existsSync as existsSync9, readFileSync as readFileSync7, rmSync, writeFileSync as writeFileSync5 } from "fs";
11534
- import { join as join6 } from "path";
12224
+ import { createHash as createHash2 } from "crypto";
12225
+ import { existsSync as existsSync10, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
12226
+ import { join as join8 } from "path";
11535
12227
  var DEFAULT_CONFIG = {
11536
12228
  version: 1,
11537
12229
  enabled: true,
@@ -11549,7 +12241,7 @@ var DEFAULT_CONFIG = {
11549
12241
  function resolveConfigPath(configPath) {
11550
12242
  if (configPath)
11551
12243
  return configPath;
11552
- return join6(getDataDir(), "clipboard-config.json");
12244
+ return join8(getDataDir(), "clipboard-config.json");
11553
12245
  }
11554
12246
  function resolveHistoryPath(historyPath) {
11555
12247
  if (historyPath)
@@ -11561,10 +12253,10 @@ function getDefaultConfig() {
11561
12253
  }
11562
12254
  function readConfig(configPath) {
11563
12255
  const path = resolveConfigPath(configPath);
11564
- if (!existsSync9(path)) {
12256
+ if (!existsSync10(path)) {
11565
12257
  return getDefaultConfig();
11566
12258
  }
11567
- const parsed = JSON.parse(readFileSync7(path, "utf8"));
12259
+ const parsed = JSON.parse(readFileSync8(path, "utf8"));
11568
12260
  return { ...getDefaultConfig(), ...parsed };
11569
12261
  }
11570
12262
  function writeConfig(config, configPath) {
@@ -11575,11 +12267,11 @@ function writeConfig(config, configPath) {
11575
12267
  }
11576
12268
  function readHistory(historyPath) {
11577
12269
  const path = resolveHistoryPath(historyPath);
11578
- if (!existsSync9(path)) {
12270
+ if (!existsSync10(path)) {
11579
12271
  return [];
11580
12272
  }
11581
12273
  try {
11582
- return JSON.parse(readFileSync7(path, "utf8"));
12274
+ return JSON.parse(readFileSync8(path, "utf8"));
11583
12275
  } catch {
11584
12276
  return [];
11585
12277
  }
@@ -11591,7 +12283,7 @@ function writeHistory(entries, historyPath) {
11591
12283
  `, "utf8");
11592
12284
  }
11593
12285
  function computeHash(content) {
11594
- return createHash("sha256").update(content).digest("hex").slice(0, 16);
12286
+ return createHash2("sha256").update(content).digest("hex").slice(0, 16);
11595
12287
  }
11596
12288
  function shouldSkipContent(content, skipPatterns) {
11597
12289
  const lower = content.toLowerCase();
@@ -11608,10 +12300,10 @@ function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
11608
12300
  }
11609
12301
  function getOrCreateClipboardKey() {
11610
12302
  const keyPath = getClipboardKeyPath();
11611
- if (existsSync9(keyPath)) {
11612
- return readFileSync7(keyPath, "utf8").trim();
12303
+ if (existsSync10(keyPath)) {
12304
+ return readFileSync8(keyPath, "utf8").trim();
11613
12305
  }
11614
- const key = createHash("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
12306
+ const key = createHash2("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
11615
12307
  ensureParentDir(keyPath);
11616
12308
  writeFileSync5(keyPath, `${key}
11617
12309
  `, "utf8");
@@ -11648,8 +12340,8 @@ function addClipboardEntry(entry, historyPath) {
11648
12340
  }
11649
12341
  function clearClipboardHistory(historyPath) {
11650
12342
  const path = resolveHistoryPath(historyPath);
11651
- if (existsSync9(path)) {
11652
- rmSync(path);
12343
+ if (existsSync10(path)) {
12344
+ rmSync2(path);
11653
12345
  }
11654
12346
  }
11655
12347
  function getClipboardStatus(historyPath) {
@@ -11664,15 +12356,15 @@ function getClipboardStatus(historyPath) {
11664
12356
 
11665
12357
  // src/commands/clipboard-daemon.ts
11666
12358
  init_paths();
11667
- import { readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
11668
- import { join as join7 } from "path";
11669
- import { createHash as createHash3 } from "crypto";
12359
+ import { readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
12360
+ import { join as join9 } from "path";
12361
+ import { createHash as createHash4 } from "crypto";
11670
12362
 
11671
12363
  // src/commands/clipboard-server.ts
11672
12364
  init_paths();
11673
12365
  import { createServer } from "http";
11674
- import { createHash as createHash2 } from "crypto";
11675
- import { readFileSync as readFileSync8 } from "fs";
12366
+ import { createHash as createHash3 } from "crypto";
12367
+ import { readFileSync as readFileSync9 } from "fs";
11676
12368
  function readLocalClipboardSync() {
11677
12369
  const platform5 = process.platform;
11678
12370
  if (platform5 === "darwin") {
@@ -11718,7 +12410,7 @@ function hasCommand3(binary) {
11718
12410
  function loadSharedSecret() {
11719
12411
  const keyPath = getClipboardKeyPath();
11720
12412
  try {
11721
- return readFileSync8(keyPath, "utf8").trim();
12413
+ return readFileSync9(keyPath, "utf8").trim();
11722
12414
  } catch {
11723
12415
  return "";
11724
12416
  }
@@ -11732,7 +12424,7 @@ function authenticate(request) {
11732
12424
  const secret = loadSharedSecret();
11733
12425
  if (!secret)
11734
12426
  return false;
11735
- return createHash2("sha256").update(token).digest("hex") === createHash2("sha256").update(secret).digest("hex");
12427
+ return createHash3("sha256").update(token).digest("hex") === createHash3("sha256").update(secret).digest("hex");
11736
12428
  }
11737
12429
  function jsonResponse(response, status, data) {
11738
12430
  response.writeHead(status, { "content-type": "application/json" });
@@ -11772,7 +12464,7 @@ function startClipboardServer(options = {}) {
11772
12464
  server,
11773
12465
  port,
11774
12466
  close: async () => {
11775
- await new Promise((resolve2) => server.close(() => resolve2()));
12467
+ await new Promise((resolve4) => server.close(() => resolve4()));
11776
12468
  }
11777
12469
  };
11778
12470
  }
@@ -11823,7 +12515,7 @@ function handleGetClipboard(response, config) {
11823
12515
  }
11824
12516
 
11825
12517
  // src/commands/clipboard-daemon.ts
11826
- var DAEMON_PID_PATH = join7(getDataDir(), "clipboard-daemon.pid");
12518
+ var DAEMON_PID_PATH = join9(getDataDir(), "clipboard-daemon.pid");
11827
12519
  function readLocalClipboardSync2() {
11828
12520
  const platform5 = process.platform;
11829
12521
  if (platform5 === "darwin") {
@@ -11881,11 +12573,11 @@ function hasDisplayServer() {
11881
12573
  return false;
11882
12574
  }
11883
12575
  function computeHash2(content) {
11884
- return createHash3("sha256").update(content).digest("hex").slice(0, 16);
12576
+ return createHash4("sha256").update(content).digest("hex").slice(0, 16);
11885
12577
  }
11886
12578
  function loadSharedSecret2() {
11887
12579
  try {
11888
- return readFileSync9(getClipboardKeyPath(), "utf8").trim();
12580
+ return readFileSync10(getClipboardKeyPath(), "utf8").trim();
11889
12581
  } catch {
11890
12582
  return "";
11891
12583
  }
@@ -11896,7 +12588,7 @@ function writePid(pid) {
11896
12588
  }
11897
12589
  function readPid() {
11898
12590
  try {
11899
- const pid = Number.parseInt(readFileSync9(DAEMON_PID_PATH, "utf8").trim());
12591
+ const pid = Number.parseInt(readFileSync10(DAEMON_PID_PATH, "utf8").trim());
11900
12592
  return Number.isFinite(pid) ? pid : null;
11901
12593
  } catch {
11902
12594
  return null;
@@ -11997,8 +12689,8 @@ async function discoverPeers() {
11997
12689
 
11998
12690
  // src/commands/heal.ts
11999
12691
  init_paths();
12000
- import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
12001
- import { join as join8 } from "path";
12692
+ import { existsSync as existsSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
12693
+ import { join as join10 } from "path";
12002
12694
  var DEFAULT_THRESHOLDS = {
12003
12695
  reconnect: 3,
12004
12696
  nmRestart: 7,
@@ -12042,16 +12734,16 @@ function defaultHealState() {
12042
12734
  };
12043
12735
  }
12044
12736
  function getHealConfigPath() {
12045
- return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] || join8(getDataDir(), "heal-config.json");
12737
+ return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] || join10(getDataDir(), "heal-config.json");
12046
12738
  }
12047
12739
  function getHealStatePath() {
12048
- return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] || join8(getDataDir(), "heal-state.json");
12740
+ return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] || join10(getDataDir(), "heal-state.json");
12049
12741
  }
12050
12742
  function readHealConfig(path) {
12051
12743
  const p = path || getHealConfigPath();
12052
- if (!existsSync10(p))
12744
+ if (!existsSync11(p))
12053
12745
  return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
12054
- const parsed = JSON.parse(readFileSync10(p, "utf8"));
12746
+ const parsed = JSON.parse(readFileSync11(p, "utf8"));
12055
12747
  return {
12056
12748
  ...DEFAULT_HEAL_CONFIG,
12057
12749
  ...parsed,
@@ -12067,10 +12759,10 @@ function writeHealConfig(config, path) {
12067
12759
  }
12068
12760
  function readHealState(path) {
12069
12761
  const p = path || getHealStatePath();
12070
- if (!existsSync10(p))
12762
+ if (!existsSync11(p))
12071
12763
  return defaultHealState();
12072
12764
  try {
12073
- return { ...defaultHealState(), ...JSON.parse(readFileSync10(p, "utf8")) };
12765
+ return { ...defaultHealState(), ...JSON.parse(readFileSync11(p, "utf8")) };
12074
12766
  } catch {
12075
12767
  return defaultHealState();
12076
12768
  }
@@ -12197,7 +12889,7 @@ function sh(cmd, timeoutMs = 8000) {
12197
12889
  }
12198
12890
  function getCurrentBootId() {
12199
12891
  try {
12200
- return readFileSync10("/proc/sys/kernel/random/boot_id", "utf8").trim();
12892
+ return readFileSync11("/proc/sys/kernel/random/boot_id", "utf8").trim();
12201
12893
  } catch {
12202
12894
  return "";
12203
12895
  }
@@ -12283,9 +12975,9 @@ function executeAction(action, config) {
12283
12975
 
12284
12976
  // src/commands/heal-daemon.ts
12285
12977
  init_paths();
12286
- import { existsSync as existsSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
12287
- import { join as join9 } from "path";
12288
- var DAEMON_PID_PATH2 = join9(getDataDir(), "heal-daemon.pid");
12978
+ import { existsSync as existsSync12, readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "fs";
12979
+ import { join as join11 } from "path";
12980
+ var DAEMON_PID_PATH2 = join11(getDataDir(), "heal-daemon.pid");
12289
12981
  var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
12290
12982
  var SYSTEM_CONF = "/etc/systemd/system.conf";
12291
12983
  function log(msg) {
@@ -12331,7 +13023,7 @@ function writePid2(pid) {
12331
13023
  }
12332
13024
  function readPid2() {
12333
13025
  try {
12334
- const pid = Number.parseInt(readFileSync11(DAEMON_PID_PATH2, "utf8").trim());
13026
+ const pid = Number.parseInt(readFileSync12(DAEMON_PID_PATH2, "utf8").trim());
12335
13027
  return Number.isFinite(pid) ? pid : null;
12336
13028
  } catch {
12337
13029
  return null;
@@ -12403,9 +13095,9 @@ function applyDeterminism(config) {
12403
13095
  }
12404
13096
  function enableHardwareWatchdog() {
12405
13097
  const log2 = [];
12406
- if (!existsSync11(SYSTEM_CONF))
13098
+ if (!existsSync12(SYSTEM_CONF))
12407
13099
  return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
12408
- let conf = readFileSync11(SYSTEM_CONF, "utf8");
13100
+ let conf = readFileSync12(SYSTEM_CONF, "utf8");
12409
13101
  const set = (key, value) => {
12410
13102
  const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
12411
13103
  if (re.test(conf))
@@ -12435,7 +13127,7 @@ function binPath() {
12435
13127
  candidates.push(`${home}/.bun/bin/machines`);
12436
13128
  candidates.push("/root/.bun/bin/machines", "/usr/local/bin/machines");
12437
13129
  for (const c of candidates) {
12438
- if (c && existsSync11(c))
13130
+ if (c && existsSync12(c))
12439
13131
  return c;
12440
13132
  }
12441
13133
  return "machines";
@@ -12471,7 +13163,7 @@ WantedBy=multi-user.target
12471
13163
  function uninstallHealService() {
12472
13164
  const log2 = [];
12473
13165
  sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
12474
- if (existsSync11(SERVICE_PATH)) {
13166
+ if (existsSync12(SERVICE_PATH)) {
12475
13167
  sh2(`rm -f ${SERVICE_PATH}`);
12476
13168
  sh2("systemctl daemon-reload");
12477
13169
  log2.push(`removed ${SERVICE_PATH}`);
@@ -12482,7 +13174,7 @@ function uninstallHealService() {
12482
13174
  }
12483
13175
  function healServiceStatus() {
12484
13176
  return {
12485
- installed: existsSync11(SERVICE_PATH),
13177
+ installed: existsSync12(SERVICE_PATH),
12486
13178
  active: sh2("systemctl is-active machines-heal.service").out === "active",
12487
13179
  enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
12488
13180
  };
@@ -12521,8 +13213,6 @@ ${items.map((item) => `- ${item}`).join(`
12521
13213
  }
12522
13214
 
12523
13215
  // src/cli/index.ts
12524
- import { rmSync as rmSync2 } from "fs";
12525
- import { readFileSync as readFileSync12 } from "fs";
12526
13216
  var program2 = new Command;
12527
13217
  function printJsonOrText(data, text, json = false) {
12528
13218
  if (json || program2.opts().quiet) {
@@ -12790,23 +13480,307 @@ program2.name("machines").description("Machine fleet management CLI + MCP for de
12790
13480
  var manifestCommand = program2.command("manifest").description("Manage the fleet manifest");
12791
13481
  var appsCommand = program2.command("apps").description("Manage installed applications per machine");
12792
13482
  var notificationsCommand = program2.command("notifications").description("Manage fleet alert delivery channels");
12793
- var eventWebhooksCommand = registerWebhookCommands(program2, { source: "machines" });
12794
- eventWebhooksCommand.description("Manage shared event webhook subscriptions");
12795
- var webhookTestCommand = eventWebhooksCommand.commands.find((command2) => command2.name() === "test");
12796
- var webhookOptions = webhookTestCommand?.options ?? [];
12797
- var webhookMessageOption = webhookOptions.find((option) => option.long === "--message");
12798
- if (webhookMessageOption) {
12799
- webhookMessageOption.defaultValue = "Shared events test delivery";
12800
- }
12801
- var eventsCommand = registerEventCommands(program2, { source: "machines" });
12802
- eventsCommand.description("Emit, list, and replay shared events");
13483
+ var eventWebhooksCommand = program2.command("webhooks").description("Manage shared event webhook subscriptions");
13484
+ var eventsCommand = program2.command("events").description("Emit, list, and replay shared events");
12803
13485
  var runtimeCommand = program2.command("runtime").description("Watch runtime conditions and emit shared events");
12804
13486
  var clipboardCommand = program2.command("clipboard").description("Real-time clipboard sync across fleet machines");
12805
13487
  var installClaudeCommand = program2.command("install-claude").description("Install or inspect Claude, Codex, and Gemini CLIs");
12806
13488
  var daemonCommand = program2.command("daemon").description("Install and inspect the machines-agent fleet daemon service");
13489
+ var trustedNotificationApproval2 = createTrustedNotificationApproval();
13490
+ function cliMachineId(machineId) {
13491
+ return machineId?.trim() || "local";
13492
+ }
13493
+ function cliResourceId(kind, ...parts) {
13494
+ const values = parts.map((part) => String(part ?? "*").trim()).filter(Boolean).join(":");
13495
+ return values ? `${kind}:${values}` : kind;
13496
+ }
13497
+ function cliMutationCallerId() {
13498
+ return process.env["HASNA_MACHINES_MUTATION_CALLER_ID"]?.trim() || "cli";
13499
+ }
13500
+ function cliMutationRunId() {
13501
+ return process.env["HASNA_MACHINES_MUTATION_RUN_ID"]?.trim() || "cli";
13502
+ }
13503
+ function requireCliMutation(operation, approvalToken, scope = {}) {
13504
+ assertMutationApproved({
13505
+ surface: "cli",
13506
+ operation,
13507
+ transport: "cli",
13508
+ callerId: cliMutationCallerId(),
13509
+ runId: cliMutationRunId(),
13510
+ machineId: scope.machineId === undefined ? undefined : cliMachineId(scope.machineId),
13511
+ resourceId: scope.resourceId === undefined || scope.resourceId === null ? undefined : scope.resourceId,
13512
+ args: scope.args,
13513
+ approvalToken
13514
+ });
13515
+ }
13516
+ function cliPlanApprovalArgs(args, plan) {
13517
+ return {
13518
+ ...args,
13519
+ plan_digest: mutationPlanDigest(plan)
13520
+ };
13521
+ }
13522
+ function cliPlanResourceId(operation, machineId, plan) {
13523
+ return cliResourceId("plan", operation, machineId, mutationPlanDigest(plan));
13524
+ }
13525
+ function createEventsClient() {
13526
+ return new EventsClient3;
13527
+ }
13528
+ function eventStoreDir2() {
13529
+ return resolve4(getEventsDataDir2());
13530
+ }
13531
+ function eventStoreScope2() {
13532
+ return { event_store_dir: eventStoreDir2() };
13533
+ }
13534
+ function eventStoreResourceId2(kind, ...parts) {
13535
+ return cliResourceId(kind, mutationArgsSha256(eventStoreScope2()), ...parts);
13536
+ }
13537
+ function withEventStoreScope2(args) {
13538
+ return { event_store_dir: eventStoreDir2(), ...args };
13539
+ }
13540
+ function readJsonArrayFile(path) {
13541
+ if (!existsSync13(path))
13542
+ return [];
13543
+ const raw = readFileSync13(path, "utf8").trim();
13544
+ if (!raw)
13545
+ return [];
13546
+ const parsed = JSON.parse(raw);
13547
+ if (!Array.isArray(parsed))
13548
+ throw new Error(`Expected ${path} to contain a JSON array.`);
13549
+ return parsed;
13550
+ }
13551
+ function readEventChannelsWithoutInit() {
13552
+ return readJsonArrayFile(join12(eventStoreDir2(), "channels.json"));
13553
+ }
13554
+ function readEventsWithoutInit() {
13555
+ return readJsonArrayFile(join12(eventStoreDir2(), "events.json"));
13556
+ }
13557
+ function filterEventsForReplay(events, options) {
13558
+ return events.filter((event) => {
13559
+ if (options.id && event.id !== options.id)
13560
+ return false;
13561
+ if (options.source && event.source !== options.source)
13562
+ return false;
13563
+ if (options.type && event.type !== options.type)
13564
+ return false;
13565
+ return true;
13566
+ });
13567
+ }
13568
+ function collectOptionValues(value, previous = []) {
13569
+ previous.push(value);
13570
+ return previous;
13571
+ }
13572
+ function parseNumberOption(value) {
13573
+ const parsed = Number(value);
13574
+ if (!Number.isFinite(parsed))
13575
+ throw new Error(`Expected a finite number, got ${value}`);
13576
+ return parsed;
13577
+ }
13578
+ function parseJsonObjectOption(value, fallback) {
13579
+ if (value === undefined)
13580
+ return fallback;
13581
+ const parsed = JSON.parse(value);
13582
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
13583
+ throw new Error("Expected a JSON object.");
13584
+ }
13585
+ return parsed;
13586
+ }
13587
+ function parseHeaderOptions(values) {
13588
+ if (!values?.length)
13589
+ return;
13590
+ const headers = {};
13591
+ for (const value of values) {
13592
+ const separator = value.indexOf("=");
13593
+ if (separator === -1)
13594
+ throw new Error(`Invalid header, expected name=value: ${value}`);
13595
+ headers[value.slice(0, separator)] = value.slice(separator + 1);
13596
+ }
13597
+ return headers;
13598
+ }
13599
+ function buildEventFilter(options) {
13600
+ const filter = {};
13601
+ if (options.source)
13602
+ filter.source = options.source;
13603
+ if (options.type)
13604
+ filter.type = options.type;
13605
+ if (options.subject)
13606
+ filter.subject = options.subject;
13607
+ if (options.severity)
13608
+ filter.severity = options.severity;
13609
+ return Object.keys(filter).length > 0 ? [filter] : undefined;
13610
+ }
13611
+ function wantsCommandJson(options, command2) {
13612
+ return Boolean(options.json || command2.optsWithGlobals?.().json || command2.parent?.optsWithGlobals?.().json || program2.opts().quiet);
13613
+ }
13614
+ function printCommandResult(data, text, json) {
13615
+ if (json || program2.opts().quiet) {
13616
+ console.log(JSON.stringify(data, null, 2));
13617
+ return;
13618
+ }
13619
+ console.log(text);
13620
+ }
13621
+ function runtimeTmuxCommand() {
13622
+ return process.env["HASNA_MACHINES_TMUX_BIN"]?.trim() || "tmux";
13623
+ }
13624
+ function runtimeTmuxEventTypes(once) {
13625
+ return once ? ["machines.tmux.pane_missing"] : ["machines.tmux.pane_died"];
13626
+ }
13627
+ eventWebhooksCommand.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectOptionValues, []).option("--arg <arg...>", "Command argument", collectOptionValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumberOption).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumberOption).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumberOption).option("--redact <path...>", "Event field path to redact before delivery", collectOptionValues, []).option("--disabled", "Create channel disabled", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (target, options, command2) => {
13628
+ const headers = parseHeaderOptions(options.header);
13629
+ const commandArgs = options.arg ?? [];
13630
+ const redactPaths = options.redact ?? [];
13631
+ const enabled = !options.disabled;
13632
+ const filter = buildEventFilter(options);
13633
+ const channel = {
13634
+ id: options.id,
13635
+ name: options.name,
13636
+ enabled,
13637
+ transport: options.transport,
13638
+ filters: filter,
13639
+ retry: options.retryAttempts || options.retryBackoffMs ? { maxAttempts: options.retryAttempts, backoffMs: options.retryBackoffMs } : undefined,
13640
+ redact: redactPaths.length > 0 ? { paths: redactPaths } : undefined
13641
+ };
13642
+ if (options.transport === "webhook") {
13643
+ channel.webhook = { url: target, secret: options.secret, headers, timeoutMs: options.timeoutMs };
13644
+ } else if (options.transport === "command") {
13645
+ channel.command = { command: target, args: commandArgs, timeoutMs: options.timeoutMs };
13646
+ } else {
13647
+ throw new Error(`Transport ${options.transport} is reserved for future use and cannot be added yet`);
13648
+ }
13649
+ requireCliMutation("machines_webhooks_add", options.approvalToken, {
13650
+ resourceId: eventStoreResourceId2("webhook", options.id),
13651
+ args: withEventStoreScope2({
13652
+ channel_id: options.id,
13653
+ target,
13654
+ transport: options.transport,
13655
+ name: options.name,
13656
+ event_type: options.type,
13657
+ source: options.source,
13658
+ subject: options.subject,
13659
+ severity: options.severity,
13660
+ secret: options.secret,
13661
+ headers,
13662
+ args: commandArgs,
13663
+ timeout_ms: options.timeoutMs,
13664
+ retry_attempts: options.retryAttempts,
13665
+ retry_backoff_ms: options.retryBackoffMs,
13666
+ redact: redactPaths,
13667
+ enabled
13668
+ })
13669
+ });
13670
+ const saved = await createEventsClient().addChannel(channel);
13671
+ printCommandResult(sanitizeChannelForOutput(saved), `Added ${saved.transport} channel ${saved.id}`, wantsCommandJson(options, command2));
13672
+ });
13673
+ eventWebhooksCommand.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (options, command2) => {
13674
+ const channels = readEventChannelsWithoutInit();
13675
+ if (wantsCommandJson(options, command2)) {
13676
+ console.log(JSON.stringify(sanitizeChannelsForOutput2(channels), null, 2));
13677
+ return;
13678
+ }
13679
+ if (!channels.length) {
13680
+ console.log("No channels configured.");
13681
+ return;
13682
+ }
13683
+ for (const channel of channels) {
13684
+ console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
13685
+ }
13686
+ });
13687
+ eventWebhooksCommand.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (id, options, command2) => {
13688
+ requireCliMutation("machines_webhooks_remove", options.approvalToken, {
13689
+ resourceId: eventStoreResourceId2("webhook", id),
13690
+ args: withEventStoreScope2({ channel_id: id })
13691
+ });
13692
+ const removed = await createEventsClient().removeChannel(id);
13693
+ printCommandResult({ removed }, removed ? `Removed ${id}` : `Channel not found: ${id}`, wantsCommandJson(options, command2));
13694
+ });
13695
+ eventWebhooksCommand.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Shared events test delivery").option("--data <json>", "Event data JSON object").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (id, options, command2) => {
13696
+ const data = parseJsonObjectOption(options.data, { test: true });
13697
+ const subject = options.subject ?? id;
13698
+ requireCliMutation("machines_webhooks_test", options.approvalToken, {
13699
+ resourceId: eventStoreResourceId2("webhook-test", id, options.type),
13700
+ args: withEventStoreScope2({ channel_id: id, event_type: options.type, subject, message: options.message, data })
13701
+ });
13702
+ const result = await createEventsClient().testChannel(id, {
13703
+ source: "machines",
13704
+ type: options.type,
13705
+ subject,
13706
+ message: options.message,
13707
+ data
13708
+ });
13709
+ printCommandResult(result, `${result.status}: ${result.channelId}`, wantsCommandJson(options, command2));
13710
+ });
13711
+ eventsCommand.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (type, options, command2) => {
13712
+ const source = options.source ?? "machines";
13713
+ const data = parseJsonObjectOption(options.data, {});
13714
+ const metadata = parseJsonObjectOption(options.metadata, {});
13715
+ requireCliMutation("machines_events_emit", options.approvalToken, {
13716
+ resourceId: eventStoreResourceId2("event", type, options.subject, options.dedupeKey),
13717
+ args: withEventStoreScope2({
13718
+ event_type: type,
13719
+ source,
13720
+ subject: options.subject,
13721
+ severity: options.severity,
13722
+ message: options.message,
13723
+ data,
13724
+ metadata,
13725
+ dedupe_key: options.dedupeKey,
13726
+ deliver: options.deliver,
13727
+ dedupe: options.dedupe
13728
+ })
13729
+ });
13730
+ const result = await createEventsClient().emit({
13731
+ source,
13732
+ type,
13733
+ subject: options.subject,
13734
+ severity: options.severity,
13735
+ message: options.message,
13736
+ dedupeKey: options.dedupeKey,
13737
+ data,
13738
+ metadata
13739
+ }, { deliver: options.deliver, dedupe: options.dedupe });
13740
+ printCommandResult(result, `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`, wantsCommandJson(options, command2));
13741
+ });
13742
+ eventsCommand.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumberOption).option("-j, --json", "Print JSON output", false).action(async (options, command2) => {
13743
+ let rows = readEventsWithoutInit();
13744
+ if (options.source)
13745
+ rows = rows.filter((event) => event.source === options.source);
13746
+ if (options.type)
13747
+ rows = rows.filter((event) => event.type === options.type);
13748
+ if (options.limit)
13749
+ rows = rows.slice(-options.limit);
13750
+ if (wantsCommandJson(options, command2)) {
13751
+ console.log(JSON.stringify(rows, null, 2));
13752
+ return;
13753
+ }
13754
+ if (!rows.length) {
13755
+ console.log("No events recorded.");
13756
+ return;
13757
+ }
13758
+ for (const event of rows) {
13759
+ console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
13760
+ }
13761
+ });
13762
+ eventsCommand.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (options, command2) => {
13763
+ if (options.dryRun !== true) {
13764
+ requireCliMutation("machines_events_replay", options.approvalToken, {
13765
+ resourceId: eventStoreResourceId2("event-replay", options.id, options.source, options.type),
13766
+ args: withEventStoreScope2({ event_id: options.id, source: options.source, event_type: options.type, dry_run: false })
13767
+ });
13768
+ }
13769
+ const result = options.dryRun === true ? { events: filterEventsForReplay(readEventsWithoutInit(), options), deliveries: [] } : await createEventsClient().replay({
13770
+ eventId: options.id,
13771
+ source: options.source,
13772
+ type: options.type,
13773
+ dryRun: options.dryRun
13774
+ });
13775
+ printCommandResult(result, `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`, wantsCommandJson(options, command2));
13776
+ });
12807
13777
  function addDaemonLifecycleCommand(action, description) {
12808
- daemonCommand.command(action).description(description).option("--platform <platform>", "Service platform to plan for (macos, linux)").option("--mode <mode>", "Service mode (user, system)", "user").option("--service-name <name>", "Service name/label", "machines-agent").option("--executable <path>", "Absolute machines-agent executable path").option("--interval-ms <ms>", "Heartbeat interval in milliseconds").option("--storage-push", "Configure daemon to push heartbeat rows to storage", false).option("--doctor-summary", "Configure daemon to include lightweight doctor summaries", false).option("--private-metadata", "Opt in to private host/network metadata in heartbeat rows", false).option("--env <name...>", "Environment variable names to include as placeholders").option("--apply", "Write service files and run planned commands", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
12809
- const plan = buildDaemonServicePlan(parseDaemonOptions(action, options));
13778
+ daemonCommand.command(action).description(description).option("--platform <platform>", "Service platform to plan for (macos, linux)").option("--mode <mode>", "Service mode (user, system)", "user").option("--service-name <name>", "Service name/label", "machines-agent").option("--executable <path>", "Absolute machines-agent executable path").option("--interval-ms <ms>", "Heartbeat interval in milliseconds").option("--storage-push", "Configure daemon to push heartbeat rows to storage", false).option("--doctor-summary", "Configure daemon to include lightweight doctor summaries", false).option("--private-metadata", "Opt in to private host/network metadata in heartbeat rows", false).option("--env <name...>", "Environment variable names to include as placeholders").option("--apply", "Write service files and run planned commands", false).option("--yes", "Confirm execution when using --apply", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
13779
+ const planOptions = parseDaemonOptions(action, options);
13780
+ const plan = buildDaemonServicePlan(planOptions);
13781
+ if (options.apply) {
13782
+ requireCliMutation(`daemon_${action}`, options.approvalToken, { resourceId: cliResourceId("daemon", action, options.serviceName), args: planOptions });
13783
+ }
12810
13784
  const result = runDaemonServicePlan(plan, { apply: options.apply, yes: options.yes });
12811
13785
  if (options.json || options.apply) {
12812
13786
  console.log(JSON.stringify(result, null, 2));
@@ -12820,7 +13794,8 @@ addDaemonLifecycleCommand("uninstall", "Plan or uninstall the machines-agent dae
12820
13794
  addDaemonLifecycleCommand("restart", "Plan or restart the machines-agent daemon service");
12821
13795
  addDaemonLifecycleCommand("status", "Plan a daemon service status command");
12822
13796
  addDaemonLifecycleCommand("logs", "Plan a daemon service log command");
12823
- manifestCommand.command("init").description("Create an empty fleet manifest").action(() => {
13797
+ manifestCommand.command("init").description("Create an empty fleet manifest").option("--approval-token <token>", "Scoped mutation approval token").action((options) => {
13798
+ requireCliMutation("manifest_init", options.approvalToken, { resourceId: "manifest:init", args: {} });
12824
13799
  console.log(manifestInit());
12825
13800
  });
12826
13801
  manifestCommand.command("path").description("Print the manifest path").action(() => {
@@ -12832,7 +13807,8 @@ manifestCommand.command("list").description("Print the fleet manifest").action((
12832
13807
  manifestCommand.command("validate").description("Validate the fleet manifest").action(() => {
12833
13808
  console.log(JSON.stringify(manifestValidate(), null, 2));
12834
13809
  });
12835
- manifestCommand.command("bootstrap").description("Detect and upsert the current machine into the manifest").action(() => {
13810
+ manifestCommand.command("bootstrap").description("Detect and upsert the current machine into the manifest").option("--approval-token <token>", "Scoped mutation approval token").action((options) => {
13811
+ requireCliMutation("manifest_bootstrap", options.approvalToken, { resourceId: "manifest:bootstrap", args: {} });
12836
13812
  console.log(JSON.stringify(manifestBootstrapCurrentMachine(), null, 2));
12837
13813
  });
12838
13814
  manifestCommand.command("get").description("Print a single machine from the manifest").argument("<id>", "Machine identifier").action((id) => {
@@ -12844,18 +13820,20 @@ manifestCommand.command("get").description("Print a single machine from the mani
12844
13820
  }
12845
13821
  console.log(JSON.stringify(machine, null, 2));
12846
13822
  });
12847
- manifestCommand.command("remove").description("Remove a machine from the manifest").argument("<id>", "Machine identifier").action((id) => {
13823
+ manifestCommand.command("remove").description("Remove a machine from the manifest").argument("<id>", "Machine identifier").option("--approval-token <token>", "Scoped mutation approval token").action((id, options) => {
13824
+ requireCliMutation("manifest_remove", options.approvalToken, { machineId: id, args: { machine_id: id } });
12848
13825
  console.log(JSON.stringify(manifestRemove(id), null, 2));
12849
13826
  });
12850
- manifestCommand.command("add").description("Add or replace a machine in the fleet manifest").option("--id <id>", "Machine identifier").option("--platform <platform>", "linux | macos | windows").option("--workspace-path <path>", "Primary workspace path").option("--hostname <hostname>", "Machine hostname").option("--ssh-address <sshAddress>", "Machine SSH address").option("--tailscale-name <tailscaleName>", "Machine Tailscale DNS name").option("--connection <connection>", "local | ssh | tailscale").option("--bun-path <path>", "Bun executable directory").option("--tag <tag...>", "Machine tags").option("--package <name...>", "Desired packages").option("--app <spec...>", "Desired apps as name[:manager[:packageName]]").option("--file <spec...>", "File sync spec source:target[:copy|symlink]").option("--metadata <json>", "Machine metadata as JSON").option("--from-stdin", "Read the full MachineManifest JSON from stdin").action((options) => {
13827
+ manifestCommand.command("add").description("Add or replace a machine in the fleet manifest").option("--id <id>", "Machine identifier").option("--platform <platform>", "linux | macos | windows").option("--workspace-path <path>", "Primary workspace path").option("--hostname <hostname>", "Machine hostname").option("--ssh-address <sshAddress>", "Machine SSH address").option("--tailscale-name <tailscaleName>", "Machine Tailscale DNS name").option("--connection <connection>", "local | ssh | tailscale").option("--bun-path <path>", "Bun executable directory").option("--tag <tag...>", "Machine tags").option("--package <name...>", "Desired packages").option("--app <spec...>", "Desired apps as name[:manager[:packageName]]").option("--file <spec...>", "File sync spec source:target[:copy|symlink]").option("--metadata <json>", "Machine metadata as JSON").option("--from-stdin", "Read the full MachineManifest JSON from stdin").option("--approval-token <token>", "Scoped mutation approval token").action((options) => {
12851
13828
  const fromStdin = Boolean(options["fromStdin"] || options["from-stdin"]);
12852
13829
  if (fromStdin) {
12853
13830
  if (process.stdin.isTTY) {
12854
13831
  console.error("error: --from-stdin requires piped input");
12855
13832
  process.exit(1);
12856
13833
  }
12857
- const input = readFileSync12(0, "utf8");
13834
+ const input = readFileSync13(0, "utf8");
12858
13835
  const machine2 = JSON.parse(input);
13836
+ requireCliMutation("manifest_add", typeof options["approvalToken"] === "string" ? options["approvalToken"] : undefined, { machineId: machine2.id, args: machine2 });
12859
13837
  console.log(JSON.stringify(manifestAdd(machine2), null, 2));
12860
13838
  return;
12861
13839
  }
@@ -12894,6 +13872,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
12894
13872
  apps,
12895
13873
  files
12896
13874
  };
13875
+ requireCliMutation("manifest_add", typeof options["approvalToken"] === "string" ? options["approvalToken"] : undefined, { machineId: machine.id, args: machine });
12897
13876
  console.log(JSON.stringify(manifestAdd(machine), null, 2));
12898
13877
  });
12899
13878
  appsCommand.command("list").description("List manifest-managed apps for a machine").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
@@ -12912,16 +13891,47 @@ appsCommand.command("plan").description("Preview app install steps for a machine
12912
13891
  const result = buildAppsPlan(options.machine);
12913
13892
  console.log(JSON.stringify(result, null, 2));
12914
13893
  });
12915
- appsCommand.command("apply").description("Install manifest-managed apps for a machine").option("--machine <id>", "Machine identifier").option("--yes", "Confirm execution", false).action((options) => {
12916
- const result = runAppsInstall(options.machine, { apply: true, yes: options.yes });
13894
+ appsCommand.command("apply").description("Install manifest-managed apps for a machine").option("--machine <id>", "Machine identifier").option("--yes", "Confirm execution", false).option("--approval-token <token>", "Scoped mutation approval token").action((options) => {
13895
+ const resolvedMachineId = cliMachineId(options.machine);
13896
+ const plan = buildAppsPlan(options.machine);
13897
+ requireCliMutation("apps_apply", options.approvalToken, {
13898
+ machineId: resolvedMachineId,
13899
+ resourceId: cliPlanResourceId("apps_apply", resolvedMachineId, plan),
13900
+ args: cliPlanApprovalArgs({ machine_id: resolvedMachineId, yes: options.yes }, plan)
13901
+ });
13902
+ const result = runAppsPlan(plan, { apply: true, yes: options.yes });
12917
13903
  console.log(JSON.stringify(result, null, 2));
12918
13904
  });
12919
- program2.command("setup").description("Prepare a machine from the fleet manifest").option("--machine <id>", "Machine identifier").option("--apply", "Execute provisioning commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
12920
- const result = options.apply ? runSetup(options.machine, { apply: true, yes: options.yes }) : buildSetupPlan(options.machine);
13905
+ program2.command("setup").description("Prepare a machine from the fleet manifest").option("--machine <id>", "Machine identifier").option("--apply", "Execute provisioning commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
13906
+ if (options.apply) {
13907
+ const resolvedMachineId = cliMachineId(options.machine);
13908
+ const plan = buildSetupPlan(options.machine);
13909
+ requireCliMutation("setup_apply", options.approvalToken, {
13910
+ machineId: resolvedMachineId,
13911
+ resourceId: cliPlanResourceId("setup_apply", resolvedMachineId, plan),
13912
+ args: cliPlanApprovalArgs({ machine_id: resolvedMachineId, yes: options.yes }, plan)
13913
+ });
13914
+ const result2 = runSetupPlan(plan, { apply: true, yes: options.yes });
13915
+ console.log(JSON.stringify(result2, null, 2));
13916
+ return;
13917
+ }
13918
+ const result = buildSetupPlan(options.machine);
12921
13919
  console.log(JSON.stringify(result, null, 2));
12922
13920
  });
12923
- program2.command("sync").description("Reconcile a machine against the fleet manifest").option("--machine <id>", "Machine identifier").option("--apply", "Execute reconciliation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
12924
- const result = options.apply ? runSync(options.machine, { apply: true, yes: options.yes }) : buildSyncPlan(options.machine);
13921
+ program2.command("sync").description("Reconcile a machine against the fleet manifest").option("--machine <id>", "Machine identifier").option("--apply", "Execute reconciliation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
13922
+ if (options.apply) {
13923
+ const resolvedMachineId = cliMachineId(options.machine);
13924
+ const plan = buildSyncPlan(options.machine);
13925
+ requireCliMutation("sync_apply", options.approvalToken, {
13926
+ machineId: resolvedMachineId,
13927
+ resourceId: cliPlanResourceId("sync_apply", resolvedMachineId, plan),
13928
+ args: cliPlanApprovalArgs({ machine_id: resolvedMachineId, yes: options.yes }, plan)
13929
+ });
13930
+ const result2 = runSyncPlan(plan, { apply: true, yes: options.yes });
13931
+ console.log(JSON.stringify(result2, null, 2));
13932
+ return;
13933
+ }
13934
+ const result = buildSyncPlan(options.machine);
12925
13935
  console.log(JSON.stringify(result, null, 2));
12926
13936
  });
12927
13937
  program2.command("topology").description("Discover local, manifest, heartbeat, SSH, and Tailscale machine topology").option("--no-tailscale", "Skip tailscale status probing").option("--private-metadata", "Print private host/network route fields", false).option("-j, --json", "Print JSON output", false).action((options) => {
@@ -12987,7 +13997,19 @@ workspaceCommand.command("doctor").description("Diagnose repo and open-files wor
12987
13997
  if (result.diagnostics.some((entry) => entry.severity === "fail") && !options.json)
12988
13998
  process.exitCode = 1;
12989
13999
  });
12990
- workspaceCommand.command("repair").description("Preview or write explicit manifest path mappings for inferred workspace roots").requiredOption("--machine <id>", "Machine identifier").requiredOption("--project <id>", "Canonical project id").option("--repo <name>", "Repository name; defaults to project id").option("--open-files-repo <name>", "Open-files repository name", "open-files").option("--workspace-root <path>", "Override the machine workspace root for resolution").option("--project-root <path>", "Explicit project root to write").option("--open-files-root <path>", "Explicit open-files root to write").option("--apply", "Write the mappings into the manifest", false).option("--allow-untrusted", "Allow writing mappings for machines not marked trusted", false).option("--no-tailscale", "Skip tailscale status probing").option("-j, --json", "Print JSON output", false).action((options) => {
14000
+ workspaceCommand.command("repair").description("Preview or write explicit manifest path mappings for inferred workspace roots").requiredOption("--machine <id>", "Machine identifier").requiredOption("--project <id>", "Canonical project id").option("--repo <name>", "Repository name; defaults to project id").option("--open-files-repo <name>", "Open-files repository name", "open-files").option("--workspace-root <path>", "Override the machine workspace root for resolution").option("--project-root <path>", "Explicit project root to write").option("--open-files-root <path>", "Explicit open-files root to write").option("--apply", "Write the mappings into the manifest", false).option("--allow-untrusted", "Allow writing mappings for machines not marked trusted", false).option("--no-tailscale", "Skip tailscale status probing").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
14001
+ if (options.apply)
14002
+ requireCliMutation("workspace_repair", options.approvalToken, { machineId: options.machine, args: {
14003
+ machine: options.machine,
14004
+ project: options.project,
14005
+ repo: options.repo,
14006
+ openFilesRepo: options.openFilesRepo,
14007
+ workspaceRoot: options.workspaceRoot,
14008
+ projectRoot: options.projectRoot,
14009
+ openFilesRoot: options.openFilesRoot,
14010
+ allowUntrusted: options.allowUntrusted,
14011
+ tailscale: options.tailscale
14012
+ } });
12991
14013
  const result = repairWorkspaceManifestMappings({
12992
14014
  machineId: options.machine,
12993
14015
  projectId: options.project,
@@ -13008,18 +14030,26 @@ program2.command("diff").description("Show manifest differences between two mach
13008
14030
  const result = diffMachines(options.left, options.right);
13009
14031
  console.log(JSON.stringify(result, null, 2));
13010
14032
  });
13011
- program2.command("backup").description("Create and optionally upload a machine backup archive").option("--bucket <name>", "S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET").option("--prefix <prefix>", "S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines").option("--apply", "Execute backup commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
14033
+ program2.command("backup").description("Create and optionally upload a machine backup archive").option("--bucket <name>", "S3 bucket name; defaults to HASNA_MACHINES_S3_BUCKET or MACHINES_S3_BUCKET").option("--prefix <prefix>", "S3 key prefix; defaults to HASNA_MACHINES_S3_PREFIX, MACHINES_S3_PREFIX, or machines").option("--apply", "Execute backup commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
14034
+ if (options.apply) {
14035
+ const target = resolveBackupTarget({ bucket: options.bucket, prefix: options.prefix });
14036
+ requireCliMutation("backup_apply", options.approvalToken, { resourceId: cliResourceId("backup", target.bucket, target.prefix), args: { bucket: target.bucket, prefix: target.prefix, yes: options.yes } });
14037
+ }
13012
14038
  const result = options.apply ? runBackup(options.bucket, options.prefix, { apply: true, yes: options.yes }) : buildBackupPlan(options.bucket, options.prefix);
13013
14039
  console.log(JSON.stringify(result, null, 2));
13014
14040
  });
13015
14041
  var certCommand = program2.command("cert").description("Manage mkcert-based local SSL certificates");
13016
- certCommand.command("issue").description("Plan or issue certificates for one or more domains").argument("<domains...>", "Domains to include in the certificate").option("--apply", "Execute certificate commands instead of previewing them", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((domains, options) => {
14042
+ certCommand.command("issue").description("Plan or issue certificates for one or more domains").argument("<domains...>", "Domains to include in the certificate").option("--apply", "Execute certificate commands instead of previewing them", false).option("--yes", "Confirm execution when using --apply", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((domains, options) => {
14043
+ if (options.apply)
14044
+ requireCliMutation("cert_apply", options.approvalToken, { resourceId: cliResourceId("cert", domains.join(",")), args: { domains, yes: options.yes } });
13017
14045
  const result = options.apply ? runCertPlan(domains, { apply: true, yes: options.yes }) : buildCertPlan(domains);
13018
14046
  console.log(JSON.stringify(result, null, 2));
13019
14047
  });
13020
14048
  var dnsCommand = program2.command("dns").description("Manage local domain mappings");
13021
- dnsCommand.command("add").description("Add or replace a local domain mapping").requiredOption("--domain <domain>", "Domain name").requiredOption("--port <port>", "Target port").option("--target-host <host>", "Target host", "127.0.0.1").option("-j, --json", "Print JSON output", false).action((options) => {
13022
- const result = addDomainMapping(options.domain, parseIntegerOption(options.port, "port", { min: 1, max: 65535 }), options.targetHost);
14049
+ dnsCommand.command("add").description("Add or replace a local domain mapping").requiredOption("--domain <domain>", "Domain name").requiredOption("--port <port>", "Target port").option("--target-host <host>", "Target host", "127.0.0.1").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
14050
+ const port = parseIntegerOption(options.port, "port", { min: 1, max: 65535 });
14051
+ requireCliMutation("dns_add", options.approvalToken, { resourceId: cliResourceId("dns", options.domain), args: { domain: options.domain, port, target_host: options.targetHost } });
14052
+ const result = addDomainMapping(options.domain, port, options.targetHost);
13023
14053
  console.log(JSON.stringify(result, null, 2));
13024
14054
  });
13025
14055
  dnsCommand.command("list").description("List saved local domain mappings").option("-j, --json", "Print JSON output", false).action(() => {
@@ -13028,43 +14058,91 @@ dnsCommand.command("list").description("List saved local domain mappings").optio
13028
14058
  dnsCommand.command("render").description("Render hosts/proxy configuration for a domain").argument("<domain>", "Domain name").option("-j, --json", "Print JSON output", false).action((domain) => {
13029
14059
  console.log(JSON.stringify(renderDomainMapping(domain), null, 2));
13030
14060
  });
13031
- notificationsCommand.command("add").description("Add or replace a notification channel").requiredOption("--id <id>", "Channel identifier").requiredOption("--type <type>", "email | webhook | command").requiredOption("--target <target>", "Email, webhook URL, or shell command").option("--event <event...>", "Events routed to this channel", ["setup_failed", "sync_failed"]).option("--disabled", "Create the channel in disabled state", false).option("-j, --json", "Print JSON output", false).action((options) => {
14061
+ notificationsCommand.command("add").description("Add or replace a notification channel").requiredOption("--id <id>", "Channel identifier").requiredOption("--type <type>", "email | webhook | command").requiredOption("--target <target>", "Email, webhook URL, or command executable").option("--arg <arg...>", "Command argument for command transports", collectOptionValues, []).option("--event <event...>", "Events routed to this channel", ["setup_failed", "sync_failed"]).option("--disabled", "Create the channel in disabled state", false).option("--approval-token <token>", "Operator mutation approval token for command transports").option("-j, --json", "Print JSON output", false).action((options) => {
14062
+ const enabled = !options.disabled;
14063
+ const events = [...new Set(options.event)];
14064
+ const commandArgs = options.arg ?? [];
14065
+ requireCliMutation("notifications_add", options.approvalToken, { resourceId: cliResourceId("notification", options.id), args: { id: options.id, type: options.type, target: options.target, args: commandArgs, event: events, enabled } });
13032
14066
  const result = addNotificationChannel({
13033
14067
  id: options.id,
13034
14068
  type: options.type,
13035
14069
  target: options.target,
13036
- events: options.event,
13037
- enabled: !options.disabled
13038
- });
14070
+ commandArgs: options.type === "command" && commandArgs.length > 0 ? commandArgs : undefined,
14071
+ events,
14072
+ enabled
14073
+ }, { trustedApproval: trustedNotificationApproval2 });
13039
14074
  printJsonOrText(result, renderNotificationConfigResult(result), options.json);
13040
14075
  });
13041
14076
  notificationsCommand.command("list").description("List configured notification channels").option("-j, --json", "Print JSON output", false).action((options) => {
13042
14077
  const result = listNotificationChannels();
13043
14078
  printJsonOrText(result, renderNotificationConfigResult(result), options.json);
13044
14079
  });
13045
- notificationsCommand.command("test").description("Preview or execute a notification test").requiredOption("--channel <id>", "Channel identifier").option("--event <name>", "Event name", "manual.test").option("--message <message>", "Test message", "machines notification test").option("--apply", "Execute the notification test instead of previewing it", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action(async (options) => {
14080
+ notificationsCommand.command("test").description("Preview or execute a notification test").requiredOption("--channel <id>", "Channel identifier").option("--event <name>", "Event name", "manual.test").option("--message <message>", "Test message", "machines notification test").option("--apply", "Execute the notification test instead of previewing it", false).option("--yes", "Confirm execution when using --apply", false).option("--approval-token <token>", "Operator mutation approval token for command transports").option("-j, --json", "Print JSON output", false).action(async (options) => {
14081
+ if (options.apply)
14082
+ requireCliMutation("notifications_test", options.approvalToken, { resourceId: cliResourceId("notification-test", options.channel, options.event), args: { channel: options.channel, event: options.event, message: options.message, yes: options.yes } });
13046
14083
  const result = await testNotificationChannel(options.channel, options.event, options.message, {
13047
14084
  apply: options.apply,
13048
- yes: options.yes
14085
+ yes: options.yes,
14086
+ trustedApproval: options.apply === true ? trustedNotificationApproval2 : undefined
13049
14087
  });
13050
14088
  printJsonOrText(result, renderNotificationTestResult(result), options.json);
13051
14089
  });
13052
- notificationsCommand.command("dispatch").description("Dispatch an event to matching notification channels").requiredOption("--event <name>", "Event name").requiredOption("--message <message>", "Message body").option("--channel <id>", "Limit delivery to one channel").option("-j, --json", "Print JSON output", false).action(async (options) => {
13053
- const result = await dispatchNotificationEvent(options.event, options.message, { channelId: options.channel });
14090
+ notificationsCommand.command("dispatch").description("Dispatch an event to matching notification channels").requiredOption("--event <name>", "Event name").requiredOption("--message <message>", "Message body").option("--channel <id>", "Limit delivery to one channel").option("--approval-token <token>", "Operator mutation approval token for command transports").option("-j, --json", "Print JSON output", false).action(async (options) => {
14091
+ requireCliMutation("notifications_dispatch", options.approvalToken, { resourceId: cliResourceId("notification-dispatch", options.channel, options.event), args: { event: options.event, message: options.message, channel: options.channel } });
14092
+ const result = await dispatchNotificationEvent(options.event, options.message, { channelId: options.channel, trustedApproval: trustedNotificationApproval2 });
13054
14093
  printJsonOrText(result, renderNotificationDispatchResult(result), options.json);
13055
14094
  });
13056
- notificationsCommand.command("remove").description("Remove a notification channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action((id, options) => {
14095
+ notificationsCommand.command("remove").description("Remove a notification channel").argument("<id>", "Channel identifier").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((id, options) => {
14096
+ requireCliMutation("notifications_remove", options.approvalToken, { resourceId: cliResourceId("notification", id), args: { id } });
13057
14097
  const result = removeNotificationChannel(id);
13058
14098
  printJsonOrText(result, renderNotificationConfigResult(result), options.json);
13059
14099
  });
13060
- runtimeCommand.command("tmux-watch").description("Watch a tmux pane and emit machines.tmux.pane_died if it disappears").argument("<target>", "tmux pane target, for example %1 or session:window.pane").option("--interval-ms <ms>", "Polling interval in milliseconds", "5000").option("--max-checks <n>", "Stop after N checks instead of watching forever").option("--once", "Probe once and emit machines.tmux.pane_missing when absent", false).option("--no-deliver", "Record the event without webhook delivery").option("-j, --json", "Print JSON output", false).action(async (target, options) => {
14100
+ runtimeCommand.command("tmux-hook-plan").description("Print a tmux pane-died hook command that emits machines events").option("--machines-command <command>", "machines CLI executable", "machines").option("--tmux-command <command>", "tmux executable").option("--deliver", "Deliver webhooks from the hook instead of recording only", false).option("--approval-token <token>", "Scoped mutation token for the generated events emit command").option("--trusted-local-mutation", "Include process-local trusted mutation env when no approval token is supplied", false).option("-j, --json", "Print JSON output", false).action((options) => {
14101
+ if (!options.approvalToken && options.trustedLocalMutation !== true) {
14102
+ throw new Error("tmux-hook-plan requires --approval-token or explicit --trusted-local-mutation.");
14103
+ }
14104
+ const result = buildTmuxPaneDiedHookPlan({
14105
+ machinesCommand: options.machinesCommand,
14106
+ tmuxCommand: options.tmuxCommand,
14107
+ deliver: options.deliver,
14108
+ approvalToken: options.approvalToken,
14109
+ trustedLocalMutation: options.trustedLocalMutation
14110
+ });
14111
+ printJsonOrText(result, result.shellCommand, options.json);
14112
+ });
14113
+ runtimeCommand.command("tmux-watch").description("Watch a tmux pane and emit machines.tmux.pane_died if it disappears").argument("<target>", "tmux pane target, for example %1 or session:window.pane").option("--interval-ms <ms>", "Polling interval in milliseconds", "5000").option("--max-checks <n>", "Stop after N checks instead of watching forever").option("--once", "Probe once and emit machines.tmux.pane_missing when absent", false).option("--no-deliver", "Record the event without webhook delivery").option("--approval-token <token>", "Scoped mutation approval token for event delivery").option("-j, --json", "Print JSON output", false).action(async (target, options) => {
14114
+ const normalizedTarget = target.trim();
14115
+ if (!normalizedTarget)
14116
+ throw new Error("tmux pane target is required");
14117
+ const intervalMs = parseIntegerOption(options.intervalMs ?? "5000", "interval-ms", { min: 0 });
13061
14118
  const maxChecks = options.once ? 1 : options.maxChecks ? parseIntegerOption(options.maxChecks, "max-checks", { min: 1 }) : undefined;
14119
+ const once = Boolean(options.once);
14120
+ const deliver = options.deliver !== false;
14121
+ const tmuxCommand = runtimeTmuxCommand();
14122
+ const eventTypes = runtimeTmuxEventTypes(once);
14123
+ const scopedIntervalMs = once ? undefined : intervalMs;
14124
+ if (deliver) {
14125
+ requireCliMutation("machines_runtime_tmux_watch_deliver", options.approvalToken, {
14126
+ resourceId: eventStoreResourceId2("runtime-tmux-watch", normalizedTarget, eventTypes.join(",")),
14127
+ args: withEventStoreScope2({
14128
+ target: normalizedTarget,
14129
+ event_types: eventTypes,
14130
+ interval_ms: scopedIntervalMs,
14131
+ max_checks: maxChecks,
14132
+ once,
14133
+ emit_initial_missing: once,
14134
+ deliver: true,
14135
+ tmux_command: tmuxCommand
14136
+ })
14137
+ });
14138
+ }
13062
14139
  const result = await watchTmuxPane({
13063
- target,
13064
- intervalMs: parseIntegerOption(options.intervalMs ?? "5000", "interval-ms", { min: 0 }),
14140
+ target: normalizedTarget,
14141
+ intervalMs,
13065
14142
  maxChecks,
13066
- emitInitialMissing: Boolean(options.once),
13067
- deliver: options.deliver !== false,
14143
+ emitInitialMissing: once,
14144
+ deliver,
14145
+ tmuxCommand,
13068
14146
  onProbe: options.json ? undefined : (probe) => {
13069
14147
  const status = probe.exists ? source_default.green("present") : source_default.yellow("missing");
13070
14148
  console.error(`tmux ${probe.target}: ${status}${probe.paneId ? ` ${probe.paneId}` : ""}`);
@@ -13133,7 +14211,7 @@ clipboardCommand.command("clear-history").description("Clear clipboard sync hist
13133
14211
  });
13134
14212
  clipboardCommand.command("key").description("Show or rotate the shared secret key").option("--rotate", "Generate a new key", false).option("-j, --json", "Print JSON output", false).action((options) => {
13135
14213
  if (options.rotate) {
13136
- rmSync2(getClipboardKeyPath(), { force: true });
14214
+ rmSync3(getClipboardKeyPath(), { force: true });
13137
14215
  }
13138
14216
  const key = getOrCreateClipboardKey();
13139
14217
  printJsonOrText({ key }, key, options.json);
@@ -13158,12 +14236,31 @@ installClaudeCommand.command("plan").description("Preview CLI install steps").op
13158
14236
  const result = buildClaudeInstallPlan(options.machine, options.tool);
13159
14237
  console.log(JSON.stringify(result, null, 2));
13160
14238
  });
13161
- installClaudeCommand.command("apply").description("Install or update the requested CLIs").option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to install (claude, codex, gemini)").option("--yes", "Confirm execution when using apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
13162
- const result = runClaudeInstall(options.machine, options.tool, { apply: true, yes: options.yes });
14239
+ installClaudeCommand.command("apply").description("Install or update the requested CLIs").option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to install (claude, codex, gemini)").option("--yes", "Confirm execution when using apply", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
14240
+ const resolvedMachineId = cliMachineId(options.machine);
14241
+ const plan = buildClaudeInstallPlan(options.machine, options.tool);
14242
+ requireCliMutation("install_claude_apply", options.approvalToken, {
14243
+ machineId: resolvedMachineId,
14244
+ resourceId: cliPlanResourceId("install_claude_apply", resolvedMachineId, plan),
14245
+ args: cliPlanApprovalArgs({ machine_id: resolvedMachineId, tools: options.tool, yes: options.yes }, plan)
14246
+ });
14247
+ const result = runClaudeInstallPlan(plan, { apply: true, yes: options.yes });
13163
14248
  console.log(JSON.stringify(result, null, 2));
13164
14249
  });
13165
- program2.command("install-tailscale").description("Install Tailscale on a machine").option("--machine <id>", "Machine identifier").option("--apply", "Execute installation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
13166
- const result = options.apply ? runTailscaleInstall(options.machine, { apply: true, yes: options.yes }) : buildTailscaleInstallPlan(options.machine);
14250
+ program2.command("install-tailscale").description("Install Tailscale on a machine").option("--machine <id>", "Machine identifier").option("--apply", "Execute installation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action((options) => {
14251
+ if (options.apply) {
14252
+ const resolvedMachineId = cliMachineId(options.machine);
14253
+ const plan = buildTailscaleInstallPlan(options.machine);
14254
+ requireCliMutation("install_tailscale_apply", options.approvalToken, {
14255
+ machineId: resolvedMachineId,
14256
+ resourceId: cliPlanResourceId("install_tailscale_apply", resolvedMachineId, plan),
14257
+ args: cliPlanApprovalArgs({ machine_id: resolvedMachineId, yes: options.yes }, plan)
14258
+ });
14259
+ const result2 = runTailscaleInstallPlan(plan, { apply: true, yes: options.yes });
14260
+ console.log(JSON.stringify(result2, null, 2));
14261
+ return;
14262
+ }
14263
+ const result = buildTailscaleInstallPlan(options.machine);
13167
14264
  console.log(JSON.stringify(result, null, 2));
13168
14265
  });
13169
14266
  program2.command("route").description("Resolve the best route for a machine").requiredOption("--machine <id>", "Machine identifier").option("--no-tailscale", "Skip tailscale status probing").option("--private-metadata", "Print private route targets", false).option("--cmd <command>", "Remote command to run").option("-j, --json", "Print JSON output", false).action((options) => {
@@ -13315,28 +14412,34 @@ storageCommand.command("status").description("Show storage sync status").option(
13315
14412
  ["tables", status.tables.join(", ")]
13316
14413
  ]), options.json);
13317
14414
  });
13318
- storageCommand.command("push").description("Push local machine runtime data to storage PostgreSQL").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
14415
+ storageCommand.command("push").description("Push local machine runtime data to storage PostgreSQL").option("--tables <tables>", "Comma-separated table names").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (options) => {
13319
14416
  try {
13320
- const { parseStorageTables: parseStorageTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
13321
- const results = await storagePush2({ tables: parseStorageTables2(options.tables) });
14417
+ const { parseStorageTables: parseStorageTables2, resolveTables: resolveTables2, storagePush: storagePush2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
14418
+ const tables = resolveTables2(parseStorageTables2(options.tables));
14419
+ requireCliMutation("storage_push", options.approvalToken, { resourceId: cliResourceId("storage-push", tables.join(",")), args: { tables } });
14420
+ const results = await storagePush2({ tables });
13322
14421
  printStorageResults(results, options.json);
13323
14422
  } catch (error) {
13324
14423
  printStorageError(error);
13325
14424
  }
13326
14425
  });
13327
- storageCommand.command("pull").description("Pull machine runtime data from storage PostgreSQL to local SQLite").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
14426
+ storageCommand.command("pull").description("Pull machine runtime data from storage PostgreSQL to local SQLite").option("--tables <tables>", "Comma-separated table names").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (options) => {
13328
14427
  try {
13329
- const { parseStorageTables: parseStorageTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
13330
- const results = await storagePull2({ tables: parseStorageTables2(options.tables) });
14428
+ const { parseStorageTables: parseStorageTables2, resolveTables: resolveTables2, storagePull: storagePull2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
14429
+ const tables = resolveTables2(parseStorageTables2(options.tables));
14430
+ requireCliMutation("storage_pull", options.approvalToken, { resourceId: cliResourceId("storage-pull", tables.join(",")), args: { tables } });
14431
+ const results = await storagePull2({ tables });
13331
14432
  printStorageResults(results, options.json);
13332
14433
  } catch (error) {
13333
14434
  printStorageError(error);
13334
14435
  }
13335
14436
  });
13336
- storageCommand.command("sync").description("Bidirectional storage sync: pull then push").option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Print JSON output", false).action(async (options) => {
14437
+ storageCommand.command("sync").description("Bidirectional storage sync: pull then push").option("--tables <tables>", "Comma-separated table names").option("--approval-token <token>", "Scoped mutation approval token").option("-j, --json", "Print JSON output", false).action(async (options) => {
13337
14438
  try {
13338
- const { parseStorageTables: parseStorageTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
13339
- const result = await storageSync2({ tables: parseStorageTables2(options.tables) });
14439
+ const { parseStorageTables: parseStorageTables2, resolveTables: resolveTables2, storageSync: storageSync2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
14440
+ const tables = resolveTables2(parseStorageTables2(options.tables));
14441
+ requireCliMutation("storage_sync", options.approvalToken, { resourceId: cliResourceId("storage-sync", tables.join(",")), args: { tables } });
14442
+ const result = await storageSync2({ tables });
13340
14443
  if (options.json) {
13341
14444
  console.log(JSON.stringify(result, null, 2));
13342
14445
  return;
@@ -13361,7 +14464,7 @@ program2.command("self-test").description("Run local package smoke checks").opti
13361
14464
  const result = runSelfTest();
13362
14465
  printJsonOrText(result, renderSelfTestResult(result), options.json);
13363
14466
  });
13364
- program2.command("serve").description("Serve a local fleet dashboard and JSON API").option("--host <host>", "Host interface to bind", "0.0.0.0").option("--port <port>", "Port to bind", "7676").option("-j, --json", "Print serve config and exit", false).action((options) => {
14467
+ program2.command("serve").description("Serve a local fleet dashboard and JSON API").option("--host <host>", "Host interface to bind", "127.0.0.1").option("--port <port>", "Port to bind", "7676").option("-j, --json", "Print serve config and exit", false).action((options) => {
13365
14468
  const info = getServeInfo({ host: options.host, port: parseIntegerOption(options.port, "port", { min: 1, max: 65535 }) });
13366
14469
  if (options.json) {
13367
14470
  console.log(JSON.stringify(info, null, 2));