Files
openwork/scripts/migration/03-post-migration-cleanup.mjs
ben 7a53c2964c feat(desktop): Tauri → Electron migration release kit (#1526)
Ships the last-missing piece of the Electron migration. After this PR
merges there are three one-shot scripts under scripts/migration/ that
carry the whole lifecycle from "cut v0.12.0" through "delete src-tauri":

  01-cut-migration-release.mjs   # bumps versions, writes the release env
                                 # fragment, tags, pushes.
  02-validate-migration.mjs      # guided smoke test against a cut release.
  03-post-migration-cleanup.mjs  # deletes src-tauri, flips defaults,
                                 # scrubs Tauri docs (dry-run by default).

Code landing in the same PR (dormant until a release sets
VITE_OPENWORK_MIGRATION_RELEASE=1):

- apps/desktop/src-tauri/src/commands/migration.rs gains
  migrate_to_electron() — downloads the matching Electron .zip, verifies
  the Apple signature, swaps the .app bundle in place via a detached
  shell script, relaunches, and exits. Windows + Linux branches are
  stubbed with clear TODOs for the follow-up.
- apps/app/src/app/lib/migration.ts grows migrateToElectron() + a
  "later" defer helper.
- apps/app/src/react-app/shell/migration-prompt.tsx adds the one-time
  "OpenWork is moving to a new engine" modal. Mounted from
  providers.tsx. Gated on isTauriRuntime() AND the build-time flag, so
  Electron users and all non-migration-release builds never render it.
- apps/app/vite.config.ts loads apps/app/.env.migration-release when
  present so the prompt gets the release-specific download URLs.
- .gitignore allows the migration-release fragment to be committed only
  on the tagged migration-release commit (removed in cleanup step).

Release workflow:
- .github/workflows/release-macos-aarch64.yml gains a publish-electron
  job alongside the Tauri jobs. Gated on RELEASE_PUBLISH_ELECTRON repo
  var OR the new publish_electron workflow_dispatch input (default
  false). Uses the existing Apple Dev ID secrets — no new credential
  story. Produces latest-mac.yml alongside Tauri's latest.json so a
  v0.12.0 release serves both updaters.

Verified:
  pnpm --filter @openwork/app typecheck   ✓
  cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml   ✓
  node --check on all mjs scripts   ✓
  python yaml parse of release-macos-aarch64.yml   ✓

Not verified (needs a real release cycle):
  end-to-end migration from a signed Tauri .app to a signed Electron
  .app through the detached-script install swap.

Co-authored-by: Benjamin Shafii <benjamin@openworklabs.com>
2026-04-22 18:52:30 -07:00

212 lines
6.8 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Post-migration cleanup. Run this ONLY after v0.12.x has been stable
// for ~1-2 weeks and telemetry confirms users have rolled over to the
// Electron build. Irreversible (well, revertible via git, but not
// trivial to redeploy the Tauri path after removing it).
//
// Usage:
// node scripts/migration/03-post-migration-cleanup.mjs --dry-run # default
// node scripts/migration/03-post-migration-cleanup.mjs --execute # actually do it
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../..");
function parseArgs(argv) {
// Default is dry-run for safety; --execute required to actually do it.
const out = { dryRun: true };
for (const arg of argv) {
if (arg === "--execute") out.dryRun = false;
else if (arg === "--dry-run") out.dryRun = true;
else if (arg === "--help" || arg === "-h") out.help = true;
else {
console.error(`unknown arg: ${arg}`);
process.exit(2);
}
}
return out;
}
function log(msg) {
console.log(`[cleanup] ${msg}`);
}
function run(cmd, args, opts = {}) {
log(`$ ${cmd} ${args.join(" ")}`);
if (opts.dryRun) return "";
const result = spawnSync(cmd, args, {
cwd: opts.cwd ?? repoRoot,
stdio: "inherit",
});
if (result.status !== 0) {
console.error(`[cleanup] command failed: ${cmd}`);
process.exit(1);
}
}
async function patchJson(path, updater, { dryRun }) {
const raw = await readFile(path, "utf8");
const parsed = JSON.parse(raw);
const next = updater(parsed);
if (JSON.stringify(parsed) === JSON.stringify(next)) {
log(`no change needed: ${path}`);
return;
}
log(`patching: ${path}`);
if (!dryRun) {
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, "utf8");
}
}
async function replaceInFile(path, replacements, { dryRun }) {
if (!existsSync(path)) return;
const original = await readFile(path, "utf8");
let next = original;
for (const [pattern, replacement] of replacements) {
next = next.replace(pattern, replacement);
}
if (next === original) return;
log(`rewriting: ${path}`);
if (!dryRun) {
await writeFile(path, next, "utf8");
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(
[
"Post-migration cleanup (Tauri → Electron).",
"",
"Runs in dry-run mode by default. Pass --execute to actually change files.",
].join("\n"),
);
return;
}
if (args.dryRun) {
log("DRY RUN — no filesystem changes. Pass --execute to apply.");
}
// 1. Flip apps/desktop/package.json defaults to Electron.
const desktopPkgPath = resolve(repoRoot, "apps/desktop/package.json");
await patchJson(
desktopPkgPath,
(pkg) => {
const scripts = { ...(pkg.scripts ?? {}) };
scripts.dev = "node ./scripts/electron-dev.mjs";
scripts.build = "node ./scripts/electron-build.mjs";
scripts.package =
"pnpm run build && pnpm exec electron-builder --config electron-builder.yml";
scripts["dev:electron"] = undefined;
scripts["build:electron"] = undefined;
scripts["package:electron"] = undefined;
scripts["package:electron:dir"] =
"pnpm run build && pnpm exec electron-builder --config electron-builder.yml --dir";
scripts.electron = undefined;
scripts["dev:react-session"] = undefined;
scripts["dev:electron:react-session"] =
"VITE_OPENWORK_REACT_SESSION=1 node ./scripts/electron-dev.mjs";
// Remove Tauri-specific script entries.
scripts["build:debug:react-session"] = undefined;
scripts["dev:windows"] = undefined;
scripts["dev:windows:x64"] = undefined;
const filteredScripts = Object.fromEntries(
Object.entries(scripts).filter(([, v]) => v != null),
);
const devDeps = { ...(pkg.devDependencies ?? {}) };
delete devDeps["@tauri-apps/cli"];
return { ...pkg, scripts: filteredScripts, devDependencies: devDeps };
},
{ dryRun: args.dryRun },
);
// 2. Strip @tauri-apps/* from apps/app and apps/story-book package.json.
for (const pkgPath of [
resolve(repoRoot, "apps/app/package.json"),
resolve(repoRoot, "apps/story-book/package.json"),
]) {
if (!existsSync(pkgPath)) continue;
await patchJson(
pkgPath,
(pkg) => {
const deps = { ...(pkg.dependencies ?? {}) };
const devDeps = { ...(pkg.devDependencies ?? {}) };
for (const name of Object.keys(deps)) {
if (name.startsWith("@tauri-apps/")) delete deps[name];
}
for (const name of Object.keys(devDeps)) {
if (name.startsWith("@tauri-apps/")) delete devDeps[name];
}
return { ...pkg, dependencies: deps, devDependencies: devDeps };
},
{ dryRun: args.dryRun },
);
}
// 3. Delete src-tauri/ entirely.
run("git", ["rm", "-r", "-f", "apps/desktop/src-tauri"], { dryRun: args.dryRun });
// 4. Collapse desktop-tauri.ts into desktop.ts. We do a surgical rename
// for now and leave the collapse for a follow-up PR; deleting the
// proxy layer is a bigger refactor.
log(
"[reminder] apps/app/src/app/lib/desktop-tauri.ts still exists. After this script lands,",
);
log(
" open a follow-up PR that inlines desktop.ts's Electron path and removes the",
);
log(" proxy + re-export surface.");
// 5. Drop AGENTS.md / ARCHITECTURE.md / README.md Tauri references.
const docReplacements = [
[/Tauri 2\.x/g, "Electron"],
[/\| Desktop\/Mobile shell \| Tauri 2\.x\s+\|/g, "| Desktop/Mobile shell | Electron |"],
[/apps\/desktop\/src-tauri/g, "apps/desktop/electron"],
];
for (const path of [
resolve(repoRoot, "AGENTS.md"),
resolve(repoRoot, "ARCHITECTURE.md"),
resolve(repoRoot, "README.md"),
]) {
await replaceInFile(path, docReplacements, { dryRun: args.dryRun });
}
// 6. Remove the migration-release env fragment once it's no longer
// relevant.
run("git", ["rm", "-f", "apps/app/.env.migration-release"], {
dryRun: args.dryRun,
});
// 7. Stage + commit.
run("git", ["add", "-A"], { dryRun: args.dryRun });
run(
"git",
[
"commit",
"-m",
"chore(desktop): remove Tauri shell, make Electron the default\n\nRun after v0.12.x stabilized in the wild. See\nscripts/migration/README.md for the full runbook.",
],
{ dryRun: args.dryRun },
);
log("");
log(
args.dryRun
? "dry run complete. rerun with --execute to apply."
: "commit created. push + open PR for review.",
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});