Skip to content

Commit 0a61352

Browse files
authored
Merge pull request #1811 from appwrite/feat/cli-function-setup-flow
feat(cli): add guided function setup workflow
2 parents 152205b + fbb43b4 commit 0a61352

13 files changed

Lines changed: 2153 additions & 138 deletions

File tree

src/SDK/Language/CLI.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,11 @@ public function getFiles(): array
11021102
'destination' => 'internal/cmd/initfunction.go',
11031103
'template' => 'cli/internal/cmd/initfunction.go',
11041104
],
1105+
[
1106+
'scope' => 'default',
1107+
'destination' => 'internal/cmd/initfunction_test.go',
1108+
'template' => 'cli/internal/cmd/initfunction_test.go',
1109+
],
11051110
[
11061111
'scope' => 'default',
11071112
'destination' => 'internal/cmd/initsite.go',

templates/cli/internal/cmd/errors.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import (
88
"io"
99
"net/url"
1010
"os"
11+
"path/filepath"
1112
"runtime"
1213
"strings"
1314

1415
"github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app"
16+
"github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config"
1517
"github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/output"
1618
"github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt"
1719
"github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/sdk"
@@ -43,6 +45,64 @@ type apiError interface {
4345
// rendered error page.
4446
const maximumMessageLength = 400
4547

48+
// actionableError carries a known recovery path without baking terminal layout
49+
// into Error(). Logs and reports retain the ordinary message; Report can render
50+
// the title, facts and command as a readable block.
51+
type actionableError struct {
52+
cause error
53+
title string
54+
details []output.FailureDetail
55+
action string
56+
command string
57+
}
58+
59+
func (e *actionableError) Error() string { return e.cause.Error() }
60+
func (e *actionableError) Unwrap() error { return e.cause }
61+
62+
func endpointMismatchError(projectEndpoint, sessionEndpoint string) error {
63+
switchEndpoint := config.NormalizeCloudConsoleEndpoint(projectEndpoint)
64+
environment := "the project environment"
65+
if base, cloud := config.CloudBaseHost(projectEndpoint); cloud {
66+
environment = "Appwrite Cloud"
67+
if strings.Contains(base, "staging") {
68+
environment = "Appwrite Cloud Staging"
69+
}
70+
}
71+
72+
cause := fmt.Errorf("project endpoint %s does not match active session endpoint %s",
73+
projectEndpoint, sessionEndpoint)
74+
return &actionableError{
75+
cause: cause,
76+
title: "Active session doesn’t match this project",
77+
details: []output.FailureDetail{
78+
{Label: "Project endpoint", Value: projectEndpoint},
79+
{Label: "Active session", Value: sessionEndpoint},
80+
},
81+
action: "Switch to " + environment + ":",
82+
command: fmt.Sprintf("%s login --switch --endpoint %s",
83+
app.ExecutableName, switchEndpoint),
84+
}
85+
}
86+
87+
func missingProjectConfigError(err error) *actionableError {
88+
var pathError *os.PathError
89+
if !errors.As(err, &pathError) || !errors.Is(err, os.ErrNotExist) ||
90+
filepath.Base(pathError.Path) != config.LocalFileName {
91+
return nil
92+
}
93+
94+
return &actionableError{
95+
cause: err,
96+
title: "Appwrite project configuration not found",
97+
details: []output.FailureDetail{
98+
{Label: "Expected file", Value: pathError.Path},
99+
},
100+
action: "Run this command from a directory containing " + config.LocalFileName +
101+
", or initialize a project:",
102+
command: app.ExecutableName + " init project",
103+
}
104+
}
105+
46106
// FormatError renders a command failure for the terminal.
47107
func FormatError(err error) string {
48108
if err == nil {
@@ -196,7 +256,16 @@ func Report(writer io.Writer, executed *cobra.Command, err error) int {
196256
output.Log(writer, "For detailed error pass the --verbose or --report flag")
197257
}
198258

199-
output.Failure(writer, "%s", FormatError(err))
259+
var actionable *actionableError
260+
if errors.As(err, &actionable) {
261+
output.ActionableFailure(writer, actionable.title, actionable.details,
262+
actionable.action, actionable.command)
263+
} else if missing := missingProjectConfigError(err); missing != nil {
264+
output.ActionableFailure(writer, missing.title, missing.details,
265+
missing.action, missing.command)
266+
} else {
267+
output.Failure(writer, "%s", FormatError(err))
268+
}
200269

201270
// --verbose has to add the response body: on the failure it matters most for
202271
// -- a response the SDK could not decode -- a message naming a field and a

templates/cli/internal/cmd/errors_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,59 @@ func TestReportDistinguishesCancellationFromFailure(t *testing.T) {
263263
}
264264
}
265265

266+
func TestEndpointMismatchRendersActionableBlock(t *testing.T) {
267+
requestWasMade(t, false)
268+
buffer := &bytes.Buffer{}
269+
err := endpointMismatchError(
270+
"https://sgp.cloud.appwrite.io/v1",
271+
"https://cloud.staging.appwrite.io/v1",
272+
)
273+
274+
if status := Report(buffer, nil, err); status != 1 {
275+
t.Fatalf("status = %d", status)
276+
}
277+
printed := buffer.String()
278+
for _, wanted := range []string{
279+
"Active session doesn’t match this project",
280+
"Project endpoint", "https://sgp.cloud.appwrite.io/v1",
281+
"Active session", "https://cloud.staging.appwrite.io/v1",
282+
"Switch to Appwrite Cloud:",
283+
"appwrite login --switch --endpoint https://cloud.appwrite.io/v1",
284+
} {
285+
if !strings.Contains(printed, wanted) {
286+
t.Errorf("output does not contain %q:\n%s", wanted, printed)
287+
}
288+
}
289+
if strings.Contains(printed, "✗ Error:") {
290+
t.Errorf("actionable block retained generic prefix:\n%s", printed)
291+
}
292+
}
293+
294+
func TestMissingProjectConfigRendersActionableBlock(t *testing.T) {
295+
requestWasMade(t, false)
296+
path := "/work/project/appwrite.config.json"
297+
buffer := &bytes.Buffer{}
298+
err := &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist}
299+
300+
if status := Report(buffer, nil, err); status != 1 {
301+
t.Fatalf("status = %d", status)
302+
}
303+
printed := buffer.String()
304+
for _, wanted := range []string{
305+
"Appwrite project configuration not found",
306+
"Expected file", path,
307+
"Run this command from a directory containing appwrite.config.json",
308+
"appwrite init project",
309+
} {
310+
if !strings.Contains(printed, wanted) {
311+
t.Errorf("output does not contain %q:\n%s", wanted, printed)
312+
}
313+
}
314+
if strings.Contains(printed, "no such file or directory") {
315+
t.Errorf("raw filesystem error leaked into primary output:\n%s", printed)
316+
}
317+
}
318+
266319
// A command that fails DURING parsing never reaches the later flags, so the
267320
// parsed globals cannot answer "did the user ask for detail". `users get
268321
// --bogus --verbose` stopped at --bogus and then advised passing --verbose.

0 commit comments

Comments
 (0)