//! `koh offer` — send the local saves to this machine's remote, and, with //! `--builds`, publish a release built from them. module cli import json2 import os import src.build import src.exchange import src.fmt import src.history import src.machine import src.release import src.remote import src.storage pub fn cmd_offer(raw []string) int { args := parse('offer', raw, Options{ values: ['notes'] flags: ['json', 'builds'] }) or { return usage_error(err.msg()) } // `--notes ""` is still `--notes`, so the flag's presence decides, not its // value; and offer takes no arguments at all. if args.positional.len > 0 { return usage_error('offer takes no arguments, not "${args.positional_at(0)}"') } if args.value_or_none('notes') != none && !args.flag('builds') { return usage_error('--notes belongs to --builds') } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } source := exchange.source_of(store) if source != '' { host := exchange.source_host(source) if !exchange.signed_in_to(host) { return refuse(args.json(), 'this repo came from ${source}, which is read-only. Sign in with "koh remote ${host}" if it\'s yours.') } // Signed in since it was stolen: the bucket's saves come in first, by the steal a person // would run, so a clash is asked about on a terminal and refused without one. mut bucket := this_machine_backend() or { return refuse(args.json(), err.msg()) } mut options := exchange.StealOptions{} if on_terminal(args.json()) { options.guide = guide_steal } report := exchange.steal(store, mut bucket, options) or { if err is exchange.ClashError { return refuse(args.json(), '${err.paths.join(', ')} changed on both machines — run "koh steal" to choose, then offer') } return refuse(args.json(), err.msg()) } exchange.forget_source(store) or { return refuse(args.json(), err.msg()) } if !args.json() { ok('signed in to ${host}: this repo now syncs with your bucket') if report.previous_head != '' { note('to undo the combine: koh load ${report.previous_head}') } note_kept_versions(report) warn_flagged(report.flagged) } } mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) } if args.flag('builds') { return offer_builds(args, store, mut backend) } report := exchange.offer(store, mut backend) or { return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(OfferedJson{ repo: report.repo saves: report.saves objects: report.objects events: report.events head: report.head })) return 0 } if report.saves + report.objects + report.events == 0 { ok('the remote already has everything from [${report.head}]') return 0 } ok('offered ${report.saves} saves · ${report.objects} objects · ${report.events} events to "${report.repo}"') note('remote head is now [${report.head}]') return 0 } // offer_builds is `koh offer --builds`: the history goes up first, then a // release built from the working tree. Unsaved changes are refused, so the // published files always belong to a save that is on the remote. fn offer_builds(args Args, store &storage.Store, mut backend remote.Backend) int { head := history.head(store) or { return refuse(args.json(), exchange.no_saves) } if exchange.dirty(store, head) or { true } { return refuse(args.json(), 'save or checkpoint first — a release is built from a save') } report := exchange.offer(store, mut backend) or { return refuse(args.json(), err.msg()) } config := build.read_config(store.root) or { return refuse(args.json(), err.msg()) } // Not `version`: that is the koh binary's own version, in this module. release_version := build.project_version(store.root) or { return refuse(args.json(), err.msg()) } notes := args.value('notes') release.check_notes(notes) or { return refuse(args.json(), err.msg()) } published := release.publish(store, mut backend, report.repo, config, release_version, notes) or { return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(PublishedJson{ repo: report.repo version: published.version files: published.files.map(PublishedFileJson{ platform: it.platform name: it.name size: it.size blake3: it.blake3 }) })) return 0 } for file in published.files { ok('${file.platform} ${file.name} (${fmt.human_bytes(file.size)})') } ok('published ${published.version} of "${report.repo}" from [${report.head}]') return 0 } // this_machine_backend builds the backend from the machine's saved remote. pub fn this_machine_backend() !remote.Backend { settings := machine.load_remote()! if settings.kind == machine.kind_folder { // Only `koh remote` creates the folder. Missing later, it is usually a // disk or share that is not mounted, and a stand-in folder would take // saves the other machine never sees. if !os.is_dir(settings.path) { return error('the remote folder ${settings.path} is not there — is its disk mounted? ("koh remote" changes it)') } return remote.new_folder(settings.path)! } credentials := machine.load_credentials()! return remote.new_s3(settings.endpoint, settings.bucket, settings.region, credentials.access_key, credentials.secret_key)! } // auto_offer runs after every save and checkpoint. A remote that is not set up, // or not reachable, must never fail the save: it prints one line and stops. // `quiet` is for `--json`, where that line would land inside the document. pub fn auto_offer(store &storage.Store, quiet bool) { if !machine.has_remote() { return } // A stolen repo rides its source: a save in it is local work and nothing else. The upgrade is // the explicit `steal` or `offer`, which bring the bucket in *before* the source is dropped. if exchange.source_of(store) != '' { return } mut backend := this_machine_backend() or { if !quiet { note('not offered: ${err.msg()}') } return } report := exchange.offer(store, mut backend) or { if !quiet { note('not offered: ${err.msg()}') } return } if !quiet && report.saves + report.objects + report.events > 0 { note('offered to "${report.repo}" — ${report.saves} saves · ${report.objects} objects') } } struct OfferedJson { repo string saves int objects int events int head string } struct PublishedFileJson { platform string name string size i64 blake3 string } struct PublishedJson { repo string version string files []PublishedFileJson }