@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,85 @@
1
+ import { record } from './config';
2
+ import { configPath } from './fleet-config';
3
+ import type { Check, Runner } from './types';
4
+
5
+ /** The proxy runs only on the manager, which performs singleton/state/provenance
6
+ * checks before starting a stopped install; on a worker this verifies the
7
+ * client-only role. Routine sync cannot upgrade or restore it.
8
+ */
9
+ export async function convergeProxy(
10
+ node: string,
11
+ revision: string,
12
+ run: Runner,
13
+ options: { apply?: boolean; cwd?: string } = {}
14
+ ): Promise<Check[]> {
15
+ const result = await run(
16
+ [
17
+ 'python3',
18
+ '-B',
19
+ 'cliproxy/scripts/fleet_sync.py',
20
+ '--definition',
21
+ configPath(),
22
+ '--node',
23
+ node,
24
+ '--revision',
25
+ revision,
26
+ ...(options.apply === false ? [] : ['--apply']),
27
+ ],
28
+ {
29
+ timeoutMs: 180_000,
30
+ ...(options.cwd ? { cwd: options.cwd } : {}),
31
+ }
32
+ );
33
+ try {
34
+ const data: unknown = JSON.parse(result.stdout);
35
+ if (
36
+ !record(data) ||
37
+ data['version'] !== 1 ||
38
+ !Array.isArray(data['phases']) ||
39
+ !data['phases'].length
40
+ )
41
+ throw new Error();
42
+ return data['phases'].map((phase): Check => {
43
+ if (
44
+ !record(phase) ||
45
+ typeof phase['code'] !== 'string' ||
46
+ !/^PROXY_[A-Z0-9_]+$/.test(phase['code'])
47
+ )
48
+ throw new Error();
49
+ return {
50
+ id: `proxy:${phase['code'].slice(6).toLowerCase().replaceAll('_', '-')}`,
51
+ status: phase['status'] === 'unchanged' ? 'pass' : 'fail',
52
+ summary: phase['code'],
53
+ remedy: { action: phaseAction(phase['remedy']) },
54
+ };
55
+ });
56
+ } catch {
57
+ return [
58
+ {
59
+ id: 'proxy:configuration',
60
+ status: 'fail',
61
+ summary: 'PROXY_RECONCILE_UNVERIFIED',
62
+ remedy: {
63
+ action:
64
+ 'Inspect the CLIProxyAPI install on the manager and its deployment manifest; runtime data and credentials were not copied.',
65
+ },
66
+ },
67
+ ];
68
+ }
69
+ }
70
+
71
+ const GENERIC_ACTION =
72
+ 'The proxy runs only on the manager. Inspect it there with its audit/recovery runbook; missing state requires fencing and encrypted recovery, never a second identity.';
73
+
74
+ /** The proxy's own remedies are authored text from tracked source (never
75
+ * subprocess output); anything else falls back to the generic runbook.
76
+ */
77
+ function phaseAction(remedy: unknown): string {
78
+ const action = record(remedy) ? remedy['action'] : undefined;
79
+ return typeof action === 'string' &&
80
+ action.length > 0 &&
81
+ action.length <= 400 &&
82
+ !/[\u0000-\u001f]/.test(action)
83
+ ? action
84
+ : GENERIC_ACTION;
85
+ }
@@ -0,0 +1,210 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFile, unlink, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { prepareSource } from './machine/source';
5
+ import { isRevision } from './machine/state';
6
+ import { sshArgs, shellQuote, sshFailure } from './ssh';
7
+ import type { FleetNode, NodeReport, Runner, Check } from './types';
8
+ import { record } from './config';
9
+ import { installedDefinition, type FleetDefinition } from './definition';
10
+ import { cleanCheck, validCheck } from './protocol';
11
+
12
+ export interface BootstrapPayload {
13
+ revision: string;
14
+ script: string;
15
+ bundle: string;
16
+ digest: string;
17
+ authorizeSudo?: string;
18
+ /** The fleet configuration every node keeps at ~/.config/dev-fleet/fleet.json. */
19
+ config?: string;
20
+ }
21
+ /** No manager tool installation; Git/source preparation is part of apply approval. */
22
+ export async function prepareBootstrap(
23
+ root: string,
24
+ revision: string,
25
+ run: Runner,
26
+ options: { definition?: FleetDefinition; checkout?: string } = {}
27
+ ): Promise<BootstrapPayload | null> {
28
+ if (!isRevision(revision)) return null;
29
+ const source = await prepareSource(root, revision, run, options.checkout);
30
+ if (!source) return null;
31
+ const script = await trackedScript(
32
+ source,
33
+ revision,
34
+ 'bootstrap/fleet-remote-bootstrap.sh',
35
+ run
36
+ );
37
+ const sudoScript = await trackedScript(
38
+ source,
39
+ revision,
40
+ 'ansible/ubuntu/authorize-sudo.sh',
41
+ run
42
+ );
43
+ if (!script || !sudoScript) return null;
44
+ const payload = await bundleRevision(
45
+ { root, source, revision },
46
+ { script, authorizeSudo: sudoScript },
47
+ run
48
+ );
49
+ // The fleet configuration is data, not source: it travels beside the code.
50
+ return payload && options.definition
51
+ ? { ...payload, config: installedDefinition(options.definition) }
52
+ : payload;
53
+ }
54
+
55
+ /** Workers run one revision and never need history: the bundle holds only the
56
+ * approved commit and its files (about a quarter of the full history), and the
57
+ * receiving checkout is marked shallow at that commit. `git bundle create`
58
+ * cannot cut history, so the v2 bundle is written directly: a header naming
59
+ * the commit, then a pack of exactly its objects.
60
+ */
61
+ const SNAPSHOT_BUNDLE = [
62
+ 'objects=$(git rev-list --objects --no-walk "$1") || exit 1',
63
+ '{ printf \'# v2 git bundle\\n%s refs/personal-fleet/%s\\n\\n\' "$1" "$1"',
64
+ 'printf \'%s\\n\' "$objects" | git pack-objects --stdout -q; } >"$2"',
65
+ ].join('\n');
66
+
67
+ async function bundleRevision(
68
+ {
69
+ root,
70
+ source,
71
+ revision,
72
+ }: { root: string; source: string; revision: string },
73
+ scripts: { script: string; authorizeSudo: string },
74
+ run: Runner
75
+ ): Promise<BootstrapPayload | null> {
76
+ const path = join(root, `transfer-${randomUUID()}.bundle`);
77
+ try {
78
+ const bundled = await run(
79
+ ['sh', '-c', SNAPSHOT_BUNDLE, 'bundle', revision, path],
80
+ { cwd: source, timeoutMs: 120_000 }
81
+ );
82
+ if (!bundled.ok || (await stat(path)).size > 512 * 1024 * 1024) return null;
83
+ const bundle = await readFile(path);
84
+ return {
85
+ revision,
86
+ ...scripts,
87
+ bundle: bundle.toString('base64'),
88
+ digest: createHash('sha256').update(bundle).digest('hex'),
89
+ };
90
+ } finally {
91
+ await unlink(path).catch(() => undefined);
92
+ }
93
+ }
94
+ export async function bootstrapRemote(
95
+ node: FleetNode,
96
+ payload: BootstrapPayload,
97
+ run: Runner
98
+ ): Promise<NodeReport> {
99
+ const cached = await cachedRevision(node, payload.revision, run);
100
+ if (typeof cached !== 'boolean') return { node: node.id, checks: [cached] };
101
+ const transfer = cached
102
+ ? { mode: 'cached', bundle: '' }
103
+ : { mode: 'transfer', bundle: payload.bundle };
104
+ const command = [
105
+ 'sh',
106
+ '-c',
107
+ payload.script,
108
+ 'fleet-bootstrap',
109
+ payload.revision,
110
+ payload.digest,
111
+ node.id,
112
+ transfer.mode,
113
+ ]
114
+ .map(shellQuote)
115
+ .join(' ');
116
+ const result = await run([...sshArgs(node), command], {
117
+ input: bootstrapInput(payload.config, transfer.bundle),
118
+ timeoutMs: 5_400_000,
119
+ });
120
+ const evidence = parseEvidence(result);
121
+ if (evidence) return { node: node.id, checks: evidence };
122
+ if (!result.ok) return { node: node.id, checks: [sshFailure(result)] };
123
+ return {
124
+ node: node.id,
125
+ checks: [
126
+ {
127
+ id: 'bootstrap:protocol',
128
+ status: 'fail',
129
+ summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
130
+ remedy: {
131
+ action:
132
+ 'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
133
+ },
134
+ },
135
+ ],
136
+ };
137
+ }
138
+
139
+ /** What the bootstrap reads on stdin: one line with the fleet configuration
140
+ * (installed under the remote lock, so overlapping runs never both write it),
141
+ * then the bundle when the worker lacks the revision.
142
+ */
143
+ export function bootstrapInput(
144
+ config: string | undefined,
145
+ bundle: string
146
+ ): string {
147
+ const line = config ? Buffer.from(config).toString('base64') : '';
148
+ return `${line}\n${bundle}`;
149
+ }
150
+
151
+ async function cachedRevision(
152
+ node: FleetNode,
153
+ revision: string,
154
+ run: Runner
155
+ ): Promise<boolean | Check> {
156
+ const command = `d="$HOME/.local/share/personal/fleet/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
157
+ const result = await run([...sshArgs(node), command], { timeoutMs: 20_000 });
158
+ if (!result.ok) {
159
+ const failure = sshFailure(result);
160
+ if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
161
+ }
162
+ return result.ok && result.stdout === 'ready';
163
+ }
164
+
165
+ function parseEvidence(result: {
166
+ stdout: string;
167
+ ok: boolean;
168
+ }): Check[] | null {
169
+ try {
170
+ const value: unknown = JSON.parse(result.stdout);
171
+ if (
172
+ record(value) &&
173
+ value['version'] === 1 &&
174
+ Array.isArray(value['checks']) &&
175
+ value['checks'].length > 0 &&
176
+ value['checks'].length <= 200 &&
177
+ value['checks'].every(validCheck)
178
+ ) {
179
+ const checks = value['checks'].map(cleanCheck);
180
+ if (!result.ok && !checks.some((check) => check.status === 'fail'))
181
+ checks.push({
182
+ id: 'bootstrap:exit',
183
+ status: 'fail',
184
+ summary: 'REMOTE_BOOTSTRAP_EXIT_FAILED',
185
+ remedy: {
186
+ action:
187
+ 'The remote process failed despite partial evidence. Inspect its operation state before retry; no success receipt may advance.',
188
+ },
189
+ });
190
+ return checks;
191
+ }
192
+ } catch {
193
+ /* Raw SSH output is never included in reports. */
194
+ }
195
+ return null;
196
+ }
197
+
198
+ export async function trackedScript(
199
+ source: string,
200
+ revision: string,
201
+ path: string,
202
+ run: Runner
203
+ ): Promise<string | null> {
204
+ const result = await run(['git', 'show', revision + ':' + path], {
205
+ cwd: source,
206
+ });
207
+ return result.ok && result.stdout.startsWith('#!/bin/sh\n')
208
+ ? result.stdout
209
+ : null;
210
+ }
@@ -0,0 +1,89 @@
1
+ import { configureWorkspace, validConfiguration } from './configure';
2
+ import { parseConfig, record } from './config';
3
+ import { probe } from './probe';
4
+ import { runCommand } from './runner';
5
+ import { syncWorkspace } from './sync';
6
+ import { repairWorkspace, validRepairs } from './repair-runner';
7
+ import { validSnapshot } from './protocol';
8
+ import type { Fingerprint } from './types';
9
+
10
+ async function main(): Promise<void> {
11
+ const raw: unknown = JSON.parse(
12
+ Buffer.from(process.argv.at(-1) ?? '', 'base64').toString()
13
+ );
14
+ if (!validRequest(raw)) throw new Error('Invalid request.');
15
+ const node = parseConfig({ version: 1, nodes: [raw['node']] }).nodes[0]!;
16
+ if (raw['action'] === 'configure') {
17
+ await configureRequest(raw, node);
18
+ } else if (raw['action'] === 'repair') {
19
+ if (
20
+ raw['apply'] !== true ||
21
+ !validRepairs(raw['repairs']) ||
22
+ !validSnapshot({
23
+ version: 1,
24
+ platform: 'request',
25
+ checks: [],
26
+ fingerprint: raw['expected'],
27
+ })
28
+ )
29
+ throw new Error(
30
+ 'Repair requires explicit authorization and supported actions.'
31
+ );
32
+ const checks = await repairWorkspace(
33
+ node.workspace,
34
+ raw['repairs'],
35
+ runCommand,
36
+ raw['expected'] as Fingerprint
37
+ );
38
+ process.stdout.write(JSON.stringify({ version: 1, checks }));
39
+ } else if (raw['action'] === 'sync') {
40
+ if (raw['apply'] !== true)
41
+ throw new Error('Sync requires explicit authorization.');
42
+ const checks = await syncWorkspace(
43
+ node.workspace,
44
+ raw['pull'] === true,
45
+ runCommand
46
+ );
47
+ process.stdout.write(JSON.stringify({ version: 1, checks }));
48
+ } else
49
+ process.stdout.write(
50
+ JSON.stringify(
51
+ await probe(node.workspace, node.required, runCommand, node.id)
52
+ )
53
+ );
54
+ }
55
+
56
+ await main().catch(() => {
57
+ process.stdout.write(
58
+ JSON.stringify({
59
+ version: 1,
60
+ error: 'Remote operation failed; inspect node locally.',
61
+ })
62
+ );
63
+ process.exitCode = 1;
64
+ });
65
+
66
+ async function configureRequest(
67
+ raw: Record<string, unknown>,
68
+ node: { id: string; workspace: string }
69
+ ): Promise<void> {
70
+ if (
71
+ raw['apply'] !== true ||
72
+ !validConfiguration(raw['configuration']) ||
73
+ raw['configuration'].node !== node.id
74
+ )
75
+ throw new Error('Invalid configuration request.');
76
+ const checks = await configureWorkspace(
77
+ node.workspace,
78
+ raw['configuration'],
79
+ runCommand
80
+ );
81
+ process.stdout.write(JSON.stringify({ version: 1, checks }));
82
+ }
83
+
84
+ function validRequest(raw: unknown): raw is Record<string, unknown> {
85
+ return (
86
+ record(raw) &&
87
+ ['probe', 'sync', 'repair', 'configure'].includes(String(raw['action']))
88
+ );
89
+ }
@@ -0,0 +1,12 @@
1
+ /** POSIX lock prelude shared by every remote operation. Linux uses flock(1);
2
+ * macOS lacks it, so the same descriptor is locked through Python's fcntl. The
3
+ * lock belongs to the shell's open descriptor and persists until that exits.
4
+ */
5
+ export function remoteLockPrelude(
6
+ root = '"$HOME/.local/share/personal/fleet"'
7
+ ): string {
8
+ return [
9
+ `exec 9>${root}/remote-operation.lock`,
10
+ 'if command -v flock >/dev/null 2>&1; then flock -n 9; else python3 -c "import fcntl; fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)" 9>&9; fi',
11
+ ].join(' && ');
12
+ }
@@ -0,0 +1,206 @@
1
+ export type AutomatedRepair = 'workspace' | 'workstation' | 'providers';
2
+ export interface RepairRecipe {
3
+ id: string;
4
+ title: string;
5
+ owner: string;
6
+ automatic: boolean;
7
+ action: string;
8
+ commands: string[][];
9
+ }
10
+ const login = (
11
+ id: string,
12
+ title: string,
13
+ commands: string[][],
14
+ action: string
15
+ ): RepairRecipe => ({
16
+ id: `login:${id}`,
17
+ title,
18
+ owner: `${id} native CLI`,
19
+ automatic: false,
20
+ action,
21
+ commands,
22
+ });
23
+ export const REPAIR_RECIPES: RepairRecipe[] = [
24
+ {
25
+ id: 'workspace',
26
+ title: 'Install locked tools and frozen dependencies',
27
+ owner: 'mise / bootstrap/setup.ts',
28
+ automatic: true,
29
+ action:
30
+ 'Runs the existing shared setup and trusted checkout lifecycle scripts. Updates user mise/PATH configuration and writes a sync receipt. Does not pull or reset Git.',
31
+ commands: [
32
+ [
33
+ 'mise',
34
+ 'exec',
35
+ '--locked',
36
+ 'bun',
37
+ '--',
38
+ 'bun',
39
+ 'bootstrap/setup.ts',
40
+ '--non-interactive',
41
+ ],
42
+ ],
43
+ },
44
+ {
45
+ id: 'workstation',
46
+ title: 'Install the devfleet launcher',
47
+ owner: 'dev-fleet',
48
+ automatic: true,
49
+ action:
50
+ 'Links ~/.local/bin/devfleet to this checkout of dev-fleet (refused for an installed package, which is not one). Nothing is copied and no configuration is changed.',
51
+ commands: [['sh', 'bootstrap/install-launcher.sh']],
52
+ },
53
+ {
54
+ id: 'providers',
55
+ title: "Refresh this machine's own service credentials",
56
+ owner: 'dev-fleet',
57
+ automatic: true,
58
+ action:
59
+ "Renews this machine's own secrets-provider session and the service logins derived from it. No credentials are copied from another computer.",
60
+ commands: [['bun', 'bootstrap/worker-auth.ts']],
61
+ },
62
+ login(
63
+ 'codex',
64
+ 'Sign in to Codex on this machine',
65
+ [['codex', 'login', '--device-auth']],
66
+ 'Approve the device flow in your browser. Run codex login status afterward; retain this machine’s own credential store.'
67
+ ),
68
+ login(
69
+ 'claude',
70
+ 'Sign in to Claude Code on this machine',
71
+ [['claude', 'auth', 'login']],
72
+ 'Complete the native browser login, then run claude auth status --json. API-key and subscription contexts can differ.'
73
+ ),
74
+ login(
75
+ 'github',
76
+ 'Sign in to GitHub and configure Git',
77
+ [
78
+ [
79
+ 'gh',
80
+ 'auth',
81
+ 'login',
82
+ '--hostname',
83
+ 'github.com',
84
+ '--web',
85
+ '--git-protocol',
86
+ 'https',
87
+ ],
88
+ ['gh', 'auth', 'setup-git'],
89
+ ],
90
+ 'Approve GitHub in the browser on the intended account, then run gh auth status --active. Environment tokens can override stored login; inspect their source locally.'
91
+ ),
92
+ login(
93
+ 'vault',
94
+ 'Authorize this machine for the shared Vault',
95
+ [],
96
+ 'Run vault login -method=userpass username=YOUR_USERNAME in your own terminal. The native CLI prompts privately for the password. Use the existing permitted project/config; never copy VAULT_TOKEN between computers.'
97
+ ),
98
+ login(
99
+ 'railway',
100
+ 'Sign in to Railway',
101
+ [['railway', 'login', '--browserless']],
102
+ 'Complete the browserless native flow, then run railway whoami --json.'
103
+ ),
104
+ login(
105
+ 'vercel',
106
+ 'Sign in to Vercel',
107
+ [['vercel', 'login']],
108
+ 'Complete native login, then run vercel whoami. Select the intended team/project separately; do not create resources as a login repair.'
109
+ ),
110
+ login(
111
+ 'cloudflare',
112
+ 'Sign in to Cloudflare',
113
+ [['wrangler', 'login', '--browser=false']],
114
+ 'Complete native OAuth login, then run wrangler whoami --json. For headless SSH, prefer an existing scoped API token injected by the approved Vault config. --browser=false suppresses browser launch; it does not make a remote loopback callback reachable. Any OAuth callback tunnel needs explicit operator setup. Resource access still depends on account permissions.'
115
+ ),
116
+ login(
117
+ 'aws',
118
+ 'Refresh the selected AWS SSO session',
119
+ [['aws', 'sso', 'login']],
120
+ 'Configure the intended profile with aws configure sso first if needed. Login and the health check use this shell’s current profile; no access keys are cloned.'
121
+ ),
122
+ login(
123
+ 'npm',
124
+ 'Sign in to npm on this machine',
125
+ [
126
+ [
127
+ 'npm',
128
+ 'login',
129
+ '--auth-type=web',
130
+ '--registry=https://registry.npmjs.org/',
131
+ ],
132
+ ],
133
+ 'Complete the specific CLI approval and run npm whoami. Prefer the existing trusted-publisher CI workflow for releases.'
134
+ ),
135
+ login(
136
+ 'opencode',
137
+ 'Authorize an OpenCode provider',
138
+ [['opencode', 'auth', 'login']],
139
+ 'Prefer the existing Vault-backed providers repair for managed API keys. Native auth login handles an operator-owned OAuth provider. Credential listing alone does not prove live provider access.'
140
+ ),
141
+ login(
142
+ 'copilot',
143
+ 'Sign in to Copilot',
144
+ [['copilot', 'login']],
145
+ 'Complete native login; provider authorization still needs supported operator diagnostics. No model prompt is used for health checking.'
146
+ ),
147
+ login(
148
+ 'gemini',
149
+ 'Sign in to Gemini',
150
+ [],
151
+ 'Open Gemini interactively and choose its supported sign-in flow. Provider authorization remains unverified without a supported read-only status adapter.'
152
+ ),
153
+ {
154
+ id: 't3',
155
+ title: 'Enable and authorize T3 Connect',
156
+ owner: 'T3',
157
+ automatic: false,
158
+ action:
159
+ 'Run t3 up --connect in this machine’s terminal and approve the intended connection. Treat pairing material as private. Then check t3 status --json and perform an operator peer test.',
160
+ commands: [['t3', 'up', '--connect']],
161
+ },
162
+ {
163
+ id: 'docker',
164
+ title: 'Start the intended Docker runtime',
165
+ owner: 'OS / Docker',
166
+ automatic: false,
167
+ action:
168
+ 'Start Docker Desktop on desktop systems, or the already-installed Docker service using the OS service manager on Linux. Confirm the intended context with docker context ls. This repair never installs a daemon, invokes sudo, changes contexts or exposes a TCP socket.',
169
+ commands: [
170
+ ['docker', 'context', 'ls'],
171
+ ['docker', 'info'],
172
+ ],
173
+ },
174
+ {
175
+ id: 'password-manager',
176
+ title: 'Review shared human login onboarding',
177
+ owner: 'password-manager / Bitwarden',
178
+ automatic: false,
179
+ action:
180
+ 'Use the existing password-manager doctor and actions list on this computer. Its setup guides native consent and Bitwarden enrollment. Keep its private state and OS credential store local. Password vault access does not authorize native CLI OAuth sessions; imports and rotations remain separate reviewed workflows.',
181
+ commands: [
182
+ ['bun', 'run', 'password-manager', 'doctor'],
183
+ ['bun', 'run', 'password-manager', 'actions', 'list'],
184
+ ],
185
+ },
186
+ {
187
+ id: 'operator',
188
+ title: 'Resolve the check with its owning tool',
189
+ owner: 'operator / existing owner',
190
+ automatic: false,
191
+ action:
192
+ 'Follow the check’s remedy on the affected machine. Verify host fingerprints, register real SSH aliases/checkouts, reconcile Git changes, or perform the named end-to-end test. Infrastructure changes remain OpenTofu-owned.',
193
+ commands: [],
194
+ },
195
+ ];
196
+ export const AUTOMATED_REPAIRS: AutomatedRepair[] = [
197
+ 'workspace',
198
+ 'workstation',
199
+ 'providers',
200
+ ];
201
+ export function recipe(id: string): RepairRecipe {
202
+ return (
203
+ REPAIR_RECIPES.find((entry) => entry.id === id) ??
204
+ REPAIR_RECIPES.find((entry) => entry.id === 'operator')!
205
+ );
206
+ }
@@ -0,0 +1,78 @@
1
+ import { AUTOMATED_REPAIRS, recipe } from './repair-catalog';
2
+ import type { RepairRecipe } from './repair-catalog';
3
+ import type { Check, FleetNode, NodeReport, Fingerprint } from './types';
4
+
5
+ export interface RepairTarget {
6
+ name: string;
7
+ root: string;
8
+ node?: FleetNode;
9
+ fingerprint?: Fingerprint;
10
+ }
11
+ export interface RepairStep extends RepairRecipe {
12
+ node: string;
13
+ checks: string[];
14
+ eligible: boolean;
15
+ next: string[];
16
+ }
17
+
18
+ export function repairId(check: Check): string {
19
+ if (check.id.startsWith('tool:docker-')) return 'docker';
20
+ if (check.id.startsWith('tool:github-auth')) return 'login:github';
21
+ if (['dependencies', 'auth:toolchain'].includes(check.id)) return 'workspace';
22
+ if (check.id.startsWith('tool:')) return 'workspace';
23
+ if (check.id.startsWith('workstation:')) return 'workstation';
24
+ if (
25
+ check.id.startsWith('auth:') &&
26
+ !['auth:vscode', 'auth:workstation'].includes(check.id)
27
+ )
28
+ return `login:${check.id.slice(5)}`;
29
+ if (check.id === 't3:settings' || check.id === 't3:api') return 't3';
30
+ return 'operator';
31
+ }
32
+
33
+ /** Every nonpassing check is accounted for; unknown checks remain operator work. */
34
+ export function repairPlan(
35
+ reports: NodeReport[],
36
+ targets: RepairTarget[]
37
+ ): RepairStep[] {
38
+ const steps: RepairStep[] = [];
39
+ for (const report of reports) {
40
+ const groups = new Map<string, Check[]>();
41
+ for (const check of report.checks.filter(
42
+ (entry) => entry.status !== 'pass'
43
+ )) {
44
+ const repair = recipe(repairId(check)).id;
45
+ groups.set(repair, [...(groups.get(repair) ?? []), check]);
46
+ }
47
+ for (const [id, checks] of groups)
48
+ steps.push({
49
+ ...recipe(id),
50
+ node: report.node,
51
+ eligible:
52
+ targets.some((target) => target.name === report.node) &&
53
+ report.snapshot !== undefined,
54
+ checks: checks.map((check) => check.id),
55
+ next: [
56
+ ...new Set(
57
+ checks.flatMap((check) =>
58
+ check.remedy ? [check.remedy.action] : [check.summary]
59
+ )
60
+ ),
61
+ ],
62
+ });
63
+ }
64
+ return steps;
65
+ }
66
+
67
+ /** Prerequisite owners always run first, independent of check or selector order. */
68
+ export function automaticRepairs(
69
+ steps: RepairStep[],
70
+ node: string
71
+ ): RepairStep[] {
72
+ return AUTOMATED_REPAIRS.flatMap((id) =>
73
+ steps.filter(
74
+ (step) =>
75
+ step.node === node && step.id === id && step.automatic && step.eligible
76
+ )
77
+ );
78
+ }