@@ -3,11 +3,11 @@ import fs from "node:fs/promises";
33import os from "node:os" ;
44import path from "node:path" ;
55import { redactMigrationPlan } from "openclaw/plugin-sdk/migration" ;
6- import { afterEach , describe , expect , it } from "vitest" ;
6+ import { afterEach , describe , expect , it , vi } from "vitest" ;
77import { resolveHomePath } from "./helpers.js" ;
88import { buildMemoryItems } from "./memory.js" ;
99import { buildClaudeMigrationProvider } from "./provider.js" ;
10- import { CLAUDE_AUTO_MEMORY_MAX_FILES , type ClaudeSource } from "./source.js" ;
10+ import { CLAUDE_AUTO_MEMORY_MAX_FILES , type ClaudeSource , discoverClaudeSource } from "./source.js" ;
1111import {
1212 cleanupTempRoots ,
1313 makeConfigRuntime ,
@@ -29,6 +29,7 @@ function planItemById(
2929
3030describe ( "Claude migration provider" , ( ) => {
3131 afterEach ( async ( ) => {
32+ vi . unstubAllEnvs ( ) ;
3233 await cleanupTempRoots ( ) ;
3334 } ) ;
3435
@@ -140,6 +141,32 @@ describe("Claude migration provider", () => {
140141 expect ( plan . items [ 0 ] ?. source ) . toBe ( path . join ( customMemory , "MEMORY.md" ) ) ;
141142 } ) ;
142143
144+ it ( "honors CLAUDE_CONFIG_DIR for a relocated Claude home" , async ( ) => {
145+ const root = await makeTempRoot ( ) ;
146+ const relocatedHome = path . join ( root , "relocated-claude" ) ;
147+ const memoryDir = path . join ( relocatedHome , "projects" , "-tmp-project" , "memory" ) ;
148+ await writeFile ( path . join ( memoryDir , "MEMORY.md" ) , "# Relocated memory\n" ) ;
149+ vi . stubEnv ( "CLAUDE_CONFIG_DIR" , relocatedHome ) ;
150+
151+ const source = await discoverClaudeSource ( ) ;
152+
153+ expect ( source . root ) . toBe ( relocatedHome ) ;
154+ expect ( source . homeDir ) . toBe ( relocatedHome ) ;
155+ expect ( source . autoMemorySources . map ( ( entry ) => entry . path ) ) . toEqual ( [ memoryDir ] ) ;
156+ } ) ;
157+
158+ it ( "treats an explicit repo root with a top-level projects/ dir as a project, not a home" , async ( ) => {
159+ const root = await makeTempRoot ( ) ;
160+ const projectRoot = path . join ( root , "my-monorepo" ) ;
161+ await writeFile ( path . join ( projectRoot , "projects" , "svc-a" , "readme.md" ) , "# svc\n" ) ;
162+ await writeFile ( path . join ( projectRoot , "settings.json" ) , "{}\n" ) ;
163+
164+ const source = await discoverClaudeSource ( projectRoot ) ;
165+
166+ expect ( source . projectDir ) . toBe ( projectRoot ) ;
167+ expect ( source . homeDir ) . toBeUndefined ( ) ;
168+ } ) ;
169+
143170 it . runIf ( process . platform !== "win32" ) (
144171 "reports an unreadable configured Claude Code auto-memory directory" ,
145172 async ( ) => {
@@ -171,6 +198,38 @@ describe("Claude migration provider", () => {
171198 } ,
172199 ) ;
173200
201+ it . runIf ( process . platform !== "win32" && process . getuid ?.( ) !== 0 ) (
202+ "reports an inaccessible configured Claude Code auto-memory directory" ,
203+ async ( ) => {
204+ const root = await makeTempRoot ( ) ;
205+ const source = path . join ( root , ".claude" ) ;
206+ const lockedParent = path . join ( root , "locked-parent" ) ;
207+ const customMemory = path . join ( lockedParent , "custom-memory" ) ;
208+ await writeFile (
209+ path . join ( source , "settings.json" ) ,
210+ JSON . stringify ( { autoMemoryDirectory : customMemory } ) ,
211+ ) ;
212+ await writeFile ( path . join ( customMemory , "MEMORY.md" ) , "# Custom memory\n" ) ;
213+ await fs . chmod ( lockedParent , 0o000 ) ;
214+ const provider = buildClaudeMigrationProvider ( ) ;
215+
216+ try {
217+ await expect (
218+ provider . plan (
219+ makeContext ( {
220+ source,
221+ stateDir : path . join ( root , "state" ) ,
222+ workspaceDir : path . join ( root , "workspace" ) ,
223+ itemKinds : [ "memory" ] ,
224+ } ) ,
225+ ) ,
226+ ) . rejects . toThrow ( customMemory ) ;
227+ } finally {
228+ await fs . chmod ( lockedParent , 0o700 ) ;
229+ }
230+ } ,
231+ ) ;
232+
174233 it . runIf ( process . platform !== "win32" ) (
175234 "reports an unreadable standard Claude Code projects directory" ,
176235 async ( ) => {
@@ -219,6 +278,27 @@ describe("Claude migration provider", () => {
219278 ) . rejects . toThrow ( "autoMemoryDirectory must be absolute or start with ~/" ) ;
220279 } ) ;
221280
281+ it ( 'rejects bare "~" as a Claude Code auto-memory directory' , async ( ) => {
282+ const root = await makeTempRoot ( ) ;
283+ const source = path . join ( root , ".claude" ) ;
284+ await writeFile (
285+ path . join ( source , "settings.json" ) ,
286+ JSON . stringify ( { autoMemoryDirectory : "~" } ) ,
287+ ) ;
288+ const provider = buildClaudeMigrationProvider ( ) ;
289+
290+ await expect (
291+ provider . plan (
292+ makeContext ( {
293+ source,
294+ stateDir : path . join ( root , "state" ) ,
295+ workspaceDir : path . join ( root , "workspace" ) ,
296+ itemKinds : [ "memory" ] ,
297+ } ) ,
298+ ) ,
299+ ) . rejects . toThrow ( "autoMemoryDirectory must be absolute or start with ~/" ) ;
300+ } ) ;
301+
222302 it ( "rejects Claude Code auto-memory that contains the import destination" , async ( ) => {
223303 const root = await makeTempRoot ( ) ;
224304 const source = path . join ( root , ".claude" ) ;
@@ -268,6 +348,41 @@ describe("Claude migration provider", () => {
268348 } ,
269349 ) ;
270350
351+ it . runIf ( process . platform !== "win32" ) (
352+ "marks a dangling Claude Code memory destination symlink as a conflict" ,
353+ async ( ) => {
354+ const root = await makeTempRoot ( ) ;
355+ const source = path . join ( root , ".claude" ) ;
356+ const workspaceDir = path . join ( root , "workspace" ) ;
357+ await writeFile (
358+ path . join ( source , "projects" , "-tmp-linked" , "memory" , "MEMORY.md" ) ,
359+ "# Source memory\n" ,
360+ ) ;
361+ const provider = buildClaudeMigrationProvider ( ) ;
362+ const context = makeContext ( {
363+ source,
364+ stateDir : path . join ( root , "state" ) ,
365+ workspaceDir,
366+ itemKinds : [ "memory" ] ,
367+ overwrite : true ,
368+ } ) ;
369+ const initial = await provider . plan ( context ) ;
370+ const target = initial . items [ 0 ] ?. target ;
371+ if ( ! target ) {
372+ throw new Error ( "expected planned Claude memory target" ) ;
373+ }
374+ await fs . mkdir ( path . dirname ( target ) , { recursive : true } ) ;
375+ await fs . symlink ( path . join ( root , "missing-memory.md" ) , target ) ;
376+
377+ const plan = await provider . plan ( context ) ;
378+
379+ expect ( plan . items [ 0 ] ) . toMatchObject ( {
380+ status : "conflict" ,
381+ reason : "target is not a regular file" ,
382+ } ) ;
383+ } ,
384+ ) ;
385+
271386 it ( "fails planning when a discovered Claude Code memory directory cannot be read" , async ( ) => {
272387 const root = await makeTempRoot ( ) ;
273388 const missingMemory = path . join ( root , "missing-memory" ) ;
0 commit comments