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-20260913153654-28994179071e
go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af
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
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -753,8 +753,8 @@ go.lumeweb.com/oauth v0.1.6 h1:6c6LrXxMwx5klbq3OshzXjkGwvYVjAQhjX2FxCAgqqg=
go.lumeweb.com/oauth v0.1.6/go.mod h1:Bfdxi1gkv+Ypj9yj9uOSmKnbZX8C7q7Pyn1OdPyuCbM=
go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9 h1:kb2adBZ8Ed11yuXoQw2I8LfmbPjpPkc0NPwyShKJ5KE=
go.lumeweb.com/opmesh v0.0.0-20260907112428-ade506e64ae9/go.mod h1:DbO27Lr6pBSzC+HogQMNNVEZMvkAHYwckL7i79aTETM=
go.lumeweb.com/pinner v0.0.0-20260913153654-28994179071e h1:g5+ZZKO0jgN8oHbumHw0tKxnqZVXpodyIYhPjsRIdgs=
go.lumeweb.com/pinner v0.0.0-20260913153654-28994179071e/go.mod h1:doOkNprjTTIZdim9LiOpEnjmxxWsTAb9pTRYkyswXpw=
go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af h1:oi/hQ2cYZaKpl3SHGbPbVSj7waf0CcNKNIzPboaAvzY=
go.lumeweb.com/pinner v0.0.0-20260913155112-17000af806af/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
1 change: 1 addition & 0 deletions internal/cli/catalog_deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func buildCatalogOpsDeps(factory ...ConfigManagerFactory) *mcpadapter.CatalogDep
VaultSetup: catalogops.VaultDeps(vaultSetupDeps),
Pins: catalogops.PinsDeps(catalogPinningDeps(cfgFactory)),
Websites: catalogops.WebsitesDeps(catalogWebsitesDeps(cfgFactory)),
Workspaces: catalogops.WorkspacesDeps(catalogWorkspacesDeps(cfgFactory)),
DNS: catalogops.DNSDeps(catalogDNSDeps(cfgFactory)),
IPNS: catalogops.IPNSDeps(catalogIPNSDeps(cfgFactory)),
ENS: catalogops.ENSDeps(catalogENSDeps(cfgFactory)),
Expand Down
40 changes: 40 additions & 0 deletions internal/cli/catalog_deps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,46 @@ func TestProductionCatalogOpsBundleExposesAdminDomain(t *testing.T) {
}
}

// TestProductionCatalogOpsBundleExposesWorkspacesDomain pins that the
// workspaces domain is wired into the production MCP surface assembled from
// buildCatalogOpsDeps (the bundle handed to the server via WithCatalogOps).
// Regression for a Kody finding: buildCatalogOpsDeps threaded everything except
// Workspaces, so the workspaces_* ops were advertised on the surface but ran
// against a zero WorkspacesDeps{} (all-nil service functions) and failed with
// "service unavailable" at invocation.
func TestProductionCatalogOpsBundleExposesWorkspacesDomain(t *testing.T) {
cfgMgr := configmocks.NewMockManager(t)
prev := configManagerFactory
configManagerFactory = func() (config.Manager, error) { return cfgMgr, nil }
defer func() { configManagerFactory = prev }()

bundle := buildCatalogOpsDeps()
oc, err := mcpadapter.AssembleCatalogOps(bundle, mcpadapter.FullDomainScope, false)
require.NoError(t, err, "production deps must assemble a catalog")

descs, err := catalogmcp.NewCompiler().Compile(oc)
require.NoError(t, err)

names := map[string]bool{}
for _, d := range descs {
names[d.Name] = true
}
for _, want := range []string{
"workspaces_list",
"workspaces_create",
"workspaces_get",
"workspaces_attach",
"workspaces_suspend",
"workspaces_resume",
"workspaces_access",
"workspaces_delete",
} {
if !names[want] {
t.Fatalf("production surface missing workspaces tool %q", want)
}
}
}

