nodesRunningMe looked for printed lines in what is a JSON list, so every register tool's "also on" hint and every nodes: "all" named no machine.
555 lines
20 KiB
Go
555 lines
20 KiB
Go
// claude-code's bundle (novox/hq design 36, ADR 0183, ADR 0206): a binary the node's runtime launches over
|
|
// stdio as the operator account (ADR 0193) and is the bus for (ADR 0198). It is given its state directory
|
|
// and two files the mesh renders into it (ADR 0192), beside the runtime's own words.
|
|
//
|
|
// At start it renders the agent's managed directory, reports what this node holds as the module's
|
|
// `holdings` state and again whenever the credentials file changes, watches the licence manager's
|
|
// `bindings` state for this node and fetches the token when it says so, and watches the module's
|
|
// `servers` state — every node's MCP server registrations (ADR 0201). node.go holds the logic.
|
|
//
|
|
// stdout is the MCP channel; everything this module says, it says on stderr.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
func say(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "[claude-code] "+format+"\n", args...)
|
|
}
|
|
|
|
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
|
|
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
|
|
func writeManaged(name, content string) (string, error) {
|
|
if strings.HasSuffix(name, "/") {
|
|
return writeManagedTree(strings.TrimSuffix(name, "/"), content)
|
|
}
|
|
path := filepath.Join(ManagedDir, name)
|
|
if was, err := os.ReadFile(path); err == nil && string(was) == content {
|
|
return name + ": unchanged", nil
|
|
}
|
|
staged, err := os.MkdirTemp("", "claude-code-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(staged)
|
|
source := filepath.Join(staged, name)
|
|
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
args := []string{"install", "-D", "-m", "0644", source, path}
|
|
if os.Geteuid() != 0 {
|
|
args = append([]string{"sudo", "-n"}, args...)
|
|
}
|
|
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("%s: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
|
name, ManagedDir, strings.TrimSpace(string(out)))
|
|
}
|
|
return name + ": written", nil
|
|
}
|
|
|
|
// writeManagedTree replaces one directory of the managed directory whole — the plugin's marketplace (ADR
|
|
// 0216) — when what it holds differs from the files given (a JSON map of path to PluginFile). Staged
|
|
// beside, then swapped in as root, so a session never reads half of it.
|
|
func writeManagedTree(name, content string) (string, error) {
|
|
var files map[string]PluginFile
|
|
if err := json.Unmarshal([]byte(content), &files); err != nil {
|
|
return "", err
|
|
}
|
|
target := filepath.Join(ManagedDir, name)
|
|
if sameTree(target, files) {
|
|
return name + "/: unchanged", nil
|
|
}
|
|
staged, err := os.MkdirTemp("", "claude-code-tree-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(staged)
|
|
for rel, f := range files {
|
|
full := filepath.Join(staged, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if f.Executable {
|
|
mode = 0o755
|
|
}
|
|
content := []byte(f.Content)
|
|
if f.From != "" {
|
|
var err error
|
|
if content, err = os.ReadFile(f.From); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if err := os.WriteFile(full, content, mode); err != nil {
|
|
return "", err
|
|
}
|
|
_ = os.Chmod(full, mode)
|
|
}
|
|
_ = os.Chmod(staged, 0o755)
|
|
script := `set -e; rm -rf "$2.next" "$2.old"; cp -r "$1" "$2.next"; chown -R root:root "$2.next"; chmod -R go-w,a+rX "$2.next";
|
|
if [ -d "$2" ]; then mv "$2" "$2.old"; fi; mv "$2.next" "$2"; rm -rf "$2.old"`
|
|
args := []string{"sh", "-c", script, "sh", staged, target}
|
|
if os.Geteuid() != 0 {
|
|
args = append([]string{"sudo", "-n"}, args...)
|
|
}
|
|
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
|
|
return "", fmt.Errorf("%s/: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
|
|
name, ManagedDir, strings.TrimSpace(string(out)))
|
|
}
|
|
return fmt.Sprintf("%s/: written, %d file(s)", name, len(files)), nil
|
|
}
|
|
|
|
// sameTree says whether a directory holds exactly these files, with these contents and executable bits.
|
|
func sameTree(dir string, files map[string]PluginFile) bool {
|
|
found := 0
|
|
err := filepath.WalkDir(dir, func(full string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(dir, full)
|
|
f, ok := files[filepath.ToSlash(rel)]
|
|
if !ok {
|
|
return errors.New("not wanted")
|
|
}
|
|
raw, err := os.ReadFile(full)
|
|
info, ierr := d.Info()
|
|
want := []byte(f.Content)
|
|
if f.From != "" {
|
|
if want, err = os.ReadFile(f.From); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err != nil || ierr != nil || !bytes.Equal(raw, want) || (info.Mode()&0o111 != 0) != f.Executable {
|
|
return errors.New("differs")
|
|
}
|
|
found++
|
|
return nil
|
|
})
|
|
return err == nil && found == len(files)
|
|
}
|
|
|
|
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
|
|
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
|
|
|
|
// stateOf adapts the SDK's state to what node.go asks of one.
|
|
type stateOf struct{ s stdio.KeptState }
|
|
|
|
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
|
|
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
|
|
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
|
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
|
e, err := s.s.Get(key)
|
|
if err != nil || e == nil {
|
|
return nil, false, err
|
|
}
|
|
return e.Value, true, nil
|
|
}
|
|
|
|
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
|
|
// tool's question.
|
|
func nodesRunningMe() ([]string, error) {
|
|
raw, err := ask("seat:mesh-controller.modules", map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return NodesRunning(raw, "claude-code"), nil
|
|
}
|
|
|
|
// NodesRunning reads where a module runs from the controller's `modules` answer. The verb runs `module list
|
|
// --json`, so the answer is a JSON list of {module, on}: the lines of the printed list it was once read as
|
|
// never came, and every "also on" hint and every `nodes: "all"` named no machine. The printed form, `<module>
|
|
// … on a, b`, is still read when that is what came.
|
|
func NodesRunning(raw json.RawMessage, module string) []string {
|
|
var listed []struct {
|
|
Module string `json:"module"`
|
|
On []string `json:"on"`
|
|
}
|
|
if json.Unmarshal(verbAnswer(raw), &listed) == nil {
|
|
for _, m := range listed {
|
|
if m.Module == module {
|
|
return m.On
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
text := outputOf(raw)
|
|
if text == "" {
|
|
text = string(raw)
|
|
}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if !strings.HasPrefix(line, module+" ") {
|
|
continue
|
|
}
|
|
_, on, ok := strings.Cut(line, " on ")
|
|
if !ok || strings.TrimSpace(on) == "nothing" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, n := range strings.Split(on, ",") {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fingerprintOfFile(path string) any {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return Fingerprint(string(raw))
|
|
}
|
|
|
|
func status(p Paths) map[string]any {
|
|
creds := ReadCredentials(p.credentials())
|
|
var token any
|
|
if g := GrantOf(creds); g != nil {
|
|
token = map[string]any{"fingerprint": Fingerprint(g.AccessToken), "expiresAt": stamp(g.ExpiresAt), "loginWaiting": HoldsLogin(creds)}
|
|
}
|
|
var managed []map[string]any
|
|
for _, f := range []string{"managed-mcp.json", "managed-settings.json", "CLAUDE.md"} {
|
|
path := filepath.Join(ManagedDir, f)
|
|
managed = append(managed, map[string]any{"file": path, "fingerprint": fingerprintOfFile(path)})
|
|
}
|
|
var licence any
|
|
var b Binding
|
|
if readJSON(p.binding(), &b) {
|
|
licence = b
|
|
}
|
|
names := []string{}
|
|
for n := range Registered(p) {
|
|
names = append(names, n)
|
|
}
|
|
return map[string]any{"node": p.Node, "licence": licence, "token": token, "holdings": HoldingsOf(p),
|
|
"managed": managed, "registered": names}
|
|
}
|
|
|
|
func str(description string) map[string]any {
|
|
return map[string]any{"type": "string", "description": description}
|
|
}
|
|
|
|
// nodesOf reads the tools' `nodes` argument: absent is this node, "all" every node, else a list.
|
|
func nodesOf(v any) []string {
|
|
s, _ := v.(string)
|
|
s = strings.TrimSpace(s)
|
|
switch s {
|
|
case "":
|
|
return nil
|
|
case "all":
|
|
return []string{"all"}
|
|
}
|
|
var out []string
|
|
for _, n := range strings.Split(s, ",") {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, configView *ConfigView) []stdio.Tool {
|
|
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
|
|
out := []stdio.Tool{
|
|
{Name: "claude_code_status",
|
|
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
|
|
Run: func(map[string]any) (any, error) { return status(p), nil }},
|
|
{Name: "claude_code_render",
|
|
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after asking the controller again what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
|
|
Run: func(map[string]any) (any, error) {
|
|
answer := map[string]any{}
|
|
if m, err := AskMeshTools(ask, lookup); err != nil {
|
|
answer["asked"] = "the controller did not answer in full (" + err.Error() + "); what was kept is rendered"
|
|
} else {
|
|
KeepMeshTools(p, m)
|
|
}
|
|
out, err := RenderNow(p, writeManaged)
|
|
answer["rendered"] = out
|
|
return answer, err
|
|
}},
|
|
{Name: "claude_code_guard",
|
|
Description: "The guard on the agent's shell on this machine (novox/hq ADR 0245): what it refuses — ssh to a mesh machine, writing /etc/hosts or /etc/resolv.conf, HOSTALIASES — the machines and the replaced commands it judges with, the \"instead of\" table rendered into the agent's instructions, and its record of what it refused and what the operator's override let through, newest last.",
|
|
Input: map[string]any{"last": map[string]any{"type": "integer", "description": "how many lines of the record (default 50)"}},
|
|
Run: func(a map[string]any) (any, error) {
|
|
last := 50
|
|
if n, ok := a["last"].(float64); ok && n > 0 {
|
|
last = int(n)
|
|
}
|
|
return GuardStatus(p, last), nil
|
|
}},
|
|
{Name: "claude_code_pull",
|
|
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
|
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
|
{Name: "claude_code_grant",
|
|
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
|
Input: map[string]any{"public_key": str("the manager's public key, PEM; the grant opens only with its private half")},
|
|
Run: func(a map[string]any) (any, error) {
|
|
key, _ := a["public_key"].(string)
|
|
if !strings.Contains(key, "PUBLIC KEY") {
|
|
return nil, errors.New("claude_code_grant seals to a public key, and none was given")
|
|
}
|
|
return GrantFor(p, key)
|
|
}},
|
|
{Name: "claude_code_add_api_key",
|
|
Description: "Add an Anthropic API key as a licence (ADR 0209): read from a file on this machine — never typed as an argument — sealed to the licence manager's key, handed over, and the file removed once taken. With use_here, this machine switches to it at once; other machines move with the manager's `switch`.",
|
|
Input: map[string]any{
|
|
"name": str("the licence's name, e.g. api"),
|
|
"file": str("a file on this machine holding the key, e.g. ~/api-key (removed once the manager has it)"),
|
|
"use_here": map[string]any{"type": "boolean", "description": "switch this machine to the new licence"},
|
|
},
|
|
Run: func(a map[string]any) (any, error) {
|
|
name, _ := a["name"].(string)
|
|
file, _ := a["file"].(string)
|
|
if strings.TrimSpace(name) == "" || strings.TrimSpace(file) == "" {
|
|
return nil, errors.New("name and file are required")
|
|
}
|
|
if strings.HasPrefix(file, "~/") {
|
|
file = filepath.Join(p.Home, file[2:])
|
|
}
|
|
useHere, _ := a["use_here"].(bool)
|
|
return AddAPIKey(p, strings.TrimSpace(name), file, useHere, ask)
|
|
}},
|
|
{Name: "claude_code_mcp_list",
|
|
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
|
Run: func(map[string]any) (any, error) {
|
|
keys, err := servers.Keys()
|
|
return map[string]any{"here": Registered(p), "everywhere": keys}, err
|
|
}},
|
|
{Name: "claude_code_mcp_register",
|
|
Description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
|
|
Input: map[string]any{
|
|
"name": str("the server's name: letters, digits, - and _"),
|
|
"type": str("http, sse or stdio (default stdio when a command is given, http when a url is)"),
|
|
"url": str("an http or sse server's url"),
|
|
"command": str("a stdio server's program"),
|
|
"args": map[string]any{"type": "array", "description": "a stdio server's arguments"},
|
|
"env": map[string]any{"type": "object", "description": "a stdio server's environment"},
|
|
"headers": map[string]any{"type": "object", "description": "an http server's headers"},
|
|
"nodes": nodesArg,
|
|
},
|
|
Run: func(a map[string]any) (any, error) {
|
|
entry := map[string]any{}
|
|
if t, _ := a["type"].(string); t != "" {
|
|
entry["type"] = t
|
|
} else if _, hasURL := a["url"]; hasURL {
|
|
entry["type"] = "http"
|
|
} else {
|
|
entry["type"] = "stdio"
|
|
}
|
|
for _, k := range []string{"url", "command", "args", "env", "headers"} {
|
|
if v, ok := a[k]; ok {
|
|
entry[k] = v
|
|
}
|
|
}
|
|
name, _ := a["name"].(string)
|
|
return RegisterServer(p, Registration{Name: name, Entry: entry, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
|
}},
|
|
{Name: "claude_code_mcp_unregister",
|
|
Description: "Remove an MCP server registered through this module, on this node or more.",
|
|
Input: map[string]any{"name": str("the server's name"), "nodes": nodesArg},
|
|
Run: func(a map[string]any) (any, error) {
|
|
name, _ := a["name"].(string)
|
|
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
|
|
}},
|
|
}
|
|
return append(out, configTools(p, config, configView)...)
|
|
}
|
|
|
|
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
|
|
func persist(what string, attempt func() error, done func(refusals int)) {
|
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
|
for n := 0; ; n++ {
|
|
err := attempt()
|
|
if err == nil {
|
|
done(n)
|
|
return
|
|
}
|
|
pause := time.Minute
|
|
if n < len(waits) {
|
|
pause = waits[n]
|
|
}
|
|
say("%s not yet (%v); asking again in %s", what, err, pause)
|
|
time.Sleep(pause)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
// The guard on the agent's shell (novox/hq ADR 0245): this binary, run by the agent's hook.
|
|
if len(os.Args) == 3 && os.Args[1] == "guard" {
|
|
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
|
|
}
|
|
p, launched := PathsFrom(os.Getenv)
|
|
if !launched {
|
|
// Outside a launch — a build, a check — it serves nothing and says why.
|
|
say("not launched by the runtime with this module's words; serving no tools")
|
|
if err := stdio.Serve("", nil); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
if _, err := Keypair(p); err != nil {
|
|
say("this module's key: %v", err)
|
|
}
|
|
if out, err := RenderNow(p, writeManaged); err != nil {
|
|
say("%v", err)
|
|
} else {
|
|
for _, line := range out {
|
|
if !strings.HasSuffix(line, "unchanged") {
|
|
say("%s", line)
|
|
}
|
|
}
|
|
}
|
|
go refreshMeshTools(p)
|
|
servers := stateOf{stdio.State("servers")}
|
|
view := NewServerView(p)
|
|
config := stateOf{stdio.State("config")}
|
|
configView := NewConfigView(p)
|
|
go run(p, view, configView)
|
|
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
|
say("%v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// run is the module's long-running half, beside the tools (ADR 0198).
|
|
func run(p Paths, view *ServerView, configView *ConfigView) {
|
|
// The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change.
|
|
go persist("watching the agent's configuration", func() error {
|
|
keys, err := stdio.State("config").Keys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if configView.Prune(keys) {
|
|
if _, err := RenderNow(p, writeManaged); err != nil {
|
|
say("rendering after what was removed while away: %v", err)
|
|
}
|
|
}
|
|
return stdio.State("config").Watch("", func(c stdio.StateChange) error {
|
|
var item *Item
|
|
if c.Op == "put" {
|
|
item = &Item{}
|
|
if json.Unmarshal(c.Value, item) != nil {
|
|
item = nil
|
|
}
|
|
}
|
|
if !configView.Take(c.Key, c.Op, item) {
|
|
return nil
|
|
}
|
|
if out, err := RenderNow(p, writeManaged); err != nil {
|
|
say("taking %s %s: %v", c.Op, c.Key, err)
|
|
} else {
|
|
say("took %s %s", c.Op, c.Key)
|
|
for _, line := range out {
|
|
if !strings.HasSuffix(line, "unchanged") {
|
|
say("%s", line)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching the agent's configuration%s", refusals(n)) })
|
|
|
|
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
|
|
go persist("watching the MCP servers", func() error {
|
|
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
|
|
var value map[string]any
|
|
_ = json.Unmarshal(c.Value, &value)
|
|
if done, err := OnServerChange(view, ServerChange{Key: c.Key, Op: c.Op, Value: value}, p, writeManaged); err != nil {
|
|
say("taking %s %s: %v", c.Op, c.Key, err) // the view took it; the next render writes it
|
|
} else if done != "" {
|
|
say("%s", done)
|
|
}
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching the MCP servers%s", refusals(n)) })
|
|
|
|
// What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on
|
|
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
|
// the old inode would go quiet. Fingerprints and expiries only.
|
|
holdings := stdio.State("holdings")
|
|
reported := ""
|
|
report := func() {
|
|
now := HoldingsOf(p)
|
|
raw, _ := json.Marshal(now)
|
|
if string(raw) == reported {
|
|
return
|
|
}
|
|
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
|
reported = string(raw)
|
|
account := "no account"
|
|
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
|
account = now.Identity.EmailAddress
|
|
}
|
|
line := "reported: " + account
|
|
if now.Kind != nil {
|
|
line += ", " + *now.Kind
|
|
}
|
|
if now.Refresh.Present {
|
|
line += ", a login waiting"
|
|
}
|
|
if now.Licence != nil {
|
|
line += fmt.Sprintf(", licence %s g%d", *now.Licence, now.Generation)
|
|
}
|
|
say("%s", line)
|
|
})
|
|
}
|
|
|
|
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
|
// generation is fetched with the seat's `current`, sealed to this module's key.
|
|
go persist("watching this node's licence binding", func() error {
|
|
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
|
if c.Key != p.Node {
|
|
return nil
|
|
}
|
|
var b *BindingState
|
|
if c.Op == "put" {
|
|
b = &BindingState{}
|
|
if err := json.Unmarshal(c.Value, b); err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
|
say("fetching this node's token failed: %v", err)
|
|
} else if done != "" {
|
|
say("%s", done)
|
|
}
|
|
go report()
|
|
return nil
|
|
})
|
|
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
|
|
|
report()
|
|
var last string
|
|
for range time.Tick(5 * time.Second) {
|
|
info, err := os.Stat(p.credentials())
|
|
now := "absent"
|
|
if err == nil {
|
|
now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size())
|
|
}
|
|
if now != last {
|
|
last = now
|
|
report()
|
|
}
|
|
}
|
|
}
|
|
|
|
func refusals(n int) string {
|
|
if n == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(" (after %d refusal(s))", n)
|
|
}
|