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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
/** Report only a commit hash, never a source URL that could contain credentials. */
|
|
7
|
+
export function installerRevision(root) {
|
|
8
|
+
let commit = null;
|
|
9
|
+
if (existsSync(join(root, '.git'))) {
|
|
10
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' });
|
|
11
|
+
if (result.status === 0 && /^[a-f0-9]{40}$/.test(result.stdout.trim())) commit = result.stdout.trim();
|
|
12
|
+
}
|
|
13
|
+
if (!commit) {
|
|
14
|
+
try {
|
|
15
|
+
// npm strips .git while packing Git dependencies, but retains the resolved SHA in its cache lock.
|
|
16
|
+
const cache = resolve(root, '../..');
|
|
17
|
+
if (basename(resolve(root, '..')) === 'node_modules') {
|
|
18
|
+
const lock = JSON.parse(readFileSync(join(cache, 'package-lock.json'), 'utf8'));
|
|
19
|
+
const entry = lock.packages?.[`node_modules/${basename(root)}`];
|
|
20
|
+
commit = /#([a-f0-9]{40})$/.exec(entry?.resolved || '')?.[1] || null;
|
|
21
|
+
}
|
|
22
|
+
} catch { /* Cache metadata is optional; the fingerprint still identifies the running code. */ }
|
|
23
|
+
}
|
|
24
|
+
const hash = createHash('sha256');
|
|
25
|
+
const installer = join(root, 'installer');
|
|
26
|
+
for (const file of readdirSync(installer).filter(name => name.endsWith('.mjs')).sort()) {
|
|
27
|
+
hash.update(file).update('\0').update(readFileSync(join(installer, file))).update('\0');
|
|
28
|
+
}
|
|
29
|
+
return `Botons installer: commit ${commit || 'unavailable'}, fingerprint ${hash.digest('hex')}`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { PATH_BLOCK_NAMES } from '../shared/legacy-names.mjs';
|
|
4
|
+
|
|
5
|
+
const blockOf = (name) => `\n# >>> ${name} PATH >>>\ncase ":$PATH:" in\n *":$HOME/.local/bin:"*) ;;\n *) export PATH="$HOME/.local/bin:$PATH" ;;\nesac\n# <<< ${name} PATH <<<\n`;
|
|
6
|
+
const block = blockOf('botons');
|
|
7
|
+
// The block under its deprecated names counts as there, and goes on uninstall (shared/legacy-names.mjs).
|
|
8
|
+
const markers = ['botons', ...PATH_BLOCK_NAMES].map(name => `# >>> ${name} PATH >>>`);
|
|
9
|
+
const blocks = [block, ...PATH_BLOCK_NAMES.map(blockOf)];
|
|
10
|
+
const files = ['.bashrc', '.zshrc', '.profile'];
|
|
11
|
+
export function onPath(home, path = '') {
|
|
12
|
+
return path.split(':').some(entry => entry && resolve(entry) === join(home, '.local/bin'));
|
|
13
|
+
}
|
|
14
|
+
export function profileAddsPath(home, shell = '') {
|
|
15
|
+
const kind = basename(shell);
|
|
16
|
+
const candidates = kind === 'bash' ? ['.bash_profile', '.bash_login', '.profile'] : kind === 'zsh' ? ['.zprofile', '.zlogin'] : ['.profile'];
|
|
17
|
+
for (const name of candidates) {
|
|
18
|
+
const path = join(home, name);
|
|
19
|
+
if (!existsSync(path)) continue;
|
|
20
|
+
const text = readFileSync(path, 'utf8');
|
|
21
|
+
const adds = text.split('\n').filter(line => !/^\s*#/.test(line)).some(line => /^\s*(?:export\s+)?PATH=/.test(line) && (/(?:\$HOME|\$\{HOME\}|~)\/\.local\/bin/.test(line) || line.includes(join(home, ".local/bin"))));
|
|
22
|
+
if (adds) return true;
|
|
23
|
+
// Bash only reads the first existing login profile. Follow its explicit .profile inclusion.
|
|
24
|
+
if (kind === 'bash') {
|
|
25
|
+
if (name !== '.profile' && /(?:source|\.)\s+["']?(?:\$HOME|~)\/\.profile["']?(?:\s|$)/m.test(text)) {
|
|
26
|
+
return profileAddsPath(home, '/bin/sh');
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
export async function configurePath({ home, shell, path, yes }, ask, log = console.log) {
|
|
34
|
+
if (onPath(home, path)) return true;
|
|
35
|
+
try {
|
|
36
|
+
if (!profileAddsPath(home, shell)) {
|
|
37
|
+
const kind = basename(shell || '');
|
|
38
|
+
const rc = join(home, kind === 'bash' ? '.bashrc' : kind === 'zsh' ? '.zshrc' : '.profile');
|
|
39
|
+
const text = existsSync(rc) ? readFileSync(rc, 'utf8') : '';
|
|
40
|
+
if (!markers.some(m => text.includes(m))) {
|
|
41
|
+
await ask(`Append the marked Botons PATH block to ${rc} so new shells find ~/.local/bin/botons`, yes);
|
|
42
|
+
// The owner may edit the file while answering. Preserve those edits and recheck the marker.
|
|
43
|
+
const latest = existsSync(rc) ? readFileSync(rc, 'utf8') : '';
|
|
44
|
+
if (!markers.some(m => latest.includes(m)) && !profileAddsPath(home, shell)) {
|
|
45
|
+
mkdirSync(dirname(rc), { recursive: true });
|
|
46
|
+
appendFileSync(rc, block);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (error) { log(`PATH setup skipped: ${error.message}. Use ~/.local/bin/botons until your shell PATH is configured.`); }
|
|
51
|
+
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
export function removePathBlock(home, log = console.log) {
|
|
55
|
+
for (const name of files) {
|
|
56
|
+
const path = join(home, name);
|
|
57
|
+
try {
|
|
58
|
+
if (!existsSync(path)) continue;
|
|
59
|
+
const text = readFileSync(path, 'utf8');
|
|
60
|
+
const cleaned = blocks.reduce((t, b) => t.replaceAll(b, ''), text);
|
|
61
|
+
if (cleaned !== text) writeFileSync(path, cleaned);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
log(`WARNING: Could not remove the Botons PATH block from ${path}: ${error.message}. Remove the marked block manually if present.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|