// domainPrefix returns the leading domain token of a dotted operation name.
func domainPrefix(name string) string {
for i := 0; i < len(name); i++ {
Expand Down
225 changes: 225 additions & 0 deletions internal/cli/catalog_workspaces_wiring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package cli

import (
"context"
"fmt"

"github.com/urfave/cli/v3"
ipfs "go.lumeweb.com/ipfs-sdk"

opmesh "go.lumeweb.com/opmesh"
"go.lumeweb.com/pinner-cli/internal/clicatalog"
"go.lumeweb.com/pinner/catalogops"
"go.lumeweb.com/pinner/core/config"
"go.lumeweb.com/pinner/core/workspaces"
)

// catalog_workspaces_wiring.go adapts the workspaces domain operations in
// internal/catalogops to the urfave CLI: it compiles the operations' command
// tree through the shape model in internal/clicatalog/shapes_workspaces.go and
// CompileCommandTree, renders each handler's result through the Output
// formatter, and maps the destructive --force gate onto operation inputs. IO
// and CLI concerns (the destructive force gate and sensitive credential
// rendering) live here, not in catalogops.
//
// Workspaces are deliberately a SEPARATE command tree from websites (an
// isolated runtime, not a domain-to-CID mapping): they mount as the top-level
// `workspaces` group (list/create/get/attach/suspend/resume/access/delete).
// Runtime workspace resolve is NOT exposed as a user operation.

// catalogWorkspacesDeps builds the catalogops.WorkspacesDeps from the live CLI
// wiring. Service construction uses the core factories; all config is read
// lazily per invocation.
func catalogWorkspacesDeps(factory ...ConfigManagerFactory) catalogops.WorkspacesDeps {
cfgFactory := resolveConfigFactory(factory...)
return catalogops.WorkspacesDeps{
CfgMgr: func() config.Manager {
cfgMgr, err := cfgFactory()
if err != nil {
return nil
}
return cfgMgr
},
Secure: func() bool {
cfgMgr, err := cfgFactory()
if err != nil {
return false
}
return GetSecureSetting(nil, cfgMgr)
},
ServiceFactory: workspaces.DefaultFactory,
NewAuthenticated: func(cfgMgr config.Manager, secure bool, token string) (workspaces.Service, error) {
return workspaces.NewAuthenticated(cfgMgr, token, secure)
},
GetAuthToken: func() string {
cfgMgr, err := cfgFactory()
if err != nil {
return ""
}
return cfgMgr.Config().AuthToken
},
}
}

// workspacesCatalogDepsVar is an indirection so the wiring and the renderer
// can both reach the canonical operation list without rebuilding it repeatedly.
var workspacesCatalogDepsVar = catalogops.WorkspacesDeps(catalogWorkspacesDeps())

// newWorkspacesCatalogCommands compiles the workspaces catalog operations and
// returns the top-level "workspaces" subcommands they produce (list, create,
// get, attach, suspend, resume, access, delete). It declares the operations'
// command shape in internal/clicatalog/shapes_workspaces.go and materializes
// the tree through mount-owned leaf and parent builders.
func newWorkspacesCatalogCommands() []*cli.Command {
root := clicatalog.WorkspacesDomainRoot
ops := catalogops.WorkspacesOperations(workspacesCatalogDepsVar)

cmds, err := clicatalog.CompileCommandTree(
ops,
clicatalog.WorkspacesShapes,
root,
workspacesCatalogConfig(),
buildWorkspacesLeaf,
buildCLIParent,
)
if err != nil {
// Compilation of well-formed catalog operations cannot fail; if it
// does we must not silently skip the workspaces group.
panic(fmt.Sprintf("catalog compile workspaces: %v", err))
}
return cmds
}

// buildWorkspacesLeaf is the mount-owned leaf builder materializing one
// workspaces leaf into an urfave *cli.Command. Shape
// (name/category/aliases/flags/usage) comes from the clicatalog model via
// NewCLILeaf; behavior (the catalog action adapter) stays mount-owned here.
func buildWorkspacesLeaf(loc clicatalog.LeafLocator, cfg any) (*cli.Command, error) {
base, err := clicatalog.NewCLILeaf(loc.Op, loc.Name, loc.Category, loc.Aliases)
if err != nil {
return nil, err
}
relaxFlagRequired(base)
base.Action = catalogActionAdapter(loc.Op, cfg.(CatalogAdapterConfig))
return base, nil
}

// workspacesCatalogConfig returns the CatalogAdapterConfig that expresses the
// workspaces domain's exact per-invocation behavior on top of the shared
// catalogActionAdapter pipeline: the result renderer, the per-invocation
// --auth-token override, and the destructive --force gate (workspaces delete
// only). All remaining fields stay nil so the shared pipeline's safe defaults
// apply.
func workspacesCatalogConfig() CatalogAdapterConfig {
return CatalogAdapterConfig{
Renderer: renderWorkspacesResult,
HonorAuthTokenOverride: true,

// Destructive gate (workspaces delete). The shared pipeline enforces
// --force/--confirm; with a target workspace and no --force, refuse
// loudly (non-zero exit) with the "workspaces delete:" message. With
// no target (no "id" arg), fall through so the handler's required-arg
// validation produces a non-zero exit.
DestructiveGate: GateForceReject(
func(ic *CatalogInvokeContext) bool {
return opmesh.StrArg(ic.Input, "id", "") != ""
},
func(ic *CatalogInvokeContext) string {
return "workspaces delete: pass --force to confirm this destructive operation"
},
),
}
}

// renderWorkspacesResult is the catalog.RenderFunc that renders a workspaces
// handler's typed result through the CLI Output formatter. It is the single
// rendering home for catalog-driven workspaces commands.
func renderWorkspacesResult(_ context.Context, c *cli.Command, op opmesh.Operation, result any) error {
output := setupOutput(c)

// Guard against a typed-nil single-object result (interface non-nil,
// underlying POINTER nil): a handler returning (nil, nil) yields a typed
// nil here, and the pointer branches below would dereference it and panic.
if result != nil && isNilPointerResult(result) {
return fmt.Errorf("%s returned no result", op.Name())
}

switch r := result.(type) {
case catalogops.ListResult:
return renderListResult(output, r)

case *ipfs.WorkspaceResponse:
// workspaces get/create/attach/suspend/resume all return a workspace.
if output.IsJSON() {
return output.PrintJSON(r)
}
renderWorkspaceHuman(output, r)
return nil

case *ipfs.WorkspaceAccessResponse:
// workspaces access. This carries SENSITIVE proxy Basic Auth
// credentials. Human output is rendered deliberately as labeled
// fields (never buried in a shared table/list). JSON output is the
// caller's explicit choice and prints the raw document.
if output.IsJSON() {
return output.PrintJSON(r)
}
renderWorkspaceAccessHuman(output, r)
return nil

case *catalogops.WorkspaceDeleteResult:
if output.IsJSON() {
return output.PrintJSON(map[string]any{"success": true, "id": r.ID, "status": r.Status})
}
output.Printfln("Workspace %s deleted successfully", r.ID)
return nil

default:
if result == nil {
return nil
}
return fmt.Errorf("catalog command %q returned an unroutable result type %T", op.Name(), result)
}
}

// renderWorkspaceHuman renders the fields of a single workspace (used by get,
// create, attach, suspend, resume).
func renderWorkspaceHuman(output Output, w *ipfs.WorkspaceResponse) {
output.Printfln("Workspace Details")

fields := []Field{
{"ID", fmt.Sprintf("%d", w.Id)},
{"Domain", w.Domain},
{"Label", w.Label},
{"Status", w.Status},
}
if w.WebsiteId != nil {
fields = append(fields, Field{"Website ID", fmt.Sprintf("%d", *w.WebsiteId)})
} else {
fields = append(fields, Field{"Website ID", "-"})
}
if w.Error != nil && *w.Error != "" {
fields = append(fields, Field{"Error", *w.Error})
}
fields = append(fields,
Field{"Created", w.Created.Format("2006-01-02 15:04:05")},
Field{"Updated", w.Updated.Format("2006-01-02 15:04:05")},
)

output.PrintFields(FieldGroup{Fields: fields})
}

// renderWorkspaceAccessHuman renders the workspace proxy access credentials.
// These are SENSITIVE; the username/password are deliberately rendered as
// labeled fields on their own (the op is HumanOnly at the catalog layer, and
// a human explicitly invoking `workspaces access` is shown the credential
// deliberately rather than folded into a shared table/list).
func renderWorkspaceAccessHuman(output Output, r *ipfs.WorkspaceAccessResponse) {
output.Printfln("Workspace Proxy Access Credentials")
output.PrintFields(FieldGroup{
Fields: []Field{
{"Username", r.Username},
{"Password", r.Password},
},
})
}
2 changes: 2 additions & 0 deletions internal/cli/command_registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func TestCommandRegistration_RootSubcommands(t *testing.T) {
"dns",
"ipns",
"websites",
"workspaces",
"dag",
"export",
"admin",
Expand Down Expand Up @@ -173,6 +174,7 @@ func TestCommandRegistration_Categories(t *testing.T) {
"dns": "Management",
"ipns": "Management",
"websites": "Management",
"workspaces": "Management",
"config": "System",
"doctor": "System",
"bench": "System",
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ For more help on any command: pinner <command> --help`,
newDNSCommand(),
newIPNSCommand(),
newWebsitesCommand(),
newWorkspacesCommand(),
newDagCommand(),
newExportCommand(),
newAdminCommand(),
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/workspaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package cli

import "github.com/urfave/cli/v3"

// newWorkspacesCommand mounts the workspaces domain as a top-level command
// tree. The parent is catalog-driven: the core workspace lifecycle
// subcommands (list, create, get, attach, suspend, resume, access, delete) are
// compiled from the canonical operation catalog (internal/catalogops) — see
// catalog_workspaces_wiring.go.
//
// Workspaces are deliberately a SEPARATE concept from websites (an isolated
// runtime with its own portal API key and proxy endpoint, not a
// domain-to-CID mapping), so they mount as their own `workspaces` tree rather
// than nesting under `websites`. To map domains to CIDs use the 'websites'
// command tree instead.
func newWorkspacesCommand() *cli.Command {
cmds := newWorkspacesCatalogCommands()

return &cli.Command{
Name: "workspaces",
Category: "Management",
Usage: "Manage workspaces",
Description: `Manage isolated workspaces: an isolated runtime with its own portal API key and proxy endpoint. Covers create/list/get, attach to a website you own (the publish link), suspend/resume its runtime, fetch proxy access credentials (access), and delete.

Workspaces are a separate concept from websites. To associate domain names with CIDs so your IPFS/IPNS content is served over custom domains, use the 'websites' command tree instead. A website association is only an optional link on a workspace (attach), not what a workspace is for.`,
Commands: cmds,
}
}
Loading
Loading