feat(nwsync): upload sink, key-addressed CLI, fail-closed publication marker
ci / ci (pull_request) Successful in 3m21s

Resolves the build half of sow-tools#60 and closes the CLI gap sow-tools#53's
sweep found: PR #71 shipped #53's original surface, not the one #56, #62 and
#65 settled.

- `depot.KeyStore` (`ProbeKey`/`PutReader`/`GetKey`) addresses the zone by
  object key instead of by depot sha, reusing the existing IPv4-pinned
  transport, retry and tri-state probe. The sha-addressed `Backend` now rides
  on it; no new HTTP client.
- `nwsync` gains a sink: the zone by default, a local tree with `--out DIR` as
  the conformance path. Blobs upload as they are produced, the index lands
  last, and a blob already in the zone is skipped without paying for
  compression.
- CLI is now `emit [--as NAME] [--out DIR] <artifact-key> <file>` and
  `assemble --group-id N [--tlk-key KEY] [--out DIR] <artifact-key>...`.
  Indexes live beside their artifact with the extension replaced, derived in
  one place. Flags may follow positionals, which cost a run during sow-tools#59.
- Fail-closed: an artifact key whose digest does not match the file is refused,
  and `assemble` refuses an artifact with no index rather than publishing a
  manifest missing a hak.

Conformance re-checked through the new CLI against upstream nwn_nwsync_write
2.1.2 on sow_vfxs_01.hak: the manifest is still byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 20:16:44 +02:00
co-authored by Claude Opus 5
parent a131b25e5b
commit f2a0d6a8d1
9 changed files with 841 additions and 203 deletions
+54 -43
View File
@@ -12,7 +12,6 @@ import (
"flag"
"fmt"
"io"
"strings"
)
const (
@@ -45,37 +44,63 @@ func Run(args []string, stdout, stderr io.Writer) int {
func printRunUsage(w io.Writer) {
fmt.Fprint(w, `usage:
nwsync emit <artifact> --out DIR
nwsync assemble --order NAMES --entries DIR --out DIR [--group-id N]
nwsync emit [--as NAME] [--out DIR] <artifact-key> <file>
nwsync assemble --group-id N [--tlk-key KEY] [--out DIR] <artifact-key>...
emit explodes one .hak/.erf or one loose file (the TLK) into NWSync blobs plus
a NSYM manifest covering only that artifact. assemble merges those per-artifact
manifests into one, reading no bulk data.
a NSYM index covering only that artifact, and uploads both. assemble merges
those indexes into one manifest, reading no bulk data. Artifact keys are depot
keys; an index lives beside its artifact, with the extension replaced.
--out DIR writes to a local repository tree instead of uploading, which is the
conformance path against upstream nwn_nwsync_write. Without it, the zone comes
from NWSYNC_STORAGE_ZONE, NWSYNC_STORAGE_PASSWORD and BUNNY_STORAGE_HOST.
`)
}
// parseArgs parses flags that may appear before, after or between positionals.
// Go's flag package stops at the first non-flag argument, which turns
// `emit <key> <file> --out DIR` into a confusing arity error.
func parseArgs(fs *flag.FlagSet, args []string) ([]string, error) {
var positional []string
for {
if err := fs.Parse(args); err != nil {
return nil, err
}
rest := fs.Args()
if len(rest) == 0 {
return positional, nil
}
positional = append(positional, rest[0])
args = rest[1:]
}
}
func runEmit(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("emit", flag.ContinueOnError)
fs.SetOutput(stderr)
out := fs.String("out", "", "output directory (blobs plus the per-artifact manifest)")
if err := fs.Parse(args); err != nil {
as := fs.String("as", "", "published name of the artifact, when it differs from the key")
out := fs.String("out", "", "write to a local repository tree instead of uploading")
positional, err := parseArgs(fs, args)
if err != nil {
return exitUsage
}
if fs.NArg() != 1 {
fmt.Fprintf(stderr, "nwsync emit: exactly one artifact is required\n")
return exitUsage
}
if *out == "" {
fmt.Fprintf(stderr, "nwsync emit: --out is required\n")
if len(positional) != 2 {
fmt.Fprintf(stderr, "nwsync emit: <artifact-key> and <file> are both required\n")
return exitUsage
}
result, err := Emit(fs.Arg(0), *out)
result, err := Emit(EmitOptions{
ArtifactKey: positional[0],
ArtifactPath: positional[1],
As: *as,
OutDir: *out,
})
if err != nil {
fmt.Fprintf(stderr, "nwsync emit: %v\n", err)
return exitInternal
}
fmt.Fprintf(stdout, "emitted %s: %d resources, %d new blobs, manifest %s\n",
fmt.Fprintf(stdout, "emitted %s: %d resources, %d new blobs, index %s\n",
result.Name, result.Entries, result.BlobsWritten, result.ManifestPath)
return exitOK
}
@@ -83,47 +108,33 @@ func runEmit(args []string, stdout, stderr io.Writer) int {
func runAssemble(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("assemble", flag.ContinueOnError)
fs.SetOutput(stderr)
order := fs.String("order", "", "comma-separated artifact names, highest priority first")
entries := fs.String("entries", "", "directory holding the per-artifact .nsym files")
out := fs.String("out", "", "repository root; the manifest lands in <out>/manifests")
tlkKey := fs.String("tlk-key", "", "depot key of the TLK, which shadows nothing and merges last")
out := fs.String("out", "", "write to a local repository tree instead of uploading")
groupID := fs.Int("group-id", 0, "NWSync group id (1 = current, 2 = testing; 0 omits it)")
moduleName := fs.String("module-name", "", "module name recorded in the sidecar")
description := fs.String("description", "", "description recorded in the sidecar")
if err := fs.Parse(args); err != nil {
positional, err := parseArgs(fs, args)
if err != nil {
return exitUsage
}
switch {
case *order == "":
fmt.Fprintf(stderr, "nwsync assemble: --order is required\n")
if len(positional) == 0 {
fmt.Fprintf(stderr, "nwsync assemble: at least one artifact key is required\n")
return exitUsage
case *entries == "":
fmt.Fprintf(stderr, "nwsync assemble: --entries is required\n")
return exitUsage
case *out == "":
fmt.Fprintf(stderr, "nwsync assemble: --out is required\n")
return exitUsage
}
names := make([]string, 0, 8)
for _, name := range strings.Split(*order, ",") {
if name = strings.TrimSpace(name); name != "" {
names = append(names, name)
}
}
result, err := Assemble(AssembleOptions{
Order: names,
EntriesDir: *entries,
OutDir: *out,
GroupID: *groupID,
ModuleName: *moduleName,
Description: *description,
ArtifactKeys: positional,
TLKKey: *tlkKey,
OutDir: *out,
GroupID: *groupID,
ModuleName: *moduleName,
Description: *description,
})
if err != nil {
fmt.Fprintf(stderr, "nwsync assemble: %v\n", err)
return exitInternal
}
fmt.Fprintf(stdout, "assembled %s: %d resources from %d artifacts\n",
result.SHA1, result.Entries, len(names))
fmt.Fprintf(stdout, "assembled manifest %s: %d resources, %s\n",
result.SHA1, result.Entries, result.ManifestPath)
return exitOK
}