Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0a573fb
Merge pull request #196 from tracebloc/feat/179-plain-language-ceremony
LukasWodka Jul 9, 2026
4ea80cc
feat(data ingest): rename --category/--table/--intent → --task/--name…
LukasWodka Jul 9, 2026
e5f2720
fix(data): drop the tokenizer.json ingest requirement for MLM (#184) …
LukasWodka Jul 9, 2026
404c441
feat(data ingest): data-first flow inversion + family-scoped task pic…
LukasWodka Jul 9, 2026
767fba3
fix(data ingest): #197 review follow-ups — picker crash + README/help…
LukasWodka Jul 10, 2026
d57a6d5
feat(data ingest): flexible file-or-folder input + path fixes (#181) …
LukasWodka Jul 10, 2026
2db12f9
feat(data ingest): expected image size + local min-size floor preview…
LukasWodka Jul 10, 2026
706f44e
feat(data ingest): wire the 5 text tasks (#182) (#209)
LukasWodka Jul 10, 2026
88f2bf3
feat(data ingest): confirm the inferred tabular schema (#185) (#210)
LukasWodka Jul 10, 2026
10e6d89
fix(data ingest): mis-cased media folder next to labels.csv stays amb…
LukasWodka Jul 10, 2026
f1afe2f
fix: harden login env-validation, Inf/NaN schema inference, and log-s…
LukasWodka Jul 10, 2026
f619db5
fix(delete): don't brick local teardown when the revoke isn't a 403 (…
LukasWodka Jul 10, 2026
8c06ab8
fix(preflight): resolve labels.csv filename by column name, not posit…
LukasWodka Jul 10, 2026
ebe36b9
feat(push): add time_series_classification category (backend#1054 WS2…
LukasWodka Jul 10, 2026
3ba1b63
fix(submit): make maxLine authoritative so the drain test isn't vacuo…
LukasWodka Jul 10, 2026
2a5e0e6
chore(schema): pin data-ingestors to the #359 merge commit (#217)
LukasWodka Jul 10, 2026
e1ea845
fix(push): fail closed when labels.csv can't be read mid-walk (text p…
saadqbal Jul 10, 2026
f05d01d
fix(push): fail closed on labels.csv read errors in label + sequence …
saadqbal Jul 10, 2026
b38d736
fix(push): SniffFamily mirrors the walk (symlinked/mis-cased/lone mar…
saadqbal Jul 10, 2026
b0ffc48
fix(cli,submit): reject misapplied task flags; don't false-fail slow-…
saadqbal Jul 10, 2026
ef672f2
fix(push): set LazyQuotes on CSV readers so a bare quote doesn't fals…
saadqbal Jul 10, 2026
e4d5ce5
fix(summary): bound SummaryParser.buf on newline-less log floods (D3,…
saadqbal Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@ The customer-facing CLI for the tracebloc declarative ingestion path. Wraps the

**v0.3.0 is released** — the latest stable [release](https://github.com/tracebloc/cli/releases/latest), cut from `develop`. It builds on v0.2.0's guided `data ingest` and `dataset rm` with a new `dataset list` command plus home-screen / output polish (clearer copy, guided-first framing). The binary implements `version`, `completion`, `data validate`, `cluster info`, and the full `data ingest` / `dataset list` / `dataset rm` flow — local schema validation, cluster discovery, data staging, submission, and Job watching, end to end.

`data ingest` covers **9 of 10 task categories**: `image_classification`, `object_detection`, `keypoint_detection`, `text_classification`, `masked_language_modeling`, `tabular_classification`, `tabular_regression`, `time_series_forecasting`, and `time_to_event_prediction`. `semantic_segmentation` is pending mask-sidecar support upstream ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)).
`data ingest` covers **15 of 16 task categories**: `image_classification`, `object_detection`, `keypoint_detection`, `text_classification`, `token_classification`, `sentence_pair_classification`, `masked_language_modeling`, `causal_language_modeling`, `seq2seq`, `embeddings`, `tabular_classification`, `tabular_regression`, `time_series_forecasting`, `time_series_classification`, and `time_to_event_prediction`. `semantic_segmentation` is pending mask-sidecar support upstream ([data-ingestors#136](https://github.com/tracebloc/data-ingestors/issues/136)).

The release pipeline ships [`v0.3.0`](https://github.com/tracebloc/cli/releases/latest) as **cosign-signed, multi-arch binaries** — Linux (`amd64`, `arm64`, `386`, `arm`), macOS (`amd64`, `arm64`), and Windows (`amd64`, `arm64`) — each with `SHA256SUMS` and the install scripts. Install via [Customer experience](#customer-experience) or [build from source](#building-from-source). (A Homebrew tap and the `install.tracebloc.io` vanity URL are later follow-ups; the GitHub release URL serves installs today.)

Expand DownExpand Up@@ -54,8 +54,8 @@ irm https://github.com/tracebloc/cli/releases/latest/download/install.ps1 | iex

# Per dataset
tracebloc data ingest ./my-data \
--table cats_dogs_train \
--category image_classification \
--name cats_dogs_train \
--task image_classification \
--intent train \
--label-column label
```
Expand Down
15 changes: 15 additions & 0 deletions internal/api/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,21 @@ func ResolveEnv(explicit string) string {
return EnvProd
}

// IsKnownEnv reports whether env is one of the recognized backends (dev/stg/prod,
// case-insensitively). Callers that let a human PICK the env (e.g. `login`) use it
// to reject a typo up front — BaseURL deliberately falls unknown values back to
// prod (a lenient library default), so without this a `--env staging`/`prd` typo
// would silently target production. Empty is NOT known here: resolve first
// (ResolveEnv turns empty into the prod default), then validate the result.
func IsKnownEnv(env string) bool {
switch strings.ToLower(env) {
case EnvDev, EnvStg, EnvProd:
return true
default:
return false
}
}

// Client talks to the backend REST API. Token (the user token from login) is
// optional: the device-flow endpoints are unauthenticated; provisioning calls
// set it.
Expand Down
17 changes: 17 additions & 0 deletions internal/api/client_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,23 @@ func TestResolveEnv(t *testing.T) {
}
}

func TestIsKnownEnv(t *testing.T) {
known := []string{"dev", "stg", "prod", "DEV", "Prod"} // case-insensitive
for _, env := range known {
if !IsKnownEnv(env) {
t.Errorf("IsKnownEnv(%q) = false, want true", env)
}
}
// Typos and the unknown values BaseURL would silently route to prod
// must be rejected so `login` fails instead of persisting a prod session.
unknown := []string{"staging", "prd", "production", "development", "", " "}
for _, env := range unknown {
if IsKnownEnv(env) {
t.Errorf("IsKnownEnv(%q) = true, want false", env)
}
}
}

func TestRequestDeviceCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/device/code" || r.Method != http.MethodPost {
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/auth.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,6 +56,15 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
return &exitError{code: 1, err: err}
}
env := api.ResolveEnv(envFlag)
// login PICKS the session env and persists it (cfg.CurrentEnv below), so a
// typo must fail HERE, not silently resolve to prod. BaseURL's lenient
// unknown→prod fallback would otherwise route `--env staging` / `CLIENT_ENV=prd`
// to production and store it as the active env for every later command.
if !api.IsKnownEnv(env) {
return &exitError{code: 1, err: fmt.Errorf(
"unknown backend environment %q — valid values are dev, stg, prod (default). "+
"Check --env / $CLIENT_ENV", env)}
}
client := newAPIClient(env)
p.Detailf("backend %s — requesting a device code …", client.BaseURL)

Expand Down
126 changes: 124 additions & 2 deletions internal/cli/coverage_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
Expand DownExpand Up@@ -48,8 +49,10 @@ func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) {
"label": "label",
}

// Verbose so the cluster block renders too — it's --verbose-only now (see
// TestPrintClusterSummary_VerboseOnly). The local summary shows regardless.
var buf bytes.Buffer
p := ui.New(&buf, ui.WithColor(false))
p := ui.New(&buf, ui.WithColor(false), ui.WithVerbose(true))
printLocalSummary(p, layout, spec)
printClusterSummary(p, release, pvc)
out := buf.String()
Expand All@@ -64,6 +67,41 @@ func TestPrintPushPreflight_RendersKeyFacts(t *testing.T) {
}
}

// TestPrintClusterSummary_VerboseOnly pins that the Kubernetes cluster detail
// (release / jobs-manager / shared PVC) is hidden on the default happy path and
// surfaces only under --verbose — the RFC-0002 §6 ceremony-hiding contract.
func TestPrintClusterSummary_VerboseOnly(t *testing.T) {
release := &cluster.ParentRelease{
ReleaseName: "ingdemo",
ChartVersion: "1.4.2",
JobsManagerService: "http://jobs-manager.ingdemo.svc.cluster.local:8080",
}
pvc := &cluster.SharedPVC{
ClaimName: "client-pvc",
MountPath: "/data/shared",
Phase: corev1.ClaimBound,
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany},
}

// Default (non-verbose): none of the cluster plumbing leaks.
var quiet bytes.Buffer
printClusterSummary(ui.New(&quiet, ui.WithColor(false)), release, pvc)
for _, hidden := range []string{"ingdemo", "1.4.2", "client-pvc", "jobs-manager", "Target cluster"} {
if strings.Contains(quiet.String(), hidden) {
t.Errorf("non-verbose output leaked cluster detail %q:\n%s", hidden, quiet.String())
}
}

// --verbose: the same facts are shown.
var loud bytes.Buffer
printClusterSummary(ui.New(&loud, ui.WithColor(false), ui.WithVerbose(true)), release, pvc)
for _, want := range []string{"ingdemo", "1.4.2", "client-pvc"} {
if !strings.Contains(loud.String(), want) {
t.Errorf("verbose output missing cluster detail %q:\n%s", want, loud.String())
}
}
}

// TestWritePushJSON checks the --output-json result serializes to
// valid JSON with the expected fields.
func TestWritePushJSON(t *testing.T) {
Expand DownExpand Up@@ -136,7 +174,10 @@ func TestClassifyPushOutcome(t *testing.T) {
func TestRunDatasetPush_OutputJSONEarlyFailureEmitsJSON(t *testing.T) {
var jsonBuf, human bytes.Buffer
a := runDataIngestArgs{
LocalPath: "./x",
// A real path so the failure is the invalid table name (exit 2), not
// the earlier path-existence check (exit 3, #181) — this test pins the
// stdout-always-JSON contract on the table-validation failure.
LocalPath: t.TempDir(),
Spec: push.SpecArgs{Table: "../bad", Category: "image_classification", Intent: "train"},
Printer: ui.New(&human, ui.WithColor(false)),
OutputJSON: true,
Expand All@@ -157,6 +198,49 @@ func TestRunDatasetPush_OutputJSONEarlyFailureEmitsJSON(t *testing.T) {
}
}

// TestRunDataIngest_ImageOnlyFlagsRejectedOnNonImage: --target-size and
// --min-size describe image resolution, so on a tabular/text task they
// must fail fast (exit 2) with a clear message rather than being parsed
// only inside the image branch — where the value, even a malformed one,
// was silently dropped (#206 review).
func TestRunDataIngest_ImageOnlyFlagsRejectedOnNonImage(t *testing.T) {
cases := []struct {
name string
mutate func(*runDataIngestArgs)
}{
{"target-size on tabular", func(a *runDataIngestArgs) { a.TargetSizeFlag = "64x64" }},
{"min-size on tabular", func(a *runDataIngestArgs) { a.MinSizeFlag = "32x32" }},
{"malformed min-size on tabular", func(a *runDataIngestArgs) { a.MinSizeFlag = "garbage" }},
}
// A real, existing path so the earlier dataset-path stat passes and
// the image-only guard is what actually fires (the path check runs
// before the guard).
dir := t.TempDir()
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var human bytes.Buffer
a := runDataIngestArgs{
LocalPath: dir,
Spec: push.SpecArgs{
Table: "t", Category: "tabular_classification",
Intent: "train", LabelColumn: "y",
},
Printer: ui.New(&human, ui.WithColor(false)),
}
c.mutate(&a)
err := runDataIngest(context.Background(), &human, &human, a)

var ee *exitError
if !errors.As(err, &ee) || ee.Code() != 2 {
t.Fatalf("err = %v, want *exitError code 2", err)
}
if !strings.Contains(ee.Error(), "image tasks only") {
t.Errorf("error should explain the flag is image-only; got: %v", ee)
}
})
}
}

// TestExpandHome covers the #37 fix: a leading ~ / ~/… resolves under
// $HOME, while relative, absolute, and empty paths pass through
// untouched (the case that bit the interactive prompt — the shell
Expand All@@ -182,6 +266,44 @@ func TestExpandHome(t *testing.T) {
}
}

// TestExpandHome_NamedUser covers the #181 ~user form: "~user" and
// "~user/…" resolve under that user's home. We look up the CURRENT user by
// name so the test doesn't depend on a fixed account existing, and compare
// against os.UserHomeDir. An unknown ~user is left literal (the path-
// existence check surfaces it), which we also pin.
func TestExpandHome_NamedUser(t *testing.T) {
u, err := user.Current()
if err != nil || u.Username == "" {
t.Skipf("no current user: %v", err)
}
// user.Lookup must resolve the same account (it can differ from
// UserHomeDir on some CI images); skip if it doesn't rather than assert
// on an environment quirk.
looked, err := user.Lookup(u.Username)
if err != nil {
t.Skipf("user.Lookup(%q) unsupported here: %v", u.Username, err)
}
home := looked.HomeDir

cases := []struct{ in, want string }{
{"~" + u.Username, home},
{"~" + u.Username + "/data", filepath.Join(home, "data")},
{"~" + u.Username + "/a/b", filepath.Join(home, "a", "b")},
}
for _, c := range cases {
if got := expandHome(c.in); got != c.want {
t.Errorf("expandHome(%q) = %q, want %q", c.in, got, c.want)
}
}

// An unknown user can't be resolved: the literal is returned unchanged so
// the downstream path-existence check reports it plainly.
const unknown = "~nsuchuser-tracebloc-181/x"
if got := expandHome(unknown); got != unknown {
t.Errorf("expandHome(%q) = %q, want it left literal", unknown, got)
}
}

// TestExitError_Methods pins the exit-code carrier: Error() surfaces
// the wrapped message (or a fallback when nil), and Code() returns the
// process exit code main() propagates.
Expand Down
Loading
Loading