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
58 changes: 46 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ cmd/
internal/
activities/ authenticated oCIS Graph activity-history client
command/ Cobra command tree and input validation
app/ application use-case orchestration
app/ public application facade and runtime composition
admin/ account and global Space administration policy
archive/ archive-download application policy
filesystem/ remote filesystem, batch, and metadata policy
share/ direct, received, and public-link share policy
spaces/ project Space lifecycle and membership policy
sync/ sync execution, state, jobs, and recovery policy
apperror/ stable error categories and exit-code mapping
archiver/ authenticated archive-download protocol client
auth/ OIDC protocol implementation
Expand Down Expand Up @@ -40,10 +46,16 @@ test/

## Dependency direction

`cmd/ocis` depends on `internal/command`, which depends on `internal/app`.
The application layer may depend on focused infrastructure packages under `internal`,
but application and infrastructure packages never depend on Cobra or the
command package.
`cmd/ocis` depends on `internal/command`, which depends on the public
`internal/app` facade. Large application domains live in subpackages such as
`internal/app/admin`, `internal/app/archive`, `internal/app/filesystem`,
`internal/app/share`, `internal/app/spaces`, and `internal/app/sync`. The facade composes runtime
clients into their narrow ports and preserves the public request and result
types used by Cobra. Domain subpackages never import the parent `internal/app`
package, which makes the dependency boundary compiler-enforced and prevents
them from reaching unrelated package-level helpers. The application layer may
depend on focused infrastructure packages under `internal`, but application
and infrastructure packages never depend on Cobra or the command package.

The executable entrypoint is intentionally small. It translates an application
error to a message and non-zero exit code; business behavior remains testable
Expand All @@ -55,19 +67,38 @@ without starting a subprocess.
map errors to exit codes.
- `internal/command`: define Cobra commands, flags, aliases, help, completion,
and syntactic validation.
- `internal/app`: expose typed use-case requests, select profiles, and coordinate
authentication and protocol operations. Focused services such as
`bidirectional_sync_service.go`, `config_service.go`,
`batch_service.go`, `filesystem_service.go`, `filesystem_tree_service.go`,
- `internal/app`: expose the compatibility facade used by Cobra, select
profiles, compose authenticated runtime clients, classify errors, and adapt
narrow domain ports. Focused services such as
`config_service.go`, `batch_service.go`, `filesystem_service.go`, `filesystem_tree_service.go`,
`filesystem_du_service.go`, `filesystem_touch_service.go`,
`filesystem_walk.go`, `metadata_service.go`,
`activity_service.go`, `event_service.go`, `notification_service.go`,
`share_overview_service.go`,
`space_member_service.go`, `space_update_service.go`,
`space_lifecycle_service.go`, and
the split `admin_*_service.go` files keep each use case independent;
`space_lifecycle_service.go` keep the remaining use cases independent;
`admin_guard.go` owns account-admin and MFA preflights, while `runtime.go`
contains shared application wiring.
- `internal/app/archive`: own archive selection, recursive preflight, limits,
output, and safe local installation through a narrow client factory. It
cannot access unrelated authentication, administration, sync, or sharing
helpers in the parent package.
- `internal/app/filesystem`: own remote file operations, bounded traversal,
batch execution, transfer presentation, and resource metadata policy behind
a narrow authenticated WebDAV/Graph port.
- `internal/app/admin`: own account inventory and mutation, advertised role
assignment, MFA-gated administration policy, and global Space inventory
through narrow Graph and OCS capability ports.
- `internal/app/share`: own direct, federated, received, overview, and
public-link application policy through a narrow authenticated client port.
It cannot access unrelated archive, administration, sync, or configuration
helpers in the parent package.
- `internal/app/spaces`: own project Space creation, updates, lifecycle,
details, recipient resolution, and membership policy through a narrow Graph
port. Profile persistence remains in the parent adapter.
- `internal/app/sync`: own one-way and bidirectional execution, conflict
policy, named jobs, local state, and interrupted-run recovery through narrow
WebDAV and persistence ports. It cannot access authentication secrets,
configuration storage, administration, or sharing policy in the parent.
- `internal/apperror`: classify usage, authentication, not-found, and conflict
errors without coupling application services to Cobra.
- `internal/archiver`: validate same-origin server-advertised archive endpoints,
Expand Down Expand Up @@ -169,6 +200,9 @@ Fast package tests remain Docker-independent.
## Design rules

- Dependencies point inward toward use cases.
- New large application domains belong in `internal/app/<domain>` with a
narrow client or repository port. Do not grow the parent package when a use
case can be isolated without creating an import cycle.
- Configuration I/O is isolated and tested.
- Destructive commands fail closed.
- Destructive Space operations require explicit intent in both the Cobra and
Expand Down
10 changes: 7 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ debugging targets.
## Design rules

- Keep Cobra code in `internal/command` thin.
- Put use-case orchestration in `internal/app`.
- Put small cross-domain orchestration in the `internal/app` facade. Put large
domain policy in `internal/app/<domain>` behind narrow injected ports; domain
packages must not import the parent `internal/app` package. Current domain
boundaries are `admin`, `archive`, `filesystem`, `share`, `spaces`, and
`sync`.
- Keep authentication and WebDAV protocol details out of commands.
- Pass contexts, dependencies, and output streams explicitly.
- Add tests at the narrowest package boundary.
Expand All @@ -34,8 +38,8 @@ debugging targets.
- Format with `gofmt`.
- Run `make check`, including per-package coverage gates, golangci-lint v2.12.2,
and `gosec`.
- Keep `app`, `auth`, `graph`, `httpapi`, `sharing`, `transfer`, and `webdav`
at or above 75% statement coverage.
- Keep the complete `app/...` tree, plus `auth`, `graph`, `httpapi`, `sharing`,
`transfer`, and `webdav`, at or above 75% statement coverage.
- Every `//nolint` directive must name the linter and explain why suppression
is safe using a second `//`, for example `//nolint:gosec // reason`.
- Write lowercase, contextual errors without trailing punctuation.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/mzner/ocis-cli

