@starlove/orbit 1.4.0 → 2.0.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 (329) hide show
  1. package/Dockerfile +10 -8
  2. package/README.md +146 -164
  3. package/dist/full/csp.txt +1 -0
  4. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
  5. package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
  6. package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
  7. package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
  8. package/dist/full/fonts/Mona_Sans/README.txt +10 -0
  9. package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
  10. package/dist/full/fonts/Newsreader/README.txt +8 -0
  11. package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
  12. package/dist/full/index.html +36 -653
  13. package/dist/full/js/app.BmuponPJ.js +32 -0
  14. package/dist/full/js/app.BmuponPJ.js.map +1 -0
  15. package/dist/full/js/chunk.1fJGQGbW.js +2 -0
  16. package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
  17. package/dist/full/js/chunk.5D7JO2zp.js +2 -0
  18. package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
  19. package/dist/full/js/chunk.BrNn_D79.js +2 -0
  20. package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
  21. package/dist/full/js/chunk.CokJXIqM.js +2 -0
  22. package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
  23. package/dist/full/js/chunk.D_Haqy6R.js +2 -0
  24. package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
  25. package/dist/full/js/chunk.RO0jsH6I.js +15 -0
  26. package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
  27. package/dist/full/js/chunk.dqWOEtBM.js +2 -0
  28. package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
  29. package/dist/full/js/warm.f260bbbc.js +15 -0
  30. package/dist/full/orbits-favicon.png +0 -0
  31. package/dist/full/orbits-logo-bg-wide.png +0 -0
  32. package/dist/full/orbits-logo-bg.png +0 -0
  33. package/dist/full/orbits-logo.png +0 -0
  34. package/dist/full/styles.2b154ffe.css +1 -0
  35. package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
  36. package/dist/full/version.json +2 -2
  37. package/docs/AGENT_GUIDE.md +114 -0
  38. package/docs/UPDATING.md +249 -64
  39. package/lib/adapters/indexeddb/blobs.d.ts +3 -0
  40. package/lib/adapters/indexeddb/blobs.js +54 -0
  41. package/lib/adapters/indexeddb/database.d.ts +60 -0
  42. package/lib/adapters/indexeddb/database.js +126 -0
  43. package/lib/adapters/indexeddb/index.d.ts +5 -0
  44. package/lib/adapters/indexeddb/index.js +3 -0
  45. package/lib/adapters/indexeddb/locks.d.ts +14 -0
  46. package/lib/adapters/indexeddb/locks.js +40 -0
  47. package/lib/adapters/indexeddb/store.d.ts +40 -0
  48. package/lib/adapters/indexeddb/store.js +424 -0
  49. package/lib/adapters/indexeddb/tables.d.ts +43 -0
  50. package/lib/adapters/indexeddb/tables.js +127 -0
  51. package/lib/adapters/memory/blobs.d.ts +3 -0
  52. package/lib/adapters/memory/blobs.js +44 -0
  53. package/lib/adapters/memory/board.d.ts +41 -0
  54. package/lib/adapters/memory/board.js +521 -0
  55. package/lib/adapters/memory/index.d.ts +2 -0
  56. package/lib/adapters/memory/index.js +2 -0
  57. package/lib/adapters/memory/search.d.ts +6 -0
  58. package/lib/adapters/memory/search.js +30 -0
  59. package/lib/adapters/memory/store.d.ts +34 -0
  60. package/lib/adapters/memory/store.js +266 -0
  61. package/lib/adapters/sqlite/backups.d.ts +53 -0
  62. package/lib/adapters/sqlite/backups.js +232 -0
  63. package/lib/adapters/sqlite/blobs.d.ts +14 -0
  64. package/lib/adapters/sqlite/blobs.js +109 -0
  65. package/lib/adapters/sqlite/board.d.ts +77 -0
  66. package/lib/adapters/sqlite/board.js +328 -0
  67. package/lib/adapters/sqlite/database.d.ts +67 -0
  68. package/lib/adapters/sqlite/database.js +285 -0
  69. package/lib/adapters/sqlite/index.d.ts +3 -0
  70. package/lib/adapters/sqlite/index.js +2 -0
  71. package/lib/adapters/sqlite/lock.d.ts +11 -0
  72. package/lib/adapters/sqlite/lock.js +34 -0
  73. package/lib/adapters/sqlite/records.d.ts +54 -0
  74. package/lib/adapters/sqlite/records.js +546 -0
  75. package/lib/adapters/sqlite/schema.d.ts +54 -0
  76. package/lib/adapters/sqlite/schema.js +276 -0
  77. package/lib/adapters/sqlite/store.d.ts +39 -0
  78. package/lib/adapters/sqlite/store.js +385 -0
  79. package/lib/browser/in-page.d.ts +31 -0
  80. package/lib/browser/in-page.js +46 -0
  81. package/lib/browser/old-preview.d.ts +13 -0
  82. package/lib/browser/old-preview.js +80 -0
  83. package/lib/browser/preview-board.d.ts +8 -0
  84. package/lib/browser/preview-board.js +69 -0
  85. package/lib/cli/main.d.ts +7 -0
  86. package/lib/cli/main.js +343 -0
  87. package/lib/cli/orbit.d.ts +8 -0
  88. package/lib/cli/orbit.js +25 -0
  89. package/lib/client/http.d.ts +38 -0
  90. package/lib/client/http.js +334 -0
  91. package/lib/client/local.d.ts +26 -0
  92. package/lib/client/local.js +190 -0
  93. package/lib/client/object-url.d.ts +8 -0
  94. package/lib/client/object-url.js +21 -0
  95. package/lib/client/types.d.ts +268 -0
  96. package/lib/client/types.js +8 -0
  97. package/lib/commands/agent-tools.d.ts +31 -0
  98. package/lib/commands/agent-tools.js +100 -0
  99. package/lib/commands/index.d.ts +56 -0
  100. package/lib/commands/index.js +298 -0
  101. package/lib/commands/parse.d.ts +73 -0
  102. package/lib/commands/parse.js +231 -0
  103. package/lib/commands/present.d.ts +134 -0
  104. package/lib/commands/present.js +198 -0
  105. package/lib/commands/session.d.ts +85 -0
  106. package/lib/commands/session.js +138 -0
  107. package/lib/commands/verbs.d.ts +35 -0
  108. package/lib/commands/verbs.js +842 -0
  109. package/lib/core/actors.d.ts +18 -0
  110. package/lib/core/actors.js +45 -0
  111. package/lib/core/agent.d.ts +128 -0
  112. package/lib/core/agent.js +323 -0
  113. package/lib/core/boards.d.ts +113 -0
  114. package/lib/core/boards.js +288 -0
  115. package/lib/core/bootstrap.d.ts +47 -0
  116. package/lib/core/bootstrap.js +100 -0
  117. package/lib/core/bytes.d.ts +10 -0
  118. package/lib/core/bytes.js +52 -0
  119. package/lib/core/card-paths.d.ts +25 -0
  120. package/lib/core/card-paths.js +44 -0
  121. package/lib/core/context.d.ts +60 -0
  122. package/lib/core/context.js +42 -0
  123. package/lib/core/cross-board.d.ts +81 -0
  124. package/lib/core/cross-board.js +228 -0
  125. package/lib/core/describe.d.ts +21 -0
  126. package/lib/core/describe.js +81 -0
  127. package/lib/core/errors.d.ts +159 -0
  128. package/lib/core/errors.js +160 -0
  129. package/lib/core/files.d.ts +79 -0
  130. package/lib/core/files.js +262 -0
  131. package/lib/core/history.d.ts +20 -0
  132. package/lib/core/history.js +53 -0
  133. package/lib/core/image-types.d.ts +16 -0
  134. package/lib/core/image-types.js +36 -0
  135. package/lib/core/model.d.ts +263 -0
  136. package/lib/core/model.js +34 -0
  137. package/lib/core/nebula-link.d.ts +14 -0
  138. package/lib/core/nebula-link.js +39 -0
  139. package/lib/core/relations.d.ts +58 -0
  140. package/lib/core/relations.js +112 -0
  141. package/lib/core/search.d.ts +38 -0
  142. package/lib/core/search.js +92 -0
  143. package/lib/core/sessions.d.ts +44 -0
  144. package/lib/core/sessions.js +158 -0
  145. package/lib/core/snapshot-schema.d.ts +295 -0
  146. package/lib/core/snapshot-schema.js +82 -0
  147. package/lib/core/snapshots.d.ts +75 -0
  148. package/lib/core/snapshots.js +409 -0
  149. package/lib/core/states.d.ts +25 -0
  150. package/lib/core/states.js +126 -0
  151. package/lib/core/text.d.ts +40 -0
  152. package/lib/core/text.js +113 -0
  153. package/lib/core/tickets.d.ts +69 -0
  154. package/lib/core/tickets.js +311 -0
  155. package/lib/core/trello.d.ts +17 -0
  156. package/lib/core/trello.js +269 -0
  157. package/lib/core/verdicts.d.ts +28 -0
  158. package/lib/core/verdicts.js +92 -0
  159. package/lib/core/views.d.ts +95 -0
  160. package/lib/core/views.js +186 -0
  161. package/lib/http/app.d.ts +46 -0
  162. package/lib/http/app.js +491 -0
  163. package/lib/mcp-server.d.ts +10 -0
  164. package/lib/mcp-server.js +120 -0
  165. package/lib/node/assets.d.ts +7 -0
  166. package/lib/node/assets.js +68 -0
  167. package/lib/node/data-folder.d.ts +21 -0
  168. package/lib/node/data-folder.js +34 -0
  169. package/lib/node/env.d.ts +38 -0
  170. package/lib/node/env.js +49 -0
  171. package/lib/node/host-check.d.ts +24 -0
  172. package/lib/node/host-check.js +53 -0
  173. package/lib/node/host-services.d.ts +9 -0
  174. package/lib/node/host-services.js +150 -0
  175. package/lib/node/local.d.ts +22 -0
  176. package/lib/node/local.js +61 -0
  177. package/lib/node/migrate.d.ts +53 -0
  178. package/lib/node/migrate.js +635 -0
  179. package/lib/node/package.d.ts +7 -0
  180. package/lib/node/package.js +14 -0
  181. package/lib/node/repo-board.d.ts +49 -0
  182. package/lib/node/repo-board.js +245 -0
  183. package/lib/node/serve.d.ts +8 -0
  184. package/lib/node/serve.js +106 -0
  185. package/lib/orbit.d.ts +47 -0
  186. package/lib/orbit.js +44 -0
  187. package/lib/quiet-sqlite-warning.d.ts +1 -0
  188. package/lib/quiet-sqlite-warning.js +18 -0
  189. package/lib/store/blob-path.d.ts +6 -0
  190. package/lib/store/blob-path.js +12 -0
  191. package/lib/store/conformance.d.ts +52 -0
  192. package/lib/store/conformance.js +1449 -0
  193. package/lib/store/search-words.d.ts +10 -0
  194. package/lib/store/search-words.js +19 -0
  195. package/lib/store/types.d.ts +452 -0
  196. package/lib/store/types.js +34 -0
  197. package/llms.txt +63 -0
  198. package/package.json +109 -25
  199. package/src/cli/orbit.js +11 -658
  200. package/src/mcp-server.js +16 -876
  201. package/SKILL-ORBIT.md +0 -151
  202. package/build.mjs +0 -210
  203. package/dist/full/js/app.62905fcb.js +0 -952
  204. package/dist/full/js/app.62905fcb.js.map +0 -7
  205. package/dist/full/styles.4e60d824.css +0 -1
  206. package/docs/AGENT_PROTOCOL.md +0 -424
  207. package/docs/MCP_SETUP.md +0 -54
  208. package/docs/ORBIT_DISPATCH.md +0 -280
  209. package/docs/SS-Orbit-Dark.png +0 -0
  210. package/docs/SS-Orbit-Light.png +0 -0
  211. package/docs/mcp-client.example.json +0 -13
  212. package/docs/setup/orbit-init-wizard.md +0 -174
  213. package/public/apple-touch-icon.png +0 -0
  214. package/public/cursors/live_arrow.cur +0 -0
  215. package/public/cursors/live_link.cur +0 -0
  216. package/public/cursors/live_move.cur +0 -0
  217. package/public/cursors/live_select.cur +0 -0
  218. package/public/cursors/live_unavail.cur +0 -0
  219. package/public/favicon-32x32.png +0 -0
  220. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  221. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  222. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  223. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  224. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  225. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  226. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  227. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  228. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  229. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  230. package/public/fonts/Chakra_Petch/OFL.txt +0 -93
  231. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  232. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  233. package/public/fonts/Inter/OFL.txt +0 -93
  234. package/public/fonts/Inter/README.txt +0 -118
  235. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  236. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  237. package/public/fonts/JetBrains_Mono/README.txt +0 -79
  238. package/public/fonts/OpenRing-Regular.ttf +0 -0
  239. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  240. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  241. package/public/fonts/Open_Sans/README.txt +0 -100
  242. package/public/fonts/Orbitron/OFL.txt +0 -93
  243. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  244. package/public/fonts/Orbitron/README.txt +0 -68
  245. package/public/fonts/Rajdhani/OFL.txt +0 -93
  246. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  247. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  248. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  249. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  250. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  251. package/public/fonts/runescape-uf.ttf +0 -0
  252. package/public/index.html +0 -769
  253. package/public/js/actor-labels.js +0 -65
  254. package/public/js/api.js +0 -39
  255. package/public/js/app.js +0 -45
  256. package/public/js/artifact-drawer.js +0 -79
  257. package/public/js/board-export.js +0 -161
  258. package/public/js/board-menu.js +0 -374
  259. package/public/js/card-actions.js +0 -545
  260. package/public/js/config.js +0 -20
  261. package/public/js/create-card.js +0 -190
  262. package/public/js/dom.js +0 -19
  263. package/public/js/drawer.js +0 -273
  264. package/public/js/font-preference.js +0 -229
  265. package/public/js/format.js +0 -433
  266. package/public/js/help-menu.js +0 -31
  267. package/public/js/icons.js +0 -16
  268. package/public/js/kanban.js +0 -694
  269. package/public/js/local-backend.js +0 -1015
  270. package/public/js/main.js +0 -161
  271. package/public/js/modal.js +0 -33
  272. package/public/js/motion-preference.js +0 -64
  273. package/public/js/panel.js +0 -58
  274. package/public/js/router.js +0 -134
  275. package/public/js/search.js +0 -56
  276. package/public/js/settings-tabs.js +0 -7
  277. package/public/js/settings.js +0 -1634
  278. package/public/js/sse.js +0 -159
  279. package/public/js/starscape.js +0 -332
  280. package/public/js/state.js +0 -129
  281. package/public/js/text-editing.js +0 -128
  282. package/public/js/theme-preference.js +0 -95
  283. package/public/js/ticket-detail.js +0 -1516
  284. package/public/js/ticket-typeahead.js +0 -49
  285. package/public/js/toast.js +0 -89
  286. package/public/js/topbar-notes.js +0 -155
  287. package/public/js/transport.js +0 -22
  288. package/public/js/typeahead.js +0 -206
  289. package/public/js/unread.js +0 -121
  290. package/public/js/update-check.js +0 -58
  291. package/public/js/url-routes.js +0 -94
  292. package/public/orbits-favicon.png +0 -0
  293. package/public/orbits-logo-bg-wide.png +0 -0
  294. package/public/orbits-logo-bg.png +0 -0
  295. package/public/orbits-logo.png +0 -0
  296. package/public/styles.css +0 -7709
  297. package/src/core/agent.js +0 -387
  298. package/src/core/agents-md.js +0 -109
  299. package/src/core/artifacts.js +0 -149
  300. package/src/core/attachments.js +0 -238
  301. package/src/core/auth.js +0 -85
  302. package/src/core/backups.js +0 -142
  303. package/src/core/board.js +0 -148
  304. package/src/core/boards.js +0 -409
  305. package/src/core/bootstrap.js +0 -170
  306. package/src/core/css-bundle.js +0 -30
  307. package/src/core/db.js +0 -315
  308. package/src/core/delete-board.js +0 -163
  309. package/src/core/dispatch.js +0 -1007
  310. package/src/core/events.js +0 -314
  311. package/src/core/http.js +0 -115
  312. package/src/core/migrate.js +0 -198
  313. package/src/core/paths.js +0 -55
  314. package/src/core/provision-repo-board.js +0 -119
  315. package/src/core/queries.js +0 -246
  316. package/src/core/registry.js +0 -211
  317. package/src/core/relations.js +0 -163
  318. package/src/core/review-verdicts.js +0 -138
  319. package/src/core/router.js +0 -556
  320. package/src/core/search.js +0 -183
  321. package/src/core/seed.js +0 -234
  322. package/src/core/snapshots.js +0 -692
  323. package/src/core/states.js +0 -167
  324. package/src/core/system-picker.js +0 -109
  325. package/src/core/tickets.js +0 -400
  326. package/src/core/util.js +0 -106
  327. package/src/core/version-check.js +0 -154
  328. package/src/mcp/orbit-client.js +0 -287
  329. package/src/server.js +0 -115
