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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codev-ai",
"version": "0.5.10",
"version": "0.5.11",
"description": "CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents.",
"keywords": [
"ai",
Expand Down
95 changes: 89 additions & 6 deletions src/lib/office.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { spawn } from "node:child_process";
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { OFFICE_DOWNLOADS_URL } from "@/lib/const.js";
import { downloadFile } from "@/lib/download.js";
import { logInfo } from "@/lib/log.js";
import { officeDownloadsDir } from "@/lib/paths.js";
import { legacyOfficeDownloadsDir, officeDownloadsDir } from "@/lib/paths.js";

// `codevhub skill office`: fetch the CoDev Office offline bundle (published by
// the codev-storage MinIO backend) for this OS and run the bundled setup
Expand Down Expand Up @@ -207,6 +216,61 @@ export function officeWrapperName(uninstall: boolean): string {
return uninstall ? "Uninstall-CoDev-Office.cmd" : "Install-CoDev-Office.cmd";
}

// Paths baked into the wrapper so a UAC elevation with a DIFFERENT admin
// account cannot strand the install on the admin's profile: the JS module
// tree goes to the shared, account-independent %PUBLIC% dir, and the skills
// root is pinned to the REAL user's profile — codevhub runs unelevated as
// that user, so homedir() is authoritative here. Cross-platform staging
// (--platform windows from another OS) cannot know the target machine's
// user, so only the modules dir is baked there. Exported for tests.
export function officeWrapperBakedArgs(hostIsWindows: boolean): string[] {
const publicDir = process.env.PUBLIC ?? "C:\\Users\\Public";
const args = ["-ModulesDir", `${publicDir}\\codev-office\\node_modules`];
if (hostIsWindows) {
args.push("-SkillsRoot", `${homedir()}\\.config\\codev\\skills`);
}
return args;
}

// One-time migration: move files staged under the old per-user dot-folder
// into the new Public dir, so multi-GB bundles are not re-downloaded just
// because the staging folder moved. Same-volume renames; anything locked or
// cross-volume is left behind (the downloader treats it as absent and
// re-fetches). Exported for tests.
// Create the staging dir, falling back when the preferred location is not
// writable (e.g. hardened ACLs on C:\Users\Public). Returns the dir that
// actually exists. Exported for tests.
export function ensureStagingDir(
preferred: string,
fallback: string | null,
): string {
try {
mkdirSync(preferred, { recursive: true });
return preferred;
} catch (err) {
if (!fallback || fallback === preferred) throw err;
console.error(
`Could not create ${preferred} (${err instanceof Error ? err.message : String(err)}) - using ${fallback} instead`,
);
mkdirSync(fallback, { recursive: true });
return fallback;
}
}

export function migrateLegacyOfficeDir(fromDir: string, toDir: string): void {
if (fromDir === toDir || !existsSync(fromDir)) return;
mkdirSync(toDir, { recursive: true });
for (const name of readdirSync(fromDir)) {
const to = join(toDir, name);
if (existsSync(to)) continue;
try {
renameSync(join(fromDir, name), to);
} catch {
// Locked or cross-volume — leave it; the download layer copes.
}
}
}

export function officeWrapperContent(script: string, args: string[]): string {
// Self-elevating so a plain DOUBLE-CLICK is enough (some environments strip
// "Run as administrator" from the context menu): when not elevated, the
Expand All @@ -218,7 +282,7 @@ export function officeWrapperContent(script: string, args: string[]): string {
// %~dp0 = the .cmd's own folder (an elevated relaunch starts in System32);
// `pause` keeps the window open so the closing "Verification passed" (or a
// [FAIL] line) stays readable.
const argStr = args.map((a) => ` ${a}`).join("");
const argStr = args.map((a) => ` ${/\s/.test(a) ? `"${a}"` : a}`).join("");
return [
"@echo off",
'cd /d "%~dp0"',
Expand Down Expand Up @@ -320,8 +384,21 @@ export async function runSkillOffice(
downloadOnly = true;
}

const dir = parsed.dir ?? officeDownloadsDir();
mkdirSync(dir, { recursive: true });
// Windows staging prefers the profile-independent %PUBLIC% folder, but a
// hardened image can deny non-admin writes under C:\Users\Public — fall
// back to the old per-user folder rather than crashing.
const dir = ensureStagingDir(
parsed.dir ?? officeDownloadsDir(),
parsed.dir === undefined && hostPlatform === "windows"
? legacyOfficeDownloadsDir()
: null,
);
// Windows moved its default staging from ~/.codev-hub/office to the
// profile-independent %PUBLIC%\Downloads\codev-office — pull already
// downloaded files across so nothing multi-GB is fetched twice.
if (hostPlatform === "windows" && parsed.dir === undefined) {
migrateLegacyOfficeDir(legacyOfficeDownloadsDir(), dir);
}

const bundle = officeBundleName(platform);
const script = parsed.uninstall
Expand Down Expand Up @@ -387,7 +464,13 @@ export async function runSkillOffice(
// Windows: stage a right-click wrapper and stop — see officeWrapperName.
if (platform === "windows") {
const wrapper = officeWrapperName(parsed.uninstall);
writeFileSync(join(dir, wrapper), officeWrapperContent(script, scriptArgs));
writeFileSync(
join(dir, wrapper),
officeWrapperContent(script, [
...scriptArgs,
...officeWrapperBakedArgs(hostPlatform === "windows"),
]),
);
const verb = parsed.uninstall ? "uninstaller" : "installer";
console.error(`\nFiles are in ${dir}.`);
console.error(
Expand Down
23 changes: 22 additions & 1 deletion src/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,30 @@ export function cliLogsDir(): string {
}

// Where `codevhub skill office` stages the offline bundle + setup script
// (up to ~1.1GB). Kept between runs so a re-run resumes an interrupted
// (up to ~3GB). Kept between runs so a re-run resumes an interrupted
// download or verifies the existing files instead of re-downloading.
//
// Windows uses a PROFILE-INDEPENDENT folder (%PUBLIC%\Downloads\codev-office):
// %USERPROFILE% diverges between a normal and an elevated shell when the UAC
// prompt is approved with a different admin account, and users kept looking
// for the files under the wrong profile. C:\Users\Public is the same path for
// every account, world-writable, not OneDrive-synced, and not targeted by
// Storage Sense cleanup.
export function officeDownloadsDir(): string {
if (process.platform === "win32") {
return join(
process.env.PUBLIC ?? "C:\\Users\\Public",
"Downloads",
"codev-office",
);
}
return join(homedir(), ".codev-hub", "office");
}

// The pre-Public staging dir on Windows — kept only so runSkillOffice can
// migrate already-downloaded files (a bundle is GBs; never re-download it
// just because the folder moved).
export function legacyOfficeDownloadsDir(): string {
return join(homedir(), ".codev-hub", "office");
}

Expand Down
71 changes: 71 additions & 0 deletions tests/lib/download.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
Expand All @@ -14,7 +15,10 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { downloadFile } from "@/lib/download.js";
import {
ensureStagingDir,
installerArgs,
migrateLegacyOfficeDir,
officeWrapperBakedArgs,
officeWrapperContent,
officeWrapperName,
runSkillOffice,
Expand Down Expand Up @@ -346,7 +350,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 353 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:353:16
expect(spawns).toEqual([]);
expect(readFileSync(join(dir, bundleName)).equals(BUNDLE)).toBe(true);
expect(readFileSync(join(dir, scriptName)).equals(SCRIPT)).toBe(true);
Expand All @@ -363,7 +367,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 370 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:370:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, scriptName), "--skip-verify"],
Expand All @@ -374,7 +378,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 381 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:381:16
});

test("a cross-platform --platform forces download-only", async () => {
Expand Down Expand Up @@ -433,6 +437,73 @@
expect(wrapper).toContain("Start-Process -FilePath '%~f0' -Verb RunAs");
expect(wrapper).toContain(":run");
expect(wrapper).toContain("Continuing without administrator rights");
// Profile-safe path baking: the shared modules dir always, but no
// -SkillsRoot on cross-platform staging - this host's homedir says
// nothing about the target machine's user.
expect(wrapper).toContain(
"-ModulesDir C:\\Users\\Public\\codev-office\\node_modules",
);
expect(wrapper).not.toContain("-SkillsRoot");

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

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > windows staging writes the right-click wrapper, flags baked in, never spawns

AssertionError: expected '@echo off\r\ncd /d "%~dp0"\r\nnet ses…' not to contain '-SkillsRoot' - Expected + Received - -SkillsRoot + @echo off + cd /d "%~dp0" + net session >nul 2>&1 + if not errorlevel 1 goto :run + echo Requesting administrator rights - choose Yes in the prompt... + powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs" >nul 2>&1 + if not errorlevel 1 exit /b 0 + echo Continuing without administrator rights - each installer will ask for permission separately. + echo. + :run + powershell -ExecutionPolicy Bypass -File ".\codev-office-windows-setup.ps1" -SkipVerify -ForceSkills -ModulesDir C:\Users\Public\codev-office\node_modules -SkillsRoot C:\Users\runneradmin\.config\codev\skills + echo. + pause + ❯ tests/lib/download.test.ts:446:23
});

test("baked args pin the real user's skills root on a Windows host", () => {
expect(officeWrapperBakedArgs(false)).toEqual([
"-ModulesDir",
"C:\\Users\\Public\\codev-office\\node_modules",
]);
const onWindows = officeWrapperBakedArgs(true);
expect(onWindows.slice(0, 2)).toEqual([
"-ModulesDir",
"C:\\Users\\Public\\codev-office\\node_modules",
]);
expect(onWindows[2]).toBe("-SkillsRoot");
expect(onWindows[3]).toContain(".config");
});

test("wrapper quotes arguments containing spaces", () => {
const content = officeWrapperContent("s.ps1", [
"-SkillsRoot",
"C:\\Users\\Van Phong\\.config\\codev\\skills",
]);
expect(content).toContain(
'-SkillsRoot "C:\\Users\\Van Phong\\.config\\codev\\skills"',
);
});

test("ensureStagingDir falls back when the preferred dir is unwritable", () => {
const locked = join(tempDir, "locked");
mkdirSync(locked, { recursive: true });
chmodSync(locked, 0o555);
const preferred = join(locked, "codev-office");
const fallback = join(tempDir, "fallback-office");
try {
const dir = ensureStagingDir(preferred, fallback);
expect(dir).toBe(fallback);

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

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/lib/download.test.ts > runSkillOffice > ensureStagingDir falls back when the preferred dir is unwritable

AssertionError: expected 'C:\Users\RUNNER~1\AppData\Local\Temp\…' to be 'C:\Users\RUNNER~1\AppData\Local\Temp\…' // Object.is equality Expected: "C:\Users\RUNNER~1\AppData\Local\Temp\codev-download-M7Lduv\fallback-office" Received: "C:\Users\RUNNER~1\AppData\Local\Temp\codev-download-M7Lduv\locked\codev-office" ❯ tests/lib/download.test.ts:481:16
expect(existsSync(fallback)).toBe(true);
} finally {
chmodSync(locked, 0o755);
}
// Writable preferred dir wins; no fallback means errors propagate.
const fine = join(tempDir, "fine");
expect(ensureStagingDir(fine, fallback)).toBe(fine);
expect(() => ensureStagingDir(preferred, null)).not.toThrow(); // now writable again
});

test("migrateLegacyOfficeDir moves files without clobbering", () => {
const from = join(tempDir, "legacy-office");
const to = join(tempDir, "public-office");
mkdirSync(from, { recursive: true });
mkdirSync(to, { recursive: true });
writeFileSync(join(from, "bundle.zip"), "old-bundle");
writeFileSync(join(from, "kept.txt"), "from-legacy");
writeFileSync(join(to, "kept.txt"), "already-new");
migrateLegacyOfficeDir(from, to);
// Moved when absent at the destination, left alone when present.
expect(readFileSync(join(to, "bundle.zip"), "utf8")).toBe("old-bundle");
expect(readFileSync(join(to, "kept.txt"), "utf8")).toBe("already-new");
expect(existsSync(join(from, "bundle.zip"))).toBe(false);
// A missing source dir is a no-op, not an error.
migrateLegacyOfficeDir(join(tempDir, "nope"), to);
});

test("wrapper name and content cover the uninstall flow", () => {
Expand All @@ -458,7 +529,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 532 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:532: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 @@ -475,7 +546,7 @@
["--download-only", "--dir", dir],
baseUrl,
);
expect(code).toBe(0);

Check failure on line 549 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:549: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 @@ -546,7 +617,7 @@
return 0;
},
);
expect(code).toBe(0);

Check failure on line 620 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:620:16
expect(spawned).toEqual({
command: "bash",
args: [join(dir, uninstallName), "--yes", "--skills-only"],
Expand Down
Loading