@intentic/machine 1.331.0 → 1.333.0

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 (257) hide show
  1. package/README.md +67 -8
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +1 -1
  4. package/dist/app.js.map +1 -1
  5. package/dist/autostart/autostart.js +1 -1
  6. package/dist/autostart/autostart.js.map +1 -1
  7. package/dist/cli-ui.d.ts +10 -0
  8. package/dist/cli-ui.d.ts.map +1 -0
  9. package/dist/cli-ui.js +62 -0
  10. package/dist/cli-ui.js.map +1 -0
  11. package/dist/commands.d.ts.map +1 -1
  12. package/dist/commands.js +61 -26
  13. package/dist/commands.js.map +1 -1
  14. package/dist/daemon-base.d.ts +1 -0
  15. package/dist/daemon-base.d.ts.map +1 -1
  16. package/dist/daemon-base.js +21 -18
  17. package/dist/daemon-base.js.map +1 -1
  18. package/dist/device/audit.d.ts.map +1 -1
  19. package/dist/device/audit.js.map +1 -1
  20. package/dist/device/commands.d.ts +8 -6
  21. package/dist/device/commands.d.ts.map +1 -1
  22. package/dist/device/commands.js +64 -42
  23. package/dist/device/commands.js.map +1 -1
  24. package/dist/device/connection.d.ts.map +1 -1
  25. package/dist/device/connection.js +2 -3
  26. package/dist/device/connection.js.map +1 -1
  27. package/dist/device/grant.d.ts +7 -0
  28. package/dist/device/grant.d.ts.map +1 -1
  29. package/dist/device/loopback-catch.d.ts.map +1 -1
  30. package/dist/device/loopback-catch.js +1 -1
  31. package/dist/device/loopback-catch.js.map +1 -1
  32. package/dist/device/mcp.d.ts.map +1 -1
  33. package/dist/device/mcp.js +19 -0
  34. package/dist/device/mcp.js.map +1 -1
  35. package/dist/device/policy.d.ts.map +1 -1
  36. package/dist/device/policy.js +3 -5
  37. package/dist/device/policy.js.map +1 -1
  38. package/dist/device/router.d.ts +7 -0
  39. package/dist/device/router.d.ts.map +1 -1
  40. package/dist/device/router.js +17 -5
  41. package/dist/device/router.js.map +1 -1
  42. package/dist/device/sandbox-cli.d.ts +12 -1
  43. package/dist/device/sandbox-cli.d.ts.map +1 -1
  44. package/dist/device/sandbox-cli.js +90 -18
  45. package/dist/device/sandbox-cli.js.map +1 -1
  46. package/dist/device/sandbox-rounds/ic-swaps.d.ts +11 -0
  47. package/dist/device/sandbox-rounds/ic-swaps.d.ts.map +1 -0
  48. package/dist/device/sandbox-rounds/ic-swaps.js +40 -0
  49. package/dist/device/sandbox-rounds/ic-swaps.js.map +1 -0
  50. package/dist/device/sandbox-rounds/keeper.d.ts +26 -1
  51. package/dist/device/sandbox-rounds/keeper.d.ts.map +1 -1
  52. package/dist/device/sandbox-rounds/keeper.js +118 -20
  53. package/dist/device/sandbox-rounds/keeper.js.map +1 -1
  54. package/dist/device/sandbox-rounds/probation-watch.d.ts +1 -1
  55. package/dist/device/sandbox-rounds/probation-watch.d.ts.map +1 -1
  56. package/dist/device/sandbox-rounds/probation-watch.js +2 -2
  57. package/dist/device/sandbox-rounds/probation-watch.js.map +1 -1
  58. package/dist/device/sandbox-rounds/sleep.d.ts +52 -0
  59. package/dist/device/sandbox-rounds/sleep.d.ts.map +1 -0
  60. package/dist/device/sandbox-rounds/sleep.js +115 -0
  61. package/dist/device/sandbox-rounds/sleep.js.map +1 -0
  62. package/dist/device/sandbox-rounds/swap-records.d.ts +1 -5
  63. package/dist/device/sandbox-rounds/swap-records.d.ts.map +1 -1
  64. package/dist/device/sandbox-rounds/swap-records.js +2 -33
  65. package/dist/device/sandbox-rounds/swap-records.js.map +1 -1
  66. package/dist/device/sandbox-rounds/swaps-fallback.d.ts +7 -0
  67. package/dist/device/sandbox-rounds/swaps-fallback.d.ts.map +1 -0
  68. package/dist/device/sandbox-rounds/swaps-fallback.js +44 -0
  69. package/dist/device/sandbox-rounds/swaps-fallback.js.map +1 -0
  70. package/dist/device/tools/agent.js +3 -3
  71. package/dist/device/tools/agent.js.map +1 -1
  72. package/dist/device/tools/android.d.ts.map +1 -1
  73. package/dist/device/tools/android.js +7 -62
  74. package/dist/device/tools/android.js.map +1 -1
  75. package/dist/device/tools/apps.d.ts.map +1 -1
  76. package/dist/device/tools/apps.js +7 -1
  77. package/dist/device/tools/apps.js.map +1 -1
  78. package/dist/device/tools/describe.d.ts +6 -0
  79. package/dist/device/tools/describe.d.ts.map +1 -1
  80. package/dist/device/tools/describe.js +30 -9
  81. package/dist/device/tools/describe.js.map +1 -1
  82. package/dist/device/tools/device.d.ts.map +1 -1
  83. package/dist/device/tools/device.js +2 -1
  84. package/dist/device/tools/device.js.map +1 -1
  85. package/dist/device/tools/ic-binary.d.ts +7 -0
  86. package/dist/device/tools/ic-binary.d.ts.map +1 -1
  87. package/dist/device/tools/ic-binary.js +16 -8
  88. package/dist/device/tools/ic-binary.js.map +1 -1
  89. package/dist/device/tools/logs/logs.d.ts +30 -0
  90. package/dist/device/tools/logs/logs.d.ts.map +1 -0
  91. package/dist/device/tools/logs/logs.js +147 -0
  92. package/dist/device/tools/logs/logs.js.map +1 -0
  93. package/dist/device/tools/programs/artifacts.d.ts.map +1 -1
  94. package/dist/device/tools/programs/artifacts.js +5 -10
  95. package/dist/device/tools/programs/artifacts.js.map +1 -1
  96. package/dist/device/tools/programs/{tunnel.d.ts → device-tunnel.d.ts} +1 -1
  97. package/dist/device/tools/programs/device-tunnel.d.ts.map +1 -0
  98. package/dist/device/tools/programs/{tunnel.js → device-tunnel.js} +1 -1
  99. package/dist/device/tools/programs/device-tunnel.js.map +1 -0
  100. package/dist/device/tools/programs/isolated.d.ts +0 -1
  101. package/dist/device/tools/programs/isolated.d.ts.map +1 -1
  102. package/dist/device/tools/programs/isolated.js +8 -10
  103. package/dist/device/tools/programs/isolated.js.map +1 -1
  104. package/dist/device/tools/programs/programs.d.ts +1 -1
  105. package/dist/device/tools/programs/programs.d.ts.map +1 -1
  106. package/dist/device/tools/programs/programs.js +89 -35
  107. package/dist/device/tools/programs/programs.js.map +1 -1
  108. package/dist/device/tools/sandboxes.d.ts.map +1 -1
  109. package/dist/device/tools/sandboxes.js +11 -10
  110. package/dist/device/tools/sandboxes.js.map +1 -1
  111. package/dist/device/tools/shell.d.ts +6 -5
  112. package/dist/device/tools/shell.d.ts.map +1 -1
  113. package/dist/device/tools/shell.js +37 -86
  114. package/dist/device/tools/shell.js.map +1 -1
  115. package/dist/engine-env.d.ts +15 -0
  116. package/dist/engine-env.d.ts.map +1 -0
  117. package/dist/engine-env.js +80 -0
  118. package/dist/engine-env.js.map +1 -0
  119. package/dist/environments/auto-upgrade.d.ts +2 -2
  120. package/dist/environments/auto-upgrade.d.ts.map +1 -1
  121. package/dist/environments/auto-upgrade.js +3 -5
  122. package/dist/environments/auto-upgrade.js.map +1 -1
  123. package/dist/environments/commands.d.ts +7 -1
  124. package/dist/environments/commands.d.ts.map +1 -1
  125. package/dist/environments/commands.js +37 -31
  126. package/dist/environments/commands.js.map +1 -1
  127. package/dist/environments/machine-upgrade.d.ts +8 -1
  128. package/dist/environments/machine-upgrade.d.ts.map +1 -1
  129. package/dist/environments/machine-upgrade.js +35 -26
  130. package/dist/environments/machine-upgrade.js.map +1 -1
  131. package/dist/environments/machine.d.ts +5 -4
  132. package/dist/environments/machine.d.ts.map +1 -1
  133. package/dist/environments/machine.js +13 -16
  134. package/dist/environments/machine.js.map +1 -1
  135. package/dist/environments/wsl.d.ts.map +1 -1
  136. package/dist/environments/wsl.js +4 -7
  137. package/dist/environments/wsl.js.map +1 -1
  138. package/dist/install.d.ts.map +1 -1
  139. package/dist/install.js +28 -30
  140. package/dist/install.js.map +1 -1
  141. package/dist/resident/last-exit.d.ts +40 -0
  142. package/dist/resident/last-exit.d.ts.map +1 -0
  143. package/dist/resident/last-exit.js +147 -0
  144. package/dist/resident/last-exit.js.map +1 -0
  145. package/dist/resident/log-roll.d.ts +8 -0
  146. package/dist/resident/log-roll.d.ts.map +1 -0
  147. package/dist/resident/log-roll.js +30 -0
  148. package/dist/resident/log-roll.js.map +1 -0
  149. package/dist/{resident.d.ts → resident/resident.d.ts} +1 -1
  150. package/dist/resident/resident.d.ts.map +1 -0
  151. package/dist/{resident.js → resident/resident.js} +52 -27
  152. package/dist/resident/resident.js.map +1 -0
  153. package/dist/{supervision.d.ts → resident/supervision.d.ts} +7 -3
  154. package/dist/resident/supervision.d.ts.map +1 -0
  155. package/dist/{supervision.js → resident/supervision.js} +20 -16
  156. package/dist/resident/supervision.js.map +1 -0
  157. package/dist/resident/watchdog.d.ts +24 -0
  158. package/dist/resident/watchdog.d.ts.map +1 -0
  159. package/dist/resident/watchdog.js +69 -0
  160. package/dist/resident/watchdog.js.map +1 -0
  161. package/dist/status.d.ts +14 -8
  162. package/dist/status.d.ts.map +1 -1
  163. package/dist/status.js +141 -107
  164. package/dist/status.js.map +1 -1
  165. package/dist/sync/attach-commands.d.ts +1 -1
  166. package/dist/sync/attach-commands.d.ts.map +1 -1
  167. package/dist/sync/attach-commands.js +19 -12
  168. package/dist/sync/attach-commands.js.map +1 -1
  169. package/dist/sync/commands.d.ts +6 -3
  170. package/dist/sync/commands.d.ts.map +1 -1
  171. package/dist/sync/commands.js +149 -113
  172. package/dist/sync/commands.js.map +1 -1
  173. package/dist/sync/exec.d.ts +9 -0
  174. package/dist/sync/exec.d.ts.map +1 -1
  175. package/dist/sync/exec.js +21 -22
  176. package/dist/sync/exec.js.map +1 -1
  177. package/dist/sync/folders.d.ts.map +1 -1
  178. package/dist/sync/folders.js +3 -14
  179. package/dist/sync/folders.js.map +1 -1
  180. package/dist/sync/forget-command.d.ts +1 -1
  181. package/dist/sync/forget-command.d.ts.map +1 -1
  182. package/dist/sync/forget-command.js +10 -7
  183. package/dist/sync/forget-command.js.map +1 -1
  184. package/dist/sync/git-bridge.d.ts.map +1 -1
  185. package/dist/sync/git-bridge.js +4 -2
  186. package/dist/sync/git-bridge.js.map +1 -1
  187. package/dist/sync/local-sandboxes.d.ts.map +1 -1
  188. package/dist/sync/local-sandboxes.js +2 -4
  189. package/dist/sync/local-sandboxes.js.map +1 -1
  190. package/dist/sync/mirror.d.ts.map +1 -1
  191. package/dist/sync/mirror.js +9 -18
  192. package/dist/sync/mirror.js.map +1 -1
  193. package/dist/sync/mutagen.d.ts.map +1 -1
  194. package/dist/sync/mutagen.js +33 -37
  195. package/dist/sync/mutagen.js.map +1 -1
  196. package/dist/sync/project/project-commands.d.ts +7 -2
  197. package/dist/sync/project/project-commands.d.ts.map +1 -1
  198. package/dist/sync/project/project-commands.js +110 -46
  199. package/dist/sync/project/project-commands.js.map +1 -1
  200. package/dist/sync/project/project-files.d.ts +4 -6
  201. package/dist/sync/project/project-files.d.ts.map +1 -1
  202. package/dist/sync/project/project-files.js +4 -6
  203. package/dist/sync/project/project-files.js.map +1 -1
  204. package/dist/sync/project/project-local.d.ts.map +1 -1
  205. package/dist/sync/project/project-local.js +3 -19
  206. package/dist/sync/project/project-local.js.map +1 -1
  207. package/dist/sync/project/project-remote.js +4 -4
  208. package/dist/sync/project/project-transfer.d.ts +13 -30
  209. package/dist/sync/project/project-transfer.d.ts.map +1 -1
  210. package/dist/sync/project/project-transfer.js +1 -1
  211. package/dist/sync/project/project-transfer.js.map +1 -1
  212. package/dist/sync/retire.d.ts.map +1 -1
  213. package/dist/sync/retire.js +3 -3
  214. package/dist/sync/retire.js.map +1 -1
  215. package/dist/sync/siblings.d.ts.map +1 -1
  216. package/dist/sync/siblings.js +3 -6
  217. package/dist/sync/siblings.js.map +1 -1
  218. package/dist/sync/ssh.d.ts +1 -1
  219. package/dist/sync/ssh.d.ts.map +1 -1
  220. package/dist/sync/ssh.js +8 -7
  221. package/dist/sync/ssh.js.map +1 -1
  222. package/dist/sync/swap-pause.d.ts.map +1 -1
  223. package/dist/sync/swap-pause.js +2 -2
  224. package/dist/sync/swap-pause.js.map +1 -1
  225. package/dist/upgrade.d.ts +7 -0
  226. package/dist/upgrade.d.ts.map +1 -1
  227. package/dist/upgrade.js +24 -13
  228. package/dist/upgrade.js.map +1 -1
  229. package/dist/upkeep/doctor.d.ts +4 -2
  230. package/dist/upkeep/doctor.d.ts.map +1 -1
  231. package/dist/upkeep/doctor.js +40 -26
  232. package/dist/upkeep/doctor.js.map +1 -1
  233. package/dist/upkeep/login.js +1 -1
  234. package/dist/upkeep/login.js.map +1 -1
  235. package/dist/upkeep/retention.d.ts +6 -0
  236. package/dist/upkeep/retention.d.ts.map +1 -1
  237. package/dist/upkeep/retention.js +55 -3
  238. package/dist/upkeep/retention.js.map +1 -1
  239. package/dist/upkeep/retired.js +1 -1
  240. package/dist/upkeep/retired.js.map +1 -1
  241. package/dist/upkeep/second-ic.d.ts.map +1 -1
  242. package/dist/upkeep/second-ic.js +4 -6
  243. package/dist/upkeep/second-ic.js.map +1 -1
  244. package/dist/upkeep/trash.d.ts.map +1 -1
  245. package/dist/upkeep/trash.js +5 -6
  246. package/dist/upkeep/trash.js.map +1 -1
  247. package/package.json +9 -9
  248. package/dist/device/tools/programs/tunnel.d.ts.map +0 -1
  249. package/dist/device/tools/programs/tunnel.js.map +0 -1
  250. package/dist/resident.d.ts.map +0 -1
  251. package/dist/resident.js.map +0 -1
  252. package/dist/supervision.d.ts.map +0 -1
  253. package/dist/supervision.js.map +0 -1
  254. package/dist/watchdog.d.ts +0 -13
  255. package/dist/watchdog.d.ts.map +0 -1
  256. package/dist/watchdog.js +0 -38
  257. package/dist/watchdog.js.map +0 -1
