A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(command): port date from just-bash

Render the current time (or a date supplied via -d) using strftime-
style format strings. Mirrors the just-bash semantics, including its
deliberate sandbox quirk of always rendering in UTC regardless of -u
so neither %Z nor %z can leak the host timezone, and the parseDate
keyword set ("now", "today", "yesterday", "tomorrow") plus the
unix-seconds-as-digits-only-string fallback.

Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 4.7 via Claude Code

Xe Iaso a8ec6db6 7501bea3

+520
+231
command/internal/date/date.go
··· 1 + package date 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "regexp" 9 + "strconv" 10 + "strings" 11 + "time" 12 + 13 + "github.com/pborman/getopt/v2" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + ) 17 + 18 + type Impl struct{} 19 + 20 + var ( 21 + days = []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} 22 + months = []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} 23 + ) 24 + 25 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 26 + if ec == nil { 27 + return errors.New("date: nil ExecContext") 28 + } 29 + 30 + stdout := ec.Stdout 31 + if stdout == nil { 32 + stdout = io.Discard 33 + } 34 + stderr := ec.Stderr 35 + if stderr == nil { 36 + stderr = io.Discard 37 + } 38 + 39 + set := getopt.New() 40 + set.SetProgram("date") 41 + set.SetParameters("[+FORMAT]") 42 + 43 + usage := func() { 44 + fmt.Fprint(stderr, "Usage: date [OPTION]... [+FORMAT]\n") 45 + fmt.Fprint(stderr, "Display the current time in the given FORMAT.\n\n") 46 + fmt.Fprint(stderr, " -d, --date=STRING display time described by STRING\n") 47 + fmt.Fprint(stderr, " -u, --utc print Coordinated Universal Time (UTC)\n") 48 + fmt.Fprint(stderr, " -I, --iso-8601 output date/time in ISO 8601 format\n") 49 + fmt.Fprint(stderr, " -R, --rfc-email output RFC 5322 date format\n") 50 + fmt.Fprint(stderr, " --help display this help and exit\n") 51 + } 52 + set.SetUsage(usage) 53 + 54 + dateStr := set.StringLong("date", 'd', "", "display time described by STRING") 55 + utc := set.BoolLong("utc", 'u', "print Coordinated Universal Time (UTC)") 56 + iso := set.BoolLong("iso-8601", 'I', "output date/time in ISO 8601 format") 57 + rfc := set.BoolLong("rfc-email", 'R', "output RFC 5322 date format") 58 + help := set.BoolLong("help", 0, "display this help and exit") 59 + 60 + if err := set.Getopt(append([]string{"date"}, args...), nil); err != nil { 61 + fmt.Fprintf(stderr, "date: %s\n", err) 62 + usage() 63 + return interp.ExitStatus(1) 64 + } 65 + if *help { 66 + usage() 67 + return nil 68 + } 69 + 70 + // The sandbox always renders in UTC to avoid leaking the host 71 + // timezone through %Z, %z, or wall-clock fields. The flag is 72 + // accepted for compatibility but is otherwise a no-op. 73 + _ = *utc 74 + 75 + var fmtStr string 76 + hasFmt := false 77 + for _, p := range set.Args() { 78 + if strings.HasPrefix(p, "+") { 79 + fmtStr = p[1:] 80 + hasFmt = true 81 + break 82 + } 83 + } 84 + 85 + var d time.Time 86 + if *dateStr != "" { 87 + parsed, ok := parseDate(*dateStr) 88 + if !ok { 89 + fmt.Fprintf(stderr, "date: invalid date '%s'\n", *dateStr) 90 + return interp.ExitStatus(1) 91 + } 92 + d = parsed 93 + } else { 94 + d = time.Now() 95 + } 96 + d = d.UTC() 97 + 98 + var out string 99 + switch { 100 + case hasFmt: 101 + out = formatDate(d, fmtStr) 102 + case *iso: 103 + out = formatDate(d, "%Y-%m-%dT%H:%M:%S%z") 104 + case *rfc: 105 + out = formatDate(d, "%a, %d %b %Y %H:%M:%S %z") 106 + default: 107 + out = formatDate(d, "%a %b %e %H:%M:%S %Z %Y") 108 + } 109 + 110 + fmt.Fprintln(stdout, out) 111 + return nil 112 + } 113 + 114 + func formatDate(d time.Time, f string) string { 115 + var b strings.Builder 116 + for i := 0; i < len(f); i++ { 117 + if f[i] != '%' || i+1 >= len(f) { 118 + b.WriteByte(f[i]) 119 + continue 120 + } 121 + i++ 122 + switch f[i] { 123 + case '%': 124 + b.WriteByte('%') 125 + case 'a': 126 + b.WriteString(days[int(d.Weekday())]) 127 + case 'b', 'h': 128 + b.WriteString(months[int(d.Month())-1]) 129 + case 'd': 130 + fmt.Fprintf(&b, "%02d", d.Day()) 131 + case 'e': 132 + fmt.Fprintf(&b, "%2d", d.Day()) 133 + case 'F': 134 + fmt.Fprintf(&b, "%d-%02d-%02d", d.Year(), int(d.Month()), d.Day()) 135 + case 'H': 136 + fmt.Fprintf(&b, "%02d", d.Hour()) 137 + case 'I': 138 + h := d.Hour() % 12 139 + if h == 0 { 140 + h = 12 141 + } 142 + fmt.Fprintf(&b, "%02d", h) 143 + case 'm': 144 + fmt.Fprintf(&b, "%02d", int(d.Month())) 145 + case 'M': 146 + fmt.Fprintf(&b, "%02d", d.Minute()) 147 + case 'n': 148 + b.WriteByte('\n') 149 + case 'p': 150 + if d.Hour() < 12 { 151 + b.WriteString("AM") 152 + } else { 153 + b.WriteString("PM") 154 + } 155 + case 'P': 156 + if d.Hour() < 12 { 157 + b.WriteString("am") 158 + } else { 159 + b.WriteString("pm") 160 + } 161 + case 'R': 162 + fmt.Fprintf(&b, "%02d:%02d", d.Hour(), d.Minute()) 163 + case 's': 164 + fmt.Fprintf(&b, "%d", d.Unix()) 165 + case 'S': 166 + fmt.Fprintf(&b, "%02d", d.Second()) 167 + case 't': 168 + b.WriteByte('\t') 169 + case 'T': 170 + fmt.Fprintf(&b, "%02d:%02d:%02d", d.Hour(), d.Minute(), d.Second()) 171 + case 'u': 172 + w := int(d.Weekday()) 173 + if w == 0 { 174 + w = 7 175 + } 176 + fmt.Fprintf(&b, "%d", w) 177 + case 'w': 178 + fmt.Fprintf(&b, "%d", int(d.Weekday())) 179 + case 'y': 180 + fmt.Fprintf(&b, "%02d", d.Year()%100) 181 + case 'Y': 182 + fmt.Fprintf(&b, "%d", d.Year()) 183 + case 'z': 184 + b.WriteString("+0000") 185 + case 'Z': 186 + b.WriteString("UTC") 187 + default: 188 + b.WriteByte('%') 189 + b.WriteByte(f[i]) 190 + } 191 + } 192 + return b.String() 193 + } 194 + 195 + var digitsOnly = regexp.MustCompile(`^\d+$`) 196 + 197 + func parseDate(s string) (time.Time, bool) { 198 + layouts := []string{ 199 + time.RFC3339Nano, 200 + time.RFC3339, 201 + "2006-01-02T15:04:05", 202 + "2006-01-02T15:04", 203 + "2006-01-02 15:04:05", 204 + "2006-01-02 15:04", 205 + "2006-01-02", 206 + "2006/01/02", 207 + time.RFC1123Z, 208 + time.RFC1123, 209 + time.RFC822Z, 210 + time.RFC822, 211 + } 212 + for _, layout := range layouts { 213 + if t, err := time.Parse(layout, s); err == nil { 214 + return t, true 215 + } 216 + } 217 + if digitsOnly.MatchString(s) { 218 + if n, err := strconv.ParseInt(s, 10, 64); err == nil { 219 + return time.Unix(n, 0).UTC(), true 220 + } 221 + } 222 + switch strings.ToLower(s) { 223 + case "now", "today": 224 + return time.Now().UTC(), true 225 + case "yesterday": 226 + return time.Now().UTC().Add(-24 * time.Hour), true 227 + case "tomorrow": 228 + return time.Now().UTC().Add(24 * time.Hour), true 229 + } 230 + return time.Time{}, false 231 + }
+287
command/internal/date/date_test.go
··· 1 + package date 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + "tangled.org/xeiaso.net/kefka/command" 11 + ) 12 + 13 + func run(t *testing.T, args []string) (string, string, error) { 14 + t.Helper() 15 + var stdout, stderr bytes.Buffer 16 + ec := &command.ExecContext{ 17 + Stdin: strings.NewReader(""), 18 + Stdout: &stdout, 19 + Stderr: &stderr, 20 + Dir: ".", 21 + } 22 + err := Impl{}.Exec(context.Background(), ec, args) 23 + return stdout.String(), stderr.String(), err 24 + } 25 + 26 + func TestDate(t *testing.T) { 27 + // 2025-01-15T12:00:00Z = Wednesday, unix 1736942400 28 + const fixed = "2025-01-15T12:00:00Z" 29 + 30 + tests := []struct { 31 + name string 32 + args []string 33 + wantStdout string 34 + wantErrSub string 35 + wantErr bool 36 + }{ 37 + { 38 + name: "default format", 39 + args: []string{"-d", fixed}, 40 + wantStdout: "Wed Jan 15 12:00:00 UTC 2025\n", 41 + }, 42 + { 43 + name: "utc flag is accepted", 44 + args: []string{"-u", "-d", fixed}, 45 + wantStdout: "Wed Jan 15 12:00:00 UTC 2025\n", 46 + }, 47 + { 48 + name: "iso 8601 short form", 49 + args: []string{"-I", "-d", fixed}, 50 + wantStdout: "2025-01-15T12:00:00+0000\n", 51 + }, 52 + { 53 + name: "iso 8601 long form", 54 + args: []string{"--iso-8601", "-d", fixed}, 55 + wantStdout: "2025-01-15T12:00:00+0000\n", 56 + }, 57 + { 58 + name: "rfc email short form", 59 + args: []string{"-R", "-d", fixed}, 60 + wantStdout: "Wed, 15 Jan 2025 12:00:00 +0000\n", 61 + }, 62 + { 63 + name: "rfc email long form", 64 + args: []string{"--rfc-email", "-d", fixed}, 65 + wantStdout: "Wed, 15 Jan 2025 12:00:00 +0000\n", 66 + }, 67 + { 68 + name: "custom format year", 69 + args: []string{"-d", fixed, "+%Y"}, 70 + wantStdout: "2025\n", 71 + }, 72 + { 73 + name: "custom format full date", 74 + args: []string{"-d", fixed, "+%F"}, 75 + wantStdout: "2025-01-15\n", 76 + }, 77 + { 78 + name: "custom format time", 79 + args: []string{"-d", fixed, "+%T"}, 80 + wantStdout: "12:00:00\n", 81 + }, 82 + { 83 + name: "custom format hour minute", 84 + args: []string{"-d", fixed, "+%R"}, 85 + wantStdout: "12:00\n", 86 + }, 87 + { 88 + name: "custom format unix seconds", 89 + args: []string{"-d", fixed, "+%s"}, 90 + wantStdout: "1736942400\n", 91 + }, 92 + { 93 + name: "custom format weekday name", 94 + args: []string{"-d", fixed, "+%a"}, 95 + wantStdout: "Wed\n", 96 + }, 97 + { 98 + name: "custom format month name", 99 + args: []string{"-d", fixed, "+%b"}, 100 + wantStdout: "Jan\n", 101 + }, 102 + { 103 + name: "custom format month name h alias", 104 + args: []string{"-d", fixed, "+%h"}, 105 + wantStdout: "Jan\n", 106 + }, 107 + { 108 + name: "custom format day zero padded", 109 + args: []string{"-d", "2025-01-05T00:00:00Z", "+%d"}, 110 + wantStdout: "05\n", 111 + }, 112 + { 113 + name: "custom format day space padded", 114 + args: []string{"-d", "2025-01-05T00:00:00Z", "+%e"}, 115 + wantStdout: " 5\n", 116 + }, 117 + { 118 + name: "custom format 12-hour clock at noon", 119 + args: []string{"-d", fixed, "+%I"}, 120 + wantStdout: "12\n", 121 + }, 122 + { 123 + name: "custom format 12-hour clock at midnight", 124 + args: []string{"-d", "2025-01-15T00:00:00Z", "+%I"}, 125 + wantStdout: "12\n", 126 + }, 127 + { 128 + name: "custom format 12-hour clock at 13", 129 + args: []string{"-d", "2025-01-15T13:00:00Z", "+%I"}, 130 + wantStdout: "01\n", 131 + }, 132 + { 133 + name: "custom format am pm uppercase", 134 + args: []string{"-d", fixed, "+%p"}, 135 + wantStdout: "PM\n", 136 + }, 137 + { 138 + name: "custom format am pm lowercase", 139 + args: []string{"-d", "2025-01-15T08:00:00Z", "+%P"}, 140 + wantStdout: "am\n", 141 + }, 142 + { 143 + name: "custom format iso weekday", 144 + args: []string{"-d", "2025-01-19T00:00:00Z", "+%u"}, 145 + wantStdout: "7\n", 146 + }, 147 + { 148 + name: "custom format weekday number", 149 + args: []string{"-d", "2025-01-19T00:00:00Z", "+%w"}, 150 + wantStdout: "0\n", 151 + }, 152 + { 153 + name: "custom format two digit year", 154 + args: []string{"-d", fixed, "+%y"}, 155 + wantStdout: "25\n", 156 + }, 157 + { 158 + name: "custom format timezone offset", 159 + args: []string{"-d", fixed, "+%z"}, 160 + wantStdout: "+0000\n", 161 + }, 162 + { 163 + name: "custom format timezone name", 164 + args: []string{"-d", fixed, "+%Z"}, 165 + wantStdout: "UTC\n", 166 + }, 167 + { 168 + name: "custom format literal percent", 169 + args: []string{"-d", fixed, "+%%"}, 170 + wantStdout: "%\n", 171 + }, 172 + { 173 + name: "custom format newline directive", 174 + args: []string{"-d", fixed, "+a%nb"}, 175 + wantStdout: "a\nb\n", 176 + }, 177 + { 178 + name: "custom format tab directive", 179 + args: []string{"-d", fixed, "+a%tb"}, 180 + wantStdout: "a\tb\n", 181 + }, 182 + { 183 + name: "custom format unknown directive passes through", 184 + args: []string{"-d", fixed, "+%Q"}, 185 + wantStdout: "%Q\n", 186 + }, 187 + { 188 + name: "custom format combined", 189 + args: []string{"-d", fixed, "+%Y-%m-%d %H:%M:%S"}, 190 + wantStdout: "2025-01-15 12:00:00\n", 191 + }, 192 + { 193 + name: "long form date equals", 194 + args: []string{"--date=" + fixed, "+%Y"}, 195 + wantStdout: "2025\n", 196 + }, 197 + { 198 + name: "long form date space", 199 + args: []string{"--date", fixed, "+%Y"}, 200 + wantStdout: "2025\n", 201 + }, 202 + { 203 + name: "unix timestamp date string", 204 + args: []string{"-d", "1736942400", "+%Y-%m-%dT%H:%M:%SZ"}, 205 + wantStdout: "2025-01-15T12:00:00Z\n", 206 + }, 207 + { 208 + name: "date keyword now is accepted", 209 + args: []string{"-d", "now", "+%Y"}, 210 + wantStdout: "", // year depends on real clock; only assert non-empty below 211 + }, 212 + { 213 + name: "invalid date string", 214 + args: []string{"-d", "not a real date"}, 215 + wantErrSub: "invalid date 'not a real date'", 216 + wantErr: true, 217 + }, 218 + { 219 + name: "unknown flag errors", 220 + args: []string{"--no-such-flag"}, 221 + wantErr: true, 222 + }, 223 + } 224 + 225 + for _, tt := range tests { 226 + t.Run(tt.name, func(t *testing.T) { 227 + stdout, stderr, err := run(t, tt.args) 228 + if tt.wantErr { 229 + if err == nil { 230 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 231 + } 232 + } else if err != nil { 233 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 234 + } 235 + if tt.wantStdout != "" && stdout != tt.wantStdout { 236 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 237 + } 238 + if tt.wantStdout == "" && !tt.wantErr && stdout == "" { 239 + t.Errorf("expected non-empty stdout, got empty") 240 + } 241 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 242 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 243 + } 244 + }) 245 + } 246 + } 247 + 248 + func TestRelativeKeywords(t *testing.T) { 249 + tests := []struct { 250 + name string 251 + arg string 252 + }{ 253 + {"now", "now"}, 254 + {"today", "today"}, 255 + {"yesterday", "yesterday"}, 256 + {"tomorrow", "tomorrow"}, 257 + } 258 + 259 + for _, tt := range tests { 260 + t.Run(tt.name, func(t *testing.T) { 261 + stdout, stderr, err := run(t, []string{"-d", tt.arg, "+%Y-%m-%d"}) 262 + if err != nil { 263 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 264 + } 265 + s := strings.TrimSpace(stdout) 266 + if _, err := time.Parse("2006-01-02", s); err != nil { 267 + t.Errorf("expected ISO date, got %q (parse err: %v)", s, err) 268 + } 269 + }) 270 + } 271 + } 272 + 273 + func TestHelp(t *testing.T) { 274 + stdout, stderr, err := run(t, []string{"--help"}) 275 + if err != nil { 276 + t.Fatalf("unexpected error: %v", err) 277 + } 278 + if stdout != "" { 279 + t.Errorf("expected empty stdout, got %q", stdout) 280 + } 281 + if !strings.Contains(stderr, "Usage: date [OPTION]... [+FORMAT]") { 282 + t.Errorf("usage line missing from stderr: %q", stderr) 283 + } 284 + if !strings.Contains(stderr, "--iso-8601") { 285 + t.Errorf("--iso-8601 missing from help: %q", stderr) 286 + } 287 + }
+2
command/registry/coreutils/coreutils.go
··· 8 8 "tangled.org/xeiaso.net/kefka/command/internal/column" 9 9 "tangled.org/xeiaso.net/kefka/command/internal/cp" 10 10 "tangled.org/xeiaso.net/kefka/command/internal/cut" 11 + "tangled.org/xeiaso.net/kefka/command/internal/date" 11 12 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 12 13 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 13 14 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 23 24 reg.Register("column", column.Impl{}) 24 25 reg.Register("cp", cp.Impl{}) 25 26 reg.Register("cut", cut.Impl{}) 27 + reg.Register("date", date.Impl{}) 26 28 reg.Register("false", falsecmd.Impl{}) 27 29 reg.Register("hostname", hostname.Impl{}) 28 30 reg.Register("ls", ls.Impl{})