getbotons 0.0.0-stage → 0.1.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 (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,668 @@
1
+ // Node builtins only: the server's start, scripts/deploy.ts, scripts/migrate-names.ts and the installer load this.
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { applyLegacyEnv, CLI_NAMES, CONFIG_DIRS, DATA_DIRS, DB_FILE, earlierEnvNames, rewriteEnvText, SHARE_DIRS, UNITS } from '../../shared/legacy-names.mjs';
7
+ import { writeBackupUnits } from '../../scripts/install-backup-timer.ts';
8
+ import { readEnvFile, writeServiceUnit } from '../../scripts/install-service.ts';
9
+ import { type BackupResult, backup as makeBackup } from './backup.ts';
10
+ import { chooseHome, DEFAULT_HOME, isBotonsSetting } from './config.ts';
11
+ import { lockDataDir } from './lock.ts';
12
+
13
+ /*
14
+ * Moves an install off the names Botons had before (shared/legacy-names.mjs, which this takes every such name
15
+ * from). Part of that module: it goes when they go.
16
+ *
17
+ * - The data folder and the database file (upgradeDataFolder): on the server's first start, and in the upgrades
18
+ * below. Under the data folder's lock (a running server stops it), after a backup: the database is checkpointed
19
+ * and renamed, then the folder is renamed to ~/.botons (one rename(2), so it is there whole or not at all). Each
20
+ * earlier name is left as a link to the new one, so the earlier release, absolute paths in workspaces (git
21
+ * worktrees, virtualenvs, scripts) and anything else that names the old path keep working. Running it again
22
+ * finishes what an interruption left and changes nothing that is done.
23
+ * - The service (upgradeService): from outside the server, which it stops and starts: `npm run deploy`, the
24
+ * installer and `npm run migrate:names`. The release root is renamed the same way; the env file is written anew
25
+ * under ~/.config/botons with each setting under its current name (the earlier file stays as it was, for the
26
+ * earlier unit); botons.service (and botons-backup.*) are written and started and the earlier units disabled.
27
+ * `undo` puts the earlier units back, which still work through the links.
28
+ * - rollbackNames (`npm run migrate:names -- --rollback`) undoes all of it from the record the upgrade keeps in
29
+ * the data folder, and names the backup taken first.
30
+ */
31
+
32
+ /** What the upgrade did, kept in the data folder for --rollback. */
33
+ export const RECORD = 'name-upgrade.json';
34
+
35
+ export interface NameRecord {
36
+ at: string;
37
+ /** The data folder's earlier path (now a link), when it was moved. */
38
+ dataFrom?: string;
39
+ /** Other earlier names that linked to it and now link to the new folder. */
40
+ dataLinks?: string[];
41
+ /** The database was renamed (and its earlier name left as a link). */
42
+ db?: boolean;
43
+ backupDir?: string | null;
44
+ /** The release root's earlier path (now a link), when it was moved. */
45
+ rootFrom?: string;
46
+ rootTo?: string;
47
+ /** The earlier units, now disabled, and whether this wrote the env file. */
48
+ service?: { unit: string; backup: string | null; envFrom: string | null; envTo: string; wroteEnv: boolean; wrote: string[]; done?: boolean };
49
+ /** When rollbackNames put it back under its earlier names: the server's own start leaves it so (see `explicit`). */
50
+ rolledBack?: string;
51
+ }
52
+
53
+ const lstat = (p: string) => {
54
+ try {
55
+ return lstatSync(p);
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+ const real = (p: string) => {
61
+ try {
62
+ return realpathSync(p);
63
+ } catch {
64
+ return null;
65
+ }
66
+ };
67
+
68
+ /** `at` → `target` (a name in the same folder), replacing whatever link is at `at`, atomically. */
69
+ function linkAt(at: string, target: string): void {
70
+ const tmp = `${at}.link-${process.pid}`;
71
+ rmSync(tmp, { force: true });
72
+ symlinkSync(target, tmp);
73
+ renameSync(tmp, at);
74
+ }
75
+
76
+ export class NameUpgradeError extends Error {
77
+ name = 'NameUpgradeError';
78
+ }
79
+
80
+ function readRecord(home: string): NameRecord | null {
81
+ try {
82
+ return JSON.parse(readFileSync(join(home, RECORD), 'utf8')) as NameRecord;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function writeRecord(home: string, patch: Partial<NameRecord>): void {
89
+ const record: Record<string, unknown> = { ...(readRecord(home) ?? {}) };
90
+ for (const [k, v] of Object.entries(patch)) if (v !== undefined) record[k] = v;
91
+ record.at = new Date().toISOString();
92
+ writeFileSync(join(home, RECORD), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
93
+ }
94
+
95
+ export interface DataUpgradeOptions {
96
+ env?: NodeJS.ProcessEnv;
97
+ userHome?: string;
98
+ backup?: (home: string) => BackupResult;
99
+ log?: (line: string) => void;
100
+ /**
101
+ * Asked for (migrate:names, deploy, the installer), not the server's own start: it moves a folder the owner
102
+ * rolled back too. The server's start leaves one alone, since the release it runs is the one rolled back from.
103
+ */
104
+ explicit?: boolean;
105
+ }
106
+
107
+ export interface DataUpgradeResult {
108
+ /** The data folder to use from now on. */
109
+ home: string;
110
+ moved: boolean;
111
+ db: boolean;
112
+ backupDir: string | null;
113
+ lines: string[];
114
+ }
115
+
116
+ /** The earlier default data folder (its path under the user's home, a real folder) that `home` is, or null. */
117
+ function legacyDefault(home: string, userHome: string): string | null {
118
+ const r = real(home);
119
+ return DATA_DIRS.map((d) => join(userHome, d)).find((p) => lstat(p)?.isDirectory() && real(p) === r) ?? null;
120
+ }
121
+
122
+ /**
123
+ * Folds everything in the WAL into the file itself and removes the empty side files, so the file alone is the whole
124
+ * database and can be renamed. The caller holds the data folder's lock, so no other connection is open.
125
+ */
126
+ function settleDb(path: string): void {
127
+ const db = new DatabaseSync(path);
128
+ try {
129
+ db.exec('pragma wal_checkpoint(TRUNCATE)');
130
+ } finally {
131
+ db.close();
132
+ }
133
+ const wal = lstat(`${path}-wal`);
134
+ if (wal && wal.size > 0) throw new NameUpgradeError(`${path}-wal still holds transactions after a checkpoint, so the database was not renamed. Nothing was changed.`);
135
+ rmSync(`${path}-wal`, { force: true });
136
+ rmSync(`${path}-shm`, { force: true });
137
+ }
138
+
139
+ /**
140
+ * Moves the data folder from an earlier default name to ~/.botons, and the database file from its earlier name,
141
+ * as described above. Nothing to do: returns at once. Throws DataDirInUseError while a server holds the folder,
142
+ * HomeConflictError when which folder is meant is unclear, NameUpgradeError when the backup fails (nothing changed).
143
+ *
144
+ * Each rename is preceded by a record of what it is about to do (in the folder, which moves with it), and every
145
+ * start first finishes what such a record says an interrupted run left: the links at the earlier names.
146
+ */
147
+ export function upgradeDataFolder(o: DataUpgradeOptions = {}): DataUpgradeResult {
148
+ const env = o.env ?? process.env;
149
+ const userHome = o.userHome ?? (env.HOME?.trim() || homedir());
150
+ const lines: string[] = [];
151
+ const say = (line: string) => {
152
+ lines.push(line);
153
+ o.log?.(line);
154
+ };
155
+ const target = join(userHome, DEFAULT_HOME);
156
+ // Before anything chooses a folder, so an earlier name a stopped run left without its link isn't made anew.
157
+ finishLinks(target, userHome, say);
158
+ const choice = chooseHome(env, userHome);
159
+ const from = !lstat(target) ? legacyDefault(choice.home, userHome) : null;
160
+ const home = from ?? choice.home;
161
+ const rolledBack = readRecord(home)?.rolledBack;
162
+ if (rolledBack && !o.explicit) {
163
+ say(`${home} was put back under its earlier names (${rolledBack}): left so until \`npm run migrate:names\` moves it again.`);
164
+ return { home, moved: false, db: false, backupDir: null, lines };
165
+ }
166
+ if (rolledBack) {
167
+ const record = readRecord(home) as unknown as Record<string, unknown>;
168
+ delete record.rolledBack;
169
+ writeFileSync(join(home, RECORD), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
170
+ }
171
+ finishDbLink(home, say);
172
+ const oldDb = join(home, DB_FILE);
173
+ const newDb = join(home, 'botons.db');
174
+ const dbToMove = !!lstat(oldDb) && !lstat(oldDb)!.isSymbolicLink();
175
+ if (!from && !dbToMove) return { home, moved: false, db: false, backupDir: null, lines };
176
+ if (dbToMove && lstat(newDb)) throw new NameUpgradeError(`${home} has both ${DB_FILE} and botons.db, so which is this server's database is not clear. Move the one not in use away. Nothing was changed.`);
177
+
178
+ const lock = lockDataDir(home);
179
+ try {
180
+ let backupDir: string | null = readRecord(home)?.backupDir ?? null;
181
+ const dbNow = dbToMove ? oldDb : newDb;
182
+ if (existsSync(dbNow) && !(backupDir && existsSync(backupDir))) {
183
+ let made: BackupResult;
184
+ try {
185
+ made = (o.backup ?? ((h) => makeBackup({ home: h })))(home);
186
+ } catch (err) {
187
+ throw new NameUpgradeError(`The backup failed, so nothing was moved: ${(err as Error).message}`);
188
+ }
189
+ if (made.manifest.workspacesError) throw new NameUpgradeError(`The backup left the workspaces out, so nothing was moved: ${made.manifest.workspacesError}`);
190
+ if (made.manifest.tarWarning) throw new NameUpgradeError(`The backup has an archive warning, so nothing was moved: ${made.manifest.tarWarning}`);
191
+ backupDir = made.dir;
192
+ say(`Backed up ${home} to ${backupDir}.`);
193
+ }
194
+ writeRecord(home, { backupDir });
195
+ if (dbToMove) {
196
+ settleDb(oldDb);
197
+ writeRecord(home, { db: true });
198
+ renameSync(oldDb, newDb);
199
+ linkAt(oldDb, 'botons.db');
200
+ say(`Renamed ${oldDb} to botons.db (the earlier name links to it).`);
201
+ }
202
+ let moved = false;
203
+ if (from) {
204
+ const links = DATA_DIRS.map((d) => join(userHome, d)).filter((p) => p !== from && lstat(p)?.isSymbolicLink() && real(p) === from);
205
+ writeRecord(from, { dataFrom: from, dataLinks: links });
206
+ try {
207
+ renameSync(from, target);
208
+ moved = true;
209
+ } catch (err) {
210
+ // Another file system (a mount of its own, say): the data stays where it is and ~/.botons links to it.
211
+ if ((err as NodeJS.ErrnoException).code !== 'EXDEV' && (err as NodeJS.ErrnoException).code !== 'EBUSY') throw err;
212
+ const record = (readRecord(from) ?? {}) as unknown as Record<string, unknown>;
213
+ delete record.dataFrom;
214
+ delete record.dataLinks;
215
+ writeFileSync(join(from, RECORD), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
216
+ symlinkSync(from, target);
217
+ say(`${from} is a mount of its own, so it stays where it is: ${target} links to it.`);
218
+ }
219
+ if (moved) {
220
+ if (backupDir?.startsWith(`${from}/`)) {
221
+ backupDir = target + backupDir.slice(from.length);
222
+ writeRecord(target, { backupDir });
223
+ }
224
+ finishLinks(target, userHome, say);
225
+ say(`Moved ${from} to ${target} (the earlier name links to it).`);
226
+ }
227
+ }
228
+ return { home: moved ? target : home, moved, db: dbToMove, backupDir, lines };
229
+ } finally {
230
+ lock.release();
231
+ }
232
+ }
233
+
234
+ /** The links a move leaves at the earlier names, made if an interruption left them out. A real folder that appeared at one is left alone (chooseHome refuses two). */
235
+ function finishLinks(target: string, userHome: string, say: (line: string) => void): void {
236
+ if (!lstat(target)?.isDirectory() || lstat(target)!.isSymbolicLink()) return;
237
+ const record = readRecord(target);
238
+ if (!record?.dataFrom) return;
239
+ for (const p of [record.dataFrom, ...(record.dataLinks ?? [])]) {
240
+ const st = lstat(p);
241
+ if (dirname(p) !== dirname(target) || (st && !st.isSymbolicLink()) || (st && readlinkSync(p) === basename(target))) continue;
242
+ linkAt(p, basename(target));
243
+ say(`Linked ${p} to ${target}.`);
244
+ }
245
+ finishDbLink(target, say);
246
+ }
247
+
248
+ /** The database's earlier name as a link to it, made if an interruption left it out. */
249
+ function finishDbLink(home: string, say: (line: string) => void): void {
250
+ const oldDb = join(home, DB_FILE);
251
+ if (!readRecord(home)?.db || lstat(oldDb) || !lstat(join(home, 'botons.db'))) return;
252
+ linkAt(oldDb, 'botons.db');
253
+ say(`Linked ${oldDb} to botons.db.`);
254
+ }
255
+
256
+ export interface ServiceUpgradeOptions {
257
+ env?: NodeJS.ProcessEnv;
258
+ userHome?: string;
259
+ configHome?: string;
260
+ unitDir?: string;
261
+ /** systemctl and the like. */
262
+ exec: (cmd: string, args: string[]) => Promise<void>;
263
+ /** Start botons.service (else only write it and disable the earlier units: the caller starts it). */
264
+ start?: boolean;
265
+ backup?: DataUpgradeOptions['backup'];
266
+ log?: (line: string) => void;
267
+ }
268
+
269
+ export interface LegacyService {
270
+ /** The earlier server unit, and its backup unit's name if it has one. */
271
+ unit: string;
272
+ backup: string | null;
273
+ unitFile: string;
274
+ /** From the unit file: the app folder, the Node binary and the env file it runs with. */
275
+ app: string | null;
276
+ node: string | null;
277
+ envFile: string | null;
278
+ }
279
+
280
+ /** The systemd user units folder. */
281
+ export const unitDirOf = (env: NodeJS.ProcessEnv, userHome: string) => join(env.XDG_CONFIG_HOME || join(userHome, '.config'), 'systemd', 'user');
282
+
283
+ /**
284
+ * The environment the service runs with: the caller's when it names a data folder, else the service env file's
285
+ * settings over it (as serverEnv does), each under its current name.
286
+ */
287
+ function serviceEnv(env: NodeJS.ProcessEnv, envFile: string | null): NodeJS.ProcessEnv {
288
+ const out: NodeJS.ProcessEnv = { ...env };
289
+ if (!envFile || ['BOTONS_HOME', ...earlierEnvNames('BOTONS_HOME')].some((k) => env[k]?.trim())) return out;
290
+ let file: Record<string, string>;
291
+ try {
292
+ file = readEnvFile(readFileSync(envFile, 'utf8'));
293
+ } catch {
294
+ return out;
295
+ }
296
+ applyLegacyEnv(file);
297
+ for (const [k, v] of Object.entries(file)) if (isBotonsSetting(k)) out[k] = v;
298
+ applyLegacyEnv(out);
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * The data folder the service uses, after an upgrade too: its env file can name one (the new env file first, then
304
+ * the one an earlier unit reads, then one at an earlier default path), else the default.
305
+ */
306
+ function serviceHome(env: NodeJS.ProcessEnv, userHome: string): string {
307
+ const configHome = env.XDG_CONFIG_HOME || join(userHome, '.config');
308
+ const unitDir = unitDirOf(env, userHome);
309
+ const unitEnv = UNITS.map((u) => join(unitDir, `${u.service}.service`)).filter((f) => existsSync(f)).flatMap((f) => [...readFileSync(f, 'utf8').matchAll(/^EnvironmentFile=-?(.*)$/gm)].map((m) => m[1].trim().replaceAll('%%', '%').replace(/^%h(?=\/)/, userHome)));
310
+ const envFile = [join(configHome, 'botons', 'env'), ...unitEnv, ...CONFIG_DIRS.map((d) => join(configHome, d, 'env'))].find((p) => existsSync(p)) ?? null;
311
+ return chooseHome(serviceEnv(env, envFile), userHome).home;
312
+ }
313
+
314
+ /** The record of a service handover in the data folder `env` names, if there is one. */
315
+ function handoverOf(env: NodeJS.ProcessEnv, userHome: string): NameRecord['service'] | null {
316
+ try {
317
+ return readRecord(chooseHome(env, userHome).home)?.service ?? null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * An earlier server unit, read from its file, that hasn't been handed over to botons.service: none beside it, or a
325
+ * handover its record says stopped part way. Null when there is none.
326
+ */
327
+ export function findLegacyService(o: { env?: NodeJS.ProcessEnv; userHome?: string; unitDir?: string } = {}): LegacyService | null {
328
+ const env = o.env ?? process.env;
329
+ const userHome = o.userHome ?? (env.HOME?.trim() || homedir());
330
+ const unitDir = o.unitDir ?? unitDirOf(env, userHome);
331
+ const u = UNITS.find((x) => existsSync(join(unitDir, `${x.service}.service`)));
332
+ if (!u) return null;
333
+ const unitFile = join(unitDir, `${u.service}.service`);
334
+ const text = readFileSync(unitFile, 'utf8');
335
+ const value = (key: string) => [...text.matchAll(new RegExp(`^${key}=(.*)$`, 'gm'))].map((m) => m[1].trim());
336
+ const unescape = (s: string) => s.replaceAll('%%', '%').replace(/^%h(?=\/)/, userHome);
337
+ const m = /^(?:"([^"]+)"|(\S+))\s+(?:"(.+)\/server\/src\/index\.ts"|(\S+)\/server\/src\/index\.ts)\s*$/.exec(value('ExecStart')[0] ?? '');
338
+ const exec = m ? [m[0], m[1] ?? m[2], m[3] ?? m[4]] : null;
339
+ const envFile = value('EnvironmentFile').map((v) => unescape(v.replace(/^-/, ''))).find((p) => existsSync(p)) ?? null;
340
+ if (existsSync(join(unitDir, 'botons.service'))) {
341
+ const handover = handoverOf(serviceEnv(env, envFile), userHome);
342
+ if (!handover || handover.done || handover.unit !== u.service) return null;
343
+ }
344
+ return {
345
+ unit: u.service,
346
+ backup: existsSync(join(unitDir, `${u.backup}.timer`)) ? u.backup : null,
347
+ unitFile,
348
+ app: exec ? unescape(exec[2]) : (value('WorkingDirectory')[0] ? unescape(value('WorkingDirectory')[0]) : null),
349
+ node: exec ? unescape(exec[1]) : null,
350
+ envFile,
351
+ };
352
+ }
353
+
354
+ /** The earlier env file: the one the earlier unit reads, else one at an earlier default path. */
355
+ function legacyEnvFile(found: LegacyService | null, configHome: string): string | null {
356
+ if (found?.envFile) return found.envFile;
357
+ return CONFIG_DIRS.map((d) => join(configHome, d, 'env')).find((p) => existsSync(p)) ?? null;
358
+ }
359
+
360
+ /** In a release root this moved: the earlier path it was moved from, written before the move. */
361
+ const ROOT_RECORD = '.name-upgrade-from';
362
+
363
+ /** A moved release root's earlier path, with its link made if an interruption left it out; null when it wasn't moved. */
364
+ function finishRoot(userHome: string, say: (line: string) => void): [string, string] | null {
365
+ const to = join(userHome, '.local', 'share', 'botons');
366
+ if (!lstat(to)?.isDirectory() || lstat(to)!.isSymbolicLink()) return null;
367
+ let from: string;
368
+ try {
369
+ from = readFileSync(join(to, ROOT_RECORD), 'utf8').trim();
370
+ } catch {
371
+ return null;
372
+ }
373
+ if (dirname(from) !== dirname(to)) return null;
374
+ const st = lstat(from);
375
+ if (!st || (st.isSymbolicLink() && readlinkSync(from) !== basename(to))) {
376
+ linkAt(from, basename(to));
377
+ say(`Linked ${from} to ${to}.`);
378
+ }
379
+ return st && !st.isSymbolicLink() ? null : [from, to];
380
+ }
381
+
382
+ /** Renames the earlier release root to ~/.local/share/botons, leaving a link; returns [from, to] (also when an earlier run moved it), or null. */
383
+ function moveRoot(userHome: string, say: (line: string) => void): [string, string] | null {
384
+ const share = join(userHome, '.local', 'share');
385
+ const to = join(share, 'botons');
386
+ if (lstat(to)) return finishRoot(userHome, say);
387
+ const from = SHARE_DIRS.map((d) => join(share, d)).find((p) => lstat(p)?.isDirectory() && !lstat(p)!.isSymbolicLink());
388
+ if (!from) return null;
389
+ // Recorded inside it first (it moves with it), so a stop between the rename and the link is finished by the next run.
390
+ writeFileSync(join(from, ROOT_RECORD), `${from}\n`);
391
+ renameSync(from, to);
392
+ linkAt(from, 'botons');
393
+ say(`Moved ${from} to ${to} (the earlier name links to it).`);
394
+ return [from, to];
395
+ }
396
+
397
+ /** `p` under `from` as the same path under `to`. */
398
+ const mapPath = (p: string, moves: [string, string][]) => {
399
+ for (const [from, to] of moves) if (p === from || p.startsWith(`${from}/`)) return to + p.slice(from.length);
400
+ return p;
401
+ };
402
+
403
+ export interface ServiceUpgrade {
404
+ lines: string[];
405
+ /** Nothing was there to upgrade. */
406
+ nothing: boolean;
407
+ /** Stops and disables botons.service, removes what this wrote and starts the earlier unit again (the links keep its paths working). */
408
+ undo: () => Promise<void>;
409
+ }
410
+
411
+ /**
412
+ * Moves a service install off its earlier names, as described above. The earlier unit is stopped first and the
413
+ * data folder moved while nothing holds it. On a failure part way, what was done is undone before it throws.
414
+ */
415
+ export async function upgradeService(o: ServiceUpgradeOptions): Promise<ServiceUpgrade> {
416
+ const env = o.env ?? process.env;
417
+ const userHome = o.userHome ?? (env.HOME?.trim() || homedir());
418
+ const configHome = o.configHome ?? (env.XDG_CONFIG_HOME || join(userHome, '.config'));
419
+ const unitDir = o.unitDir ?? unitDirOf(env, userHome);
420
+ const lines: string[] = [];
421
+ const say = (line: string) => {
422
+ lines.push(line);
423
+ o.log?.(line);
424
+ };
425
+ // Before anything reads the earlier unit's paths: the release root's link, if a stopped run left it out.
426
+ finishRoot(userHome, say);
427
+ const found = findLegacyService({ env, userHome, unitDir });
428
+ const envFrom = legacyEnvFile(found, configHome);
429
+ const envTo = join(configHome, 'botons', 'env');
430
+ // The data folder is the one the service uses, which its env file may name.
431
+ const runEnv = serviceEnv(env, envFrom);
432
+ const nothing = { lines, nothing: true, undo: async () => {} };
433
+ if (!found) {
434
+ // No unit (a checkout run by hand, or an install made with --no-service): the release root, the data folder and
435
+ // the env file still move. The caller makes sure no server is running (the data folder's lock refuses one).
436
+ const share = join(userHome, '.local', 'share');
437
+ const rootToMove = !lstat(join(share, 'botons')) && SHARE_DIRS.some((d) => lstat(join(share, d))?.isDirectory() && !lstat(join(share, d))!.isSymbolicLink());
438
+ if (!rootToMove && !(envFrom && !existsSync(envTo))) return nothing;
439
+ const root = moveRoot(userHome, say);
440
+ const data = upgradeDataFolder({ env: runEnv, userHome, backup: o.backup, log: say, explicit: true });
441
+ if (root) rewriteMarker(root[1], [root, ...dataMoves(data)]);
442
+ let wroteEnv = false;
443
+ if (envFrom && !existsSync(envTo)) {
444
+ writeEnv(envFrom, envTo, data, root);
445
+ wroteEnv = true;
446
+ say(`Wrote ${envTo} from ${envFrom}, each setting under its current name.`);
447
+ }
448
+ writeRecord(data.home, { rootFrom: root?.[0], rootTo: root?.[1], service: { unit: '', backup: null, envFrom, envTo, wroteEnv, wrote: [] } });
449
+ return { lines, nothing: false, undo: async () => void (wroteEnv && rmSync(envTo, { force: true })) };
450
+ }
451
+
452
+ const wrote: string[] = [];
453
+ let stopped = false;
454
+ // Resuming a handover that stopped part way: what it had done already.
455
+ const homeNow = chooseHome(runEnv, userHome).home;
456
+ const prior = readRecord(homeNow)?.service;
457
+ let wroteEnv = prior?.unit === found.unit ? prior.wroteEnv : false;
458
+ const note = (home: string, patch: Partial<NonNullable<NameRecord['service']>>) =>
459
+ writeRecord(home, { service: { unit: found.unit, backup: found.backup, envFrom, envTo, wroteEnv, wrote, done: false, ...patch } });
460
+ const undo = async () => {
461
+ for (const args of [['disable', '--now', 'botons.service'], ['disable', '--now', 'botons-backup.timer']]) await o.exec('systemctl', ['--user', ...args]).catch(() => {});
462
+ for (const f of wrote) rmSync(f, { force: true });
463
+ if (wroteEnv) rmSync(envTo, { force: true });
464
+ await o.exec('systemctl', ['--user', 'daemon-reload']).catch(() => {});
465
+ if (stopped) {
466
+ await o.exec('systemctl', ['--user', 'enable', '--now', `${found.unit}.service`]);
467
+ if (found.backup) await o.exec('systemctl', ['--user', 'enable', '--now', `${found.backup}.timer`]);
468
+ say(`Started ${found.unit}.service again.`);
469
+ }
470
+ };
471
+ try {
472
+ // Recorded before any unit changes, so a run that stops part way is found and finished by the next.
473
+ if (existsSync(homeNow)) note(homeNow, {});
474
+ say(`Stopping ${found.unit}.service to move it to botons.service`);
475
+ await o.exec('systemctl', ['--user', 'stop', `${found.unit}.service`]);
476
+ if (found.backup) await o.exec('systemctl', ['--user', 'stop', `${found.backup}.timer`]);
477
+ stopped = true;
478
+ const root = moveRoot(userHome, say);
479
+ const data = upgradeDataFolder({ env: runEnv, userHome, backup: o.backup, log: say, explicit: true });
480
+ const moves: [string, string][] = root ? [root] : [];
481
+ if (root) rewriteMarker(root[1], [root, ...dataMoves(data)]);
482
+ if (!existsSync(envTo)) {
483
+ writeEnv(envFrom, envTo, data, root);
484
+ wroteEnv = true;
485
+ note(data.home, {});
486
+ say(`Wrote ${envTo}${envFrom ? ` from ${envFrom}, each setting under its current name` : ''}.`);
487
+ } else say(`Kept ${envTo}.`);
488
+ const app = found.app ? mapPath(found.app, moves) : null;
489
+ if (!app || !existsSync(join(app, 'server', 'src', 'index.ts'))) throw new NameUpgradeError(`${found.unitFile} does not name a Botons folder this can move to botons.service. Nothing more was changed.`);
490
+ const node = found.node ? mapPath(found.node, moves) : undefined;
491
+ writeRecord(data.home, { rootFrom: root?.[0], rootTo: root?.[1] });
492
+ wrote.push(writeServiceUnit({ name: 'botons', app, envPath: envTo, unitDir, node, userHome, log: say }));
493
+ if (found.backup) wrote.push(...writeBackupUnits({ app, unitDir, node, log: say }));
494
+ note(data.home, {});
495
+ await o.exec('systemctl', ['--user', 'daemon-reload']);
496
+ await o.exec('systemctl', ['--user', 'disable', `${found.unit}.service`]);
497
+ if (found.backup) await o.exec('systemctl', ['--user', 'disable', `${found.backup}.timer`]);
498
+ if (o.start !== false) {
499
+ await o.exec('systemctl', ['--user', 'enable', '--now', 'botons.service']);
500
+ if (found.backup) await o.exec('systemctl', ['--user', 'enable', '--now', 'botons-backup.timer']);
501
+ say(`Started botons.service; ${found.unit}.service is disabled (its file stays until the next release).`);
502
+ }
503
+ // Done (with start: false, the caller starts botons.service and can still undo).
504
+ note(data.home, { done: true });
505
+ } catch (err) {
506
+ say(`The move to botons.service stopped part way: ${(err as Error).message}`);
507
+ await undo();
508
+ throw err;
509
+ }
510
+ return { lines, nothing: false, undo };
511
+ }
512
+
513
+ /** The data folder's earlier default paths that now link to it, as [earlier, current]. */
514
+ function dataMoves(data: DataUpgradeResult): [string, string][] {
515
+ if (basename(data.home) !== DEFAULT_HOME) return [];
516
+ return DATA_DIRS.map((d) => join(dirname(data.home), d)).filter((p) => lstat(p)?.isSymbolicLink() && real(p) === real(data.home)).map((p) => [p, data.home]);
517
+ }
518
+
519
+ /** The installer's own record of where things are (installer.json in its root), with moved paths as their new ones. */
520
+ function rewriteMarker(root: string, moves: [string, string][]): void {
521
+ const marker = join(root, 'installer.json');
522
+ if (!existsSync(marker)) return;
523
+ const saved = JSON.parse(readFileSync(marker, 'utf8')) as Record<string, unknown>;
524
+ for (const [k, v] of Object.entries(saved)) if (typeof v === 'string') saved[k] = mapPath(v, moves);
525
+ writeFileSync(marker, JSON.stringify(saved));
526
+ }
527
+
528
+ /** Writes the new env file from the earlier one (or an empty one), each setting under its current name and each moved path as its new one. */
529
+ function writeEnv(envFrom: string | null, envTo: string, data: DataUpgradeResult, root: [string, string] | null): void {
530
+ const text = envFrom ? readFileSync(envFrom, 'utf8') : '';
531
+ const moves: [string, string][] = [...(root ? [root] : []), ...dataMoves(data)];
532
+ mkdirSync(dirname(envTo), { recursive: true, mode: 0o700 });
533
+ writeFileSync(envTo, rewriteEnvText(text, moves), { mode: 0o600 });
534
+ }
535
+
536
+ /**
537
+ * Undoes the service part of an upgrade made with `start: false` (the installer's, when what follows fails): the
538
+ * earlier units are enabled and started again, and what it wrote is removed. Folders stay moved (their links work).
539
+ */
540
+ export async function undoService(o: { env?: NodeJS.ProcessEnv; userHome?: string; exec: (cmd: string, args: string[]) => Promise<void>; log?: (line: string) => void }): Promise<void> {
541
+ const env = o.env ?? process.env;
542
+ const userHome = o.userHome ?? (env.HOME?.trim() || homedir());
543
+ const home = serviceHome(env, userHome);
544
+ const s = readRecord(home)?.service;
545
+ if (!s?.unit) return;
546
+ for (const args of [['disable', '--now', 'botons.service'], ['disable', '--now', 'botons-backup.timer']]) await o.exec('systemctl', ['--user', ...args]).catch(() => {});
547
+ for (const f of s.wrote) rmSync(f, { force: true });
548
+ if (s.wroteEnv) rmSync(s.envTo, { force: true });
549
+ await o.exec('systemctl', ['--user', 'daemon-reload']);
550
+ await o.exec('systemctl', ['--user', 'enable', '--now', `${s.unit}.service`]);
551
+ if (s.backup) await o.exec('systemctl', ['--user', 'enable', '--now', `${s.backup}.timer`]);
552
+ const record = readRecord(home)!;
553
+ delete record.service;
554
+ writeFileSync(join(home, RECORD), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
555
+ o.log?.(`Started ${s.unit}.service again.`);
556
+ }
557
+
558
+ export interface RollbackOptions {
559
+ env?: NodeJS.ProcessEnv;
560
+ userHome?: string;
561
+ exec: (cmd: string, args: string[]) => Promise<void>;
562
+ log?: (line: string) => void;
563
+ }
564
+
565
+ /**
566
+ * Undoes the upgrade from its record: the earlier units started again (botons.service disabled and removed), the
567
+ * release root, data folder and database back under their earlier names. The env file it wrote stays (the earlier
568
+ * one was never changed). Says which backup was taken first, for a database a newer release has upgraded.
569
+ */
570
+ export async function rollbackNames(o: RollbackOptions): Promise<string[]> {
571
+ const env = o.env ?? process.env;
572
+ const userHome = o.userHome ?? (env.HOME?.trim() || homedir());
573
+ const lines: string[] = [];
574
+ const say = (line: string) => {
575
+ lines.push(line);
576
+ o.log?.(line);
577
+ };
578
+ finishRoot(userHome, say);
579
+ const home = serviceHome(env, userHome);
580
+ const record = readRecord(home);
581
+ if (!record) {
582
+ say(`${home} has no record of a name upgrade: nothing to undo.`);
583
+ return lines;
584
+ }
585
+ const s = record.service;
586
+ if (s?.unit) {
587
+ for (const args of [['disable', '--now', 'botons.service'], ['disable', '--now', 'botons-backup.timer']]) await o.exec('systemctl', ['--user', ...args]).catch(() => {});
588
+ for (const f of s.wrote) rmSync(f, { force: true });
589
+ await o.exec('systemctl', ['--user', 'daemon-reload']);
590
+ say(`Removed botons.service${s.backup ? ' and botons-backup.*' : ''}.`);
591
+ }
592
+ // The env file the upgrade wrote names the new folders: set aside (edits kept), so the earlier one is what the
593
+ // earlier unit and every admin command read again.
594
+ if (s?.wroteEnv && existsSync(s.envTo)) {
595
+ renameSync(s.envTo, `${s.envTo}.rolled-back`);
596
+ say(`Set ${s.envTo} aside as ${s.envTo}.rolled-back; ${s.envFrom ?? 'the earlier settings'} is used again.`);
597
+ }
598
+ let backupDir = record.backupDir ?? null;
599
+ const root: [string, string] | null = record.rootFrom && record.rootTo ? [record.rootFrom, record.rootTo] : finishRoot(userHome, say);
600
+ const dataBack = record.dataFrom && lstat(record.dataFrom)?.isSymbolicLink() && real(record.dataFrom) === real(home) ? record.dataFrom : null;
601
+ // The installer's record of where things are goes back first, while its folder is still where it says.
602
+ const rootBack = root && lstat(root[0])?.isSymbolicLink() && lstat(root[1])?.isDirectory() ? root : null;
603
+ const lock = lockDataDir(home);
604
+ try {
605
+ if (rootBack) rewriteMarker(rootBack[1], [[rootBack[1], rootBack[0]], ...(dataBack ? [[home, dataBack] as [string, string]] : [])]);
606
+ if (record.db) {
607
+ const oldDb = join(home, DB_FILE);
608
+ if (lstat(oldDb)?.isSymbolicLink()) rmSync(oldDb);
609
+ if (!lstat(oldDb)) {
610
+ // A server that stopped uncleanly can leave transactions in botons.db-wal: into the file first.
611
+ settleDb(join(home, 'botons.db'));
612
+ renameSync(join(home, 'botons.db'), oldDb);
613
+ say(`Renamed botons.db back to ${DB_FILE}.`);
614
+ }
615
+ }
616
+ if (dataBack) {
617
+ rmSync(dataBack);
618
+ renameSync(home, dataBack);
619
+ for (const p of record.dataLinks ?? []) if (lstat(p)?.isSymbolicLink()) linkAt(p, basename(dataBack));
620
+ // What the server's start reads, so the release it runs doesn't move it straight back.
621
+ writeFileSync(join(dataBack, RECORD), `${JSON.stringify({ rolledBack: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
622
+ if (backupDir) backupDir = mapPath(backupDir, [[home, dataBack]]);
623
+ say(`Moved ${home} back to ${dataBack}.`);
624
+ } else writeFileSync(join(home, RECORD), `${JSON.stringify({ rolledBack: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
625
+ } finally {
626
+ lock.release();
627
+ }
628
+ if (rootBack) {
629
+ // Links that name the root by its new path (the installer's are absolute: current, previous, the command on
630
+ // PATH) are pointed at the earlier one first, so the earlier unit and command run once it is back.
631
+ const links = [join(rootBack[1], 'current'), join(rootBack[1], 'previous'), ...['botons', ...CLI_NAMES].map((n) => join(userHome, '.local', 'bin', n))];
632
+ for (const l of links) {
633
+ if (!lstat(l)?.isSymbolicLink()) continue;
634
+ const target = readlinkSync(l);
635
+ if (target.startsWith(`${rootBack[1]}/`)) linkAt(l, rootBack[0] + target.slice(rootBack[1].length));
636
+ }
637
+ rmSync(rootBack[0]);
638
+ rmSync(join(rootBack[1], ROOT_RECORD), { force: true });
639
+ renameSync(rootBack[1], rootBack[0]);
640
+ say(`Moved ${rootBack[1]} back to ${rootBack[0]}.`);
641
+ }
642
+
643
+ if (s?.unit) {
644
+ await o.exec('systemctl', ['--user', 'enable', '--now', `${s.unit}.service`]);
645
+ if (s.backup) await o.exec('systemctl', ['--user', 'enable', '--now', `${s.backup}.timer`]);
646
+ say(`Started ${s.unit}.service again.`);
647
+ }
648
+ if (backupDir && existsSync(backupDir)) say(`The backup taken before the upgrade is ${backupDir}: if a newer release has upgraded the database since, restore it with \`npm run restore -- ${backupDir}\`.`);
649
+ return lines;
650
+ }
651
+
652
+ /** For doctor: what is still under an earlier name, as one line each (empty when nothing is). */
653
+ export function legacyLeftovers(o: { env?: NodeJS.ProcessEnv; userHome?: string } = {}): string[] {
654
+ const env = o.env ?? process.env;
655
+ const userHome = o.userHome ?? (env.HOME?.trim() || homedir());
656
+ const out: string[] = [];
657
+ const found = findLegacyService({ env, userHome });
658
+ if (found) out.push(`The service still runs as ${found.unit}.service, a deprecated name: \`npm run migrate:names\` moves it to botons.service.`);
659
+ let choice: ReturnType<typeof chooseHome> | null = null;
660
+ try {
661
+ choice = chooseHome(env, userHome);
662
+ } catch {}
663
+ const rolledBack = choice ? readRecord(choice.home)?.rolledBack : undefined;
664
+ if (rolledBack) out.push(`The data folder was put back under its earlier names (${rolledBack}): \`npm run migrate:names\` moves it again.`);
665
+ else if (choice?.source === 'legacy') out.push(`The data folder is still ${choice.home}, a deprecated name: the server's next start moves it to ~/${DEFAULT_HOME}.`);
666
+ if (!rolledBack && choice && lstat(join(choice.home, DB_FILE)) && !lstat(join(choice.home, DB_FILE))!.isSymbolicLink()) out.push(`The database is still ${DB_FILE}, a deprecated name: the server's next start renames it botons.db.`);
667
+ return out;
668
+ }