diff --git a/package.json b/package.json index 897f530..5772110 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/office.ts b/src/lib/office.ts index a099a3d..dfd54f1 100644 --- a/src/lib/office.ts +++ b/src/lib/office.ts @@ -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 @@ -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 @@ -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"', @@ -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 @@ -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( diff --git a/src/lib/paths.ts b/src/lib/paths.ts index f321134..f82d501 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -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"); } diff --git a/tests/lib/download.test.ts b/tests/lib/download.test.ts index 492d444..0120b2d 100644 --- a/tests/lib/download.test.ts +++ b/tests/lib/download.test.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -14,7 +15,10 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { downloadFile } from "@/lib/download.js"; import { + ensureStagingDir, installerArgs, + migrateLegacyOfficeDir, + officeWrapperBakedArgs, officeWrapperContent, officeWrapperName, runSkillOffice, @@ -433,6 +437,73 @@ describe("runSkillOffice", () => { 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"); + }); + + 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); + 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", () => {