@@ -0,0 +1,635 @@
1
+ /**
2
+ * `orbit migrate`: an older Orbit's boards (1.x, or 2.0 before its rebuild)
3
+ * brought into a new data folder of this one. The source is a data folder or
4
+ * board exports; it is only ever read: its files are copied to a temporary
5
+ * folder first and opened there, so not even SQLite's own files beside them
6
+ * change. Each board keeps its id, its slug and every record's id, and is
7
+ * checked against its source after it is written: the counts, the ids and a
8
+ * hash of each kind of record, its images' and markdown files' bytes too.
9
+ * What can't come (a link to a file outside Orbit's folder, a board whose
10
+ * file is gone) is said in the report, never dropped quietly. The new folder
11
+ * is built beside its final name and moved into place once every board is
12
+ * in, so a run that stops leaves nothing half made under that name; a second
13
+ * run finds the boards there and changes nothing, and a folder holding other
14
+ * boards is refused.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
+ import { DatabaseSync } from "node:sqlite";
21
+ import { openSqliteStore } from "../adapters/sqlite/index.js";
22
+ import { checkFile } from "../adapters/sqlite/database.js";
23
+ import { localOwnerActor } from "../core/actors.js";
24
+ import { ARTIFACTS, cardLinkFromElsewhere } from "../core/card-paths.js";
25
+ import { onBoard } from "../core/context.js";
26
+ import { OrbitError, isOrbitError } from "../core/errors.js";
27
+ import { parseNebulaUrl } from "../core/nebula-link.js";
28
+ import { EXPORT_FORMAT, OLD_PREVIEW_FORMAT, exportBoard, fromOldPreview, importKeepingBoard, keptLabelColor } from "../core/snapshots.js";
29
+ import { storedImageType } from "../core/files.js";
30
+ import { cleanName, isSlug, normalizeEntryType, normalizePriority, normalizeTicketType, slugify } from "../core/text.js";
31
+ import { createOrbit } from "../orbit.js";
32
+ const KINDS = ["states", "labels", "tickets", "comments", "review_verdicts", "relations", "attachments", "board_entries", "events"];
33
+ const text = (value) => (typeof value === "string" ? value : typeof value === "number" || typeof value === "bigint" || typeof value === "boolean" ? String(value) : "");
34
+ // ------------------------------------------------------------------ reading a folder
35
+ /**
36
+ * The real path of `path` when it is a plain file inside `root` (both read
37
+ * through any links), else null: a symlink, a folder or a device is never
38
+ * read, wherever it points.
39
+ */
40
+ function plainFileInside(root, path) {
41
+ try {
42
+ if (!lstatSync(path).isFile())
43
+ return null;
44
+ const real = realpathSync(path);
45
+ return inside(realpathSync(root), real) !== null ? real : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Copies a database and the journal files beside it to `into` as
53
+ * `board.db`, and opens the copy. Each must be a plain file inside `root`;
54
+ * null when the database isn't, or a journal beside it is something else.
55
+ */
56
+ function openCopy(root, path, into) {
57
+ if (!plainFileInside(root, path))
58
+ return null;
59
+ const journals = ["-wal", "-shm"].filter((suffix) => existsOrLink(path + suffix));
60
+ if (journals.some((suffix) => !plainFileInside(root, path + suffix)))
61
+ return null;
62
+ mkdirSync(into, { recursive: true });
63
+ for (const suffix of ["", ...journals])
64
+ copyFileSync(path + suffix, join(into, `board.db${suffix}`));
65
+ return new DatabaseSync(join(into, "board.db"));
66
+ }
67
+ /**
68
+ * A 1.x board kept in a repo, as 1.x's registry can name one: a plain file
69
+ * (`lstat`) named `board.db`, judged by its real path, directly in a folder
70
+ * named `.orbit` (`<repo>/.orbit/board.db`), or, for a repo's later boards
71
+ * in 1.0, in `<repo>/.orbit/boards/<slug>/`. The `.orbit` folder's real
72
+ * path, which then stands in for the old folder for this board's files, and
73
+ * the file's real path; or null. A registry can name any path, but this way
74
+ * it can only bring another of the same person's boards.
75
+ */
76
+ function repoBoard(path) {
77
+ try {
78
+ if (!lstatSync(path).isFile())
79
+ return null;
80
+ const real = realpathSync(path);
81
+ if (basename(real) !== "board.db")
82
+ return null;
83
+ const folder = dirname(real);
84
+ if (basename(folder) === ".orbit")
85
+ return { root: folder, path: real };
86
+ const orbitFolder = dirname(dirname(folder));
87
+ const later = isSlug(basename(folder)) && basename(dirname(folder)) === "boards" && basename(orbitFolder) === ".orbit";
88
+ return later ? { root: orbitFolder, path: real } : null;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /** True when anything is at `path`, a dangling link included. */
95
+ function existsOrLink(path) {
96
+ try {
97
+ lstatSync(path);
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ function tableRows(db, table) {
105
+ const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
106
+ return exists ? db.prepare(`SELECT * FROM ${table}`).all() : [];
107
+ }
108
+ /** Every plain file under `dir`, by its path inside it, and how many entries weren't plain files or folders (links). */
109
+ function filesUnder(dir) {
110
+ const out = new Map();
111
+ let refused = 0;
112
+ if (!existsSync(dir) || !lstatSync(dir).isDirectory())
113
+ return { files: out, refused: existsOrLink(dir) ? 1 : 0 };
114
+ const walk = (at) => {
115
+ for (const entry of readdirSync(at, { withFileTypes: true })) {
116
+ const path = join(at, entry.name);
117
+ if (entry.isDirectory())
118
+ walk(path);
119
+ else if (entry.isFile())
120
+ out.set(relative(dir, path).split(sep).join("/"), readFileSync(path));
121
+ else
122
+ refused += 1;
123
+ }
124
+ };
125
+ walk(dir);
126
+ return { files: out, refused };
127
+ }
128
+ const inside = (root, path) => {
129
+ const rel = relative(root, path);
130
+ return rel && !rel.startsWith("..") && !rel.startsWith(sep) && rel !== path ? rel.split(sep).join("/") : null;
131
+ };
132
+ /**
133
+ * A card's link to markdown, as v2 keeps it: relative to the board's
134
+ * artifacts. A full path into the old board's artifacts keeps its part
135
+ * inside them; one into the old folder's dispatch runs (1.x) brings that
136
+ * file along under `dispatch-runs/`; anything else names a file this Orbit
137
+ * can't reach, so the link is left empty and said.
138
+ */
139
+ function linkFor(source, oldData, boardDir, card, field) {
140
+ const value = text(card[field]);
141
+ if (!value || !/^([a-z]:)?[\\/]/i.test(value))
142
+ return;
143
+ const path = value.replace(/\\/g, "/");
144
+ const label = field === "plan_artifact_path" ? "plan link" : "work summary link";
145
+ const inArtifacts = boardDir ? inside(join(boardDir, "artifacts"), path) : null;
146
+ if (inArtifacts) {
147
+ card[field] = inArtifacts;
148
+ return;
149
+ }
150
+ const inRuns = oldData ? inside(join(oldData, "dispatch-runs"), path) : null;
151
+ const real = oldData && inRuns ? plainFileInside(oldData, path) : null;
152
+ if (inRuns && existsOrLink(path) && !real)
153
+ source.refused += 1;
154
+ if (inRuns && real) {
155
+ const kept = `dispatch-runs/${inRuns}`;
156
+ source.files.set(kept, readFileSync(real));
157
+ card[field] = kept;
158
+ return;
159
+ }
160
+ const fromExport = boardDir === null ? cardLinkFromElsewhere(value) : null;
161
+ if (fromExport) {
162
+ card[field] = fromExport;
163
+ return;
164
+ }
165
+ card[field] = "";
166
+ source.notes.push(`#${text(card.number)}'s ${label} named a file ${boardDir === null ? "the export doesn't carry" : "outside Orbit's folder"} (${value}); it is left empty.`);
167
+ }
168
+ /**
169
+ * Why an image's file wasn't read, in plain words, or null when it is just
170
+ * missing. `place` names the folder the board was read against (the old
171
+ * folder, or a repo board's `.orbit` folder).
172
+ */
173
+ function imageRefusal(stored, path, escapes, place) {
174
+ if (escapes)
175
+ return `names a path outside its board's attachments (${stored})`;
176
+ let entry;
177
+ try {
178
+ entry = lstatSync(path);
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ if (entry.isSymbolicLink())
184
+ return "is a link, not a plain file";
185
+ if (!entry.isFile())
186
+ return "isn't a plain file";
187
+ return `is reached through a link out of ${place}`;
188
+ }
189
+ /** One board's database (a copy of it), read as an export. */
190
+ function readBoardFile(db, files, root, attachmentsDir, place) {
191
+ const board = tableRows(db, "boards")[0] ?? {};
192
+ const labelsOf = new Map();
193
+ const labels = tableRows(db, "labels");
194
+ const labelById = new Map(labels.map((label) => [text(label.id), label]));
195
+ for (const link of tableRows(db, "ticket_labels")) {
196
+ const label = labelById.get(text(link.label_id));
197
+ if (label)
198
+ labelsOf.set(text(link.ticket_id), [...(labelsOf.get(text(link.ticket_id)) ?? []), label]);
199
+ }
200
+ const missingImages = [];
201
+ const refusedImages = [];
202
+ const attachments = tableRows(db, "ticket_attachments").map((row) => {
203
+ const stored = text(row.stored_path);
204
+ const path = join(attachmentsDir, stored);
205
+ const escapes = inside(attachmentsDir, path) === null;
206
+ const real = escapes ? null : plainFileInside(root, path);
207
+ const name = text(row.original_name) || text(row.id);
208
+ const refusal = real ? null : imageRefusal(stored, path, escapes, place);
209
+ if (refusal)
210
+ refusedImages.push(`The image ${name} ${refusal}, so it wasn't read; its card has no copy of it.`);
211
+ else if (!real)
212
+ missingImages.push(`The image ${name} wasn't in ${place}; its card has no copy of it.`);
213
+ return { ...row, data_base64: real ? readFileSync(real).toString("base64") : null, included: Boolean(real) };
214
+ });
215
+ const snapshot = {
216
+ format: EXPORT_FORMAT,
217
+ board,
218
+ states: tableRows(db, "states"),
219
+ labels,
220
+ tickets: tableRows(db, "tickets").map((ticket) => ({ ...ticket, labels: labelsOf.get(text(ticket.id)) ?? [] })),
221
+ comments: tableRows(db, "comments"),
222
+ review_verdicts: tableRows(db, "review_verdicts"),
223
+ relations: tableRows(db, "relations"),
224
+ attachments,
225
+ board_entries: tableRows(db, "board_entries"),
226
+ events: tableRows(db, "events")
227
+ };
228
+ return { id: text(board.id), slug: text(board.slug), snapshot, files, missingImages, refusedImages };
229
+ }
230
+ const HOW_TO_BRING = "To bring it, export it from Orbit 1.x or 2.0 (npx @starlove/orbit@1, or the 2.0 you had) and name the export beside the old folder: orbit migrate <old folder> <export> --to <new folder>.";
231
+ /** Why none of a board's files that are there was read, in plain words. */
232
+ function refusedReason(folder, present) {
233
+ const path = present[0] ?? "";
234
+ if (lstatSync(path).isSymbolicLink())
235
+ return `its file (${path}) is a link, not a plain file, so it wasn't read.`;
236
+ if (!lstatSync(path).isFile())
237
+ return `its registry names ${path}, which isn't a file.`;
238
+ const real = realpathSync(path);
239
+ if (inside(resolve(folder), path) !== null)
240
+ return `its file is reached through a link out of the old folder (it is really ${real}), so it wasn't read.`;
241
+ if (real !== resolve(path))
242
+ return `its registry names ${path}, which is reached through a link to ${real}, and that isn't a repo's .orbit/board.db, so it wasn't read.`;
243
+ return `its registry names a file outside the old folder (${path}) that isn't a repo's .orbit/board.db, so it wasn't read.`;
244
+ }
245
+ /** Why a board whose file isn't there wasn't brought: a path that can't be reached says why, one outside the folder says so. */
246
+ function goneReason(folder, candidates, named) {
247
+ for (const candidate of candidates) {
248
+ try {
249
+ lstatSync(candidate);
250
+ }
251
+ catch (error) {
252
+ const code = error.code;
253
+ if (code === "ENOENT")
254
+ continue;
255
+ const why = code === "ELOOP" ? "its path goes round a loop of links" : code === "ENOTDIR" ? "a part of its path isn't a folder" : code === "EACCES" || code === "EPERM" ? "Orbit isn't allowed to read it" : `the system said ${code ?? "no"}`;
256
+ return `its registry names ${candidate}, which can't be reached (${why}), so it wasn't read.`;
257
+ }
258
+ }
259
+ const outside = named !== "" && inside(resolve(folder), resolve(folder, named)) === null;
260
+ if (outside)
261
+ return `its file isn't where its registry says (${cleanName(named)}).`;
262
+ return `its file is gone from the old folder${named ? ` (its registry names ${cleanName(named)})` : ""}.`;
263
+ }
264
+ /**
265
+ * An older Orbit's data folder: each board in its registry, read from a
266
+ * copy. Only plain files are read: a board's file is found under
267
+ * `boards/<slug>/` (when its slug is one) or where its registry row says,
268
+ * when that is inside the folder or is a 1.x repo board's file in
269
+ * `<repo>/.orbit/` (`repoBoard`), whose own folder then holds
270
+ * its images and markdown files. Copies are named by their place in the
271
+ * registry, never by anything the folder says.
272
+ */
273
+ function readFolder(folder, scratch, notes) {
274
+ const registryPath = join(folder, "registry.db");
275
+ try {
276
+ if (checkFile(registryPath))
277
+ throw new OrbitError("migration_source_unreadable", { message: `${folder} is already this Orbit's data folder: point DATA_DIR at it.` });
278
+ }
279
+ catch (error) {
280
+ if (!isOrbitError(error) || error.code !== "data_needs_migration")
281
+ throw error;
282
+ }
283
+ const registry = openCopy(folder, registryPath, join(scratch, "registry"));
284
+ if (!registry)
285
+ throw new OrbitError("migration_source_unreadable", { message: `${registryPath} isn't a plain file inside ${folder}, so it wasn't read.` });
286
+ const rows = tableRows(registry, "boards");
287
+ registry.close();
288
+ const boards = [];
289
+ for (const [n, row] of rows.entries()) {
290
+ const slug = text(row.slug);
291
+ const label = cleanName(slug) || cleanName(row.name) || `#${String(n + 1)}`;
292
+ // 1.4 and 2.0 keep every board under boards/<slug>; 1.x's registry may name a repo's .orbit/board.db.
293
+ const named = text(row.db_path);
294
+ const candidates = [isSlug(slug) ? join(folder, "boards", slug, "board.db") : "", named ? resolve(folder, named) : ""].filter(Boolean);
295
+ const present = candidates.filter(existsOrLink);
296
+ const inFolder = present.find((candidate) => plainFileInside(folder, candidate));
297
+ const repo = inFolder ? null : present.map(repoBoard).find((found) => found !== null) ?? null;
298
+ const path = inFolder ?? repo?.path ?? null;
299
+ const root = repo?.root ?? folder;
300
+ if (!path) {
301
+ notes.push(present.length ? `The board ${label} wasn't brought: ${refusedReason(folder, present)} ${HOW_TO_BRING}` : `The board ${label} (${cleanName(row.name)}) wasn't brought: ${goneReason(folder, candidates, named)} ${HOW_TO_BRING}`);
302
+ continue;
303
+ }
304
+ const boardDir = dirname(path);
305
+ const db = openCopy(root, path, join(scratch, "boards", String(n)));
306
+ if (!db) {
307
+ notes.push(`The board ${label} wasn't brought: a file beside its database (its -wal or -shm) is a link or not a plain file, so it wasn't read. Put the file itself in the link's place and run this again.`);
308
+ continue;
309
+ }
310
+ const artifacts = filesUnder(join(boardDir, "artifacts"));
311
+ let read;
312
+ try {
313
+ read = readBoardFile(db, artifacts.files, root, join(boardDir, "attachments"), repo ? `its .orbit folder (${repo.root})` : "the old folder");
314
+ }
315
+ catch {
316
+ notes.push(`The board ${label} wasn't brought: its file isn't a board database Orbit can read.`);
317
+ continue;
318
+ }
319
+ finally {
320
+ db.close();
321
+ }
322
+ const source = {
323
+ ...read,
324
+ from: path,
325
+ refused: artifacts.refused + read.refusedImages.length,
326
+ notes: [
327
+ ...read.missingImages,
328
+ ...read.refusedImages
329
+ ]
330
+ };
331
+ if (repo)
332
+ source.notes.push(`It was a board kept in a repo, read from ${path} (nothing there was changed).`);
333
+ if (artifacts.refused)
334
+ source.notes.push(`${String(artifacts.refused)} ${artifacts.refused === 1 ? "entry under its markdown files wasn't a plain file (a link), so it wasn't" : "entries under its markdown files weren't plain files (links), so they weren't"} read.`);
335
+ const folder1x = cleanName(row.repo_path);
336
+ if (folder1x)
337
+ source.folder1x = folder1x;
338
+ for (const card of source.snapshot.tickets) {
339
+ linkFor(source, folder, boardDir, card, "plan_artifact_path");
340
+ linkFor(source, folder, boardDir, card, "implementation_artifact_path");
341
+ }
342
+ boards.push(source);
343
+ }
344
+ return boards;
345
+ }
346
+ // ------------------------------------------------------------------ reading an export
347
+ function readExport(path) {
348
+ let raw;
349
+ try {
350
+ raw = JSON.parse(readFileSync(path, "utf8"));
351
+ }
352
+ catch {
353
+ throw new OrbitError("migration_source_unreadable", { message: `${path} isn't a data folder or a board's export.` });
354
+ }
355
+ const row = raw && typeof raw === "object" ? raw : {};
356
+ const shaped = row.schema === OLD_PREVIEW_FORMAT ? fromOldPreview(row) : row;
357
+ const board = shaped.board && typeof shaped.board === "object" ? shaped.board : null;
358
+ if (shaped.format !== EXPORT_FORMAT || !board || !text(board.id) || !text(board.slug))
359
+ throw new OrbitError("migration_source_unreadable", { message: `${path} isn't a board's export from Orbit.` });
360
+ const snapshot = { ...shaped, board, tickets: (shaped.tickets ?? []).map((ticket) => ({ ...ticket })) };
361
+ const source = { id: text(board.id), slug: text(board.slug), from: path, snapshot, files: new Map(), notes: [], refused: 0 };
362
+ const images = (shaped.attachments ?? []).filter((image) => !image.data_base64).length;
363
+ if (images)
364
+ source.notes.push(`${images} image${images === 1 ? "" : "s"} came without their bytes (the export was made without images); the cards keep their names only.`);
365
+ for (const card of snapshot.tickets) {
366
+ linkFor(source, null, null, card, "plan_artifact_path");
367
+ linkFor(source, null, null, card, "implementation_artifact_path");
368
+ }
369
+ if (snapshot.tickets.some((card) => text(card.plan_artifact_path) || text(card.implementation_artifact_path)))
370
+ source.notes.push("An export carries no markdown files: cards' plan and work summary links name files this folder doesn't have.");
371
+ return source;
372
+ }
373
+ // ------------------------------------------------------------------ checking
374
+ const sha = (value) => createHash("sha256").update(typeof value === "string" || value instanceof Uint8Array ? value : JSON.stringify(value)).digest("hex");
375
+ /** The fields each kind of record must bring unchanged, as v2 keeps them. */
376
+ function projected(kind, row) {
377
+ const at = (...keys) => Object.fromEntries(keys.map((key) => [key, row[key] ?? null]));
378
+ switch (kind) {
379
+ case "states":
380
+ return { ...at("id", "position", "role", "created_at"), name: cleanName(row.name) || "Lane", is_default: Number(row.is_default ?? 0) };
381
+ case "labels":
382
+ return { ...at("id", "name", "created_at"), color: keptLabelColor(row.color) };
383
+ case "tickets":
384
+ return {
385
+ ...at("id", "number", "title", "description", "parent_ticket_id", "ai_plan", "implementation_summary", "implementation_updates", "state_id", "created_at", "updated_at", "archived_at"),
386
+ type: normalizeTicketType(row.type ?? "task"),
387
+ priority: normalizePriority(row.priority),
388
+ plan_artifact_path: cardLinkFromElsewhere(row.plan_artifact_path) ?? "",
389
+ implementation_artifact_path: cardLinkFromElsewhere(row.implementation_artifact_path) ?? "",
390
+ nebula_url: parseNebulaUrl(row.nebula_url) ?? "",
391
+ labels: (row.labels ?? []).map((label) => text(label.id)).sort()
392
+ };
393
+ case "comments":
394
+ return { ...at("id", "ticket_id", "body", "created_at"), author: row.author ?? "import", kind: row.kind ?? "comment", omitted_for_ai: Number(row.omitted_for_ai ?? 0) };
395
+ case "review_verdicts":
396
+ return at("id", "ticket_id", "verdict", "blocking_findings_json", "optional_findings_json", "evidence_commands_json", "reviewer_profile", "reviewed_commit_sha", "created_at");
397
+ case "relations":
398
+ return { ...at("id", "source_ticket_id", "target_ticket_id", "created_at"), type: row.type ?? "relates_to" };
399
+ case "attachments":
400
+ // Stored as its bytes say (an SVG as a download only), on both sides.
401
+ return { ...at("id", "ticket_id", "original_name", "size_bytes", "sha256", "created_at"), mime_type: storedImageType(row.mime_type || "image/png", typeof row.data_base64 === "string" && row.data_base64 ? Buffer.from(row.data_base64, "base64") : null) };
402
+ case "board_entries":
403
+ return { ...at("id", "title", "body", "ticket_id", "created_at", "updated_at", "struck_at"), type: normalizeEntryType(row.type) };
404
+ case "events":
405
+ return at("id", "ticket_id", "actor", "type", "body_json", "created_at");
406
+ }
407
+ }
408
+ function fingerprint(kind, rows) {
409
+ const hashes = new Map();
410
+ for (const row of rows)
411
+ hashes.set(text(row.id), sha(projected(kind, row)));
412
+ return { ids: [...hashes.keys()].sort(), hashes };
413
+ }
414
+ /** The board as written, against its source: per kind, its count, ids and content; then its markdown files. */
415
+ async function check(c, source, boardId) {
416
+ const row = await c.store.boards.get(boardId);
417
+ if (!row)
418
+ throw new OrbitError("storage_failed", { message: `The board ${source.slug} wasn't written.` });
419
+ const bc = await onBoard(c, row);
420
+ const written = (await exportBoard(bc, { includeAttachments: true }));
421
+ const report = { slug: row.slug, id: row.id, from: source.from, counts: {}, matched: [], notes: [...source.notes], images_kept_as_downloads: 0, refused_files: source.refused, ...(source.folder1x ? { folder_1x: source.folder1x } : {}) };
422
+ for (const kind of KINDS) {
423
+ const want = fingerprint(kind, source.snapshot[kind] ?? []);
424
+ const got = fingerprint(kind, written[kind] ?? []);
425
+ report.counts[kind] = got.ids.length;
426
+ const missing = want.ids.filter((id) => !got.hashes.has(id));
427
+ const changed = want.ids.filter((id) => got.hashes.has(id) && got.hashes.get(id) !== want.hashes.get(id));
428
+ const extra = got.ids.filter((id) => !want.hashes.has(id));
429
+ if (!missing.length && !changed.length && !extra.length)
430
+ report.matched.push(kind);
431
+ else
432
+ report.notes.push(`${kind.replace(/_/g, " ")}: ${[missing.length ? `${missing.length} not brought (${missing.slice(0, 5).join(", ")})` : "", changed.length ? `${changed.length} changed (${changed.slice(0, 5).join(", ")})` : "", extra.length ? `${extra.length} more than the source` : ""].filter(Boolean).join("; ")}.`);
433
+ }
434
+ // Images: the bytes each record names are the bytes stored.
435
+ for (const image of written.attachments ?? []) {
436
+ if (image.download_only)
437
+ report.images_kept_as_downloads += 1;
438
+ if (image.data_base64 && sha(Buffer.from(text(image.data_base64), "base64")) !== text(image.sha256))
439
+ report.notes.push(`The image ${text(image.original_name)}'s bytes don't match its record.`);
440
+ }
441
+ let files = 0;
442
+ for (const [path, bytes] of source.files) {
443
+ const stored = await c.blobs.get(boardId, ARTIFACTS + path);
444
+ if (stored && sha(stored) === sha(bytes))
445
+ files += 1;
446
+ else
447
+ report.notes.push(`The markdown file ${path} didn't come over.`);
448
+ }
449
+ report.counts.markdown_files = files;
450
+ if (files === source.files.size)
451
+ report.matched.push("markdown_files");
452
+ if (report.images_kept_as_downloads)
453
+ report.notes.push(`${String(report.images_kept_as_downloads)} image${report.images_kept_as_downloads === 1 ? " is" : "s are"} kept as a file to download only (an SVG, or bytes that aren't a PNG, JPEG, GIF, WebP or AVIF image).`);
454
+ return report;
455
+ }
456
+ // ------------------------------------------------------------------ the run
457
+ function isEmptyDir(path) {
458
+ if (!existsOrLink(path))
459
+ return true;
460
+ const stat = lstatSync(path);
461
+ return stat.isDirectory() && readdirSync(path).length === 0;
462
+ }
463
+ /** Opens Orbit over a data folder of v2's, as the owner. */
464
+ async function openTarget(dataDir) {
465
+ const store = await openSqliteStore({ dataDir });
466
+ const orbit = createOrbit({ store, blobs: store.blobs, deployment: { data_dir: dataDir } });
467
+ return { c: orbit.context(localOwnerActor()), close: () => store.close() };
468
+ }
469
+ /** The old boards that a board already in the target would collide with, by slug or id. */
470
+ async function clashes(c, boards) {
471
+ const held = await c.store.boards.list();
472
+ const found = [];
473
+ for (const old of boards) {
474
+ const row = held.find((board) => board.id === old.id || board.slug === old.slug);
475
+ if (!row)
476
+ continue;
477
+ const cards = (await (await onBoard(c, row)).data.read((r) => r.tickets.list())).length;
478
+ found.push({ old, here: { slug: row.slug, id: row.id, cards } });
479
+ }
480
+ return found;
481
+ }
482
+ /** The refusal when old boards collide with boards here: each one with its cards on both sides, then what to run. */
483
+ function clashText(to, found) {
484
+ const cards = (n) => `${String(n)} card${n === 1 ? "" : "s"}`;
485
+ const one = found.length === 1;
486
+ const lines = [`Nothing was changed: ${to} already holds ${one ? "a board" : "boards"} the older Orbit also has.`];
487
+ for (const { old, here } of found) {
488
+ const there = cards(old.snapshot.tickets.length);
489
+ lines.push(here.slug === old.slug ? ` ${here.slug}: ${cards(here.cards)} here, ${there} in the older Orbit.` : ` ${here.slug}: ${cards(here.cards)} here; it is the older Orbit's ${old.slug} (the same board), with ${there} there.`);
490
+ }
491
+ const names = found.map((clash) => clash.here.slug);
492
+ const listed = one ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}`;
493
+ lines.push(`If ${listed} here ${one ? "holds" : "hold"} nothing you need, delete ${one ? "it" : "them"} in the app (Settings, Data, Delete board) and run \`orbit migrate\` again. Or bring the old boards to another folder: \`orbit migrate --to <folder>\`.`);
494
+ return lines.join("\n");
495
+ }
496
+ /** `path` with every link resolved, for a path that may not exist yet: its nearest existing folder's real path, then the rest. */
497
+ function realPathOf(path) {
498
+ const rest = [];
499
+ let at = resolve(path);
500
+ for (;;) {
501
+ try {
502
+ return join(realpathSync(at), ...rest.reverse());
503
+ }
504
+ catch {
505
+ const up = dirname(at);
506
+ if (up === at)
507
+ return resolve(path);
508
+ rest.push(basename(at));
509
+ at = up;
510
+ }
511
+ }
512
+ }
513
+ /**
514
+ * A source's slug as v2 keeps it. An old registry or an export may say
515
+ * anything, so a slug that isn't one (a path, a line break) is made one, and
516
+ * the report says so.
517
+ */
518
+ function keptSlug(source, n) {
519
+ if (isSlug(source.slug))
520
+ return;
521
+ const made = slugify(source.slug) || `board-${String(n + 1)}`;
522
+ source.notes.push(`Its slug in the old data wasn't one Orbit keeps (${JSON.stringify(cleanName(source.slug).slice(0, 80))}); it is ${made} here.`);
523
+ source.slug = made;
524
+ }
525
+ /** One board into the target, with its markdown files, then checked against its source. */
526
+ async function bring(c, board) {
527
+ const { board: row, crossDropped } = await importKeepingBoard(c, board.snapshot, { id: board.id, slug: board.slug });
528
+ if (crossDropped)
529
+ board.notes.push(`${String(crossDropped)} link${crossDropped === 1 ? "" : "s"} to cards on other boards didn't come: the other cards aren't in this folder.`);
530
+ for (const [path, bytes] of board.files)
531
+ await c.blobs.put(row.id, ARTIFACTS + path, bytes);
532
+ return check(c, board, row.id);
533
+ }
534
+ /** Every board into a folder that held none; when one fails, the boards this run made are removed and the error rethrown. */
535
+ async function bringInPlace(c, boards) {
536
+ const reports = [];
537
+ try {
538
+ for (const board of boards)
539
+ reports.push(await bring(c, board));
540
+ return reports;
541
+ }
542
+ catch (error) {
543
+ for (const board of boards) {
544
+ if (!(await c.store.boards.get(board.id)))
545
+ continue;
546
+ await c.store.boards.remove(board.id);
547
+ await c.blobs.removeBoard(board.id);
548
+ }
549
+ throw error;
550
+ }
551
+ }
552
+ export async function migrate(options) {
553
+ const to = resolve(options.to);
554
+ const sources = options.sources.map((source) => resolve(source));
555
+ if (!sources.length)
556
+ throw new OrbitError("migration_source_unreadable", { message: "Name the old data folder or the exports to bring over." });
557
+ const realTo = realPathOf(to);
558
+ for (const source of sources) {
559
+ if (!existsSync(source))
560
+ throw new OrbitError("migration_source_unreadable", { message: `${source} doesn't exist.` });
561
+ const realSource = realpathSync(source);
562
+ const around = (a, b) => a === b || inside(a, b) !== null || inside(b, a) !== null;
563
+ if (around(source, to) || around(realSource, realTo))
564
+ throw new OrbitError("migration_target_in_use", { message: `Orbit brings old boards into a new folder of its own, never into or around the old one: choose another folder than ${to}.` });
565
+ }
566
+ const scratch = mkdtempSync(join(tmpdir(), "orbit-migrate-"));
567
+ const notes = [];
568
+ try {
569
+ const boards = sources.flatMap((source, i) => (lstatSync(realpathSync(source)).isDirectory() ? readFolder(source, join(scratch, String(i)), notes) : [readExport(source)]));
570
+ boards.forEach(keptSlug);
571
+ const slugs = new Set();
572
+ for (const board of boards) {
573
+ if (slugs.has(board.slug))
574
+ throw new OrbitError("migration_target_in_use", { message: `Two of the sources hold a board named ${board.slug}: bring them over one at a time, into two folders.` });
575
+ slugs.add(board.slug);
576
+ }
577
+ // A folder an earlier run made: when every board is there, nothing is done.
578
+ if (!isEmptyDir(to)) {
579
+ if (lstatSync(to).isSymbolicLink())
580
+ throw new OrbitError("migration_target_in_use", { message: `${to} is a link, not a folder: choose a new folder for Orbit's data.` });
581
+ if (!existsSync(join(to, "registry.db")))
582
+ throw new OrbitError("migration_target_in_use", { message: `${to} isn't empty: choose a new folder for Orbit's data.` });
583
+ const target = await openTarget(to);
584
+ try {
585
+ const there = await Promise.all(boards.map((board) => target.c.store.boards.get(board.id)));
586
+ if (there.every(Boolean))
587
+ return { to, already: true, boards: await Promise.all(boards.map((board) => check(target.c, board, board.id))), notes };
588
+ // Orbit's own folder with no board yet (an upgrader ran orbit run first), or, filled with no
589
+ // folder named, holding none of these boards' slugs or ids: the boards come in through the
590
+ // store, which an Orbit running there shares, and go again if one fails.
591
+ if (!(await target.c.store.boards.list()).length)
592
+ return { to, already: false, boards: await bringInPlace(target.c, boards), notes };
593
+ if (options.fill) {
594
+ const found = await clashes(target.c, boards);
595
+ if (found.length)
596
+ throw new OrbitError("migration_target_in_use", { message: clashText(to, found) });
597
+ return { to, already: false, boards: await bringInPlace(target.c, boards), notes };
598
+ }
599
+ }
600
+ finally {
601
+ await target.close();
602
+ }
603
+ throw new OrbitError("migration_target_in_use", { message: `${to} already holds other boards: choose a new folder for Orbit's data.` });
604
+ }
605
+ // Built beside its final name, then moved into place.
606
+ mkdirSync(dirname(to), { recursive: true });
607
+ const building = mkdtempSync(join(dirname(to), `.${basename(to)}.migrating-`));
608
+ try {
609
+ const target = await openTarget(building);
610
+ const reports = [];
611
+ try {
612
+ for (const board of boards)
613
+ reports.push(await bring(target.c, board));
614
+ }
615
+ finally {
616
+ await target.close();
617
+ }
618
+ // The target was absent or empty when the run began; anything in it now is someone else's, so it is refused, never removed.
619
+ if (existsOrLink(to)) {
620
+ if (!isEmptyDir(to) || lstatSync(to).isSymbolicLink())
621
+ throw new OrbitError("migration_target_in_use", { message: `${to} was filled while the boards were brought over: nothing was written there. Choose a new folder for Orbit's data.` });
622
+ rmdirSync(to);
623
+ }
624
+ renameSync(building, to);
625
+ return { to, already: false, boards: reports, notes };
626
+ }
627
+ catch (error) {
628
+ rmSync(building, { recursive: true, force: true });
629
+ throw error;
630
+ }
631
+ }
632
+ finally {
633
+ rmSync(scratch, { recursive: true, force: true });
634
+ }
635
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The installed package: its folder and its version. Kept apart from the
3
+ * store so `orbit -v` reads one small file and loads nothing else.
4
+ */
5
+ /** The package's folder: two up from this file in src/node/ or lib/node/. */
6
+ export declare const PACKAGE_ROOT: string;
7
+ export declare function packageVersion(): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The installed package: its folder and its version. Kept apart from the
3
+ * store so `orbit -v` reads one small file and loads nothing else.
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ /** The package's folder: two up from this file in src/node/ or lib/node/. */
9
+ export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+ let version = null;
11
+ export function packageVersion() {
12
+ version ??= JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version ?? "0.0.0";
13
+ return version;
14
+ }