Skip to content

Comments

feat: add service exec and resolve IDs from service#173

Merged
yuaanlin merged 1 commit intomainfrom
feat/service-exec-and-remove-context-defaults
Feb 20, 2026
Merged

feat: add service exec and resolve IDs from service#173
yuaanlin merged 1 commit intomainfrom
feat/service-exec-and-remove-context-defaults

Conversation

@yuaanlin
Copy link
Member

@yuaanlin yuaanlin commented Feb 20, 2026

Summary

  • Add zeabur service exec command to run commands inside service containers via the executeCommand GraphQL mutation
  • Remove context-based ID resolution from all commands — when a service ID is provided, environment and project IDs are now derived from the service itself instead of relying on project context
  • Add ResolveEnvironmentIDByServiceID helper to internal/util/env.go

Motivation

The old design resolved environment IDs from the project context, which could point to a completely different project than the service belongs to, causing NOT_RUNNING_SERVICE errors even when the service is active.

Now: service ID → fetch service → get project ID → resolve environment. No more wrong env IDs.

Changed commands (32 files)

  • New: service exec — execute commands in service containers
  • Service: restart, redeploy, suspend, delete, expose, get, metric, instruction, network, update/tag, list, deploy
  • Domain: create, list, delete
  • Variable: create, list, update, delete, env
  • Deployment: get, list, log
  • Other: deploy, upload

Test plan

  • go build ./... and go test ./... pass
  • zeabur service exec --id <svc> -- ls resolves env from service's project
  • zeabur service restart --id <svc> no longer requires project context
  • Interactive mode still prompts correctly when no flags provided

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added service exec command to execute commands inside service containers with results and exit codes.
    • Added --region flag to template deployment for automatic project creation in specified regions.
  • Refactor

    • Simplified command parameter resolution and validation for improved reliability.

… of context

Add `zeabur service exec` to run commands inside service containers via
the `executeCommand` GraphQL mutation.

Remove context-based ID resolution from all commands. When a service ID
is provided, environment and project IDs are now derived from the
service itself rather than relying on the (often wrong) project context.
This eliminates mismatches where context points to a different project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 20, 2026

Walkthrough

The PR systematically removes context-based parameter defaulting from command initialization across ~25 command files, replacing PreRunE hooks with explicit API-based ID resolution during execution. A new service exec command is added, along with supporting API methods and model types for command execution.

Changes

Cohort / File(s) Summary
Service Commands - Parameter Resolution Refactoring
internal/cmd/service/list/list.go, internal/cmd/service/get/get.go, internal/cmd/service/delete/delete.go, internal/cmd/service/restart/restart.go, internal/cmd/service/suspend/suspend.go
Removed PreRunE context initialization; added non-interactive logic to resolve service ID from name, enforce --id or --name requirement, and derive environmentID from serviceID when absent.
Service Commands - Additional Operations
internal/cmd/service/metric/metric.go, internal/cmd/service/instruction/instruction.go, internal/cmd/service/redeploy/redeploy.go
Removed context-based defaults and paramCheck validation; introduced explicit name-to-ID resolution, environment resolution via service ID, and consolidated error handling in non-interactive flows.
Service Commands - Specialized Operations
internal/cmd/service/expose/expose.go, internal/cmd/service/update/tag/tag.go, internal/cmd/service/deploy/deploy.go
Removed context dependencies; added explicit --project-id flag handling (deploy), service-based project/environment resolution (expose), and tag validation (tag).
Service Exec Command (New)
internal/cmd/service/exec/exec.go
Introduces new service exec command to run commands inside service containers, with interactive/non-interactive paths, service/environment resolution, and exit code propagation.
Service Command Registration
internal/cmd/service/service.go
Registered new exec subcommand in service command tree.
Domain Commands
internal/cmd/domain/create/create.go, internal/cmd/domain/delete/delete.go, internal/cmd/domain/list/list.go
Removed context-based PreRunE initialization; added non-interactive name-to-ID resolution and environment resolution via service ID; removed paramCheck validation.
Deployment Commands
internal/cmd/deployment/get/get.go, internal/cmd/deployment/list/list.go, internal/cmd/deployment/log/log.go
Removed context defaulting and PreRunE chains; added direct deployment-ID path support (get), enforced --service-id or --service-name requirement (list/log), introduced environment resolution by serviceID.
Variable Commands
internal/cmd/variable/create/create.go, internal/cmd/variable/delete/delete.go, internal/cmd/variable/list/list.go, internal/cmd/variable/update/update.go, internal/cmd/variable/env/env.go
Removed context initialization; added non-interactive service name-to-ID resolution, explicit --id/--name validation, and environment resolution via service ID for all variable operations.
Other Commands
internal/cmd/deploy/deploy.go, internal/cmd/upload/upload.go, internal/cmd/template/deploy/deploy.go
Removed PreRunE context checks (deploy, upload); added region-based project creation with fallback to ProjectCreatePreferred (template deploy).
Utility Functions
internal/util/env.go
Added ResolveEnvironmentIDByServiceID function that retrieves service by ID, validates project association, and delegates to existing ResolveEnvironmentID; improved error handling in ResolveEnvironmentID.
API Interface & Implementation
pkg/api/interface.go, pkg/api/service.go
Added ExecuteCommand method to ServiceAPI interface and client implementation; method executes GraphQL mutation to run commands in service containers.
Model Types
pkg/model/command.go
New CommandResult struct with ExitCode and Output fields to represent command execution results.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • pan93412
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add service exec and resolve IDs from service' clearly summarizes the main changes: adding a new service exec command and refactoring ID resolution to use service-derived IDs instead of project context.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/service-exec-and-remove-context-defaults

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
internal/cmd/variable/delete/delete.go (2)

