From 44a3f8cbaf078b4f7a1297719422f839071df5b4 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Tue, 4 Aug 2026 19:58:54 +0700 Subject: [PATCH] Add an unadvertised --uninstall mode to skill office codevhub skill office --uninstall fetches the published per-OS uninstall script (codev-office--uninstall.sh|ps1) and runs it - no bundle download. --yes / --skills-only / --purge-downloads pass through to the uninstaller; install flags are rejected in uninstall mode and vice versa, so nothing is silently forwarded to a script that would choke on it. Deliberately absent from OFFICE_USAGE and codevhub help. Co-Authored-By: Claude Fable 5 --- src/lib/office.ts | 134 +++++++++++++++++++++++++++++-------- tests/lib/download.test.ts | 79 ++++++++++++++++++---- tests/lib/office.test.ts | 45 +++++++++++++ 3 files changed, 218 insertions(+), 40 deletions(-) diff --git a/src/lib/office.ts b/src/lib/office.ts index 8dc19d7..6d5345e 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -37,6 +37,13 @@ export interface OfficeArgs { minimal: boolean; skipVerify: boolean; forceSkills: boolean; + // Deliberately unadvertised (absent from OFFICE_USAGE and `codevhub help`): + // fetches and runs the published uninstall script instead of the installer. + uninstall: boolean; + // Uninstall-only passthroughs, equally unadvertised. + yes: boolean; + skillsOnly: boolean; + purgeDownloads: boolean; error?: string; } @@ -46,6 +53,10 @@ export function parseOfficeArgs(argv: string[]): OfficeArgs { minimal: false, skipVerify: false, forceSkills: false, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -89,11 +100,33 @@ export function parseOfficeArgs(argv: string[]): OfficeArgs { case "--force-skills": parsed.forceSkills = true; break; + case "--uninstall": + parsed.uninstall = true; + break; + case "--yes": + parsed.yes = true; + break; + case "--skills-only": + parsed.skillsOnly = true; + break; + case "--purge-downloads": + parsed.purgeDownloads = true; + break; default: parsed.error = `unknown option: ${arg}`; return parsed; } } + // The two modes take disjoint flag sets — reject mixtures loudly rather + // than silently forwarding a flag the target script would choke on. + if (parsed.uninstall) { + if (parsed.minimal || parsed.skipVerify || parsed.forceSkills) { + parsed.error = + "--minimal/--skip-verify/--force-skills do not apply with --uninstall"; + } + } else if (parsed.yes || parsed.skillsOnly || parsed.purgeDownloads) { + parsed.error = "--yes/--skills-only/--purge-downloads require --uninstall"; + } return parsed; } @@ -110,6 +143,12 @@ export function officeScriptName(platform: OfficePlatform): string { : `codev-office-${platform}-setup.sh`; } +export function officeUninstallScriptName(platform: OfficePlatform): string { + return platform === "windows" + ? "codev-office-windows-uninstall.ps1" + : `codev-office-${platform}-uninstall.sh`; +} + // Rough bundle sizes for the pre-download heads-up only; progress totals come // from the server's content-length. const APPROX_BUNDLE_MB: Record = { @@ -182,6 +221,24 @@ export function installerArgs( ]; } +export function uninstallerArgs( + parsed: OfficeArgs, + platform: OfficePlatform, +): string[] { + if (platform === "windows") { + return [ + ...(parsed.yes ? ["-Yes"] : []), + ...(parsed.skillsOnly ? ["-SkillsOnly"] : []), + ...(parsed.purgeDownloads ? ["-PurgeDownloads"] : []), + ]; + } + return [ + ...(parsed.yes ? ["--yes"] : []), + ...(parsed.skillsOnly ? ["--skills-only"] : []), + ...(parsed.purgeDownloads ? ["--purge-downloads"] : []), + ]; +} + // Exposed for tests (mocked); production always uses the default. export type OfficeSpawner = ( command: string, @@ -233,29 +290,40 @@ export async function runSkillOffice( mkdirSync(dir, { recursive: true }); const bundle = officeBundleName(platform); - const script = officeScriptName(platform); - console.error(`CoDev Office offline skills bundle (${platform})`); - console.error( - `Heads-up: the bundle is ~${APPROX_BUNDLE_MB[platform]} MB — downloading might ` + - "take a while. An interrupted run picks up where it left off; an " + - "already-downloaded bundle is reused after checking with the server " + - "that it is still the published version.", + const script = parsed.uninstall + ? officeUninstallScriptName(platform) + : officeScriptName(platform); + if (parsed.uninstall) { + console.error(`CoDev Office offline skills uninstaller (${platform})`); + } else { + console.error(`CoDev Office offline skills bundle (${platform})`); + console.error( + `Heads-up: the bundle is ~${APPROX_BUNDLE_MB[platform]} MB — downloading might ` + + "take a while. An interrupted run picks up where it left off; an " + + "already-downloaded bundle is reused after checking with the server " + + "that it is still the published version.", + ); + } + logInfo( + parsed.uninstall + ? "office uninstall starting" + : "office bundle download starting", + { + action: parsed.uninstall ? "office.uninstall" : "office.install", + extra: { platform, dir, downloadOnly }, + }, ); - logInfo("office bundle download starting", { - action: "office.install", - extra: { platform, dir, downloadOnly }, - }); - // Without per-file checksums an existing file is reused as-is. That's the - // point for the GB-scale bundle, but the setup script is tiny and must - // track the published version — always refetch it. The `.partial` goes too: - // downloadFile resumes from it via Range, so a leftover from an interrupted - // run would splice stale bytes onto a script that has since been republished - // — and with no expected checksum, nothing would catch it. + // The scripts are tiny and must track the published version — always + // refetch them (belt and braces on top of the ETag revalidation). The + // `.partial` goes too: downloadFile resumes from it via Range, so a + // leftover from an interrupted run would splice stale bytes onto a script + // that has since been republished. rmSync(join(dir, script), { force: true }); rmSync(join(dir, `${script}.partial`), { force: true }); - for (const name of [script, bundle]) { + // Uninstall needs no bundle — only the script. + for (const name of parsed.uninstall ? [script] : [script, bundle]) { const progress = makeProgressPrinter(name); try { await downloadFile({ @@ -279,12 +347,19 @@ export async function runSkillOffice( } if (downloadOnly) { - console.error(`\nFiles are in ${dir}. To install, run from that folder:`); + console.error( + `\nFiles are in ${dir}. To ${parsed.uninstall ? "uninstall" : "install"}, run from that folder:`, + ); console.error(` ${manualRunCommand(platform, script)}`); return 0; } - console.error(`\nRunning the installer (${script})...\n`); + const scriptArgs = parsed.uninstall + ? uninstallerArgs(parsed, platform) + : installerArgs(parsed, platform); + console.error( + `\nRunning the ${parsed.uninstall ? "uninstaller" : "installer"} (${script})...\n`, + ); const [command, args] = platform === "windows" ? [ @@ -294,16 +369,21 @@ export async function runSkillOffice( "Bypass", "-File", join(dir, script), - ...installerArgs(parsed, platform), + ...scriptArgs, ], ] - : ["bash", [join(dir, script), ...installerArgs(parsed, platform)]]; + : ["bash", [join(dir, script), ...scriptArgs]]; // cwd = download dir: the scripts locate their bundle zip next to - // themselves/CWD, and both files were just staged there. + // themselves/CWD, and the staged files are there. const code = await spawner(command, args, dir); - logInfo("office installer finished", { - action: "office.install", - extra: { platform, exitCode: code }, - }); + logInfo( + parsed.uninstall + ? "office uninstaller finished" + : "office installer finished", + { + action: parsed.uninstall ? "office.uninstall" : "office.install", + extra: { platform, exitCode: code }, + }, + ); return code; } diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 80ae975..ac60678 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -13,7 +13,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { downloadFile } from "@/lib/download.js"; -import { installerArgs, runSkillOffice } from "@/lib/office.js"; +import { + installerArgs, + runSkillOffice, + uninstallerArgs, +} from "@/lib/office.js"; const sha256 = (data: Buffer | string) => createHash("sha256").update(data).digest("hex"); @@ -438,11 +442,44 @@ describe("runSkillOffice", () => { ); expect(code).toBe(1); }); + + test("--uninstall fetches only the uninstall script and runs it with passthroughs", async () => { + const uninstallName = `codev-office-${hostPlatform}-uninstall.sh`; + objects.set(`/${uninstallName}`, SCRIPT); + const dir = join(tempDir, "office"); + let spawned: { command: string; args: string[] } | null = null; + const code = await runSkillOffice( + ["--uninstall", "--yes", "--skills-only", "--dir", dir], + baseUrl, + async (command, args) => { + spawned = { command, args }; + return 0; + }, + ); + expect(code).toBe(0); + expect(spawned).toEqual({ + command: "bash", + args: [join(dir, uninstallName), "--yes", "--skills-only"], + }); + // The bundle is never touched in uninstall mode. + expect(existsSync(join(dir, bundleName))).toBe(false); + }); }); +const NO_FLAGS = { + downloadOnly: false, + minimal: false, + skipVerify: false, + forceSkills: false, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, +}; + describe("installerArgs", () => { const base = { - downloadOnly: false, + ...NO_FLAGS, minimal: true, skipVerify: true, forceSkills: true, @@ -465,16 +502,32 @@ describe("installerArgs", () => { }); test("no flags when none requested", () => { - expect( - installerArgs( - { - downloadOnly: false, - minimal: false, - skipVerify: false, - forceSkills: false, - }, - "windows", - ), - ).toEqual([]); + expect(installerArgs(NO_FLAGS, "windows")).toEqual([]); + }); +}); + +describe("uninstallerArgs", () => { + const base = { + ...NO_FLAGS, + uninstall: true, + yes: true, + skillsOnly: true, + purgeDownloads: true, + }; + + test("bash platforms get GNU-style flags", () => { + expect(uninstallerArgs(base, "ubuntu")).toEqual([ + "--yes", + "--skills-only", + "--purge-downloads", + ]); + }); + + test("windows gets PowerShell switches", () => { + expect(uninstallerArgs(base, "windows")).toEqual([ + "-Yes", + "-SkillsOnly", + "-PurgeDownloads", + ]); }); }); diff --git a/tests/lib/office.test.ts b/tests/lib/office.test.ts index f5d5f85..6510485 100644 --- a/tests/lib/office.test.ts +++ b/tests/lib/office.test.ts @@ -4,6 +4,7 @@ import { formatSize, officeBundleName, officeScriptName, + officeUninstallScriptName, parseOfficeArgs, } from "@/lib/office.js"; @@ -27,6 +28,10 @@ describe("parseOfficeArgs", () => { minimal: false, skipVerify: false, forceSkills: false, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, }); }); @@ -48,9 +53,37 @@ describe("parseOfficeArgs", () => { minimal: true, skipVerify: true, forceSkills: true, + uninstall: false, + yes: false, + skillsOnly: false, + purgeDownloads: false, }); }); + test("uninstall mode with its passthrough flags (unadvertised)", () => { + const parsed = parseOfficeArgs([ + "--uninstall", + "--yes", + "--skills-only", + "--purge-downloads", + ]); + expect(parsed.error).toBeUndefined(); + expect(parsed.uninstall).toBe(true); + expect(parsed.yes).toBe(true); + expect(parsed.skillsOnly).toBe(true); + expect(parsed.purgeDownloads).toBe(true); + }); + + test("rejects install flags combined with --uninstall", () => { + expect(parseOfficeArgs(["--uninstall", "--minimal"]).error).toMatch( + /do not apply with --uninstall/, + ); + }); + + test("rejects uninstall passthroughs without --uninstall", () => { + expect(parseOfficeArgs(["--yes"]).error).toMatch(/require --uninstall/); + }); + test("--platform=value form", () => { expect(parseOfficeArgs(["--platform=macos"]).platform).toBe("macos"); }); @@ -85,6 +118,18 @@ describe("office file names", () => { expect(officeScriptName("macos")).toBe("codev-office-macos-setup.sh"); expect(officeScriptName("windows")).toBe("codev-office-windows-setup.ps1"); }); + + test("uninstall script names", () => { + expect(officeUninstallScriptName("ubuntu")).toBe( + "codev-office-ubuntu-uninstall.sh", + ); + expect(officeUninstallScriptName("macos")).toBe( + "codev-office-macos-uninstall.sh", + ); + expect(officeUninstallScriptName("windows")).toBe( + "codev-office-windows-uninstall.ps1", + ); + }); }); describe("formatSize", () => {