@@ -1,23 +1,24 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { mkdir, readdir } from "node:fs/promises";
3
2
  import { join, resolve } from "node:path";
4
3
  import { setTimeout as sleep } from "node:timers/promises";
5
4
  import { errorMessage, undefinedIfMissing } from "@intentic/base/errors";
6
5
  import { plural } from "@intentic/base/format";
7
6
  import { WORKSPACE_ROOT } from "@intentic/constants";
8
- import { clearWindowsRunValue, createUi, homeDir } from "@intentic/local-agent";
7
+ import { clearWindowsRunValue, explain, homeDir } from "@intentic/local-agent";
9
8
  import { environmentKeyOf, projectDirNameOf, sandboxIdFromUrl, SyncEnrollmentAnswerSchema, } from "@intentic/sandbox-contract";
10
- import { buildCommand, buildRouteMap } from "@stricli/core";
9
+ import { buildRouteMap } from "@stricli/core";
10
+ import { buildCommand, machineUi, sandboxUrlOf } from "../cli-ui.js";
11
11
  import { z } from "zod";
12
12
  import { postWhileWarming } from "../daemon-base.js";
13
13
  import { completeSetup, prepareSetup } from "../install.js";
14
14
  import { machineId } from "../machine-id.js";
15
15
  import { wslEnvironment } from "../environments/wsl.js";
