diff --git a/go.mod b/go.mod index 0c2e8c87..bcc745cf 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( go.lumeweb.com/mcpplane v0.0.0-20260912095121-3753dd085aa8 go.lumeweb.com/oauth v0.1.6 go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 - go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb + go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0 go.lumeweb.com/portal-sdk v0.1.73 go.lumeweb.com/queryutil v0.3.19 go.lumeweb.com/tunneler v0.0.0-20260907123602-56944ca7aeae diff --git a/go.sum b/go.sum index af86f298..17daa035 100644 --- a/go.sum +++ b/go.sum @@ -763,6 +763,8 @@ go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743 h1:R3GNQ7ENa2IGbaHJWvB5 go.lumeweb.com/pinner v0.0.0-20260913165519-0cd854ac5743/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb h1:Cw0gjgj5auBFpEhnLj0YWRhNOoHCLiTQFMrqur74Wlw= go.lumeweb.com/pinner v0.0.0-20260913174043-31c1b6e65acb/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= +go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0 h1:QsMQcXyrRXZoO0E5PPbFaDLMJMpfh6B5K30f35hxZ9M= +go.lumeweb.com/pinner v0.0.0-20260915081600-895fc1fa83b0/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw= go.lumeweb.com/portal v0.5.1 h1:l28uCNFmT+VewwRGhFwlmonYEwLxQfRh06hm7n0SQr8= go.lumeweb.com/portal v0.5.1/go.mod h1:JXy/eHHlUdXMsPbXSbRyX2ZM7s/OGNfSNsXH7XRYWCk= go.lumeweb.com/portal-middleware v0.3.7 h1:kq4SZq4T/uhauqHehw2JaTbNJpPpE8/EQAC3R737H7c= diff --git a/internal/cli/catalog_workspaces_wiring.go b/internal/cli/catalog_workspaces_wiring.go index 7960db44..389d0747 100644 --- a/internal/cli/catalog_workspaces_wiring.go +++ b/internal/cli/catalog_workspaces_wiring.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "strconv" "github.com/urfave/cli/v3" ipfs "go.lumeweb.com/ipfs-sdk" @@ -47,9 +48,17 @@ func catalogWorkspacesDeps(factory ...ConfigManagerFactory) catalogops.Workspace } return GetSecureSetting(nil, cfgMgr) }, - ServiceFactory: workspaces.DefaultFactory, + // Services are wrapped so single-workspace operations accept the + // workspace's label slug as the id (see workspaces_label.go). + ServiceFactory: func(cfgMgr config.Manager, secure bool, opts ...workspaces.Option) workspaces.Service { + return wrapLabelResolvingWorkspaces(workspaces.DefaultFactory(cfgMgr, secure, opts...)) + }, NewAuthenticated: func(cfgMgr config.Manager, secure bool, token string) (workspaces.Service, error) { - return workspaces.NewAuthenticated(cfgMgr, token, secure) + svc, err := workspaces.NewAuthenticated(cfgMgr, token, secure) + if err != nil { + return nil, err + } + return wrapLabelResolvingWorkspaces(svc), nil }, GetAuthToken: func() string { cfgMgr, err := cfgFactory() @@ -146,7 +155,7 @@ func renderWorkspacesResult(_ context.Context, c *cli.Command, op opmesh.Operati switch r := result.(type) { case catalogops.ListResult: - return renderListResult(output, r) + return renderWorkspacesListResult(output, r) case *ipfs.WorkspaceResponse: // workspaces get/create/attach/suspend/resume all return a workspace. @@ -182,15 +191,51 @@ func renderWorkspacesResult(_ context.Context, c *cli.Command, op opmesh.Operati } } +// renderWorkspacesListResult renders a workspaces list page. The human table +// is rebuilt here (rather than using the catalogops rows) so the workspace's +// user-facing ID is its label slug and the LABEL column stays out of the +// table — the slug IS the label, so the column would repeat the ID. +func renderWorkspacesListResult(output Output, r catalogops.ListResult) error { + if ws, ok := r.ListItems().([]ipfs.WorkspaceResponse); ok { + rows := make([][]string, 0, len(ws)) + for i := range ws { + rows = append(rows, workspaceListRow(&ws[i])) + } + r = catalogops.NewListResult(ws, catalogops.ListResultMeta{ + Noun: "workspace(s)", + Headers: []string{"ID", "DOMAIN", "STATUS", "WEBSITE ID", "CREATED"}, + Rows: rows, + Total: r.ListTotal(), + }) + } + return renderListResult(output, r) +} + +// workspaceListRow is one row of the workspaces list table, keyed by the +// label-slug ID. +func workspaceListRow(w *ipfs.WorkspaceResponse) []string { + websiteID := "-" + if w.WebsiteId != nil { + websiteID = strconv.Itoa(*w.WebsiteId) + } + return []string{ + workspaceSlugID(w), + w.Domain, + w.Status, + websiteID, + w.Created.Format("2006-01-02 15:04:05"), + } +} + // renderWorkspaceHuman renders the fields of a single workspace (used by get, -// create, attach, suspend, resume). +// create, attach, suspend, resume). The ID is the label slug; the label itself +// is not shown separately (it would only repeat the ID). func renderWorkspaceHuman(output Output, w *ipfs.WorkspaceResponse) { output.Printfln("Workspace Details") fields := []Field{ - {"ID", fmt.Sprintf("%d", w.Id)}, + {"ID", workspaceSlugID(w)}, {"Domain", w.Domain}, - {"Label", w.Label}, {"Status", w.Status}, } if w.WebsiteId != nil { diff --git a/internal/cli/workspaces_label.go b/internal/cli/workspaces_label.go new file mode 100644 index 00000000..f0e36657 --- /dev/null +++ b/internal/cli/workspaces_label.go @@ -0,0 +1,159 @@ +package cli + +import ( + "context" + "fmt" + "strconv" + "strings" + + ipfs "go.lumeweb.com/ipfs-sdk" + "go.lumeweb.com/pinner" + "go.lumeweb.com/pinner/core/workspaces" +) + +// workspaces_label.go maps the workspace's opaque URL label (its user-facing +// identity) onto its sequential numeric ID. Generated labels settled on the +// bare slug (portal-plugin-ipfs#1040, e.g. "ugki684o" -> .build.); +// legacy labels carry a "ws-" prefix (e.g. "ws-ugki684o"). The strip-fallback +// logic here accepts both, so present-day and post-#1040 workspaces answer to +// the same short form. + +// workspaceLabelLegacyPrefix is the prefix stripped from legacy generated +// workspace labels ("ws-") to produce the short user-facing ID. +const workspaceLabelLegacyPrefix = "ws-" + +// workspaceSlugFromLabel is the user-facing workspace ID implied by a label: +// the label with any legacy "ws-" prefix removed. +func workspaceSlugFromLabel(label string) string { + return strings.TrimPrefix(label, workspaceLabelLegacyPrefix) +} + +// workspaceSlugID is the user-facing ID for a workspace response: the slug +// form of its label, falling back to the numeric ID when the label is empty. +func workspaceSlugID(w *ipfs.WorkspaceResponse) string { + if w == nil { + return "" + } + if w.Label == "" { + return strconv.Itoa(w.Id) + } + return workspaceSlugFromLabel(w.Label) +} + +// workspaceScanPageSize is the page size used when scanning the user's +// workspace list to map a label onto its numeric ID. +const workspaceScanPageSize = 100 + +// labelResolvingWorkspaces decorates a workspaces.Service so the +// single-workspace operations (get/attach/suspend/resume/access/delete) +// accept the workspace's label slug (or its full legacy "ws-" label) as +// the `id` argument, resolving it to the numeric ID by scanning the paged +// list. Label matching runs first for every input — including numeric-looking +// ones — so a purely numeric label slug is never shadowed by the numeric +// interpretation; an unmatched numeric id falls back to the numeric form the +// backend expects. Resolve failure says so explicitly rather than letting the +// backend 404 an unresolvable input. +// +// Both CLI and MCP surfaces build their workspaces catalog deps through this +// wrapper, so label-based control is consistent across frontends. +type labelResolvingWorkspaces struct { + inner workspaces.Service +} + +// wrapLabelResolvingWorkspaces returns svc wrapped by labelResolvingWorkspaces +// (a nil inner is returned unchanged; catalogops rejects it downstream). +func wrapLabelResolvingWorkspaces(svc workspaces.Service) workspaces.Service { + if svc == nil { + return nil + } + return &labelResolvingWorkspaces{inner: svc} +} + +func (s *labelResolvingWorkspaces) RequireAuthenticated() error { + return s.inner.RequireAuthenticated() +} + +func (s *labelResolvingWorkspaces) SetAuthToken(token string) { + s.inner.SetAuthToken(token) +} + +func (s *labelResolvingWorkspaces) List(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) { + return s.inner.List(ctx, opts) +} + +func (s *labelResolvingWorkspaces) Create(ctx context.Context, req ipfs.WorkspaceRequest) (*ipfs.WorkspaceResponse, error) { + return s.inner.Create(ctx, req) +} + +func (s *labelResolvingWorkspaces) Get(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + resolved, err := s.resolveID(ctx, id) + if err != nil { + return nil, err + } + return s.inner.Get(ctx, resolved) +} + +func (s *labelResolvingWorkspaces) Delete(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + resolved, err := s.resolveID(ctx, id) + if err != nil { + return nil, err + } + return s.inner.Delete(ctx, resolved) +} + +func (s *labelResolvingWorkspaces) Access(ctx context.Context, id string, rotate bool) (*ipfs.WorkspaceAccessResponse, error) { + resolved, err := s.resolveID(ctx, id) + if err != nil { + return nil, err + } + return s.inner.Access(ctx, resolved, rotate) +} + +func (s *labelResolvingWorkspaces) Attach(ctx context.Context, id string, websiteID int) (*ipfs.WorkspaceResponse, error) { + resolved, err := s.resolveID(ctx, id) + if err != nil { + return nil, err + } + return s.inner.Attach(ctx, resolved, websiteID) +} + +func (s *labelResolvingWorkspaces) Resume(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + resolved, err := s.resolveID(ctx, id) + if err != nil { + return nil, err + } + return s.inner.Resume(ctx, resolved) +} + +func (s *labelResolvingWorkspaces) Suspend(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + resolved, err := s.resolveID(ctx, id) + if err != nil { + return nil, err + } + return s.inner.Suspend(ctx, resolved) +} + +// resolveID maps a workspace id argument to the numeric ID the backend +// expects. It matches the workspace's label slug (tolerating the legacy +// "ws-" form and the full label) by scanning the user's paged list — +// labels are matched before the numeric interpretation so a purely numeric +// label slug is reachable — and passes an unmatched numeric id through. +func (s *labelResolvingWorkspaces) resolveID(ctx context.Context, id string) (string, error) { + if id == "" { + return id, nil + } + matchesLabel := func(w ipfs.WorkspaceResponse) (bool, error) { + return workspaceSlugFromLabel(w.Label) == id || w.Label == id, nil + } + ws, found, err := pinner.ScanPagesWithOptions(ctx, s, matchesLabel, nil, workspaceScanPageSize) + if err != nil { + return "", err + } + if found { + return strconv.Itoa(ws.Id), nil + } + if _, err := strconv.Atoi(id); err == nil { + return id, nil + } + return "", fmt.Errorf("workspace %q not found", id) +} diff --git a/internal/cli/workspaces_label_test.go b/internal/cli/workspaces_label_test.go new file mode 100644 index 00000000..d31c706c --- /dev/null +++ b/internal/cli/workspaces_label_test.go @@ -0,0 +1,245 @@ +package cli + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + ipfs "go.lumeweb.com/ipfs-sdk" + + "go.lumeweb.com/pinner/catalogops" + "go.lumeweb.com/pinner/core/workspaces" +) + +// fakeWorkspacesService is an in-memory workspaces.Service stub with +// call-recording for the resolution tests. +type fakeWorkspacesService struct { + listFn func(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) + getFn func(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) + getIDs []string + lists int + lastOpt workspaces.ListOptions +} + +func (f *fakeWorkspacesService) RequireAuthenticated() error { return nil } +func (f *fakeWorkspacesService) SetAuthToken(token string) {} + +func (f *fakeWorkspacesService) List(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) { + f.lists++ + f.lastOpt = opts + if f.listFn == nil { + return nil, nil + } + return f.listFn(ctx, opts) +} + +func (f *fakeWorkspacesService) Get(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + f.getIDs = append(f.getIDs, id) + if f.getFn == nil { + return nil, nil + } + return f.getFn(ctx, id) +} + +func (f *fakeWorkspacesService) Create(ctx context.Context, req ipfs.WorkspaceRequest) (*ipfs.WorkspaceResponse, error) { + return nil, nil +} + +func (f *fakeWorkspacesService) Delete(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + f.getIDs = append(f.getIDs, id) + return nil, nil +} + +func (f *fakeWorkspacesService) Access(ctx context.Context, id string, rotate bool) (*ipfs.WorkspaceAccessResponse, error) { + f.getIDs = append(f.getIDs, id) + return nil, nil +} + +func (f *fakeWorkspacesService) Attach(ctx context.Context, id string, websiteID int) (*ipfs.WorkspaceResponse, error) { + f.getIDs = append(f.getIDs, id) + return nil, nil +} + +func (f *fakeWorkspacesService) Resume(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + f.getIDs = append(f.getIDs, id) + return nil, nil +} + +func (f *fakeWorkspacesService) Suspend(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + f.getIDs = append(f.getIDs, id) + return nil, nil +} + +var _ workspaces.Service = (*fakeWorkspacesService)(nil) + +// tableOutput captures PrintTable calls while delegating everything else. +type tableOutput struct { + Output + headers []string + rows [][]string +} + +func (t *tableOutput) PrintTable(headers []string, rows [][]string) { + t.headers = headers + t.rows = rows +} + +func TestWorkspaceSlugFromLabel(t *testing.T) { + assert.Equal(t, "ugki684o", workspaceSlugFromLabel("ws-ugki684o")) + assert.Equal(t, "k7x4p9zq", workspaceSlugFromLabel("k7x4p9zq")) + assert.Equal(t, "", workspaceSlugFromLabel("")) +} + +func TestWorkspaceSlugID(t *testing.T) { + assert.Equal(t, "ugki684o", workspaceSlugID(&ipfs.WorkspaceResponse{Id: 9, Label: "ws-ugki684o"})) + assert.Equal(t, "k7x4p9zq", workspaceSlugID(&ipfs.WorkspaceResponse{Id: 9, Label: "k7x4p9zq"})) + assert.Equal(t, "7", workspaceSlugID(&ipfs.WorkspaceResponse{Id: 7})) + assert.Equal(t, "", workspaceSlugID(nil)) +} + +func TestLabelResolvingWorkspaces_NumericIDPassesThrough(t *testing.T) { + fake := &fakeWorkspacesService{} + svc := wrapLabelResolvingWorkspaces(fake) + + _, err := svc.Get(context.Background(), "42") + require.NoError(t, err) + + assert.Equal(t, []string{"42"}, fake.getIDs) + assert.GreaterOrEqual(t, fake.lists, 1, "labels are matched first, even for numeric ids") +} + +// A purely numeric label slug must not be shadowed by the numeric id +// interpretation: the label resolves to its workspace before the numeric +// passthrough is considered. +func TestLabelResolvingWorkspaces_NumericLabelSlugTakesPrecedence(t *testing.T) { + fake := &fakeWorkspacesService{ + listFn: func(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) { + return []ipfs.WorkspaceResponse{{Id: 7, Label: "12345678"}}, nil + }, + } + svc := wrapLabelResolvingWorkspaces(fake) + + _, err := svc.Get(context.Background(), "12345678") + require.NoError(t, err) + + assert.Equal(t, []string{"7"}, fake.getIDs) +} + +func TestLabelResolvingWorkspaces_ResolvesSlugAcrossPages(t *testing.T) { + fake := &fakeWorkspacesService{ + listFn: func(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) { + if opts.Start == 0 { + page := make([]ipfs.WorkspaceResponse, workspaceScanPageSize) + for i := range page { + page[i] = ipfs.WorkspaceResponse{Id: i + 1, Label: fmt.Sprintf("ws-fill%03d", i)} + } + return page, nil + } + return []ipfs.WorkspaceResponse{{Id: 9, Label: "ws-ugki684o"}}, nil + }, + getFn: func(ctx context.Context, id string) (*ipfs.WorkspaceResponse, error) { + return &ipfs.WorkspaceResponse{Id: 9, Label: "ws-ugki684o"}, nil + }, + } + svc := wrapLabelResolvingWorkspaces(fake) + + ws, err := svc.Get(context.Background(), "ugki684o") + require.NoError(t, err) + require.NotNil(t, ws) + assert.Equal(t, 9, ws.Id) + + // The numeric id arrived at the backend. + assert.Equal(t, []string{"9"}, fake.getIDs) + // The scan paged with the configured page size. + assert.GreaterOrEqual(t, fake.lists, 2) + assert.Equal(t, workspaceScanPageSize, fake.lastOpt.Limit) +} + +func TestLabelResolvingWorkspaces_AcceptsFullLegacyLabelAndBareLabel(t *testing.T) { + fake := &fakeWorkspacesService{ + listFn: func(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) { + return []ipfs.WorkspaceResponse{{Id: 9, Label: "ws-ugki684o"}}, nil + }, + } + svc := wrapLabelResolvingWorkspaces(fake) + + for _, id := range []string{"ws-ugki684o", "ugki684o"} { + _, err := svc.Get(context.Background(), id) + require.NoError(t, err, "id %q should resolve", id) + } +} + +func TestLabelResolvingWorkspaces_NotFound(t *testing.T) { + fake := &fakeWorkspacesService{ + listFn: func(ctx context.Context, opts workspaces.ListOptions) ([]ipfs.WorkspaceResponse, error) { + return []ipfs.WorkspaceResponse{{Id: 9, Label: "ws-other1"}}, nil + }, + } + svc := wrapLabelResolvingWorkspaces(fake) + + _, err := svc.Get(context.Background(), "ugki6840") + require.Error(t, err) + assert.Contains(t, err.Error(), `workspace "ugki6840" not found`) + assert.Empty(t, fake.getIDs, "no backend call may fire for an unresolvable label") +} + +func TestRenderWorkspacesListResult_HumanTableUsesSlugIDAndDropsLabel(t *testing.T) { + created := parseTime(t, "2026-09-14 22:27:13") + websiteID := 4 + result := catalogops.NewListResult([]ipfs.WorkspaceResponse{ + {Id: 9, Domain: "ws-ugki684o.build.pinned.site", Label: "ws-ugki684o", Status: "deleting", WebsiteId: &websiteID, Created: created}, + {Id: 10, Domain: "k7x4p9zq.build.pinned.site", Label: "k7x4p9zq", Status: "running", Created: created}, + }, catalogops.ListResultMeta{ + Noun: "workspace(s)", + Headers: []string{"ID", "DOMAIN", "LABEL", "STATUS", "WEBSITE ID", "CREATED"}, + Rows: [][]string{ + {"9", "ws-ugki684o.build.pinned.site", "ws-ugki684o", "deleting", "4", "2026-09-14 22:27:13"}, + {"10", "k7x4p9zq.build.pinned.site", "k7x4p9zq", "running", "-", "2026-09-14 22:27:13"}, + }, + }) + + out := &tableOutput{Output: newTestOutput()} + require.NoError(t, renderWorkspacesListResult(out, result)) + + assert.Equal(t, []string{"ID", "DOMAIN", "STATUS", "WEBSITE ID", "CREATED"}, out.headers) + require.Len(t, out.rows, 2) + assert.Equal(t, []string{"ugki684o", "ws-ugki684o.build.pinned.site", "deleting", "4", "2026-09-14 22:27:13"}, out.rows[0]) + assert.Equal(t, []string{"k7x4p9zq", "k7x4p9zq.build.pinned.site", "running", "-", "2026-09-14 22:27:13"}, out.rows[1]) +} + +func TestRenderWorkspaceHuman_IDIsSlugAndNoLabelRow(t *testing.T) { + websiteID := 4 + fake := &fieldsOutput{Output: newTestOutput()} + renderWorkspaceHuman(fake, &ipfs.WorkspaceResponse{ + Id: 9, + Domain: "ws-ugki684o.build.pinned.site", + Label: "ws-ugki684o", + Status: "deleting", + WebsiteId: &websiteID, + }) + + require.Len(t, fake.groups, 1) + require.Len(t, fake.groups[0].Fields, 6) + assert.Equal(t, Field{"ID", "ugki684o"}, fake.groups[0].Fields[0]) + assert.Equal(t, Field{"Website ID", "4"}, fake.groups[0].Fields[3]) +} + +// fieldsOutput captures PrintFields groups while delegating everything else. +type fieldsOutput struct { + Output + groups []FieldGroup +} + +func (f *fieldsOutput) PrintFields(group FieldGroup) { + f.groups = append(f.groups, group) +} + +func parseTime(t *testing.T, s string) time.Time { + t.Helper() + parsed, err := time.Parse("2006-01-02 15:04:05", s) + require.NoError(t, err) + return parsed +}