@quickchatai/quickreview 1.2.0 → 1.3.2

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 (350) hide show
  1. package/README.md +116 -53
  2. package/bin/qcr.js +1 -1
  3. package/config.example.toml +25 -7
  4. package/deploy/ansible/README.md +20 -12
  5. package/deploy/ansible/inventory.example.ini +1 -1
  6. package/deploy/collect-secrets.sh +1 -1
  7. package/deploy/qcr-update.sh +1 -1
  8. package/dist/extensions/cli/home.js +14 -0
  9. package/dist/extensions/cli/home.js.map +1 -0
  10. package/dist/extensions/cli/selfimprove.js +18 -0
  11. package/dist/extensions/cli/selfimprove.js.map +1 -0
  12. package/dist/extensions/home.js +350 -0
  13. package/dist/extensions/home.js.map +1 -0
  14. package/dist/extensions/login-route.js +1 -1
  15. package/dist/extensions/login-route.js.map +1 -1
  16. package/dist/extensions/onboarding.js +8 -7
  17. package/dist/extensions/onboarding.js.map +1 -1
  18. package/dist/extensions/selfimprove.js +49 -0
  19. package/dist/extensions/selfimprove.js.map +1 -0
  20. package/dist/extensions/setup-scan.js +23 -592
  21. package/dist/extensions/setup-scan.js.map +1 -1
  22. package/dist/extensions/setup.js +2 -47
  23. package/dist/extensions/setup.js.map +1 -1
  24. package/dist/extensions/vendor/openviking-pi/index.js +4 -0
  25. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
  26. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
  27. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
  28. package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
  29. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
  30. package/dist/src/archive/restore.js +18 -15
  31. package/dist/src/archive/restore.js.map +1 -1
  32. package/dist/src/botworker.js +13 -2
  33. package/dist/src/botworker.js.map +1 -1
  34. package/dist/src/channelfs.js +3 -26
  35. package/dist/src/channelfs.js.map +1 -1
  36. package/dist/src/cli.js +14 -50
  37. package/dist/src/cli.js.map +1 -1
  38. package/dist/src/config.js +16 -0
  39. package/dist/src/config.js.map +1 -1
  40. package/dist/src/daemon.js +33 -4
  41. package/dist/src/daemon.js.map +1 -1
  42. package/dist/src/debuground.js +1 -0
  43. package/dist/src/debuground.js.map +1 -1
  44. package/dist/src/debugroundpi.js +1 -0
  45. package/dist/src/debugroundpi.js.map +1 -1
  46. package/dist/src/engine/caserunner-sdk.js +15 -251
  47. package/dist/src/engine/caserunner-sdk.js.map +1 -1
  48. package/dist/src/home/adapters.js +349 -0
  49. package/dist/src/home/adapters.js.map +1 -0
  50. package/dist/src/home/advanced.js +318 -0
  51. package/dist/src/home/advanced.js.map +1 -0
  52. package/dist/src/home/advancedtools.js +57 -0
  53. package/dist/src/home/advancedtools.js.map +1 -0
  54. package/dist/src/home/autoreview.js +325 -0
  55. package/dist/src/home/autoreview.js.map +1 -0
  56. package/dist/src/home/daemonstatus.js +32 -0
  57. package/dist/src/home/daemonstatus.js.map +1 -0
  58. package/dist/src/home/devicelogin.js +61 -0
  59. package/dist/src/home/devicelogin.js.map +1 -0
  60. package/dist/src/home/launch.js +202 -0
  61. package/dist/src/home/launch.js.map +1 -0
  62. package/dist/src/home/loginowner.js +205 -0
  63. package/dist/src/home/loginowner.js.map +1 -0
  64. package/dist/src/home/memory.js +122 -0
  65. package/dist/src/home/memory.js.map +1 -0
  66. package/dist/src/home/model.js +811 -0
  67. package/dist/src/home/model.js.map +1 -0
  68. package/dist/src/home/panel.js +520 -0
  69. package/dist/src/home/panel.js.map +1 -0
  70. package/dist/src/home/ports.js +19 -0
  71. package/dist/src/home/ports.js.map +1 -0
  72. package/dist/src/home/review.js +163 -0
  73. package/dist/src/home/review.js.map +1 -0
  74. package/dist/src/home/reviews.js +478 -0
  75. package/dist/src/home/reviews.js.map +1 -0
  76. package/dist/src/home/setup.js +507 -0
  77. package/dist/src/home/setup.js.map +1 -0
  78. package/dist/src/home/setupadapters.js +214 -0
  79. package/dist/src/home/setupadapters.js.map +1 -0
  80. package/dist/src/home/setupcard.js +199 -0
  81. package/dist/src/home/setupcard.js.map +1 -0
  82. package/dist/src/home/setuptools.js +45 -0
  83. package/dist/src/home/setuptools.js.map +1 -0
  84. package/dist/src/home/spend.js +160 -0
  85. package/dist/src/home/spend.js.map +1 -0
  86. package/dist/src/home/switches.js +209 -0
  87. package/dist/src/home/switches.js.map +1 -0
  88. package/dist/src/home/switchon.js +439 -0
  89. package/dist/src/home/switchon.js.map +1 -0
  90. package/dist/src/home/toolnames.js +41 -0
  91. package/dist/src/home/toolnames.js.map +1 -0
  92. package/dist/src/home/tune.js +341 -0
  93. package/dist/src/home/tune.js.map +1 -0
  94. package/dist/src/home/words.js +7 -0
  95. package/dist/src/home/words.js.map +1 -0
  96. package/dist/src/homes.js +14 -0
  97. package/dist/src/homes.js.map +1 -1
  98. package/dist/src/install.js +1 -1
  99. package/dist/src/install.js.map +1 -1
  100. package/dist/src/ipc/workerspec.js +8 -0
  101. package/dist/src/ipc/workerspec.js.map +1 -1
  102. package/dist/src/ledgercheck.js +39 -10
  103. package/dist/src/ledgercheck.js.map +1 -1
  104. package/dist/src/live/publish.js +1 -1
  105. package/dist/src/live/publish.js.map +1 -1
  106. package/dist/src/livecli.js +1 -1
  107. package/dist/src/livecli.js.map +1 -1
  108. package/dist/src/loginrefresh/handover.js +121 -0
  109. package/dist/src/loginrefresh/handover.js.map +1 -0
  110. package/dist/src/loginrefresh/lock.js +54 -3
  111. package/dist/src/loginrefresh/lock.js.map +1 -1
  112. package/dist/src/loginrefresh/refresher.js +33 -5
  113. package/dist/src/loginrefresh/refresher.js.map +1 -1
  114. package/dist/src/loginrefresh/supervisor.js +18 -1
  115. package/dist/src/loginrefresh/supervisor.js.map +1 -1
  116. package/dist/src/npmverbs.js +42 -12
  117. package/dist/src/npmverbs.js.map +1 -1
  118. package/dist/src/onboard/choices.js +6 -3
  119. package/dist/src/onboard/choices.js.map +1 -1
  120. package/dist/src/onboard/engine.js +33 -292
  121. package/dist/src/onboard/engine.js.map +1 -1
  122. package/dist/src/onboard/steps/bringup.js +3 -2
  123. package/dist/src/onboard/steps/bringup.js.map +1 -1
  124. package/dist/src/onboard/steps/configfill.js +18 -7
  125. package/dist/src/onboard/steps/configfill.js.map +1 -1
  126. package/dist/src/onboard/steps/loginshare.js +213 -0
  127. package/dist/src/onboard/steps/loginshare.js.map +1 -0
  128. package/dist/src/onboard/steps/memory.js +14 -2
  129. package/dist/src/onboard/steps/memory.js.map +1 -1
  130. package/dist/src/onboard/steps/repoconfig.js +72 -0
  131. package/dist/src/onboard/steps/repoconfig.js.map +1 -0
  132. package/dist/src/onboard/toolnames.js +2 -2
  133. package/dist/src/onboard/toolnames.js.map +1 -1
  134. package/dist/src/piagentdir.js +13 -0
  135. package/dist/src/piagentdir.js.map +1 -0
  136. package/dist/src/pilogin.js +31 -16
  137. package/dist/src/pilogin.js.map +1 -1
  138. package/dist/src/qcrbin.js +16 -11
  139. package/dist/src/qcrbin.js.map +1 -1
  140. package/dist/src/reviewargs.js +122 -0
  141. package/dist/src/reviewargs.js.map +1 -0
  142. package/dist/src/reviewcli.js +7 -161
  143. package/dist/src/reviewcli.js.map +1 -1
  144. package/dist/src/reviewdetach.js +76 -11
  145. package/dist/src/reviewdetach.js.map +1 -1
  146. package/dist/src/scancli.js +39 -691
  147. package/dist/src/scancli.js.map +1 -1
  148. package/dist/src/selfimprove.js +66 -0
  149. package/dist/src/selfimprove.js.map +1 -0
  150. package/dist/src/session-command-supervisor.js +38 -9
  151. package/dist/src/session-command-supervisor.js.map +1 -1
  152. package/dist/src/setup/adapters.js +5 -223
  153. package/dist/src/setup/adapters.js.map +1 -1
  154. package/dist/src/setup/caserunner.js +15 -249
  155. package/dist/src/setup/caserunner.js.map +1 -1
  156. package/dist/src/setup/engine.js +32 -341
  157. package/dist/src/setup/engine.js.map +1 -1
  158. package/dist/src/setup/facts.js +3 -592
  159. package/dist/src/setup/facts.js.map +1 -1
  160. package/dist/src/setup/findings.js +3 -947
  161. package/dist/src/setup/findings.js.map +1 -1
  162. package/dist/src/setup/presentation.js +3 -8
  163. package/dist/src/setup/presentation.js.map +1 -1
  164. package/dist/src/setup/scan.js +3 -724
  165. package/dist/src/setup/scan.js.map +1 -1
  166. package/dist/src/setup/scanbuild.js +9 -92
  167. package/dist/src/setup/scanbuild.js.map +1 -1
  168. package/dist/src/setup/scanlease.js +3 -250
  169. package/dist/src/setup/scanlease.js.map +1 -1
  170. package/dist/src/setup/scanreport.js +3 -288
  171. package/dist/src/setup/scanreport.js.map +1 -1
  172. package/dist/src/setup/scanrun.js +3 -1732
  173. package/dist/src/setup/scanrun.js.map +1 -1
  174. package/dist/src/setup/scantree.js +3 -256
  175. package/dist/src/setup/scantree.js.map +1 -1
  176. package/dist/src/setup/stage2request.js +3 -258
  177. package/dist/src/setup/stage2request.js.map +1 -1
  178. package/dist/src/setup/unitcut.js +3 -436
  179. package/dist/src/setup/unitcut.js.map +1 -1
  180. package/dist/src/state.js +75 -9
  181. package/dist/src/state.js.map +1 -1
  182. package/dist/src/takeover.js +1 -0
  183. package/dist/src/takeover.js.map +1 -1
  184. package/dist/src/takeoverpi.js +2 -1
  185. package/dist/src/takeoverpi.js.map +1 -1
  186. package/dist/src/tui/alerts.js +4 -1
  187. package/dist/src/tui/alerts.js.map +1 -1
  188. package/dist/src/tui/board.js +2 -2
  189. package/dist/src/tui/board.js.map +1 -1
  190. package/dist/src/tui/companion.js +2 -1
  191. package/dist/src/tui/companion.js.map +1 -1
  192. package/dist/src/tui/deploytab.js +2 -2
  193. package/dist/src/tui/deploytab.js.map +1 -1
  194. package/dist/src/tui/index.js +9 -6
  195. package/dist/src/tui/index.js.map +1 -1
  196. package/dist/src/tui/memory.js +3 -16
  197. package/dist/src/tui/memory.js.map +1 -1
  198. package/dist/src/tui/memoryurl.js +22 -0
  199. package/dist/src/tui/memoryurl.js.map +1 -0
  200. package/dist/src/tui/remote.js +1 -1
  201. package/dist/src/tui/remote.js.map +1 -1
  202. package/dist/src/tui/route.js +2 -2
  203. package/dist/src/tui/route.js.map +1 -1
  204. package/dist/src/tui/setupstate.js +8 -5
  205. package/dist/src/tui/setupstate.js.map +1 -1
  206. package/dist/src/tui/sshtriage.js +1 -1
  207. package/dist/src/tui/sshtriage.js.map +1 -1
  208. package/dist/src/verdicts.js +12 -0
  209. package/dist/src/verdicts.js.map +1 -1
  210. package/dist/src/wire.js +9 -4
  211. package/dist/src/wire.js.map +1 -1
  212. package/dist/src/workerbreaker.js +32 -5
  213. package/dist/src/workerbreaker.js.map +1 -1
  214. package/dist/src/workerworld.js +4 -0
  215. package/dist/src/workerworld.js.map +1 -1
  216. package/docker/README.md +1 -1
  217. package/docker/entrypoint.sh +40 -6
  218. package/docker-compose.yml +16 -1
  219. package/extensions/cli/home.ts +14 -0
  220. package/extensions/cli/selfimprove.ts +18 -0
  221. package/extensions/home.ts +484 -0
  222. package/extensions/login-route.ts +1 -1
  223. package/extensions/onboarding.ts +8 -7
  224. package/extensions/selfimprove.ts +65 -0
  225. package/extensions/setup-scan.ts +79 -766
  226. package/extensions/setup.ts +14 -70
  227. package/extensions/vendor/openviking-pi/NOTICE +5 -0
  228. package/extensions/vendor/openviking-pi/README.md +39 -5
  229. package/extensions/vendor/openviking-pi/index.ts +4 -0
  230. package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
  231. package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
  232. package/extensions/vendor/openviking-pi/sync.ts +80 -0
  233. package/extensions/vendor/openviking-pi/upstream.json +2 -1
  234. package/package.json +6 -4
  235. package/prompts/onboarding.md +8 -7
  236. package/schemas/review-result.v1.json +60 -0
  237. package/scripts/deploy.sh +8 -6
  238. package/scripts/qcr-host.sh +28 -5
  239. package/scripts/vm-ghcr-login.sh +7 -4
  240. package/src/archive/restore.ts +18 -21
  241. package/src/botworker.ts +13 -2
  242. package/src/channelfs.ts +3 -26
  243. package/src/cli.ts +13 -54
  244. package/src/config.ts +38 -0
  245. package/src/daemon.ts +34 -4
  246. package/src/debuground.ts +3 -0
  247. package/src/debugroundpi.ts +1 -0
  248. package/src/engine/caserunner-sdk.ts +29 -314
  249. package/src/home/adapters.ts +418 -0
  250. package/src/home/advanced.ts +421 -0
  251. package/src/home/advancedtools.ts +96 -0
  252. package/src/home/autoreview.ts +385 -0
  253. package/src/home/daemonstatus.ts +55 -0
  254. package/src/home/devicelogin.ts +107 -0
  255. package/src/home/launch.ts +257 -0
  256. package/src/home/loginowner.ts +248 -0
  257. package/src/home/memory.ts +176 -0
  258. package/src/home/model.ts +965 -0
  259. package/src/home/panel.ts +666 -0
  260. package/src/home/ports.ts +338 -0
  261. package/src/home/review.ts +206 -0
  262. package/src/home/reviews.ts +565 -0
  263. package/src/home/setup.ts +689 -0
  264. package/src/home/setupadapters.ts +217 -0
  265. package/src/home/setupcard.ts +288 -0
  266. package/src/home/setuptools.ts +57 -0
  267. package/src/home/spend.ts +194 -0
  268. package/src/home/switches.ts +293 -0
  269. package/src/home/switchon.ts +554 -0
  270. package/src/home/toolnames.ts +46 -0
  271. package/src/home/tune.ts +405 -0
  272. package/src/home/words.ts +8 -0
  273. package/src/homes.ts +17 -0
  274. package/src/install.ts +1 -1
  275. package/src/ipc/workerspec.ts +15 -0
  276. package/src/ledgercheck.ts +45 -9
  277. package/src/live/publish.ts +4 -1
  278. package/src/livecli.ts +6 -1
  279. package/src/loginrefresh/handover.ts +138 -0
  280. package/src/loginrefresh/lock.ts +72 -3
  281. package/src/loginrefresh/refresher.ts +34 -5
  282. package/src/loginrefresh/supervisor.ts +25 -2
  283. package/src/npmverbs.ts +51 -11
  284. package/src/onboard/choices.ts +6 -3
  285. package/src/onboard/engine.ts +64 -434
  286. package/src/onboard/steps/bringup.ts +3 -2
  287. package/src/onboard/steps/configfill.ts +23 -7
  288. package/src/onboard/steps/loginshare.ts +258 -0
  289. package/src/onboard/steps/memory.ts +18 -2
  290. package/src/onboard/steps/repoconfig.ts +85 -0
  291. package/src/onboard/toolnames.ts +2 -2
  292. package/src/piagentdir.ts +12 -0
  293. package/src/pilogin.ts +32 -16
  294. package/src/qcrbin.ts +16 -11
  295. package/src/reviewargs.ts +119 -0
  296. package/src/reviewcli.ts +6 -167
  297. package/src/reviewdetach.ts +107 -13
  298. package/src/scancli.ts +75 -826
  299. package/src/selfimprove.ts +90 -0
  300. package/src/session-command-supervisor.ts +41 -8
  301. package/src/setup/adapters.ts +13 -221
  302. package/src/setup/caserunner.ts +34 -273
  303. package/src/setup/engine.ts +62 -496
  304. package/src/setup/facts.ts +3 -627
  305. package/src/setup/findings.ts +3 -1264
  306. package/src/setup/presentation.ts +3 -18
  307. package/src/setup/scan.ts +3 -1033
  308. package/src/setup/scanbuild.ts +20 -114
  309. package/src/setup/scanlease.ts +3 -297
  310. package/src/setup/scanreport.ts +3 -373
  311. package/src/setup/scanrun.ts +3 -2268
  312. package/src/setup/scantree.ts +3 -259
  313. package/src/setup/stage2request.ts +3 -347
  314. package/src/setup/unitcut.ts +3 -523
  315. package/src/state.ts +126 -11
  316. package/src/takeover.ts +5 -0
  317. package/src/takeoverpi.ts +3 -2
  318. package/src/tui/alerts.ts +9 -2
  319. package/src/tui/board.ts +2 -2
  320. package/src/tui/companion.ts +2 -1
  321. package/src/tui/deploytab.ts +2 -2
  322. package/src/tui/index.ts +9 -6
  323. package/src/tui/memory.ts +3 -15
  324. package/src/tui/memoryurl.ts +22 -0
  325. package/src/tui/remote.ts +1 -1
  326. package/src/tui/route.ts +2 -2
  327. package/src/tui/setupstate.ts +20 -16
  328. package/src/tui/sshtriage.ts +1 -1
  329. package/src/verdicts.ts +13 -0
  330. package/src/wire.ts +9 -4
  331. package/src/workerbreaker.ts +41 -5
  332. package/src/workerworld.ts +9 -1
  333. package/dist/src/live/projection.js +0 -450
  334. package/dist/src/live/projection.js.map +0 -1
  335. package/dist/src/live/read.js +0 -60
  336. package/dist/src/live/read.js.map +0 -1
  337. package/dist/src/onboard/launch.js +0 -75
  338. package/dist/src/onboard/launch.js.map +0 -1
  339. package/dist/src/setup/ledger.js +0 -207
  340. package/dist/src/setup/ledger.js.map +0 -1
  341. package/dist/src/setup/stage2.js +0 -617
  342. package/dist/src/setup/stage2.js.map +0 -1
  343. package/dist/src/setup/textsniff.js +0 -89
  344. package/dist/src/setup/textsniff.js.map +0 -1
  345. package/src/live/projection.ts +0 -491
  346. package/src/live/read.ts +0 -86
  347. package/src/onboard/launch.ts +0 -103
  348. package/src/setup/ledger.ts +0 -324
  349. package/src/setup/stage2.ts +0 -926
  350. package/src/setup/textsniff.ts +0 -95
@@ -1,27 +1,49 @@
1
- // OnboardingEngine — the seam of the onboarding wizard: a checklist state
2
- // machine plus deterministic step implementations behind injected adapters.
3
- // pi is a thin driver over this engine (extensions/onboarding.ts registers one
4
- // tool per step); the engine
5
- // itself is fully drivable from tests and from a non-LLM fallback entry, and
6
- // nothing in here touches the network, Docker, or a real pi except through
7
- // the adapters.
1
+ // OnboardingEngine: the onboarding wizard's checklist, the plugin's setup
2
+ // checklist (@quickchatai/pi-quickreview/src/setupchecklist.ts, the step registry
3
+ // and its engine) bound to this app's adapters and state. pi is a thin
4
+ // driver over it (extensions/onboarding.ts registers one tool per step); the
5
+ // engine itself is fully drivable from tests and from a non-LLM fallback
6
+ // entry, and nothing in here touches the network, Docker, or a real pi except
7
+ // through the adapters.
8
8
  //
9
- // Resumability contract:
10
- // - checklist state persists to state/onboarding/checklist.json after every
11
- // step, tmp+rename atomic (the verdicts.ts convention);
12
- // - startup reconciles claimed state against reality: a step claimed done is
13
- // re-verified with its verify(), never trusted from the file alone;
14
- // - a fully-done checklist re-runs as verify-only (the wizard doubles as a
15
- // diagnostic);
16
- // - a corrupt/unreadable checklist degrades to a fresh one (the resume
17
- // re-verifies everything anyway) — never a crash.
18
- //
19
- // The finish path writes the first-run marker state/companion/first-run.json
20
- // ({"completed_at": "<iso>"}) exactly once — the contract the companion's
21
- // first-run greeting consumes. Keep the path and shape stable.
9
+ // What this module adds to the core's checklist:
10
+ // - the wizard's adapter set (OnboardAdapters) and its step type;
11
+ // - where the checklist lives: state/onboarding/checklist.json, tmp+rename
12
+ // atomic, re-verified on resume (the core's resumability contract);
13
+ // - the finish path's first-run marker, state/companion/first-run.json
14
+ // ({"completed_at": "<iso>"}), written exactly once: the contract the
15
+ // companion's first-run greeting consumes. Keep the path and shape
16
+ // stable.
17
+ import {
18
+ SetupChecklist,
19
+ checklistCompleteNow,
20
+ readChecklistFile,
21
+ writeAtomic,
22
+ type ChecklistAdapters,
23
+ type ChecklistFile,
24
+ type SetupStep,
25
+ type StepContext as ChecklistStepContext,
26
+ type StepRegistry,
27
+ type VerifyAllResult,
28
+ } from "@quickchatai/pi-quickreview/src/setupchecklist.js";
22
29
  import type { StepForward, StepForwardRequest } from "./stepforward.js";
