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,52 @@
|
|
|
1
|
+
// `npm run backup:install [-- --app <dir> --dir <unit dir>]`: writes the nightly backup units. It enables nothing.
|
|
2
|
+
import '../server/src/env-init.ts';
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
|
|
10
|
+
/** Writes botons-backup.service and botons-backup.timer (or `name`.*) into `unitDir`, running `app`'s backup with `node`. Returns their paths. */
|
|
11
|
+
export function writeBackupUnits(o: { app: string; unitDir: string; name?: string; node?: string; log?: (line: string) => void }): string[] {
|
|
12
|
+
const name = o.name ?? 'botons-backup';
|
|
13
|
+
if (!/^[\w@.-]+$/.test(name)) throw new Error(`--name ${name}: use letters, digits and - _ . @ only`);
|
|
14
|
+
mkdirSync(o.unitDir, { recursive: true });
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
for (const [template, unit] of [['botons-backup.service', `${name}.service`], ['botons-backup.timer', `${name}.timer`]]) {
|
|
17
|
+
const text = readFileSync(join(repo, 'scripts', template), 'utf8')
|
|
18
|
+
.replaceAll('@NODE@', o.node ?? process.execPath)
|
|
19
|
+
.replaceAll('@REPO@', o.app)
|
|
20
|
+
.replaceAll('@TIMER@', `${name}.timer`)
|
|
21
|
+
.replaceAll('@SERVICE@', `${name}.service`);
|
|
22
|
+
writeFileSync(join(o.unitDir, unit), text);
|
|
23
|
+
(o.log ?? console.log)(`Wrote ${join(o.unitDir, unit)}`);
|
|
24
|
+
out.push(join(o.unitDir, unit));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
30
|
+
const arg = (name: string) => {
|
|
31
|
+
const at = process.argv.indexOf(name);
|
|
32
|
+
return at > 0 && process.argv[at + 1] ? process.argv[at + 1] : null;
|
|
33
|
+
};
|
|
34
|
+
const dir = resolve(arg('--dir') ?? join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user'));
|
|
35
|
+
const name = arg('--name') ?? 'botons-backup';
|
|
36
|
+
try {
|
|
37
|
+
writeBackupUnits({ app: resolve(arg('--app') ?? repo), unitDir: dir, name });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error((err as Error).message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
if (!process.argv.includes('--quiet')) console.log(`
|
|
43
|
+
Turn on the nightly backup (03:30, or at the next boot if that was missed):
|
|
44
|
+
systemctl --user daemon-reload
|
|
45
|
+
systemctl --user enable --now ${name}.timer
|
|
46
|
+
|
|
47
|
+
Check it:
|
|
48
|
+
systemctl --user list-timers ${name}.timer
|
|
49
|
+
systemctl --user start ${name}.service && journalctl --user -u ${name}.service -n 20
|
|
50
|
+
|
|
51
|
+
Run this again after moving the app folder or changing the Node version.`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// `npm run service:install [-- --app <dir> --name <name> --dir <unit dir> --env-path <file>]`: writes the
|
|
2
|
+
// systemd user unit, and the environment file if there is none. It starts nothing.
|
|
3
|
+
import '../server/src/env-init.ts';
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { CONFIG_DIRS, UNITS, applyLegacyEnv } from '../shared/legacy-names.mjs';
|
|
9
|
+
import { DEFAULT_DRAIN_MS, isBotonsSetting, parseDuration } from '../server/src/config.ts';
|
|
10
|
+
|
|
11
|
+
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const configHome = () => process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
13
|
+
|
|
14
|
+
/** The service's environment file, unless --env-path names another: ~/.config/botons/env, or a deprecated one until the upgrade moves it (server/src/legacy-upgrade.ts). */
|
|
15
|
+
export function defaultEnvPath(): string {
|
|
16
|
+
const now = join(configHome(), 'botons', 'env');
|
|
17
|
+
return existsSync(now) ? now : (CONFIG_DIRS.map((d) => join(configHome(), d, 'env')).find((p) => existsSync(p)) ?? now);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const unitDirDefault = () => join(configHome(), 'systemd', 'user');
|
|
21
|
+
|
|
22
|
+
/** The server's unit: botons, or the deprecated unit an install still runs under until the upgrade moves it (server/src/legacy-upgrade.ts). */
|
|
23
|
+
export function defaultUnitName(unitDir = unitDirDefault()): string {
|
|
24
|
+
if (existsSync(join(unitDir, 'botons.service'))) return 'botons';
|
|
25
|
+
return UNITS.find((u) => existsSync(join(unitDir, `${u.service}.service`)))?.service ?? 'botons';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** systemd expands % specifiers in unit settings; a literal % is %%. */
|
|
29
|
+
const escapeUnit = (s: string) => s.replaceAll('%', '%%');
|
|
30
|
+
|
|
31
|
+
const quoteEnv = (v: string) => (/^[\w@%+=:,./-]*$/.test(v) ? v : `"${v.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`);
|
|
32
|
+
|
|
33
|
+
/** The first executable `cmd` on `path`, or `cmd` itself when it is a path. */
|
|
34
|
+
function which(cmd: string, path: string): string | null {
|
|
35
|
+
const executable = (file: string) => {
|
|
36
|
+
try {
|
|
37
|
+
accessSync(file, constants.X_OK);
|
|
38
|
+
return statSync(file).isFile();
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
if (cmd.includes('/')) return executable(resolve(cmd)) ? resolve(cmd) : null;
|
|
44
|
+
for (const dir of path.split(delimiter)) {
|
|
45
|
+
if (dir && isAbsolute(dir) && executable(join(dir, cmd))) return join(dir, cmd);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** KEY=value pairs of an environment file (comments and blank lines skipped, quotes removed). */
|
|
51
|
+
export function readEnvFile(text: string): Record<string, string> {
|
|
52
|
+
const out: Record<string, string> = {};
|
|
53
|
+
for (const raw of text.split('\n')) {
|
|
54
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(raw);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
let v = m[2];
|
|
57
|
+
if (/^".*"$/.test(v)) v = v.slice(1, -1).replace(/\\(["\\])/g, '$1');
|
|
58
|
+
else if (/^'.*'$/.test(v)) v = v.slice(1, -1);
|
|
59
|
+
out[m[1]] = v;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The environment a server-side script acts with. BOTONS_HOME set means "this server"; otherwise the BOTONS_* and
|
|
66
|
+
* PORT (and PATH, for finding the CLIs) of the service's env file win, as for deploy and backup. Throws only when a
|
|
67
|
+
* file named by `named` can't be read.
|
|
68
|
+
*/
|
|
69
|
+
export function serverEnv(named: string | undefined, base: NodeJS.ProcessEnv = process.env): { env: NodeJS.ProcessEnv; from: string | null } {
|
|
70
|
+
if (base.BOTONS_HOME?.trim()) return { env: base, from: null };
|
|
71
|
+
const envPath = resolve(named ?? defaultEnvPath());
|
|
72
|
+
let file: Record<string, string>;
|
|
73
|
+
try {
|
|
74
|
+
file = readEnvFile(readFileSync(envPath, 'utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
if (named !== undefined) throw new Error(`Cannot read ${envPath}.`);
|
|
77
|
+
return { env: base, from: null };
|
|
78
|
+
}
|
|
79
|
+
applyLegacyEnv(file);
|
|
80
|
+
const env: NodeJS.ProcessEnv = { ...base };
|
|
81
|
+
for (const [k, v] of Object.entries(file)) if (isBotonsSetting(k) || k === 'PORT' || k === 'PATH' || k === 'DISABLE_AUTOUPDATER' || k === 'DISABLE_UPDATES') env[k] = v;
|
|
82
|
+
applyLegacyEnv(env);
|
|
83
|
+
return { env, from: envPath };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function arg(name: string): string | null {
|
|
87
|
+
const at = process.argv.indexOf(name);
|
|
88
|
+
return at > 0 && process.argv[at + 1] ? process.argv[at + 1] : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ServiceUnitOptions {
|
|
92
|
+
/** The unit's name, without .service. */
|
|
93
|
+
name: string;
|
|
94
|
+
/** The Botons folder the unit runs. */
|
|
95
|
+
app: string;
|
|
96
|
+
envPath: string;
|
|
97
|
+
unitDir: string;
|
|
98
|
+
/** The Node binary (default: this one). */
|
|
99
|
+
node?: string;
|
|
100
|
+
/** The user's home, for writing the default env file's path as %h (default: this user's). */
|
|
101
|
+
userHome?: string;
|
|
102
|
+
log?: (line: string) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Writes the environment file if there is none, then the unit (both starting nothing). Returns the unit file's path. */
|
|
106
|
+
export function writeServiceUnit(o: ServiceUnitOptions): string {
|
|
107
|
+
const { name, app, envPath, unitDir } = o;
|
|
108
|
+
const log = o.log ?? ((line: string) => console.log(line));
|
|
109
|
+
if (!/^[\w@.-]+$/.test(name)) throw new Error(`--name ${name}: use letters, digits and - _ . @ only`);
|
|
110
|
+
// %h when it is the default file, which the backup unit reads too.
|
|
111
|
+
const envInUnit = envPath === join(o.userHome ?? homedir(), '.config', 'botons', 'env') ? '%h/.config/botons/env' : escapeUnit(envPath);
|
|
112
|
+
const node = o.node ?? process.execPath;
|
|
113
|
+
|
|
114
|
+
if (!existsSync(join(app, 'server', 'src', 'index.ts'))) throw new Error(`${app} has no server/src/index.ts; pass --app <the Botons folder>`);
|
|
115
|
+
|
|
116
|
+
if (!existsSync(envPath)) {
|
|
117
|
+
const path = process.env.PATH ?? '';
|
|
118
|
+
const claude = which(process.env.BOTONS_CLAUDE_BIN || 'claude', path);
|
|
119
|
+
const codex = which(process.env.BOTONS_CODEX_BIN || 'codex', path);
|
|
120
|
+
// node's and the CLIs' folders, the shell's PATH without what npm adds for scripts, then the system folders.
|
|
121
|
+
const npmAdded = (d: string) => /[/\\]node_modules[/\\]|[/\\]node_modules$|node-gyp-bin/.test(d);
|
|
122
|
+
const dirs = [dirname(node), ...[claude, codex].flatMap((p) => (p ? [dirname(p)] : [])), ...path.split(delimiter).filter((d) => !npmAdded(d)), '/usr/local/bin', '/usr/bin', '/bin'];
|
|
123
|
+
const servicePath = [...new Set(dirs.filter((d) => d && isAbsolute(d)))].join(delimiter);
|
|
124
|
+
const line = (k: string, v: string | null, what: string) => (v ? `${k}=${quoteEnv(v)}` : `# ${k}=/path/to/${what} (not found on PATH when this file was written)`);
|
|
125
|
+
mkdirSync(dirname(envPath), { recursive: true, mode: 0o700 });
|
|
126
|
+
writeFileSync(
|
|
127
|
+
envPath,
|
|
128
|
+
`# Environment for the Botons server (${name}.service) and its nightly backup.
|
|
129
|
+
# Written by \`npm run service:install\`; edit freely,
|
|
130
|
+
# then: systemctl --user restart ${name}
|
|
131
|
+
#
|
|
132
|
+
# systemd does not read your shell profile, so PATH must name every folder the
|
|
133
|
+
# server and your Botons need: node, the claude and codex CLIs, and the tools
|
|
134
|
+
# Botons run (git, gh, ...).
|
|
135
|
+
PATH=${quoteEnv(servicePath)}
|
|
136
|
+
${line('BOTONS_CLAUDE_BIN', claude, 'claude')}
|
|
137
|
+
${line('BOTONS_CODEX_BIN', codex, 'codex')}
|
|
138
|
+
#
|
|
139
|
+
# Other settings (see "Configuration" in the README), for example:
|
|
140
|
+
# BOTONS_HOME=/home/you/.botons
|
|
141
|
+
# BOTONS_PORT=7777
|
|
142
|
+
# BOTONS_PUBLIC_URL=http://yourhost.your-tailnet.ts.net:7777
|
|
143
|
+
`,
|
|
144
|
+
{ mode: 0o600 },
|
|
145
|
+
);
|
|
146
|
+
log(`Wrote ${envPath}`);
|
|
147
|
+
if (!claude || !codex) log(` ${[!claude && 'claude', !codex && 'codex'].filter(Boolean).join(' and ')} not found on PATH: set the path in that file if you use it.`);
|
|
148
|
+
} else {
|
|
149
|
+
const env = readEnvFile(readFileSync(envPath, 'utf8'));
|
|
150
|
+
applyLegacyEnv(env);
|
|
151
|
+
const missing = ['PATH', 'BOTONS_CLAUDE_BIN', 'BOTONS_CODEX_BIN'].filter((k) => !env[k]);
|
|
152
|
+
log(`Kept ${envPath}${missing.length ? ` (it does not set ${missing.join(', ')}; the service needs PATH to find node's tools and the CLIs)` : ''}`);
|
|
153
|
+
}
|
|
154
|
+
// The stop timeout is the drain budget plus 30s to stop the agents and close.
|
|
155
|
+
const fileEnv = readEnvFile(readFileSync(envPath, 'utf8'));
|
|
156
|
+
applyLegacyEnv(fileEnv);
|
|
157
|
+
const drainMs = parseDuration(fileEnv.BOTONS_DRAIN_MS ?? '');
|
|
158
|
+
const stopTimeoutSec = Math.ceil((Number.isNaN(drainMs) ? DEFAULT_DRAIN_MS : drainMs) / 1000) + 30;
|
|
159
|
+
|
|
160
|
+
mkdirSync(unitDir, { recursive: true });
|
|
161
|
+
const unitFile = join(unitDir, `${name}.service`);
|
|
162
|
+
const unit = readFileSync(join(repo, 'scripts', 'botons.service'), 'utf8')
|
|
163
|
+
.replaceAll('@DESCRIPTION@', name === 'botons' ? 'Botons server' : `Botons server (${name})`)
|
|
164
|
+
.replaceAll('@APP@', escapeUnit(app))
|
|
165
|
+
.replaceAll('@NODE@', escapeUnit(node))
|
|
166
|
+
.replaceAll('@ENV_FILE@', envInUnit)
|
|
167
|
+
.replaceAll('@STOP_TIMEOUT@', String(stopTimeoutSec))
|
|
168
|
+
.replaceAll('@NAME@', name);
|
|
169
|
+
writeFileSync(unitFile, unit);
|
|
170
|
+
log(`Wrote ${unitFile}`);
|
|
171
|
+
return unitFile;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function main() {
|
|
175
|
+
const unitDir = resolve(arg('--dir') ?? unitDirDefault());
|
|
176
|
+
const name = arg('--name') ?? 'botons';
|
|
177
|
+
if (!arg('--name') && defaultUnitName(unitDir) !== 'botons') {
|
|
178
|
+
throw new Error(`This install's service still runs under a deprecated name (${defaultUnitName(unitDir)}.service). Run \`npm run migrate:names\` first: it moves it to botons.service. Nothing was changed.`);
|
|
179
|
+
}
|
|
180
|
+
const app = resolve(arg('--app') ?? repo);
|
|
181
|
+
const envPath = resolve(arg('--env-path') ?? defaultEnvPath());
|
|
182
|
+
writeServiceUnit({ name, app, envPath, unitDir });
|
|
183
|
+
if (process.argv.includes('--quiet')) return;
|
|
184
|
+
console.log(` runs ${process.execPath} ${join(app, 'server/src/index.ts')}
|
|
185
|
+
|
|
186
|
+
Start it now and at every boot (stop any other Botons server on the same data
|
|
187
|
+
folder first):
|
|
188
|
+
systemctl --user daemon-reload
|
|
189
|
+
systemctl --user enable --now ${name}
|
|
190
|
+
|
|
191
|
+
Check it:
|
|
192
|
+
systemctl --user status ${name}
|
|
193
|
+
journalctl --user -u ${name} -f
|
|
194
|
+
|
|
195
|
+
Run this again after moving the app folder, changing the Node version or
|
|
196
|
+
changing BOTONS_DRAIN_MS (the unit's stop timeout follows it).`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
200
|
+
try {
|
|
201
|
+
main();
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error((err as Error).message);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import '../server/src/env-init.ts';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { loadConfig } from '../server/src/config.ts';
|
|
4
|
+
import { cliHomesEnv, prepareCliHomes } from '../server/src/cli-homes.ts';
|
|
5
|
+
import { agentEnv } from '../server/src/providers/process.ts';
|
|
6
|
+
const kind = process.argv[2];
|
|
7
|
+
if (kind !== 'codex' && kind !== 'claude') {
|
|
8
|
+
console.error('Usage: botons login codex | botons login claude');
|
|
9
|
+
process.exitCode = 2;
|
|
10
|
+
} else {
|
|
11
|
+
const cfg = loadConfig();
|
|
12
|
+
prepareCliHomes();
|
|
13
|
+
const result = spawnSync(kind === 'codex' ? cfg.codexBin : cfg.claudeBin,
|
|
14
|
+
kind === 'codex' ? ['login', '--device-auth'] : ['auth', 'login'],
|
|
15
|
+
{ env: { ...agentEnv({}), ...cliHomesEnv(process.env, true) }, stdio: 'inherit' });
|
|
16
|
+
process.exitCode = result.status ?? 1;
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `npm run migrate:names [-- --rollback | --no-start | --undo]`: moves an install off deprecated names (see
|
|
2
|
+
// server/src/legacy-upgrade.ts and shared/legacy-names.mjs). Part of the legacy-names module: it goes with it.
|
|
3
|
+
import '../server/src/env-init.ts';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { ConfigError } from '../server/src/config.ts';
|
|
6
|
+
import { NameUpgradeError, rollbackNames, undoService, upgradeDataFolder, upgradeService } from '../server/src/legacy-upgrade.ts';
|
|
7
|
+
import { DataDirInUseError } from '../server/src/lock.ts';
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: npm run migrate:names [-- --rollback]
|
|
10
|
+
|
|
11
|
+
Moves an install whose data folder, settings file, service or database still
|
|
12
|
+
have a deprecated name to the current ones: it stops the service, backs the
|
|
13
|
+
data up, moves the folders (leaving a link at each earlier path), writes
|
|
14
|
+
~/.config/botons/env with each setting under its current name, and starts
|
|
15
|
+
botons.service in place of the earlier unit, which it disables. Running it
|
|
16
|
+
again finishes what is missing and changes nothing else. The server and
|
|
17
|
+
\`npm run deploy\` do the same by themselves; this is for doing it now.
|
|
18
|
+
|
|
19
|
+
--rollback undoes it: the earlier unit, folders and database name come back,
|
|
20
|
+
and it names the backup taken first.`;
|
|
21
|
+
|
|
22
|
+
const exec = async (cmd: string, args: string[]) => {
|
|
23
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit' });
|
|
24
|
+
if (r.error) throw r.error;
|
|
25
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`);
|
|
26
|
+
};
|
|
27
|
+
const log = (line: string) => console.log(line);
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
if (args.includes('--help') || args.includes('-h')) return console.log(USAGE);
|
|
32
|
+
if (args.includes('--rollback')) return void (await rollbackNames({ exec, log }));
|
|
33
|
+
// The installer's own: --no-start leaves starting botons.service to it, and --undo puts the earlier unit back if that fails.
|
|
34
|
+
if (args.includes('--undo')) return undoService({ exec, log });
|
|
35
|
+
const done = await upgradeService({ exec, log, start: !args.includes('--no-start') });
|
|
36
|
+
if (done.nothing) {
|
|
37
|
+
const data = upgradeDataFolder({ log, explicit: true });
|
|
38
|
+
if (!data.moved && !data.db) log('Nothing has a deprecated name: nothing to do.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
main().catch((err) => {
|
|
43
|
+
if (err instanceof ConfigError) console.error(err.errors.join('\n'));
|
|
44
|
+
else if (err instanceof NameUpgradeError || err instanceof DataDirInUseError) console.error(err.message);
|
|
45
|
+
else console.error(err);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import '../server/src/env-init.ts';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import type { AccessMode } from '../shared/types.ts';
|
|
7
|
+
import { type AuditEntry, HOST_ACTOR, recordAudit } from '../server/src/audit.ts';
|
|
8
|
+
import { approvePairingRequest, deleteAllDevices, deleteDevice, listDevices, pendingPairingRequests } from '../server/src/auth.ts';
|
|
9
|
+
import { type Config, loadConfig, botonsHome } from '../server/src/config.ts';
|
|
10
|
+
import { ACCESS_MODES, accessModeProblem, approveCommand, isAccessMode, readAccessMode, writeAccessMode } from '../server/src/setup.ts';
|
|
11
|
+
import { dbFileIn } from '../server/src/backup.ts';
|
|
12
|
+
import { serverEnv } from './install-service.ts';
|
|
13
|
+
|
|
14
|
+
const USAGE = `Usage:
|
|
15
|
+
npm run pair how to pair a device, and the requests waiting now
|
|
16
|
+
npm run pair -- --approve <code> approve the device that shows this code (it signs in at once)
|
|
17
|
+
npm run pair -- --approve <code> --label "Work iPad"
|
|
18
|
+
the same, and name the device
|
|
19
|
+
npm run pair -- --list list paired devices
|
|
20
|
+
npm run pair -- --revoke <id> revoke one device
|
|
21
|
+
npm run pair -- --revoke-all revoke every device
|
|
22
|
+
npm run pair -- --access-mode <mode> which networks may connect: local, tailscale, lan, public,
|
|
23
|
+
or none (only the listeners decide); takes effect at once
|
|
24
|
+
|
|
25
|
+
To pair a device, open Botons on it: it shows a code. Approve only a code you
|
|
26
|
+
are reading off the screen of the device you want to pair. A Boton can ask to
|
|
27
|
+
pair too, so never approve a code that reached you in a chat or a message.
|
|
28
|
+
|
|
29
|
+
Uses the data folder in BOTONS_HOME if it is set; otherwise the one the
|
|
30
|
+
service's environment file sets (--env-path, default ~/.config/botons/env);
|
|
31
|
+
otherwise ~/.botons.`;
|
|
32
|
+
|
|
33
|
+
function fail(message: string, code = 1): never {
|
|
34
|
+
console.error(message);
|
|
35
|
+
process.exit(code);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let args;
|
|
39
|
+
try {
|
|
40
|
+
args = parseArgs({
|
|
41
|
+
allowPositionals: false,
|
|
42
|
+
options: {
|
|
43
|
+
label: { type: 'string' },
|
|
44
|
+
approve: { type: 'string' },
|
|
45
|
+
list: { type: 'boolean' },
|
|
46
|
+
revoke: { type: 'string' },
|
|
47
|
+
'revoke-all': { type: 'boolean' },
|
|
48
|
+
'access-mode': { type: 'string' },
|
|
49
|
+
'env-path': { type: 'string' },
|
|
50
|
+
help: { type: 'boolean', short: 'h' },
|
|
51
|
+
},
|
|
52
|
+
}).values;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
fail(`${(err as Error).message}\n\n${USAGE}`, 2);
|
|
55
|
+
}
|
|
56
|
+
if (args.help) {
|
|
57
|
+
console.log(USAGE);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let env: NodeJS.ProcessEnv;
|
|
62
|
+
let from: string | null;
|
|
63
|
+
try {
|
|
64
|
+
({ env, from } = serverEnv(args['env-path']));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
fail((err as Error).message);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let home: string;
|
|
70
|
+
try {
|
|
71
|
+
home = botonsHome(env);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
fail((err as Error).message);
|
|
74
|
+
}
|
|
75
|
+
const dbPath = dbFileIn(home);
|
|
76
|
+
if (!existsSync(dbPath)) {
|
|
77
|
+
fail(
|
|
78
|
+
`There is no Botons database at ${dbPath}${from ? ` (BOTONS_HOME as ${from} sets it, or the default)` : ''}. Start the server once (it creates it), or set BOTONS_HOME to the data folder it uses.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const db = new DatabaseSync(dbPath);
|
|
82
|
+
db.exec('pragma busy_timeout = 5000; pragma foreign_keys = on;');
|
|
83
|
+
|
|
84
|
+
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'never');
|
|
85
|
+
// Someone at this machine, with the data folder: the owner. A database the server hasn't upgraded yet has no audit log.
|
|
86
|
+
const audit = (entry: AuditEntry) => {
|
|
87
|
+
if (db.prepare("select 1 from sqlite_master where type = 'table' and name = 'audit_events'").get()) recordAudit(db, HOST_ACTOR, entry);
|
|
88
|
+
};
|
|
89
|
+
const hasRequests = () => !!db.prepare("select 1 from sqlite_master where type = 'table' and name = 'pairing_requests'").get();
|
|
90
|
+
const config = (): Config | null => {
|
|
91
|
+
try {
|
|
92
|
+
return loadConfig(env, undefined, { create: false });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.warn(`(Could not read the server's settings: ${(err as Error).message})`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
if (args.list) {
|
|
101
|
+
const devices = listDevices(db);
|
|
102
|
+
if (!devices.length) console.log('No paired devices. Open Botons on a device and approve the code it shows with `npm run pair -- --approve <code>`.');
|
|
103
|
+
for (const d of devices) console.log(`${d.id} ${d.label} (paired ${when(d.createdAt)}, last used ${when(d.lastUsedAt)})`);
|
|
104
|
+
} else if (args['access-mode'] !== undefined) {
|
|
105
|
+
const raw = args['access-mode'].trim().toLowerCase();
|
|
106
|
+
const to: AccessMode | null = raw === 'none' ? null : isAccessMode(raw) ? raw : fail(`--access-mode must be one of ${ACCESS_MODES.join(', ')} or none; got "${raw}"`, 2);
|
|
107
|
+
const cfg = to === 'lan' || to === 'public' ? config() : null;
|
|
108
|
+
if (to === 'lan' || to === 'public') {
|
|
109
|
+
if (!cfg) fail(`Fix the server's settings before choosing ${to}.`);
|
|
110
|
+
const problem = accessModeProblem(to, cfg);
|
|
111
|
+
if (problem) fail(problem);
|
|
112
|
+
}
|
|
113
|
+
const from = readAccessMode(db);
|
|
114
|
+
writeAccessMode(db, to);
|
|
115
|
+
if (from !== to) audit({ action: 'access_mode.changed', details: { from, to } });
|
|
116
|
+
console.log(to === null ? 'No access mode: only the listeners (BOTONS_HOST, the published port) decide who connects.' : `Access mode: ${to}. A running server applies it to its next request.`);
|
|
117
|
+
} else if (args.revoke !== undefined) {
|
|
118
|
+
const device = listDevices(db).find((d) => d.id === args.revoke);
|
|
119
|
+
if (!device || !deleteDevice(db, device.id)) fail(`No paired device with id ${args.revoke}. \`npm run pair -- --list\` shows them.`);
|
|
120
|
+
audit({ action: 'device.revoked', target: { kind: 'device', id: device.id, label: device.label } });
|
|
121
|
+
console.log(`Revoked ${device.id} (${device.label}). Its next request is refused, and an open web app on it is signed out within half a minute.`);
|
|
122
|
+
} else if (args['revoke-all']) {
|
|
123
|
+
const n = deleteAllDevices(db);
|
|
124
|
+
audit({ action: 'devices.revoked_all', details: { count: n } });
|
|
125
|
+
console.log(`Revoked ${n} device${n === 1 ? '' : 's'}. To pair one again, open Botons on it and approve the code it shows with \`npm run pair -- --approve <code>\`.`);
|
|
126
|
+
} else if (args.approve !== undefined) {
|
|
127
|
+
if (!hasRequests()) fail('This database has no pairing requests yet: start the server once (it upgrades the database), then open Botons on the device.');
|
|
128
|
+
const approved = approvePairingRequest(db, args.approve, args.label ?? '');
|
|
129
|
+
if (!approved) {
|
|
130
|
+
fail(
|
|
131
|
+
'No device is waiting with that code. Check it against the device\'s screen: a code lasts 15 minutes, works once, and belongs to the Botons server with its data in ' +
|
|
132
|
+
`${home}.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
audit({ action: 'pairing_request.approved', details: { label: approved.label, requestedAt: approved.createdAt } });
|
|
136
|
+
console.log(`Approved "${approved.label}" (asked ${when(approved.createdAt)}). It signs in within a few seconds.`);
|
|
137
|
+
} else {
|
|
138
|
+
if (args.label !== undefined) fail('--label names the device you approve: use it with --approve <code>.', 2);
|
|
139
|
+
// Only for the URLs to show: a bad setting elsewhere must not stop you getting back in.
|
|
140
|
+
let urls: string[] = [];
|
|
141
|
+
try {
|
|
142
|
+
const cfg = loadConfig(env, undefined, { create: false });
|
|
143
|
+
// BOTONS_PUBLIC_URL first: in a container or behind a proxy it is the only address a device can open.
|
|
144
|
+
urls = [...new Set([cfg.publicUrl, ...cfg.urls])];
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.warn(`(Not listing the server's URLs: ${(err as Error).message})`);
|
|
147
|
+
}
|
|
148
|
+
console.log(`\n To pair a device with the Botons server whose data is in ${home}:`);
|
|
149
|
+
console.log(' 1. Open Botons on that device. It shows a code:');
|
|
150
|
+
for (const u of urls) console.log(` ${u}`);
|
|
151
|
+
console.log(` 2. Approve that code here: ${approveCommand({ container: env.BOTONS_CONTAINER === '1' })}`);
|
|
152
|
+
console.log('\n Approve only a code you read off the screen of the device you are pairing. A Boton');
|
|
153
|
+
console.log(' can ask to pair too, so never approve a code that reached you in a chat or a message.\n');
|
|
154
|
+
const waiting = hasRequests() ? pendingPairingRequests(db).filter((r) => !r.approved) : [];
|
|
155
|
+
if (waiting.length) {
|
|
156
|
+
console.log(` Waiting now (newest last; codes show only on the devices):`);
|
|
157
|
+
for (const r of waiting) console.log(` ${r.label || 'Unnamed device'} (asked ${when(r.createdAt)})`);
|
|
158
|
+
console.log('');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
db.close();
|
|
163
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// `npm run restore -- <backup> [--yes]`: put a backup made by `npm run backup` back into BOTONS_HOME.
|
|
2
|
+
// The server must be stopped. Nothing is deleted: the current database and folders move to before-restore-<time>/.
|
|
3
|
+
// BOTONS_HOME may not exist yet (a new machine): it is created, private, with --yes.
|
|
4
|
+
import '../server/src/env-init.ts';
|
|
5
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { userInfo } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { listBackups, planRestore, restore } from '../server/src/backup.ts';
|
|
10
|
+
import { botonsHome } from '../server/src/config.ts';
|
|
11
|
+
import { DataDirInUseError, lockDataDir } from '../server/src/lock.ts';
|
|
12
|
+
import { SCHEMA_VERSION } from '../server/src/schema.ts';
|
|
13
|
+
import { defaultUnitName, serverEnv } from './install-service.ts';
|
|
14
|
+
|
|
15
|
+
const USAGE = `Usage:
|
|
16
|
+
npm run restore -- --list list the backups in BOTONS_HOME/backups, oldest first
|
|
17
|
+
npm run restore -- <backup> show what restoring <backup> would do (changes nothing)
|
|
18
|
+
npm run restore -- <backup> --yes restore it (the server must be stopped)
|
|
19
|
+
|
|
20
|
+
<backup> is a folder name from --list, "latest", or the path of a backup folder.
|
|
21
|
+
Uses the data folder in BOTONS_HOME if it is set; otherwise the one the
|
|
22
|
+
service's environment file sets (--env-path, default ~/.config/botons/env);
|
|
23
|
+
otherwise ~/.botons. In Docker, see docs/DOCKER.md.`;
|
|
24
|
+
|
|
25
|
+
function fail(message: string, code = 1): never {
|
|
26
|
+
console.error(message);
|
|
27
|
+
process.exit(code);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = parseArgs({ allowPositionals: true, options: { yes: { type: 'boolean' }, list: { type: 'boolean' }, 'env-path': { type: 'string' }, help: { type: 'boolean', short: 'h' } } });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
fail(`${(err as Error).message}\n\n${USAGE}`, 2);
|
|
35
|
+
}
|
|
36
|
+
const { values: args, positionals } = parsed;
|
|
37
|
+
if (args.help) {
|
|
38
|
+
console.log(USAGE);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let home: string;
|
|
43
|
+
try {
|
|
44
|
+
home = botonsHome(serverEnv(args['env-path']).env);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
fail((err as Error).message);
|
|
47
|
+
}
|
|
48
|
+
if (args.list) {
|
|
49
|
+
const names = listBackups(join(home, 'backups'));
|
|
50
|
+
if (!names.length) console.log(`No backups in ${join(home, 'backups')}. \`npm run backup\` makes one.`);
|
|
51
|
+
for (const n of names) {
|
|
52
|
+
let about = '';
|
|
53
|
+
try {
|
|
54
|
+
const m = JSON.parse(readFileSync(join(home, 'backups', n, 'manifest.json'), 'utf8'));
|
|
55
|
+
about = ` schema v${m.schemaVersion}${m.workspaces ? ', workspaces' : ', database only'}`;
|
|
56
|
+
} catch {}
|
|
57
|
+
console.log(`${n}${about}`);
|
|
58
|
+
}
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
if (positionals.length !== 1) fail(USAGE, 2);
|
|
62
|
+
|
|
63
|
+
let plan;
|
|
64
|
+
try {
|
|
65
|
+
plan = planRestore({ home, backup: positionals[0], maxSchema: SCHEMA_VERSION });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
fail(`Cannot restore: ${(err as Error).message}`);
|
|
68
|
+
}
|
|
69
|
+
const fresh = !existsSync(home);
|
|
70
|
+
console.log(`Restore ${plan.from}
|
|
71
|
+
into ${home}${fresh ? ' (it does not exist yet: created, private)' : ''}
|
|
72
|
+
database schema v${plan.schemaVersion}${plan.schemaVersion < SCHEMA_VERSION ? ` (the server upgrades it to v${SCHEMA_VERSION} at its next start)` : ''}
|
|
73
|
+
${plan.workspaces ? 'workspaces, shared, templates and browser profiles from workspaces.tar.gz' : 'no workspaces.tar.gz: the database only; the folders now in place are set aside too'}
|
|
74
|
+
moves ${plan.moves.length ? `${plan.moves.join(', ')} to ${plan.setAside}` : 'nothing: the data folder is empty'}`);
|
|
75
|
+
|
|
76
|
+
// The data folder, or the nearest folder it would be created in, must be writable by this user: say so before anything moves.
|
|
77
|
+
let writable = home;
|
|
78
|
+
while (!existsSync(writable) && dirname(writable) !== writable) writable = dirname(writable);
|
|
79
|
+
try {
|
|
80
|
+
accessSync(writable, constants.W_OK | constants.X_OK);
|
|
81
|
+
} catch {
|
|
82
|
+
fail(
|
|
83
|
+
`\nCannot restore: ${writable} is not writable by this user (${userInfo().username}). Nothing was changed. Run the restore as the user that owns the data folder (the one the server runs as), or give that user write access to it (for example chmod u+rwx ${writable}), then run it again.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!args.yes) {
|
|
87
|
+
console.log('\nNothing was changed. Stop the server, then run this again with --yes.');
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let lock: ReturnType<typeof lockDataDir> | undefined;
|
|
92
|
+
try {
|
|
93
|
+
if (fresh) mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
94
|
+
lock = lockDataDir(home);
|
|
95
|
+
restore(home, plan);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
lock?.release();
|
|
98
|
+
if (err instanceof DataDirInUseError) fail(`${err.message.replace('Nothing was changed.', '')}Stop the server first (systemctl --user stop ${defaultUnitName()}, or docker compose stop botons). Nothing was changed.`);
|
|
99
|
+
const message = (err as Error).message;
|
|
100
|
+
const next = /No space left on device|ENOSPC/.test(message)
|
|
101
|
+
? `\nFree space on the disk that holds ${home} (df -h ${home}), or restore onto a larger disk, then run this again.`
|
|
102
|
+
: /Permission denied|EACCES|EPERM/.test(message)
|
|
103
|
+
? `\nGive the user running this (${userInfo().username}) write access to everything in ${home}, or run it as the user that owns it, then run this again.`
|
|
104
|
+
: '';
|
|
105
|
+
fail(`${message}${next}`);
|
|
106
|
+
}
|
|
107
|
+
lock.release();
|
|
108
|
+
console.log(`\nRestored.${plan.moves.length ? ` The previous state is in ${plan.setAside} (delete it once you are happy).` : ''} Start the server again.`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
// The turn a Boton's request to the server came from, while the server handles it: so what the request posts in the
|
|
4
|
+
// chat (a memory saved, a file shared...) is known to be that turn's. Set only for a Boton's own token, from the turn
|
|
5
|
+
// the engine is running for it, never from anything in the request.
|
|
6
|
+
const acting = new AsyncLocalStorage<{ botId: string; turnId: string }>();
|
|
7
|
+
|
|
8
|
+
export function asTurn<T>(botId: string, turnId: string | null | undefined, fn: () => T): T {
|
|
9
|
+
return turnId ? acting.run({ botId, turnId }, fn) : fn();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The turn of the Boton's request being handled, if it is `botId`'s: else null (the owner, the server). */
|
|
13
|
+
export function actingTurn(botId: string): string | null {
|
|
14
|
+
const a = acting.getStore();
|
|
15
|
+
return a && a.botId === botId ? a.turnId : null;
|
|
16
|
+
}
|