Skip to content

Commit d8a83e9

Browse files
authored
Merge ecc673e into bac398f
2 parents bac398f + ecc673e commit d8a83e9

File tree

3 files changed

+240
-3
lines changed

3 files changed

+240
-3
lines changed

internal/command/launch/plan_builder.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,9 +465,13 @@ func determineBaseAppConfig(ctx context.Context) (*appconfig.Config, bool, error
465465

466466
// if --attach is specified, we should return the config as the base config
467467
attach := flag.GetBool(ctx, "attach")
468-
copyConfig := flag.GetBool(ctx, "copy-config") || attach
468+
// An explicit --config flag means the caller deliberately chose the file
469+
// (e.g. the deployer passing --config fly.api-server.toml). Treat it as
470+
// copy-config so we never prompt and never fall back to source scanning.
471+
explicitConfig := flag.IsSpecified(ctx, "config")
472+
copyConfig := flag.GetBool(ctx, "copy-config") || attach || explicitConfig
469473

470-
if !flag.IsSpecified(ctx, "copy-config") && !attach && !flag.GetYes(ctx) {
474+
if !flag.IsSpecified(ctx, "copy-config") && !attach && !explicitConfig && !flag.GetYes(ctx) {
471475
var err error
472476
copyConfig, err = prompt.Confirm(ctx, colorize.Yellow("Would you like to use this fly.toml configuration for this app?"))
473477
fmt.Fprintln(io.Out)
@@ -671,6 +675,10 @@ func determineOrg(ctx context.Context, config *appconfig.Config) (*fly.Organizat
671675
for _, o := range orgs {
672676
bySlug[o.Slug] = o
673677
}
678+
byRawSlug := make(map[string]fly.Organization, len(orgs))
679+
for _, o := range orgs {
680+
byRawSlug[o.RawSlug] = o
681+
}
674682
byName := make(map[string]fly.Organization, len(orgs))
675683
for _, o := range orgs {
676684
byName[o.Name] = o
@@ -699,6 +707,13 @@ func determineOrg(ctx context.Context, config *appconfig.Config) (*fly.Organizat
699707
return &org, "specified on the command line", nil
700708
}
701709

710+
// The personal org's canonical Slug is always "personal", but callers
711+
// (e.g. the deployer) may supply the real/raw slug (e.g. "lubien-339").
712+
// Fall back to a RawSlug lookup before giving up.
713+
if org, foundRawSlug := byRawSlug[orgRequested]; foundRawSlug {
714+
return &org, "specified on the command line", nil
715+
}
716+
702717
if !foundPersonal {
703718
return nil, "", errors.New("no personal organization found")
704719
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package launch
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/spf13/pflag"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
fly "github.com/superfly/fly-go"
11+
"github.com/superfly/flyctl/internal/appconfig"
12+
"github.com/superfly/flyctl/internal/flag/flagctx"
13+
"github.com/superfly/flyctl/internal/flyutil"
14+
"github.com/superfly/flyctl/internal/mock"
15+
"github.com/superfly/flyctl/iostreams"
16+
)
17+
18+
func newDetermineOrgCtx(t *testing.T, orgFlag string) context.Context {
19+
t.Helper()
20+
21+
ctx := context.Background()
22+
23+
flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError)
24+
flagSet.String("org", "", "")
25+
flagSet.Bool("attach", false, "")
26+
27+
if orgFlag != "" {
28+
require.NoError(t, flagSet.Set("org", orgFlag))
29+
}
30+
31+
return flagctx.NewContext(ctx, flagSet)
32+
}
33+
34+
func TestDetermineOrg(t *testing.T) {
35+
personalOrg := fly.Organization{
36+
ID: "org-id-personal",
37+
Slug: "personal",
38+
RawSlug: "logan-griswold-339",
39+
Name: "Logan Griswold",
40+
Type: "PERSONAL",
41+
}
42+
teamOrg := fly.Organization{
43+
ID: "org-id-team",
44+
Slug: "my-team",
45+
RawSlug: "my-team",
46+
Name: "My Team",
47+
Type: "SHARED",
48+
}
49+
orgs := []fly.Organization{personalOrg, teamOrg}
50+
51+
makeClient := func() *mock.Client {
52+
return &mock.Client{
53+
GetOrganizationsFunc: func(ctx context.Context, filters ...fly.OrganizationFilter) ([]fly.Organization, error) {
54+
return orgs, nil
55+
},
56+
}
57+
}
58+
59+
t.Run("no org flag defaults to personal", func(t *testing.T) {
60+
ctx := newDetermineOrgCtx(t, "")
61+
ctx = flyutil.NewContextWithClient(ctx, makeClient())
62+
63+
org, _, err := determineOrg(ctx, nil)
64+
require.NoError(t, err)
65+
assert.Equal(t, "personal", org.Slug)
66+
assert.Equal(t, "logan-griswold-339", org.RawSlug)
67+
})
68+
69+
t.Run("canonical personal slug", func(t *testing.T) {
70+
ctx := newDetermineOrgCtx(t, "personal")
71+
ctx = flyutil.NewContextWithClient(ctx, makeClient())
72+
73+
org, _, err := determineOrg(ctx, nil)
74+
require.NoError(t, err)
75+
assert.Equal(t, "personal", org.Slug)
76+
assert.Equal(t, "logan-griswold-339", org.RawSlug)
77+
})
78+
79+
t.Run("real raw slug of personal org", func(t *testing.T) {
80+
ctx := newDetermineOrgCtx(t, "logan-griswold-339")
81+
ctx = flyutil.NewContextWithClient(ctx, makeClient())
82+
83+
org, _, err := determineOrg(ctx, nil)
84+
require.NoError(t, err)
85+
assert.Equal(t, "personal", org.Slug)
86+
assert.Equal(t, "logan-griswold-339", org.RawSlug)
87+
})
88+
89+
t.Run("team org by slug", func(t *testing.T) {
90+
ctx := newDetermineOrgCtx(t, "my-team")
91+
ctx = flyutil.NewContextWithClient(ctx, makeClient())
92+
93+
org, _, err := determineOrg(ctx, nil)
94+
require.NoError(t, err)
95+
assert.Equal(t, "my-team", org.Slug)
96+
})
97+
98+
t.Run("org by display name", func(t *testing.T) {
99+
ctx := newDetermineOrgCtx(t, "My Team")
100+
ctx = flyutil.NewContextWithClient(ctx, makeClient())
101+
102+
org, _, err := determineOrg(ctx, nil)
103+
require.NoError(t, err)
104+
assert.Equal(t, "my-team", org.Slug)
105+
})
106+
107+
t.Run("unknown org returns error and falls back to personal", func(t *testing.T) {
108+
ctx := newDetermineOrgCtx(t, "does-not-exist")
109+
ctx = flyutil.NewContextWithClient(ctx, makeClient())
110+
111+
org, _, err := determineOrg(ctx, nil)
112+
assert.Error(t, err)
113+
require.NotNil(t, org)
114+
assert.Equal(t, "personal", org.Slug)
115+
})
116+
}
117+
118+
// newDetermineBaseAppConfigCtx builds a context wired with the flags that
119+
// determineBaseAppConfig reads. Pass configPath="" to leave --config unset.
120+
func newDetermineBaseAppConfigCtx(t *testing.T, copyConfigFlag, explicitConfigPath bool) context.Context {
121+
t.Helper()
122+
123+
ctx := context.Background()
124+
ctx = iostreams.NewContext(ctx, iostreams.System())
125+
126+
flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError)
127+
flagSet.String("config", "", "")
128+
flagSet.Bool("copy-config", false, "")
129+
flagSet.Bool("attach", false, "")
130+
flagSet.Bool("yes", false, "")
131+
132+
if copyConfigFlag {
133+
require.NoError(t, flagSet.Set("copy-config", "true"))
134+
}
135+
if explicitConfigPath {
136+
require.NoError(t, flagSet.Set("config", "fly.custom.toml"))
137+
}
138+
139+
return flagctx.NewContext(ctx, flagSet)
140+
}
141+
142+
func TestDetermineBaseAppConfig(t *testing.T) {
143+
// existingCfg simulates what LoadAppConfigIfPresent puts in context when
144+
// the customer has a custom fly.toml with a non-default dockerfile.
145+
existingCfg := appconfig.NewConfig()
146+
existingCfg.Build = &appconfig.Build{
147+
Dockerfile: "docker.ui-server.dockerfile",
148+
}
149+
150+
t.Run("no flags and no existing config returns blank config", func(t *testing.T) {
151+
ctx := newDetermineBaseAppConfigCtx(t, false, false)
152+
// No config in context — simulates no fly.toml present.
153+
154+
cfg, copied, err := determineBaseAppConfig(ctx)
155+
require.NoError(t, err)
156+
assert.False(t, copied)
157+
assert.Nil(t, cfg.Build)
158+
})
159+
160+
t.Run("--copy-config adopts existing config without prompting", func(t *testing.T) {
161+
ctx := newDetermineBaseAppConfigCtx(t, true, false)
162+
ctx = appconfig.WithConfig(ctx, existingCfg)
163+
164+
cfg, copied, err := determineBaseAppConfig(ctx)
165+
require.NoError(t, err)
166+
assert.True(t, copied)
167+
assert.Equal(t, "docker.ui-server.dockerfile", cfg.Build.Dockerfile)
168+
})
169+
170+
t.Run("explicit --config adopts existing config without prompting", func(t *testing.T) {
171+
// This is the deployer scenario: --config fly.custom.toml is passed but
172+
// --copy-config is not. The explicit path signals intent, so we must
173+
// not fall through to source scanning with an empty config.
174+
ctx := newDetermineBaseAppConfigCtx(t, false, true)
175+
ctx = appconfig.WithConfig(ctx, existingCfg)
176+
177+
cfg, copied, err := determineBaseAppConfig(ctx)
178+
require.NoError(t, err)
179+
assert.True(t, copied)
180+
assert.Equal(t, "docker.ui-server.dockerfile", cfg.Build.Dockerfile)
181+
})
182+
183+
t.Run("no flags in non-interactive mode returns error", func(t *testing.T) {
184+
ctx := newDetermineBaseAppConfigCtx(t, false, false)
185+
ctx = appconfig.WithConfig(ctx, existingCfg)
186+
// Non-interactive iostreams → prompt.Confirm returns ErrNonInteractive.
187+
ios, _, _, _ := iostreams.Test()
188+
ctx = iostreams.NewContext(ctx, ios)
189+
190+
_, _, err := determineBaseAppConfig(ctx)
191+
assert.Error(t, err)
192+
})
193+
}

internal/command/launch/plan_commands.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func newPropose() *cobra.Command {
3838
flag.Add(cmd,
3939
flag.Region(),
4040
flag.Org(),
41+
flag.AppConfig(),
4142
flag.String{
4243
Name: "from",
4344
Description: "A github repo URL to use as a template for the new app",
@@ -183,6 +184,12 @@ func newGenerate() *cobra.Command {
183184
flag.Region(),
184185
flag.Org(),
185186
flag.AppConfig(),
187+
flag.Yes(),
188+
flag.Bool{
189+
Name: "copy-config",
190+
Description: "Use the configuration file if present without prompting",
191+
Default: false,
192+
},
186193
flag.Bool{
187194
Name: "no-deploy",
188195
Description: "Don't deploy the app",
@@ -222,7 +229,18 @@ func runPropose(ctx context.Context) error {
222229
ctx = logger.NewContext(ctx, logger.New(os.Stderr, logger.FromContext(ctx).Level(), iostreams.IsTerminalWriter(os.Stdout)))
223230
}
224231

225-
err := RunPlan(ctx, "propose")
232+
// LoadAppConfigIfPresent is registered on the parent "plan" command, but
233+
// because that command has no Run func cobra never executes its RunE and the
234+
// preparer is never called. Load the config here explicitly so that a custom
235+
// path supplied via --config (e.g. fly.api-server.toml) is in context before
236+
// buildManifest → determineBaseAppConfig reads it.
237+
var err error
238+
ctx, err = command.LoadAppConfigIfPresent(ctx)
239+
if err != nil {
240+
return err
241+
}
242+
243+
err = RunPlan(ctx, "propose")
226244
if err != nil {
227245
return err
228246
}
@@ -257,5 +275,16 @@ func runTigris(ctx context.Context) error {
257275
func runGenerate(ctx context.Context) error {
258276
flag.SetString(ctx, "from-manifest", flag.FirstArg(ctx))
259277

278+
// LoadAppConfigIfPresent is registered on the parent "plan" command, but
279+
// because that command has no Run func cobra never executes its RunE and the
280+
// preparer is never called. Load the config here explicitly so that a custom
281+
// path supplied via --config (e.g. fly.api-server.toml) is in context before
282+
// buildManifest → determineBaseAppConfig reads it.
283+
var err error
284+
ctx, err = command.LoadAppConfigIfPresent(ctx)
285+
if err != nil {
286+
return err
287+
}
288+
260289
return RunPlan(ctx, "generate")
261290
}

0 commit comments

Comments
 (0)