Files
Ole André Vadla Ravnås 50555a459c build: Stage every agent source on Windows
The relative path was cut with a hardcoded "/", and skipDescendants(),
reached at node_modules, abandons the rest of the walk. Between them
the entrypoints were staged and nothing they import was.

npm is a .cmd shim, which CreateProcess declines, and there is no
/usr/bin/env to hand it to.
2026-09-08 18:54:43 +02:00

701 lines
25 KiB
Swift

import Dispatch
import Foundation
import Frida
private let _lumaMainGroup = DispatchGroup()
_lumaMainGroup.enter()
Task {
await LumaBundleCompiler.main()
_lumaMainGroup.leave()
}
_lumaMainGroup.wait()
struct LumaBundleCompiler {
enum Kind: String, Decodable {
case agent
case module
}
struct Entry {
let kind: Kind
let swiftName: String
let entrypoint: String
}
struct CompiledEntry {
let kind: Kind
let swiftName: String
let source: String
}
struct TypingsEntry {
let swiftName: String
let packageName: String
}
struct CompiledTypings {
let swiftName: String
let files: [(filePath: String, content: String)]
}
struct Config: Decodable {
struct Output: Decodable {
let agent: String
let typings: String?
}
struct Input: Decodable {
let bundles: [BundleEntry]
let typings: [TypingsPackage]
let externals: [String]
}
struct BundleEntry: Decodable {
let name: String
let kind: Kind
let entrypoint: String
}
struct TypingsPackage: Decodable {
let name: String
let package: String
}
let output: Output
let input: Input
}
static func main() async {
do {
try await run()
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
}
static func run() async throws {
var args = Array(CommandLine.arguments.dropFirst())
var projectRoot: String?
var swiftOutPath: String?
var typingsOutPath: String?
var stagingDir: String?
var manifestPath: String?
var lockfilePath: String?
var shouldUpdateLockfile = true
var entries: [Entry] = []
var typingsEntries: [TypingsEntry] = []
var packageSpecs: [String] = []
var localPackages: [(name: String, path: String)] = []
var externals: [String] = []
func popValue(for flag: String) throws -> String {
guard let idx = args.firstIndex(of: flag), idx + 1 < args.count else {
throw ToolError.usage("Missing value for \(flag)")
}
let value = args[idx + 1]
args.removeSubrange(idx...(idx + 1))
return value
}
if args.contains("--help") || args.isEmpty {
printUsageAndExit(success: true)
}
while !args.isEmpty {
let flag = args.removeFirst()
switch flag {
case "--config":
guard !args.isEmpty else { throw ToolError.usage("Missing value for --config") }
let configPath = args.removeFirst()
let config = try loadConfig(at: configPath)
let configDir = URL(fileURLWithPath: configPath).deletingLastPathComponent()
manifestPath = configDir.appendingPathComponent("package.json").path
lockfilePath = configDir.appendingPathComponent("package-lock.json").path
applyConfig(
config,
swiftOutPath: &swiftOutPath,
typingsOutPath: &typingsOutPath,
entries: &entries,
typingsEntries: &typingsEntries,
externals: &externals)
case "--project-root":
guard !args.isEmpty else { throw ToolError.usage("Missing value for --project-root") }
projectRoot = args.removeFirst()
case "--swift-out":
guard !args.isEmpty else { throw ToolError.usage("Missing value for --swift-out") }
swiftOutPath = args.removeFirst()
case "--typings-out":
guard !args.isEmpty else { throw ToolError.usage("Missing value for --typings-out") }
typingsOutPath = args.removeFirst()
case "--staging-dir":
guard !args.isEmpty else { throw ToolError.usage("Missing value for --staging-dir") }
stagingDir = args.removeFirst()
case "--no-lockfile-update":
shouldUpdateLockfile = false
case "--package":
guard !args.isEmpty else { throw ToolError.usage("Missing value for --package") }
packageSpecs.append(args.removeFirst())
case "--external":
guard !args.isEmpty else { throw ToolError.usage("Missing value for --external") }
externals.append(args.removeFirst())
case "--local-package":
guard args.count >= 2 else {
throw ToolError.usage("Missing values for --local-package <name> <path>")
}
let name = args.removeFirst()
let path = args.removeFirst()
localPackages.append((name: name, path: path))
case "--agent":
guard args.count >= 2 else {
throw ToolError.usage("Missing values for --agent <swiftName> <entry.ts>")
}
let swiftName = args.removeFirst()
let entry = args.removeFirst()
entries.append(
Entry(
kind: .agent,
swiftName: swiftName,
entrypoint: entry))
case "--module":
guard args.count >= 2 else {
throw ToolError.usage("Missing values for --module <swiftName> <entry.ts>")
}
let swiftName = args.removeFirst()
let entry = args.removeFirst()
entries.append(
Entry(
kind: .module,
swiftName: swiftName,
entrypoint: entry))
default:
throw ToolError.usage("Unknown argument: \(flag)")
}
}
guard let swiftOutPath else {
throw ToolError.usage("Missing --swift-out.")
}
guard !entries.isEmpty else {
throw ToolError.usage("No --agent or --module entries provided.")
}
if !typingsEntries.isEmpty && typingsOutPath == nil {
throw ToolError.usage("--typings-out is required when using --typings.")
}
let hasManifest = manifestPath.map { FileManager.default.fileExists(atPath: $0) } ?? false
let effectiveProjectRoot: String
if !packageSpecs.isEmpty || !localPackages.isEmpty || hasManifest {
guard let stagingDir else {
throw ToolError.usage("--staging-dir is required when using --package, --local-package, or --config.")
}
let fm = FileManager.default
if !fm.fileExists(atPath: stagingDir) {
try fm.createDirectory(atPath: stagingDir, withIntermediateDirectories: true)
}
for local in localPackages {
let dst = URL(fileURLWithPath: stagingDir)
.appendingPathComponent("node_modules", isDirectory: true)
.appendingPathComponent(local.name, isDirectory: true)
if (try? fm.destinationOfSymbolicLink(atPath: dst.path)) != nil {
try fm.removeItem(at: dst)
}
}
if hasManifest || !packageSpecs.isEmpty {
if hasManifest, let src = manifestPath {
try syncFile(at: src, into: stagingDir)
}
if let src = lockfilePath, fm.fileExists(atPath: src) {
try syncFile(at: src, into: stagingDir)
}
let summary = packageSpecs.isEmpty ? "from manifest" : packageSpecs.joined(separator: ", ")
fputs("[packages] installing: \(summary)\n", stderr)
func installWithNPM() throws {
let stagingLockfile = URL(fileURLWithPath: stagingDir)
.appendingPathComponent("package-lock.json").path
try runNPMInstall(
in: stagingDir,
specs: packageSpecs,
preferCleanInstall: packageSpecs.isEmpty && fm.fileExists(atPath: stagingLockfile))
}
let pm = PackageManager()
do {
_ = try await pm.install(
specs: packageSpecs, projectRoot: stagingDir, role: .runtime)
} catch {
fputs("[packages] frida install failed: \(error.localizedDescription)\n", stderr)
fputs("[packages] falling back to npm\n", stderr)
try installWithNPM()
}
fputs("[packages] done\n", stderr)
let stagingLockfile = URL(fileURLWithPath: stagingDir)
.appendingPathComponent("package-lock.json").path
if shouldUpdateLockfile, let dst = lockfilePath, fm.fileExists(atPath: stagingLockfile) {
if fm.fileExists(atPath: dst) {
try fm.removeItem(atPath: dst)
}
try fm.copyItem(atPath: stagingLockfile, toPath: dst)
}
}
for local in localPackages {
let dst = URL(fileURLWithPath: stagingDir)
.appendingPathComponent("node_modules", isDirectory: true)
.appendingPathComponent(local.name, isDirectory: true)
let src = URL(fileURLWithPath: local.path).standardizedFileURL
if fm.fileExists(atPath: dst.path) {
try fm.removeItem(at: dst)
}
try fm.createSymbolicLink(at: dst, withDestinationURL: src)
fputs("[packages] overriding \(local.name) with \(local.path)\n", stderr)
}
let sourceRoot = projectRoot ?? "."
for entry in entries {
let srcURL = URL(fileURLWithPath: entry.entrypoint, relativeTo: URL(fileURLWithPath: sourceRoot))
let dstURL = URL(fileURLWithPath: entry.entrypoint, relativeTo: URL(fileURLWithPath: stagingDir))
try fm.createDirectory(at: dstURL.deletingLastPathComponent(), withIntermediateDirectories: true)
if fm.fileExists(atPath: dstURL.path) {
try fm.removeItem(at: dstURL)
}
try fm.copyItem(at: srcURL.standardizedFileURL, to: dstURL)
}
try copyAgentSources(from: sourceRoot, to: stagingDir)
effectiveProjectRoot = stagingDir
} else {
effectiveProjectRoot = projectRoot ?? "."
}
let compiler = Compiler()
let eventsTask = Task {
for await event in compiler.events {
switch event {
case .starting:
fputs("[compiler] starting\n", stderr)
case .finished:
fputs("[compiler] finished\n", stderr)
case .output(let bundle):
fputs("[compiler] output length: \(bundle.utf8.count) bytes\n", stderr)
case .diagnostics(let any):
fputs("[compiler] diagnostics: \(any)\n", stderr)
}
}
}
var compiled: [CompiledEntry] = []
compiled.reserveCapacity(entries.count)
for entry in entries {
let bundle = try await compiler.build(
entrypoint: entry.entrypoint,
externals: externals.isEmpty ? nil : externals,
projectRoot: effectiveProjectRoot,
sourceMaps: .omitted)
let source: String
switch entry.kind {
case .agent:
source = bundle
case .module:
source = try unwrapSingleModule(from: bundle)
}
compiled.append(
CompiledEntry(
kind: entry.kind,
swiftName: entry.swiftName,
source: source))
}
eventsTask.cancel()
try writeGeneratedFile(makeAgentFile(from: compiled), to: swiftOutPath)
if let typingsOutPath, let stagingDir {
let compiledTypings = try typingsEntries.map { entry in
CompiledTypings(
swiftName: entry.swiftName,
files: try readTypingsFiles(for: entry, stagingDir: stagingDir))
}
try writeGeneratedFile(makeTypingsFile(from: compiledTypings), to: typingsOutPath)
}
}
static func loadConfig(at path: String) throws -> (config: Config, baseDir: String) {
let url = URL(fileURLWithPath: path).standardizedFileURL
let data = try Data(contentsOf: url)
let config = try JSONDecoder().decode(Config.self, from: data)
return (config, url.deletingLastPathComponent().path)
}
static func applyConfig(
_ loaded: (config: Config, baseDir: String),
swiftOutPath: inout String?,
typingsOutPath: inout String?,
entries: inout [Entry],
typingsEntries: inout [TypingsEntry],
externals: inout [String]
) {
let (config, baseDir) = loaded
let resolve: (String) -> String = { resolvePath($0, baseDir: baseDir) }
if swiftOutPath == nil {
swiftOutPath = resolve(config.output.agent)
}
if typingsOutPath == nil, let out = config.output.typings {
typingsOutPath = resolve(out)
}
for bundle in config.input.bundles {
entries.append(Entry(kind: bundle.kind, swiftName: bundle.name, entrypoint: bundle.entrypoint))
}
for typing in config.input.typings {
typingsEntries.append(
TypingsEntry(swiftName: typing.name, packageName: typing.package))
}
externals.append(contentsOf: config.input.externals)
}
static func syncFile(at source: String, into directory: String) throws {
let fm = FileManager.default
let dst = URL(fileURLWithPath: directory)
.appendingPathComponent((source as NSString).lastPathComponent).path
if fm.fileExists(atPath: dst) {
try fm.removeItem(atPath: dst)
}
try fm.copyItem(atPath: source, toPath: dst)
}
static func resolvePath(_ path: String, baseDir: String) -> String {
if (path as NSString).isAbsolutePath { return path }
return URL(fileURLWithPath: path, relativeTo: URL(fileURLWithPath: baseDir, isDirectory: true))
.standardizedFileURL.path
}
static func writeGeneratedFile(_ contents: String, to path: String) throws {
let url = URL(fileURLWithPath: path)
// Preserve the mtime on no-op runs so SwiftPM stays incremental.
let alreadyUpToDate = (try? String(contentsOf: url, encoding: .utf8)) == contents
if alreadyUpToDate { return }
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true,
attributes: nil)
try contents.write(to: url, atomically: true, encoding: .utf8)
}
static func readTypingsFiles(
for entry: TypingsEntry,
stagingDir: String
) throws -> [(filePath: String, content: String)] {
let fm = FileManager.default
let packageRoot = URL(fileURLWithPath: stagingDir)
.appendingPathComponent("node_modules", isDirectory: true)
.appendingPathComponent(entry.packageName, isDirectory: true)
.standardizedFileURL
let enumerator = fm.enumerator(
at: packageRoot,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
)
var files: [(filePath: String, content: String)] = []
while let url = enumerator?.nextObject() as? URL {
let values = try url.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { continue }
guard url.pathExtension == "ts", url.lastPathComponent.hasSuffix(".d.ts") else { continue }
let relPath = url.path.replacingOccurrences(of: packageRoot.path + "/", with: "")
let content = try String(contentsOf: url, encoding: .utf8)
files.append((filePath: "\(entry.packageName)/\(relPath)", content: content))
}
files.sort { $0.filePath < $1.filePath }
return files
}
static func printUsageAndExit(success: Bool) -> Never {
let prog = (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "LumaBundleCompiler"
let text = """
Usage:
\(prog) --config <config.json> --project-root <path> --staging-dir <path>
Options:
--config <path> JSON config listing packages, typings, agents, modules
--project-root <path> Root that agent entrypoints resolve against
--staging-dir <path> Directory for package installation and compilation
--no-lockfile-update Do not copy the staging lockfile back to the config directory
--swift-out <path> Override agent output path from config
--typings-out <path> Override typings output path from config
--package <spec> Install an extra npm package
--external <name> Do not bundle imports of this npm package
--typings <swiftName> <spec> Install an extra typings package and embed its *.d.ts files
--local-package <name> <path> Override an installed package with a local checkout
--agent <swiftName> <entry> Add a Frida-bundle agent entry
--module <swiftName> <entry> Add an unwrapped-module entry
"""
fputs(text, success ? stdout : stderr)
exit(success ? 0 : 1)
}
/// Copy the Agent source tree into the staging directory so the
/// compiler can resolve relative imports alongside installed packages.
static func copyAgentSources(from sourceRoot: String, to stagingDir: String) throws {
let fm = FileManager.default
let agentRelDir = "Agent"
let srcDir = URL(fileURLWithPath: agentRelDir, relativeTo: URL(fileURLWithPath: sourceRoot)).standardizedFileURL
let dstDir = URL(fileURLWithPath: agentRelDir, relativeTo: URL(fileURLWithPath: stagingDir))
guard fm.fileExists(atPath: srcDir.path) else { return }
var pending: [(source: URL, relative: [String])] = [(srcDir, [])]
while let directory = pending.popLast() {
let entries = try fm.contentsOfDirectory(
at: directory.source,
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
)
for url in entries {
let name = url.lastPathComponent
if name == "node_modules" { continue }
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
let relative = directory.relative + [name]
if values.isDirectory == true {
pending.append((url, relative))
continue
}
guard values.isRegularFile == true else { continue }
guard relative != ["package.json"], relative != ["package-lock.json"] else { continue }
var dst = dstDir
for component in relative {
dst.appendPathComponent(component)
}
if fm.fileExists(atPath: dst.path) {
try fm.removeItem(at: dst)
}
try fm.createDirectory(at: dst.deletingLastPathComponent(), withIntermediateDirectories: true)
try fm.copyItem(at: url, to: dst)
}
}
}
static func runNPMInstall(in directory: String, specs: [String], preferCleanInstall: Bool) throws {
let args: [String]
if preferCleanInstall {
args = ["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund"]
} else {
args = ["npm", "install", "--ignore-scripts", "--no-audit", "--no-fund"] + specs
}
let process = Process()
#if os(Windows)
let comSpec = ProcessInfo.processInfo.environment["ComSpec"] ?? #"C:\Windows\System32\cmd.exe"#
process.executableURL = URL(fileURLWithPath: comSpec)
process.arguments = ["/c"] + args
#else
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = args
#endif
process.currentDirectoryURL = URL(fileURLWithPath: directory, isDirectory: true)
process.standardOutput = FileHandle.standardError
process.standardError = FileHandle.standardError
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw ToolError.buildFailed("npm \(args.dropFirst().joined(separator: " ")) exited with \(process.terminationStatus)")
}
}
}
enum ToolError: LocalizedError {
case usage(String)
case buildFailed(String)
var errorDescription: String? {
switch self {
case .usage(let message):
return message
case .buildFailed(let message):
return "Build failed: \(message)"
}
}
}
private func makeAgentFile(from entries: [LumaBundleCompiler.CompiledEntry]) -> String {
var out = generatedFileHeader() + """
enum LumaAgent {
"""
for (index, entry) in entries.enumerated() {
let indented = indentMultiline(entry.source, by: 4)
if index > 0 {
out += "\n"
}
out += """
static let \(entry.swiftName)Source: String = #\"\"\"
\(indented)
\"\"\"#
"""
}
out += "}\n"
return out
}
private func makeTypingsFile(from entries: [LumaBundleCompiler.CompiledTypings]) -> String {
var out = generatedFileHeader() + """
enum LumaTypings {
"""
for (index, entry) in entries.enumerated() {
if index > 0 {
out += "\n"
}
out += """
static let \(entry.swiftName): [TypeScriptTypingFile] = [
"""
for file in entry.files {
let indented = indentMultiline(file.content, by: 12)
out += """
TypeScriptTypingFile(
filePath: \"\(file.filePath)\",
content: #\"\"\"
\(indented)
\"\"\"#),
"""
}
out += """
]
"""
}
out += "}\n"
return out
}
private func generatedFileHeader() -> String {
"""
// This file is auto-generated by LumaBundleCompiler.
// Do not edit by hand. Your changes will be overwritten.
import Foundation
"""
}
private func indentMultiline(_ string: String, by spaces: Int) -> String {
let prefix = String(repeating: " ", count: spaces)
return
string
.split(separator: "\n", omittingEmptySubsequences: false)
.map { prefix + $0 }
.joined(separator: "\n")
}
private func unwrapSingleModule(from bundle: String) throws -> String {
let headerPrefix = "📦\n"
let separator = "✄\n"
guard bundle.hasPrefix(headerPrefix) else {
throw ESMUnwrapError.invalidFormat
}
guard let separatorRange = bundle.range(of: "\n" + separator) else {
throw ESMUnwrapError.headerNotFound
}
let headerString = String(bundle[..<separatorRange.lowerBound])
let headerAndSepString = String(bundle[..<separatorRange.upperBound])
guard let bundleData = bundle.data(using: .utf8) else {
throw ESMUnwrapError.encodingError
}
let headerAndSepByteCount = headerAndSepString.utf8.count
let bodyBytes = bundleData[headerAndSepByteCount...]
let headerLines = headerString.split(separator: "\n", omittingEmptySubsequences: false)
guard headerLines.first == "📦" else {
throw ESMUnwrapError.invalidFormat
}
guard headerLines.count >= 2 else {
throw ESMUnwrapError.invalidFormat
}
let line = headerLines[1]
let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2, let size = Int(parts[0]) else {
throw ESMUnwrapError.invalidHeaderLine(String(line))
}
guard bodyBytes.count >= size else {
throw ESMUnwrapError.sizeOutOfRange
}
let start = bodyBytes.startIndex
let end = bodyBytes.index(start, offsetBy: size)
let fileData = bodyBytes[start..<end]
guard let source = String(data: fileData, encoding: .utf8) else {
throw ESMUnwrapError.encodingError
}
return source
}
private enum ESMUnwrapError: Swift.Error {
case invalidFormat
case headerNotFound
case invalidHeaderLine(String)
case encodingError
case sizeOutOfRange
}