From 4128e201e4962f876614be81008480600791fbad Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Fri, 25 Sep 2026 13:07:25 +0200 Subject: [PATCH 1/2] fix: keep existing home links in prepare steps Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5 --- src/cli/services/path.service.spec.ts | 44 +++++++++++++++++++++++++++ src/cli/services/path.service.ts | 13 ++++++++ src/cli/tools/dart/utils.ts | 22 ++++++-------- src/cli/tools/docker/index.spec.ts | 3 ++ src/cli/tools/docker/index.ts | 7 +++-- src/cli/tools/dotnet/index.spec.ts | 3 ++ src/cli/tools/dotnet/index.ts | 5 ++- src/cli/tools/dotnet/mono.ts | 12 +++++--- src/cli/tools/flutter.spec.ts | 3 ++ src/cli/tools/flutter.ts | 9 +++--- src/cli/tools/java/index.ts | 11 ++++--- src/cli/tools/node/utils.spec.ts | 3 ++ src/cli/tools/node/utils.ts | 9 ++++-- src/cli/tools/python/conan.spec.ts | 3 ++ src/cli/tools/python/conan.ts | 5 ++- 15 files changed, 113 insertions(+), 39 deletions(-) diff --git a/src/cli/services/path.service.spec.ts b/src/cli/services/path.service.spec.ts index dd136c63ba..93c53f2ec0 100644 --- a/src/cli/services/path.service.spec.ts +++ b/src/cli/services/path.service.spec.ts @@ -281,6 +281,50 @@ describe('cli/services/path.service', () => { ); }); + test('createSymlink', async () => { + const target = rootPath('env123/target'); + const link = rootPath('env123/link'); + await pathSvc.createDir(rootPath('env123')); + await writeFile(target, ''); + + expect(await pathSvc.createSymlink(target, link)).toBeUndefined(); + + expect(await fs.readlink(link)).toBe(target); + }); + + test('createSymlink: keeps an existing link', async () => { + const target = rootPath('env123/target'); + const link = rootPath('env123/link'); + await pathSvc.createDir(rootPath('env123')); + await writeFile(target, ''); + await pathSvc.createSymlink(target, link); + + await expect(pathSvc.createSymlink(target, link)).resolves.toBeUndefined(); + + expect(await fs.readlink(link)).toBe(target); + }); + + test('createSymlink: keeps an existing folder', async () => { + const target = rootPath('env123/target'); + const dir = rootPath('env123/dir'); + await pathSvc.createDir(dir); + + await pathSvc.createSymlink(target, dir); + + expect((await stat(dir)).isDirectory()).toBe(true); + }); + + test('createSymlink: keeps a dangling link', async () => { + const target = rootPath('env123/missing'); + const link = rootPath('env123/link'); + await pathSvc.createDir(rootPath('env123')); + await fs.symlink(target, link); + + await pathSvc.createSymlink(rootPath('env123/other'), link); + + expect(await fs.readlink(link)).toBe(target); + }); + test('toolInit', async () => { expect(pathSvc.toolInitPath('node')).toBe( rootPath('tmp/containerbase/tool.init.d/node'), diff --git a/src/cli/services/path.service.ts b/src/cli/services/path.service.ts index fdbb9c0e56..215fb32ddc 100644 --- a/src/cli/services/path.service.ts +++ b/src/cli/services/path.service.ts @@ -132,6 +132,19 @@ export class PathService { await this.setOwner({ path, mode }); } + /** + * Creates a symlink at `path` pointing to `target`. An existing file, + * folder or link at `path` is left as is. + */ + async createSymlink(target: string, path: string): Promise { + const stats = await fs.lstat(path).catch(() => null); + if (stats) { + logger.debug({ path }, 'path exists, skipping symlink'); + return; + } + await fs.symlink(target, path); + } + /** Creates the tool path and returns it. */ async createToolPath(tool: string): Promise { const toolPath = this.toolPath(tool); diff --git a/src/cli/tools/dart/utils.ts b/src/cli/tools/dart/utils.ts index 29234822ea..49131905ed 100644 --- a/src/cli/tools/dart/utils.ts +++ b/src/cli/tools/dart/utils.ts @@ -4,21 +4,19 @@ import type { EnvService, PathService } from '../../services'; import { pathExists } from '../../utils/index.ts'; /** - * Links the user's `.dart` and `.dart-tool` folders to the cache, and turns - * off the dart analytics for root. + * Links the user's `.dart` and `.dart-tool` folders to the cache, keeping any + * existing link, and turns off the dart analytics for root. */ export async function prepareDartHome( envSvc: EnvService, pathSvc: PathService, ): Promise { const dart = join(envSvc.userHome, '.dart'); - if (!(await pathExists(dart))) { - await fs.symlink(join(pathSvc.cachePath, '.dart'), dart); - await fs.symlink( - join(pathSvc.cachePath, '.dart-tool'), - join(envSvc.userHome, '.dart-tool'), - ); - } + await pathSvc.createSymlink(join(pathSvc.cachePath, '.dart'), dart); + await pathSvc.createSymlink( + join(pathSvc.cachePath, '.dart-tool'), + join(envSvc.userHome, '.dart-tool'), + ); // for root const rootDart = join(envSvc.rootDir, 'root', '.dart'); @@ -52,15 +50,13 @@ export async function initDartHome(pathSvc: PathService): Promise { } } -/** Links the user's `.pub-cache` folder to the cache. */ +/** Links the user's `.pub-cache` folder to the cache, keeping any existing link. */ export async function preparePubCache( envSvc: EnvService, pathSvc: PathService, ): Promise { const pubCache = join(envSvc.userHome, '.pub-cache'); - if (!(await pathExists(pubCache))) { - await fs.symlink(join(pathSvc.cachePath, '.pub-cache'), pubCache); - } + await pathSvc.createSymlink(join(pathSvc.cachePath, '.pub-cache'), pubCache); } /** Creates the `.pub-cache` folder in the cache. */ diff --git a/src/cli/tools/docker/index.spec.ts b/src/cli/tools/docker/index.spec.ts index 906e4856e0..b0afa618b8 100644 --- a/src/cli/tools/docker/index.spec.ts +++ b/src/cli/tools/docker/index.spec.ts @@ -114,6 +114,9 @@ describe('cli/tools/docker/index', () => { join(envSvc.rootDir, 'usr/local/lib/docker', 'cli-plugins'), ), ).toBe(join(pathSvc.cachePath, '.docker', 'cli-plugins')); + + // second run is a no-op, it would throw on existing symlinks otherwise + await expect(svc.prepare()).resolves.toBeUndefined(); }); }); }); diff --git a/src/cli/tools/docker/index.ts b/src/cli/tools/docker/index.ts index 9dce097254..d82aef99c1 100644 --- a/src/cli/tools/docker/index.ts +++ b/src/cli/tools/docker/index.ts @@ -11,18 +11,19 @@ export class DockerPrepareService extends BasePrepareService { /** * Adds the user to a `docker` group, and links the user's `.docker` folder - * and the global cli plugins folder to the cache. + * and the global cli plugins folder to the cache, keeping any existing + * link. */ override async prepare(): Promise { await this._spawn('groupadd', ['-g', '999', 'docker']); await this._spawn('usermod', ['-aG', 'docker', this.envSvc.userName]); const globalDocker = join(this.envSvc.rootDir, 'usr/local/lib/docker'); await fs.mkdir(globalDocker, { recursive: true }); - await fs.symlink( + await this.pathSvc.createSymlink( join(this.pathSvc.cachePath, '.docker'), join(this.envSvc.userHome, '.docker'), ); - await fs.symlink( + await this.pathSvc.createSymlink( join(this.pathSvc.cachePath, '.docker', 'cli-plugins'), join(globalDocker, 'cli-plugins'), ); diff --git a/src/cli/tools/dotnet/index.spec.ts b/src/cli/tools/dotnet/index.spec.ts index ba74b438c3..b8b02f9e10 100644 --- a/src/cli/tools/dotnet/index.spec.ts +++ b/src/cli/tools/dotnet/index.spec.ts @@ -216,6 +216,9 @@ describe('cli/tools/dotnet/index', () => { ).toContain( 'export DOTNET_CLI_TELEMETRY_OPTOUT=${DOTNET_CLI_TELEMETRY_OPTOUT-1}', ); + + // second run is a no-op, it would throw on the existing symlink otherwise + await expect(svc.prepare()).resolves.toBeUndefined(); }); test('initialize is idempotent', async () => { diff --git a/src/cli/tools/dotnet/index.ts b/src/cli/tools/dotnet/index.ts index 084fd61c30..737515a7da 100644 --- a/src/cli/tools/dotnet/index.ts +++ b/src/cli/tools/dotnet/index.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs/promises'; import { join } from 'node:path'; import { inject, injectFromHierarchy, injectable } from 'inversify'; import { BaseInstallService } from '../../install-tool/base-install.service.ts'; @@ -16,7 +15,7 @@ export class DotnetPrepareService extends BasePrepareService { /** * Installs the apt packages dotnet needs on jammy and noble, initializes the - * cache and links `~/.nuget` to it. + * cache and links `~/.nuget` to it, keeping any existing link. */ override async prepare(): Promise { const distro = await getDistro(); @@ -47,7 +46,7 @@ export class DotnetPrepareService extends BasePrepareService { } await this.initialize(); - await fs.symlink( + await this.pathSvc.createSymlink( join(this.pathSvc.cachePath, '.nuget'), join(this.envSvc.userHome, '.nuget'), ); diff --git a/src/cli/tools/dotnet/mono.ts b/src/cli/tools/dotnet/mono.ts index 0df146ce7c..a18fe8cf68 100644 --- a/src/cli/tools/dotnet/mono.ts +++ b/src/cli/tools/dotnet/mono.ts @@ -14,16 +14,18 @@ export class MonoPrepareService extends BasePrepareService { readonly name = 'mono'; /** - * Initializes the cache, links `~/.mono` to it, and sets up the mono cert - * store with a hook that syncs it on ca certificate updates. + * Initializes the cache, links `~/.mono` to it, keeping any existing link, + * and sets up the mono cert store with a hook that syncs it on ca + * certificate updates. */ override async prepare(): Promise { // TODO: install mono dependencies if needed await this.initialize(); const mono = join(this.envSvc.userHome, '.mono'); - if (!(await pathExists(mono))) { - await fs.symlink(join(this.pathSvc.cachePath, '.mono'), mono); - } + await this.pathSvc.createSymlink( + join(this.pathSvc.cachePath, '.mono'), + mono, + ); const src = join(this.pathSvc.sslPath, 'mono'); if (!(await pathExists(src))) { diff --git a/src/cli/tools/flutter.spec.ts b/src/cli/tools/flutter.spec.ts index be92f574b1..77df201380 100644 --- a/src/cli/tools/flutter.spec.ts +++ b/src/cli/tools/flutter.spec.ts @@ -111,6 +111,9 @@ describe('cli/tools/flutter', () => { expect( await fs.readlink(join(envSvc.userHome, '.flutter_tool_state')), ).toBe(join(pathSvc.cachePath, '.flutter_tool_state')); + + // second run is a no-op, it would throw on existing symlinks otherwise + await expect(svc.prepare()).resolves.toBeUndefined(); }); }); }); diff --git a/src/cli/tools/flutter.ts b/src/cli/tools/flutter.ts index 954022e5d5..68467862cf 100644 --- a/src/cli/tools/flutter.ts +++ b/src/cli/tools/flutter.ts @@ -16,8 +16,9 @@ export class FlutterPrepareService extends BasePrepareService { readonly name = 'flutter'; /** - * Initializes the cache, links the dart and pub cache folders, and turns - * off analytics and the welcome message for root and the user. + * Initializes the cache, links the dart and pub cache folders, keeping any + * existing link, and turns off analytics and the welcome message for root + * and the user. */ override async prepare(): Promise { await this.initialize(); @@ -31,12 +32,12 @@ export class FlutterPrepareService extends BasePrepareService { ); // for user - await fs.symlink( + await this.pathSvc.createSymlink( join(this.pathSvc.cachePath, '.flutter'), join(this.envSvc.userHome, '.flutter'), ); - await fs.symlink( + await this.pathSvc.createSymlink( join(this.pathSvc.cachePath, '.flutter_tool_state'), join(this.envSvc.userHome, '.flutter_tool_state'), ); diff --git a/src/cli/tools/java/index.ts b/src/cli/tools/java/index.ts index 7c054771b0..08a5dc92bd 100644 --- a/src/cli/tools/java/index.ts +++ b/src/cli/tools/java/index.ts @@ -24,7 +24,8 @@ export class JavaPrepareService extends BasePrepareService { /** * Initializes the cache, links the maven, gradle and android folders to it, - * and sets up the shared cacerts from the latest lts jre, once. + * keeping any existing link, and sets up the shared cacerts from the + * latest lts jre, once. * * @throws when the latest jre can't be resolved */ @@ -39,19 +40,19 @@ export class JavaPrepareService extends BasePrepareService { } // compatibility with gradle and maven - await fs.symlink( + await this.pathSvc.createSymlink( path.join(this.pathSvc.cachePath, '.m2'), path.join(this.envSvc.userHome, '.m2'), ); - await fs.symlink( + await this.pathSvc.createSymlink( path.join(this.pathSvc.cachePath, '.gradle'), path.join(this.envSvc.userHome, '.gradle'), ); - await fs.symlink( + await this.pathSvc.createSymlink( path.join(this.pathSvc.cachePath, '.android'), path.join(this.envSvc.userHome, '.android'), ); - await fs.symlink( + await this.pathSvc.createSymlink( path.join(this.pathSvc.cachePath, '.android-sdk'), path.join(this.envSvc.userHome, '.android-sdk'), ); diff --git a/src/cli/tools/node/utils.spec.ts b/src/cli/tools/node/utils.spec.ts index 5e220aac15..45506d9f6d 100644 --- a/src/cli/tools/node/utils.spec.ts +++ b/src/cli/tools/node/utils.spec.ts @@ -412,6 +412,9 @@ describe('cli/tools/node/utils', () => { expect(await fs.readlink(join(envSvc.userHome, '.npmrc'))).toBe( join(pathSvc.cachePath, '.npmrc'), ); + + // second run is a no-op + await expect(prepareSymlinks(envSvc, pathSvc)).resolves.toBeUndefined(); }); test('prepareGlobalConfig', async () => { diff --git a/src/cli/tools/node/utils.ts b/src/cli/tools/node/utils.ts index 81bacc2405..716b789620 100644 --- a/src/cli/tools/node/utils.ts +++ b/src/cli/tools/node/utils.ts @@ -316,16 +316,19 @@ export async function prepareNpmrc(pathSvc: PathService): Promise { } } -/** Links the user's `.npm` folder and `.npmrc` to the cache. */ +/** + * Links the user's `.npm` folder and `.npmrc` to the cache, keeping any + * existing link. + */ export async function prepareSymlinks( envSvc: EnvService, pathSvc: PathService, ): Promise { - await fs.symlink( + await pathSvc.createSymlink( join(pathSvc.cachePath, '.npm'), join(envSvc.userHome, '.npm'), ); - await fs.symlink( + await pathSvc.createSymlink( join(pathSvc.cachePath, '.npmrc'), join(envSvc.userHome, '.npmrc'), ); diff --git a/src/cli/tools/python/conan.spec.ts b/src/cli/tools/python/conan.spec.ts index ff7d1576bb..ec115f6032 100644 --- a/src/cli/tools/python/conan.spec.ts +++ b/src/cli/tools/python/conan.spec.ts @@ -108,6 +108,9 @@ describe('cli/tools/python/conan', () => { expect(await fs.readlink(join(envSvc.userHome, '.conan2'))).toBe( join(pathSvc.cachePath, '.conan2'), ); + + // second run is a no-op, it would throw on the existing symlink otherwise + await expect(svc.prepare()).resolves.toBeUndefined(); }, ); diff --git a/src/cli/tools/python/conan.ts b/src/cli/tools/python/conan.ts index 1fff8cd855..3c22def729 100644 --- a/src/cli/tools/python/conan.ts +++ b/src/cli/tools/python/conan.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs/promises'; import { join } from 'node:path'; import { inject, injectFromHierarchy, injectable } from 'inversify'; import { BasePrepareService } from '../../prepare-tool/base-prepare.service.ts'; @@ -17,14 +16,14 @@ export class ConanPrepareService extends BasePrepareService { /** * Installs the build tools conan needs, initializes the cache and links - * `~/.conan2` to it. + * `~/.conan2` to it, keeping any existing link. */ override async prepare(): Promise { await this.aptSvc.install('cmake', 'gcc', 'g++', 'make', 'perl'); await this.initialize(); - await fs.symlink( + await this.pathSvc.createSymlink( join(this.pathSvc.cachePath, '.conan2'), join(this.envSvc.userHome, '.conan2'), ); From 8777506d05d8cc0422f4911a5e479eeeb17539de Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Fri, 25 Sep 2026 14:32:41 +0200 Subject: [PATCH 2/2] fix(docker): keep an existing docker group in prepare Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5 --- src/cli/tools/docker/index.spec.ts | 2 +- src/cli/tools/docker/index.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cli/tools/docker/index.spec.ts b/src/cli/tools/docker/index.spec.ts index b0afa618b8..0355bffdc7 100644 --- a/src/cli/tools/docker/index.spec.ts +++ b/src/cli/tools/docker/index.spec.ts @@ -98,7 +98,7 @@ describe('cli/tools/docker/index', () => { expect(execaMock).toHaveBeenCalledWith( 'groupadd', - ['-g', '999', 'docker'], + ['-f', '-g', '999', 'docker'], expect.any(Object), ); expect(execaMock).toHaveBeenCalledWith( diff --git a/src/cli/tools/docker/index.ts b/src/cli/tools/docker/index.ts index d82aef99c1..efafda65ea 100644 --- a/src/cli/tools/docker/index.ts +++ b/src/cli/tools/docker/index.ts @@ -10,12 +10,12 @@ export class DockerPrepareService extends BasePrepareService { readonly name = 'docker'; /** - * Adds the user to a `docker` group, and links the user's `.docker` folder - * and the global cli plugins folder to the cache, keeping any existing - * link. + * Adds the user to a `docker` group, creating it if it doesn't already + * exist, and links the user's `.docker` folder and the global cli plugins + * folder to the cache, keeping any existing link. */ override async prepare(): Promise { - await this._spawn('groupadd', ['-g', '999', 'docker']); + await this._spawn('groupadd', ['-f', '-g', '999', 'docker']); await this._spawn('usermod', ['-aG', 'docker', this.envSvc.userName]); const globalDocker = join(this.envSvc.rootDir, 'usr/local/lib/docker'); await fs.mkdir(globalDocker, { recursive: true });