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,97 @@
1
+ /*
2
+ * A Boton's handoff of its managed browser to the owner, as data: the Boton asks (browser_handoff, with a short
3
+ * reason) when a step is the owner's (a sign-in, a captcha, a code), the chat shows a card with Take over, and the
4
+ * owner drives the browser in an overlay over the chat until they hand it back. The card is one chat event whose meta
5
+ * holds this record; the server moves it through these states and nothing else does:
6
+ *
7
+ * requested ──take──▶ taken ──hand back──▶ handed-back
8
+ * └──────close──▶ closed (the Boton asked again, its browser was turned off, or nobody took it in time)
9
+ *
10
+ * Taking over is the existing handover (browser-handover.ts), with all of its rules: the Boton's browser closes and its
11
+ * tools are refused until the owner finishes, and what the owner types goes into the page and nowhere else. Handing
12
+ * back (or the handover ending any other way) wakes the Boton with a note of where the page is now.
13
+ *
14
+ * This file is shared by the server and the web app, and imports nothing.
15
+ */
16
+
17
+ export type HandoffState = 'requested' | 'taken' | 'handed-back' | 'closed';
18
+
19
+ export interface BrowserHandoff {
20
+ state: HandoffState;
21
+ /** The Boton's own words for why, cleaned and short: shown as its words, never as the server's. */
22
+ reason: string;
23
+ /** Where its browser was when it asked (origin and path only), or null. */
24
+ url: string | null;
25
+ requestedAt: string;
26
+ /** A request nobody took by then is closed. */
27
+ expiresAt: string;
28
+ takenAt?: string;
29
+ /** The device that took it over, as the server names it. */
30
+ takenBy?: string;
31
+ handedBackAt?: string;
32
+ /** Where the owner left the page (origin and path only), or null when no page was open. */
33
+ page?: string | null;
34
+ /** How the handover ended: `finished` when the owner handed it back, else why it ended on its own. */
35
+ how?: string;
36
+ /** closed: why, in the server's words. */
37
+ why?: string;
38
+ }
39
+
40
+ export type HandoffEvent =
41
+ | { type: 'take'; at: string; by: string }
42
+ | { type: 'hand-back'; at: string; page: string | null; how: string }
43
+ | { type: 'close'; at: string; why: string };
44
+
45
+ export const HANDOFF_REASON_MAX = 200;
46
+ /** How long a request waits to be taken. */
47
+ export const HANDOFF_TTL_MS = 24 * 3_600_000;
48
+
49
+ /** The reason a Boton gave, cleaned (control and invisible characters out, one line, trimmed), or why it can't be used. */
50
+ export function handoffReason(raw: unknown): string | { why: string } {
51
+ if (typeof raw !== 'string') return { why: 'reason must be a short text, such as "Sign in to example.com"' };
52
+ const t = raw
53
+ .replace(/[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')
54
+ .replace(/\s+/g, ' ')
55
+ .trim();
56
+ if (!t) return { why: 'reason must say what the owner should do, such as "Sign in to example.com"' };
57
+ if (t.length > HANDOFF_REASON_MAX) return { why: `reason is too long (at most ${HANDOFF_REASON_MAX} characters)` };
58
+ return t;
59
+ }
60
+
61
+ /** A new request, as the Boton made it. */
62
+ export function requestHandoff(reason: string, url: string | null, at: Date): BrowserHandoff {
63
+ return { state: 'requested', reason, url, requestedAt: at.toISOString(), expiresAt: new Date(at.getTime() + HANDOFF_TTL_MS).toISOString() };
64
+ }
65
+
66
+ /** Still waiting on the owner to take it: requested and not past its expiry. */
67
+ export function handoffWaiting(h: BrowserHandoff, now: string): boolean {
68
+ return h.state === 'requested' && h.expiresAt > now;
69
+ }
70
+
71
+ /** Open: waiting to be taken, or taken and not yet handed back. */
72
+ export function handoffOpen(h: BrowserHandoff, now: string): boolean {
73
+ return handoffWaiting(h, now) || h.state === 'taken';
74
+ }
75
+
76
+ /**
77
+ * The record after `e`, or null when `e` can't happen from where it stands: only a waiting request is taken (a
78
+ * second take of a taken one changes nothing and is null too), only a taken one is handed back, and only a waiting
79
+ * one is closed. A request past its expiry can only be closed.
80
+ */
81
+ export function handoffStep(h: BrowserHandoff, e: HandoffEvent): BrowserHandoff | null {
82
+ switch (e.type) {
83
+ case 'take':
84
+ return handoffWaiting(h, e.at) ? { ...h, state: 'taken', takenAt: e.at, takenBy: e.by } : null;
85
+ case 'hand-back':
86
+ return h.state === 'taken' ? { ...h, state: 'handed-back', handedBackAt: e.at, page: e.page, how: e.how } : null;
87
+ case 'close':
88
+ return h.state === 'requested' ? { ...h, state: 'closed', why: e.why } : null;
89
+ }
90
+ }
91
+
92
+ /** What the Boton reads when it is woken by the hand-back: the server's words, never the owner's. */
93
+ export function handBackNote(owner: string, h: BrowserHandoff): string {
94
+ const done = h.how === 'finished' ? `${owner} finished in your browser and handed it back` : `${owner}'s turn in your browser ended (${h.how ?? 'it ended'})`;
95
+ const page = h.page ? ` The page is now ${h.page}: open it to go on.` : ' No page was open.';
96
+ return `(From the Botons server: ${done}. You asked for: “${h.reason}”.${page} Read it before you assume the step worked.)`;
97
+ }
@@ -0,0 +1,7 @@
1
+ export type InstallType = 'managed' | 'docker' | 'service';
2
+ /** Arguments are hints for a human, never executed by a shell. */
3
+ export function installCommand(type: InstallType, action: 'pair' | 'backup' | 'restore' | 'doctor' | 'repair' | 'serve', args = ''): string {
4
+ if (action === 'repair') return type === 'service' ? 'npm run build' : type === 'docker' ? 'docker compose up -d --build botons' : '~/.local/bin/botons repair';
5
+ const base = type === 'docker' ? `docker compose exec botons botons ${action}` : type === 'managed' ? `~/.local/bin/botons ${action}` : `npm run ${action}`;
6
+ return base + (args ? `${type === 'service' ? ' --' : ''} ${args}` : '');
7
+ }
@@ -0,0 +1,35 @@
1
+ // Types for legacy-names.mjs (see there).
2
+ export interface LegacyEnvNotice {
3
+ /** The earlier name that is set. */
4
+ old: string;
5
+ /** Its current name. */
6
+ name: string;
7
+ /** Both are set, to different values: `name`'s is used. */
8
+ conflict: boolean;
9
+ }
10
+ export const ENV_PREFIXES: readonly (readonly [old: string, now: string])[];
11
+ export function currentEnvName(name: string): string | null;
12
+ export function earlierEnvNames(name: string): string[];
13
+ export function applyLegacyEnv(env: Record<string, string | undefined>): LegacyEnvNotice[];
14
+ export function legacyEnvNoticeText(n: LegacyEnvNotice): string;
15
+ export function isLegacySetting(name: string): boolean;
16
+ export function withLegacyTurnNames(env: Record<string, string>): Record<string, string>;
17
+ export function rewriteEnvText(text: string, moves?: readonly (readonly [from: string, to: string])[]): string;
18
+ export const DATA_DIRS: readonly string[];
19
+ export const CONFIG_DIRS: readonly string[];
20
+ export const SHARE_DIRS: readonly string[];
21
+ export const DB_FILE: string;
22
+ export const UNITS: readonly { readonly service: string; readonly backup: string }[];
23
+ export const CLI_NAMES: readonly string[];
24
+ export const IMAGE_CLI_NAMES: readonly string[];
25
+ export const PATH_BLOCK_NAMES: readonly string[];
26
+ export const RENAMED: Readonly<Record<string, string>>;
27
+ export function earlierName(now: string): string | null;
28
+ export function currentName(value: string): string;
29
+ export function storedValue(storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>, key: string): string | null;
30
+ export function currentPortable<T>(file: T): T;
31
+ export const PRIVATE_TERMS_FILES: readonly string[];
32
+ export function earlierStorageKey(key: string): string | null;
33
+ export const OLD_TEXT: RegExp;
34
+ export const OLD_WORD: RegExp;
35
+ export const FORMERLY: RegExp;
@@ -0,0 +1,244 @@
1
+ // LEGACY NAMES: the one place that knows what Botons was called before (Minions, then Tots).
2
+ //
3
+ // Everything that still reads, accepts or moves an earlier name gets it from this file, for one release after the
4
+ // rename. To drop them: delete this file and legacy-names.d.mts, delete server/src/legacy-upgrade.ts,
5
+ // scripts/migrate-names.ts, docker/legacy-names.sh and server/test/legacy-names.test.ts, then fix what the type
6
+ // checker lists. None of these names may appear in docs, help text or the app; doctor names them once, as
7
+ // deprecated. Plain JavaScript with no imports, so the server, its scripts, the web app and the installer (which
8
+ // may run on an older Node) can all load it.
9
+
10
+ /** The earlier names as words in text, in any case (check:firstrun looks for them on every screen). */
11
+ export const OLD_TEXT = /\b(minions?|tots?)\b/i;
12
+
13
+ /** Earlier setting prefixes and their current ones, newest first: where two are set, the newer one wins. */
14
+ export const ENV_PREFIXES = Object.freeze([
15
+ ['TOTS_', 'BOTONS_'],
16
+ ['TOT_', 'BOTON_'],
17
+ ['MINIONS_', 'BOTONS_'],
18
+ ['MINION_', 'BOTON_'],
19
+ ]);
20
+
21
+ /** The current name of an earlier setting name, or null when `name` is not one. */
22
+ export function currentEnvName(name) {
23
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) return null;
24
+ for (const [old, now] of ENV_PREFIXES) if (name.startsWith(old) && name.length > old.length) return now + name.slice(old.length);
25
+ return null;
26
+ }
27
+
28
+ /** The earlier names of a current setting name, newest first (none when it has no earlier names). */
29
+ export function earlierEnvNames(name) {
30
+ const out = [];
31
+ for (const [old, now] of ENV_PREFIXES) if (name.startsWith(now) && name.length > now.length) out.push(old + name.slice(now.length));
32
+ return out;
33
+ }
34
+
35
+ const isSet = (v) => v !== undefined && v !== null && String(v).trim() !== '';
36
+ const rank = (name) => ENV_PREFIXES.findIndex(([old]) => name.startsWith(old));
37
+
38
+ /**
39
+ * Reads each earlier setting name as its current one, in place: where the current name is unset, it gets the
40
+ * newest earlier name's value. Earlier names are left as they are. Idempotent. One notice per earlier name that is
41
+ * set: `conflict` when its value differs from the one used. Notices name variables, never values.
42
+ */
43
+ export function applyLegacyEnv(env) {
44
+ const notices = [];
45
+ const olds = Object.keys(env)
46
+ .filter((k) => currentEnvName(k) && isSet(env[k]))
47
+ .sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1));
48
+ for (const old of olds) {
49
+ const name = currentEnvName(old);
50
+ if (!isSet(env[name])) {
51
+ env[name] = env[old];
52
+ notices.push({ old, name, conflict: false });
53
+ } else notices.push({ old, name, conflict: String(env[name]).trim() !== String(env[old]).trim() });
54
+ }
55
+ return notices;
56
+ }
57
+
58
+ /** Doctor's one line about a notice: names only. */
59
+ export function legacyEnvNoticeText(n) {
60
+ return n.conflict
61
+ ? `${n.old} (deprecated) and ${n.name} are both set, to different values: ${n.name} is used. Remove ${n.old}.`
62
+ : `${n.old} is deprecated and works for this release only: rename it to ${n.name}.`;
63
+ }
64
+
65
+ /** True for an earlier setting name. */
66
+ export const isLegacySetting = (name) => currentEnvName(name) !== null;
67
+
68
+ /**
69
+ * A turn's variables under their earlier names too (BOTON_TOKEN also as the two earlier names), for one release,
70
+ * so an agent's own scripts and saved procedures keep working. The values are the same, so nothing more is exposed.
71
+ */
72
+ export function withLegacyTurnNames(env) {
73
+ const out = { ...env };
74
+ for (const [k, v] of Object.entries(env)) for (const old of earlierEnvNames(k)) if (out[old] === undefined) out[old] = v;
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * An env file's text with each earlier setting name written as its current one, and each path in `moves`
80
+ * ([from, to]) as its new path. Where a setting is set under more than one name, only the line the server would use
81
+ * stays (the current name, else the newest earlier one), so the file means what it meant.
82
+ */
83
+ export function rewriteEnvText(text, moves = []) {
84
+ const lines = text.split('\n');
85
+ const parse = (line) => /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
86
+ const valueOf = (raw) => (/^"(.*)"$/.test(raw) || /^'(.*)'$/.test(raw) ? raw.slice(1, -1) : raw);
87
+ // The line whose value the server uses for each setting: the current name's, else the newest earlier name's, and
88
+ // an empty value counts as unset (as applyLegacyEnv reads them). The last of the same name wins, as in a shell.
89
+ // A name assigned twice means its last assignment (an empty one too): only that line counts.
90
+ const last = new Map();
91
+ lines.forEach((line, i) => {
92
+ const m = parse(line);
93
+ if (m) last.set(m[1], i);
94
+ });
95
+ const winner = new Map();
96
+ lines.forEach((line, i) => {
97
+ const m = parse(line);
98
+ if (!m || last.get(m[1]) !== i) return;
99
+ const now = currentEnvName(m[1]) ?? m[1];
100
+ const r = currentEnvName(m[1]) ? rank(m[1]) : -1;
101
+ const set = isSet(valueOf(m[2]));
102
+ const best = winner.get(now);
103
+ const better = !best || (set && !best.set) || (set === best.set && (r < best.r || (r === best.r && i > best.i)));
104
+ if (better) winner.set(now, { i, r, set, legacy: (best?.legacy ?? false) || r >= 0 });
105
+ else best.legacy ||= r >= 0;
106
+ });
107
+ for (const [name] of last) if (currentEnvName(name)) winner.get(currentEnvName(name)).legacy = true;
108
+ const out = [];
109
+ lines.forEach((line, i) => {
110
+ const m = parse(line);
111
+ let next = line;
112
+ if (m) {
113
+ const now = currentEnvName(m[1]) ?? m[1];
114
+ // Only settings with an earlier name lose lines; anything else stays as it was written.
115
+ if (winner.get(now).legacy && winner.get(now).i !== i) return;
116
+ if (now !== m[1]) next = line.replace(m[1], now);
117
+ for (const [from, to] of moves) next = replacePathPrefix(next, from, to);
118
+ }
119
+ out.push(next);
120
+ });
121
+ return out.join('\n');
122
+ }
123
+
124
+ /** `text` with each whole-path occurrence of `from` (followed by /, a quote, a separator or the end) as `to`. */
125
+ function replacePathPrefix(text, from, to) {
126
+ const esc = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
127
+ return text.replace(new RegExp(`${esc}(?=$|[/"':;\\s])`, 'g'), to);
128
+ }
129
+
130
+ /** Earlier data folders under the user's home, newest first. The current one is ~/.botons. */
131
+ export const DATA_DIRS = Object.freeze(['.tots', '.minions']);
132
+ /** Earlier settings folders under XDG_CONFIG_HOME (~/.config), each with an `env` file. */
133
+ export const CONFIG_DIRS = Object.freeze(['tots', 'minions']);
134
+ /** Earlier release roots under ~/.local/share (deploy's and the installer's). */
135
+ export const SHARE_DIRS = Object.freeze(['tots', 'minions']);
136
+ /** The database's earlier file name. */
137
+ export const DB_FILE = 'minions.db';
138
+ /** Earlier systemd user units: the server's, and its nightly backup's (a .service and a .timer). */
139
+ export const UNITS = Object.freeze([
140
+ { service: 'tots', backup: 'tots-backup' },
141
+ { service: 'minions', backup: 'minions-backup' },
142
+ ]);
143
+ /** Earlier names of the Botons command (the agents' CLI and the installer's admin command). */
144
+ export const CLI_NAMES = Object.freeze(['tots', 'minion']);
145
+ /** Earlier names in the marked PATH block the installer adds to a shell's rc file. */
146
+ export const PATH_BLOCK_NAMES = Object.freeze(['tots']);
147
+ /** Earlier names of check:public's private terms list in ~/.config/private-terms (outside the repo). */
148
+ export const PRIVATE_TERMS_FILES = Object.freeze(['minions.txt']);
149
+ /** Earlier names of the image's admin command (docker/botons); the Dockerfile links each to it. */
150
+ export const IMAGE_CLI_NAMES = Object.freeze(['tots', 'minions']);
151
+
152
+ /**
153
+ * Earlier names of what the server stores, sends and reads back, by their current name: an actor's kind (audit
154
+ * events, reactions, turn tokens), the managed browser's connector, export file formats, webhook and callback
155
+ * headers. Data under them is moved by the schema upgrade, read on import, accepted or sent beside the current one.
156
+ */
157
+ export const RENAMED = Object.freeze({
158
+ boton: 'minion',
159
+ botons_browser: 'minions_browser',
160
+ 'botons-config': 'minions-config',
161
+ 'botons-sidebar': 'minions-sidebar',
162
+ 'botons-reactions': 'minions-reactions',
163
+ 'x-boton-key': 'x-minion-key',
164
+ 'x-boton-signature': 'x-minion-signature',
165
+ 'x-boton-run': 'x-minion-run',
166
+ });
167
+ /** The earlier name of a renamed value, or null. */
168
+ export const earlierName = (now) => (Object.hasOwn(RENAMED, now) ? RENAMED[now] : null);
169
+ /** The current name of a value, if it is an earlier one; otherwise the value itself. */
170
+ export function currentName(value) {
171
+ for (const [now, old] of Object.entries(RENAMED)) if (value === old) return now;
172
+ return value;
173
+ }
174
+
175
+ /** Earlier keys of the web app's own storage on a device, by current key (or key prefix, ending in "."). */
176
+ const STORAGE = Object.freeze({
177
+ 'botons.deviceToken': 'minions.deviceToken',
178
+ 'botons.avatarMotion': 'minions.avatarMotion',
179
+ 'botons.webAccessSuggestion.': 'tots.webAccessSuggestion.',
180
+ 'botons.theme': 'tots.theme',
181
+ });
182
+ /** A storage key's earlier name, or null (for web/index.html's first paint, which runs before any module loads). */
183
+ export const earlierStorageKey = (key) => (Object.hasOwn(STORAGE, key) ? STORAGE[key] : null);
184
+ /**
185
+ * `key`'s value in `storage`, taken over from its earlier key the first time (written under `key`, the earlier one
186
+ * removed), so a device stays paired and keeps its choices. Null when neither is set.
187
+ */
188
+ export function storedValue(storage, key) {
189
+ const now = storage.getItem(key);
190
+ if (now !== null) return now;
191
+ const entry = Object.entries(STORAGE).find(([k]) => k === key || (k.endsWith('.') && key.startsWith(k)));
192
+ if (!entry) return null;
193
+ const old = entry[0] === key ? entry[1] : entry[1] + key.slice(entry[0].length);
194
+ const value = storage.getItem(old);
195
+ if (value === null) return null;
196
+ try {
197
+ storage.setItem(key, value);
198
+ storage.removeItem(old);
199
+ } catch {}
200
+ return value;
201
+ }
202
+
203
+ /**
204
+ * A configuration export made under an earlier name, with the names this server reads: its format, its Botons'
205
+ * list and the primary Boton's setting, its sidebar's format, a skill's author kind and the managed browser's
206
+ * connector id. Anything
207
+ * else is left as it is; a file under the current names comes back unchanged.
208
+ */
209
+ export function currentPortable(file) {
210
+ if (!file || typeof file !== 'object' || Array.isArray(file) || file.format !== RENAMED['botons-config']) return file;
211
+ const browser = `lib:${RENAMED.botons_browser}`;
212
+ const out = { ...file, format: 'botons-config' };
213
+ // An owner's own connector already named as the browser is now takes a free name first, with every grant of it.
214
+ const map = new Map([[browser, 'lib:botons_browser']]);
215
+ const connectors = Array.isArray(out.connectors) ? out.connectors : [];
216
+ if (connectors.some((c) => c && (c.id === 'lib:botons_browser' || c.name === 'botons_browser'))) {
217
+ const used = new Set(connectors.map((c) => c && c.name));
218
+ let n = 2;
219
+ while (used.has(`botons_browser_${n}`)) n++;
220
+ map.set('lib:botons_browser', `lib:botons_browser_${n}`);
221
+ out.connectors = connectors.map((c) => (c && (c.id === 'lib:botons_browser' || c.name === 'botons_browser') ? { ...c, id: `lib:botons_browser_${n}`, name: `botons_browser_${n}` } : c));
222
+ }
223
+ const ids = (v) => (Array.isArray(v) ? v.map((x) => map.get(x) ?? x) : v);
224
+ if (Array.isArray(out.connectors)) out.connectors = out.connectors.map((c) => (c && c.id === browser ? { ...c, id: 'lib:botons_browser', name: 'botons_browser' } : c));
225
+ if ('minions' in out) {
226
+ out.botons = Array.isArray(out.minions) ? out.minions.map((m) => (m && typeof m === 'object' && !Array.isArray(m) ? { ...m, connections: ids(m.connections) } : m)) : out.minions;
227
+ delete out.minions;
228
+ }
229
+ if (out.settings && typeof out.settings === 'object' && !Array.isArray(out.settings) && 'primaryMinion' in out.settings) {
230
+ const { primaryMinion, ...settings } = out.settings;
231
+ out.settings = { ...settings, primaryBoton: primaryMinion };
232
+ }
233
+ if (out.sidebar && typeof out.sidebar === 'object' && out.sidebar.format === RENAMED['botons-sidebar']) out.sidebar = { ...out.sidebar, format: 'botons-sidebar' };
234
+ if (Array.isArray(out.skills)) {
235
+ out.skills = out.skills.map((k) => (k && typeof k === 'object' && k.author && typeof k.author === 'object' && k.author.kind === RENAMED.boton ? { ...k, author: { ...k.author, kind: 'boton' } } : k));
236
+ }
237
+ if (Array.isArray(out.templates)) out.templates = out.templates.map((t) => (t && typeof t === 'object' && t.bot && typeof t.bot === 'object' ? { ...t, bot: { ...t.bot, connections: ids(t.bot.connections) } } : t));
238
+ return out;
239
+ }
240
+
241
+ /** The earlier names themselves, as words: check:public refuses them anywhere this module doesn't allow (oldNames). */
242
+ export const OLD_WORD = /^(minions?|tots?)$/i;
243
+ /** The changelog's one line that says what the product was called before. */
244
+ export const FORMERLY = /\(formerly Minions, and then Tots\)/;
@@ -0,0 +1,94 @@
1
+ /*
2
+ * The live view of a handover (the owner driving a Boton's browser), as pure parts both sides use: the stream's
3
+ * chunks, and where a point on the owner's screen lands in the page.
4
+ *
5
+ * The stream is one long response of chunks, each a kind byte, a 4-byte big-endian length, then that many bytes:
6
+ * LIVE_FRAME is a frame (2 bytes each of its CSS width and height, then the JPEG), LIVE_NOTICE a notice (UTF-8
7
+ * JSON, a HandoverNotice). It imports nothing.
8
+ */
9
+
10
+ export const LIVE_FRAME = 1;
11
+ export const LIVE_NOTICE = 2;
12
+ /** No chunk is longer (a frame of the largest page at twice its pixels is far less). */
13
+ export const LIVE_CHUNK_MAX = 16 * 1024 * 1024;
14
+
15
+ function chunk(kind: number, payload: Uint8Array): Uint8Array {
16
+ const out = new Uint8Array(5 + payload.length);
17
+ out[0] = kind;
18
+ new DataView(out.buffer).setUint32(1, payload.length);
19
+ out.set(payload, 5);
20
+ return out;
21
+ }
22
+
23
+ export function liveFrame(jpeg: Uint8Array, width: number, height: number): Uint8Array {
24
+ const body = new Uint8Array(4 + jpeg.length);
25
+ const v = new DataView(body.buffer);
26
+ v.setUint16(0, Math.max(0, Math.min(0xffff, Math.round(width))));
27
+ v.setUint16(2, Math.max(0, Math.min(0xffff, Math.round(height))));
28
+ body.set(jpeg, 4);
29
+ return chunk(LIVE_FRAME, body);
30
+ }
31
+
32
+ export function liveNotice(notice: unknown): Uint8Array {
33
+ return chunk(LIVE_NOTICE, new TextEncoder().encode(JSON.stringify(notice)));
34
+ }
35
+
36
+ export type LiveChunk = { kind: 'frame'; width: number; height: number; jpeg: Uint8Array } | { kind: 'notice'; notice: unknown };
37
+
38
+ /** Reads chunks from the stream's bytes as they arrive, in whatever pieces the network gives them. */
39
+ export class LiveParser {
40
+ #buf = new Uint8Array(0);
41
+
42
+ push(piece: Uint8Array): LiveChunk[] {
43
+ const joined = new Uint8Array(this.#buf.length + piece.length);
44
+ joined.set(this.#buf);
45
+ joined.set(piece, this.#buf.length);
46
+ const out: LiveChunk[] = [];
47
+ let at = 0;
48
+ while (joined.length - at >= 5) {
49
+ const v = new DataView(joined.buffer, joined.byteOffset + at);
50
+ const kind = v.getUint8(0);
51
+ const len = v.getUint32(1);
52
+ if (len > LIVE_CHUNK_MAX) throw new Error('The live view sent a chunk too large to be one');
53
+ if (joined.length - at - 5 < len) break;
54
+ const body = joined.slice(at + 5, at + 5 + len);
55
+ at += 5 + len;
56
+ if (kind === LIVE_FRAME && body.length >= 4) {
57
+ const b = new DataView(body.buffer);
58
+ out.push({ kind: 'frame', width: b.getUint16(0), height: b.getUint16(2), jpeg: body.slice(4) });
59
+ } else if (kind === LIVE_NOTICE) {
60
+ try {
61
+ out.push({ kind: 'notice', notice: JSON.parse(new TextDecoder().decode(body)) });
62
+ } catch {}
63
+ }
64
+ }
65
+ this.#buf = joined.slice(at);
66
+ return out;
67
+ }
68
+ }
69
+
70
+ export interface Rect {
71
+ left: number;
72
+ top: number;
73
+ width: number;
74
+ height: number;
75
+ }
76
+
77
+ /**
78
+ * Where the owner's pointer at (`clientX`, `clientY`) lands in the page: `box` is the view's box on screen, in which
79
+ * the frame is drawn whole and centred (object-fit: contain, so a frame of another shape leaves bands), and `page` the
80
+ * page's size in CSS pixels that the frame shows. Null on a band or outside the frame. Screen pixel ratios cancel out:
81
+ * the box and the client point are both CSS pixels of the owner's screen, and the page is CSS pixels of the page.
82
+ */
83
+ export function toPagePoint(clientX: number, clientY: number, box: Rect, page: { width: number; height: number }): { x: number; y: number } | null {
84
+ if (!(box.width > 0 && box.height > 0 && page.width > 0 && page.height > 0)) return null;
85
+ const scale = Math.min(box.width / page.width, box.height / page.height);
86
+ const drawnW = page.width * scale;
87
+ const drawnH = page.height * scale;
88
+ const left = box.left + (box.width - drawnW) / 2;
89
+ const top = box.top + (box.height - drawnH) / 2;
90
+ const x = (clientX - left) / scale;
91
+ const y = (clientY - top) / scale;
92
+ if (x < 0 || y < 0 || x >= page.width || y >= page.height) return null;
93
+ return { x: Math.min(page.width - 1, Math.floor(x)), y: Math.min(page.height - 1, Math.floor(y)) };
94
+ }