mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-28 19:01:32 +08:00
Prepare Claude App VM storage before launch
This commit is contained in:
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app";
|
||||
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
||||
import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "@ccr/core/agents/claude-app/cdp";
|
||||
import { prepareClaudeAppVmStorage } from "@ccr/core/agents/claude-app/vm-storage";
|
||||
import { claudeCodeModelEnv as claudeCodeProfileModelEnv, claudeCodeUtcTimezoneEnvOverride, isClaudeCodeManagedModelEnvKey } from "@ccr/core/agents/claude-code/environment";
|
||||
import { resolveClaudeCodeSettingsFile } from "@ccr/core/profiles/launch-core";
|
||||
import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery";
|
||||
@@ -52,6 +53,10 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
|
||||
const settingsDir = path.dirname(settingsFile);
|
||||
const userDataDir = resolveClaudeAppProfileUserDataDir(configDir, profile);
|
||||
mkdirSync(userDataDir, { recursive: true });
|
||||
const vmStorage = prepareClaudeAppVmStorage(configDir, userDataDir);
|
||||
if (vmStorage.action === "skipped" && vmStorage.reason === "clone-failed") {
|
||||
console.warn(`[profile] Failed to clone Claude App VM seed for ${profile.name || profile.id}. Claude App may rebuild its VM in ${vmStorage.targetBundleDir}.`);
|
||||
}
|
||||
prepareClaudeAppCdpUserDataDir(userDataDir);
|
||||
const shouldOpenDesign = shouldOpenClaudeAppDesign(config);
|
||||
const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign);
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, chmodSync, renameSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveRuntimeAppPath } from "@ccr/core/runtime/app-paths";
|
||||
|
||||
const claudeAppVmBundlesDir = "vm_bundles";
|
||||
const claudeAppVmBundleName = "claudevm.bundle";
|
||||
const claudeAppVmSeedEnv = "CCR_CLAUDE_APP_VM_SEED_DIR";
|
||||
const claudeAppVmSeedDisabledEnv = "CCR_CLAUDE_APP_VM_SEED_DISABLED";
|
||||
const maxSmallFileCopyBytes = 64 * 1024 * 1024;
|
||||
const lockRetryIntervalMs = 100;
|
||||
const lockTimeoutMs = 15_000;
|
||||
const staleLockMs = 10 * 60_000;
|
||||
|
||||
export type ClaudeAppVmStoragePrepareResult =
|
||||
| {
|
||||
action: "prepared";
|
||||
seedBundleDir: string;
|
||||
sourceBundleDir: string;
|
||||
targetBundleDir: string;
|
||||
}
|
||||
| {
|
||||
action: "skipped";
|
||||
reason: string;
|
||||
targetBundleDir: string;
|
||||
};
|
||||
|
||||
export function prepareClaudeAppVmStorage(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult {
|
||||
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
|
||||
if (isDisabledEnv(process.env[claudeAppVmSeedDisabledEnv])) {
|
||||
return { action: "skipped", reason: "disabled", targetBundleDir };
|
||||
}
|
||||
return withVmStorageLock(configDir, targetBundleDir, () => prepareClaudeAppVmStorageLocked(configDir, userDataDir));
|
||||
}
|
||||
|
||||
function prepareClaudeAppVmStorageLocked(configDir: string, userDataDir: string): ClaudeAppVmStoragePrepareResult {
|
||||
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
|
||||
if (isUsableClaudeAppVmBundle(targetBundleDir)) {
|
||||
return { action: "skipped", reason: "target-present", targetBundleDir };
|
||||
}
|
||||
|
||||
const sourceBundleDir = findClaudeAppVmSeedBundle(configDir, userDataDir);
|
||||
if (!sourceBundleDir) {
|
||||
return { action: "skipped", reason: "no-seed", targetBundleDir };
|
||||
}
|
||||
|
||||
const seedBundleDir = ensureSharedClaudeAppVmSeed(configDir, sourceBundleDir, targetBundleDir);
|
||||
if (!seedBundleDir || samePath(seedBundleDir, targetBundleDir)) {
|
||||
return { action: "skipped", reason: "seed-unavailable", targetBundleDir };
|
||||
}
|
||||
|
||||
if (!prepareTargetBundlePath(targetBundleDir)) {
|
||||
return { action: "skipped", reason: "target-not-replaceable", targetBundleDir };
|
||||
}
|
||||
|
||||
try {
|
||||
cloneDirectory(seedBundleDir, targetBundleDir);
|
||||
return {
|
||||
action: "prepared",
|
||||
seedBundleDir,
|
||||
sourceBundleDir,
|
||||
targetBundleDir
|
||||
};
|
||||
} catch {
|
||||
rmSync(targetBundleDir, { force: true, recursive: true });
|
||||
return { action: "skipped", reason: "clone-failed", targetBundleDir };
|
||||
}
|
||||
}
|
||||
|
||||
function withVmStorageLock(
|
||||
configDir: string,
|
||||
targetBundleDir: string,
|
||||
run: () => ClaudeAppVmStoragePrepareResult
|
||||
): ClaudeAppVmStoragePrepareResult {
|
||||
const lockDir = path.join(configDir, "app-cache", "claude-app", ".vm-storage.lock");
|
||||
if (!acquireDirectoryLock(lockDir)) {
|
||||
return { action: "skipped", reason: "lock-timeout", targetBundleDir };
|
||||
}
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
rmSync(lockDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function acquireDirectoryLock(lockDir: string): boolean {
|
||||
const deadline = Date.now() + lockTimeoutMs;
|
||||
mkdirSync(path.dirname(lockDir), { mode: 0o700, recursive: true });
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
mkdirSync(lockDir, { mode: 0o700 });
|
||||
return true;
|
||||
} catch {
|
||||
removeStaleLock(lockDir);
|
||||
sleepSync(lockRetryIntervalMs);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function removeStaleLock(lockDir: string): void {
|
||||
try {
|
||||
const stat = statSync(lockDir);
|
||||
if (Date.now() - stat.mtimeMs > staleLockMs) {
|
||||
rmSync(lockDir, { force: true, recursive: true });
|
||||
}
|
||||
} catch {
|
||||
// Another process may have released the lock between attempts.
|
||||
}
|
||||
}
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
export function resolveClaudeAppVmBundleDir(userDataDir: string): string {
|
||||
return path.join(userDataDir, claudeAppVmBundlesDir, claudeAppVmBundleName);
|
||||
}
|
||||
|
||||
export function resolveSharedClaudeAppVmSeedBundleDir(configDir: string): string {
|
||||
return path.join(configDir, "app-cache", "claude-app", "vm-seeds", claudeAppVmBundleName);
|
||||
}
|
||||
|
||||
export function resolveClaudeAppDefaultUserDataDirs(): string[] {
|
||||
if (process.platform === "darwin") {
|
||||
const applicationSupport = path.join(resolveRuntimeAppPath("home"), "Library", "Application Support");
|
||||
return [
|
||||
path.join(applicationSupport, "Claude"),
|
||||
path.join(applicationSupport, "Claude Desktop"),
|
||||
path.join(applicationSupport, "Claude-3p")
|
||||
];
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const roaming = resolveRuntimeAppPath("appData");
|
||||
const local = process.env.LOCALAPPDATA || path.join(roaming, "..", "Local");
|
||||
return [
|
||||
path.join(roaming, "Claude"),
|
||||
path.join(roaming, "Claude Desktop"),
|
||||
path.join(local, "Claude"),
|
||||
path.join(local, "Claude Desktop"),
|
||||
path.join(local, "Claude-3p")
|
||||
];
|
||||
}
|
||||
const configHome = resolveRuntimeAppPath("appData");
|
||||
return [
|
||||
path.join(configHome, "Claude"),
|
||||
path.join(configHome, "claude"),
|
||||
path.join(configHome, "Claude Desktop"),
|
||||
path.join(configHome, "claude-desktop"),
|
||||
path.join(configHome, "Claude-3p")
|
||||
];
|
||||
}
|
||||
|
||||
function findClaudeAppVmSeedBundle(configDir: string, userDataDir: string): string | undefined {
|
||||
const targetBundleDir = resolveClaudeAppVmBundleDir(userDataDir);
|
||||
return uniqueStrings([
|
||||
...configuredSeedBundleCandidates(),
|
||||
resolveSharedClaudeAppVmSeedBundleDir(configDir),
|
||||
...resolveClaudeAppDefaultUserDataDirs().map(resolveClaudeAppVmBundleDir),
|
||||
...existingCcrProfileVmBundleCandidates(configDir)
|
||||
]).find((candidate) =>
|
||||
!samePath(candidate, targetBundleDir) &&
|
||||
isUsableClaudeAppVmBundle(candidate)
|
||||
);
|
||||
}
|
||||
|
||||
function configuredSeedBundleCandidates(): string[] {
|
||||
const configured = process.env[claudeAppVmSeedEnv]?.trim();
|
||||
if (!configured) {
|
||||
return [];
|
||||
}
|
||||
return configured
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((entry) => {
|
||||
const resolved = resolveUserPath(entry);
|
||||
return path.basename(resolved) === claudeAppVmBundleName
|
||||
? [resolved]
|
||||
: [resolved, resolveClaudeAppVmBundleDir(resolved)];
|
||||
});
|
||||
}
|
||||
|
||||
function existingCcrProfileVmBundleCandidates(configDir: string): string[] {
|
||||
const profilesDir = path.join(configDir, "profiles");
|
||||
if (!isDirectory(profilesDir)) {
|
||||
return [];
|
||||
}
|
||||
const results: string[] = [];
|
||||
const pending: Array<{ depth: number; dir: string }> = [{ depth: 0, dir: profilesDir }];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.shift();
|
||||
if (!current || current.depth > 8) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of readDirEntries(current.dir)) {
|
||||
const file = path.join(current.dir, entry);
|
||||
if (entry === claudeAppVmBundleName && isDirectory(file) && file.includes(`${path.sep}${claudeAppVmBundlesDir}${path.sep}`)) {
|
||||
results.push(file);
|
||||
continue;
|
||||
}
|
||||
if (isDirectory(file)) {
|
||||
pending.push({ depth: current.depth + 1, dir: file });
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function ensureSharedClaudeAppVmSeed(
|
||||
configDir: string,
|
||||
sourceBundleDir: string,
|
||||
targetBundleDir: string
|
||||
): string | undefined {
|
||||
const seedBundleDir = resolveSharedClaudeAppVmSeedBundleDir(configDir);
|
||||
if (isUsableClaudeAppVmBundle(seedBundleDir)) {
|
||||
return seedBundleDir;
|
||||
}
|
||||
if (samePath(sourceBundleDir, seedBundleDir)) {
|
||||
return isUsableClaudeAppVmBundle(sourceBundleDir) ? sourceBundleDir : undefined;
|
||||
}
|
||||
|
||||
if (!prepareTargetBundlePath(seedBundleDir)) {
|
||||
return sourceBundleDir;
|
||||
}
|
||||
|
||||
try {
|
||||
cloneDirectory(sourceBundleDir, seedBundleDir);
|
||||
return seedBundleDir;
|
||||
} catch {
|
||||
rmSync(seedBundleDir, { force: true, recursive: true });
|
||||
return samePath(sourceBundleDir, targetBundleDir) ? undefined : sourceBundleDir;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareTargetBundlePath(bundleDir: string): boolean {
|
||||
if (!pathEntryExists(bundleDir)) {
|
||||
mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true });
|
||||
return true;
|
||||
}
|
||||
if (!isReplaceableIncompleteBundle(bundleDir)) {
|
||||
return false;
|
||||
}
|
||||
rmSync(bundleDir, { force: true, recursive: true });
|
||||
mkdirSync(path.dirname(bundleDir), { mode: 0o700, recursive: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function isUsableClaudeAppVmBundle(bundleDir: string): boolean {
|
||||
return isDirectory(bundleDir) && (
|
||||
existsSync(path.join(bundleDir, "rootfs.img")) ||
|
||||
existsSync(path.join(bundleDir, "rootfs.img.zst"))
|
||||
);
|
||||
}
|
||||
|
||||
function isReplaceableIncompleteBundle(bundleDir: string): boolean {
|
||||
if (!isDirectory(bundleDir)) {
|
||||
return isSymlink(bundleDir);
|
||||
}
|
||||
const entries = readDirEntries(bundleDir);
|
||||
if (entries.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return entries.every((entry) =>
|
||||
entry === ".cowork-adopted" ||
|
||||
entry === ".DS_Store" ||
|
||||
entry.startsWith(".wvm-tmp-")
|
||||
);
|
||||
}
|
||||
|
||||
function cloneDirectory(sourceDir: string, targetDir: string): void {
|
||||
const parentDir = path.dirname(targetDir);
|
||||
const tempDir = path.join(parentDir, `.${path.basename(targetDir)}.ccr-clone-${process.pid}-${Date.now()}`);
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
try {
|
||||
copyDirectoryContents(sourceDir, tempDir);
|
||||
rmSync(targetDir, { force: true, recursive: true });
|
||||
renameSync(tempDir, targetDir);
|
||||
} catch (error) {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectoryContents(sourceDir: string, targetDir: string): void {
|
||||
const sourceStat = statSync(sourceDir);
|
||||
mkdirSync(targetDir, { mode: sourceStat.mode & 0o777, recursive: true });
|
||||
chmodBestEffort(targetDir, sourceStat.mode & 0o777);
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const source = path.join(sourceDir, entry.name);
|
||||
const target = path.join(targetDir, entry.name);
|
||||
const sourceEntryStat = lstatSync(source);
|
||||
if (sourceEntryStat.isSymbolicLink()) {
|
||||
symlinkSync(readlinkSync(source), target);
|
||||
continue;
|
||||
}
|
||||
if (sourceEntryStat.isDirectory()) {
|
||||
copyDirectoryContents(source, target);
|
||||
continue;
|
||||
}
|
||||
if (sourceEntryStat.isFile()) {
|
||||
copyFileWithClone(source, target, sourceEntryStat.size);
|
||||
chmodBestEffort(target, sourceEntryStat.mode & 0o777);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyFileWithClone(source: string, target: string, size: number): void {
|
||||
if (process.platform === "darwin" && cloneFileWithMacCp(source, target)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
copyFileSync(source, target, constants.COPYFILE_FICLONE_FORCE);
|
||||
return;
|
||||
} catch {
|
||||
if (size > maxSmallFileCopyBytes) {
|
||||
throw new Error(`Copy-on-write clone is not available for ${source}.`);
|
||||
}
|
||||
}
|
||||
copyFileSync(source, target);
|
||||
}
|
||||
|
||||
function cloneFileWithMacCp(source: string, target: string): boolean {
|
||||
const result = spawnSync("/bin/cp", ["-c", source, target], {
|
||||
stdio: "ignore"
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function pathEntryExists(file: string): boolean {
|
||||
try {
|
||||
lstatSync(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(file: string): boolean {
|
||||
try {
|
||||
return statSync(file).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSymlink(file: string): boolean {
|
||||
try {
|
||||
return lstatSync(file).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readDirEntries(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
return normalizeComparablePath(left) === normalizeComparablePath(right);
|
||||
}
|
||||
|
||||
function normalizeComparablePath(value: string): string {
|
||||
const normalized = path.resolve(value);
|
||||
return process.platform === "win32" ? normalized.replace(/\\/g, "/").toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = normalizeComparablePath(value);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveUserPath(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function chmodBestEffort(file: string, mode: number): void {
|
||||
try {
|
||||
chmodSync(file, mode);
|
||||
} catch {
|
||||
// File permissions are best-effort across platforms.
|
||||
}
|
||||
}
|
||||
|
||||
function isDisabledEnv(value: string | undefined): boolean {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
prepareClaudeAppVmStorage,
|
||||
resolveClaudeAppDefaultUserDataDirs,
|
||||
resolveClaudeAppVmBundleDir,
|
||||
resolveSharedClaudeAppVmSeedBundleDir
|
||||
} from "@ccr/core/agents/claude-app/vm-storage.ts";
|
||||
|
||||
test("Claude App VM storage prepares profile bundle from the default Claude App data dir", () => {
|
||||
withRuntimeEnv((root) => {
|
||||
const configDir = path.join(root, "ccr");
|
||||
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
|
||||
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
|
||||
writeVmBundle(sourceBundle, "default-vm");
|
||||
|
||||
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
|
||||
|
||||
assert.equal(result.action, "prepared");
|
||||
assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "default-vm");
|
||||
assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "default-vm");
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude App VM storage can seed new profiles from an existing CCR profile", () => {
|
||||
withRuntimeEnv((root) => {
|
||||
const configDir = path.join(root, "ccr");
|
||||
const sourceUserDataDir = path.join(configDir, "profiles", "source", "claude", ".claude-code-router", "claude-app-user-data", "source");
|
||||
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
|
||||
const sourceBundle = resolveClaudeAppVmBundleDir(sourceUserDataDir);
|
||||
writeVmBundle(sourceBundle, "profile-vm");
|
||||
|
||||
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
|
||||
|
||||
assert.equal(result.action, "prepared");
|
||||
assert.equal(result.sourceBundleDir, sourceBundle);
|
||||
assert.equal(readRootfs(resolveSharedClaudeAppVmSeedBundleDir(configDir)), "profile-vm");
|
||||
assert.equal(readRootfs(resolveClaudeAppVmBundleDir(targetUserDataDir)), "profile-vm");
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude App VM storage leaves an existing profile VM untouched", () => {
|
||||
withRuntimeEnv((root) => {
|
||||
const configDir = path.join(root, "ccr");
|
||||
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
|
||||
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
|
||||
const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir);
|
||||
writeVmBundle(sourceBundle, "source-vm");
|
||||
writeVmBundle(targetBundle, "target-vm");
|
||||
|
||||
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
action: "skipped",
|
||||
reason: "target-present",
|
||||
targetBundleDir: targetBundle
|
||||
});
|
||||
assert.equal(readRootfs(targetBundle), "target-vm");
|
||||
});
|
||||
});
|
||||
|
||||
test("Claude App VM storage replaces incomplete temporary VM bundles", () => {
|
||||
withRuntimeEnv((root) => {
|
||||
const configDir = path.join(root, "ccr");
|
||||
const sourceBundle = resolveClaudeAppVmBundleDir(resolveClaudeAppDefaultUserDataDirs()[0]);
|
||||
const targetUserDataDir = path.join(configDir, "profiles", "target", "claude", ".claude-code-router", "claude-app-user-data", "target");
|
||||
const targetBundle = resolveClaudeAppVmBundleDir(targetUserDataDir);
|
||||
writeVmBundle(sourceBundle, "source-vm");
|
||||
mkdirSync(path.join(targetBundle, ".wvm-tmp-123"), { recursive: true });
|
||||
writeFileSync(path.join(targetBundle, ".wvm-tmp-123", "rootfs.img"), "partial-vm");
|
||||
writeFileSync(path.join(targetBundle, ".cowork-adopted"), "marker");
|
||||
|
||||
const result = prepareClaudeAppVmStorage(configDir, targetUserDataDir);
|
||||
|
||||
assert.equal(result.action, "prepared");
|
||||
assert.equal(readRootfs(targetBundle), "source-vm");
|
||||
assert.equal(existsSync(path.join(targetBundle, ".wvm-tmp-123")), false);
|
||||
});
|
||||
});
|
||||
|
||||
function writeVmBundle(bundleDir, content) {
|
||||
mkdirSync(bundleDir, { recursive: true });
|
||||
writeFileSync(path.join(bundleDir, "rootfs.img"), content);
|
||||
writeFileSync(path.join(bundleDir, "machineIdentifier"), "machine");
|
||||
}
|
||||
|
||||
function readRootfs(bundleDir) {
|
||||
return readFileSync(path.join(bundleDir, "rootfs.img"), "utf8");
|
||||
}
|
||||
|
||||
function withRuntimeEnv(run) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-app-vm-storage-"));
|
||||
const previous = {
|
||||
appData: process.env.CCR_INTERNAL_APP_DATA_DIR,
|
||||
home: process.env.CCR_INTERNAL_HOME_DIR,
|
||||
seed: process.env.CCR_CLAUDE_APP_VM_SEED_DIR,
|
||||
seedDisabled: process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED
|
||||
};
|
||||
try {
|
||||
process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(root, "app-data");
|
||||
process.env.CCR_INTERNAL_HOME_DIR = path.join(root, "home");
|
||||
delete process.env.CCR_CLAUDE_APP_VM_SEED_DIR;
|
||||
delete process.env.CCR_CLAUDE_APP_VM_SEED_DISABLED;
|
||||
run(root);
|
||||
} finally {
|
||||
setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previous.appData);
|
||||
setOptionalEnv("CCR_INTERNAL_HOME_DIR", previous.home);
|
||||
setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DIR", previous.seed);
|
||||
setOptionalEnv("CCR_CLAUDE_APP_VM_SEED_DISABLED", previous.seedDisabled);
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function setOptionalEnv(name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = value;
|
||||
}
|
||||
Reference in New Issue
Block a user