From 23c1020b1f3b5964e3a0038f8817240e540d7fa8 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 21:24:35 +0100 Subject: [PATCH 1/3] fix: don't pass the CLI's own options to package managers ensureAllDependenciesAreInstalled passes path, frameworkPath, disableNpmInstall and ignoreScripts as install options, and getFlagsString turned them into flags. npm 12 fails on unknown flags, so ns commands that install with --path, like ns debug --path app --force, stopped with EUNKNOWNCONFIG. --- lib/base-package-manager.ts | 12 ++++++++++++ test/base-package-manager.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 test/base-package-manager.ts diff --git a/lib/base-package-manager.ts b/lib/base-package-manager.ts index 5ee9a96abd..daa7141578 100644 --- a/lib/base-package-manager.ts +++ b/lib/base-package-manager.ts @@ -13,6 +13,14 @@ import { IHostInfo, } from "./common/declarations"; +// Options for the CLI itself. npm 12 fails on flags it doesn't know. +const CLI_ONLY_OPTIONS = [ + "path", + "frameworkPath", + "disableNpmInstall", + "ignoreScripts", +]; + export abstract class BasePackageManager implements INodePackageManager { public abstract install( packageName: string, @@ -136,6 +144,10 @@ export abstract class BasePackageManager implements INodePackageManager { protected getFlagsString(config: any, asArray: boolean): any { const array: Array = []; for (const flag in config) { + if (CLI_ONLY_OPTIONS.includes(flag)) { + continue; + } + if ( flag === "global" && this.packageManager !== "yarn" && diff --git a/test/base-package-manager.ts b/test/base-package-manager.ts new file mode 100644 index 0000000000..d94cf1aced --- /dev/null +++ b/test/base-package-manager.ts @@ -0,0 +1,32 @@ +import { assert } from "chai"; +import { BasePackageManager } from "../lib/base-package-manager"; + +class TestPackageManager extends (BasePackageManager) { + constructor() { + super({}, {}, {}, {}, {}, "npm"); + } +} + +describe("BasePackageManager", () => { + const flags = (config: object): string[] => + (new TestPackageManager()).getFlagsString(config, true); + + it("doesn't pass the CLI's own options to the package manager", () => { + assert.deepEqual( + flags({ + path: "apps/app", + frameworkPath: "runtime.tgz", + disableNpmInstall: true, + ignoreScripts: true, + }), + [], + ); + }); + + it("passes the package manager's options on", () => { + assert.deepEqual(flags({ "ignore-scripts": true, "save-dev": true }), [ + "--ignore-scripts", + "--save-dev", + ]); + }); +}); From f23e658cdfa5865ec5dac38cd3ad2bca082999b1 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 21:30:46 +0100 Subject: [PATCH 2/3] fix(ios): resolve NativeSource paths against the project They were resolved against the working directory, so running from outside the project with --path found no files and left the group empty, and the build failed on the missing types. --- lib/services/ios-project-service.ts | 27 ++++++++---- test/ios-project-service-native-source.ts | 50 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 test/ios-project-service-native-source.ts diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 7ac6f30c0e..9e596e328f 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -1532,7 +1532,11 @@ export class IOSProjectService projectData: IProjectData, ): Promise { const project = this.createPbxProj(projectData); - const group = await this.getRootGroup(groupName, sourceFolderPath); + const group = await this.getRootGroup( + groupName, + sourceFolderPath, + projectData.projectDir, + ); // pin the sources to the main app target: without an explicit target the // underlying xcode lib picks whichever "Sources" build phase it finds // first, which can be an extension target (e.g. a widget) once one @@ -1597,7 +1601,11 @@ export class IOSProjectService } } - private async getRootGroup(name: string, rootPath: string) { + private async getRootGroup( + name: string, + rootPath: string, + projectRoot: string, + ) { const filePathsArr: string[] = []; const rootGroup: INativeSourceCodeGroup = { name: name, @@ -1606,17 +1614,19 @@ export class IOSProjectService }; if (fastGlob.isDynamicPattern(rootPath)) { - const projectRoot = this.$projectDataService.getProjectData().projectDir; - const filePaths = await fastGlob(rootPath); + // Relative to the project: --path can run the CLI from anywhere. + const filePaths = await fastGlob(rootPath, { cwd: projectRoot }); for (const filePath of filePaths) { const sourceFilePath = path.normalize(path.join(projectRoot, filePath)); filePathsArr.push(sourceFilePath); } } else { - if (this.$fs.exists(rootPath)) { - const stats = this.$fs.getFsStats(rootPath); - if (stats.isDirectory() && !this.$fs.isEmptyDir(rootPath)) { - this.$fs.readDirectory(rootPath).forEach((fileName) => { + const folderPath = path.resolve(projectRoot, rootPath); + rootGroup.path = folderPath; + if (this.$fs.exists(folderPath)) { + const stats = this.$fs.getFsStats(folderPath); + if (stats.isDirectory() && !this.$fs.isEmptyDir(folderPath)) { + this.$fs.readDirectory(folderPath).forEach((fileName) => { const filePath = path.join(rootGroup.path, fileName); filePathsArr.push(filePath); }); @@ -1684,6 +1694,7 @@ export class IOSProjectService const group = await this.getRootGroup( pluginData.name, pluginPlatformsFolderPath, + projectData.projectDir, ); project.removePbxGroup(group.name, group.path); project.removeFromHeaderSearchPaths(group.path); diff --git a/test/ios-project-service-native-source.ts b/test/ios-project-service-native-source.ts new file mode 100644 index 0000000000..fd8d55ee77 --- /dev/null +++ b/test/ios-project-service-native-source.ts @@ -0,0 +1,50 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { FileSystem } from "../lib/common/file-system"; +import { IOSProjectService } from "../lib/services/ios-project-service"; + +describe("iOSProjectService getRootGroup", () => { + let projectDir: string; + let sourcesDir: string; + let cwd: string; + + beforeEach(() => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "native-source-")); + projectDir = path.join(root, "apps", "app"); + sourcesDir = path.join(root, "libs", "native"); + fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(sourcesDir, { recursive: true }); + fs.writeFileSync(path.join(sourcesDir, "Source.swift"), ""); + cwd = process.cwd(); + process.chdir(root); + }); + + afterEach(() => { + process.chdir(cwd); + }); + + const getFiles = async (rootPath: string): Promise => { + const { files } = await (IOSProjectService.prototype).getRootGroup.call( + { $fs: new FileSystem({}) }, + "Native", + rootPath, + projectDir, + ); + return files.map((file: string) => fs.realpathSync(file)); + }; + + const expectedSource = () => + fs.realpathSync(path.join(sourcesDir, "Source.swift")); + + it("resolves a glob against the project, not the working directory", async () => { + assert.deepEqual(await getFiles("../../libs/native/*.swift"), [ + expectedSource(), + ]); + }); + + it("resolves a folder against the project, not the working directory", async () => { + assert.deepEqual(await getFiles("../../libs/native"), [expectedSource()]); + }); +}); From 8dfaa4faaaa2eee88c901707d78d78a03fda8e9d Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 22:03:41 +0100 Subject: [PATCH 3/3] fix(ios): use the folders of a NativeSource glob's headers as search paths The glob itself ended up in HEADER_SEARCH_PATHS, where it matches nothing, relative to the Xcode project rather than the app. Folder paths now use the path resolved against the project for the search path and for the module map check as well. --- lib/services/ios-project-service.ts | 18 +++++++-- test/ios-project-service-native-source.ts | 46 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 9e596e328f..c1b4b27db8 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -1547,16 +1547,26 @@ export class IOSProjectService filesRelativeToProject: true, target: project.getFirstTarget()?.uuid, }); - project.addToHeaderSearchPaths(group.path); - const headerFiles = this.$fs.exists(sourceFolderPath) + // A glob is no use as a search path, the folders of its headers are. + const headerSearchPaths = fastGlob.isDynamicPattern(sourceFolderPath) + ? _.uniq( + group.files + .filter((file) => path.extname(file) === ".h") + .map((file) => path.dirname(file)), + ) + : [group.path]; + headerSearchPaths.forEach((searchPath) => + project.addToHeaderSearchPaths(searchPath), + ); + const headerFiles = this.$fs.exists(group.path) ? this.$fs.enumerateFilesInDirectorySync( - sourceFolderPath, + group.path, (file, stat) => stat.isDirectory() || path.extname(file) === ".h", ) : []; if ( headerFiles.length > 0 && - !this.$fs.exists(path.join(sourceFolderPath, "module.modulemap")) + !this.$fs.exists(path.join(group.path, "module.modulemap")) ) { this.$logger.warn( `warning: Directory ${sourceFolderPath} with native iOS source code doesn't contain a modulemap file. Metadata for it will not be generated and it will not be accessible from JavaScript. To learn more see https://docs.nativescript.org/guides/ios-source-code`, diff --git a/test/ios-project-service-native-source.ts b/test/ios-project-service-native-source.ts index fd8d55ee77..dd8f692126 100644 --- a/test/ios-project-service-native-source.ts +++ b/test/ios-project-service-native-source.ts @@ -47,4 +47,50 @@ describe("iOSProjectService getRootGroup", () => { it("resolves a folder against the project, not the working directory", async () => { assert.deepEqual(await getFiles("../../libs/native"), [expectedSource()]); }); + + describe("header search paths", () => { + const prepare = async (rootPath: string): Promise => { + const searchPaths: string[] = []; + const proto = IOSProjectService.prototype; + await proto.prepareNativeSourceCode.call( + { + $fs: new FileSystem({}), + $logger: { warn: () => {} }, + getRootGroup: proto.getRootGroup, + savePbxProj: () => {}, + createPbxProj: () => ({ + addPbxGroup: () => {}, + getFirstTarget: () => ({ uuid: "target" }), + addToHeaderSearchPaths: (searchPath: string) => + searchPaths.push(searchPath), + }), + }, + "Native", + rootPath, + { projectDir }, + ); + return searchPaths.map((searchPath) => fs.realpathSync(searchPath)); + }; + + it("uses the folders of the headers a glob matches", async () => { + const nested = path.join(sourcesDir, "nested"); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, "One.h"), ""); + fs.writeFileSync(path.join(nested, "Two.h"), ""); + + assert.deepEqual(await prepare("../../libs/native/**/*.{h,swift}"), [ + fs.realpathSync(nested), + ]); + }); + + it("adds nothing for a glob without headers", async () => { + assert.deepEqual(await prepare("../../libs/native/*.swift"), []); + }); + + it("uses the folder itself, against the project", async () => { + assert.deepEqual(await prepare("../../libs/native"), [ + fs.realpathSync(sourcesDir), + ]); + }); + }); });