go 1.26.5
go 1.26.6

require (
github.com/bdragon300/tusgo v0.2.0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package app
package admin

import (
"context"
Expand All @@ -9,11 +9,11 @@ import (
"github.com/mzner/ocis-cli/internal/graph"
)

func runAdminGroupCreate(
func RunGroupCreate(
ctx context.Context,
request AdminGroupCreateRequest,
request GroupCreateRequest,
selectedProfile string,
options RunOptions,
options Options,
) error {
request.Name = strings.TrimSpace(request.Name)
if request.Name == "" {
Expand All @@ -22,10 +22,13 @@ func runAdminGroupCreate(
fmt.Errorf("group name is required"),
)
}
selected, err := newAdminMutationClient(ctx, selectedProfile, options)
selected, err := options.NewClient(ctx, selectedProfile)
if err != nil {
return err
}
if err := options.RequireAccountAdmin(ctx, selected); err != nil {
return err
}
if request.DryRun {
return output(
options, "admin-group-change",
Expand All @@ -35,7 +38,7 @@ func runAdminGroupCreate(
"Would create group %s\n", request.Name,
)
}
created, err := selected.graphClient().CreateGroup(
created, err := selected.Graph().CreateGroup(
ctx, graph.CreateGroupRequest{DisplayName: request.Name},
)
if err != nil {
Expand All @@ -47,11 +50,11 @@ func runAdminGroupCreate(
)
}

func runAdminGroupUpdate(
func RunGroupUpdate(
ctx context.Context,
request AdminGroupUpdateRequest,
request GroupUpdateRequest,
selectedProfile string,
options RunOptions,
options Options,
) error {
request.Identifier = strings.TrimSpace(request.Identifier)
request.Name = strings.TrimSpace(request.Name)
Expand All @@ -61,10 +64,13 @@ func runAdminGroupUpdate(
fmt.Errorf("group identifier and --name are required"),
)
}
selected, err := newAdminMutationClient(ctx, selectedProfile, options)
selected, err := options.NewClient(ctx, selectedProfile)
if err != nil {
return err
}
if err := options.RequireAccountAdmin(ctx, selected); err != nil {
return err
}
group, err := resolveMutationGroup(ctx, selected, request.Identifier)
if err != nil {
return err
Expand All @@ -85,7 +91,7 @@ func runAdminGroupUpdate(
)
}
name := request.Name
if err := selected.graphClient().UpdateGroup(
if err := selected.Graph().UpdateGroup(
ctx, group.ID, graph.UpdateGroupRequest{DisplayName: &name},
); err != nil {
return adminMutationError("group", err)
Expand All @@ -102,11 +108,11 @@ func runAdminGroupUpdate(
)
}

func runAdminGroupDelete(
func RunGroupDelete(
ctx context.Context,
request AdminGroupDeleteRequest,
request GroupDeleteRequest,
selectedProfile string,
options RunOptions,
options Options,
) error {
request.Identifier = strings.TrimSpace(request.Identifier)
if request.Identifier == "" {
Expand All @@ -115,10 +121,13 @@ func runAdminGroupDelete(
fmt.Errorf("group identifier is required"),
)
}
selected, err := newAdminMutationClient(ctx, selectedProfile, options)
selected, err := options.NewClient(ctx, selectedProfile)
if err != nil {
return err
}
if err := options.RequireAccountAdmin(ctx, selected); err != nil {
return err
}
group, err := resolveMutationGroup(ctx, selected, request.Identifier)
if err != nil {
return err
Expand All @@ -137,7 +146,7 @@ func runAdminGroupDelete(
group.DisplayName, group.ID,
)
}
if err := selected.graphClient().DeleteGroup(ctx, group.ID); err != nil {
if err := selected.Graph().DeleteGroup(ctx, group.ID); err != nil {
return adminMutationError("group", err)
}
return output(
Expand All @@ -150,11 +159,11 @@ func runAdminGroupDelete(
)
}

func runAdminGroupMemberMutation(
func RunGroupMemberMutation(
ctx context.Context,
request AdminGroupMemberMutationRequest,
request GroupMemberMutationRequest,
selectedProfile string,
options RunOptions,
options Options,
) error {
request.Group = strings.TrimSpace(request.Group)
request.User = strings.TrimSpace(request.User)
Expand All @@ -164,10 +173,13 @@ func runAdminGroupMemberMutation(
fmt.Errorf("group and user identifiers are required"),
)
}
selected, err := newAdminMutationClient(ctx, selectedProfile, options)
selected, err := options.NewClient(ctx, selectedProfile)
if err != nil {
return err
}
if err := options.RequireAccountAdmin(ctx, selected); err != nil {
return err
}
group, err := resolveMutationGroup(ctx, selected, request.Group)
if err != nil {
return err
Expand Down Expand Up @@ -198,9 +210,9 @@ func runAdminGroupMemberMutation(
)
}
if request.Remove {
err = selected.graphClient().RemoveGroupMember(ctx, group.ID, user.ID)
err = selected.Graph().RemoveGroupMember(ctx, group.ID, user.ID)
} else {
err = selected.graphClient().AddGroupMember(ctx, group.ID, user.ID)
err = selected.Graph().AddGroupMember(ctx, group.ID, user.ID)
}
if err != nil {
return adminMutationError("group membership", err)
Expand Down
Loading