16
- import { ensureResident, readResidentBuild, readResidentPid } from "../resident.js";
16
+ import { ensureResident, readResidentBuild, readResidentPid } from "../resident/resident.js";
17
17
  import { MACHINE_VERSION } from "../version.js";
18
- import { machineLauncher } from "../supervision.js";
18
+ import { machineLauncher } from "../resident/supervision.js";
19
19
  import { isAttachedPairing, isProjectPairing, pairingKey, pairingRemoteDir, readState, removePairing, setAutoHealOff, setMirrorOff, setPortIgnored, upsertPairing, } from "./config.js";
20
20
  import { transportFor } from "./endpoint.js";
21
+ import { runProcess } from "./exec.js";
21
22
  import { overlappingPairing } from "./folders.js";
22
23
  import { realBridgeExec, runGitBridge } from "./git-bridge.js";
23
24
  import { retireMirroredPort, retirePairingMirror, teardownAllForwards } from "./mirror.js";
@@ -30,22 +31,35 @@ import { refuseAcrossPc } from "./siblings.js";
30
31
  import { syncSshPort, tunnelReady } from "./tunnel.js";
31
32
  import { assertSshConfigVisible, ensureSshKey, hostKeyOf, mutagenSshPath, pairingSshConfig, probeSshTransport, removeManagedSshConfig, replaceKnownHost, sanitizeId, sshAlias, writeManagedSshConfig, } from "./ssh.js";
32
33
  export { transportFor } from "./endpoint.js";
