diff --git a/.gitignore b/.gitignore index a8963ed..aec5fd9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,10 @@ data/ .env tinyforge tinyforge.exe +/cli +/cli.exe server.exe +tinyforge-server.exe docker-watcher docker-watcher.exe docker-watcher.exe~ diff --git a/README.md b/README.md index b257f3c..47c4485 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,46 @@ curl -X POST https://your-domain/api/webhook/ \ 3. Enter your provider's Issuer URL, Client ID, and Client Secret 4. Set the Redirect URL to `https://your-domain/api/auth/oidc/callback` +## CLI + +`tinyforge` is a terminal client for driving a server from the shell, built on the same HTTP API as the web UI. + +### Build + +```bash +go build -o tinyforge ./cmd/cli # ./tinyforge (tinyforge.exe on Windows) +``` + +### Usage + +```bash +# Log in once — caches a 24h token in ~/.tinyforge/config.json (mode 0600) +tinyforge login --base-url http://localhost:8090 +# ...or non-interactively (no password echo / shell-history leak): +TINYFORGE_PASSWORD=… tinyforge login --base-url http://localhost:8090 --user admin + +tinyforge apps # list apps + container state +tinyforge deploy my-app # deploy and wait for completion +tinyforge deploy my-app --ref v1.2.3 --note "hotfix" +tinyforge logs my-app -f # follow logs (Ctrl-C to stop) +tinyforge status # server health + current user +tinyforge status my-app # one app's containers +tinyforge logout # revoke + clear the cached token +``` + +### Server & token resolution + +| Setting | Flag | Env | Default | +| -------- | ------------ | ----------------- | ------------------------ | +| Base URL | `--base-url` | `TINYFORGE_URL` | `http://localhost:8080` | +| Token | `--token` | `TINYFORGE_TOKEN` | cached by `login` | +| Config | `--config` | `TINYFORGE_CONFIG`| `~/.tinyforge/config.json` | + +### Notes + +- Login returns a **24h JWT** — there is no long-lived API token yet, so unattended use re-logs in when the token expires. `deploy` / `stop` / `start` require an **admin** account. +- The token is sent as an `Authorization: Bearer` header (never placed in the URL) and the config file is written with `0600` permissions. + ## Development ```bash diff --git a/cmd/cli/apps.go b/cmd/cli/apps.go new file mode 100644 index 0000000..0c3fee6 --- /dev/null +++ b/cmd/cli/apps.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + "time" +) + +func runApps(args []string) error { + // Accept an optional "list" subcommand: `tinyforge apps` == `tinyforge apps list`. + if len(args) > 0 && args[0] == "list" { + args = args[1:] + } + fs := flag.NewFlagSet("apps", flag.ExitOnError) + g := addGlobalFlags(fs) + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: tinyforge apps [list] [--base-url URL]\n\nList apps (workloads with a source) and their container state.\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + sess, err := newSession(g) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var workloads []Workload + if err := sess.client.doJSON(ctx, "GET", "/api/workloads", nil, &workloads); err != nil { + return err + } + + // One extra call fetches every container so state can be shown without an + // N+1 per-app request. + var containers []Container + if err := sess.client.doJSON(ctx, "GET", "/api/containers", nil, &containers); err != nil { + return err + } + byWorkload := map[string][]Container{} + for _, c := range containers { + byWorkload[c.WorkloadID] = append(byWorkload[c.WorkloadID], c) + } + + apps := make([]Workload, 0, len(workloads)) + for _, w := range workloads { + if w.isApp() { + apps = append(apps, w) + } + } + sort.Slice(apps, func(i, j int) bool { return apps[i].Name < apps[j].Name }) + + if len(apps) == 0 { + fmt.Println("No apps yet. Create one in the web UI, then deploy with 'tinyforge deploy '.") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tSOURCE\tSTATE\tID") + for _, w := range apps { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", w.Name, w.SourceKind, stateSummary(byWorkload[w.ID]), idShort(w.ID)) + } + return tw.Flush() +} + +// stateSummary condenses a workload's containers into one status word. +func stateSummary(cs []Container) string { + if len(cs) == 0 { + return "—" + } + running := 0 + for _, c := range cs { + if c.State == "running" { + running++ + } + } + switch { + case running == len(cs): + return "running" + case running == 0: + return cs[0].State // e.g. stopped / failed / missing + default: + return fmt.Sprintf("%d/%d running", running, len(cs)) + } +} + +// resolveApp maps a user-supplied reference (name, full id, or id prefix) to a +// single app workload. Exact id wins, then exact name, then a unique id prefix. +func resolveApp(ctx context.Context, c *Client, ref string) (Workload, error) { + var workloads []Workload + if err := c.doJSON(ctx, "GET", "/api/workloads", nil, &workloads); err != nil { + return Workload{}, err + } + + var byID, byName, byPrefix []Workload + for _, w := range workloads { + if !w.isApp() { + continue + } + switch { + case w.ID == ref: + byID = append(byID, w) + case strings.EqualFold(w.Name, ref): + byName = append(byName, w) + case len(ref) >= 6 && strings.HasPrefix(w.ID, ref): + byPrefix = append(byPrefix, w) + } + } + + if len(byID) == 1 { + return byID[0], nil + } + if len(byName) == 1 { + return byName[0], nil + } + if len(byName) > 1 { + return Workload{}, ambiguousErr(ref, byName) + } + if len(byPrefix) == 1 { + return byPrefix[0], nil + } + if len(byPrefix) > 1 { + return Workload{}, ambiguousErr(ref, byPrefix) + } + return Workload{}, fmt.Errorf("no app matching %q (try 'tinyforge apps list')", ref) +} + +func ambiguousErr(ref string, matches []Workload) error { + var b strings.Builder + fmt.Fprintf(&b, "%q matches multiple apps; use the id:\n", ref) + for _, w := range matches { + fmt.Fprintf(&b, " %s %s\n", idShort(w.ID), w.Name) + } + return fmt.Errorf("%s", strings.TrimRight(b.String(), "\n")) +} + +func idShort(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} diff --git a/cmd/cli/client.go b/cmd/cli/client.go new file mode 100644 index 0000000..67edf6c --- /dev/null +++ b/cmd/cli/client.go @@ -0,0 +1,232 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +// apiError carries the server's error message plus the HTTP status, so callers +// can distinguish auth failures (401) from other errors without losing the +// server's message (e.g. "invalid credentials" vs "invalid or expired token"). +type apiError struct { + status int + msg string +} + +func (e *apiError) Error() string { return e.msg } + +// isAuthError reports whether err is a 401 from the API. +func isAuthError(err error) bool { + var ae *apiError + return errors.As(err, &ae) && ae.status == http.StatusUnauthorized +} + +// Client talks to the Tinyforge HTTP API. It has no global timeout so that +// long synchronous deploys and follow streams work; callers pass a context +// with the appropriate deadline. +type Client struct { + baseURL string + token string + http *http.Client +} + +func newClient(baseURL, token string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + http: &http.Client{}, + } +} + +// apiEnvelope mirrors the server's response wrapper. The server's struct is +// unexported, so the CLI defines its own matching shape. Data is deferred so a +// single decode path serves every endpoint. +type apiEnvelope struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error string `json:"error"` +} + +// SessionToken is the data payload of POST /api/auth/login. +type SessionToken struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` +} + +// User is the data payload of GET /api/auth/me. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` +} + +// Workload is the subset of the workload row the CLI needs. An "app" is a +// workload with a non-empty SourceKind. +type Workload struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + AppID string `json:"app_id"` + SourceKind string `json:"source_kind"` + CreatedAt string `json:"created_at"` +} + +func (w Workload) isApp() bool { return w.SourceKind != "" } + +// Container is the subset of a container row the CLI needs. State is one of +// running|stopped|failed|missing|starting|created|restarting|paused|... +type Container struct { + ID string `json:"id"` + WorkloadID string `json:"workload_id"` + Role string `json:"role"` + ContainerID string `json:"container_id"` + ImageRef string `json:"image_ref"` + State string `json:"state"` + Port int `json:"port"` + Subdomain string `json:"subdomain"` + CreatedAt string `json:"created_at"` +} + +// DeployResult is the data payload of POST /api/workloads/{id}/deploy. +type DeployResult struct { + WorkloadID string `json:"workload_id"` + Reference string `json:"reference"` + TriggeredBy string `json:"triggered_by"` +} + +// doJSON performs a JSON request and unwraps the response envelope. body may be +// nil. out may be nil when the caller does not need the data payload. A 401 +// maps to errNotAuthenticated; any other non-success surfaces the server's +// error message. +func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) error { + var reqBody io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + reqBody = bytes.NewReader(buf) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + c.authorize(req) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + var env apiEnvelope + if jsonErr := json.Unmarshal(raw, &env); jsonErr != nil { + // Non-JSON body (e.g. a proxy error page). Surface status + a snippet, + // preserving auth-error typing for 401s with an unparseable body. + if resp.StatusCode >= 400 { + return &apiError{status: resp.StatusCode, msg: fmt.Sprintf( + "%s %s: unexpected response (status %d): %s", method, path, resp.StatusCode, snippet(raw))} + } + return fmt.Errorf("%s %s: decode response: %w", method, path, jsonErr) + } + if resp.StatusCode >= 400 || !env.Success { + msg := env.Error + if msg == "" { + msg = fmt.Sprintf("%s %s: request failed (status %d)", method, path, resp.StatusCode) + } + return &apiError{status: resp.StatusCode, msg: msg} + } + if out != nil && len(env.Data) > 0 { + if err := json.Unmarshal(env.Data, out); err != nil { + return fmt.Errorf("decode response data: %w", err) + } + } + return nil +} + +// authorize attaches the bearer token. Using the Authorization header (rather +// than a ?token= query param) keeps the JWT out of server and proxy logs. +func (c *Client) authorize(req *http.Request) { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } +} + +// streamSSE opens an SSE stream and invokes onData for each `data:` payload. +// Comment lines (heartbeats, beginning with ':') and blanks are skipped. The +// stream ends on EOF, context cancellation, or when onData returns an error. +func (c *Client) streamSSE(ctx context.Context, path string, onData func(payload []byte) error) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "text/event-stream") + c.authorize(req) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("GET %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var env apiEnvelope + msg := fmt.Sprintf("GET %s: stream failed (status %d)", path, resp.StatusCode) + if json.Unmarshal(raw, &env) == nil && env.Error != "" { + msg = env.Error + } + return &apiError{status: resp.StatusCode, msg: msg} + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64<<10), 2<<20) // tolerate long log lines + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, ":") { + continue // blank separator or SSE comment/heartbeat + } + data, ok := strings.CutPrefix(line, "data:") + if !ok { + continue // ignore event:/id: fields — the API uses default events + } + if err := onData([]byte(strings.TrimPrefix(data, " "))); err != nil { + return err + } + } + if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("read stream: %w", err) + } + return nil +} + +// snippet returns a short, single-line view of an unexpected response body. +func snippet(b []byte) string { + const max = 200 + s := strings.TrimSpace(string(b)) + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > max { + s = s[:max] + "…" + } + if s == "" { + s = "(empty body)" + } + return s +} diff --git a/cmd/cli/config.go b/cmd/cli/config.go new file mode 100644 index 0000000..92ab85c --- /dev/null +++ b/cmd/cli/config.go @@ -0,0 +1,148 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" +) + +// defaultBaseURL matches the server's default LISTEN_ADDR (:8080). The dev +// server runs on :8090; point at it with --base-url or $TINYFORGE_URL. +const defaultBaseURL = "http://localhost:8080" + +// Config is the persisted CLI state at ~/.tinyforge/config.json. +type Config struct { + BaseURL string `json:"base_url"` + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` +} + +// globals holds the cross-cutting flags every command accepts. +type globals struct { + baseURL *string + token *string + configPath *string +} + +// addGlobalFlags registers the shared flags on a command's flag set. +func addGlobalFlags(fs *flag.FlagSet) *globals { + return &globals{ + baseURL: fs.String("base-url", "", "Tinyforge server URL (default $TINYFORGE_URL or "+defaultBaseURL+")"), + token: fs.String("token", "", "auth token (default $TINYFORGE_TOKEN or cached config)"), + configPath: fs.String("config", "", "config file path (default $TINYFORGE_CONFIG or ~/.tinyforge/config.json)"), + } +} + +// configFilePath resolves the config file location with precedence: +// --config flag > $TINYFORGE_CONFIG > ~/.tinyforge/config.json. +func configFilePath(g *globals) (string, error) { + if g != nil && *g.configPath != "" { + return *g.configPath, nil + } + if env := os.Getenv("TINYFORGE_CONFIG"); env != "" { + return env, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("locate home directory: %w", err) + } + return filepath.Join(home, ".tinyforge", "config.json"), nil +} + +// loadConfig reads the config file. A missing file yields a zero Config and no +// error — first run is not a failure. +func loadConfig(path string) (Config, error) { + var cfg Config + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, fmt.Errorf("read config %s: %w", path, err) + } + // An empty or whitespace-only file (e.g. freshly touched) is treated as + // "no config yet" rather than a parse error. + if len(bytes.TrimSpace(data)) == 0 { + return cfg, nil + } + if err := json.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("parse config %s: %w", path, err) + } + return cfg, nil +} + +// saveConfig writes the config file with 0600 permissions, since it holds a +// bearer token. The parent directory is created if absent. +func saveConfig(path string, cfg Config) error { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encode config: %w", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write config %s: %w", path, err) + } + // os.WriteFile only applies the mode when creating the file; Chmod ensures + // 0600 even when overwriting a pre-existing, looser-permissioned config. + if err := os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("secure config %s: %w", path, err) + } + return nil +} + +// resolveBaseURL applies precedence: --base-url > $TINYFORGE_URL > config > default. +func resolveBaseURL(g *globals, cfg Config) string { + if g != nil && *g.baseURL != "" { + return *g.baseURL + } + if env := os.Getenv("TINYFORGE_URL"); env != "" { + return env + } + if cfg.BaseURL != "" { + return cfg.BaseURL + } + return defaultBaseURL +} + +// resolveToken applies precedence: --token > $TINYFORGE_TOKEN > config. +func resolveToken(g *globals, cfg Config) string { + if g != nil && *g.token != "" { + return *g.token + } + if env := os.Getenv("TINYFORGE_TOKEN"); env != "" { + return env + } + return cfg.Token +} + +// session bundles the resolved client with the loaded config and its path, so +// commands can both make requests and persist updates (e.g. login). +type session struct { + client *Client + cfg Config + configPath string +} + +// newSession loads config and builds a client with resolved base URL + token. +func newSession(g *globals) (*session, error) { + path, err := configFilePath(g) + if err != nil { + return nil, err + } + cfg, err := loadConfig(path) + if err != nil { + return nil, err + } + return &session{ + client: newClient(resolveBaseURL(g, cfg), resolveToken(g, cfg)), + cfg: cfg, + configPath: path, + }, nil +} diff --git a/cmd/cli/deploy.go b/cmd/cli/deploy.go new file mode 100644 index 0000000..90d4b84 --- /dev/null +++ b/cmd/cli/deploy.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" +) + +func runDeploy(args []string) error { + fs := flag.NewFlagSet("deploy", flag.ExitOnError) + g := addGlobalFlags(fs) + ref := fs.String("ref", "", "image tag / git ref / source-specific deploy target") + note := fs.String("note", "", "free-text note recorded with the deploy") + timeout := fs.Duration("timeout", 15*time.Minute, "max time to wait for the deploy to finish") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: tinyforge deploy [--ref TAG] [--note TEXT] [--timeout DUR]\n\n"+ + "Trigger a deploy and wait for it to finish. Requires an admin token.\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + fs.Usage() + return fmt.Errorf("expected exactly one app (name or id)") + } + + sess, err := newSession(g) + if err != nil { + return err + } + + // Resolve the app on a short deadline; the deploy itself gets the full one. + resolveCtx, cancelResolve := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelResolve() + app, err := resolveApp(resolveCtx, sess.client, fs.Arg(0)) + if err != nil { + return err + } + + body := map[string]string{} + if *ref != "" { + body["reference"] = *ref + } + if *note != "" { + body["note"] = *note + } + + fmt.Printf("Deploying %s%s…\n", app.Name, refSuffix(*ref)) + + // The endpoint returns 202 but blocks until the deploy completes, so a + // success here means it finished; allow plenty of time for pull/build. + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + var result DeployResult + if err := sess.client.doJSON(ctx, "POST", "/api/workloads/"+app.ID+"/deploy", body, &result); err != nil { + return err + } + + fmt.Printf("Deploy of %s completed (triggered by %s).\n", app.Name, result.TriggeredBy) + fmt.Printf("Follow with: tinyforge logs %s -f\n", app.Name) + return nil +} + +func refSuffix(ref string) string { + if ref == "" { + return "" + } + return fmt.Sprintf(" @ %s", ref) +} diff --git a/cmd/cli/login.go b/cmd/cli/login.go new file mode 100644 index 0000000..1d386e0 --- /dev/null +++ b/cmd/cli/login.go @@ -0,0 +1,136 @@ +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "os" + "strings" + "time" +) + +func runLogin(args []string) error { + fs := flag.NewFlagSet("login", flag.ExitOnError) + g := addGlobalFlags(fs) + user := fs.String("user", "", "username (prompted if omitted)") + pass := fs.String("password", "", "password (insecure; prefer $TINYFORGE_PASSWORD or the prompt)") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: tinyforge login [--user U] [--password P] [--base-url URL]\n\n"+ + "Authenticate against the server and cache the token.\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + sess, err := newSession(g) + if err != nil { + return err + } + + username := *user + if username == "" { + username, err = promptLine("Username: ") + if err != nil { + return err + } + } + + password := *pass + if password == "" { + password = os.Getenv("TINYFORGE_PASSWORD") + } + if password == "" { + password, err = promptPassword("Password: ") + if err != nil { + return err + } + } + if username == "" || password == "" { + return fmt.Errorf("username and password are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var tok SessionToken + body := map[string]string{"username": username, "password": password} + if err := sess.client.doJSON(ctx, "POST", "/api/auth/login", body, &tok); err != nil { + return err + } + + // Persist the resolved base URL alongside the token so later commands need + // no flags. The token file is written 0600 by saveConfig. + sess.cfg.BaseURL = sess.client.baseURL + sess.cfg.Token = tok.Token + sess.cfg.ExpiresAt = tok.ExpiresAt + if err := saveConfig(sess.configPath, sess.cfg); err != nil { + return err + } + + fmt.Printf("Logged in to %s as %s.\n", sess.client.baseURL, username) + if exp := friendlyExpiry(tok.ExpiresAt); exp != "" { + fmt.Printf("Token valid until %s.\n", exp) + } + return nil +} + +func runLogout(args []string) error { + fs := flag.NewFlagSet("logout", flag.ExitOnError) + g := addGlobalFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + + sess, err := newSession(g) + if err != nil { + return err + } + if sess.client.token == "" { + fmt.Println("Not logged in.") + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Best-effort server-side revocation; clear the local token regardless. + revokeErr := sess.client.doJSON(ctx, "POST", "/api/auth/logout", nil, nil) + + sess.cfg.Token = "" + sess.cfg.ExpiresAt = "" + if err := saveConfig(sess.configPath, sess.cfg); err != nil { + return err + } + + if revokeErr != nil { + fmt.Printf("Cleared local token (server revocation skipped: %v).\n", revokeErr) + return nil + } + fmt.Println("Logged out.") + return nil +} + +// promptLine reads a single trimmed line from stdin. +func promptLine(label string) (string, error) { + fmt.Fprint(os.Stderr, label) + r := bufio.NewReader(os.Stdin) + line, err := r.ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("read input: %w", err) + } + return strings.TrimSpace(line), nil +} + +// friendlyExpiry formats an RFC3339 expiry as a local time, best-effort. +func friendlyExpiry(s string) string { + if s == "" { + return "" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return t.Local().Format("2006-01-02 15:04 MST") +} diff --git a/cmd/cli/logs.go b/cmd/cli/logs.go new file mode 100644 index 0000000..525d21f --- /dev/null +++ b/cmd/cli/logs.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "os/signal" + "strings" + "time" +) + +func runLogs(args []string) error { + fs := flag.NewFlagSet("logs", flag.ExitOnError) + g := addGlobalFlags(fs) + follow := fs.Bool("f", false, "follow the log stream (Ctrl-C to stop)") + tail := fs.Int("tail", 200, "number of trailing lines to show (max 5000)") + container := fs.String("container", "", "container row id/prefix or role (when an app has several)") + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: tinyforge logs [-f] [--tail N] [--container CID]\n\nPrint or follow a container's logs.\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + fs.Usage() + return fmt.Errorf("expected exactly one app (name or id)") + } + + sess, err := newSession(g) + if err != nil { + return err + } + + resolveCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + app, err := resolveApp(resolveCtx, sess.client, fs.Arg(0)) + if err != nil { + return err + } + + var containers []Container + if err := sess.client.doJSON(resolveCtx, "GET", "/api/workloads/"+app.ID+"/containers", nil, &containers); err != nil { + return err + } + target, err := chooseContainer(containers, *container) + if err != nil { + return err + } + + q := url.Values{} + q.Set("tail", fmt.Sprintf("%d", *tail)) + base := "/api/workloads/" + app.ID + "/containers/" + target.ID + "/logs" + + if !*follow { + var lines []string + if err := sess.client.doJSON(resolveCtx, "GET", base+"?"+q.Encode(), nil, &lines); err != nil { + return err + } + for _, line := range lines { + fmt.Println(line) + } + return nil + } + + // Follow: stream until EOF or Ctrl-C. + q.Set("follow", "true") + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + err = sess.client.streamSSE(ctx, base+"?"+q.Encode(), func(payload []byte) error { + var frame struct { + Line string `json:"line"` + } + if json.Unmarshal(payload, &frame) != nil { + return nil // ignore frames we can't parse + } + fmt.Println(frame.Line) + return nil + }) + if ctx.Err() != nil { // user interrupted — clean exit + return nil + } + return err +} + +// chooseContainer selects which container to read. With an explicit selector, +// it matches the row id (exact or prefix) or the role. Otherwise it uses the +// sole container, or the sole running one, and errors with a list when the +// choice is ambiguous. +func chooseContainer(cs []Container, selector string) (Container, error) { + if len(cs) == 0 { + return Container{}, fmt.Errorf("app has no containers yet — deploy it first") + } + + if selector != "" { + var matches []Container + for _, c := range cs { + if c.ID == selector || strings.EqualFold(c.Role, selector) || + (len(selector) >= 6 && strings.HasPrefix(c.ID, selector)) { + matches = append(matches, c) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return Container{}, fmt.Errorf("no container matching %q\n%s", selector, containerList(cs)) + default: + return Container{}, fmt.Errorf("%q matches multiple containers\n%s", selector, containerList(cs)) + } + } + + if len(cs) == 1 { + return cs[0], nil + } + var running []Container + for _, c := range cs { + if c.State == "running" { + running = append(running, c) + } + } + if len(running) == 1 { + return running[0], nil + } + return Container{}, fmt.Errorf("app has %d containers; pick one with --container:\n%s", len(cs), containerList(cs)) +} + +func containerList(cs []Container) string { + var b strings.Builder + for _, c := range cs { + role := c.Role + if role == "" { + role = "(default)" + } + fmt.Fprintf(&b, " %s %-12s %s\n", idShort(c.ID), role, c.State) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..4f61196 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,95 @@ +// Command tinyforge is a terminal client for a Tinyforge server. +// +// It drives the existing HTTP API: log in to obtain a 24h JWT, then list +// apps, trigger deploys, stream logs, and check status. The token is cached +// in ~/.tinyforge/config.json (mode 0600) so subsequent commands reuse it. +// +// Usage: +// +// tinyforge login [--user U] [--password P] +// tinyforge apps [list] +// tinyforge deploy [--ref TAG] [--note TEXT] +// tinyforge logs [-f] [--tail N] [--container CID] +// tinyforge status [] +// tinyforge logout +// tinyforge version +// +// The target server is resolved from --base-url, then $TINYFORGE_URL, then the +// saved config, then http://localhost:8080. +package main + +import ( + "fmt" + "os" +) + +// version is the CLI build version. Overridable at build time via +// -ldflags "-X main.version=...". +var version = "dev" + +func main() { + if len(os.Args) < 2 { + usage(os.Stderr) + os.Exit(2) + } + + cmd, args := os.Args[1], os.Args[2:] + + var err error + switch cmd { + case "login": + err = runLogin(args) + case "logout": + err = runLogout(args) + case "apps": + err = runApps(args) + case "deploy": + err = runDeploy(args) + case "logs": + err = runLogs(args) + case "status": + err = runStatus(args) + case "version", "--version", "-v": + fmt.Printf("tinyforge %s\n", version) + case "help", "-h", "--help": + usage(os.Stdout) + default: + fmt.Fprintf(os.Stderr, "tinyforge: unknown command %q\n\n", cmd) + usage(os.Stderr) + os.Exit(2) + } + + if err != nil { + // Authenticated commands that hit a 401 get a re-login hint; the login + // command itself surfaces the server message ("invalid credentials"). + if cmd != "login" && isAuthError(err) { + err = fmt.Errorf("%w — run 'tinyforge login'", err) + } + fmt.Fprintf(os.Stderr, "tinyforge: %v\n", err) + os.Exit(1) + } +} + +func usage(w *os.File) { + fmt.Fprint(w, `tinyforge — terminal client for a Tinyforge server + +Usage: + tinyforge [flags] + +Commands: + login Authenticate and cache a token + logout Revoke the cached token and clear it + apps [list] List your apps (workloads with a source) + deploy Trigger a deploy (waits for completion) + logs Print container logs (use -f to follow) + status [] Show server health, or one app's containers + version Print the CLI version + +Global flags (accepted by any command): + --base-url URL Server URL (default $TINYFORGE_URL or http://localhost:8080) + --token TOKEN Auth token (default $TINYFORGE_TOKEN or cached config) + --config PATH Config file (default $TINYFORGE_CONFIG or ~/.tinyforge/config.json) + +Run "tinyforge -h" for command-specific flags. +`) +} diff --git a/cmd/cli/password_other.go b/cmd/cli/password_other.go new file mode 100644 index 0000000..c626ad8 --- /dev/null +++ b/cmd/cli/password_other.go @@ -0,0 +1,38 @@ +//go:build !windows + +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" +) + +// promptPassword reads a password from stdin with echo disabled via stty. If +// stty is unavailable (no tty, missing binary), it falls back to an echoed +// read so the command still works in pipes/CI. +func promptPassword(label string) (string, error) { + fmt.Fprint(os.Stderr, label) + + echoDisabled := stty("-echo") == nil + if echoDisabled { + defer func() { + _ = stty("echo") + fmt.Fprintln(os.Stderr) // the Enter keystroke was not echoed + }() + } + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("read password: %w", err) + } + return strings.TrimRight(line, "\r\n"), nil +} + +func stty(arg string) error { + cmd := exec.Command("stty", arg) + cmd.Stdin = os.Stdin + return cmd.Run() +} diff --git a/cmd/cli/password_windows.go b/cmd/cli/password_windows.go new file mode 100644 index 0000000..e9d5cd8 --- /dev/null +++ b/cmd/cli/password_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "syscall" + "unsafe" +) + +// enableEchoInput is the Windows console mode bit that echoes typed input. +const enableEchoInput = 0x0004 + +// promptPassword reads a password from the console with echo disabled, using +// kernel32 directly so no third-party dependency is needed. If the console +// mode cannot be changed (e.g. piped stdin), it falls back to an echoed read. +func promptPassword(label string) (string, error) { + fmt.Fprint(os.Stderr, label) + + kernel32 := syscall.NewLazyDLL("kernel32.dll") + getConsoleMode := kernel32.NewProc("GetConsoleMode") + setConsoleMode := kernel32.NewProc("SetConsoleMode") + handle := syscall.Handle(os.Stdin.Fd()) + + var mode uint32 + echoDisabled := false + if r, _, _ := getConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))); r != 0 { + if ret, _, _ := setConsoleMode.Call(uintptr(handle), uintptr(mode&^enableEchoInput)); ret != 0 { + echoDisabled = true + defer setConsoleMode.Call(uintptr(handle), uintptr(mode)) + } + } + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if echoDisabled { + fmt.Fprintln(os.Stderr) // the Enter keystroke was not echoed + } + if err != nil && line == "" { + return "", fmt.Errorf("read password: %w", err) + } + return strings.TrimRight(line, "\r\n"), nil +} diff --git a/cmd/cli/status.go b/cmd/cli/status.go new file mode 100644 index 0000000..a66c64e --- /dev/null +++ b/cmd/cli/status.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "text/tabwriter" + "time" +) + +func runStatus(args []string) error { + fs := flag.NewFlagSet("status", flag.ExitOnError) + g := addGlobalFlags(fs) + fs.Usage = func() { + fmt.Fprint(os.Stderr, "Usage: tinyforge status []\n\nWith no app: server health and the logged-in user.\nWith an app: that app's containers.\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + + sess, err := newSession(g) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if fs.NArg() == 0 { + return serverStatus(ctx, sess) + } + return appStatus(ctx, sess.client, fs.Arg(0)) +} + +func serverStatus(ctx context.Context, sess *session) error { + fmt.Printf("Server: %s\n", sess.client.baseURL) + + var me User + if err := sess.client.doJSON(ctx, "GET", "/api/auth/me", nil, &me); err != nil { + fmt.Printf("User: not logged in (%v)\n", err) + } else { + fmt.Printf("User: %s (%s)\n", me.Username, me.Role) + } + if exp := friendlyExpiry(sess.cfg.ExpiresAt); exp != "" { + fmt.Printf("Token: valid until %s\n", exp) + } + + var health map[string]any + if err := sess.client.doJSON(ctx, "GET", "/api/health", nil, &health); err != nil { + return err + } + fmt.Printf("DB: %s\n", connState(health, "database")) + docker := connState(health, "docker") + if v := nestedString(health, "docker", "version"); v != "" { + docker += " (v" + v + ")" + } + fmt.Printf("Docker: %s\n", docker) + if _, ok := health["proxy"]; ok { + fmt.Printf("Proxy: %s\n", connState(health, "proxy")) + } + return nil +} + +func appStatus(ctx context.Context, c *Client, ref string) error { + app, err := resolveApp(ctx, c, ref) + if err != nil { + return err + } + var containers []Container + if err := c.doJSON(ctx, "GET", "/api/workloads/"+app.ID+"/containers", nil, &containers); err != nil { + return err + } + + fmt.Printf("%s (%s, %s)\n", app.Name, app.SourceKind, idShort(app.ID)) + if len(containers) == 0 { + fmt.Println("No containers — not deployed yet.") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "ROLE\tSTATE\tIMAGE\tPORT\tSUBDOMAIN\tCONTAINER") + for _, c := range containers { + role := c.Role + if role == "" { + role = "(default)" + } + port := "" + if c.Port != 0 { + port = fmt.Sprintf("%d", c.Port) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + role, c.State, c.ImageRef, port, c.Subdomain, idShort(c.ID)) + } + return tw.Flush() +} + +// connState reads health[section].connected and renders connected/disconnected, +// appending the section's error string when present. +func connState(health map[string]any, section string) string { + m, ok := health[section].(map[string]any) + if !ok { + return "unknown" + } + connected, _ := m["connected"].(bool) + if connected { + return "connected" + } + if msg, ok := m["error"].(string); ok && msg != "" { + return "disconnected (" + msg + ")" + } + return "disconnected" +} + +func nestedString(m map[string]any, section, key string) string { + sub, ok := m[section].(map[string]any) + if !ok { + return "" + } + s, _ := sub[key].(string) + return s +}