From 64563b4311cc0e385ac3bcd7c6194482df65d482 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 23 Jul 2026 13:16:49 -0700 Subject: [PATCH] feat(doctor): support asdf-managed Ruby in the CocoaPods check When Ruby is managed by asdf, the `ns doctor` CocoaPods verification runs in a fresh temp directory that lacks the project's `.tool-versions`, so `pod install` can fail on otherwise healthy setups. Detect the active Ruby version via `asdf current ruby` and write it to a `.tool-versions` file in the temporary CocoaPods project: - The probe uses `ignoreError: true`, so a missing/misconfigured asdf no longer makes the check report a broken environment. - The version is resolved with an explicit `cwd` (process.cwd()), so it reflects the project even when `ns doctor` is run outside of it. - The `.tool-versions` entry is newline-terminated so it does not merge with existing content. Adds unit tests covering asdf-present and asdf-absent behavior. --- packages/doctor/src/sys-info.ts | 34 +++++ packages/doctor/src/wrappers/file-system.ts | 15 ++- packages/doctor/test/sys-info.ts | 131 ++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/packages/doctor/src/sys-info.ts b/packages/doctor/src/sys-info.ts index 7c71d43491..9bc9b662c0 100644 --- a/packages/doctor/src/sys-info.ts +++ b/packages/doctor/src/sys-info.ts @@ -424,6 +424,39 @@ export class SysInfo implements NativeScriptDoctor.ISysInfo { tempDirectory, ); const xcodeProjectDir = path.join(tempDirectory, "cocoapods"); + + // If asdf version manager is installed, get the current Ruby version for the project directory and write it to the temporary project directory. + // Resolve relative to the directory `ns doctor` was invoked from, since it can be run outside of a project directory. + const asdfResult = await this.childProcess.spawnFromEvent( + "asdf", + ["current", "ruby"], + "exit", + { ignoreError: true, spawnOptions: { cwd: process.cwd() } }, + ); + + if (asdfResult.exitCode === 0) { + const asdfVersionMatch = (asdfResult.stdout as string).match( + SysInfo.VERSION_REGEXP, + ); + + if (asdfVersionMatch?.[0]) { + const asdfVersion = asdfVersionMatch[0]; + const asdfConfigPath = path.join( + xcodeProjectDir, + ".tool-versions", + ); + const wroteASDFConfig = this.fileSystem.appendFile( + asdfConfigPath, + `ruby ${asdfVersion}\n`, + ); + if (!wroteASDFConfig) { + console.warn( + `CocoaPods invocation may fail, check asdf config`, + ); + } + } + } + const spawnResult = await this.childProcess.spawnFromEvent( "pod", ["install"], @@ -432,6 +465,7 @@ export class SysInfo implements NativeScriptDoctor.ISysInfo { ); return !spawnResult.exitCode; } catch (err) { + console.log(`Pod command failed - ${err}`); return false; } finally { this.fileSystem.deleteEntry(tempDirectory); diff --git a/packages/doctor/src/wrappers/file-system.ts b/packages/doctor/src/wrappers/file-system.ts index ac7a05ed95..759c826d0c 100644 --- a/packages/doctor/src/wrappers/file-system.ts +++ b/packages/doctor/src/wrappers/file-system.ts @@ -8,6 +8,17 @@ export class FileSystem { return fs.existsSync(path.resolve(filePath)); } + public appendFile(filePath: string, text: string): boolean { + let success = false; + try { + fs.appendFileSync(path.resolve(filePath), text); + success = true; + } catch (err) { + console.error(`appendFile failed with ${err}`); + } + return success; + } + public extractZip(pathToZip: string, outputDir: string): Promise { return new Promise((resolve, reject) => { yauzl.open( @@ -46,7 +57,7 @@ export class FileSystem { zipFile.once("end", () => resolve()); zipFile.readEntry(); - } + }, ); }); } @@ -57,7 +68,7 @@ export class FileSystem { public readJson( filePath: string, - options?: { encoding?: null; flag?: string } + options?: { encoding?: null; flag?: string }, ): T { const content = fs.readFileSync(filePath, options); return JSON.parse(content.toString()); diff --git a/packages/doctor/test/sys-info.ts b/packages/doctor/test/sys-info.ts index 5b41c32487..f2b6d24d59 100644 --- a/packages/doctor/test/sys-info.ts +++ b/packages/doctor/test/sys-info.ts @@ -1,4 +1,5 @@ import * as assert from "assert"; +import * as fs from "fs"; import * as path from "path"; import { EOL } from "os"; import { SysInfo } from "../src/sys-info"; @@ -910,4 +911,134 @@ Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)`), }); }); }); + + describe("isCocoaPodsWorkingCorrectly", () => { + interface ICocoaPodsMockOptions { + // Mimics the ChildProcess result for `asdf current ruby`. + // When omitted, asdf is treated as not installed. + asdfResult?: { + stdout?: string; + stderr?: string; + exitCode?: number | string; + }; + podExitCode?: number; + } + + const createCocoaPodsSysInfo = (options: ICocoaPodsMockOptions) => { + const appendedFiles: { filePath: string; text: string }[] = []; + const spawnCalls: { + command: string; + options?: ISpawnFromEventOptions; + }[] = []; + + const childProcess: any = { + spawnFromEvent: async ( + command: string, + args: string[], + event: string, + spawnFromEventOptions?: ISpawnFromEventOptions, + ) => { + const fullCommand = `${command} ${args.join(" ")}`; + spawnCalls.push({ + command: fullCommand, + options: spawnFromEventOptions, + }); + + if (fullCommand === "asdf current ruby") { + // Mirror the ChildProcess wrapper: with `ignoreError` it always + // resolves, surfacing a non-zero exitCode instead of throwing when + // asdf is missing/misconfigured. + return ( + options.asdfResult || { + stdout: "", + stderr: "spawn asdf ENOENT", + exitCode: "ENOENT", + } + ); + } + + return { + stdout: "", + stderr: "", + exitCode: options.podExitCode ?? 0, + }; + }, + exec: async () => ({ stdout: "", stderr: "" }), + execFile: async (): Promise => undefined, + execSync: (): string => null, + }; + + const fileSystem: any = { + exists: () => true, + extractZip: () => Promise.resolve(), + readDirectory: () => [], + appendFile: (filePath: string, text: string) => { + appendedFiles.push({ filePath, text }); + return true; + }, + deleteEntry: (filePath: string) => + fs.rmSync(filePath, { recursive: true, force: true }), + }; + + const hostInfo: any = { + isDarwin: true, + isWindows: false, + isLinux: false, + }; + + const helpers = new Helpers(hostInfo); + const sysInfo = new SysInfo( + childProcess, + fileSystem, + helpers, + hostInfo, + null, + androidToolsInfo, + ); + + return { sysInfo, appendedFiles, spawnCalls }; + }; + + it("writes the active Ruby version to .tool-versions when asdf is available", async () => { + const { sysInfo, appendedFiles, spawnCalls } = createCocoaPodsSysInfo({ + asdfResult: { + stdout: + "ruby 3.2.1 /Users/user/app/.tool-versions", + exitCode: 0, + }, + }); + + const result = await sysInfo.isCocoaPodsWorkingCorrectly(); + + assert.deepEqual(result, true); + assert.deepEqual(appendedFiles.length, 1); + assert.ok( + appendedFiles[0].filePath.endsWith( + path.join("cocoapods", ".tool-versions"), + ), + ); + // The entry must be newline-terminated so it does not merge with existing content. + assert.deepEqual(appendedFiles[0].text, "ruby 3.2.1\n"); + + const asdfCall = spawnCalls.find( + (c) => c.command === "asdf current ruby", + ); + assert.ok(asdfCall, "expected asdf to be probed"); + // The probe must not throw when asdf is missing/misconfigured... + assert.deepEqual(asdfCall.options.ignoreError, true); + // ...and it must resolve the version relative to the invocation directory. + assert.deepEqual(asdfCall.options.spawnOptions.cwd, process.cwd()); + }); + + it("does not write .tool-versions and stays healthy when asdf is not installed", async () => { + const { sysInfo, appendedFiles } = createCocoaPodsSysInfo({ + // asdf missing -> wrapper resolves with a non-zero (ENOENT) exit code. + }); + + const result = await sysInfo.isCocoaPodsWorkingCorrectly(); + + assert.deepEqual(result, true); + assert.deepEqual(appendedFiles.length, 0); + }); + }); });