| Rename to nimstatic, and detect dependencies from the source 53cd879 nandi 13h ago | 1 | ## nimstatic — fully static Nim binaries, dependencies and all. |
| 2 | ## |
| 3 | ## nimstatic foo.nim |
| 4 | ## |
| 5 | ## It asks the Nim compiler what `foo.nim` links against (including the |
| 6 | ## libraries Nim would `dlopen` at runtime, which never touch the link line), |
| 7 | ## fetches musl-built static archives for them from Alpine's mirrors, and |
| 8 | ## compiles against a sysroot it owns. No apk, no container, no root. |
| 9 | |
| 10 | import nimstatic/[build, detect, flags, index, repo, sysroot] |
| 11 | export build, detect, flags, index, repo, sysroot |
| 12 | |
| 13 | when isMainModule: |
| 14 | import std/[os, sequtils, strutils, tables] |
| 15 | |
| 16 | const usageText = """ |
| 17 | nimstatic — fully static Nim binaries, dependencies and all |
| 18 | |
| 19 | Usage: |
| 20 | nimstatic <file.nim> [-- <nim args>] Detect, fetch, build static |
| 21 | nimstatic detect <file.nim> Show what it needs, change nothing |
| 22 | nimstatic add <pkg>... Put packages in the sysroot by hand |
| 23 | nimstatic list Show what the sysroot holds |
| 24 | nimstatic libs Show the sysroot's static libraries |
| 25 | nimstatic search <text> Search Alpine's index |
| 26 | nimstatic show <pkg> Index record for one package |
| 27 | nimstatic nimflags [-l lib] Print nim flags for the sysroot |
| 28 | nimstatic ccflags [-l lib] Print cc/clang flags |
| 29 | nimstatic nimcfg [-o file] Write a nim.cfg fragment |
| 30 | nimstatic zigcc [-o file] Write a `zig cc -target …-musl` wrapper |
| 31 | nimstatic env Shell exports (PKG_CONFIG_*, NIMSTATIC_ROOT) |
| 32 | nimstatic path Print the sysroot path |
| 33 | nimstatic clean Remove the sysroot (cache is kept) |
| 34 | |
| 35 | Build options: |
| 36 | -o, --output <path> Binary to write (default: the source's name) |
| 37 | -d, --debug Skip -d:release |
| 38 | -n, --dry-run Print the build command instead of running it |
| 39 | --map <lib=pkg> Map a library to an Alpine package (repeatable) |
| 40 | --pkg <name> Also install this package (repeatable) |
| 41 | --cc <path> Compiler to use instead of a generated zig wrapper |
| 42 | --nim <path> Nim executable (default: nim) |
| 43 | |
| 44 | Sysroot options: |
| 45 | -r, --root <dir> Sysroot (default $XDG_DATA_HOME/nimstatic/sysroot) |
| 46 | -b, --branch <ver> Alpine branch (default v3.21; `edge` for rolling) |
| 47 | -a, --arch <arch> Target arch (default x86_64) |
| 48 | -m, --mirror <url> Mirror base URL |
| 49 | --repo <names> Comma-separated repositories (default main,community) |
| 50 | -l, --lib <name> Add -l<name> to emitted flags (repeatable) |
| 51 | --no-deps Do not pull dependencies (add only) |
| 52 | --refresh Re-fetch the index even if it is fresh |
| 53 | -q, --quiet No progress on stderr |
| 54 | -h, --help Show help |
| 55 | |
| 56 | Everything after `--` goes to the Nim compiler, for both the probe and the |
| 57 | build, so conditional imports resolve the same way twice: |
| 58 | |
| 59 | nimstatic app.nim -- -d:ssl -d:danger |
| 60 | |
| 61 | Packages come over HTTPS and are unpacked as-is; nimstatic does not verify |
| 62 | Alpine's signatures, so treat a sysroot as build input, not as a trust root. |
| 63 | """ |
| 64 | |
| 65 | type Opts = object |
| 66 | root, mirror, branch, arch, cc, output, nimExe: string |
| 67 | repos, libs, args, nimArgs, pkgs: seq[string] |
| 68 | maps: Table[string, string] |
| 69 | noDeps, refresh, quiet, help, debug, dryRun: bool |
| 70 | |
| 71 | proc defaultRoot(): string = getDataDir() / "nimstatic" / "sysroot" |
| 72 | |
| 73 | proc die(msg: string) = |
| 74 | stderr.writeLine "nimstatic: " & msg |
| 75 | quit(1) |
| 76 | |
| 77 | proc parseOpts(): Opts = |
| 78 | ## Hand-rolled so `--root dir`, `--root=dir` and `-r dir` all work, and so |
| 79 | ## everything past `--` can be handed to the compiler untouched. |
| 80 | result = Opts(root: getEnv("NIMSTATIC_ROOT", defaultRoot()), |
| 81 | mirror: defaultMirror, branch: defaultBranch, |
| 82 | arch: defaultArch, repos: defaultRepos, nimExe: "nim") |
| 83 | let argv = commandLineParams() |
| 84 | var i = 0 |
| 85 | while i < argv.len: |
| 86 | let arg = argv[i] |
| 87 | if arg == "--": |
| 88 | result.nimArgs = argv[i + 1 .. ^1] |
| 89 | break |
| 90 | if not arg.startsWith("-") or arg == "-": |
| 91 | result.args.add arg |
| 92 | inc i |
| 93 | continue |
| 94 | var |
| 95 | key = arg.strip(leading = true, trailing = false, chars = {'-'}) |
| 96 | val = "" |
| 97 | attached = false |
| 98 | for sep in ['=', ':']: |
| 99 | let at = key.find(sep) |
| 100 | if at >= 0: |
| 101 | val = key[at + 1 .. ^1] |
| 102 | key = key[0 ..< at] |
| 103 | attached = true |
| 104 | break |
| 105 | |
| 106 | proc takeValue(): string = |
| 107 | if attached: return val |
| 108 | inc i |
| 109 | if i >= argv.len: die "option --" & key & " needs a value" |
| 110 | argv[i] |
| 111 | |
| 112 | case key |
| 113 | of "r", "root": result.root = takeValue() |
| 114 | of "b", "branch": result.branch = takeValue() |
| 115 | of "a", "arch": result.arch = takeValue() |
| 116 | of "m", "mirror": result.mirror = takeValue() |
| 117 | of "repo": result.repos = takeValue().split(',').filterIt(it.len > 0) |
| 118 | of "l", "lib": result.libs.add takeValue() |
| 119 | of "o", "output": result.output = takeValue() |
| 120 | of "cc": result.cc = takeValue() |
| 121 | of "nim": result.nimExe = takeValue() |
| 122 | of "pkg": result.pkgs.add takeValue() |
| 123 | of "map": |
| 124 | let m = takeValue().split('=', 1) |
| 125 | if m.len != 2: die "--map wants lib=package, got: " & m.join("=") |
| 126 | result.maps[m[0]] = m[1] |
| 127 | of "d", "debug": result.debug = true |
| 128 | of "n", "dry-run": result.dryRun = true |
| 129 | of "no-deps": result.noDeps = true |
| 130 | of "refresh": result.refresh = true |
| 131 | of "q", "quiet": result.quiet = true |
| 132 | of "h", "help": result.help = true |
| 133 | else: die "unknown option: " & arg |
| 134 | inc i |
| 135 | |
| 136 | proc emit(o: Opts, text: string) = |
| 137 | if o.output.len > 0: |
| 138 | createDir o.output.parentDir |
| 139 | writeFile(o.output, text) |
| 140 | if not o.quiet: stderr.writeLine "wrote " & o.output |
| 141 | else: |
| 142 | stdout.write text |
| 143 | |
| 144 | proc buildOpts(o: Opts): BuildOpts = |
| 145 | BuildOpts(root: o.root, cc: o.cc, output: o.output, nimExe: o.nimExe, |
| 146 | target: o.arch & "-linux-musl", nimArgs: o.nimArgs, |
| 147 | extraMap: o.maps, extraPackages: o.pkgs, |
| 148 | release: not o.debug, dryRun: o.dryRun, quiet: o.quiet) |
| 149 | |
| 150 | proc cmdBuild(o: Opts, remote: Remote, source: string) = |
| 151 | let res = buildOpts(o).build(remote, source) |
| 152 | if o.dryRun: |
| 153 | echo res.command.quoteShellCommand |
| 154 | return |
| 155 | if not o.quiet: |
| 156 | let size = if fileExists(res.output): getFileSize(res.output) div 1024 else: 0 |
| 157 | stderr.writeLine "wrote " & res.output & " (" & $size & " KiB, static)" |
| 158 | |
| 159 | proc cmdDetect(o: Opts, source: string) = |
| 160 | let d = detect(source, o.nimArgs, o.nimExe, o.maps) |
| 161 | if d.needs.len == 0: |
| 162 | echo "no external libraries needed — plain --passL:-static will do" |
| 163 | return |
| 164 | echo "library".alignLeft(16), "how".alignLeft(9), "alpine package" |
| 165 | for need in d.needs: |
| 166 | echo need.lib.alignLeft(16), |
| 167 | (if need.dynlib: "dlopen" else: "link").alignLeft(9), |
| 168 | (if need.package.len > 0: need.package else: "(unmapped)") |
| 169 | if packages(d).len > 0: |
| 170 | echo "\nnimstatic add ", packages(d).join(" ") |
| 171 | if d.unmapped.len > 0: |
| 172 | echo "\nunmapped: ", d.unmapped.join(", "), |
| 173 | "\nsearch for one with: nimstatic search ", d.unmapped[0] |
| 174 | |
| 175 | proc cmdAdd(o: Opts, remote: Remote) = |
| 176 | if o.args.len == 0: die "add: name at least one package" |
| 177 | let pkgs = remote.fetchIndex(o.refresh).resolve(o.args, not o.noDeps) |
| 178 | for pkg in pkgs: |
| 179 | unpack(remote.fetchPackage(pkg), o.root) |
| 180 | record(o.root, pkgs) |
| 181 | if not o.quiet: |
| 182 | stderr.writeLine "unpacked " & $pkgs.len & " package(s) into " & o.root |
| 183 | |
| 184 | proc cmdList(o: Opts) = |
| 185 | let entries = readManifest(o.root) |
| 186 | if entries.len == 0: |
| 187 | stderr.writeLine "nothing installed in " & o.root |
| 188 | return |
| 189 | for e in entries: |
| 190 | echo e.name.alignLeft(32), e.version.alignLeft(20), e.repo |
| 191 | |
| 192 | proc cmdSearch(o: Opts, remote: Remote) = |
| 193 | if o.args.len == 0: die "search: give some text" |
| 194 | let |
| 195 | idx = remote.fetchIndex(o.refresh) |
| 196 | needle = o.args.join(" ").toLowerAscii |
| 197 | var hits = 0 |
| 198 | for name, pkg in idx.packages: |
| 199 | if needle in name.toLowerAscii or needle in pkg.description.toLowerAscii: |
| 200 | echo name.alignLeft(34), pkg.version.alignLeft(16), pkg.description |
| 201 | inc hits |
| 202 | if hits == 0: stderr.writeLine "no matches for " & needle |
| 203 | |
| 204 | proc cmdShow(o: Opts, remote: Remote) = |
| 205 | if o.args.len == 0: die "show: name a package" |
| 206 | let idx = remote.fetchIndex(o.refresh) |
| 207 | for want in o.args: |
| 208 | let name = idx.find(want) |
| 209 | if name.len == 0: die "no package provides '" & want & "'" |
| 210 | let pkg = idx.packages[name] |
| 211 | echo "name: ", pkg.name |
| 212 | echo "version: ", pkg.version |
| 213 | echo "repository: ", pkg.repo, "/", remote.arch |
| 214 | echo "size: ", pkg.size div 1024, " KiB" |
| 215 | echo "description: ", pkg.description |
| 216 | if pkg.depends.len > 0: echo "depends: ", pkg.depends.join(" ") |
| 217 | if pkg.provides.len > 0: echo "provides: ", pkg.provides.join(" ") |
| 218 | echo "url: ", remote.repoUrl(pkg.repo), "/", apkFile(pkg) |
| 219 | |
| 220 | proc main() = |
| 221 | let o = parseOpts() |
| 222 | if o.help or o.args.len == 0: |
| 223 | stdout.write usageText |
| 224 | quit(if o.help: 0 else: 1) |
| 225 | let |
| 226 | head = o.args[0] |
| 227 | rest = block: |
| 228 | var r = o |
| 229 | r.args = o.args[1 .. ^1] |
| 230 | r |
| 231 | remote = initRemote(o.mirror, o.branch, o.arch, o.repos, quiet = o.quiet) |
| 232 | |
| 233 | # A .nim path is the whole point, so it needs no subcommand. |
| 234 | if head.endsWith(".nim") or fileExists(head): |
| 235 | cmdBuild(o, remote, head) |
| 236 | return |
| 237 | |
| 238 | case head |
| 239 | of "build": |
| 240 | if rest.args.len == 0: die "build: name a .nim file" |
| 241 | cmdBuild(rest, remote, rest.args[0]) |
| 242 | of "detect": |
| 243 | if rest.args.len == 0: die "detect: name a .nim file" |
| 244 | cmdDetect(rest, rest.args[0]) |
| 245 | of "add": cmdAdd(rest, remote) |
| 246 | of "list": cmdList(rest) |
| 247 | of "libs": stdout.write describe(rest.root) |
| 248 | of "search": cmdSearch(rest, remote) |
| 249 | of "show": cmdShow(rest, remote) |
| 250 | of "nimflags": |
| 251 | echo nimFlags(rest.root, overridesIn(rest.root), rest.libs).join(" ") |
| 252 | of "ccflags": echo ccFlags(rest.root, rest.libs).join(" ") |
| 253 | of "nimcfg": |
| 254 | rest.emit nimCfg(rest.root, rest.cc, overridesIn(rest.root), rest.libs) |
| 255 | of "zigcc": |
| 256 | let path = if rest.output.len > 0: rest.output else: "zigcc" |
| 257 | writeZigCc(path, rest.arch & "-linux-musl") |
| 258 | if not rest.quiet: stderr.writeLine "wrote " & path |
| 259 | of "env": |
| 260 | for kv in pkgConfigEnv(rest.root): |
| 261 | echo "export ", kv |
| 262 | echo "export NIMSTATIC_ROOT=", rest.root |
| 263 | of "path": echo rest.root |
| 264 | of "clean": |
| 265 | removeDir rest.root |
| 266 | if not rest.quiet: stderr.writeLine "removed " & rest.root |
| 267 | else: die "unknown command: " & head & " (try --help)" |
| 268 | |
| 269 | try: |
| 270 | main() |
| 271 | except CatchableError as e: |
| 272 | die e.msg |