134-154: ⚠️ Potential issue | 🟡 Minor

Display messages may show empty service name when --id is used.

Same issue as other variable commands - opts.name will be empty if --id is provided directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/variable/delete/delete.go` around lines 134 - 154, The displayed
service name can be empty when the command was invoked with --id; update all
user-facing messages to fall back to the identifier when opts.name is empty
(e.g. use a local variable like displayName := opts.name; if displayName == "" {
displayName = opts.id }) and use displayName in spinner.WithSuffix, the
fmt.Errorf call, and f.Log.Infof so messages never show an empty service name;
make no behavioral changes to UpdateVariables or error handling.

132-152: ⚠️ Potential issue | 🟠 Major

Bug: Spinner is created but never started.

The spinner s is created at line 132 but s.Start() is never called. The subsequent s.Stop() calls at lines 145, 149, and 152 will attempt to stop an unstarted spinner, which is a no-op but indicates missing functionality.

Suggested fix
 	s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
 		spinner.WithColor(cmdutil.SpinnerColor),
 		spinner.WithSuffix(fmt.Sprintf(" Deleting variables of service: %s...", opts.name)),
 	)
+	s.Start()

 	for k, v := range opts.keys {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/variable/delete/delete.go` around lines 132 - 152, The spinner
`s` is created but never started; call s.Start() immediately after creating the
spinner (right after spinner.New) and then defer s.Stop() so the spinner is
stopped on all exit paths; update the block around the spinner creation
(variable `s`) in the delete flow, start the spinner before calling
f.ApiClient.UpdateVariables (which updates opts.keys via UpdateVariables) and
either remove the explicit s.Stop() calls in the error branches or keep them
(defer ensures a final stop) to avoid leaving the spinner unstarted or leaking
runtime state for service `opts.name`.
internal/cmd/variable/update/update.go (2)

130-143: ⚠️ Potential issue | 🟠 Major

Bug: Spinner is created but never started.

The spinner s is created at line 130 but s.Start() is never called. The s.Stop() calls at lines 136, 140, and 143 will operate on an unstarted spinner.

Suggested fix
 	s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
 		spinner.WithColor(cmdutil.SpinnerColor),
 		spinner.WithSuffix(fmt.Sprintf(" Updating variables of service: %s...", opts.name)),
 	)
