Skip to content
Closed
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
116 changes: 116 additions & 0 deletions cmd/project_rename.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"fmt"
"time"

"github.com/GoogleCloudPlatform/scion/pkg/api"
"github.com/GoogleCloudPlatform/scion/pkg/config"
"github.com/GoogleCloudPlatform/scion/pkg/hubclient"
"github.com/spf13/cobra"
)

var projectRenameCmd = &cobra.Command{
Use: "rename <project> <new-name>",
Short: "Rename a project",
Long: `Rename a project's display name and slug.

The <project> argument can be a project name, slug, or ID. The <new-name>
argument becomes both the new display name and the basis for the new slug
(generated by converting the name to a URL-safe form).

Renaming a project updates its slug, which affects:
- Filesystem paths for hub-managed project workspaces
- Group identifiers (project:slug:agents, project:slug:members)

Renaming does NOT affect:
- The project's unique ID (agents reference projects by ID)
- Git remote configuration
- Agent records or running agents

Running agents retain the old slug until restarted. New agents will use the
updated slug immediately.

Requires Hub connectivity.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
projectRef := args[0]
newName := args[1]

newSlug := api.Slugify(newName)
if newSlug == "" {
return fmt.Errorf("invalid new name: must contain at least one alphanumeric character")
}

resolvedPath, _, err := config.ResolveProjectPath(projectPath)
if err != nil {
return fmt.Errorf("failed to resolve project path: %w", err)
}

settings, err := config.LoadSettings(resolvedPath)
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}

client, err := getHubClient(settings)
if err != nil {
return err
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

project, err := resolveProjectByNameOrID(ctx, client, projectRef)
if err != nil {
return fmt.Errorf("failed to find project: %w", err)
}

updated, err := client.Projects().Update(ctx, project.ID, &hubclient.UpdateProjectRequest{
Name: newName,
Slug: newSlug,
})
if err != nil {
return fmt.Errorf("failed to rename project: %w", err)
}

if isJSONOutput() {
return outputJSON(ActionResult{
Status: "success",
Command: "project rename",
Message: fmt.Sprintf("Project renamed from %q to %q", project.Name, updated.Name),
Details: map[string]interface{}{
"id": updated.ID,
"name": updated.Name,
"slug": updated.Slug,
"old_name": project.Name,
"old_slug": project.Slug,
},
})
}

fmt.Printf("Project renamed: %s → %s\n", project.Name, updated.Name)
if project.Slug != updated.Slug {
fmt.Printf("Slug updated: %s → %s\n", project.Slug, updated.Slug)
}
return nil
},
}

func init() {
projectCmd.AddCommand(projectRenameCmd)
}
69 changes: 69 additions & 0 deletions pkg/hub/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5267,6 +5267,7 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string

var updates struct {
Name string `json:"name,omitempty"`
Slug string `json:"slug,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Visibility string `json:"visibility,omitempty"`
DefaultRuntimeBrokerID string `json:"defaultRuntimeBrokerId,omitempty"`
Expand All @@ -5277,9 +5278,27 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string
return
}

oldSlug := project.Slug

if updates.Name != "" {
project.Name = updates.Name
}
if updates.Slug != "" {
newSlug := api.Slugify(updates.Slug)
if newSlug == "" {
BadRequest(w, "Invalid slug: must contain at least one alphanumeric character")
return
}
if newSlug != oldSlug {
existing, err := s.store.GetProjectBySlug(ctx, newSlug)
if err == nil && existing.ID != project.ID {
writeError(w, http.StatusConflict, ErrCodeConflict,
fmt.Sprintf("A project with slug %q already exists", newSlug), nil)
return
}
project.Slug = newSlug
}
}
if updates.Labels != nil {
project.Labels = updates.Labels
}
Expand All @@ -5295,11 +5314,61 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string
return
}

// If the slug changed, update associated group slugs and filesystem paths.
if project.Slug != oldSlug {
s.migrateProjectSlug(ctx, project, oldSlug)
}

s.events.PublishProjectUpdated(ctx, project)

writeJSON(w, http.StatusOK, project)
}

// migrateProjectSlug updates group slugs and filesystem paths after a project slug change.
// This is best-effort: failures are logged but don't roll back the rename.
func (s *Server) migrateProjectSlug(ctx context.Context, project *store.Project, oldSlug string) {
newSlug := project.Slug

// Migrate the project agents group slug.
oldAgentsSlug := "project:" + oldSlug + ":agents"
newAgentsSlug := "project:" + newSlug + ":agents"
if group, err := s.store.GetGroupBySlug(ctx, oldAgentsSlug); err == nil {
group.Slug = newAgentsSlug
group.Name = project.Name + " Agents"
if err := s.store.UpdateGroup(ctx, group); err != nil {
slog.Warn("failed to migrate project agents group slug",
"project_id", project.ID, "old_slug", oldAgentsSlug, "new_slug", newAgentsSlug, "error", err)
}
}

// Migrate the project members group slug.
oldMembersSlug := "project:" + oldSlug + ":members"
newMembersSlug := "project:" + newSlug + ":members"
if group, err := s.store.GetGroupBySlug(ctx, oldMembersSlug); err == nil {
group.Slug = newMembersSlug
group.Name = project.Name + " Members"
if err := s.store.UpdateGroup(ctx, group); err != nil {
slog.Warn("failed to migrate project members group slug",
"project_id", project.ID, "old_slug", oldMembersSlug, "new_slug", newMembersSlug, "error", err)
}
}

// Migrate hub-managed project filesystem paths (best-effort).
if oldPath, err := hubManagedProjectPath(oldSlug); err == nil {
if newPath, err := hubManagedProjectPath(newSlug); err == nil {
// Only rename if the old path exists and the new path doesn't.
if _, statErr := os.Stat(oldPath); statErr == nil {
if _, statErr := os.Stat(newPath); os.IsNotExist(statErr) {
if err := os.Rename(oldPath, newPath); err != nil {
slog.Warn("failed to rename project workspace directory",
"project_id", project.ID, "old_path", oldPath, "new_path", newPath, "error", err)
}
}
}
}
}
}

func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request, id string) {
ctx := r.Context()

Expand Down
Loading
Loading