23
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
- import { basename, dirname, join, resolve } from "node:path";
30
+ import { existsSync } from "node:fs";
31
+ import { join, resolve } from "node:path";
32
+
33
+ export {
34
+ CHECKLIST_VERSION,
35
+ failures,
36
+ readTextOrNull,
37
+ writeAtomic,
38
+ type CheckResult,
39
+ type ChecklistFile,
40
+ type ReconcileReport,
41
+ type StepRecord,
42
+ type StepReport,
43
+ type StepRunResult,
44
+ type StepStatusLine,
45
+ type VerifyAllResult,
46
+ } from "@quickchatai/pi-quickreview/src/setupchecklist.js";
25
47
 
26
48
  // ---- adapters ---------------------------------------------------------------
27
49
 
@@ -41,7 +63,7 @@ export interface ProcessRunner {
41
63
  * fake every field; extensions/onboarding.ts builds the real set. Steps use
42
64
  * fetch (manifest exchange, health probes) and prompter (non-LLM fallback
43
65
  * entry) as they need them; preflight needs neither. */
44
- export interface OnboardAdapters {
66
+ export interface OnboardAdapters extends ChecklistAdapters {
45
67
  /** HTTP seam (GitHub App manifest exchange, health endpoints, …). */
46
68
  fetch: typeof fetch;
47
69
  runner: ProcessRunner;
@@ -70,101 +92,17 @@ export interface OnboardAdapters {
70
92
 
71
93
  // ---- step vocabulary --------------------------------------------------------
72
94
 
73
- export interface CheckResult {
74
- name: string;
75
- ok: boolean;
76
- detail: string;
77
- }
78
-
79
- /** What run() and verify() both return. `data` (run only) is persisted onto
80
- * the step's checklist record and handed back as ctx.data on later calls. */
81
- export interface StepReport {
82
- ok: boolean;
83
- checks: CheckResult[];
84
- data?: Record<string, unknown>;
85
- }
86
-
87
- export interface StepContext {
88
- adapters: OnboardAdapters;
89
- /** The tool call's inputs — the operator's answer to a step that asks one
90
- * (`{}` for a step that declares no `parameters`, and always on verify()).
91
- * A step that needs an answer and does not get one must return a refusal
92
- * naming the options, never a guess: the wizard offers, the operator picks. */
93
- input: Readonly<Record<string, unknown>>;
94
- /** Recorded operator choices (topology, auth route, captured uid, …). */
95
- choices: Readonly<Record<string, unknown>>;
96
- /** This step's persisted data from a previous run ({} on first run). */
97
- data: Readonly<Record<string, unknown>>;
98
- /** Record an operator choice / derived fact for later steps (persisted with
99
- * the step result). */
100
- recordChoice(key: string, value: unknown): void;
101
- }
95
+ export type StepContext = ChecklistStepContext<OnboardAdapters>;
102
96
 
103
97
  /**
104
98
  * One onboarding step (the registered set is defaultSteps in
105
- * src/onboard/steps/index.ts); the interface is the extension point:
106
- * - `id` must be tool-name-safe ([a-z][a-z0-9_]*) — the pi extension
107
- * registers each step as tool `onboard_<id>`;
108
- * - `explain` is the teach-text the guide relays before running the step;
109
- * - `enabled` gates on recorded choices (e.g. the VM route's steps are
110
- * disabled on the laptop topology); absent ⇒ always enabled;
111
- * - `run` performs the step; `verify` re-checks its outcome against reality
112
- * and must be safe to call any number of times.
99
+ * src/onboard/steps/index.ts): the core's SetupStep over the wizard's
100
+ * adapters. The pi extension registers each step as tool `onboard_<id>`.
113
101
  */
114
- export interface OnboardingStep {
115
- id: string;
116
- title: string;
117
- explain: string;
118
- /** Ascending display/registration order. */
119
- order: number;
120
- /** JSON-schema object the step's pi tool declares, for steps that need the
121
- * operator's answer (a choice, a hostname). Absent ⇒ the tool takes no
122
- * input, which is the common case. */
123
- parameters?: unknown;
124
- enabled?(choices: Readonly<Record<string, unknown>>): boolean;
125
- /** After setup: the step still runs and verifies like any other, but the
126
- * checklist completes without it — finish never waits on it. (The first
127
- * review and the go-live flip: the board offers them once setup is done.) */
128
- optional?: boolean;
129
- run(ctx: StepContext): Promise<StepReport>;
130
- verify(ctx: StepContext): Promise<StepReport>;
131
- }
132
-
133
- export function failures(checks: readonly CheckResult[]): string[] {
134
- return checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
135
- }
102
+ export type OnboardingStep = SetupStep<OnboardAdapters>;
136
103
 
137
104
  // ---- checklist file ---------------------------------------------------------
138
105
 
139
- export const CHECKLIST_VERSION = 1;
140
-
141
- export interface StepRecord {
142
- status: "pending" | "done";
143
- completed_at: string | null;
144
- /** Last successful re-verification (resume / finish). */
145
- verified_at: string | null;
146
- /** Why a claimed-done step was demoted, or other engine notes. */
147
- note: string | null;
148
- data: Record<string, unknown>;
149
- }
150
-
151
- export interface ChecklistFile {
152
- version: number;
153
- created_at: string;
154
- updated_at: string;
155
- /** Set exactly once, by finish() when every enabled step verifies green.
156
- * History — when onboarding first finished; it never moves and never clears. */
157
- completed_at: string | null;
158
- /** When a completed checklist stopped verifying (a demotion, a failed run,
159
- * a refused finish, a newly-enabled unfinished step, a step run again —
160
- * a run changes what the full verification checked); null/absent while the
161
- * completion still holds. A green finish() clears it. Whether onboarding is
162
- * complete now is `completed_at` set and this unset. */
163
- reopened_at?: string | null;
164
- choices: Record<string, unknown>;
165
- steps: Record<string, StepRecord>;
166
- }
167
-
168
106
  export function checklistPath(stateDir: string): string {
169
107
  return join(stateDir, "onboarding", "checklist.json");
170
108
  }
@@ -175,32 +113,9 @@ export function firstRunMarkerPath(stateDir: string): string {
175
113
  return join(stateDir, "companion", "first-run.json");
176
114
  }
177
115
 
178
- function isPlainObject(v: unknown): v is Record<string, unknown> {
179
- return typeof v === "object" && v !== null && !Array.isArray(v);
180
- }
181
-
182
116
  /** Read the checklist; null when absent or unreadable (both mean "fresh"). */
183
117
  export function readChecklist(stateDir: string): ChecklistFile | null {
184
- const path = checklistPath(stateDir);
185
- if (!existsSync(path)) return null;
186
- try {
187
- const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
188
- if (typeof raw !== "object" || raw === null) return null;
189
- const file = raw as ChecklistFile;
190
- // Every field the engine dereferences is checked here: a file that parses
191
- // but lacks one (hand-edited, foreign) would otherwise throw out of a
192
- // step predicate or record() on every call — a wedge, not a fresh start.
193
- if (typeof file.version !== "number") return null;
194
- if (!isPlainObject(file.choices)) return null;
195
- if (file.completed_at !== null && typeof file.completed_at !== "string") return null;
196
- if (!isPlainObject(file.steps)) return null;
197
- for (const rec of Object.values(file.steps)) {
198
- if (!isPlainObject(rec) || !isPlainObject(rec.data)) return null;
199
- }
200
- return file;
201
- } catch {
202
- return null;
203
- }
118
+ return readChecklistFile(checklistPath(stateDir));
204
119
  }
205
120
 
206
121
  /** Does the checklist file say onboarding finished? The CLI's post-exit
@@ -208,13 +123,7 @@ export function readChecklist(stateDir: string): ChecklistFile | null {
208
123
  * re-verification, so "the file says so" is a verified claim). */
209
124
  export function checklistComplete(stateDir: string): boolean {
210
125
  const file = readChecklist(stateDir);
211
- return file !== null && completeNow(file);
212
- }
213
-
214
- /** Is onboarding complete NOW: finished once, and not reopened since. The one
215
- * reading of the two fields, for the file on disk and the engine's copy. */
216
- function completeNow(file: Pick<ChecklistFile, "completed_at" | "reopened_at">): boolean {
217
- return typeof file.completed_at === "string" && !file.reopened_at;
126
+ return file !== null && checklistCompleteNow(file);
218
127
  }
219
128
 
220
129
  /** `<sandbox root>` — the parent of `paths.stateDir`, by the adapters'
@@ -223,315 +132,36 @@ export function sandboxRoot(paths: { readonly stateDir: string }): string {
223
132
  return resolve(paths.stateDir, "..");
224
133
  }
225
134
 
226
- /** tmp+rename atomic write — the one writer every onboarding file write goes
227
- * through (the checklist, config.toml, .env, the inventory): a reader, the
228
- * daemon's own loader one bind mount away included, never sees a half-written
229
- * file, and an interrupted write leaves the previous file whole. */
230
- export function writeAtomic(path: string, content: string): void {
231
- mkdirSync(dirname(path), { recursive: true });
232
- const tmp = join(dirname(path), `.${basename(path)}.onboard.tmp`);
233
- writeFileSync(tmp, content);
234
- renameSync(tmp, path);
235
- }
236
-
237
- /** A file's text, or null when it cannot be read for any reason. For a read
238
- * where only ABSENCE may be null, read it yourself. */
239
- export function readTextOrNull(path: string): string | null {
240
- try {
241
- return readFileSync(path, "utf8");
242
- } catch {
243
- return null;
244
- }
245
- }
246
-
247
135
  // ---- engine -----------------------------------------------------------------
248
136
 
249
- export interface StepStatusLine {
250
- id: string;
251
- title: string;
252
- explain: string;
253
- status: "pending" | "done" | "disabled";
254
- note: string | null;
255
- /** The step's `optional` flag: setup completes without it. */
256
- optional?: boolean;
257
- }
258
-
259
- export interface ReconcileReport {
260
- /** Claimed-done steps whose verify() held. */
261
- verified: string[];
262
- /** Claimed-done steps whose verify() failed — demoted to pending. */
263
- demoted: Array<{ id: string; failures: string[] }>;
264
- }
265
-
266
- export interface StepRunResult {
267
- id: string;
268
- ok: boolean;
269
- checks: CheckResult[];
270
- failures: string[];
271
- }
272
-
273
- export interface VerifyAllResult {
274
- ok: boolean;
275
- /** `optional` results are reported but never hold `ok` false. */
276
- results: Array<{ id: string; ok: boolean; failures: string[]; optional?: boolean }>;
277
- }
278
-
279
137
  export type FinishResult =
280
138
  | { ok: true; completed_at: string; marker_written: boolean }
281
139
  | { ok: false; results: VerifyAllResult["results"] };
282
140
 
283
- export class OnboardingEngine {
284
- private readonly registry: OnboardingStep[];
285
- private checklist: ChecklistFile;
286
-
141
+ export class OnboardingEngine extends SetupChecklist<OnboardAdapters> {
287
142
  constructor(
288
- steps: OnboardingStep[],
289
- private readonly adapters: OnboardAdapters,
143
+ steps: StepRegistry<OnboardAdapters> | readonly OnboardingStep[],
144
+ adapters: OnboardAdapters,
290
145
  ) {
291
- this.registry = [...steps].sort((a, b) => a.order - b.order);
292
- const loaded = readChecklist(adapters.paths.stateDir);
293
- if (loaded === null && existsSync(checklistPath(adapters.paths.stateDir))) {
294
- adapters.log?.(
295
- "onboarding: checklist file unreadable — starting fresh (recorded choices and step progress were reset)",
296
- );
297
- }
298
- this.checklist = loaded ?? this.fresh();
299
- }
300
-
301
- private fresh(): ChecklistFile {
302
- const now = this.adapters.clock().toISOString();
303
- return {
304
- version: CHECKLIST_VERSION,
305
- created_at: now,
306
- updated_at: now,
307
- completed_at: null,
308
- choices: {},
309
- steps: {},
310
- };
311
- }
312
-
313
- private save(): void {
314
- this.checklist.updated_at = this.adapters.clock().toISOString();
315
- writeAtomic(
316
- checklistPath(this.adapters.paths.stateDir),
317
- `${JSON.stringify(this.checklist, null, 2)}\n`,
318
- );
319
- }
320
-
321
- private record(id: string): StepRecord {
322
- const existing = this.checklist.steps[id];
323
- if (existing) return existing;
324
- const created: StepRecord = {
325
- status: "pending",
326
- completed_at: null,
327
- verified_at: null,
328
- note: null,
329
- data: {},
330
- };
331
- this.checklist.steps[id] = created;
332
- return created;
333
- }
334
-
335
- private ctxFor(id: string, input: Record<string, unknown> = {}): StepContext {
336
- return {
337
- adapters: this.adapters,
338
- input,
339
- choices: this.checklist.choices,
340
- data: this.record(id).data,
341
- recordChoice: (key, value) => {
342
- this.checklist.choices[key] = value;
343
- },
344
- };
345
- }
346
-
347
- /** The registered steps, in order (the pi tools register off this). */
348
- stepDefs(): readonly OnboardingStep[] {
349
- return this.registry;
350
- }
351
-
352
- private enabled(step: OnboardingStep): boolean {
353
- return step.enabled ? step.enabled(this.checklist.choices) : true;
354
- }
355
-
356
- choices(): Readonly<Record<string, unknown>> {
357
- return this.checklist.choices;
358
- }
359
-
360
- isComplete(): boolean {
361
- return completeNow(this.checklist);
362
- }
363
-
364
- /** A completed checklist no longer verifies: withdraw the current-completion
365
- * claim (the post-exit handoff reads it) without touching the history. */
366
- private reopen(): void {
367
- if (this.checklist.completed_at === null || this.checklist.reopened_at) return;
368
- this.checklist.reopened_at = this.adapters.clock().toISOString();
369
- }
370
-
371
- list(): StepStatusLine[] {
372
- return this.registry.map((step) => {
373
- const rec = this.checklist.steps[step.id];
374
- return {
375
- id: step.id,
376
- title: step.title,
377
- explain: step.explain,
378
- status: this.enabled(step) ? (rec?.status ?? "pending") : "disabled",
379
- note: rec?.note ?? null,
380
- ...(step.optional ? { optional: true } : {}),
381
- };
382
- });
146
+ super(steps, adapters, { file: checklistPath(adapters.paths.stateDir) });
383
147
  }
384
148
 
385
149
  /**
386
- * Resume: re-verify every enabled claimed-done step against reality. A step
387
- * whose verify() fails is demoted to pending with the failures in its note —
388
- * the file's claim is never trusted alone. A fully-done checklist therefore
389
- * re-runs as verify-only. Persists once at the end.
390
- */
391
- async reconcile(): Promise<ReconcileReport> {
392
- const report: ReconcileReport = { verified: [], demoted: [] };
393
- for (const step of this.registry) {
394
- if (!this.enabled(step)) continue;
395
- const rec = this.checklist.steps[step.id];
396
- if (!rec || rec.status !== "done") {
397
- this.reopen();
398
- continue;
399
- }
400
- const outcome = await this.safeVerify(step);
401
- if (outcome.ok) {
402
- rec.verified_at = this.adapters.clock().toISOString();
403
- report.verified.push(step.id);
404
- } else {
405
- const fails = failures(outcome.checks);
406
- rec.status = "pending";
407
- rec.note = `re-verification failed: ${fails.join("; ") || "unknown"}`;
408
- this.reopen();
409
- report.demoted.push({ id: step.id, failures: fails });
410
- }
411
- }
412
- this.save();
413
- return report;
414
- }
415
-
416
- /** Run one step, optionally with the operator's answer (`input`, the pi
417
- * tool's params). Unknown/disabled ids are refusals-as-answers, never
418
- * throws (the caller is an LLM tool belt). Persists after the step. */
419
- async runStep(id: string, input: Record<string, unknown> = {}): Promise<StepRunResult> {
420
- const step = this.registry.find((s) => s.id === id);
421
- if (!step) {
422
- return {
423
- id,
424
- ok: false,
425
- checks: [],
426
- failures: [`unknown step "${id}" — known: ${this.registry.map((s) => s.id).join(", ")}`],
427
- };
428
- }
429
- if (!this.enabled(step)) {
430
- return {
431
- id,
432
- ok: false,
433
- checks: [],
434
- failures: [`step "${id}" is disabled by the recorded choices`],
435
- };
436
- }
437
- const rec = this.record(id);
438
- rec.status = "pending";
439
- this.reopen();
440
- this.save();
441
- let report: StepReport;
442
- try {
443
- report = await step.run(this.ctxFor(id, input));
444
- } catch (err) {
445
- report = {
446
- ok: false,
447
- checks: [{ name: id, ok: false, detail: `step threw: ${String(err)}` }],
448
- };
449
- }
450
- if (report.data) rec.data = { ...rec.data, ...report.data };
451
- if (report.ok) {
452
- rec.status = "done";
453
- rec.completed_at = this.adapters.clock().toISOString();
454
- rec.verified_at = null;
455
- rec.note = null;
456
- // A run acts on the installation (choices, config, deploys) after the
457
- // full verification that backed the completion claim: only the next
458
- // green finish() may make that claim again.
459
- this.reopen();
460
- } else {
461
- rec.status = "pending";
462
- rec.note = `last run failed: ${failures(report.checks).join("; ") || "unknown"}`;
463
- this.reopen();
464
- }
465
- this.save();
466
- return { id, ok: report.ok, checks: report.checks, failures: failures(report.checks) };
467
- }
468
-
469
- private async safeVerify(step: OnboardingStep): Promise<StepReport> {
470
- try {
471
- return await step.verify(this.ctxFor(step.id));
472
- } catch (err) {
473
- return {
474
- ok: false,
475
- checks: [{ name: step.id, ok: false, detail: `verify threw: ${String(err)}` }],
476
- };
477
- }
478
- }
479
-
480
- /**
481
- * Verify every enabled step — done or not. A pending step whose verify()
482
- * holds is promoted to done (its outcome exists in reality, which is the
483
- * only truth the checklist mirrors); a done step whose verify() fails
484
- * reports red here (finish refuses) but demotion is reconcile()'s job.
485
- */
486
- async verifyAll(): Promise<VerifyAllResult> {
487
- const results: VerifyAllResult["results"] = [];
488
- for (const step of this.registry) {
489
- if (!this.enabled(step)) continue;
490
- const outcome = await this.safeVerify(step);
491
- const rec = this.record(step.id);
492
- if (outcome.ok) {
493
- rec.verified_at = this.adapters.clock().toISOString();
494
- if (rec.status !== "done") {
495
- rec.status = "done";
496
- rec.completed_at = rec.completed_at ?? rec.verified_at;
497
- rec.note = "verified green without a recorded run";
498
- }
499
- }
500
- results.push({
501
- id: step.id,
502
- ok: outcome.ok,
503
- failures: failures(outcome.checks),
504
- ...(step.optional ? { optional: true } : {}),
505
- });
506
- }
507
- this.save();
508
- return { ok: results.every((r) => r.ok || r.optional === true), results };
509
- }
510
-
511
- /**
512
- * The finish tool's engine half: re-run the full verification checklist.
513
- * Anything red ⇒ refuse with the failures (the caller returns them as the
514
- * tool result and does not exit). All green ⇒ mark the checklist complete
515
- * and write the first-run marker — once: a marker already on disk is left
150
+ * The finish tool's engine half: the checklist's full verification. Anything
151
+ * red ⇒ refuse with the failures (the caller returns them as the tool
152
+ * result and does not exit). All green ⇒ the checklist is complete, and the
153
+ * first-run marker is written — once: a marker already on disk is left
516
154
  * untouched, so the completed_at the companion greets on never moves.
517
155
  */
518
- async finish(): Promise<FinishResult> {
519
- const all = await this.verifyAll();
520
- if (!all.ok) {
521
- this.reopen();
522
- this.save();
523
- return { ok: false, results: all.results };
524
- }
525
- const completedAt = this.checklist.completed_at ?? this.adapters.clock().toISOString();
526
- this.checklist.completed_at = completedAt;
527
- this.checklist.reopened_at = null;
528
- this.save();
156
+ override async finish(): Promise<FinishResult> {
157
+ const done = await super.finish();
158
+ if (!done.ok) return done;
529
159
  const marker = firstRunMarkerPath(this.adapters.paths.stateDir);
530
160
  let markerWritten = false;
531
161
  if (!existsSync(marker)) {
532
- writeAtomic(marker, `${JSON.stringify({ completed_at: completedAt })}\n`);
162
+ writeAtomic(marker, `${JSON.stringify({ completed_at: done.completed_at })}\n`);
533
163
  markerWritten = true;
534
164
  }
535
- return { ok: true, completed_at: completedAt, marker_written: markerWritten };
165
+ return { ok: true, completed_at: done.completed_at, marker_written: markerWritten };
536
166
  }
537
167
  }
@@ -80,13 +80,14 @@ import {
80
80
  embedderInputsCheck,
81
81
  localEmbedderEnvEntries,
82
82
  } from "./embeddings.js";
83
+ import { CHOICE_KEYS } from "../choices.js";
83
84
  import { readEnvKey, readTomlKey, setEnvKey } from "./ingress.js";
84
85
  import { UID_CHOICE_KEY } from "./preflight.js";
85
86
  import { topologyOf } from "./topology.js";
86
87
 
87
88
  export const BRINGUP_STEP_ID = "bringup";
88
89
  /** Recorded timestamp of this step's successful bring-up. */
89
- export const BROUGHT_UP_CHOICE_KEY = "stack_brought_up_at";
90
+ export const BROUGHT_UP_CHOICE_KEY = CHOICE_KEYS.broughtUpAt;
90
91
 
91
92
  /**
92
93
  * The note, in the operator's words, emitted before the pull begins.
@@ -442,7 +443,7 @@ export function bringUpStep(opts: BringUpStepOptions = {}): OnboardingStep {
442
443
  }),
443
444
  );
444
445
  const ok = checks.every((c) => c.ok);
445
- if (ok) ctx.recordChoice(BROUGHT_UP_CHOICE_KEY, ctx.adapters.clock().toISOString());
446
+ if (ok) ctx.recordChoice(CHOICE_KEYS.broughtUpAt, ctx.adapters.clock().toISOString());
446
447
  return { ok, checks, data: { profile, up_at: ctx.adapters.clock().toISOString() } };
447
448
  },
448
449
 
@@ -121,9 +121,12 @@ function escapeRe(s: string): string {
121
121
  * marker in place — the EXPLANATION after it is the part worth keeping. */
122
122
  export function rewriteFillMarker(comment: string): string {
123
123
  if (!/←\s*FILL/.test(comment)) return comment;
124
- return comment.replace(/←\s*FILL(\s*\(\d+\))?\s*:?\s*/, "filled by qcr onboard: ");
124
+ return comment.replace(/←\s*FILL(\s*\(\d+\))?\s*:?\s*/, "filled by qcr: ");
125
125
  }
126
126
 
127
+ /** A line without the CR of its CRLF ending. */
128
+ const bare = (line: string): string => line.replace(/\r$/, "");
129
+
127
130
  function sectionAt(lines: string[]): string[] {
128
131
  const out: string[] = [];
129
132
  let cur = "";
@@ -135,10 +138,12 @@ function sectionAt(lines: string[]): string[] {
135
138
  if (splitTail(continuation) !== null) continuation = null;
136
139
  continue;
137
140
  }
138
- const m = HEADER_RE.exec(line);
141
+ const m = HEADER_RE.exec(bare(line));
139
142
  if (m) cur = m[1]!.trim();
140
143
  out.push(cur);
141
- const assignment = /^\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(line);
144
+ const assignment = /^\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(
145
+ bare(line),
146
+ );
142
147
  if (assignment && splitTail(assignment[1]!) === null) continuation = assignment[1]!;
143
148
  }
144
149
  return out;
@@ -149,9 +154,18 @@ function sectionAt(lines: string[]): string[] {
149
154
  * key order. An existing assignment has only its VALUE replaced (its trailing
150
155
  * comment survives, with a spent `← FILL` marker rewritten); a commented-out
151
156
  * assignment is uncommented in place; a key absent from its section is appended
152
- * to the end of that section; a missing section is appended at EOF.
157
+ * to the end of that section; a missing section is appended at EOF. Line
158
+ * endings survive too: a CRLF file is patched as LF and given its CRs back, so
159
+ * an edited or added line ends the way every other line does, and in a file of
160
+ * mixed endings the edited line keeps its own.
153
161
  */
154
162
  export function patchToml(text: string, edits: readonly TomlEdit[]): string {
163
+ if (text.includes("\r\n") && !/(^|[^\r])\n/.test(text))
164
+ return patchLines(text.replace(/\r\n/g, "\n"), edits).replace(/\n/g, "\r\n");
165
+ return patchLines(text, edits);
166
+ }
167
+
168
+ function patchLines(text: string, edits: readonly TomlEdit[]): string {
155
169
  const lines = text.split("\n");
156
170
  for (const edit of edits) {
157
171
  const sections = sectionAt(lines);
@@ -162,7 +176,7 @@ export function patchToml(text: string, edits: readonly TomlEdit[]): string {
162
176
  for (const re of [live, commented]) {
163
177
  for (let i = 0; i < lines.length && !applied; i++) {
164
178
  if (sections[i] !== edit.section) continue;
165
- const m = re.exec(lines[i]!);
179
+ const m = re.exec(bare(lines[i]!));
166
180
  if (!m) continue;
167
181
  try {
168
182
  if (!Object.hasOwn(parseToml(`${m[2]} = 0`), edit.key)) continue;
@@ -171,11 +185,13 @@ export function patchToml(text: string, edits: readonly TomlEdit[]): string {
171
185
  }
172
186
  let end = i;
173
187
  let tail = m[4]!;
174
- while (splitTail(tail) === null && end + 1 < lines.length) tail += "\n" + lines[++end]!;
188
+ while (splitTail(tail) === null && end + 1 < lines.length)
189
+ tail += "\n" + bare(lines[++end]!);
175
190
  const parts = splitTail(tail);
176
191
  if (!parts) throw new Error(`cannot patch unterminated TOML assignment ${edit.key}`);
177
192
  const comment = parts.comment ? `${parts.gap}${rewriteFillMarker(parts.comment)}` : "";
178
- lines.splice(i, end - i + 1, `${m[1]}${m[2]}${m[3]}${edit.value}${comment}`);
193
+ const cr = lines[end]!.endsWith("\r") ? "\r" : "";
194
+ lines.splice(i, end - i + 1, `${m[1]}${m[2]}${m[3]}${edit.value}${comment}${cr}`);
179
195
  applied = true;
180
196
  }
181
197
  if (applied) break;