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.
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getbotons",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A self-hosted home for AI agents that runs your own Claude Code or Codex.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"agents",
|
|
8
|
+
"self-hosted",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/boundsj/botons#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/boundsj/botons.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/boundsj/botons/issues"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"bin": {
|
|
23
|
+
"botons": "installer/botons.mjs"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/payload/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jesse Bounds
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please report it privately, never in a public issue or pull request: use [**Report a vulnerability**](https://github.com/boundsj/botons/security/advisories/new) on the repository's **Security** tab (GitHub's private vulnerability reporting). Include what you found, how to reproduce it, and what it lets an attacker do. You should hear back within a week; a fix and an advisory follow once it is confirmed.
|
|
6
|
+
|
|
7
|
+
Only the latest release and `main` get security fixes.
|
|
8
|
+
|
|
9
|
+
## What Botons trusts
|
|
10
|
+
|
|
11
|
+
Botons is a single-owner server: it runs agent CLIs, as the server's user, on the owner's behalf. Read these before exposing it.
|
|
12
|
+
|
|
13
|
+
- **A paired device is the owner.** It can read every chat, change every Boton and approve what Botons ask for. Devices pair once: a new device asks and shows a code, and the owner approves that code from a paired device or on the server's machine (`npm run pair -- --approve`); the owner's own Tailscale devices pair with one click. The server can't tell a browser from a Boton that leaves out its token, so a code is never a credential: only the device that asked holds the secret the approval unlocks, and a code seen in a chat pairs nothing. Tokens and request secrets are stored hashed. A new server is claimed the same way, with the first approval only from the server's machine, so reaching the page first is not enough to own it. No Boton token can ask, approve or pair.
|
|
14
|
+
- **The access mode** (`local`, `tailscale`, `lan`, `public`) refuses requests from other networks on the app's port before anything else runs. It only narrows what the listeners expose; widening exposure is always a change the owner makes on the host. Public needs HTTPS and auth, and no device can choose a mode that would refuse itself.
|
|
15
|
+
- **The audit log** records pairing, access, setup, approvals and what each Boton was given, by whom; never a secret. Botons can't read it, and nothing in the API deletes it.
|
|
16
|
+
- **A Boton with Host access is the server's user.** It can read and change everything that user can: the database, every workspace, the CLI logins, the server's environment. In Docker that is everything in the container.
|
|
17
|
+
- **A Boton with Workspace access** runs in the CLI's sandbox, which keeps it out of other workspaces, `BOTONS_HOME`, the CLI logins and the common credential folders listed in `server/src/isolation.ts` (plus `BOTONS_PRIVATE_PATHS`).
|
|
18
|
+
- **Runs started by a webhook** use Workspace access by default, even for a Boton with Host access, and then can't change the instruction files later turns load. The owner can turn that off for a routine (**Webhook runs use Workspace access** in its settings; `webhookWorkspace: false`): then a Host-access Boton's webhook runs have Host access, like its other turns, and the instruction files get no such protection. Only the owner can turn it off, never a Boton, and it never gives a Workspace Boton more than Workspace access.
|
|
19
|
+
- **Webhooks** need the routine's own key and, where configured, a signature; they are rate limited. A signed sender's signature is checked on the exact bytes before the body is parsed, and a form body has at most 1000 fields. `BOTONS_HOOKS_PORT` serves webhooks alone, for the internet, while the app stays private. A webhook run never carries on the chat's session, whatever the routine's `session` says: it sees none of the chat unless the owner shares the latest messages with that routine's webhook runs (`webhookHistory`, at most 20, inside a budget of its own), and then each run starts a fresh session with those alone, so no run sees more. Only the owner can share them; a Boton can only set it back to 0. Whoever holds the routine's key can read each run's reply.
|
|
20
|
+
- **Ask first** pauses a covered tool call for the owner. **Allow once** allows that one call with exactly the arguments shown (only a Bash command's description, the model's own words about it, may differ), and only once: another call, even an identical one made at the same moment, waits for a decision of its own.
|
|
21
|
+
- **Botons asking Botons**: a Boton answers each colleague's asks in a turn of their own, apart from the owner's messages and other colleagues' asks: none still waiting is in that turn's input, or in the chat replayed to a new session, and the owner's reactions go only to the owner's turns. The turn still has the Boton's conversation so far, as any turn does.
|
|
22
|
+
- **Local MCP servers** (a connector that is a command on this computer) start in the Workspace sandbox's view of the computer for every Boton, whatever its access: bubblewrap with an empty read-only root, the system's programs and libraries, the folders on `PATH` and the folders the command and its absolute-path arguments name, each read-only, and an empty `/tmp` of their own; no workspace, shared folder, data folder or other home folder, by any path, a second mount (bind mount) of one included. A connector naming something in those is refused. Unlike Workspace commands they keep the host's network (Unix sockets stay refused), so the Botons given one and its saved environment limit what it is handed, not what it can reach over the network. Without bubblewrap none start (`server/src/connector-launch.ts`).
|
|
23
|
+
- **Outbound requests** (routine callbacks, OAuth connectors) refuse private and local addresses unless allowed by `BOTONS_CALLBACK_ALLOW` or `BOTONS_OAUTH_ALLOW`, and follow no redirects.
|
|
24
|
+
- **The browser**: every request's `Host` must be one of the server's names (against DNS rebinding), and changes need a same-origin JSON request.
|
|
25
|
+
|
|
26
|
+
## Deploying it safely
|
|
27
|
+
|
|
28
|
+
- Keep the app off the open internet: the default listens on `127.0.0.1` and the machine's Tailscale address, and the Docker setup publishes on `127.0.0.1`. Use Tailscale (or HTTPS behind a reverse proxy) to reach it from other devices; never plain HTTP across a network you don't control.
|
|
29
|
+
- Keep authentication enabled during first-run setup and pairing. Approve the code on the server instead of disabling auth.
|
|
30
|
+
- Pairing admission is capped at 50 unexpired requests; new requests receive 429 when full. Accepted codes remain valid until expiry, but sustained unauthenticated traffic can delay new requests. Keep the app private and use proxy rate limits when exposing it.
|
|
31
|
+
- Never put credentials in the server's environment: Botons with Host access can read it.
|
|
32
|
+
- Back up `BOTONS_HOME` (`npm run backup`, or `botons backup` in Docker): it holds chat history and webhook keys, and the backups are private to the server's user.
|
|
33
|
+
- `npm run doctor` (`botons doctor` in Docker) flags auth off, exposed interfaces, plain HTTP, missing backups and a broken sandbox.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { serverEnv } from '../scripts/install-service.ts';
|
|
4
|
+
const [script, ...args] = process.argv.slice(2);
|
|
5
|
+
const result = spawnSync(process.execPath, [realpathSync(script), ...args], { env: serverEnv(undefined).env, stdio: 'inherit' });
|
|
6
|
+
process.exit(result.status ?? 1);
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, lstatSync, readlinkSync, statSync, readdirSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, cpSync, rmSync, symlinkSync, renameSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir, arch, platform, tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
7
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { createInterface } from 'node:readline/promises';
|
|
9
|
+
import { cliInstallPlan, installCli, cliPresent } from './brain-clis.mjs';
|
|
10
|
+
import { installerRevision } from './revision.mjs';
|
|
11
|
+
import { configureLinger, lingerCommand } from './linger.mjs';
|
|
12
|
+
import { configurePath, onPath, removePathBlock } from './shell-path.mjs';
|
|
13
|
+
import { plan, summary, installOutput } from './output.mjs';
|
|
14
|
+
import { CLI_NAMES, DB_FILE, SHARE_DIRS, UNITS, earlierEnvNames } from '../shared/legacy-names.mjs';
|
|
15
|
+
let output;
|
|
16
|
+
const envelope = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const source = existsSync(join(envelope, 'payload/package.json')) ? join(envelope, 'payload') : envelope;
|
|
18
|
+
export function parse(args) {
|
|
19
|
+
const o = { command: 'install' };
|
|
20
|
+
if (args[0] && !args[0].startsWith('-')) o.command = args.shift();
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const key = args[i];
|
|
23
|
+
if (['--yes', '--dry-run', '--no-sudo', '--no-service', '--purge', '--verbose'].includes(key)) o[key.slice(2)] = true;
|
|
24
|
+
else if (['--home', '--host', '--public-url', '--ref', '--source', '--brain'].includes(key) && args[i + 1] && !args[i + 1].startsWith('--')) o[key.slice(2)] = args[++i];
|
|
25
|
+
else throw new Error(`Unknown or incomplete option: ${key}`);
|
|
26
|
+
}
|
|
27
|
+
if (!['install', 'upgrade', 'repair', 'uninstall', 'doctor', 'backup', 'restore', 'pair', 'serve'].includes(o.command)) throw new Error('Unknown command');
|
|
28
|
+
for (const key of ['home', 'host', 'public-url']) if (o[key] && /[\r\n\0]/.test(o[key])) throw new Error(`Invalid ${key}`);
|
|
29
|
+
if (o.brain && !['claude', 'codex'].includes(o.brain)) throw new Error('--brain requires claude or codex');
|
|
30
|
+
return o;
|
|
31
|
+
}
|
|
32
|
+
export function verified(bytes, sums, filename) {
|
|
33
|
+
const line = sums.split('\n').find(l => l.endsWith(` ${filename}`));
|
|
34
|
+
if (!line || line.split(' ')[0] !== createHash('sha256').update(bytes).digest('hex')) throw new Error('Node archive checksum mismatch');
|
|
35
|
+
}
|
|
36
|
+
export function publicSettings(text, value) {
|
|
37
|
+
const url = new URL(value);
|
|
38
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('--public-url requires an HTTPS origin without credentials');
|
|
39
|
+
const kept = text.split('\n').filter(line => !/^\s*(?:export\s+)?BOTONS_(?:OAUTH_)?PUBLIC_URL\s*=/.test(line)).join('\n');
|
|
40
|
+
return kept.trimEnd() + `\nBOTONS_PUBLIC_URL=${JSON.stringify(url.origin)}\nBOTONS_OAUTH_PUBLIC_URL=${JSON.stringify(url.origin)}\n`;
|
|
41
|
+
}
|
|
42
|
+
async function consent(message, yes = false) {
|
|
43
|
+
if (yes) return;
|
|
44
|
+
if (!process.stdin.isTTY) throw new Error(`${message}: use --yes to consent`);
|
|
45
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
+
try { const answer = (await rl.question(`${message} [Y/n] `)).trim().toLowerCase(); if (answer && answer !== 'y' && answer !== 'yes') throw new Error('Cancelled'); } finally { rl.close(); }
|
|
47
|
+
}
|
|
48
|
+
const run = (cmd, args, env = process.env, cwd) => {
|
|
49
|
+
output?.detail(JSON.stringify([cmd, ...args]));
|
|
50
|
+
try { const result = execFileSync(cmd, args, { stdio: output?.stdio || 'inherit', env, cwd }); output?.replay(); return result; }
|
|
51
|
+
catch { throw new Error('An installation command failed'); }
|
|
52
|
+
};
|
|
53
|
+
async function download(url) {
|
|
54
|
+
const r = await fetch(url); if (!r.ok) throw new Error(`Download failed: ${r.status}`); return Buffer.from(await r.arrayBuffer());
|
|
55
|
+
}
|
|
56
|
+
export function sourceVersion(input) {
|
|
57
|
+
const base = JSON.parse(readFileSync(join(input, 'package.json'))).version || '0.0.0';
|
|
58
|
+
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][a-zA-Z0-9.-]+)?$/.test(base)) throw new Error('Source needs a semantic package version');
|
|
59
|
+
if (!existsSync(join(input, '.git'))) {
|
|
60
|
+
if (JSON.parse(readFileSync(join(input, 'package.json'))).version) return base;
|
|
61
|
+
const hash = createHash('sha256');
|
|
62
|
+
for (const item of ['installer', 'server', 'shared', 'scripts', 'web', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'LICENSE']) {
|
|
63
|
+
const root = join(input, item);
|
|
64
|
+
if (!existsSync(root)) continue;
|
|
65
|
+
const visit = (path, name) => {
|
|
66
|
+
if (['node_modules', '.git'].includes(name.split('/').at(-1))) return;
|
|
67
|
+
if (statSync(path).isDirectory()) {
|
|
68
|
+
for (const child of readdirSync(path).sort()) visit(join(path, child), name + '/' + child);
|
|
69
|
+
} else {
|
|
70
|
+
const bytes = readFileSync(path);
|
|
71
|
+
hash.update(JSON.stringify([name, bytes.length])).update(bytes);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
visit(root, item);
|
|
75
|
+
}
|
|
76
|
+
return `${base}+source.${hash.digest('hex')}`;
|
|
77
|
+
}
|
|
78
|
+
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: input, encoding: 'utf8' }).trim();
|
|
79
|
+
return `${base.split('+')[0]}+git.${sha}`;
|
|
80
|
+
}
|
|
81
|
+
async function nodeRuntime(root) {
|
|
82
|
+
const index = JSON.parse((await download('https://nodejs.org/dist/index.json')).toString());
|
|
83
|
+
const version = index.find(v => /^v24\./.test(v.version)).version;
|
|
84
|
+
const dest = join(root, 'node', version);
|
|
85
|
+
if (validRuntime(join(dest, 'bin/node'))) return join(dest, 'bin/node');
|
|
86
|
+
rmSync(dest, { recursive: true, force: true });
|
|
87
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
88
|
+
const filename = `node-${version}-linux-${arch() === 'arm64' ? 'arm64' : 'x64'}.tar.xz`;
|
|
89
|
+
const base = `https://nodejs.org/dist/${version}/`;
|
|
90
|
+
const bytes = await download(base + filename);
|
|
91
|
+
verified(bytes, (await download(base + 'SHASUMS256.txt')).toString(), filename);
|
|
92
|
+
const staging = join(root, `.runtime-${randomUUID()}`);
|
|
93
|
+
mkdirSync(staging); const archive = join(staging, 'node.tar.xz'); writeFileSync(archive, bytes);
|
|
94
|
+
try { run('tar', ['-xJf', archive, '--strip-components=1', '-C', staging]); rmSync(archive); renameSync(staging, dest); }
|
|
95
|
+
finally { rmSync(staging, { recursive: true, force: true }); }
|
|
96
|
+
return join(dest, 'bin/node');
|
|
97
|
+
}
|
|
98
|
+
function validRuntime(node) {
|
|
99
|
+
return spawnSync(node, ['-e', 'if (Number(process.versions.node.split(".")[0]) < 24) process.exit(1)'], { stdio: 'ignore' }).status === 0;
|
|
100
|
+
}
|
|
101
|
+
function link(target, path) {
|
|
102
|
+
const tmp = `${path}.${randomUUID()}`; symlinkSync(target, tmp); renameSync(tmp, path);
|
|
103
|
+
}
|
|
104
|
+
export function packageCommands(osRelease) {
|
|
105
|
+
const id = /^ID=(.*)$/m.exec(osRelease)?.[1].replaceAll('"', '');
|
|
106
|
+
if (['debian', 'ubuntu'].includes(id)) return [
|
|
107
|
+
['apt-get', 'update'],
|
|
108
|
+
['apt-get', 'install', '-y', 'bubblewrap', 'socat', 'systemd', 'xz-utils', 'tar'],
|
|
109
|
+
];
|
|
110
|
+
if (['fedora', 'rhel', 'rocky', 'almalinux'].includes(id)) return [
|
|
111
|
+
['dnf', 'install', '-y', 'bubblewrap', 'socat', 'systemd', 'xz', 'tar'],
|
|
112
|
+
];
|
|
113
|
+
throw new Error('Unsupported distribution; install bubblewrap >=0.11, socat, systemd and xz manually.');
|
|
114
|
+
}
|
|
115
|
+
export function dependencyProblems(probe = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' })) {
|
|
116
|
+
const missing = [];
|
|
117
|
+
const bwrap = probe('bwrap', ['--version']);
|
|
118
|
+
if (bwrap.status !== 0 || !/bubblewrap\s+(?:0\.(?:1[1-9]|[2-9]\d)|[1-9]\d*\.\d+)\./.test(bwrap.stdout || '')) missing.push('bubblewrap >=0.11');
|
|
119
|
+
for (const [cmd, args] of [['socat', ['-V']], ['xz', ['--version']], ['tar', ['--version']]]) {
|
|
120
|
+
if (probe(cmd, args).status !== 0) missing.push(cmd);
|
|
121
|
+
}
|
|
122
|
+
return missing;
|
|
123
|
+
}
|
|
124
|
+
async function waitHealthy(env) {
|
|
125
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(`http://127.0.0.1:${env.BOTONS_PORT || env.PORT || 7777}/api/health`, { signal: AbortSignal.timeout(2000) });
|
|
128
|
+
const body = await response.json();
|
|
129
|
+
if (response.ok || (response.status === 503 && body.failing?.length && body.failing.every(item => item === 'providers'))) return;
|
|
130
|
+
} catch {}
|
|
131
|
+
await new Promise(done => setTimeout(done, 1000));
|
|
132
|
+
}
|
|
133
|
+
throw new Error('Health check failed');
|
|
134
|
+
}
|
|
135
|
+
async function main() {
|
|
136
|
+
const args = process.argv.slice(2);
|
|
137
|
+
const version = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8')).version;
|
|
138
|
+
if (args.length === 1 && args[0] === '--version') { console.log(version); return; }
|
|
139
|
+
// Administrative scripts own their argument parsing.
|
|
140
|
+
const admin = ['login', 'doctor', 'backup', 'restore', 'pair', 'serve'].includes(args[0]) ? args.shift() : null;
|
|
141
|
+
const o = admin ? { command: admin } : parse(args);
|
|
142
|
+
if (!admin && o.verbose) console.log(installerRevision(envelope));
|
|
143
|
+
if (o['public-url']) publicSettings('', o['public-url']);
|
|
144
|
+
const share = join(homedir(), '.local/share');
|
|
145
|
+
const root = join(share, 'botons');
|
|
146
|
+
const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
147
|
+
const config = join(configHome, 'botons/env');
|
|
148
|
+
const units = join(configHome, 'systemd/user');
|
|
149
|
+
// A managed install under a deprecated name (shared/legacy-names.mjs): used as it is until this moves it, after consent.
|
|
150
|
+
const isDir = (p) => { try { return lstatSync(p).isDirectory(); } catch { return false; } };
|
|
151
|
+
const earlierRoot = isDir(root) ? null : SHARE_DIRS.map(d => join(share, d)).find(p => isDir(p) && existsSync(join(p, 'installer.json'))) ?? null;
|
|
152
|
+
const readSaved = (at) => existsSync(join(at, 'installer.json')) ? JSON.parse(readFileSync(join(at, 'installer.json'), 'utf8')) : null;
|
|
153
|
+
// A move off the deprecated names that stopped part way: the release root records where it came from, and that
|
|
154
|
+
// path's link comes back first, so the earlier unit's and the saved runtime's paths work again.
|
|
155
|
+
try {
|
|
156
|
+
const from = readFileSync(join(root, '.name-upgrade-from'), 'utf8').trim();
|
|
157
|
+
if (dirname(from) === share && !existsSync(from) && !lstatSync(from, { throwIfNoEntry: false })) symlinkSync('botons', from);
|
|
158
|
+
} catch {}
|
|
159
|
+
const handover = (dataDir) => { try { return JSON.parse(readFileSync(join(dataDir, 'name-upgrade.json'), 'utf8')).service ?? null; } catch { return null; } };
|
|
160
|
+
const earlierUnit = UNITS.some(u => existsSync(join(units, `${u.service}.service`)));
|
|
161
|
+
const rootSaved = readSaved(root);
|
|
162
|
+
const unfinished = !earlierRoot && !!rootSaved && earlierUnit && (!existsSync(join(units, 'botons.service')) || (handover(rootSaved.data)?.unit && !handover(rootSaved.data).done));
|
|
163
|
+
let current = join(earlierRoot ?? root, 'current');
|
|
164
|
+
let saved = readSaved(earlierRoot ?? root);
|
|
165
|
+
let data = resolve(o.home || saved?.data || process.env.BOTONS_HOME || join(homedir(), '.botons'));
|
|
166
|
+
if (saved && data !== saved.data) throw new Error('Managed data path differs; refusing to change it');
|
|
167
|
+
if (admin) {
|
|
168
|
+
const node = saved?.node;
|
|
169
|
+
if (!node) throw new Error('No managed Node runtime; run repair');
|
|
170
|
+
const script = admin === 'serve' ? 'server/src/index.ts' : `scripts/${admin}.ts`;
|
|
171
|
+
const r = spawnSync(node, [realpathSync(join(current, 'installer/admin.mjs')), realpathSync(join(current, script)), ...args], { stdio: 'inherit' }); process.exit(r.status ?? 1);
|
|
172
|
+
}
|
|
173
|
+
if (platform() !== 'linux' || !['x64', 'arm64'].includes(arch())) throw new Error('Installer requires Linux x64 or arm64');
|
|
174
|
+
// An install under a deprecated name that this installer did not make (a checkout's deploy) moves with its own tools.
|
|
175
|
+
if (!earlierRoot && !unfinished && !existsSync(join(units, 'botons.service')) && (SHARE_DIRS.some(d => isDir(join(share, d))) || earlierUnit)) {
|
|
176
|
+
throw new Error('Existing Botons install under a deprecated name that this installer did not make: run `npm run migrate:names` in its checkout first; nothing changed.');
|
|
177
|
+
}
|
|
178
|
+
let marker = join(earlierRoot ?? root, 'installer.json');
|
|
179
|
+
if (existsSync(earlierRoot ?? root) && !existsSync(marker)) throw new Error('Existing unmanaged Botons directory; refusing to overwrite it.');
|
|
180
|
+
if (!existsSync(marker) && (existsSync(config) || existsSync(join(units, 'botons.service')))) throw new Error('Existing unmanaged Botons service/configuration; refusing to overwrite it.');
|
|
181
|
+
const bin = join(homedir(), '.local/bin/botons');
|
|
182
|
+
// The command's deprecated names, where this installer made them (shared/legacy-names.mjs): they run it for one release.
|
|
183
|
+
const legacyBins = () => CLI_NAMES.map(n => join(homedir(), '.local/bin', n)).filter(p => {
|
|
184
|
+
try { return lstatSync(p).isSymbolicLink() && [root, ...SHARE_DIRS.map(d => join(share, d))].some(r => readlinkSync(p).startsWith(r + '/')); } catch { return false; }
|
|
185
|
+
});
|
|
186
|
+
// A release's installer: installer/botons.mjs, or the file a release made under an earlier name has (shared/legacy-names.mjs).
|
|
187
|
+
const entryOf = (dir) => ['botons', ...CLI_NAMES].map(n => join(dir, 'installer', `${n}.mjs`)).find(p => existsSync(p)) ?? join(dir, 'installer', 'botons.mjs');
|
|
188
|
+
if (existsSync(bin) && (!existsSync(current) || realpathSync(bin) !== realpathSync(entryOf(current)))) throw new Error('Existing botons command; refusing to overwrite it.');
|
|
189
|
+
console.log(`Botons ${version}`);
|
|
190
|
+
console.log(plan(o.command, o.brain));
|
|
191
|
+
if (o.purge) console.log(`• Permanently delete data at ${data} and its configuration.`);
|
|
192
|
+
if (o.brain && saved?.node) throw new Error('--brain is for a fresh install; update an existing CLI from Settings or its publisher command');
|
|
193
|
+
if (o['dry-run']) { if (o.brain) console.log(`Selected subscription CLI: ${o.brain}; official npm package, managed prefix, no sign-in`); return; }
|
|
194
|
+
if (process.getuid?.() === 0) throw new Error('Run as an ordinary user, never root');
|
|
195
|
+
if (o.ref && !o.source) throw new Error('--ref requires --source <git URL>');
|
|
196
|
+
await consent('Continue?', o.yes);
|
|
197
|
+
// Off the deprecated names first (server/src/legacy-upgrade.ts, with the install's own Node): the earlier units
|
|
198
|
+
// stop and are disabled, the folders move (each earlier path left as a link), and the env file is written anew.
|
|
199
|
+
// Nothing new starts until the steps below; if they fail, `undoNames` starts the earlier units again.
|
|
200
|
+
let helpers = null;
|
|
201
|
+
let undoNames = null;
|
|
202
|
+
if ((earlierRoot || unfinished) && o.command === 'uninstall') throw new Error('This install still has a deprecated name: run repair first (it moves it to the current names), then uninstall. Nothing changed.');
|
|
203
|
+
if (earlierRoot || unfinished) {
|
|
204
|
+
if (!saved?.node || !validRuntime(saved.node)) throw new Error('The install under a deprecated name has no working Node runtime: repair it with the installer it was made with first; nothing changed.');
|
|
205
|
+
helpers = mkdtempSync(join(tmpdir(), 'botons-helpers-'));
|
|
206
|
+
for (const item of ['scripts', 'server/src', 'shared']) cpSync(join(source, item), join(helpers, item), { recursive: true });
|
|
207
|
+
const names = (args) => run(saved.node, [join(helpers, 'scripts/migrate-names.ts'), ...args]);
|
|
208
|
+
names(['--no-start']);
|
|
209
|
+
undoNames = () => names(['--undo']);
|
|
210
|
+
saved = readSaved(root);
|
|
211
|
+
if (!saved) throw new Error('The move off the deprecated names did not finish; run this again.');
|
|
212
|
+
current = join(root, 'current');
|
|
213
|
+
marker = join(root, 'installer.json');
|
|
214
|
+
data = resolve(o.home || saved.data);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
mkdirSync(root, { recursive: true });
|
|
218
|
+
if (!saved && o.command !== 'uninstall') writeFileSync(marker, JSON.stringify({ data }));
|
|
219
|
+
output = installOutput(join(root, 'install.log'), o.verbose);
|
|
220
|
+
if (o.command === 'uninstall') {
|
|
221
|
+
if (!existsSync(marker)) throw new Error('No managed install');
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if (!o['no-service']) { run('systemctl', ['--user', 'disable', '--now', 'botons', 'botons-backup.timer']); }
|
|
225
|
+
const units = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd/user');
|
|
226
|
+
for (const unit of ['botons.service', 'botons-backup.service', 'botons-backup.timer']) rmSync(join(units, unit), { force: true });
|
|
227
|
+
rmSync(bin, { force: true });
|
|
228
|
+
for (const p of legacyBins()) rmSync(p, { force: true });
|
|
229
|
+
removePathBlock(homedir());
|
|
230
|
+
for (const item of ['current', 'releases', 'node']) rmSync(join(root, item), { recursive: true, force: true });
|
|
231
|
+
if (!o['no-service']) run('systemctl', ['--user', 'daemon-reload']);
|
|
232
|
+
if (o.purge) { rmSync(data, { recursive: true, force: true }); rmSync(config, { force: true }); rmSync(root, { recursive: true, force: true }); }
|
|
233
|
+
console.log('✓ Botons uninstalled.');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!o['no-service']) {
|
|
237
|
+
const manager = spawnSync('systemctl', ['--user', 'show-environment'], { encoding: 'utf8' });
|
|
238
|
+
if (manager.status !== 0) throw new Error('No systemd user manager/session. Log in with a user session or explicitly use --no-service.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const missing = dependencyProblems();
|
|
242
|
+
if (missing.length) {
|
|
243
|
+
output.detail('Missing requirements: ' + missing.join(', '));
|
|
244
|
+
const commands = packageCommands(readFileSync('/etc/os-release', 'utf8'));
|
|
245
|
+
output.detail(commands.map(args => ['sudo', ...args].join(' ')).join('\n'));
|
|
246
|
+
if (o['no-sudo']) throw new Error('Required system tools missing; run the commands above then retry');
|
|
247
|
+
for (const command of commands) { await consent('Run exactly: sudo ' + command.join(' '), o.yes); run('sudo', command); }
|
|
248
|
+
const remaining = dependencyProblems();
|
|
249
|
+
if (remaining.length) throw new Error('System requirements still missing: ' + remaining.join(', '));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
mkdirSync(root, { recursive: true });
|
|
253
|
+
if (!saved) writeFileSync(marker, JSON.stringify({ data }));
|
|
254
|
+
const node = saved?.node && validRuntime(saved.node) ? saved.node : await nodeRuntime(root);
|
|
255
|
+
// Node refuses type stripping beneath npm's node_modules cache.
|
|
256
|
+
const helpers = join(root, '.helpers-' + randomUUID());
|
|
257
|
+
let serviceEnv;
|
|
258
|
+
try {
|
|
259
|
+
for (const item of ['scripts', 'server/src', 'shared']) cpSync(join(source, item), join(helpers, item), { recursive: true });
|
|
260
|
+
const reader = join(helpers, 'scripts/install-service.ts');
|
|
261
|
+
const loaded = execFileSync(node, ['--input-type=module', '-e',
|
|
262
|
+
`import {serverEnv} from ${JSON.stringify('file://' + reader)}; console.log(JSON.stringify(serverEnv(undefined, {...process.env, ${['BOTONS_HOME', ...earlierEnvNames('BOTONS_HOME')].map(k => `${k}:''`).join(', ')}}).env));`], { encoding: 'utf8' });
|
|
263
|
+
serviceEnv = JSON.parse(loaded);
|
|
264
|
+
} finally { rmSync(helpers, { recursive: true, force: true }); }
|
|
265
|
+
const previousEnv = { ...serviceEnv, BOTONS_HOME: data };
|
|
266
|
+
const env = { ...serviceEnv, PATH: `${dirname(node)}:${serviceEnv.PATH}`, BOTONS_HOME: data, BOTONS_HOST: o.host || serviceEnv.BOTONS_HOST || '127.0.0.1', BOTONS_NO_TAILSCALE: serviceEnv.BOTONS_NO_TAILSCALE || '1', ...(o['public-url'] ? { BOTONS_PUBLIC_URL: new URL(o['public-url']).origin, BOTONS_OAUTH_PUBLIC_URL: new URL(o['public-url']).origin } : {}) };
|
|
267
|
+
// A CLI is selected separately from --yes: unattended installs never choose a subscription for the owner.
|
|
268
|
+
let brain = o.brain;
|
|
269
|
+
if (brain) {
|
|
270
|
+
const key = `BOTONS_${brain.toUpperCase()}_BIN`;
|
|
271
|
+
const configured = env[key];
|
|
272
|
+
if (configured || cliPresent(brain, env)) {
|
|
273
|
+
output.detail(`Keeping ${configured || brain}; no CLI installation performed. Sign in during setup.`);
|
|
274
|
+
} else {
|
|
275
|
+
const plan = cliInstallPlan(root, node, brain);
|
|
276
|
+
output.detail(plan.display);
|
|
277
|
+
await installCli(plan, { consent: async () => {}, yes: true, env, run: (cmd, args, options) => spawnSync(cmd, args, { ...options, stdio: output.stdio }) });
|
|
278
|
+
if (!cliPresent(plan.bin, env)) throw new Error('Installed CLI did not pass its version check');
|
|
279
|
+
env[key] = plan.bin;
|
|
280
|
+
// Managed launches must obey Botons' update consent.
|
|
281
|
+
if (brain === 'claude') { env.DISABLE_AUTOUPDATER = '1'; env.DISABLE_UPDATES = '1'; }
|
|
282
|
+
output.detail(`${brain} installed. The owner signs in during setup; no credentials were created.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
output.done('Runtime and brains ready');
|
|
286
|
+
const old = existsSync(current) ? realpathSync(current) : null;
|
|
287
|
+
const hasDb = () => ['botons.db', DB_FILE].some(f => existsSync(join(data, f)));
|
|
288
|
+
if (old && hasDb()) run(node, [realpathSync(join(old, 'scripts/backup.ts'))], env);
|
|
289
|
+
const selected = o.source || (o.command === 'upgrade' ? saved?.source : null);
|
|
290
|
+
if (o.command === 'upgrade' && !selected) throw new Error('Upgrade requires --source <tarball, directory or git URL>; no registry release is assumed.');
|
|
291
|
+
let input = old && o.command === 'repair' && !o.source ? old : source;
|
|
292
|
+
let temporary;
|
|
293
|
+
if (selected) {
|
|
294
|
+
temporary = join(root, '.source-' + randomUUID());
|
|
295
|
+
mkdirSync(temporary);
|
|
296
|
+
if (/^(git\+|https?:.*\.git)/.test(selected)) {
|
|
297
|
+
run('git', ['clone', '--', selected.replace(/^git\+/, ''), join(temporary, 'app')]);
|
|
298
|
+
if (o.ref || saved?.ref) run('git', ['checkout', '--detach', o.ref || saved.ref], process.env, join(temporary, 'app'));
|
|
299
|
+
input = join(temporary, 'app');
|
|
300
|
+
} else if (existsSync(resolve(selected)) && !/\.t(?:ar\.gz|gz)$/.test(selected)) {
|
|
301
|
+
input = resolve(selected);
|
|
302
|
+
} else {
|
|
303
|
+
const archive = join(temporary, 'source.tgz');
|
|
304
|
+
writeFileSync(archive, /^https?:/.test(selected) ? await download(selected) : readFileSync(resolve(selected)));
|
|
305
|
+
mkdirSync(join(temporary, 'app'));
|
|
306
|
+
run('tar', ['-xzf', archive, '--strip-components=1', '-C', join(temporary, 'app')]);
|
|
307
|
+
input = join(temporary, 'app');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (existsSync(join(input, 'payload/package.json'))) input = join(input, 'payload');
|
|
311
|
+
const version = sourceVersion(input);
|
|
312
|
+
const release = join(root, 'releases', version);
|
|
313
|
+
const reuse = old === release && o.command !== 'upgrade' && !o.source;
|
|
314
|
+
const staging = join(root, '.release-' + randomUUID());
|
|
315
|
+
try {
|
|
316
|
+
if (existsSync(release) && !reuse) throw new Error('Release version already installed; choose a distinct source version');
|
|
317
|
+
if (!reuse) {
|
|
318
|
+
mkdirSync(staging, { recursive: true });
|
|
319
|
+
for (const item of ['package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'server', 'shared', 'scripts', 'installer', 'web', 'LICENSE']) {
|
|
320
|
+
if (existsSync(join(input, item))) cpSync(join(input, item), join(staging, item), { recursive: true, filter: p => !['node_modules', '.git'].includes(p.split('/').at(-1)) });
|
|
321
|
+
}
|
|
322
|
+
if (!existsSync(join(staging, 'package-lock.json')) && existsSync(join(staging, 'installer/source-lock.json'))) cpSync(join(staging, 'installer/source-lock.json'), join(staging, 'package-lock.json'));
|
|
323
|
+
const npm = join(dirname(node), '../lib/node_modules/npm/bin/npm-cli.js');
|
|
324
|
+
if (!existsSync(join(staging, 'web/dist/index.html'))) {
|
|
325
|
+
run(node, [npm, 'ci', '--ignore-scripts'], env, staging);
|
|
326
|
+
run(node, [npm, 'run', 'build'], env, staging);
|
|
327
|
+
}
|
|
328
|
+
run(node, [npm, 'ci', '--omit=dev', '--ignore-scripts'], env, staging);
|
|
329
|
+
mkdirSync(dirname(release), { recursive: true });
|
|
330
|
+
renameSync(staging, release);
|
|
331
|
+
} else {
|
|
332
|
+
const npm = join(dirname(node), '../lib/node_modules/npm/bin/npm-cli.js');
|
|
333
|
+
run(node, [npm, 'ci', '--omit=dev', '--ignore-scripts'], env, release);
|
|
334
|
+
}
|
|
335
|
+
} finally {
|
|
336
|
+
rmSync(staging, { recursive: true, force: true });
|
|
337
|
+
if (temporary) rmSync(temporary, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
const unitRoot = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd/user');
|
|
340
|
+
const priorUnits = ['botons.service', 'botons-backup.service', 'botons-backup.timer'].map(name => {
|
|
341
|
+
const path = join(unitRoot, name);
|
|
342
|
+
return { path, contents: existsSync(path) ? readFileSync(path) : null };
|
|
343
|
+
});
|
|
344
|
+
const priorConfig = existsSync(config) ? readFileSync(config) : null;
|
|
345
|
+
const priorMarker = existsSync(marker) ? readFileSync(marker) : null;
|
|
346
|
+
// The command links as they are, to put back exactly if activation fails.
|
|
347
|
+
const priorLinks = [bin, ...legacyBins()].map(path => ({ path, target: lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ? readlinkSync(path) : null }));
|
|
348
|
+
try {
|
|
349
|
+
link(release, current);
|
|
350
|
+
mkdirSync(dirname(bin), { recursive: true }); link(entryOf(current), bin);
|
|
351
|
+
for (const p of legacyBins()) link(entryOf(current), p);
|
|
352
|
+
if (!existsSync(config)) {
|
|
353
|
+
mkdirSync(dirname(config), { recursive: true, mode: 0o700 });
|
|
354
|
+
writeFileSync(config, Object.entries(env).filter(([k]) => ['PATH', 'BOTONS_HOME', 'BOTONS_HOST', 'BOTONS_PORT', 'BOTONS_NO_TAILSCALE', 'BOTONS_PUBLIC_URL', 'BOTONS_OAUTH_PUBLIC_URL', 'BOTONS_BACKUP_DIR', 'BOTONS_CLAUDE_BIN', 'BOTONS_CODEX_BIN', 'DISABLE_AUTOUPDATER', 'DISABLE_UPDATES'].includes(k)).map(([k,v]) => `${k}=${JSON.stringify(v)}`).join('\n') + '\n', { mode: 0o600 });
|
|
355
|
+
}
|
|
356
|
+
if (o.host) {
|
|
357
|
+
const text = readFileSync(config, 'utf8').split('\n').filter(line => !/^\s*(?:export\s+)?BOTONS_HOST\s*=/.test(line)).join('\n');
|
|
358
|
+
writeFileSync(config, text.trimEnd() + '\nBOTONS_HOST=' + JSON.stringify(o.host) + '\n', { mode: 0o600 });
|
|
359
|
+
}
|
|
360
|
+
if (o['public-url']) {
|
|
361
|
+
writeFileSync(config, publicSettings(readFileSync(config, 'utf8'), o['public-url']), { mode: 0o600 });
|
|
362
|
+
output.detail('Saved BOTONS_PUBLIC_URL and BOTONS_OAUTH_PUBLIC_URL for the app and OAuth callbacks: ' + new URL(o['public-url']).origin);
|
|
363
|
+
}
|
|
364
|
+
if (!o['no-service']) {
|
|
365
|
+
try {
|
|
366
|
+
run(node, [realpathSync(join(current, 'scripts/install-service.ts')), '--app', current, '--name', 'botons', '--quiet'], env);
|
|
367
|
+
run(node, [realpathSync(join(current, 'scripts/install-backup-timer.ts')), '--app', current, '--quiet'], env);
|
|
368
|
+
run('systemctl', ['--user', 'daemon-reload']); run('systemctl', ['--user', 'enable', '--now', 'botons-backup.timer']); run('systemctl', ['--user', 'restart', 'botons']); run('systemctl', ['--user', 'enable', 'botons']);
|
|
369
|
+
await waitHealthy(env);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
throw new Error(`${e.message}. Activation failed; attempting to restore code and configuration. Schema changes require restoring the fresh backup; data is never restored automatically.`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
writeFileSync(marker, JSON.stringify({ data, node, source: selected || saved?.source || null, ref: o.ref || saved?.ref || null }));
|
|
375
|
+
} catch (error) {
|
|
376
|
+
try {
|
|
377
|
+
if (!old && !o['no-service']) spawnSync('systemctl', ['--user', 'disable', '--now', 'botons', 'botons-backup.timer'], { stdio: output.stdio });
|
|
378
|
+
for (const unit of priorUnits) {
|
|
379
|
+
if (unit.contents) writeFileSync(unit.path, unit.contents);
|
|
380
|
+
else rmSync(unit.path, { force: true });
|
|
381
|
+
}
|
|
382
|
+
if (priorConfig) writeFileSync(config, priorConfig, { mode: 0o600 });
|
|
383
|
+
else rmSync(config, { force: true });
|
|
384
|
+
if (priorMarker) writeFileSync(marker, priorMarker);
|
|
385
|
+
else rmSync(marker, { force: true });
|
|
386
|
+
if (old) link(old, current);
|
|
387
|
+
else rmSync(current, { force: true });
|
|
388
|
+
for (const { path, target } of priorLinks) {
|
|
389
|
+
if (target) link(target, path);
|
|
390
|
+
else rmSync(path, { force: true });
|
|
391
|
+
}
|
|
392
|
+
if (!o['no-service']) {
|
|
393
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
394
|
+
if (undoNames) {
|
|
395
|
+
undoNames();
|
|
396
|
+
undoNames = null;
|
|
397
|
+
await waitHealthy(previousEnv);
|
|
398
|
+
} else if (old) {
|
|
399
|
+
run('systemctl', ['--user', 'restart', 'botons']);
|
|
400
|
+
await waitHealthy(previousEnv);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch (rollbackError) {
|
|
404
|
+
throw new Error(`${error.message} Rollback also failed: ${rollbackError.message}. Restore the fresh backup if the schema changed, then retry.`);
|
|
405
|
+
} finally {
|
|
406
|
+
if (!reuse && (!existsSync(current) || realpathSync(current) !== release)) rmSync(release, { recursive: true, force: true });
|
|
407
|
+
}
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
output.done('Botons and backups installed');
|
|
411
|
+
let pathReady = true;
|
|
412
|
+
await configurePath({ home: homedir(), shell: process.env.SHELL, path: process.env.PATH, yes: true }, consent, message => { pathReady = false; output.detail(message); });
|
|
413
|
+
const bootReady = o['no-service'] ? true : await configureLinger({ yes: o.yes, noSudo: o['no-sudo'] }, async (message, yes) => { if (message.startsWith('Run exactly:')) await consent(message, yes); }, { log: message => output.detail(message) });
|
|
414
|
+
const command = onPath(homedir(), process.env.PATH) ? 'botons' : bin;
|
|
415
|
+
if (!o['no-service'] && hasDb()) {
|
|
416
|
+
run(node, [realpathSync(join(current, 'scripts/backup.ts'))], env);
|
|
417
|
+
}
|
|
418
|
+
output.replay();
|
|
419
|
+
output.done('Setup ready');
|
|
420
|
+
if (!pathReady) console.log(`• PATH setup needs attention; use ${command} and see ${join(root, 'install.log')}.`);
|
|
421
|
+
console.log(summary({ command, url: env.BOTONS_PUBLIC_URL || `http://127.0.0.1:${env.BOTONS_PORT || env.PORT || 7777}`, running: !o['no-service'], bootReady, bootFix: lingerCommand() }));
|
|
422
|
+
} catch (error) {
|
|
423
|
+
// Anything that failed before the activation's own rollback: the earlier units come back.
|
|
424
|
+
if (undoNames) { try { undoNames(); } catch {} }
|
|
425
|
+
throw error;
|
|
426
|
+
} finally {
|
|
427
|
+
if (helpers) rmSync(helpers, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(e => { console.error(`✗ ${e.message}`); if (output) console.error(output.failure()); process.exitCode = 1; });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const packages = Object.freeze({ claude: '@anthropic-ai/claude-code', codex: '@openai/codex' });
|
|
5
|
+
|
|
6
|
+
/** Fixed publisher packages only; no shell evaluates the displayed command. */
|
|
7
|
+
export function cliInstallPlan(root, node, provider, prefix = join(root, 'clis', provider)) {
|
|
8
|
+
if (!Object.hasOwn(packages, provider)) throw new Error('Choose claude or codex');
|
|
9
|
+
const npm = join(dirname(node), '../lib/node_modules/npm/bin/npm-cli.js');
|
|
10
|
+
const args = [npm, 'install', '--global', '--prefix', prefix, '--registry=https://registry.npmjs.org', '--userconfig=/dev/null', `--globalconfig=${join(prefix, '.botons-empty-npmrc')}`, packages[provider] + '@latest'];
|
|
11
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
12
|
+
return { provider, prefix, bin: join(prefix, 'bin', provider), command: node, args, display: [node, ...args].map(quote).join(' ') };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Consent precedes every mutation, including when --yes supplies that consent. */
|
|
16
|
+
export async function installCli(plan, { consent, yes = false, env = process.env, run = (cmd, args, options) => spawnSync(cmd, args, options) }) {
|
|
17
|
+
await consent(`Install ${plan.provider} for the server user using exactly:\n${plan.display}`, yes);
|
|
18
|
+
const result = run(plan.command, plan.args, {
|
|
19
|
+
stdio: 'inherit', timeout: 5 * 60_000,
|
|
20
|
+
env: { ...env, PATH: `${dirname(plan.command)}:${env.PATH || ''}`, DISABLE_AUTOUPDATER: '1', DISABLE_UPDATES: '1' },
|
|
21
|
+
});
|
|
22
|
+
if (result.error || result.status !== 0) throw new Error(`${plan.provider} installation failed; sign-in has not been started`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Tests and callers pass the same environment the service will use. */
|
|
26
|
+
export function cliPresent(bin, env = process.env) {
|
|
27
|
+
return spawnSync(bin, ['--version'], { env, stdio: 'ignore', timeout: 10_000 }).status === 0;
|
|
28
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
|
|
4
|
+
export function lingerCommand(user = userInfo().username) {
|
|
5
|
+
const quotedUser = /^[a-zA-Z0-9_.-]+$/.test(user) ? user : "'" + user.replaceAll("'", "'\\''") + "'";
|
|
6
|
+
return `sudo loginctl enable-linger ${quotedUser}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Optional boot persistence must never undo a working service installation. */
|
|
10
|
+
export async function configureLinger(options, ask, hooks = {}) {
|
|
11
|
+
const execute = hooks.execute ?? ((cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' }));
|
|
12
|
+
const log = hooks.log ?? console.log;
|
|
13
|
+
const user = hooks.user ?? userInfo().username;
|
|
14
|
+
const fix = lingerCommand(user);
|
|
15
|
+
const warn = reason => {
|
|
16
|
+
log(`WARNING: Linger is not enabled (${reason}). Botons remains enabled and running, but will not survive logout/reboot until linger is on.\nRun: ${fix}`);
|
|
17
|
+
return false;
|
|
18
|
+
};
|
|
19
|
+
try {
|
|
20
|
+
const state = execute('loginctl', ['show-user', user, '-p', 'Linger']);
|
|
21
|
+
if (state.status === 0 && /^Linger=yes\s*$/m.test(state.stdout || '')) {
|
|
22
|
+
log('Linger is already enabled.');
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
await ask('Enable linger so Botons starts before login', options.yes);
|
|
26
|
+
const result = execute('loginctl', ['enable-linger']);
|
|
27
|
+
if (result.status === 0) return true;
|
|
28
|
+
const detail = `${result.stderr || ''}\n${result.stdout || ''}\n${result.error?.message || ''}`;
|
|
29
|
+
if (!/access denied|permission denied|polkit|pkttyagent|authentication|not authorized/i.test(detail)) return warn(detail.trim() || 'loginctl failed');
|
|
30
|
+
log(fix);
|
|
31
|
+
if (options.noSudo) return warn('--no-sudo');
|
|
32
|
+
await ask(`Run exactly: ${fix}`, options.yes);
|
|
33
|
+
const elevated = execute('sudo', ['loginctl', 'enable-linger', user]);
|
|
34
|
+
if (elevated.status === 0) return true;
|
|
35
|
+
return warn(elevated.stderr?.trim() || elevated.error?.message || 'sudo loginctl failed');
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return warn(error.message || 'linger setup failed');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, renameSync, openSync, appendFileSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export function plan(command, brain) {
|
|
4
|
+
return command === 'uninstall' ? '• Stop Botons and remove its managed program, units and PATH block; keep data unless --purge.' : [
|
|
5
|
+
'• Set up Node 24 for Botons (system Node untouched).',
|
|
6
|
+
'• Install missing sandbox tools, including bubblewrap (sudo).',
|
|
7
|
+
'• Start Botons at boot (linger), configure backups and PATH.',
|
|
8
|
+
`• ${brain ? `Install ${brain} if missing; sign in later in setup.` : 'Keep existing brains; choose or install a CLI in setup.'}`,
|
|
9
|
+
].join('\n');
|
|
10
|
+
}
|
|
11
|
+
export function summary({ command = 'botons', url, running = true, bootReady = true, bootFix = 'sudo loginctl enable-linger <server-user>' }) {
|
|
12
|
+
return `${running ? 'Botons is running! 🐾' : 'Botons is installed! 🐾'}${running ? '' : `\n Start Botons: ${command} serve`}\n Open ${url}\n Approve your browser’s code: ${command} pair --approve <code>\n Pick a brain in the setup screen — it can update Codex and fix config links for you.${bootReady ? '' : `\n • Start at boot: ${bootFix}`}`;
|
|
13
|
+
}
|
|
14
|
+
export function installOutput(path, verbose = false) {
|
|
15
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
16
|
+
if (existsSync(path)) renameSync(path, path + '.1');
|
|
17
|
+
const fd = openSync(path, 'a', 0o600);
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
let offset = 0;
|
|
20
|
+
return {
|
|
21
|
+
replay() { const text = readFileSync(path, 'utf8'); if (verbose) process.stdout.write(text.slice(offset)); offset = text.length; },
|
|
22
|
+
fd, stdio: ['inherit', fd, fd],
|
|
23
|
+
detail(message) { appendFileSync(path, message + '\n'); },
|
|
24
|
+
done(message) { console.log(`✓ ${message} (${Math.round((Date.now() - start) / 1000)}s)`); },
|
|
25
|
+
failure() { try { return readFileSync(path, 'utf8').trimEnd().split('\n').slice(-20).join('\n') + `\nLog: ${path}`; } catch { return `Log unavailable: ${path}`; } },
|
|
26
|
+
};
|
|
27
|
+
}
|