+	s.Start()
+
 	createVarResult, err := f.ApiClient.UpdateVariables(context.Background(), opts.id, opts.environmentID, opts.keys)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/variable/update/update.go` around lines 130 - 143, Spinner `s`
is constructed but never started; call s.Start() immediately after creating it
and ensure it is stopped on all exit paths (preferably with defer s.Stop() right
after starting) around the call to f.ApiClient.UpdateVariables in function
handling opts (use the same spinner variable `s`), so the spinner runs while
UpdateVariables executes and is reliably stopped whether UpdateVariables returns
an error, false, or success.

43-51: ⚠️ Potential issue | 🔴 Critical

Bug: opts.keys is reset, discarding any values passed via --key flag.

Line 44 reinitializes opts.keys to an empty map, which discards any key-value pairs passed via the --key flag before entering either the interactive or non-interactive flow. Non-interactive mode with --key flags will not work as expected.

Suggested fix
 func runUpdateVariable(f *cmdutil.Factory, opts *Options) error {
-	opts.keys = make(map[string]string)
-
 	if f.Interactive {
+		opts.keys = make(map[string]string)
 		return runUpdateVariableInteractive(f, opts)
 	} else {
 		return runUpdateVariableNonInteractive(f, opts)
 	}
 }

Only initialize opts.keys in the interactive path where it's needed for the prompt flow. In non-interactive mode, opts.keys should retain the values passed via --key.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/variable/update/update.go` around lines 43 - 51, The code
currently resets opts.keys at the start of runUpdateVariable, discarding any
--key flag values; remove that global reinitialization and instead initialize
opts.keys only when entering the interactive flow: in runUpdateVariable, check
f.Interactive and call runUpdateVariableInteractive(f, opts) (ensure that
function initializes opts.keys = make(map[string]string) before prompting),
while leaving opts.keys untouched before calling runUpdateVariableNonInteractive
so supplied --key flags are preserved; update any related logic in
runUpdateVariableInteractive and runUpdateVariableNonInteractive as needed to
rely on opts.keys being present or initialized there.
internal/cmd/variable/list/list.go (1)

85-101: ⚠️ Potential issue | 🟡 Minor

Spinner and log messages may show empty service name when --id is used.

Similar to other variable commands, when --id is provided directly without --name, the spinner suffix (line 87) and log messages (lines 97, 101, 106) will display an empty string.

Consider computing idOrName for display purposes as done in redeploy.go.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/variable/list/list.go` around lines 85 - 101, Spinner suffix and
subsequent log messages use opts.name which can be empty when the user supplies
--id; compute a display string (e.g., idOrName) that prefers opts.name but falls
back to opts.id and use that in spinner.WithSuffix and in f.Log.Infof calls
(replace occurrences referencing opts.name in this file's ListVariables flow
with the new idOrName variable); mirror the idOrName construction pattern used
in redeploy.go so all UX messages show the id when name is missing.
internal/cmd/variable/create/create.go (1)

109-112: ⚠️ Potential issue | 🟡 Minor

Spinner and log messages may show empty service name.

When --id is provided directly without --name, opts.name remains empty. The spinner suffix (line 111), error message (line 135), and success message (line 139) will display an empty string for the service name.

Consider using the same pattern as seen in redeploy.go (lines 86-89) where idOrName is computed:

Suggested fix
+	// to show friendly message
+	idOrName := opts.name
+	if idOrName == "" {
+		idOrName = opts.id
+	}
+
 	s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
 		spinner.WithColor(cmdutil.SpinnerColor),
-		spinner.WithSuffix(fmt.Sprintf(" Creating variables of service: %s...", opts.name)),
+		spinner.WithSuffix(fmt.Sprintf(" Creating variables of service: %s...", idOrName)),
 	)

Then use idOrName in subsequent messages as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/variable/create/create.go` around lines 109 - 112, The spinner
and log messages use opts.name which can be empty when the user supplied --id;
compute an idOrName fallback (e.g., idOrName := opts.name; if idOrName == "" {
idOrName = opts.id }) as done in redeploy.go, then replace usages of opts.name
in spinner.WithSuffix, the error message in the create flow, and the success
message with idOrName so the UI always shows the provided identifier; update
references around the spinner variable s and the fmt.Sprintf calls that build
those messages accordingly.
internal/cmd/service/restart/restart.go (1)

91-100: ⚠️ Potential issue | 🟡 Minor

Incorrect confirmation message: says "deploy" instead of "restart".

The confirmation prompt text is incorrect for this command.

