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
134 changes: 107 additions & 27 deletions src/lib/office.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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];
Expand Down Expand Up @@ -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;
}

Expand All @@ -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<OfficePlatform, number> = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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"
? [
Expand All @@ -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;
}
79 changes: 66 additions & 13 deletions tests/lib/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
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");
Expand Down Expand Up @@ -303,7 +307,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 310 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > --download-only stages both files and never spawns

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:310:16
expect(spawns).toEqual([]);
expect(readFileSync(join(dir, bundleName)).equals(BUNDLE)).toBe(true);
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
Expand All @@ -320,7 +324,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 327 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > runs the installer via bash with translated flags

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:327:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, scriptName), "--minimal", "--skip-verify"],
Expand All @@ -331,7 +335,7 @@
test("propagates the installer's exit code", async () => {
const dir = join(tempDir, "office");
const code = await runSkillOffice(["--dir", dir], baseUrl, async () => 7);
expect(code).toBe(7);

Check failure on line 338 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > propagates the installer's exit code

AssertionError: expected 1 to be 7 // Object.is equality - Expected + Received - 7 + 1 ❯ tests/lib/download.test.ts:338:16
});

test("a cross-platform --platform forces download-only", async () => {
Expand All @@ -348,7 +352,7 @@
},
);
expect(code).toBe(0);
expect(spawns).toEqual([]);

Check failure on line 355 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > a cross-platform --platform forces download-only

AssertionError: expected [ 'powershell.exe' ] to deeply equal [] - Expected + Received - [] + [ + "powershell.exe", + ] ❯ tests/lib/download.test.ts:355:18
expect(existsSync(join(dir, "codev-office-windows.zip"))).toBe(true);
});

Expand All @@ -361,7 +365,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 368 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > always refetches the setup script, but reuses a finished bundle

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:368:16
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
// No checksum to disagree with, so the existing bundle is trusted as-is.
expect(readFileSync(join(dir, bundleName), "utf8")).toBe("stale-bundle");
Expand All @@ -378,7 +382,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 385 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > drops a stale .partial for the script instead of resuming onto it

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:385:16
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
// Both requests went out without a Range header.
expect(rangeLog).toEqual([undefined, undefined]);
Expand Down Expand Up @@ -438,11 +442,44 @@
);
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);

Check failure on line 459 in tests/lib/download.test.ts

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > --uninstall fetches only the uninstall script and runs it with passthroughs

AssertionError: expected 1 to be +0 // Object.is equality - Expected + Received - 0 + 1 ❯ tests/lib/download.test.ts:459:16
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,
Expand All @@ -465,16 +502,32 @@
});

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",
]);
});
});
45 changes: 45 additions & 0 deletions tests/lib/office.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
formatSize,
officeBundleName,
officeScriptName,
officeUninstallScriptName,
parseOfficeArgs,
} from "@/lib/office.js";

Expand All @@ -27,6 +28,10 @@ describe("parseOfficeArgs", () => {
minimal: false,
skipVerify: false,
forceSkills: false,
uninstall: false,
yes: false,
skillsOnly: false,
purgeDownloads: false,
});
});

Expand All @@ -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");
});
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading