~ehmry/nim_lk

536f728116c400ef4adf566fb0881e6b1b798719 — Emery Hemingway 6 months ago e29542d
New subcommand "update"
2 files changed, 140 insertions(+), 42 deletions(-)

M fish_completions/nim_lk.fish
M pkg/nim_lk.nim
M fish_completions/nim_lk.fish => fish_completions/nim_lk.fish +7 -2
@@ 1,6 1,6 @@
# Completions for the `nim_lk` command

set --local commands nimble-to-nix nimble-to-sbom
set --local commands nimble-to-nix nimble-to-sbom update

complete --command nim_lk \
	--no-files


@@ 10,5 10,10 @@ complete --command nim_lk \
	--arguments "$commands"

complete --command nim_lk \
	--condition "__fish_seen_subcommand_from $commands" \
	--condition "__fish_seen_subcommand_from nimble-to-nix nimble-to-sbom" \
	--arguments '(__fish_complete_directories (commandline -ct) "Nimble package directory")'

complete --command nim_lk \
	--condition "__fish_seen_subcommand_from update" \
	--arguments '(__fish_complete_suffix sbom.json)' \
	--short-option c --long-option component

M pkg/nim_lk.nim => pkg/nim_lk.nim +133 -40
@@ 85,10 85,13 @@ proc startProcess(cmd: string; cmdArgs: varargs[string]): Process =
  # stderr.writeLine(cmd, " ", join(cmdArgs, " "))
  startProcess(cmd, args = cmdArgs, options = {poUsePath})

proc gitLsRemote(url: string; withTags: bool): seq[tuple[tag: string, rev: string]] =
type GitPair = object
  `ref`, `rev`: string

proc gitLsRemote(url: string; tagsArg = false): seq[GitPair] =
  var line, rev, refer: string
  var process =
    if withTags:
    if tagsArg:
      startProcess("git", "ls-remote", "--tags", url)
    else:
      startProcess("git", "ls-remote", url)


