Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
57 changes: 51 additions & 6 deletions internal/cli/catalog_workspaces_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"strconv"

"github.com/urfave/cli/v3"
ipfs "go.lumeweb.com/ipfs-sdk"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
158 changes: 158 additions & 0 deletions internal/cli/workspaces_label.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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" -> <slug>.build.<root>);
// 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-<slug>") 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-<slug>" label) as
// the `id` argument, resolving it to the numeric ID by scanning the paged
// list. A numeric id is treated as the numeric row id without a scan — that
// keeps the numeric path scan-free and gives a deterministic winner when the
// id is ambiguous between a row id and a purely numeric label slug (the
// numeric interpretation takes precedence). Non-numeric slugs that match
// nothing fail explicitly rather than letting the backend 404 them.
//
// 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. Numeric ids are the numeric row id, taken as-is; non-numeric ids
// match the workspace's label slug (tolerating the legacy "ws-<slug>" form
// and the full label) by scanning the user's paged list.
func (s *labelResolvingWorkspaces) resolveID(ctx context.Context, id string) (string, error) {
Comment thread
kody-ai[bot] marked this conversation as resolved.
if id == "" {
return id, nil
}
if _, err := strconv.Atoi(id); err == nil {
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 "", fmt.Errorf("workspace %q not found", id)
}
return strconv.Itoa(ws.Id), nil
}
Loading
Loading