Proposed fix
-		confirm, err := f.Prompter.Confirm(fmt.Sprintf("Are you sure to deploy service <%s>?", idOrName), true)
+		confirm, err := f.Prompter.Confirm(fmt.Sprintf("Are you sure to restart service <%s>?", idOrName), true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/service/restart/restart.go` around lines 91 - 100, The
confirmation prompt text is wrong — update the message passed to
f.Prompter.Confirm so it asks about restarting rather than deploying;
specifically change the fmt.Sprintf(...) call that currently uses "Are you sure
to deploy service <%s>?" to reference "restart" and keep the same idOrName
variable and current behavior around f.Interactive and opts.skipConfirm (the
call to f.Prompter.Confirm, the confirm/error handling, and returned results
should remain unchanged).
🧹 Nitpick comments (2)
internal/cmd/service/network/network.go (1)

39-63: Fallback display name when only --id is provided.

If users pass only --id, opts.name stays empty, so the spinner/log output reads with a blank service name. A small fallback improves UX without extra API calls.

💡 Suggested tweak
+	displayName := opts.name
+	if displayName == "" {
+		displayName = opts.id
+	}
+
 	s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
 		spinner.WithColor(cmdutil.SpinnerColor),
-		spinner.WithSuffix(fmt.Sprintf(" Fetching network information of service %s ...", opts.name)),
+		spinner.WithSuffix(fmt.Sprintf(" Fetching network information of service %s ...", displayName)),
 	)
@@
-	f.Log.Infof("Private DNS name for %s: %s", opts.name, dnsName+".zeabur.internal")
+	f.Log.Infof("Private DNS name for %s: %s", displayName, dnsName+".zeabur.internal")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/service/network/network.go` around lines 39 - 63, When only --id
is provided runNetwork leaves opts.name empty causing blank labels; set a
fallback so opts.name is populated when opts.id is present (e.g., if opts.name
== "" && opts.id != "" then set opts.name = opts.id) before any spinner/log
output or later use; update the logic in runNetwork (around uses of opts.id and
opts.name) so the display always shows a name without making extra API calls.
internal/cmd/domain/delete/delete.go (1)

109-123: Avoid unnecessary environment resolution in non-interactive delete.

RemoveDomain only uses the domain name, so resolving environmentID here adds an extra API call and a potential failure path without any functional benefit. Consider dropping this block unless you plan to use environmentID later in this function.

♻️ Suggested simplification
-	if opts.environmentID == "" && opts.id != "" {
-		envID, err := util.ResolveEnvironmentIDByServiceID(f.ApiClient, opts.id)
-		if err != nil {
-			return err
-		}
-		opts.environmentID = envID
-	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/domain/delete/delete.go` around lines 109 - 123, The code is
making an unnecessary API call to resolve environmentID via
util.ResolveEnvironmentIDByServiceID when deleting a domain even though
RemoveDomain only needs the domain name; remove the block that checks
opts.environmentID == "" and calls ResolveEnvironmentIDByServiceID so you don't
call the API or introduce a failure path, leaving only the service name → ID
resolution (util.GetServiceByName) and then call RemoveDomain with opts.name
(and opts.id if still needed); ensure no other code in this function relies on
opts.environmentID before deleting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/cmd/service/expose/expose.go`:
- Around line 65-85: The code can pass an empty projectID into ExposeService
when --env-id is provided because service.Project is not validated; add a guard
so we fail fast: after fetching service (in the same block that currently sets
opts.environmentID via util.ResolveEnvironmentID) check if opts.environmentID !=
"" (or simply check when opts.environmentID was provided) and if service.Project
== nil or service.Project.ID == "" return a clear error like "service has no
associated project" before computing projectID or calling ExposeService; ensure
the same validation logic used for the ResolveEnvironmentID branch is applied so
projectID is never left as an empty string.

In `@internal/cmd/service/metric/metric.go`:
- Around line 75-116: The MetricType is constructed from raw opts.metricType so
lowercase flag values bypass the WithMeasureUnit switch; change the construction
to use the normalized value by replacing mt := model.MetricType(opts.metricType)
with mt := model.MetricType(upperCaseMetricType) (and optionally validate
allowed values) so WithMeasureUnit and any switches on MetricType (referencing
mt and the constants "CPU","MEMORY","NETWORK") will match correctly.

In `@internal/cmd/template/deploy/deploy.go`:
- Around line 93-102: Add an explicit check for the case when both opts.region
and opts.projectID are provided and surface a warning (or validation) instead of
silently ignoring region: before the existing if block that creates a project,
add logic that if opts.region != "" && opts.projectID != "" then either (a) log
a warning via f.Log.Warnf that the --region flag will be ignored because
--project-id was provided, or (b) perform validation by calling
f.ApiClient.GetProject(ctx, opts.projectID) and compare the returned
project.Region to opts.region and return an error if they differ; reference
opts.region, opts.projectID, f.Log and f.ApiClient.GetProject/GetProjectByID (or
CreateProject) when implementing.

---

Outside diff comments:
In `@internal/cmd/service/restart/restart.go`:
- Around line 91-100: The confirmation prompt text is wrong — update the message
passed to f.Prompter.Confirm so it asks about restarting rather than deploying;
specifically change the fmt.Sprintf(...) call that currently uses "Are you sure
to deploy service <%s>?" to reference "restart" and keep the same idOrName
variable and current behavior around f.Interactive and opts.skipConfirm (the
call to f.Prompter.Confirm, the confirm/error handling, and returned results
should remain unchanged).

In `@internal/cmd/variable/create/create.go`:
- Around line 109-112: The spinner and log messages use opts.name which can be
empty when the user supplied --id; compute an idOrName fallback (e.g., idOrName
:= opts.name; if idOrName == "" { idOrName = opts.id }) as done in redeploy.go,
then replace usages of opts.name in spinner.WithSuffix, the error message in the
create flow, and the success message with idOrName so the UI always shows the
provided identifier; update references around the spinner variable s and the
fmt.Sprintf calls that build those messages accordingly.

In `@internal/cmd/variable/delete/delete.go`:
- Around line 134-154: The displayed service name can be empty when the command
was invoked with --id; update all user-facing messages to fall back to the
identifier when opts.name is empty (e.g. use a local variable like displayName
:= opts.name; if displayName == "" { displayName = opts.id }) and use
displayName in spinner.WithSuffix, the fmt.Errorf call, and f.Log.Infof so
messages never show an empty service name; make no behavioral changes to
UpdateVariables or error handling.
- Around line 132-152: The spinner `s` is created but never started; call
s.Start() immediately after creating the spinner (right after spinner.New) and
then defer s.Stop() so the spinner is stopped on all exit paths; update the
block around the spinner creation (variable `s`) in the delete flow, start the
spinner before calling f.ApiClient.UpdateVariables (which updates opts.keys via
UpdateVariables) and either remove the explicit s.Stop() calls in the error
branches or keep them (defer ensures a final stop) to avoid leaving the spinner
unstarted or leaking runtime state for service `opts.name`.

In `@internal/cmd/variable/list/list.go`:
- Around line 85-101: Spinner suffix and subsequent log messages use opts.name
which can be empty when the user supplies --id; compute a display string (e.g.,
idOrName) that prefers opts.name but falls back to opts.id and use that in
spinner.WithSuffix and in f.Log.Infof calls (replace occurrences referencing
opts.name in this file's ListVariables flow with the new idOrName variable);
mirror the idOrName construction pattern used in redeploy.go so all UX messages
show the id when name is missing.

In `@internal/cmd/variable/update/update.go`:
- Around line 130-143: Spinner `s` is constructed but never started; call
s.Start() immediately after creating it and ensure it is stopped on all exit
paths (preferably with defer s.Stop() right after starting) around the call to
f.ApiClient.UpdateVariables in function handling opts (use the same spinner
variable `s`), so the spinner runs while UpdateVariables executes and is
reliably stopped whether UpdateVariables returns an error, false, or success.
- Around line 43-51: The code currently resets opts.keys at the start of
runUpdateVariable, discarding any --key flag values; remove that global
reinitialization and instead initialize opts.keys only when entering the
interactive flow: in runUpdateVariable, check f.Interactive and call
runUpdateVariableInteractive(f, opts) (ensure that function initializes
opts.keys = make(map[string]string) before prompting), while leaving opts.keys
untouched before calling runUpdateVariableNonInteractive so supplied --key flags
are preserved; update any related logic in runUpdateVariableInteractive and
runUpdateVariableNonInteractive as needed to rely on opts.keys being present or
initialized there.

---

Nitpick comments:
In `@internal/cmd/domain/delete/delete.go`:
- Around line 109-123: The code is making an unnecessary API call to resolve
environmentID via util.ResolveEnvironmentIDByServiceID when deleting a domain
even though RemoveDomain only needs the domain name; remove the block that
checks opts.environmentID == "" and calls ResolveEnvironmentIDByServiceID so you
don't call the API or introduce a failure path, leaving only the service name →
ID resolution (util.GetServiceByName) and then call RemoveDomain with opts.name
(and opts.id if still needed); ensure no other code in this function relies on
opts.environmentID before deleting.

In `@internal/cmd/service/network/network.go`:
- Around line 39-63: When only --id is provided runNetwork leaves opts.name
empty causing blank labels; set a fallback so opts.name is populated when
opts.id is present (e.g., if opts.name == "" && opts.id != "" then set opts.name
= opts.id) before any spinner/log output or later use; update the logic in
runNetwork (around uses of opts.id and opts.name) so the display always shows a
name without making extra API calls.

Comment on lines +65 to 85
// Resolve environment and project from the service
service, err := f.ApiClient.GetService(context.Background(), opts.id, "", "", "")
if err != nil {
return fmt.Errorf("get service failed: %w", err)
}

if opts.environmentID == "" {
projectID := f.Config.GetContext().GetProject().GetID()
envID, err := util.ResolveEnvironmentID(f.ApiClient, projectID)
if service.Project == nil || service.Project.ID == "" {
return fmt.Errorf("service has no associated project")
}
envID, err := util.ResolveEnvironmentID(f.ApiClient, service.Project.ID)
if err != nil {
return err
}
opts.environmentID = envID
}

err := paramCheck(opts)
if err != nil {
return err
projectID := ""
if service.Project != nil {
projectID = service.Project.ID
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for ExposeService usage and signature
rg -n --context 4 "ExposeService" internal/cmd -A 5 -B 2

Repository: zeabur/cli

Length of output: 677


🏁 Script executed:

#!/bin/bash
# Also search for where ExposeService might be defined in the pkg directory
fd -e go -type f | xargs rg -l "ExposeService" | head -20

Repository: zeabur/cli

Length of output: 319


🏁 Script executed:

#!/bin/bash
# Check the specific file to understand the context around the snippet
cat -n internal/cmd/service/expose/expose.go | head -100

Repository: zeabur/cli

Length of output: 3389


🏁 Script executed:

#!/bin/bash
# Find ExposeService function definition
rg -n "func.*ExposeService" pkg/api/service.go -A 15

Repository: zeabur/cli

Length of output: 709


🏁 Script executed:

#!/bin/bash
# Also check the interface definition
rg -n "ExposeService" pkg/api/interface.go -B 2 -A 2

Repository: zeabur/cli

Length of output: 593


🏁 Script executed:

#!/bin/bash
# Find ObjectID implementation
rg -n "func.*ObjectID" pkg/api -A 5

Repository: zeabur/cli

Length of output: 336


🏁 Script executed:

#!/bin/bash
# Also look for the pattern where projectID is used elsewhere to understand expectations
rg -n "projectID" pkg/api/service.go | head -20

Repository: zeabur/cli

Length of output: 2137


Fail fast if the service has no associated project before calling ExposeService.

When --env-id is provided, the code skips the Project validation at line 72 but still derives projectID from the service. If service.Project is nil, projectID becomes an empty string, causing the GraphQL call to fail with a less clear error. Add an explicit check to match the validation pattern used when environment resolution is needed.

Suggested guard
+ if service.Project == nil || service.Project.ID == "" {
+     return fmt.Errorf("service has no associated project")
+ }
  projectID := service.Project.ID
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/service/expose/expose.go` around lines 65 - 85, The code can
pass an empty projectID into ExposeService when --env-id is provided because
service.Project is not validated; add a guard so we fail fast: after fetching
service (in the same block that currently sets opts.environmentID via
util.ResolveEnvironmentID) check if opts.environmentID != "" (or simply check
when opts.environmentID was provided) and if service.Project == nil or
service.Project.ID == "" return a clear error like "service has no associated
project" before computing projectID or calling ExposeService; ensure the same
validation logic used for the ResolveEnvironmentID branch is applied so
projectID is never left as an empty string.

Comment on lines +75 to +116
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
if err != nil {
return err
}
opts.environmentID = envID
opts.id = service.ID
}

if err := paramCheck(opts); err != nil {
return err
if opts.id == "" {
return fmt.Errorf("--id or --name is required")
}

// if name is set, get service id by name
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
// Resolve environment and project from the service
service, err := f.ApiClient.GetService(context.Background(), opts.id, "", "", "")
if err != nil {
return fmt.Errorf("get service failed: %w", err)
}

projectID := ""
if service.Project != nil {
projectID = service.Project.ID
}

if opts.environmentID == "" {
envID, err := util.ResolveEnvironmentID(f.ApiClient, projectID)
if err != nil {
return err
}
opts.id = service.ID
opts.environmentID = envID
}

upperCaseMetricType := strings.ToUpper(opts.metricType)
if opts.metricType == "" {
return fmt.Errorf("metric type is required")
}

upperCaseMetricType := strings.ToUpper(opts.metricType)
mt := model.MetricType(opts.metricType)

startTime := time.Now().Add(-time.Duration(opts.hour) * time.Hour)
endTime := time.Now()

metrics, err := f.ApiClient.ServiceMetric(context.Background(), opts.id, f.Config.GetContext().GetProject().GetID(), opts.environmentID, upperCaseMetricType, startTime, endTime)
metrics, err := f.ApiClient.ServiceMetric(context.Background(), opts.id, projectID, opts.environmentID, upperCaseMetricType, startTime, endTime)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the metric.go file
find . -name "metric.go" -path "*/internal/cmd/service/metric/*" | head -5

Repository: zeabur/cli

Length of output: 93


🏁 Script executed:

#!/bin/bash
# Search for MetricType definition
rg -n "type MetricType" --type go

Repository: zeabur/cli

Length of output: 101


🏁 Script executed:

#!/bin/bash
# Search for WithMeasureUnit usage
rg -n "WithMeasureUnit" --type go -B 2 -A 2

Repository: zeabur/cli

Length of output: 1071


🏁 Script executed:

#!/bin/bash
# Search for ValidArgs in metric context
rg -n "ValidArgs" internal/cmd/service/metric/ --type go -B 2 -A 2

Repository: zeabur/cli

Length of output: 414


🏁 Script executed:

#!/bin/bash
# Get MetricType constant definitions
rg -n "MetricType[A-Z]" pkg/model/service.go -A 1

Repository: zeabur/cli

Length of output: 391


🏁 Script executed:

#!/bin/bash
# Get full WithMeasureUnit implementation
rg -n "func \(m MetricType\) WithMeasureUnit" -A 20 --type go

Repository: zeabur/cli

Length of output: 1034


🏁 Script executed:

#!/bin/bash
# Get the full context around ValidArgs in metric.go
sed -n '30,40p' internal/cmd/service/metric/metric.go

Repository: zeabur/cli

Length of output: 369


🏁 Script executed:

#!/bin/bash
# Verify the exact code around lines 75-116 in metric.go
sed -n '70,120p' internal/cmd/service/metric/metric.go

Repository: zeabur/cli

Length of output: 1372


🏁 Script executed:

#!/bin/bash
# Check if there's an interactive mode that might bypass ValidArgs
rg -n "RunE|PersistentPreRunE|PreRunE" internal/cmd/service/metric/metric.go -B 5 -A 10

Repository: zeabur/cli

Length of output: 644


🏁 Script executed:

#!/bin/bash
# Check the full command structure to understand how opts.metricType is set
sed -n '20,60p' internal/cmd/service/metric/metric.go

Repository: zeabur/cli

Length of output: 1174


🏁 Script executed:

#!/bin/bash
# Check the interactive path to see if it also has the same pattern
sed -n '60,75p' internal/cmd/service/metric/metric.go

Repository: zeabur/cli

Length of output: 514


🏁 Script executed:

#!/bin/bash
# Verify how the flag value flows through the code - check if there's any normalization
sed -n '45,75p' internal/cmd/service/metric/metric.go

Repository: zeabur/cli

Length of output: 960


Normalize MetricType construction to handle lowercase input from the --metric-type flag.

The --metric-type flag has no input validation, allowing users to pass lowercase values (e.g., --metric-type cpu). While upperCaseMetricType is correctly normalized for the API call, mt is constructed from the raw input. The WithMeasureUnit switch only matches uppercase constants ("CPU", "MEMORY", "NETWORK"), so lowercase input would skip all cases and return the unformatted value. Use the normalized value when constructing mt:

upperCaseMetricType := strings.ToUpper(opts.metricType)
mt := model.MetricType(upperCaseMetricType)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
if err != nil {
return err
}
opts.environmentID = envID
opts.id = service.ID
}
if err := paramCheck(opts); err != nil {
return err
if opts.id == "" {
return fmt.Errorf("--id or --name is required")
}
// if name is set, get service id by name
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
// Resolve environment and project from the service
service, err := f.ApiClient.GetService(context.Background(), opts.id, "", "", "")
if err != nil {
return fmt.Errorf("get service failed: %w", err)
}
projectID := ""
if service.Project != nil {
projectID = service.Project.ID
}
if opts.environmentID == "" {
envID, err := util.ResolveEnvironmentID(f.ApiClient, projectID)
if err != nil {
return err
}
opts.id = service.ID
opts.environmentID = envID
}
upperCaseMetricType := strings.ToUpper(opts.metricType)
if opts.metricType == "" {
return fmt.Errorf("metric type is required")
}
upperCaseMetricType := strings.ToUpper(opts.metricType)
mt := model.MetricType(opts.metricType)
startTime := time.Now().Add(-time.Duration(opts.hour) * time.Hour)
endTime := time.Now()
metrics, err := f.ApiClient.ServiceMetric(context.Background(), opts.id, f.Config.GetContext().GetProject().GetID(), opts.environmentID, upperCaseMetricType, startTime, endTime)
metrics, err := f.ApiClient.ServiceMetric(context.Background(), opts.id, projectID, opts.environmentID, upperCaseMetricType, startTime, endTime)
if opts.id == "" && opts.name != "" {
service, err := util.GetServiceByName(f.Config, f.ApiClient, opts.name)
if err != nil {
return err
}
opts.id = service.ID
}
if opts.id == "" {
return fmt.Errorf("--id or --name is required")
}
// Resolve environment and project from the service
service, err := f.ApiClient.GetService(context.Background(), opts.id, "", "", "")
if err != nil {
return fmt.Errorf("get service failed: %w", err)
}
projectID := ""
if service.Project != nil {
projectID = service.Project.ID
}
if opts.environmentID == "" {
envID, err := util.ResolveEnvironmentID(f.ApiClient, projectID)
if err != nil {
return err
}
opts.environmentID = envID
}
if opts.metricType == "" {
return fmt.Errorf("metric type is required")
}
upperCaseMetricType := strings.ToUpper(opts.metricType)
mt := model.MetricType(upperCaseMetricType)
startTime := time.Now().Add(-time.Duration(opts.hour) * time.Hour)
endTime := time.Now()
metrics, err := f.ApiClient.ServiceMetric(context.Background(), opts.id, projectID, opts.environmentID, upperCaseMetricType, startTime, endTime)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/service/metric/metric.go` around lines 75 - 116, The MetricType
is constructed from raw opts.metricType so lowercase flag values bypass the
WithMeasureUnit switch; change the construction to use the normalized value by
replacing mt := model.MetricType(opts.metricType) with mt :=
model.MetricType(upperCaseMetricType) (and optionally validate allowed values)
so WithMeasureUnit and any switches on MetricType (referencing mt and the
constants "CPU","MEMORY","NETWORK") will match correctly.

Comment on lines +93 to 102
if opts.region != "" && opts.projectID == "" {
project, err := f.ApiClient.CreateProject(context.Background(), opts.region, nil)
if err != nil {
return fmt.Errorf("create project in region %s: %w", opts.region, err)
}
opts.projectID = project.ID
f.Log.Infof("Created project %q in region %s.", project.ID, opts.region)
} else if _, err := f.ParamFiller.ProjectCreatePreferred(&opts.projectID); err != nil {
return err
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider warning when --region is provided alongside --project-id.

When both --region and --project-id are specified, the region flag is silently ignored since the condition on line 93 requires projectID == "". This could confuse users who expect the region to be validated against the existing project.

Consider adding explicit handling:

💡 Proposed fix to warn users
+	if opts.region != "" && opts.projectID != "" {
+		f.Log.Warnf("Both --region and --project-id provided; --region will be ignored.")
+	}
+
 	if opts.region != "" && opts.projectID == "" {
 		project, err := f.ApiClient.CreateProject(context.Background(), opts.region, nil)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if opts.region != "" && opts.projectID == "" {
project, err := f.ApiClient.CreateProject(context.Background(), opts.region, nil)
if err != nil {
return fmt.Errorf("create project in region %s: %w", opts.region, err)
}
opts.projectID = project.ID
f.Log.Infof("Created project %q in region %s.", project.ID, opts.region)
} else if _, err := f.ParamFiller.ProjectCreatePreferred(&opts.projectID); err != nil {
return err
}
if opts.region != "" && opts.projectID != "" {
f.Log.Warnf("Both --region and --project-id provided; --region will be ignored.")
}
if opts.region != "" && opts.projectID == "" {
project, err := f.ApiClient.CreateProject(context.Background(), opts.region, nil)
if err != nil {
return fmt.Errorf("create project in region %s: %w", opts.region, err)
}
opts.projectID = project.ID
f.Log.Infof("Created project %q in region %s.", project.ID, opts.region)
} else if _, err := f.ParamFiller.ProjectCreatePreferred(&opts.projectID); err != nil {
return err
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/template/deploy/deploy.go` around lines 93 - 102, Add an
explicit check for the case when both opts.region and opts.projectID are
provided and surface a warning (or validation) instead of silently ignoring
region: before the existing if block that creates a project, add logic that if
opts.region != "" && opts.projectID != "" then either (a) log a warning via
f.Log.Warnf that the --region flag will be ignored because --project-id was
provided, or (b) perform validation by calling f.ApiClient.GetProject(ctx,
opts.projectID) and compare the returned project.Region to opts.region and
return an error if they differ; reference opts.region, opts.projectID, f.Log and
f.ApiClient.GetProject/GetProjectByID (or CreateProject) when implementing.

@yuaanlin yuaanlin merged commit 429f5f5 into main Feb 20, 2026
5 checks passed
@yuaanlin yuaanlin deleted the feat/service-exec-and-remove-context-defaults branch February 20, 2026 07:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant