Files
devplacepy/nodes/client/main.go
T
retoorandClaude Sonnet 5.5 02ab747cd0
DevPlace CI / test (push) Failing after 44m45s
Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
Continuous Delivery could never be turned off once a crop was planted. Add a
one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool
game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant,
autoreplant claim or Refactor. Autoreplant now also respects crop locks through
the shared _crop_lock predicate. Plan and measurements in happyplanting.md.

Also includes the pending community sync, node network and related work in the
tree; the full suite passed (4999) with all of it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 02:23:43 +02:00

213 lines
6.3 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
_ "time/tzdata"
)
var Version = "dev"
const (
exitOK = 0
exitError = 1
exitUsage = 2
exitFatal = 3
usageText = `usage: devplace-node <command> [flags]
commands:
login store the server and device secret (--server URL --secret dpn_...)
status show the stored configuration, today's usage and whether the server accepts this device
run connect and serve jobs in the foreground
version print the version
flags for every command: --config PATH (default: <user config dir>/devplace-node/config.json)
`
)
func main() {
os.Exit(execute(os.Args[1:], os.Stdout, os.Stderr))
}
func execute(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, usageText)
return exitUsage
}
command, rest := args[0], args[1:]
switch command {
case "version":
fmt.Fprintln(stdout, "devplace-node", Version)
return exitOK
case "login":
return runLogin(rest, stdout, stderr)
case "status":
return runStatus(rest, stdout, stderr)
case "run":
return runRun(rest, stderr)
case "help", "-h", "--help":
fmt.Fprint(stdout, usageText)
return exitOK
}
fmt.Fprintf(stderr, "unknown command %q\n%s", command, usageText)
return exitUsage
}
func resolveConfigPath(flagValue string) (string, error) {
if flagValue != "" {
return flagValue, nil
}
return DefaultConfigPath()
}
func newFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
set := flag.NewFlagSet(name, flag.ContinueOnError)
set.SetOutput(stderr)
path := set.String("config", "", "config file path")
return set, path
}
func runLogin(args []string, stdout, stderr io.Writer) int {
set, pathFlag := newFlags("login", stderr)
server := set.String("server", "", "server url, for example https://devplace.net")
secret := set.String("secret", "", "device secret (or set DEVPLACE_NODE_SECRET)")
bandwidth := set.Int("bandwidth-mb", DefaultBandwidthMB, "daily bandwidth cap in MB")
concurrent := set.Int("concurrency", DefaultMaxConcurrent, "maximum parallel jobs")
tunnels := set.Int("tunnels", DefaultMaxTunnels, "maximum parallel tunnels")
from := set.String("from", "", "active hours start HH:MM")
to := set.String("to", "", "active hours end HH:MM")
zone := set.String("timezone", "", "IANA timezone for the active hours (default: system)")
skip := set.Bool("no-verify", false, "store without asking the server to accept the secret")
if err := set.Parse(args); err != nil {
return exitUsage
}
if *secret == "" {
*secret = os.Getenv("DEVPLACE_NODE_SECRET")
}
path, err := resolveConfigPath(*pathFlag)
if err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
config := Config{
Server: *server, Secret: *secret, BandwidthMBDay: *bandwidth, MaxConcurrent: *concurrent,
MaxTunnels: *tunnels, ActiveFrom: *from, ActiveTo: *to, Timezone: *zone,
}
if err := config.Validate(); err != nil {
fmt.Fprintln(stderr, "login refused:", err)
return exitUsage
}
if !*skip {
if code := verifyDevice(config, stderr); code != exitOK {
return code
}
}
if err := SaveConfig(path, config); err != nil {
fmt.Fprintln(stderr, "config could not be written:", err)
return exitError
}
fmt.Fprintf(stdout, "stored %s (mode 0600)\n", path)
return exitOK
}
func verifyDevice(config Config, stderr io.Writer) int {
session := NewSession(config, NewPolicy(), nil, nil, nil, NewLogger(stderr, false))
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(TicketTimeoutSeconds+5)*time.Second)
defer cancel()
_, result := session.fetchTicket(ctx)
if result.fatal != "" {
fmt.Fprintln(stderr, "server refused this device:", result.fatal)
return exitFatal
}
if result.reason != "" {
fmt.Fprintln(stderr, "server not verified:", result.reason, "(use --no-verify to store anyway)")
return exitError
}
return exitOK
}
func runStatus(args []string, stdout, stderr io.Writer) int {
set, pathFlag := newFlags("status", stderr)
if err := set.Parse(args); err != nil {
return exitUsage
}
path, err := resolveConfigPath(*pathFlag)
if err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
config, err := LoadConfig(path)
if err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
location, _ := config.Location()
used := ReadUsage(filepath.Join(filepath.Dir(path), UsageFileName), location, time.Now())
fmt.Fprintf(stdout, "version: %s\nconfig: %s\nserver: %s\nsecret: stored\n", Version, path, config.Server)
fmt.Fprintf(stdout, "bandwidth: %d MB/day (used today: %.1f MB)\n", config.BandwidthMBDay, float64(used)/mebibyte)
fmt.Fprintf(stdout, "concurrency: %d jobs, %d tunnels\n", config.MaxConcurrent, config.MaxTunnels)
hours := "always"
if config.ActiveFrom != "" {
hours = config.ActiveFrom + "-" + config.ActiveTo
}
fmt.Fprintf(stdout, "active hours: %s (%s)\n", hours, location)
if code := verifyDevice(config, stderr); code != exitOK {
fmt.Fprintln(stdout, "server: not accepting this device")
return code
}
fmt.Fprintln(stdout, "server: accepts this device")
return exitOK
}
func runRun(args []string, stderr io.Writer) int {
set, pathFlag := newFlags("run", stderr)
verbose := set.Bool("v", false, "verbose logging")
if err := set.Parse(args); err != nil {
return exitUsage
}
path, err := resolveConfigPath(*pathFlag)
if err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
config, err := LoadConfig(path)
if err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
logger := NewLogger(stderr, *verbose)
limiter, err := NewLimiter(config, filepath.Join(filepath.Dir(path), UsageFileName), time.Now)
if err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
limiter.SetBatteryProbe(systemBattery, logger.Debugf)
policy := NewPolicy()
stats := NewStats(time.Now())
session := NewSession(config, policy, limiter, NewFetcher(policy), stats, logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Printf("started version %s, server %s", Version, config.Server)
err = session.Run(ctx)
limiter.Flush()
logger.Printf("%s", stats.Line())
var fatal *FatalError
if errors.As(err, &fatal) {
logger.Printf("%s", fatal.Error())
return exitFatal
}
if err != nil {
logger.Printf("stopped: %v", err)
return exitError
}
return exitOK
}