134 lines
6.4 KiB
TypeScript
134 lines
6.4 KiB
TypeScript
// Where a JS project's native plugins (Capacitor, Cordova, React Native) live, also in monorepos: Node's own lookup
|
|
// (node_modules next to the app's package.json and in every folder above it, up to the repo root), workspace package
|
|
// folders (package.json "workspaces", npm / yarn / pnpm-style array or { packages }, as Bun reads them) and local
|
|
// SwiftPM packages (`.package(path:)` in a Package.swift, e.g. Capacitor 8's CapApp-SPM). Used by `privacy-manifest`
|
|
// and `scan`. Read-only; never throws.
|
|
import { type Dirent, existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { skipDir } from './walk-skip';
|
|
|
|
interface PkgJson { name?: string; dependencies?: Record<string, string>; workspaces?: string[] | { packages?: string[] } }
|
|
const readJson = (p: string): PkgJson | null => { try { return JSON.parse(readFileSync(p, 'utf8')) as PkgJson; } catch { return null; } };
|
|
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
const real = (p: string): string => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
|
|
/** The repo root at or above `dir`: the nearest folder with .git, else the top-most package.json with workspaces,
|
|
* else `dir` itself. */
|
|
export function repoRoot(dir: string): string {
|
|
let ws: string | null = null;
|
|
for (let d = resolve(dir); ; d = dirname(d)) {
|
|
if (existsSync(join(d, '.git'))) return d;
|
|
if (readJson(join(d, 'package.json'))?.workspaces) ws = d;
|
|
if (dirname(d) === d) return ws ?? resolve(dir);
|
|
}
|
|
}
|
|
|
|
/** Workspace package folders of a repo root ("apps/*", "packages/**" one level deep, or literal folders). */
|
|
export function workspaceDirs(root: string): string[] {
|
|
const w = readJson(join(root, 'package.json'))?.workspaces;
|
|
const pats = Array.isArray(w) ? w : Array.isArray(w?.packages) ? w.packages : [];
|
|
const out: string[] = [];
|
|
for (const p of pats) {
|
|
if (typeof p !== 'string' || p.startsWith('!')) continue;
|
|
const m = /^(.*?)\/\*\*?$/.exec(p);
|
|
if (m) {
|
|
const base = join(root, m[1]!);
|
|
let names: string[] = [];
|
|
try { names = readdirSync(base).sort(); } catch { /* missing */ }
|
|
for (const n of names) if (existsSync(join(base, n, 'package.json'))) out.push(join(base, n));
|
|
} else if (!p.includes('*') && existsSync(join(root, p, 'package.json'))) out.push(join(root, p));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Folder of package `name` as Node resolves it from `fromDir` (node_modules there and in every parent up to `root`),
|
|
* else the workspace package of that name. */
|
|
export function resolvePackage(name: string, fromDir: string, root: string): string | null {
|
|
const top = resolve(root);
|
|
for (let d = resolve(fromDir); ; d = dirname(d)) {
|
|
const p = join(d, 'node_modules', name);
|
|
if (existsSync(join(p, 'package.json'))) return p;
|
|
if (d === top || dirname(d) === d) break;
|
|
}
|
|
for (const w of workspaceDirs(top)) if (readJson(join(w, 'package.json'))?.name === name) return w;
|
|
return null;
|
|
}
|
|
|
|
/** The package has native iOS code: a podspec or Package.swift at its root, or an ios/ folder. */
|
|
export function hasNativeIos(dir: string): boolean {
|
|
let names: string[] = [];
|
|
try { names = readdirSync(dir); } catch { return false; }
|
|
return names.some(n => n.endsWith('.podspec') || n === 'Package.swift') || isDir(join(dir, 'ios'));
|
|
}
|
|
|
|
/** Local SwiftPM packages (`.package(path: "...")` / `.package(name: "X", path: "...")`) of every Package.swift under
|
|
* `dir` (a few levels, skipping build output and checkouts). */
|
|
export function localSwiftPackages(dir: string): string[] {
|
|
const out: string[] = [];
|
|
const skip = new Set(['Pods', 'build', 'DerivedData', 'node_modules', '.build', 'SourcePackages', '.git', 'xcuserdata']);
|
|
const walk = (d: string, depth: number) => {
|
|
if (depth > 4) return;
|
|
let ents: Dirent[] = [];
|
|
try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
for (const e of ents) {
|
|
const n = e.name;
|
|
if (skip.has(n)) continue;
|
|
const p = join(d, n);
|
|
if (n === 'Package.swift') {
|
|
let t = '';
|
|
try { t = readFileSync(p, 'utf8'); } catch { continue; }
|
|
for (const m of t.matchAll(/\.package\(\s*(?:name:\s*"[^"]*"\s*,\s*)?path:\s*"([^"]+)"/g)) {
|
|
const target = resolve(d, m[1]!);
|
|
if (isDir(target)) out.push(target);
|
|
}
|
|
} else if ((e.isDirectory() || (e.isSymbolicLink() && isDir(p))) && !/\.(xc)?framework$|\.xcassets$/.test(n) && !skipDir(dir, p, n)) walk(p, depth + 1);
|
|
}
|
|
};
|
|
walk(dir, 0);
|
|
return out;
|
|
}
|
|
|
|
export interface NativePlugin { name: string; dir: string }
|
|
|
|
/**
|
|
* The native plugin packages an iOS project at `iosDir` builds: the dependencies of the app's package.json (the
|
|
* nearest one at or above `iosDir`, i.e. next to capacitor.config) resolved through node_modules and workspaces,
|
|
* plus the local SwiftPM packages its Package.swift files reference. Without a package.json: every package with
|
|
* native iOS code in the node_modules folders from `iosDir` up to the repo root. Deduplicated by real path.
|
|
*/
|
|
export function nativeIosPlugins(iosDir: string): NativePlugin[] {
|
|
const start = resolve(iosDir);
|
|
const root = repoRoot(start);
|
|
const out: NativePlugin[] = [];
|
|
const seen = new Set<string>();
|
|
const add = (dir: string, name?: string) => {
|
|
const r = real(dir);
|
|
if (seen.has(r) || !hasNativeIos(dir)) return;
|
|
seen.add(r);
|
|
out.push({ name: name ?? readJson(join(dir, 'package.json'))?.name ?? dir.split(/[\\/]/).pop()!, dir });
|
|
};
|
|
let appDir: string | null = null;
|
|
for (let d = start; ; d = dirname(d)) {
|
|
if (existsSync(join(d, 'package.json'))) { appDir = d; break; }
|
|
if (d === root || dirname(d) === d) break;
|
|
}
|
|
if (appDir) {
|
|
for (const name of Object.keys(readJson(join(appDir, 'package.json'))?.dependencies ?? {})) {
|
|
const p = resolvePackage(name, appDir, root);
|
|
if (p) add(p, name);
|
|
}
|
|
} else {
|
|
for (let d = start; ; d = dirname(d)) {
|
|
const nm = join(d, 'node_modules');
|
|
let names: string[] = [];
|
|
try { names = readdirSync(nm).filter(n => !n.startsWith('.')).sort(); } catch { /* none */ }
|
|
for (const n of names) {
|
|
if (n.startsWith('@')) { try { for (const k of readdirSync(join(nm, n)).sort()) add(join(nm, n, k), `${n}/${k}`); } catch { /* unreadable */ } }
|
|
else add(join(nm, n), n);
|
|
}
|
|
if (d === root || dirname(d) === d) break;
|
|
}
|
|
}
|
|
for (const p of localSwiftPackages(start)) add(p);
|
|
return out;
|
|
}
|