@crvouga/dev-fleet 0.0.0-stage → 0.2.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 (363) hide show
  1. package/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
  2. package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
  3. package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
  4. package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
  5. package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
  6. package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
  7. package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
  8. package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
  9. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
  10. package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
  11. package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
  12. package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
  13. package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
  14. package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
  15. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
  16. package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
  17. package/LICENSE +21 -0
  18. package/README.md +357 -2
  19. package/agents/antigravity-proxy.ts +3 -0
  20. package/agents/audit.md +89 -0
  21. package/agents/claude-proxy +5 -0
  22. package/agents/claude-proxy.ts +3 -0
  23. package/agents/codex-proxy.ts +3 -0
  24. package/agents/grok-proxy.ts +3 -0
  25. package/agents/managed-proxy.ts +55 -0
  26. package/agents/opencode-proxy.ts +3 -0
  27. package/agents/registry.json +209 -0
  28. package/agents/registry.schema.json +149 -0
  29. package/agents/setup.md +158 -0
  30. package/agents/t3.json +40 -0
  31. package/ansible/macos/manager.yml +30 -0
  32. package/ansible/macos/policy.json +9 -0
  33. package/ansible/monitoring/README.md +215 -0
  34. package/ansible/monitoring/agent-darwin.yml +198 -0
  35. package/ansible/monitoring/agent.yml +172 -0
  36. package/ansible/monitoring/beszel-agent.service +42 -0
  37. package/ansible/monitoring/compose.yml +74 -0
  38. package/ansible/monitoring/hub-darwin.yml +102 -0
  39. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  40. package/ansible/monitoring/lock.json +16 -0
  41. package/ansible/net/remote-probe.sh +13 -0
  42. package/ansible/pyproject.toml +12 -0
  43. package/ansible/ubuntu/authorize-sudo.sh +51 -0
  44. package/ansible/ubuntu/baseline.yml +89 -0
  45. package/ansible/ubuntu/disk-gc.py +336 -0
  46. package/ansible/ubuntu/disk-guard.py +104 -0
  47. package/ansible/ubuntu/policy.json +52 -0
  48. package/ansible/ubuntu/recovery.yml +15 -0
  49. package/ansible/ubuntu/t3/policy.json +7 -0
  50. package/ansible/ubuntu/t3-desktop.yml +220 -0
  51. package/ansible/ubuntu/t3-health.sh +33 -0
  52. package/ansible/ubuntu/t3-policy.yml +76 -0
  53. package/ansible/ubuntu/t3-route.yml +28 -0
  54. package/ansible/ubuntu/tailscale.yml +78 -0
  55. package/ansible/ubuntu/tasks/credentials.yml +47 -0
  56. package/ansible/ubuntu/tasks/docker.yml +31 -0
  57. package/ansible/ubuntu/tasks/packages.yml +56 -0
  58. package/ansible/ubuntu/tasks/power.yml +51 -0
  59. package/ansible/ubuntu/tasks/recovery.yml +39 -0
  60. package/ansible/ubuntu/tasks/resources.yml +312 -0
  61. package/ansible/ubuntu/tasks/services.yml +31 -0
  62. package/ansible/ubuntu/tasks/ssh.yml +71 -0
  63. package/ansible/ubuntu/tasks/user.yml +120 -0
  64. package/ansible/uv.lock +190 -0
  65. package/bin/devfleet.mjs +36 -0
  66. package/bootstrap/bootstrap.ps1 +50 -0
  67. package/bootstrap/bootstrap.sh +80 -0
  68. package/bootstrap/devfleet +7 -0
  69. package/bootstrap/docker-desktop.sh +33 -0
  70. package/bootstrap/doctor.ts +234 -0
  71. package/bootstrap/fleet-manager-run.sh +103 -0
  72. package/bootstrap/fleet-remote-bootstrap.sh +258 -0
  73. package/bootstrap/global-config.ts +171 -0
  74. package/bootstrap/install-launcher.sh +10 -0
  75. package/bootstrap/native-aws.ts +86 -0
  76. package/bootstrap/native-t3.ts +154 -0
  77. package/bootstrap/npm.ts +358 -0
  78. package/bootstrap/release-version.ts +157 -0
  79. package/bootstrap/setup-status.ts +51 -0
  80. package/bootstrap/setup.ts +88 -0
  81. package/bootstrap/shell-path.ts +105 -0
  82. package/bootstrap/verify-t3-archive.py +49 -0
  83. package/bootstrap/worker-auth.ts +65 -0
  84. package/bootstrap/worker-github.ts +47 -0
  85. package/bootstrap/worker-tools.ts +24 -0
  86. package/cliproxy/AGENTS.md +11 -0
  87. package/cliproxy/README.md +269 -0
  88. package/cliproxy/clients/bootstrap.py +143 -0
  89. package/cliproxy/clients/launcher.py +57 -0
  90. package/cliproxy/compose.yaml +90 -0
  91. package/cliproxy/config.template.yaml +70 -0
  92. package/cliproxy/docs/cloud-adoption.md +16 -0
  93. package/cliproxy/docs/deployment.png +0 -0
  94. package/cliproxy/docs/deployment.svg +109 -0
  95. package/cliproxy/docs/health-workflow.example.yml +185 -0
  96. package/cliproxy/docs/migration.md +131 -0
  97. package/cliproxy/docs/model-health-report.schema.json +364 -0
  98. package/cliproxy/docs/model-health.md +393 -0
  99. package/cliproxy/image.lock.json +5 -0
  100. package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
  101. package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
  102. package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
  103. package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
  104. package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
  105. package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
  106. package/cliproxy/models.json +1618 -0
  107. package/cliproxy/requirements.lock +78 -0
  108. package/cliproxy/scripts/accounts.py +38 -0
  109. package/cliproxy/scripts/audit.py +123 -0
  110. package/cliproxy/scripts/backup +234 -0
  111. package/cliproxy/scripts/check.py +34 -0
  112. package/cliproxy/scripts/checkpoint.py +226 -0
  113. package/cliproxy/scripts/cli.py +64 -0
  114. package/cliproxy/scripts/cliproxy_host.py +257 -0
  115. package/cliproxy/scripts/cloud_state.py +150 -0
  116. package/cliproxy/scripts/container-health +6 -0
  117. package/cliproxy/scripts/docker +24 -0
  118. package/cliproxy/scripts/external-health.py +79 -0
  119. package/cliproxy/scripts/fleet_config.py +43 -0
  120. package/cliproxy/scripts/fleet_sync.py +274 -0
  121. package/cliproxy/scripts/initialize +84 -0
  122. package/cliproxy/scripts/install-dependencies +48 -0
  123. package/cliproxy/scripts/install.py +406 -0
  124. package/cliproxy/scripts/model_health.py +16 -0
  125. package/cliproxy/scripts/modelhealth/__init__.py +5 -0
  126. package/cliproxy/scripts/modelhealth/checks.py +429 -0
  127. package/cliproxy/scripts/modelhealth/cli.py +117 -0
  128. package/cliproxy/scripts/modelhealth/config.py +186 -0
  129. package/cliproxy/scripts/modelhealth/emulator.py +536 -0
  130. package/cliproxy/scripts/modelhealth/expect.py +88 -0
  131. package/cliproxy/scripts/modelhealth/findings.py +183 -0
  132. package/cliproxy/scripts/modelhealth/protocol.py +330 -0
  133. package/cliproxy/scripts/modelhealth/redact.py +28 -0
  134. package/cliproxy/scripts/modelhealth/report.py +150 -0
  135. package/cliproxy/scripts/modelhealth/runner.py +390 -0
  136. package/cliproxy/scripts/modelhealth/scenario.py +131 -0
  137. package/cliproxy/scripts/modelhealth/schema.py +63 -0
  138. package/cliproxy/scripts/modelhealth/simulate.py +111 -0
  139. package/cliproxy/scripts/modelhealth/transport.py +213 -0
  140. package/cliproxy/scripts/monitor +68 -0
  141. package/cliproxy/scripts/ops +527 -0
  142. package/cliproxy/scripts/public-endpoint +127 -0
  143. package/cliproxy/scripts/recover_node.py +177 -0
  144. package/cliproxy/scripts/restore-verify +60 -0
  145. package/cliproxy/scripts/retention.py +144 -0
  146. package/cliproxy/scripts/service-start +7 -0
  147. package/cliproxy/scripts/validate-config +56 -0
  148. package/cliproxy/scripts/vault-access.py +57 -0
  149. package/cliproxy/scripts/vault-backup-login +55 -0
  150. package/cliproxy/scripts/vault-recovery +56 -0
  151. package/cliproxy/scripts/vault-renew +30 -0
  152. package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
  153. package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
  154. package/cliproxy/systemd/cliproxy-backup.service +13 -0
  155. package/cliproxy/systemd/cliproxy-backup.timer +8 -0
  156. package/cliproxy/systemd/cliproxy-fleet.service +17 -0
  157. package/cliproxy/systemd/cliproxy-gc.service +13 -0
  158. package/cliproxy/systemd/cliproxy-gc.timer +8 -0
  159. package/cliproxy/systemd/cliproxy-monitor.service +8 -0
  160. package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
  161. package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
  162. package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
  163. package/cliproxy/vendor/LICENSE +22 -0
  164. package/cliproxy/vendor/config.upstream.yaml +1349 -0
  165. package/docs/consumer-setup.md +408 -0
  166. package/docs/declared.md +89 -0
  167. package/docs/machine-setup.md +399 -0
  168. package/docs/operations.md +276 -0
  169. package/docs/releasing.md +103 -0
  170. package/docs/state.md +254 -0
  171. package/docs/topology.md +67 -0
  172. package/docs/ubuntu-sync.md +351 -0
  173. package/docs/worker-contract.md +67 -0
  174. package/examples/fleet.json +100 -0
  175. package/examples/roster.json +13 -0
  176. package/llms.txt +35 -0
  177. package/mise.lock +1521 -0
  178. package/mise.toml +110 -0
  179. package/package.json +74 -4
  180. package/schema/fleet.schema.json +420 -0
  181. package/schema/roster.schema.json +77 -0
  182. package/src/agents/account-relay.ts +111 -0
  183. package/src/agents/accounts.ts +85 -0
  184. package/src/agents/audit.ts +77 -0
  185. package/src/agents/cli.ts +13 -0
  186. package/src/agents/command.ts +204 -0
  187. package/src/agents/converge.ts +123 -0
  188. package/src/agents/doctor.ts +107 -0
  189. package/src/agents/errors.ts +2 -0
  190. package/src/agents/files.ts +73 -0
  191. package/src/agents/fleet-verify.ts +165 -0
  192. package/src/agents/launch.ts +290 -0
  193. package/src/agents/native-verify.ts +371 -0
  194. package/src/agents/provider-errors.ts +57 -0
  195. package/src/agents/registry.ts +61 -0
  196. package/src/agents/render.ts +110 -0
  197. package/src/agents/round-trip.ts +192 -0
  198. package/src/agents/secrets.ts +13 -0
  199. package/src/agents/toolchain.ts +34 -0
  200. package/src/agents/verify-cache.ts +103 -0
  201. package/src/agents/verify.ts +127 -0
  202. package/src/auth-checks.ts +163 -0
  203. package/src/cli/agent-guide.ts +129 -0
  204. package/src/cli/app.ts +122 -0
  205. package/src/cli/cli.ts +28 -0
  206. package/src/cli/command-index.ts +57 -0
  207. package/src/cli/contracts.ts +25 -0
  208. package/src/cli/fleet-reference.ts +17 -0
  209. package/src/cli/fleet-state.ts +499 -0
  210. package/src/cli/fleet-sync-checkout.ts +130 -0
  211. package/src/cli/fleet-sync-enrollment.ts +140 -0
  212. package/src/cli/fleet-sync-forward.ts +134 -0
  213. package/src/cli/fleet-sync-manager.ts +249 -0
  214. package/src/cli/fleet-sync-monitoring.ts +185 -0
  215. package/src/cli/fleet-sync-output.ts +317 -0
  216. package/src/cli/fleet-sync-privilege.ts +27 -0
  217. package/src/cli/fleet-sync-progress.ts +109 -0
  218. package/src/cli/fleet-sync-remote.ts +242 -0
  219. package/src/cli/fleet-sync-report.ts +26 -0
  220. package/src/cli/fleet-sync-schedule.ts +138 -0
  221. package/src/cli/fleet-sync-state.ts +86 -0
  222. package/src/cli/fleet-sync-vault.ts +166 -0
  223. package/src/cli/fleet-sync.ts +375 -0
  224. package/src/cli/fleet.ts +165 -0
  225. package/src/cli/managed-health.ts +219 -0
  226. package/src/cli/node-deployment.ts +34 -0
  227. package/src/cli/node-sync.ts +121 -0
  228. package/src/cli/node-t3.ts +31 -0
  229. package/src/cli/options.ts +141 -0
  230. package/src/cli/probe-source.ts +7 -0
  231. package/src/cli/program.ts +185 -0
  232. package/src/cli/project.ts +51 -0
  233. package/src/cli/register-fleet.ts +296 -0
  234. package/src/cli/registry.ts +185 -0
  235. package/src/cli/repair-apply.ts +67 -0
  236. package/src/cli/repair-guard.ts +11 -0
  237. package/src/cli/repair-inspect.ts +150 -0
  238. package/src/cli/repair-script.ts +76 -0
  239. package/src/cli/repair.ts +209 -0
  240. package/src/cli/roster.ts +105 -0
  241. package/src/cli/search.ts +35 -0
  242. package/src/cli/settings.ts +67 -0
  243. package/src/cli/source-operations.ts +102 -0
  244. package/src/cli/t3/command.ts +90 -0
  245. package/src/cli/t3/register.ts +55 -0
  246. package/src/cli/tailnet.ts +127 -0
  247. package/src/cli/ui-actions.ts +133 -0
  248. package/src/cli/ui-controller.ts +113 -0
  249. package/src/cli/ui-data.ts +106 -0
  250. package/src/cli/ui-execute.ts +163 -0
  251. package/src/cli/ui-repairs.ts +34 -0
  252. package/src/cli/ui-reports.ts +211 -0
  253. package/src/cli/ui-targets.ts +100 -0
  254. package/src/cli/ui-terminal.ts +109 -0
  255. package/src/cli/ui-types.ts +49 -0
  256. package/src/cli/ui-wizard.ts +101 -0
  257. package/src/cli/ui.ts +9 -0
  258. package/src/cli/worker-health-output.ts +37 -0
  259. package/src/config.ts +186 -0
  260. package/src/configure.ts +255 -0
  261. package/src/definition.ts +290 -0
  262. package/src/diagnostics.ts +92 -0
  263. package/src/doctor-check.ts +10 -0
  264. package/src/errors.ts +1 -0
  265. package/src/fingerprint.ts +120 -0
  266. package/src/fleet-config.ts +174 -0
  267. package/src/input-hashes.ts +73 -0
  268. package/src/machine/local-source.ts +52 -0
  269. package/src/machine/lock-error.ts +2 -0
  270. package/src/machine/platform.ts +44 -0
  271. package/src/machine/source.ts +68 -0
  272. package/src/machine/state.ts +173 -0
  273. package/src/machine-health.ts +95 -0
  274. package/src/managed-source.ts +47 -0
  275. package/src/manager-failure.ts +48 -0
  276. package/src/manager-sync.ts +405 -0
  277. package/src/manager.ts +62 -0
  278. package/src/monitoring/agent-evidence.ts +146 -0
  279. package/src/monitoring/apply.ts +183 -0
  280. package/src/monitoring/beszel.ts +198 -0
  281. package/src/monitoring/enroll.ts +111 -0
  282. package/src/monitoring/hub-access.ts +114 -0
  283. package/src/monitoring/hub-backup.ts +121 -0
  284. package/src/monitoring/hub-viewer.ts +142 -0
  285. package/src/monitoring/hub.ts +93 -0
  286. package/src/monitoring/manager-agent.ts +73 -0
  287. package/src/monitoring/manager-hub.ts +248 -0
  288. package/src/monitoring/remote.ts +65 -0
  289. package/src/network-definition.ts +83 -0
  290. package/src/node-converge-linux.ts +374 -0
  291. package/src/node-converge-shared.ts +83 -0
  292. package/src/node-converge.ts +40 -0
  293. package/src/onboard.ts +35 -0
  294. package/src/package-info.ts +45 -0
  295. package/src/platform/ansible-receipt.ts +128 -0
  296. package/src/platform/ansible.ts +66 -0
  297. package/src/platform/darwin.ts +203 -0
  298. package/src/platform/detect.ts +11 -0
  299. package/src/platform/fallback.ts +110 -0
  300. package/src/platform/index.ts +38 -0
  301. package/src/platform/launchd.ts +98 -0
  302. package/src/platform/linux.ts +139 -0
  303. package/src/platform/ps.ts +45 -0
  304. package/src/platform/resource-verify.ts +132 -0
  305. package/src/platform/systemd-user.ts +95 -0
  306. package/src/platform/t3-policy.ts +137 -0
  307. package/src/platform/t3-route.ts +68 -0
  308. package/src/platform/t3-service.ts +75 -0
  309. package/src/platform/types.ts +123 -0
  310. package/src/platform/ubuntu-audit.ts +66 -0
  311. package/src/platform/ubuntu-verify.ts +105 -0
  312. package/src/platform/windows.ts +148 -0
  313. package/src/power-recovery.ts +55 -0
  314. package/src/probe.ts +51 -0
  315. package/src/protocol.ts +119 -0
  316. package/src/proxy-converge.ts +85 -0
  317. package/src/remote-bootstrap.ts +210 -0
  318. package/src/remote-entry.ts +89 -0
  319. package/src/remote-lock.ts +12 -0
  320. package/src/repair-catalog.ts +206 -0
  321. package/src/repair-plan.ts +78 -0
  322. package/src/repair-runner.ts +84 -0
  323. package/src/report.ts +71 -0
  324. package/src/runner.ts +104 -0
  325. package/src/secrets.ts +169 -0
  326. package/src/service-auth.ts +115 -0
  327. package/src/ssh.ts +135 -0
  328. package/src/state/bucket.ts +208 -0
  329. package/src/state/check.ts +74 -0
  330. package/src/state/connection.ts +92 -0
  331. package/src/state/enrollment.ts +45 -0
  332. package/src/state/legacy.ts +89 -0
  333. package/src/state/litestream.ts +143 -0
  334. package/src/state/operator.ts +20 -0
  335. package/src/state/paths.ts +40 -0
  336. package/src/state/roster.ts +62 -0
  337. package/src/state/store.ts +169 -0
  338. package/src/state/sync.ts +352 -0
  339. package/src/sync.ts +104 -0
  340. package/src/t3/accounts.ts +81 -0
  341. package/src/t3/desired.ts +165 -0
  342. package/src/t3/files.ts +81 -0
  343. package/src/t3/names-check.ts +53 -0
  344. package/src/t3/names.ts +155 -0
  345. package/src/t3/plan.ts +82 -0
  346. package/src/t3/providers.ts +74 -0
  347. package/src/t3/reconcile.ts +123 -0
  348. package/src/t3-endpoint.ts +104 -0
  349. package/src/t3-linked-check.ts +123 -0
  350. package/src/tailnet-health.ts +231 -0
  351. package/src/tailnet-matching.ts +37 -0
  352. package/src/tailscale.ts +141 -0
  353. package/src/toolchain-checks.ts +108 -0
  354. package/src/topology.ts +24 -0
  355. package/src/transport.ts +122 -0
  356. package/src/types.ts +99 -0
  357. package/src/vault-config.ts +38 -0
  358. package/src/worker-access-checks.ts +168 -0
  359. package/src/worker-capacity.ts +34 -0
  360. package/src/worker-host-checks.ts +181 -0
  361. package/src/worker-policy.ts +64 -0
  362. package/src/worker-readiness.ts +64 -0
  363. package/src/workstation-checks.ts +29 -0
@@ -0,0 +1,139 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import type {
6
+ NotificationKind,
7
+ NotifierCapability,
8
+ NotifierTestResult,
9
+ Platform,
10
+ ProcessInfo,
11
+ ScheduledJob,
12
+ ScheduledJobStatus,
13
+ } from './types';
14
+ import { existsSync } from 'node:fs';
15
+ import {
16
+ installSystemdJob,
17
+ removeSystemdJob,
18
+ systemdJobStatus,
19
+ } from './systemd-user';
20
+ import { psProcesses } from './ps';
21
+
22
+ export class LinuxPlatform implements Platform {
23
+ readonly name = 'linux' as const;
24
+ readonly label = 'Linux';
25
+
26
+ opencodeDir(): string {
27
+ const base =
28
+ process.env['XDG_CONFIG_HOME']?.trim() || join(homedir(), '.config');
29
+ return join(base, 'opencode');
30
+ }
31
+
32
+ cacheDir(): string {
33
+ const base =
34
+ process.env['XDG_CACHE_HOME']?.trim() || join(homedir(), '.cache');
35
+ return base;
36
+ }
37
+
38
+ globalBinDir(): string {
39
+ return join(homedir(), '.local/bin');
40
+ }
41
+
42
+ canBuildSwiftNotifier(): boolean {
43
+ return false;
44
+ }
45
+
46
+ shellRcFiles(): readonly string[] {
47
+ const home = homedir();
48
+ return [
49
+ join(home, '.bashrc'),
50
+ join(home, '.bash_profile'),
51
+ join(home, '.zshrc'),
52
+ join(home, '.config/fish/config.fish'),
53
+ ];
54
+ }
55
+
56
+ notifierCapabilities(): NotifierCapability {
57
+ return {
58
+ native: true,
59
+ sounds: false,
60
+ clickToFocus: false,
61
+ reason:
62
+ 'Linux uses notify-send (no sounds, no click-to-focus); the macOS OpenCodeNotifier app is macOS-only.',
63
+ };
64
+ }
65
+
66
+ async postNotification(input: {
67
+ title: string;
68
+ message: string;
69
+ kind: NotificationKind;
70
+ }): Promise<NotifierTestResult> {
71
+ void input.kind;
72
+ const candidates: ReadonlyArray<{ cmd: string; args: string[] }> = [
73
+ { cmd: 'notify-send', args: [input.title, input.message] },
74
+ { cmd: 'zenity', args: ['--notification', `--text=${input.message}`] },
75
+ ];
76
+ for (const candidate of candidates) {
77
+ const r = spawnSync(candidate.cmd, candidate.args, { stdio: 'ignore' });
78
+ if (r.status === 0) {
79
+ return { ok: true, detail: `posted via ${candidate.cmd}` };
80
+ }
81
+ }
82
+ return {
83
+ ok: false,
84
+ detail: 'no notifier found (install libnotify: notify-send)',
85
+ };
86
+ }
87
+
88
+ isNotifierDaemonRunning(): boolean {
89
+ return false;
90
+ }
91
+
92
+ restartNotifierDaemon(): void {
93
+ // No daemon on Linux — no-op.
94
+ }
95
+
96
+ availableSystemSounds(): string[] {
97
+ return [];
98
+ }
99
+
100
+ async playSystemSound(name: string): Promise<NotifierTestResult> {
101
+ void name;
102
+ return {
103
+ ok: false,
104
+ detail: 'sounds unsupported on Linux (notify-send has no sound API)',
105
+ };
106
+ }
107
+
108
+ listProcesses(): ProcessInfo[] | null {
109
+ return psProcesses();
110
+ }
111
+
112
+ tailscaleAppBinaries(): readonly string[] {
113
+ return ['/usr/bin/tailscale', '/usr/sbin/tailscale'].filter((p) =>
114
+ existsSync(p)
115
+ );
116
+ }
117
+
118
+ vscodeSettingsPath(): string | null {
119
+ const base =
120
+ process.env['XDG_CONFIG_HOME']?.trim() || join(homedir(), '.config');
121
+ return join(base, 'Code/User/settings.json');
122
+ }
123
+
124
+ vscodeCliBinaries(): readonly string[] {
125
+ return ['/usr/share/code/bin/code'].filter((p) => existsSync(p));
126
+ }
127
+
128
+ installScheduledJob(job: ScheduledJob): NotifierTestResult {
129
+ return installSystemdJob(job);
130
+ }
131
+
132
+ removeScheduledJob(label: string): NotifierTestResult {
133
+ return removeSystemdJob(label);
134
+ }
135
+
136
+ scheduledJobStatus(label: string): ScheduledJobStatus {
137
+ return systemdJobStatus(label);
138
+ }
139
+ }
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ import type { ProcessInfo } from './types';
4
+
5
+ // `args` goes last because it is the only column that may contain spaces.
6
+ const PS_ARGS = [
7
+ '-A',
8
+ '-ww',
9
+ '-o',
10
+ 'pid=,ppid=,pgid=,uid=,rss=,pcpu=,stat=,args=',
11
+ ];
12
+ const ROW =
13
+ /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\S+)\s+(.*)$/;
14
+ const PS_MAX_BUFFER = 64 * 1024 * 1024;
15
+
16
+ export function parsePsOutput(stdout: string): ProcessInfo[] {
17
+ const rows: ProcessInfo[] = [];
18
+ for (const line of stdout.split('\n')) {
19
+ const m = ROW.exec(line);
20
+ if (m === null) continue;
21
+ rows.push({
22
+ pid: Number(m[1]),
23
+ ppid: Number(m[2]),
24
+ pgid: Number(m[3]),
25
+ uid: Number(m[4]),
26
+ rssKb: Number(m[5]),
27
+ cpu: Number(m[6]),
28
+ state: m[7] ?? '',
29
+ command: (m[8] ?? '').trim(),
30
+ });
31
+ }
32
+ return rows;
33
+ }
34
+
35
+ /** One `ps` call for the whole table (BSD + procps compatible). */
36
+ export function psProcesses(): ProcessInfo[] | null {
37
+ const r = spawnSync('ps', PS_ARGS, {
38
+ encoding: 'utf8',
39
+ maxBuffer: PS_MAX_BUFFER,
40
+ // Force `.` decimals in pcpu regardless of the user's locale.
41
+ env: { ...process.env, LC_ALL: 'C' },
42
+ });
43
+ if (r.status !== 0 || typeof r.stdout !== 'string') return null;
44
+ return parsePsOutput(r.stdout);
45
+ }
@@ -0,0 +1,132 @@
1
+ import policy from '../../ansible/ubuntu/policy.json';
2
+ import type { Check, Runner } from '../types';
3
+
4
+ const AGENT_DROP_IN =
5
+ '/etc/systemd/user/t3code-.scope.d/60-personal-fleet.conf';
6
+ const GUARDS = [
7
+ 'systemd-oomd.service',
8
+ 'personal-fleet-disk-guard.service',
9
+ 'personal-fleet-disk-gc.timer',
10
+ ];
11
+ const MIB = 1024 * 1024;
12
+ const FLOOR_MIB = policy.resourceSafety.diskGuardFloorMiB;
13
+
14
+ /** A failure code and remedy, or undefined when the probe passes. */
15
+ type Probe = (run: Runner) => Promise<[string, string] | undefined>;
16
+
17
+ const guardsRunning: Probe = async (run) => {
18
+ for (const unit of GUARDS) {
19
+ const active = await run(['systemctl', 'is-active', unit]);
20
+ if (active.stdout.trim() !== 'active')
21
+ return [
22
+ 'RESOURCE_GUARD_INACTIVE',
23
+ `${unit} is not running. Reapply fleet-sync; runaway memory or disk use is unguarded until then.`,
24
+ ];
25
+ }
26
+ return undefined;
27
+ };
28
+
29
+ const memoryReserved: Probe = async (run) => {
30
+ const show = (property: string, unit: string) =>
31
+ run(['systemctl', 'show', `--property=${property}`, '--', unit]);
32
+ const user = await show('MemoryMax', 'user.slice');
33
+ const root = await show('ManagedOOMSwap', '-.slice');
34
+ const ok =
35
+ /^MemoryMax=\d+$/m.test(user.stdout) &&
36
+ /^ManagedOOMSwap=kill$/m.test(root.stdout);
37
+ return ok
38
+ ? undefined
39
+ : [
40
+ 'MEMORY_RESERVE_MISSING',
41
+ 'User workloads are not capped below physical memory, or swap exhaustion is unmanaged. Reapply fleet-sync.',
42
+ ];
43
+ };
44
+
45
+ const agentsBounded: Probe = async (run) =>
46
+ (await run(['test', '-f', AGENT_DROP_IN])).ok
47
+ ? undefined
48
+ : [
49
+ 'AGENT_MEMORY_LIMIT_MISSING',
50
+ 'T3 agent scopes have no per-agent memory limit. Reapply fleet-sync; new agents pick it up without restarts.',
51
+ ];
52
+
53
+ const swapActive: Probe = async (run) => {
54
+ // /proc/swaps, not swapon: /usr/sbin is not on the remote worker PATH.
55
+ const swaps = await run(['cat', '/proc/swaps']);
56
+ return swaps.ok && swaps.stdout.trim().split('\n').length > 1
57
+ ? undefined
58
+ : [
59
+ 'SWAP_MISSING',
60
+ 'No swap is active. Reapply fleet-sync with enough free disk for the swap file.',
61
+ ];
62
+ };
63
+
64
+ const diskAboveFloor =
65
+ (path: string): Probe =>
66
+ async (run) => {
67
+ const df = await run(['df', '--output=avail', '-B1', path]);
68
+ const free = Number(df.stdout.trim().split('\n').at(-1));
69
+ if (!df.ok || !Number.isSafeInteger(free))
70
+ return ['DISK_FREE_UNREADABLE', `Free space on ${path} is unreadable.`];
71
+ if (free >= FLOOR_MIB * MIB) return undefined;
72
+ return [
73
+ 'DISK_FREE_BELOW_FLOOR',
74
+ `${path} has ${Math.floor(free / MIB)} MiB free, under the ${FLOOR_MIB} MiB floor. The disk GC already removed idle build output and caches; remove stale worktrees or data, or run "sudo systemctl start personal-fleet-disk-gc.service" and read its journal.`,
75
+ ];
76
+ };
77
+
78
+ /** Free space cannot show this: /tmp is RAM-backed with a per-user quota, so a
79
+ * write fails while the filesystem still reports room. Probe a real 1 MiB write.
80
+ */
81
+ const tmpWritable: Probe = async (run) =>
82
+ (
83
+ await run([
84
+ 'sh',
85
+ '-c',
86
+ 'f=$(mktemp /tmp/personal-fleet-probe.XXXXXX) || exit 1; head -c 1048576 /dev/zero >"$f"; s=$?; rm -f -- "$f"; exit "$s"',
87
+ ])
88
+ ).ok
89
+ ? undefined
90
+ : [
91
+ 'TMP_QUOTA_EXHAUSTED',
92
+ 'This user cannot write to /tmp (full, or its per-user quota is used up), so agents and builds fail. The guard never deletes files; remove stale caches and scratch directories under /tmp.',
93
+ ];
94
+
95
+ /** Measured memory and disk safety on an Ubuntu worker: the running managers,
96
+ * not the files Ansible wrote. Free space below the guard floor fails because
97
+ * what is left after the disk GC is work a person must decide about.
98
+ */
99
+ export async function verifyResourceSafety(
100
+ home: string,
101
+ run: Runner
102
+ ): Promise<Check[]> {
103
+ const probes = [
104
+ guardsRunning,
105
+ memoryReserved,
106
+ agentsBounded,
107
+ swapActive,
108
+ diskAboveFloor('/'),
109
+ diskAboveFloor(home),
110
+ tmpWritable,
111
+ ];
112
+ for (const probe of probes) {
113
+ const failure = await probe(run);
114
+ if (failure)
115
+ return [
116
+ {
117
+ id: 'ubuntu:resource-safety',
118
+ status: 'fail',
119
+ summary: failure[0],
120
+ remedy: { action: failure[1] },
121
+ },
122
+ ];
123
+ }
124
+ return [
125
+ {
126
+ id: 'ubuntu:resource-safety',
127
+ status: 'pass',
128
+ summary:
129
+ 'systemd-oomd, the disk guard and the disk GC timer are running, user workloads are capped below physical memory, T3 agents have per-agent limits, swap is active, free disk is above the guard floor and /tmp is writable.',
130
+ },
131
+ ];
132
+ }
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import type {
7
+ NotifierTestResult,
8
+ ScheduledJob,
9
+ ScheduledJobStatus,
10
+ } from './types';
11
+
12
+ function unitDir(): string {
13
+ const base =
14
+ process.env['XDG_CONFIG_HOME']?.trim() || join(homedir(), '.config');
15
+ return join(base, 'systemd/user');
16
+ }
17
+
18
+ function quote(arg: string): string {
19
+ return /^[\w@%+=:,./-]+$/.test(arg)
20
+ ? arg
21
+ : `"${arg.replace(/(["\\$`])/g, '\\$1')}"`;
22
+ }
23
+
24
+ /** systemd user service + timer for a periodic job. Pure — unit-tested. */
25
+ export function systemdUnits(job: ScheduledJob): {
26
+ service: string;
27
+ timer: string;
28
+ } {
29
+ const env = Object.entries(job.env).map(
30
+ ([k, v]) => `Environment=${quote(`${k}=${v}`)}`
31
+ );
32
+ const service = [
33
+ '[Unit]',
34
+ `Description=${job.label}`,
35
+ '',
36
+ '[Service]',
37
+ 'Type=oneshot',
38
+ 'Nice=10',
39
+ ...env,
40
+ `ExecStart=${job.command.map(quote).join(' ')}`,
41
+ `StandardOutput=append:${join(job.logDir, `${job.label}.out.log`)}`,
42
+ `StandardError=append:${join(job.logDir, `${job.label}.err.log`)}`,
43
+ '',
44
+ ].join('\n');
45
+ const timer = [
46
+ '[Unit]',
47
+ `Description=${job.label} every ${job.intervalSeconds}s`,
48
+ '',
49
+ '[Timer]',
50
+ 'OnBootSec=60',
51
+ `OnUnitActiveSec=${job.intervalSeconds}`,
52
+ '',
53
+ '[Install]',
54
+ 'WantedBy=timers.target',
55
+ '',
56
+ ].join('\n');
57
+ return { service, timer };
58
+ }
59
+
60
+ function systemctl(args: readonly string[]): { ok: boolean; out: string } {
61
+ const r = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8' });
62
+ return {
63
+ ok: r.status === 0,
64
+ out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim(),
65
+ };
66
+ }
67
+
68
+ export function installSystemdJob(job: ScheduledJob): NotifierTestResult {
69
+ const dir = unitDir();
70
+ mkdirSync(dir, { recursive: true });
71
+ mkdirSync(job.logDir, { recursive: true });
72
+ const { service, timer } = systemdUnits(job);
73
+ writeFileSync(join(dir, `${job.label}.service`), service);
74
+ writeFileSync(join(dir, `${job.label}.timer`), timer);
75
+ systemctl(['daemon-reload']);
76
+ const r = systemctl(['enable', '--now', `${job.label}.timer`]);
77
+ return r.ok
78
+ ? { ok: true, detail: `systemd user timer ${job.label}.timer` }
79
+ : { ok: false, detail: `systemctl --user enable failed: ${r.out}` };
80
+ }
81
+
82
+ export function removeSystemdJob(label: string): NotifierTestResult {
83
+ systemctl(['disable', '--now', `${label}.timer`]);
84
+ rmSync(join(unitDir(), `${label}.timer`), { force: true });
85
+ rmSync(join(unitDir(), `${label}.service`), { force: true });
86
+ systemctl(['daemon-reload']);
87
+ return { ok: true, detail: `removed systemd user timer ${label}` };
88
+ }
89
+
90
+ export function systemdJobStatus(label: string): ScheduledJobStatus {
91
+ const r = systemctl(['is-active', `${label}.timer`]);
92
+ return r.ok
93
+ ? { supported: true, installed: true, detail: 'timer active' }
94
+ : { supported: true, installed: false, detail: r.out || 'not installed' };
95
+ }
@@ -0,0 +1,137 @@
1
+ import { runAnsible } from './ansible';
2
+ import { desired } from '../t3/desired';
3
+ import policy from '../../ansible/ubuntu/t3/policy.json';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { rename, rm, symlink } from 'node:fs/promises';
6
+ import { userInfo } from 'node:os';
7
+ import type { Runner, Check } from '../types';
8
+
9
+ export async function stageT3Policy(
10
+ root: string,
11
+ executable: string,
12
+ run: Runner,
13
+ node: string
14
+ ): Promise<Check> {
15
+ const instances = await desired(
16
+ resolve(root, 'agents/t3.json'),
17
+ await stableWorkspace(root),
18
+ node
19
+ );
20
+ const user = userInfo();
21
+ const document = {
22
+ ...policy,
23
+ settings: Object.fromEntries(
24
+ Object.entries(instances).map(([id, value]) => [
25
+ `providerInstances.${id}`,
26
+ { value, locked: true },
27
+ ])
28
+ ),
29
+ };
30
+ const result = await runAnsible(run, 't3-policy.yml', {
31
+ cwd: resolve(root, 'ansible/ubuntu'),
32
+ input: JSON.stringify({
33
+ t3_executable: executable,
34
+ t3_policy_document: document,
35
+ development_user: user.username,
36
+ development_uid: user.uid,
37
+ }),
38
+ timeoutMs: 120_000,
39
+ });
40
+ return {
41
+ id: 't3:policy-staged',
42
+ status: result.ok ? 'pass' : 'fail',
43
+ summary: result.ok
44
+ ? 'Native T3 policy validated and staged; a running T3 that had not loaded it was restarted.'
45
+ : 'T3_POLICY_STAGE_FAILED',
46
+ remedy: {
47
+ action:
48
+ 'Review any existing administrator policy and the pinned native validation result. No live settings database or user state is overwritten.',
49
+ },
50
+ };
51
+ }
52
+ export async function verifyT3Policy(
53
+ executable: string,
54
+ run: Runner,
55
+ pause: () => Promise<void> = () =>
56
+ new Promise((done) => setTimeout(done, 1000))
57
+ ): Promise<Check> {
58
+ // A newly started native server can be active before loading its policy:
59
+ // bounded, read-only observation.
60
+ let loaded = false;
61
+ for (let attempt = 0; attempt < 6; attempt++) {
62
+ const result = await run([executable, 'policy', 'show'], {
63
+ timeoutMs: 5000,
64
+ });
65
+ loaded = result.ok && /loaded this policy\.\s*$/.test(result.stdout);
66
+ if (loaded) break;
67
+ if (attempt < 5) await pause();
68
+ }
69
+ return {
70
+ id: 't3:policy-loaded',
71
+ status: loaded ? 'pass' : 'fail',
72
+ summary: loaded
73
+ ? 'Running T3 server confirms it loaded the installed managed policy.'
74
+ : 'T3_POLICY_RESTART_PENDING',
75
+ remedy: {
76
+ action:
77
+ 'T3 did not load the installed policy after a restart. Inspect systemctl --user status t3code-desktop.service on the node, then rerun fleet-sync.',
78
+ },
79
+ };
80
+ }
81
+
82
+ /** Summary when the policy play restarted T3: its manager link reconnects next. */
83
+ export const T3_RESTARTED_SUMMARY =
84
+ 'T3 was restarted to load the new managed policy; the running server confirms it.';
85
+
86
+ /** `t3-policy.yml` restarts a running T3 that has not loaded the staged policy
87
+ * (ending its running turns), so every node offers the same providers after
88
+ * every sync. This measures the outcome; `restarted` says whether the app
89
+ * restarted during this sync and so needs time to reconnect.
90
+ */
91
+ export async function loadedT3Policy(
92
+ executable: string,
93
+ run: Runner,
94
+ options: { restarted: boolean; pause?: () => Promise<void> }
95
+ ): Promise<Check> {
96
+ // A restarted desktop app takes a few seconds to come back with its server.
97
+ const check = await verifyT3Policy(
98
+ executable,
99
+ run,
100
+ options.pause ??
101
+ (() =>
102
+ new Promise((done) =>
103
+ setTimeout(done, options.restarted ? 5000 : 1000)
104
+ ))
105
+ );
106
+ if (check.status !== 'pass')
107
+ return { ...check, summary: 'T3_POLICY_RESTART_FAILED' };
108
+ return options.restarted
109
+ ? { ...check, summary: T3_RESTARTED_SUMMARY }
110
+ : check;
111
+ }
112
+
113
+ /** Fleet revisions live at .../fleet/revisions/<sha> (workers) or
114
+ * .../fleet/manager-runs/<sha> (the manager). Point .../fleet/current at the
115
+ * applied revision and reference it from T3, so provider settings (and the T3
116
+ * restart a policy needs) change only when they really change, not on every
117
+ * new commit, and never name a checkout that is later pruned. Other checkouts
118
+ * are used as-is.
119
+ */
120
+ export async function stableWorkspace(root: string): Promise<string> {
121
+ const current = stablePath(root);
122
+ if (current === root) return root;
123
+ const revisions = dirname(resolve(root));
124
+ const staged = `${current}.${process.pid}`;
125
+ await rm(staged, { force: true });
126
+ await symlink(join(basename(revisions), basename(resolve(root))), staged);
127
+ await rename(staged, current);
128
+ return current;
129
+ }
130
+
131
+ /** Where `stableWorkspace` points for this checkout, without moving it. */
132
+ export function stablePath(root: string): string {
133
+ const revisions = dirname(resolve(root));
134
+ return ['revisions', 'manager-runs'].includes(basename(revisions))
135
+ ? join(dirname(revisions), 'current')
136
+ : root;
137
+ }
@@ -0,0 +1,68 @@
1
+ import { inspectRoute } from '../monitoring/hub';
2
+ import { parseTailnet } from '../tailscale';
3
+ import type { Check, Runner } from '../types';
4
+
5
+ /** The one place the route is declared; `t3-route.yml` receives it. */
6
+ export const T3_ROUTE = { port: '443', proxy: 'http://127.0.0.1:3773' };
7
+ const target = T3_ROUTE;
8
+ /** `t3-route.yml` publishes the route when port 443 has no owner; this refuses
9
+ * a conflicting owner beforehand and measures the result. Stock Serve persists
10
+ * independently of T3 startup, so a temporarily unavailable VPN does not
11
+ * prevent the local development server from starting. No Funnel, pairing,
12
+ * firewall change or service restart.
13
+ */
14
+ export async function convergeT3Route(
15
+ run: Runner,
16
+ publish: () => Promise<boolean>
17
+ ): Promise<Check[]> {
18
+ const dns = await selfDns(run);
19
+ if (!dns) return pending('T3_PRIVATE_IDENTITY_REQUIRED');
20
+ const url = 'https://' + dns;
21
+ const before = await run(['tailscale', 'serve', 'status', '--json']);
22
+ if (!before.ok) return pending('T3_PRIVATE_ROUTE_UNVERIFIED');
23
+ const route = inspectRoute(before.stdout, url, target);
24
+ if (Array.isArray(route)) return pending('T3_PRIVATE_ROUTE_CONFLICT');
25
+ if (!route && !(await publish())) return pending('T3_PRIVATE_ROUTE_REQUIRED');
26
+ const after = route
27
+ ? before
28
+ : await run(['tailscale', 'serve', 'status', '--json']);
29
+ if (!after.ok || inspectRoute(after.stdout, url, target) !== true)
30
+ return pending('T3_PRIVATE_ROUTE_UNVERIFIED');
31
+ return [
32
+ {
33
+ id: 't3:private-route',
34
+ status: 'pass',
35
+ summary:
36
+ 'Private HTTPS Serve route observed at ' +
37
+ url +
38
+ '; manager reachability and paired tool execution remain separate checks.',
39
+ },
40
+ ];
41
+ }
42
+ function pending(code: string): Check[] {
43
+ return [
44
+ {
45
+ id: 't3:private-route',
46
+ status: 'fail',
47
+ summary: code,
48
+ remedy: {
49
+ action:
50
+ 'Inspect this node’s Tailscale identity, HTTPS capability and port 443 Serve ownership. Preserve existing routes; never reset Serve or enable Funnel. Pair using the native T3 client after a separate manager connection succeeds.',
51
+ },
52
+ },
53
+ ];
54
+ }
55
+
56
+ async function selfDns(run: Runner): Promise<string | null> {
57
+ const status = await run(['tailscale', 'status', '--json']);
58
+ let dns: string | undefined;
59
+ try {
60
+ if (status.ok)
61
+ dns = parseTailnet(JSON.parse(status.stdout)).computers.find(
62
+ (peer) => peer.self
63
+ )?.dns;
64
+ } catch {
65
+ /* No auth responses or raw status in reports. */
66
+ }
67
+ return dns && /^[a-z0-9.-]+\.ts\.net$/.test(dns) ? dns : null;
68
+ }
@@ -0,0 +1,75 @@
1
+ import type { Runner, Check } from '../types';
2
+
3
+ /** The user unit that runs the pinned desktop app (UI and embedded server). */
4
+ export const T3_DESKTOP_UNIT = 't3code-desktop.service';
5
+
6
+ /** Workers run one T3 process: the desktop app, started with the auto-login
7
+ * graphical session. `t3-desktop.yml` installs it, retires the former headless
8
+ * server and starts the app; this only refuses a foreign launcher beforehand
9
+ * and measures the result afterwards.
10
+ */
11
+ export async function convergeT3Service(
12
+ artifact: { path: string; version: string },
13
+ run: Runner,
14
+ prepare: () => Promise<Check[]> = async () => [],
15
+ install: () => Promise<boolean> = async () => true
16
+ ): Promise<Check[]> {
17
+ const existing = await run(['t3', '--version']);
18
+ if (conflictingVersion(existing, artifact.version))
19
+ return pending(
20
+ 'T3_EXECUTABLE_OWNER_CONFLICT',
21
+ 'The existing T3 launcher has a different version. Review the pinned release; preserve its application data and active sessions.'
22
+ );
23
+ const preparation = await prepare();
24
+ if (preparation.length) return preparation;
25
+ if (!(await install()))
26
+ return pending(
27
+ 'T3_DESKTOP_INSTALL_FAILED',
28
+ 'Inspect the t3-desktop.yml Ansible output (package download, apt, GDM auto-login, user unit). T3 data in ~/.t3 is untouched.'
29
+ );
30
+ const active = await userctl(run, 'is-active', T3_DESKTOP_UNIT);
31
+ const enabled = await userctl(run, 'is-enabled', T3_DESKTOP_UNIT);
32
+ if (!active.ok || !enabled.ok)
33
+ return pending(
34
+ 'T3_DESKTOP_STOPPED',
35
+ `The desktop app needs the auto-login graphical session. Reboot or log in on the machine, then inspect journalctl --user -u ${T3_DESKTOP_UNIT}; ~/.t3 is preserved.`
36
+ );
37
+ return [
38
+ {
39
+ id: 't3:service',
40
+ status: 'pass',
41
+ summary:
42
+ 'Pinned T3 desktop app is enabled for the auto-login session and running. Private routing, policy and pairing remain separate checks.',
43
+ },
44
+ ];
45
+ }
46
+
47
+ /** When the desktop app last started; changes exactly when it restarts. */
48
+ export async function t3StartedAt(run: Runner): Promise<string> {
49
+ const shown = await run([
50
+ 'systemctl',
51
+ '--user',
52
+ 'show',
53
+ '--property=ActiveEnterTimestampMonotonic',
54
+ '--value',
55
+ T3_DESKTOP_UNIT,
56
+ ]);
57
+ return shown.ok ? shown.stdout.trim() : '';
58
+ }
59
+
60
+ function userctl(run: Runner, verb: string, unit: string) {
61
+ return run(['systemctl', '--user', verb, unit]);
62
+ }
63
+
64
+ function pending(code: string, action: string): Check[] {
65
+ return [
66
+ { id: 't3:service', status: 'fail', summary: code, remedy: { action } },
67
+ ];
68
+ }
69
+
70
+ function conflictingVersion(
71
+ result: { ok: boolean; stdout: string },
72
+ version: string
73
+ ): boolean {
74
+ return result.ok && result.stdout.trim() !== `t3 v${version}`;
75
+ }