Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ pnpm dev # só a UI num navegador comum, com o FakeBotloft (src/dev/pre
pnpm check # tsc --noEmit + biome check + vitest run
pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release\bundle\nsis
pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup
pnpm local # compila em release e troca o app e o botloftd do Botloft instalado, sem instalador; `pnpm local --restore` volta o anterior
```

O `pnpm local` usa os dados reais (`%LOCALAPPDATA%\Botloft`), não os de dev. As migrations novas rodam no banco real, e um daemon mais antigo deixa de abrir esse banco. O atualizador continua oferecendo releases de versão maior.

Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A assinatura de código passa por `app/scripts/sign.mjs` e só liga com a variável `BOTLOFT_SIGN_THUMBPRINT` (spec 15.5); a política pública fica em `docs/code-signing-policy.md`. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou.

Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá.
Expand Down
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"tauri": "tauri",
"bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json",
"bundle:setup": "node scripts/setup.mjs",
"local": "node scripts/local.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"lint": "biome check .",
"format": "biome check --write .",
Expand Down
105 changes: 105 additions & 0 deletions app/scripts/local.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Puts this checkout over the Botloft installed on this computer, without
// an installer: builds the app and botloftd for release, swaps both into
// the install folder and has the scheduled task run the new daemon. The
// owner's data stays where it is; the database moves forward to this
// build's migrations, so an older daemon will no longer open it.
//
// pnpm local build, swap, reopen the app
// pnpm local --no-build install what the last build left in target/release
// pnpm local --restore put back what the last run replaced

import { execFileSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const app = join(dirname(fileURLToPath(import.meta.url)), "..");
const root = join(app, "..");
const target = process.env.CARGO_TARGET_DIR ?? join(root, "target");
const restore = process.argv.includes("--restore");
const build = !restore && !process.argv.includes("--no-build");

// The installed copy, never a dev one (CLAUDE.md: BOTLOFT_HOME).
const home = join(process.env.LOCALAPPDATA ?? "", "Botloft");
const env = { ...process.env };
delete env.BOTLOFT_HOME;
const folder = installFolder();
const backup = join(folder, "local-backup");
const files = ["Botloft.exe", "botloftd.exe"];

if (!existsSync(join(folder, "Botloft.exe"))) {
throw new Error(`no installed Botloft in ${folder}; install it once with the setup first`);
}

if (restore && !files.every((file) => existsSync(join(backup, file)))) {
throw new Error(`nothing to restore in ${backup}`);
}

if (build) {
execFileSync("cargo", ["build", "--release", "-p", "botloftd"], { cwd: root, stdio: "inherit" });
// `tauri build` embeds the UI; a plain `cargo build` would point it at
// the dev server. Without the bundle config there is no sidecar to need.
execFileSync("pnpm", ["tauri", "build", "--no-bundle"], {
cwd: app,
stdio: "inherit",
shell: true,
});
}

closeApp();
if (restore) {
for (const file of files) {
copyFileSync(join(backup, file), join(folder, file));
}
} else {
mkdirSync(backup, { recursive: true });
for (const file of files) {
copyFileSync(join(folder, file), join(backup, file));
copyFileSync(join(target, "release", file), join(folder, file));
}
}
// Copies the daemon next to the data, restarts the task and waits for the
// new version to answer (spec 14).
execFileSync(join(folder, "botloftd.exe"), ["--home", home, "service", "install"], {
env,
stdio: "inherit",
});
// Through `start`, so the app holds none of this terminal's handles.
execFileSync("cmd", ["/c", "start", '""', `"${join(folder, "Botloft.exe")}"`], {
env,
windowsVerbatimArguments: true,
});
console.log(
restore ? `restored the previous build in ${folder}` : `installed this build in ${folder}`,
);

/** Where the installer put Botloft (hooks.nsh), or the default folder. */
function installFolder() {
try {
const out = execFileSync("reg", ["query", "HKCU\\Software\\Botloft\\Botloft", "/ve"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const path = out.match(/REG_SZ\s+(.+?)\s*$/m)?.[1];
if (path) {
return path.replace(/^"|"$/g, "");
}
} catch {
// No key: an install older than the key, in the default folder.
}
return home;
}

/** The running app locks its exe; the daemon keeps the bots meanwhile. */
function closeApp() {
const exe = join(folder, "Botloft.exe").replaceAll("'", "''");
execFileSync(
"powershell",
[
"-NoProfile",
"-Command",
`Get-Process Botloft -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq '${exe}' } | ForEach-Object { Stop-Process -Id $_.Id -Force; $_.WaitForExit(5000) | Out-Null }; exit 0`,
],
{ stdio: "inherit" },
);
}
Loading