@@ 102,32 105,32 @@ proc gitLsRemote(url: string; withTags: bool): seq[tuple[tag: string, rev: strin
      headsTags = "refs/heads/"
    if refer.startsWith(refsTags):
      refer.removePrefix(refsTags)
      result.add((refer, rev,))
      result.add GitPair(`ref`: refer, `rev`: rev)
    elif refer.startsWith(headsTags):
      refer.removePrefix(headsTags)
      result.add((refer, rev,))
      result.add GitPair(`ref`: refer, `rev`: rev)
  stderr.write(process.errorStream.readAll)
  close(process)
  if withTags and result.len == 0:
    result = gitLsRemote(url, not withTags)
  if tagsArg and result.len == 0:
    result = gitLsRemote(url, not tagsArg)

proc matchRev(url: string; wanted: VersionRange): tuple[tag: string, rev: string] =
proc matchRev(url: string; wanted: VersionRange): GitPair =
  if wanted.kind == verSpecial:
    let special = $wanted.spe
    if special[0] == '#':
      result[1] = special[1..special.high]
      result.rev = special[1..special.high]
    else:
      quit("unhandled version " & url & " " & $wanted)
  else:
    let withTags = wanted.kind != verAny
    let pairs = gitLsRemote(url, withTags)
    var resultVersion: Version
    for (tag, rev) in pairs:
    for pair in pairs:
      try:
        var tagVer = tag.newVersion
        var tagVer = pair.`ref`.newVersion
        if tagVer.withinRange(wanted) and resultVersion < tagVer:
          resultVersion = tagVer
          result = (tag, rev)
          result = pair
      except ParseVersionError: discard
    if result.rev == "" and pairs.len > 0:
      result = pairs[pairs.high]


@@ 144,26 147,16 @@ proc collectMetadata(data: JsonNode) =
    quit("no .nimble files found in " & storePath)
  packageNames.sort()
  data{"packages"} = %packageNames
  var
    nimbleFilePath = findNimbleFile(storePath, true)
    pkg = getPkgInfoFromFile(nimbleFilePath, nimbleOptions)
  var nimbleFilePath = storePath.findNimbleFile(true)
  var pkg = getPkgInfoFromFile(nimbleFilePath, nimbleOptions)
  data{"srcDir"} = %pkg.srcDir

proc prefetchGit(uri: Uri; version: VersionRange): JsonNode =
  var
    uri = uri
    subdir = ""
  uri.scheme.removePrefix("git+")
  if uri.query != "":
    if uri.query.startsWith("subdir="):
      subdir = uri.query[7 .. ^1]
    uri.query = ""
  let cloneUrl = $uri
  let (tag, rev) = matchRev(cloneUrl, version)
  var archiveUri = uri
proc prefetchGit(cloneUrl: string; pair: GitPair; subDir = ""): JsonNode =
  assert pair.rev != ""
  var archiveUri = cloneUrl.parseUri
  archiveUri.scheme = "https"
  archiveUri.path.removeSuffix ".git"
  archiveUri.path = archiveUri.path / "archive" / rev & ".tar.gz"
  archiveUri.path = archiveUri.path / "archive" / pair.rev & ".tar.gz"
  let client = newHttpClient()
  defer: close(client)
  let


@@ 185,7 178,7 @@ proc prefetchGit(uri: Uri; version: VersionRange): JsonNode =
      {
        "method":  "fetchzip",
        "path":  storePath,
        "rev":  rev,
        "rev":  pair.rev,
        "sha256":  hash,
        "url":  archiveUrl
      }


@@ 194,7 187,7 @@ proc prefetchGit(uri: Uri; version: VersionRange): JsonNode =
        # a Nim attribute not used by the fetcher
  else:
    stderr.writeLine "fetch of ", archiveUrl, " returned ", resp.code
    var args = @["--quiet", "--fetch-submodules", "--url", cloneUrl, "--rev", rev]
    var args = @["--quiet", "--fetch-submodules", "--url", cloneUrl, "--rev", pair.rev]
    stderr.writeLine "prefetch ", cloneUrl
    let dump = execProcess(
      "nix-prefetch-git",


@@ 208,10 201,32 @@ proc prefetchGit(uri: Uri; version: VersionRange): JsonNode =
      result{"subdir"} = %subdir
        # a Nim attribute not used by the fetcher
    result{"method"} = %"git"
  if tag != "":
    result{"ref"} = %tag
  if pair.`ref` != "":
    result{"ref"} = %pair.`ref`
  collectMetadata(result)

proc prefetchGit(uri: Uri; version: VersionRange): JsonNode =
  var
    uri = uri
    subdir = ""
  uri.scheme.removePrefix("git+")
  if uri.query != "":
    if uri.query.startsWith("subdir="):
      subdir = uri.query[7 .. ^1]
    uri.query = ""
  let cloneUrl = $uri
  cloneUrl.prefetchGit(cloneUrl.matchRev(version), subdir=subdir)

proc selectGitCommit(url: string): GitPair =
  let pairs = gitLsRemote(url)
  stdout.writeLine "select a tag from ", url, ":"
  for pair in pairs:
    stdout.writeLine "\t", pair.`ref`, "\t", pair.`rev`
  let choice = stdin.readLine.strip
  for pair in pairs:
    if choice == pair.`ref` or choice == pair.`rev`:
      return pair

proc containsPackageUri(lockAttrs: JsonNode; pkgUrl: string): bool =
  for e in lockAttrs.items:
    if e{"url"}.getStr == pkgUrl:


@@ 361,9 376,12 @@ proc queueNimbleDependencies(
        dependsOn.add %dep.bomRef
        queue.addLast dep

proc getComponent(bom: JsonNode; bomRef: string): JsonNode =
proc getComponent(bom: JsonNode; bomRef: string; byName = false): JsonNode =
  for e in bom{"components"}.elems:
    if e{"bom-ref"}.getStr == bomRef: return e
  if result.isNil and byName:
    for e in bom{"components"}.elems:
      if e{"name"}.getStr == bomRef: return e
  return newJObject()

proc getDepends(bom: JsonNode; bomRef: string): JsonNode =


@@ 469,16 487,70 @@ proc generateBom(nimblePath: string): JsonNode =
    if not result.containsComponent(dep.bomRef):
      result.addComponent(dep, queue)

proc replaceProperty(props: JsonNode; key, val: string): bool =
  for pair in props.elems:
    if pair{"name"}.getStr == key:
      if pair{"value"}.getStr == val:
        return false
      else:
        pair{"value"} = %val
        return true
  props.add(%*{ "name": key, "value": val })
  true

proc updateFodData(bom: JsonNode; bomRef, version: string; fodData: JsonNode): bool =
  let
    comp = bom.getComponent(bomRef)
    props = comp{"properties"}
  if comp{"version"}.getStr != version:
    comp{"version"} = %version
    result = true
  for (key, val) in fodData.pairs:
    let vs = val.getStr
    if vs != "":
      result = props.replaceProperty("nix:fod:" & key, vs) or result

proc update(bom: JsonNode; bomRef: string; byName: bool): bool =
  let comp = bom.getComponent(bomRef, byName)
  assert not comp.isNil
  var bomRef = comp{"bom-ref"}.getStr
  assert not bomRef.isNil
  var suitableRefFound = false
  let extRefs = comp{"externalReferences"}
  for extRef in extRefs.elems:
    if extRef{"type"}.getStr == "vcs":
      suitableRefFound = true
      stderr.writeLine "updating ", extRef
      let url = extRef{"url"}.getStr
      if not url.parseUri.isGitUrl:
        stderr.writeLine("assuming ", url, " is a git URL")
      let gitPair = url.selectGitCommit()
      let pkgData = url.prefetchGit gitPair
      result = bom.updateFodData(bomRef, gitPair.`ref`.strip(chars={'v'}), pkgData)
  if not suitableRefFound:
    stderr.writeLine "cannot update from \"externalReferences\": ", extRefs

type Mode = enum
  unspecifiedMode,
  lockFileMode,
  bomFromNimbleMode,
  listComponentsMode
  listComponentsMode,
  updateComponentMode

const nimbleModes = {lockFileMode,  bomFromNimbleMode, updateComponentMode}

const nimbleModes = {lockFileMode,  bomFromNimbleMode}
proc findSbom(path: string): string =
  result = path
  if result.dirExists:
    result = result / "sbom.json"
  if not result.fileExists:
    quit("no file to parse at " & result)

proc parseSbom(path: string): JsonNode = path.findSbom.parseFile

proc main =
  var
    componentArgs: seq[string]
    packagePath: string
    mode = unspecifiedMode
    recursive = true


@@ 498,12 570,18 @@ proc main =
        mode = lockFileMode
      of "list-components", "components", "comps":
        mode = listComponentsMode
      of "update-component", "update":
        mode = updateComponentMode
      else:
        if packagePath != "":
          quit("no more than a single package path may be specified")
        packagePath = key
    of cmdShortOption, cmdLongOption:
      case key
      of "component", "comp", "c":
        if val == "":
          quit("a component must be specified with " & key & ":")
        componentArgs.add val
      of "recursive", "r":
        recursive = true
      of "flat":


@@ 520,7 598,7 @@ proc main =
    packagePath = getCurrentDir()

  if mode in nimbleModes:
    nimbleOptions = parseCmdLine()
    nimbleOptions = initOptions()
    nimbleOptions.nimBin = findExe("nim")
    if packagePath.fileExists:
      packagePath = packagePath.parentDir


@@ 532,13 610,28 @@ proc main =
  of bomFromNimbleMode:
    stdout.writeLine generateBom(packagePath).pretty
  of listComponentsMode:
    if packagePath.existsDir:
      packagePath = packagePath / "sbom.json"
    if not packagePath.existsFile:
      quit("no file to parse at " & packagePath)
    let bom = packagePath.parseFile()
    let bom = packagePath.parseSbom()
    for comp in bom{"components"}.elems:
      stdout.writeLine comp{"bom-ref"}.getStr
  of updateComponentMode:
    var updated = false
    if componentArgs.len == 0:
      quit("update requires at least one --component:… argument")
    let
      sbomPath = packagePath.findSbom()
      bom = sbomPath.parseFile()
    for s in componentArgs:
      if bom.update(s, byName=true):
        updated = true
      else:
        stderr.writeLine "no update to ", s
    if updated:
      var text = bom.pretty
      text.add '\n'
      sbomPath.writeFile text
    else:
      stderr.writeLine "no updates to ", sbomPath

  of unspecifiedMode:
    raiseAssert "mode unspecified"