34
+ const urlHost = (value) => {
35
+ if (!value.includes("://")) {
36
+ return undefined;
37
+ }
38
+ try {
39
+ return new URL(value).host;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ };
33
45
  export const selectPairings = (state, selector) => {
34
46
  if (selector === undefined) {
35
47
  return state.pairings;
36
48
  }
37
49
  const ids = [...new Set(state.pairings.map((pairing) => pairing.sandboxId))];
38
- const exact = ids.filter((id) => id === sanitizeId(selector));
50
+ const wanted = sanitizeId(urlHost(selector) ?? selector);
51
+ const exact = ids.filter((id) => id === wanted);
39
52
  const matched = exact.length === 1 ? exact : ids.filter((id) => id.includes(selector));
40
53
  if (matched.length === 0) {
41
- throw new Error(`no paired sandbox matches "${selector}". This machine pairs: ${ids.join(", ") || "none"}`);
54
+ throw new Error(explain(`No paired sandbox matches ${selector}.`, `this device pairs ${ids.length === 0 ? "no sandbox" : ids.join(", ")}.`));
42
55
  }
43
56
  if (matched.length > 1) {
44
- throw new Error(`"${selector}" matches more than one paired sandbox: ${matched.join(", ")}`);
57
+ throw new Error(explain(`${selector} matches more than one paired sandbox.`, `it matches ${matched.join(", ")}.`, "name one of them in full with --sandbox."));
45
58
  }
46
59
  return state.pairings.filter((pairing) => pairing.sandboxId === matched[0]);
47
60
  };
48
- export const enrollKey = async (sandboxUrl, pairToken, key, { attempts, delayMs, takeover = false, identity, } = {}) => {
61
+ const SYNC_NOT_SET_UP = "Desktop sync was not set up.";
62
+ export const enrollKey = async (sandboxUrl, pairToken, key, { attempts, delayMs, takeover = false, identity, say, } = {}) => {
49
63
  const response = await postWhileWarming(sandboxUrl, "/system/authorized-key", {
50
64
  headers: {
51
65
  "content-type": "application/json",
@@ -55,25 +69,26 @@ export const enrollKey = async (sandboxUrl, pairToken, key, { attempts, delayMs,
55
69
  body: JSON.stringify({ key, ...identity }),
56
70
  }, {
57
71
  doing: "enrolling the sync key",
58
- expired: "pairing expired: click 'Enable desktop sync' again in your browser for a fresh command.",
72
+ expired: explain(SYNC_NOT_SET_UP, "the pairing has expired.", "click Enable desktop sync again in your browser for a fresh command."),
73
+ say,
59
74
  attempts,
60
75
  delayMs,
61
76
  });
62
77
  if (response.status === 423) {
63
78
  const held = (await response.json().catch(() => ({})));
64
79
  const from = held.machine !== undefined ? ` from "${held.machine}"` : "";
65
- throw new Error(`desktop sync is already active on this sandbox${from}. Re-run with --takeover to move it to this machine (this stops syncing on the other one).`);
80
+ throw new Error(explain(`Desktop sync is already active on this sandbox${from}.`, "one device at a time holds a sandbox's file sync.", "run the same command again with --takeover to move it to this device; the other one stops syncing."));
66
81
  }
67
82
  if (!response.ok) {
68
- throw new Error(`enrolling the sync key failed (${response.status}): ${await response.text()}`);
83
+ throw new Error(explain(SYNC_NOT_SET_UP, `enrolling the sync key failed (${response.status}): ${await response.text()}`));
69
84
  }
70
85
  const answer = SyncEnrollmentAnswerSchema.safeParse(await response.json());
71
86
  if (!answer.success) {
72
- throw new Error(`the sandbox enrolled this machine but its answer could not be read: ${z.prettifyError(answer.error)}`);
87
+ throw new Error(explain(SYNC_NOT_SET_UP, `the sandbox enrolled this device but its answer could not be read: ${z.prettifyError(answer.error)}`));
73
88
  }
74
89
  const body = answer.data;
75
90
  if (body.syncToken === undefined) {
76
- throw new Error("the sandbox enrolled this machine but returned no sync credential: update the sandbox and enable sync again.");
91
+ throw new Error(explain(SYNC_NOT_SET_UP, "the sandbox enrolled this device but returned no sync credential.", "update the sandbox, then enable sync again."));
77
92
  }
78
93
  return { syncToken: body.syncToken, mode: body.mode ?? "sync", hostKey: hostKeyOf(body.hostKey) };
79
94
  };
@@ -113,11 +128,12 @@ const wantedFolder = (flags, sandboxId) => resolve(flags.dir === undefined
113
128
  : flags.dir.replace(/^~(?=[\\/]|$)/, homeDir()));
114
129
  const setup = buildCommand({
115
130
  docs: {
116
- brief: "Enroll this machine with a pairing token and start a Mutagen sync of the local dir ↔ sandbox /work (or a project folder in it), over ssh or, for a project on this machine's Docker engine, through Docker",
131
+ brief: "Enroll this device with a pairing token and start a Mutagen sync of the local dir ↔ sandbox /work (or a project folder in it), over ssh or, for a project on this device's Docker engine, through Docker",
117
132
  },
118
133
  parameters: {
119
134
  flags: {
120
- url: { kind: "parsed", parse: String, brief: "The sandbox's public URL (e.g. https://sandbox-xxx.example.dev)" },
135
+ url: { kind: "parsed", parse: String, optional: true, brief: "The sandbox's public URL (e.g. https://sandbox-xxx.example.dev)" },
136
+ sandbox: { kind: "parsed", parse: String, optional: true, brief: "The same as --url" },
121
137
  pair: { kind: "parsed", parse: String, brief: "The one-time pairing token from the Desktop sync card" },
122
138
  dir: {
123
139
  kind: "parsed",
@@ -126,7 +142,7 @@ const setup = buildCommand({
126
142
  brief: "Local directory to sync (default: ~/intentic/<sandbox id>, the id in the sandbox's own URL)",
127
143
  },
128
144
  sandboxId: { kind: "parsed", parse: String, optional: true, brief: "Session/alias id (default: the sandbox URL host)" },
129
- takeover: { kind: "boolean", brief: "Take over sync from another machine already enrolled on this sandbox (revokes its key)" },
145
+ takeover: { kind: "boolean", brief: "Take over sync from another device already enrolled on this sandbox (revokes its key)" },
130
146
  remoteDir: {
131
147
  kind: "parsed",
132
148
  parse: parseRemoteDir,
@@ -141,7 +157,7 @@ const setup = buildCommand({
141
157
  kind: "parsed",
142
158
  parse: parseTransport,
143
159
  optional: true,
144
- brief: "How the sandbox is reached: auto (the default: Docker for a project whose sandbox runs on this machine's engine, ssh otherwise), ssh or docker",
160
+ brief: "How the sandbox is reached: auto (the default: Docker for a project whose sandbox runs on this device's engine, ssh otherwise), ssh or docker",
145
161
  },
146
162
  projectsHost: {
147
163
  kind: "boolean",
@@ -150,21 +166,18 @@ const setup = buildCommand({
150
166
  },
151
167
  },
152
168
  },
153
- async func(flags) {
154
- await prepareSetup((message) => void this.process.stdout.write(`${message}\n`), process.argv.slice(2));
155
- const ui = createUi(this.process);
169
+ async func(given) {
170
+ const flags = { ...given, url: sandboxUrlOf(given.url, given.sandbox, "Desktop sync was not set up.") };
171
+ const ui = machineUi(this.process);
172
+ await prepareSetup(ui.note, process.argv.slice(2));
156
173
  const out = ui.note;
157
174
  ui.begin("intentic · desktop sync", SETUP_PLAN);
158
- try {
159
- await runSetup(ui, out, flags);
160
- }
161
- finally {
162
- ui.close();
163
- }
175
+ await runSetup(ui, out, flags);
176
+ ui.close();
164
177
  },
165
178
  });
166
179
  const SETUP_PLAN = [
167
- { phase: "sync-enrolling", label: "Enrol this machine", weight: 25 },
180
+ { phase: "sync-enrolling", label: "Connect this device", weight: 25 },
168
181
  { phase: "sync-linking", label: "Link the folder", weight: 5 },
169
182
  { phase: "sync-starting", label: "Start syncing", weight: 20 },
170
183
  ];
@@ -175,27 +188,28 @@ export const placementChange = (held, placement) => {
175
188
  const same = pairingRemoteDir(held) === pairingRemoteDir(placement) && isProjectPairing(held) === isProjectPairing(placement);
176
189
  return same
177
190
  ? undefined
178
- : `${held.sandboxId} already syncs ${held.localDir} with its ${pairingRemoteDir(held)}${isProjectPairing(held) ? " as a project" : ""}; setting it up again for ${pairingRemoteDir(placement)}${isProjectPairing(placement) ? " as a project" : ""} would rewrite what that folder holds. Unpair it first (\`intentic-machine sync uninstall --sandbox ${held.sandboxId}\`), then set it up again.`;
191
+ : explain(`${held.sandboxId} was not set up again.`, `it already syncs ${held.localDir} with its ${pairingRemoteDir(held)}${isProjectPairing(held) ? " as a project" : ""}; setting it up again for ${pairingRemoteDir(placement)}${isProjectPairing(placement) ? " as a project" : ""} would rewrite what that folder holds. Unpair it first, then set it up again.`, unpairCommand(held.sandboxId));
179
192
  };
193
+ const unpairCommand = (sandboxId) => `intentic-machine sync uninstall --sandbox ${sandboxId}`;
180
194
  export const projectAskedWithoutFlag = (flags, env = process.env) => {
181
195
  const remote = env["SYNC_REMOTE_DIR"] ?? ``;
182
196
  const asked = (env["SYNC_PROJECT"] ?? ``) !== `` || (remote !== `` && remote !== WORKSPACE_ROOT);
183
197
  return asked && !flags.project
184
- ? `This setup was asked to sync a project folder (SYNC_PROJECT, SYNC_REMOTE_DIR), but the install script that ran it did not pass --project: it is older than this agent. Nothing was enrolled. Run the setup again once the script is current.`
198
+ ? explain("Nothing was enrolled.", "this setup was asked to sync a project folder (SYNC_PROJECT, SYNC_REMOTE_DIR), but the install script that ran it did not pass --project: it is older than this agent.", "run the setup again once the script is current.")
185
199
  : undefined;
186
200
  };
187
201
  export const projectsHostChange = (held, flags) => {
188
202
  if (flags.projectsHost === true && (flags.dir !== undefined || flags.remoteDir !== undefined || flags.project)) {
189
- return "--projects-host enrolls this computer's sandbox with no folder of its own, so it takes no --dir, --remote-dir or --project. Attach folders to it afterwards with `intentic-machine sync attach`.";
203
+ return explain("Nothing was enrolled.", "--projects-host enrolls this device's sandbox with no folder of its own, so it takes no --dir, --remote-dir or --project; folders attach to it afterwards.", "run the same command again without --dir, --remote-dir and --project.");
190
204
  }
191
205
  if (held === undefined) {
192
206
  return undefined;
193
207
  }
194
208
  if (flags.projectsHost === true && held.localDir !== undefined) {
195
- return `${held.sandboxId} already syncs ${held.localDir}; setting it up as this computer's sandbox for folders would stop that. Unpair it first (\`intentic-machine sync uninstall --sandbox ${held.sandboxId}\`), then set it up again.`;
209
+ return explain(`${held.sandboxId} was not set up again.`, `it already syncs ${held.localDir}; setting it up as this device's sandbox for folders would stop that. Unpair it first, then set it up again.`, unpairCommand(held.sandboxId));
196
210
  }
197
211
  if (flags.projectsHost !== true && held.projectsHost === true) {
198
- return `${held.sandboxId} is this computer's sandbox for folders, which attach to it; setting it up for a folder of its own would leave them without it. Set it up again with --projects-host, or unpair it first (\`intentic-machine sync uninstall --sandbox ${held.sandboxId}\`).`;
212
+ return explain(`${held.sandboxId} was not set up again.`, "it is this device's sandbox for folders, which attach to it; setting it up for a folder of its own would leave them without it. Set it up again with --projects-host, or unpair it first.", unpairCommand(held.sandboxId));
199
213
  }
200
214
  return undefined;
201
215
  };
@@ -227,7 +241,7 @@ const planSetup = async (flags) => {
227
241
  }
228
242
  const clash = await overlappingPairing(folder, sandboxId, pairings);
229
243
  if (clash !== undefined) {
230
- throw new Error(`${folder} overlaps ${clash.localDir}, which already syncs with ${clash.sandboxId}: two syncs over one folder overwrite each other's files. Choose a folder that neither is, holds nor sits inside one this machine syncs (\`intentic-machine status\` lists them).`);
244
+ throw new Error(explain(`${folder} was not set up.`, `it overlaps ${clash.localDir}, which already syncs with ${clash.sandboxId}, and two syncs over one folder overwrite each other's files.`, "choose a folder that neither is, holds nor sits inside one this device syncs; intentic-machine status lists them."));
231
245
  }
232
246
  const across = await refuseAcrossPc(folder);
233
247
  if (across.refusal !== undefined) {
@@ -237,15 +251,16 @@ const planSetup = async (flags) => {
237
251
  return { sandboxId, placement, folder, reach, unread: across.unread };
238
252
  };
239
253
  const runSetup = async (ui, out, flags) => {
240
- ui.step("sync-enrolling", "enrolling this machine with your sandbox…");
254
+ ui.step("sync-enrolling", "enrolling this device with your sandbox…");
241
255
  const { sandboxId, placement, folder, reach, unread = [] } = await planSetup(flags);
242
256
  if (unread.length > 0) {
243
- out(`note: ${unread.join(", ")} could not be asked which folders ${unread.length === 1 ? "it syncs" : "they sync"}, so ${folder ?? "this folder"} was not checked against ${unread.length === 1 ? "it" : "them"}.`);
257
+ ui.warn(`${unread.join(", ")} could not be asked which folders ${unread.length === 1 ? "it syncs" : "they sync"}, so ${folder ?? "this folder"} was not checked against ${unread.length === 1 ? "it" : "them"}.`);
244
258
  }
245
259
  const publicKey = await ensureSshKey();
246
260
  const [{ syncToken, mode, hostKey }, mutagen] = await Promise.all([
247
261
  enrollKey(flags.url, flags.pair, publicKey, {
248
262
  takeover: flags.takeover,
263
+ say: out,
249
264
  identity: { machineId: machineId(), environment: environmentKeyOf({ wsl: await wslEnvironment() }) },
250
265
  }),
251
266
  ensureMutagen(),
@@ -254,8 +269,8 @@ const runSetup = async (ui, out, flags) => {
254
269
  const container = dockerContainerOf(reach, mode);
255
270
  await pinHostKey(sandboxId, hostKey, container);
256
271
  if ((placement.project === true || flags.projectsHost === true) && mode !== "sync") {
257
- await revokeEnrollment(flags.url, syncToken).catch((error) => out(`note: ${flags.url} could not be told to forget this machine (${errorMessage(error)}), so its sync key is still authorized there. Remove this machine from that sandbox's Devices view.`));
258
- throw new Error(`another machine holds file sync for this sandbox, so ${flags.projectsHost === true ? "no folder can attach to it" : "this project folder cannot sync"} here. Re-run with --takeover to move sync to this machine.`);
272
+ await revokeEnrollment(flags.url, syncToken).catch((error) => ui.warn(`${flags.url} could not be told to forget this device (${errorMessage(error)}), so its sync key is still authorized there. Remove this device from that sandbox's Devices view.`));
273
+ throw new Error(explain(flags.projectsHost === true ? "No folder can attach to this sandbox here." : "This project folder cannot sync here.", "another device holds file sync for this sandbox.", "run the same command again with --takeover to move sync to this device."));
259
274
  }
260
275
  const localDir = mode === "sync" ? folder : undefined;
261
276
  const adopt = localDir !== undefined && placement.project !== true && (await holdsEntries(localDir));
@@ -277,16 +292,19 @@ const runSetup = async (ui, out, flags) => {
277
292
  const pairings = (await readState()).pairings;
278
293
  await writeManagedSshConfig(pairingSshConfig(pairings));
279
294
  ui.step("sync-starting", "starting the sync engine…");
295
+ const later = [];
280
296
  await ensureResident(out);
281
297
  await completeSetup(out);
282
- await proveTransport(out, sandboxId, container);
298
+ await proveTransport(out, ui.warn, sandboxId, container);
283
299
  if (mode === "sync" && localDir !== undefined && !(await syncSessionAppears(mutagen, sandboxId, SESSION_READY_MS))) {
284
- out(await notStartedYet(localDir));
300
+ const late = await notStartedYet(localDir);
301
+ ui.warn(late.said);
302
+ later.push(late.later);
285
303
  }
286
304
  retireOrphanSessions(mutagen, pairings, out);
287
305
  runGitBridge(realBridgeExec, pairing, out, undefined);
288
306
  registerMutagenAutostart(mutagen, machineLauncher(), out);
289
- finishSetup(ui, flags.url, pairing, pairings);
307
+ finishSetup(ui, flags.url, pairing, pairings, later);
290
308
  };
291
309
  const holdsEntries = async (dir) => ((await readdir(dir).catch(undefinedIfMissing))?.length ?? 0) > 0;
292
310
  const dockerContainerOf = (reach, mode) => mode === "sync" && reach.transport === "docker" ? reach.container : undefined;
@@ -295,18 +313,18 @@ const pinHostKey = async (sandboxId, hostKey, container) => {
295
313
  await replaceKnownHost(sshAlias(sandboxId), syncSshPort(sandboxId), hostKey);
296
314
  }
297
315
  };
298
- const proveTransport = async (out, sandboxId, container) => {
316
+ const proveTransport = async (out, warn, sandboxId, container) => {
299
317
  if (container !== undefined) {
300
- out(`reaching ${sandboxId} through Docker on this machine (${container}): no ssh key, tunnel or public address on the data path`);
318
+ out(`reaching ${sandboxId} through Docker on this device (${container}): no ssh key, tunnel or public address on the data path`);
301
319
  return;
302
320
  }
303
321
  const alias = sshAlias(sandboxId);
304
322
  const port = syncSshPort(sandboxId);
305
323
  if (!(await tunnelReady(port, TUNNEL_READY_MS))) {
306
- out(`note: the sync transport for ${sandboxId} isn't listening on 127.0.0.1:${port} yet, syncing starts as soon as it is.`);
324
+ warn(`The sync transport for ${sandboxId} isn't listening on 127.0.0.1:${port} yet; syncing starts as soon as it is.`);
307
325
  }
308
326
  const ssh = mutagenSshPath(process.platform, process.env["MUTAGEN_SSH_PATH"]);
309
- assertSshConfigVisible(ssh, alias, port);
327
+ await assertSshConfigVisible(ssh, alias, port);
310
328
  await probeSshTransport(ssh, alias, out);
311
329
  };
312
330
  const syncedSentence = (pairing) => {
@@ -315,25 +333,23 @@ const syncedSentence = (pairing) => {
315
333
  ? `${kept}\nThe folder already held files: any that differ from the sandbox's copy are moved to ${asidePrefix(pairing.localDir)}<time> first, so the sandbox's version is the one that syncs. Nothing is deleted.`
316
334
  : kept;
317
335
  };
318
- const finishSetup = (ui, sandboxUrl, pairing, pairings) => {
336
+ const finishSetup = (ui, sandboxUrl, pairing, pairings, later = []) => {
319
337
  if (pairings.length > 1) {
320
- ui.note(`This machine now syncs ${pairings.length} pairings:`);
338
+ ui.note(`This device now syncs ${pairings.length} pairings:`);
321
339
  for (const held of pairings) {
322
- ui.note(` ${pairingKey(held)}${held.projectsHost === true ? " (this computer's sandbox, for folders)" : held.localDir === undefined ? " (ports only)" : ` → ${held.localDir}`}`);
340
+ ui.note(` ${pairingKey(held)}${held.projectsHost === true ? " (this device's sandbox, for folders)" : held.localDir === undefined ? " (ports only)" : ` → ${held.localDir}`}`);
323
341
  }
324
342
  }
325
343
  if (pairing.projectsHost === true) {
326
- ui.finished("This computer's sandbox is ready for folders.", undefined, `Ports from ${sandboxUrl} answer on this machine's localhost, and folders attach to it as ${WORKSPACE_ROOT}/<name>.`, [
327
- ["attach a folder", "intentic-machine sync attach --sandbox-url <url> --dir <folder> --name <name>"],
344
+ ui.finished("This device's sandbox is ready for folders.", undefined, `Ports from ${sandboxUrl} answer on this device's localhost, and folders attach to it as ${WORKSPACE_ROOT}/<name>.`, [
345
+ ["attach a folder", `intentic-machine sync attach --sandbox-url ${sandboxUrl} --dir <folder> --name <name>`],
328
346
  ["check it", "intentic-machine status"],
347
+ ...later,
329
348
  ]);
330
349
  return;
331
350
  }
332
351
  const syncing = pairing.mode === "sync";
333
- ui.finished(syncing ? "Desktop sync is running." : "Enrolled for port mirroring.", syncing ? pairing.localDir : undefined, syncing ? syncedSentence(pairing) : `Ports from ${sandboxUrl} now answer on this machine's localhost (mirror-only, no file sync).`, [
334
- ["check it", "intentic-machine status"],
335
- ["remove it", "intentic-machine sync uninstall"],
336
- ]);
352
+ ui.finished(syncing ? "Desktop sync is running." : "Enrolled for port mirroring.", syncing ? pairing.localDir : undefined, syncing ? syncedSentence(pairing) : `Ports from ${sandboxUrl} now answer on this device's localhost (mirror-only, no file sync).`, [["check it", "intentic-machine status"], ["remove it", unpairCommand(pairing.sandboxId)], ...later]);
337
353
  };
338
354
  const TUNNEL_READY_MS = 10_000;
339
355
  const SESSION_READY_MS = 90_000;
@@ -350,15 +366,21 @@ const syncSessionAppears = async (mutagen, key, withinMs) => {
350
366
  const notStartedYet = async (folder) => {
351
367
  const running = await readResidentBuild();
352
368
  return running !== undefined && running !== MACHINE_VERSION
353
- ? `note: the agent running here is ${running}, which starts file sync for ${folder} only at its next start; \`intentic-machine upgrade\` brings it to ${MACHINE_VERSION} now.`
354
- : `note: this machine's agent has not started file sync for ${folder} yet; it keeps trying, and \`intentic-machine status\` shows when it is running.`;
369
+ ? {
370
+ said: `The agent running here is ${running}, which starts file sync for ${folder} only at its next start; ${MACHINE_VERSION} starts it at once.`,
371
+ later: ["update it now", "intentic-machine upgrade"],
372
+ }
373
+ : {
374
+ said: `This device's agent has not started file sync for ${folder} yet; it keeps trying.`,
375
+ later: ["see when it runs", "intentic-machine status"],
376
+ };
355
377
  };
356
378
  const sandboxFlag = {
357
379
  sandbox: {
358
380
  kind: "parsed",
359
381
  parse: String,
360
382
  optional: true,
361
- brief: "Act on one paired sandbox (its id, or any substring matching exactly one). Default: all of them",
383
+ brief: "Act on one paired sandbox (its URL, its id, or any part of the id matching exactly one). Default: all of them",
362
384
  },
363
385
  };
364
386
  export const syncSwitchPlan = (syncing, held) => {
@@ -372,44 +394,48 @@ const pairingCommand = (brief, flags, none, act) => buildCommand({
372
394
  docs: { brief },
373
395
  parameters: { flags },
374
396
  async func(given) {
375
- const out = (message) => void this.process.stdout.write(`${message}\n`);
397
+ const ui = machineUi(this.process);
376
398
  const selected = selectPairings(await readState(), given.sandbox);
377
399
  if (selected.length === 0) {
378
- out(none);
400
+ ui.quiet(none);
379
401
  return;
380
402
  }
381
- await act(selected, given, out);
403
+ await act(selected, given, ui);
382
404
  },
383
405
  });
384
- const NONE_TO_MIRROR = "no sandboxes are paired on this machine: nothing to mirror. Enable it from a sandbox's Desktop sync card.";
385
- const noteIfStopped = async (out) => {
406
+ const NONE_TO_MIRROR = "No sandbox is paired on this device, so there is nothing to mirror. Desktop sync starts from a sandbox's Desktop sync card.";
407
+ const noteIfStopped = async (ui) => {
386
408
  if ((await readResidentPid()) === undefined) {
387
- out("Note: this machine's agent is NOT running, so nothing will mirror until you start it: `intentic-machine run`.");
409
+ ui.warn("This device's agent is not running, so nothing mirrors until it starts.");
410
+ ui.later([["start the agent", "intentic-machine run"]]);
388
411
  }
389
412
  };
390
- const fileSyncSwitch = (brief, verb) => pairingCommand(brief, sandboxFlag, `no sandboxes are paired on this machine: nothing to ${verb}. Enable sync from a sandbox's Desktop sync card.`, async (selected, _flags, out) => {
413
+ const fileSyncSwitch = (brief, verb) => pairingCommand(brief, sandboxFlag, `No sandbox is paired on this device, so there is nothing to ${verb}. Desktop sync starts from a sandbox's Desktop sync card.`, async (selected, _flags, ui) => {
414
+ const done = verb === "pause" ? "paused" : "resumed";
391
415
  const syncing = selected.filter((pairing) => pairing.mode === "sync" && pairing.projectsHost !== true);
392
416
  if (syncing.length === 0) {
393
- out(`mirror-only enrollment${selected.length > 1 ? "s" : ""}, no file sync to ${verb}.`);
417
+ ui.quiet(`${selected.length > 1 ? "These enrollments only mirror ports" : "This enrollment only mirrors ports"}, so there is no file sync to ${verb}.`);
394
418
  return;
395
419
  }
420
+ ui.act(`${verb === "pause" ? "Pausing" : "Resuming"} file sync`);
396
421
  const mutagen = await ensureMutagen();
397
422
  const held = existingSyncSessions(mutagen, syncing.flatMap((pairing) => syncSessionNames(pairing)));
398
423
  if (held === undefined) {
399
- throw new Error(`Mutagen did not answer when asked which sessions it holds, so nothing was ${verb === "pause" ? "paused" : "resumed"}. Try again in a moment.`);
424
+ throw new Error(explain(`File sync was not ${done}.`, "Mutagen did not answer when asked which sessions it holds.", "run the same command again in a moment."));
400
425
  }
401
426
  const plan = syncSwitchPlan(syncing, held);
402
427
  if (plan.names.length === 0) {
403
- out(`No file-sync session is running for: ${named(syncing)}. Nothing to ${verb}; syncing starts when the sandbox answers again.`);
428
+ ui.done(`No file-sync session is running for ${named(syncing)}, so nothing was ${done}. Syncing starts when the sandbox answers again.`);
404
429
  return;
405
430
  }
406
431
  runMutagen(mutagen, ["sync", verb, ...plan.names]);
407
- out(`${verb === "pause" ? "Paused" : "Resumed"} file sync for: ${named(plan.acted)}`);
408
432
  if (plan.idle.length > 0) {
409
- out(`No file-sync session to ${verb} for: ${named(plan.idle)}.`);
433
+ ui.note(`No file-sync session to ${verb} for ${named(plan.idle)}.`);
410
434
  }
435
+ ui.done(`${verb === "pause" ? "Paused" : "Resumed"} file sync for ${named(plan.acted)}.`);
411
436
  });
412
- const mirrorSwitch = (brief, off) => pairingCommand(brief, sandboxFlag, NONE_TO_MIRROR, async (selected, _flags, out) => {
437
+ const mirrorSwitch = (brief, off) => pairingCommand(brief, sandboxFlag, NONE_TO_MIRROR, async (selected, _flags, ui) => {
438
+ ui.act(off ? "Turning port mirroring off" : "Turning port mirroring on");
413
439
  const mutagen = await ensureMutagen();
414
440
  for (const pairing of selected) {
415
441
  await setMirrorOff(pairingKey(pairing), off);
@@ -418,22 +444,23 @@ const mirrorSwitch = (brief, off) => pairingCommand(brief, sandboxFlag, NONE_TO_
418
444
  for (const sandboxId of new Set(selected.map((pairing) => pairing.sandboxId))) {
419
445
  await retirePairingMirror(mutagen, sandboxId);
420
446
  }
421
- }
422
- if (off) {
423
- out(`Port mirroring OFF for: ${named(selected)}. Those ports are off this device's localhost. File syncing is untouched.`);
447
+ ui.note("Those ports are off this device's localhost. File syncing is untouched.");
448
+ ui.done(`Turned port mirroring off for ${named(selected)}.`);
424
449
  return;
425
450
  }
426
- out(`Port mirroring on for: ${named(selected)}. Their ports return to localhost within a few seconds.`);
427
- await noteIfStopped(out);
451
+ ui.note("Their ports return to localhost within a few seconds.");
452
+ ui.done(`Turned port mirroring on for ${named(selected)}.`);
453
+ await noteIfStopped(ui);
428
454
  });
429
455
  const parsePort = (value) => {
430
456
  const port = Number(value);
431
457
  if (!Number.isInteger(port) || port < 1 || port > 65_535) {
432
- throw new Error(`"${value}" is not a port number (1-65535).`);
458
+ throw new Error(`${value} is not a port number (1-65535).`);
433
459
  }
434
460
  return port;
435
461
  };
436
- const mirrorPortSwitch = (brief, ignored) => pairingCommand(brief, { ...sandboxFlag, port: { kind: "parsed", parse: parsePort, brief: "The port number, as the sandbox serves it" } }, NONE_TO_MIRROR, async (selected, flags, out) => {
462
+ const mirrorPortSwitch = (brief, ignored) => pairingCommand(brief, { ...sandboxFlag, port: { kind: "parsed", parse: parsePort, brief: "The port number, as the sandbox serves it" } }, NONE_TO_MIRROR, async (selected, flags, ui) => {
463
+ ui.act(ignored ? `Leaving port ${flags.port} off this device` : `Mirroring port ${flags.port} again`);
437
464
  const mutagen = await ensureMutagen();
438
465
  let taken = 0;
439
466
  for (const pairing of selected.filter((held) => !isAttachedPairing(held))) {
@@ -443,11 +470,13 @@ const mirrorPortSwitch = (brief, ignored) => pairingCommand(brief, { ...sandboxF
443
470
  }
444
471
  }
445
472
  if (ignored) {
446
- out(`Port ${flags.port} will not be mirrored for: ${named(selected)}.${taken === 0 ? "" : ` Took localhost:${flags.port} off this device.`} Every other port is untouched.`);
473
+ ui.note(`${taken === 0 ? "" : `Took localhost:${flags.port} off this device. `}Every other port is untouched.`);
474
+ ui.done(`Port ${flags.port} is no longer mirrored for ${named(selected)}.`);
447
475
  return;
448
476
  }
449
- out(`Port ${flags.port} will be mirrored again for: ${named(selected)}. It returns to localhost within a few seconds, unless something else on this machine is holding it.`);
450
- await noteIfStopped(out);
477
+ ui.note(`It returns to localhost within a few seconds, unless something else on this device is holding it.`);
478
+ ui.done(`Port ${flags.port} is mirrored again for ${named(selected)}.`);
479
+ await noteIfStopped(ui);
451
480
  });
452
481
  const mirror = buildRouteMap({
453
482
  routes: {
@@ -462,35 +491,44 @@ const clean = buildCommand({
462
491
  docs: { brief: "Clear build output this device left in directories the sandbox deleted, so those deletions can land" },
463
492
  parameters: { flags: sandboxFlag },
464
493
  async func(flags) {
465
- const out = (message) => void this.process.stdout.write(`${message}\n`);
494
+ const ui = machineUi(this.process);
466
495
  const syncing = selectPairings(await readState(), flags.sandbox).filter((pairing) => pairing.mode === "sync");
467
496
  if (syncing.length === 0) {
468
- out("no file-syncing sandbox is paired on this machine: there is no folder here to clear anything from.");
497
+ ui.quiet("No file-syncing sandbox is paired on this device, so there is no folder here to clear anything from.");
469
498
  return;
470
499
  }
500
+ ui.act("Clearing build output the sandbox's deletions are waiting on");
471
501
  const mutagen = await ensureMutagen();
472
502
  let removed = 0;
473
503
  let standing = 0;
474
504
  for (const pairing of syncing) {
475
- const outcome = await healDerivedConflicts(mutagen, pairing, out, true);
505
+ const outcome = await healDerivedConflicts(mutagen, pairing, ui.note, true);
476
506
  removed += outcome.removed.length;
477
507
  standing += outcome.standing;
478
508
  }
479
- out(removed === 0
480
- ? "Nothing to clear: no directory the sandbox deleted is being held open by build output on this device."
481
- : `Cleared ${removed} director${removed === 1 ? "y" : "ies"}. The deletions they were holding back land within a few seconds.`);
482
509
  if (standing > 0) {
483
- out(`${plural(standing, "conflict")} still standing, and not this command's to settle: two copies somebody wrote is a choice only a person makes. \`intentic-machine status\` lists the paths.`);
510
+ ui.warn(`${plural(standing, "conflict")} still standing, and not this command's to settle: two copies somebody wrote is a choice only a person makes.`);
511
+ }
512
+ ui.done(removed === 0
513
+ ? "Nothing to clear: no directory the sandbox deleted is held open by build output on this device."
514
+ : `Cleared ${plural(removed, "directory", "directories")}. The deletions they held back land within a few seconds.`);
515
+ if (standing > 0) {
516
+ ui.later([["see the paths", "intentic-machine status"]]);
484
517
  }
485
518
  },
486
519
  });
487
- const autoHealSwitch = (brief, off) => pairingCommand(brief, sandboxFlag, "no sandboxes are paired on this machine: nothing to switch.", async (selected, _flags, out) => {
520
+ const autoHealSwitch = (brief, off) => pairingCommand(brief, sandboxFlag, "No sandbox is paired on this device, so there is nothing to switch.", async (selected, _flags, ui) => {
521
+ ui.act(off ? "Turning off clearing build output" : "Turning on clearing build output");
488
522
  for (const pairing of selected) {
489
523
  await setAutoHealOff(pairingKey(pairing), off);
490
524
  }
491
- out(off
492
- ? `Clearing derived residue is OFF for: ${named(selected)}. A deletion the sandbox makes will now stop syncing whenever this device has build output inside it; \`intentic-machine sync clean\` clears one by hand.`
493
- : `Clearing derived residue is on for: ${named(selected)}.`);
525
+ if (!off) {
526
+ ui.done(`This device clears build output that holds back a deletion for ${named(selected)}.`);
527
+ return;
528
+ }
529
+ ui.note("A deletion the sandbox makes now stops syncing whenever this device has build output inside it.");
530
+ ui.done(`This device no longer clears build output by itself for ${named(selected)}.`);
531
+ ui.later([["clear it by hand", "intentic-machine sync clean"]]);
494
532
  });
495
533
  const autoheal = buildRouteMap({
496
534
  routes: {
@@ -499,7 +537,7 @@ const autoheal = buildRouteMap({
499
537
  },
500
538
  docs: { brief: "Whether this device clears its own build output when it blocks a deletion, for one sandbox or all" },
501
539
  });
502
- export const syncUninstall = async (out, sandbox, { forGood = false } = {}) => {
540
+ export const syncUninstall = async (say, sandbox, { forGood = false } = {}) => {
503
541
  const state = await readState();
504
542
  const dropped = selectPairings(state, sandbox);
505
543
  const mutagen = await ensureMutagen();
@@ -509,48 +547,46 @@ export const syncUninstall = async (out, sandbox, { forGood = false } = {}) => {
509
547
  const firstOfSandbox = !revoked.has(pairing.sandboxId);
510
548
  revoked.add(pairing.sandboxId);
511
549
  if (pairing.syncToken !== undefined && firstOfSandbox) {
512
- await revokeEnrollment(pairing.sandboxUrl, pairing.syncToken).catch((error) => out(`note: ${pairing.sandboxUrl} could not be told to forget this machine (${errorMessage(error)}), so its sync key is still authorized there. Remove this machine from that sandbox's Devices view.`));
550
+ await revokeEnrollment(pairing.sandboxUrl, pairing.syncToken).catch((error) => say.warn(`${pairing.sandboxUrl} could not be told to forget this device (${errorMessage(error)}), so its sync key is still authorized there. Remove this device from that sandbox's Devices view.`));
513
551
  }
514
552
  if (pairing.mode === "sync" && syncSessionNames(pairing).length > 0) {
515
- spawnSync(mutagen, ["sync", "terminate", ...syncSessionNames(pairing)], {
516
- stdio: "ignore",
517
- windowsHide: true,
518
- timeout: MUTAGEN_CALL_TIMEOUT_MS,
519
- });
553
+ await runProcess(mutagen, ["sync", "terminate", ...syncSessionNames(pairing)], { timeoutMs: MUTAGEN_CALL_TIMEOUT_MS });
520
554
  }
521
555
  if (firstOfSandbox) {
522
556
  await retirePairingMirror(mutagen, pairing.sandboxId);
523
557
  }
524
558
  await removePairing(pairingKey(pairing));
525
- out(`unpaired ${pairingKey(pairing)}${pairing.localDir === undefined ? "" : ` (${pairing.localDir} is no longer synced)`}.`);
559
+ say.note(`Unpaired ${pairingKey(pairing)}${pairing.localDir === undefined ? "" : `; ${pairing.localDir} is no longer synced`}.`);
526
560
  }
527
561
  if (remaining.length > 0) {
528
562
  await writeManagedSshConfig(pairingSshConfig(remaining));
529
- await ensureResident(out, { forGood });
530
- out(`Still syncing ${plural(remaining.length, "pairing")}: ${named(remaining)}`);
531
- return;
563
+ await ensureResident(say.note, { forGood });
564
+ return `Unpaired ${plural(dropped.length, "pairing")}. Still syncing ${plural(remaining.length, "pairing")}: ${named(remaining)}.`;
532
565
  }
533
- await ensureResident(out, { forGood });
534
- await teardownAllForwards(mutagen, out);
566
+ await ensureResident(say.note, { forGood });
567
+ await teardownAllForwards(mutagen, say.note);
535
568
  await removeManagedSshConfig();
536
569
  const ownCopy = isOwnMutagen(mutagen) && userMutagenOnPath() === undefined;
537
570
  if (ownCopy) {
538
571
  unregisterMutagenAutostart(mutagen);
539
- spawnSync(mutagen, ["daemon", "stop"], { stdio: "ignore", windowsHide: true, timeout: MUTAGEN_CALL_TIMEOUT_MS });
572
+ await runProcess(mutagen, ["daemon", "stop"], { timeoutMs: MUTAGEN_CALL_TIMEOUT_MS });
573
+ say.note("Removed the ssh-config include, and stopped and unregistered the Mutagen daemon.");
540
574
  }
541
- else if (process.platform === "win32") {
542
- clearWindowsRunValue(MUTAGEN_RUN_VALUE);
575
+ else {
576
+ if (process.platform === "win32") {
577
+ clearWindowsRunValue(MUTAGEN_RUN_VALUE);
578
+ }
579
+ say.note("Removed the ssh-config include. Your own Mutagen install and its daemon are untouched.");
543
580
  }
544
- out(ownCopy
545
- ? "Sync terminated; ssh-config include removed; Mutagen daemon stopped and unregistered."
546
- : "Sync terminated; ssh-config include removed. (Your own Mutagen install is untouched, `mutagen daemon unregister` if you no longer want its daemon at login.)");
581
+ return dropped.length === 0 ? "Nothing was syncing. Removed any leftovers." : "Stopped syncing on this device.";
547
582
  };
548
583
  const uninstall = buildCommand({
549
- docs: { brief: "Unpair a sandbox (--sandbox), or stop syncing every sandbox on this machine" },
584
+ docs: { brief: "Unpair a sandbox (--sandbox), or stop syncing every sandbox on this device" },
550
585
  parameters: { flags: sandboxFlag },
551
586
  async func(flags) {
552
- const out = (message) => void this.process.stdout.write(`${message}\n`);
553
- await syncUninstall(out, flags.sandbox);
587
+ const ui = machineUi(this.process);
588
+ ui.act(flags.sandbox === undefined ? "Stopping sync on this device" : `Unpairing ${flags.sandbox}`);
589
+ ui.done(await syncUninstall(ui, flags.sandbox));
554
590
  },
555
591
  });
556
592
  const pause = fileSyncSwitch("Pause file syncing", "pause");