@@ -25,6 +25,8 @@ type fakeAPI struct {
2525 * httptest.Server
2626 mu sync.Mutex
2727 requests []recorded
28+ // projects is the JSON array GET /projects returns (default: none).
29+ projects string
2830}
2931
3032type recorded struct {
@@ -50,10 +52,24 @@ func newFakeAPI(t *testing.T) *fakeAPI {
5052 return
5153 }
5254 _ , _ = io .WriteString (w , `{"id":"proj_1","name":"` + body ["name" ].(string )+ `","description":null,"domain":"acme.com","context":"A marketplace","globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z","secretKey":"sk_proj_new"}` )
55+ case r .Method == http .MethodGet && r .URL .Path == "/projects" :
56+ projects := f .projects
57+ if projects == "" {
58+ projects = "[]"
59+ }
60+ _ , _ = io .WriteString (w , `{"data":` + projects + `,"pagination":{"page":1,"pageSize":100,"total":0,"totalPages":1,"hasMore":false}}` )
61+ case r .Method == http .MethodPost && strings .HasSuffix (r .URL .Path , "/api-keys" ):
62+ if auth != "Bearer sk_org_test" {
63+ w .WriteHeader (http .StatusUnauthorized )
64+ _ , _ = io .WriteString (w , `{"message":"bad key","code":"unauthorized"}` )
65+ return
66+ }
67+ id := strings .TrimSuffix (strings .TrimPrefix (r .URL .Path , "/projects/" ), "/api-keys" )
68+ _ , _ = io .WriteString (w , `{"id":"key_1","name":"` + body ["name" ].(string )+ `","projectId":"` + id + `","createdAt":"2026-10-23T00:00:00Z","secretKey":"sk_proj_linked"}` )
5369 case r .URL .Path == "/webhook-secret" :
5470 _ , _ = io .WriteString (w , `{"secret":"whsec_new"}` )
5571 case r .URL .Path == "/account" :
56- if auth != "Bearer sk_proj_new" && auth != "Bearer sk_proj_existing" {
72+ if auth != "Bearer sk_proj_new" && auth != "Bearer sk_proj_existing" && auth != "Bearer sk_proj_linked" {
5773 w .WriteHeader (http .StatusUnauthorized )
5874 _ , _ = io .WriteString (w , `{"message":"bad key","code":"unauthorized"}` )
5975 return
@@ -151,8 +167,11 @@ func TestInitCreatesProjectAndWritesEnv(t *testing.T) {
151167 t .Fatalf (".env.local = %q, want %q" , env , want )
152168 }
153169
170+ if paths := api .paths (); len (paths ) < 2 || paths [0 ] != "GET /projects" || paths [1 ] != "POST /projects" {
171+ t .Fatalf ("requests = %v, want GET /projects (none to link), then POST /projects" , paths )
172+ }
154173 api .mu .Lock ()
155- create := api .requests [0 ]
174+ create := api .requests [1 ]
156175 api .mu .Unlock ()
157176 if create .Path != "/projects" || create .Body ["name" ] != "market" || create .Body ["brief" ] != "No scams" || create .Body ["usedByMinors" ] != false {
158177 t .Fatalf ("create request = %+v" , create )
@@ -209,7 +228,7 @@ func TestInitDryRunTouchesNothing(t *testing.T) {
209228 dir := setup (t )
210229 api := newFakeAPI (t )
211230 t .Setenv ("MODAPI_ORGANIZATION_KEY" , "sk_org_test" )
212- stdout , _ , code := run (t , "init" , "--dir" , dir , "--server-url" , api .URL , "--dry-run" , "-o" , "json" )
231+ stdout , _ , code := run (t , "init" , "--dir" , dir , "--server-url" , api .URL , "--new" , "-- dry-run" , "-o" , "json" )
213232 if code != 0 || ! strings .Contains (stdout , `"would_create_project":true` ) {
214233 t .Fatalf ("exit %d: %s" , code , stdout )
215234 }
@@ -396,3 +415,132 @@ func TestDoctorListsProblemsWithoutAnErrorBlock(t *testing.T) {
396415 t .Fatalf ("doctor printed an error block: %s" , stderr )
397416 }
398417}
418+
419+ const twoProjects = `[
420+ {"id":"proj_1","name":"Market","description":null,"domain":"acme.com","context":null,"globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z"},
421+ {"id":"proj_2","name":"Forum","description":null,"domain":null,"context":null,"globalFlaggingMode":"ACTIVE","enableRequestLogging":true,"casebookEnabled":true,"createdAt":"2026-10-22T00:00:00Z","updatedAt":"2026-10-22T00:00:00Z"}
422+ ]`
423+
424+ func readEnv (t * testing.T , path string ) string {
425+ t .Helper ()
426+ data , err := os .ReadFile (path )
427+ if err != nil {
428+ t .Fatal (err )
429+ }
430+ return string (data )
431+ }
432+
433+ func TestInitWithProjectsAsksAnAgentToChoose (t * testing.T ) {
434+ dir := setup (t )
435+ api := newFakeAPI (t )
436+ api .projects = twoProjects
437+ t .Setenv ("MODAPI_ORGANIZATION_KEY" , "sk_org_test" )
438+
439+ _ , stderr , code := run (t , "init" , "--dir" , dir , "--server-url" , api .URL , "-o" , "json" )
440+ if code != 2 {
441+ t .Fatalf ("exit %d, want 2: %s" , code , stderr )
442+ }
443+ for _ , want := range []string {"Market (proj_1)" , "Forum (proj_2)" , "--project" , "--new" } {
444+ if ! strings .Contains (stderr , want ) {
445+ t .Fatalf ("stderr misses %q: %s" , want , stderr )
446+ }
447+ }
448+ if paths := api .paths (); len (paths ) != 1 || paths [0 ] != "GET /projects" {
449+ t .Fatalf ("requests = %v, want only the project list" , paths )
450+ }
451+ }
452+
453+ func TestInitLinksAProjectByName (t * testing.T ) {
454+ dir := setup (t )
455+ api := newFakeAPI (t )
456+ api .projects = twoProjects
457+ t .Setenv ("MODAPI_ORGANIZATION_KEY" , "sk_org_test" )
458+
459+ stdout , stderr , code := run (t , "init" , "--dir" , dir , "--server-url" , api .URL , "--project" , "forum" , "-o" , "json" )
460+ if code != 0 {
461+ t .Fatalf ("exit %d\n stdout: %s\n stderr: %s" , code , stdout , stderr )
462+ }
463+ if ! strings .Contains (stdout , `"status":"linked"` ) || strings .Contains (stdout , "sk_proj_linked" ) {
464+ t .Fatalf ("result = %s" , stdout )
465+ }
466+ if paths := api .paths (); ! strings .Contains (strings .Join (paths , "," ), "POST /projects/proj_2/api-keys" ) {
467+ t .Fatalf ("requests = %v" , paths )
468+ }
469+ api .mu .Lock ()
470+ var name string
471+ for _ , r := range api .requests {
472+ if strings .HasSuffix (r .Path , "/api-keys" ) {
473+ name , _ = r .Body ["name" ].(string )
474+ }
475+ }
476+ api .mu .Unlock ()
477+ if ! strings .HasPrefix (name , "CLI · " ) {
478+ t .Fatalf ("key name = %q" , name )
479+ }
480+ if env := readEnv (t , filepath .Join (dir , ".env.local" )); env != "MODAPI_SECRET_KEY=sk_proj_linked\n MODAPI_WEBHOOK_SECRET=whsec_new\n " {
481+ t .Fatalf (".env.local = %q" , env )
482+ }
483+ }
484+
485+ func TestInitRefusesProjectAndNewTogether (t * testing.T ) {
486+ dir := setup (t )
487+ _ , _ , code := run (t , "init" , "--dir" , dir , "--project" , "x" , "--new" )
488+ if code != 2 {
489+ t .Fatalf ("exit %d, want 2" , code )
490+ }
491+ }
492+
493+ func TestLinkRefusesAmbiguousName (t * testing.T ) {
494+ dir := setup (t )
495+ api := newFakeAPI (t )
496+ api .projects = strings .Replace (twoProjects , `"Forum"` , `"market"` , 1 )
497+ t .Setenv ("MODAPI_ORGANIZATION_KEY" , "sk_org_test" )
498+
499+ _ , stderr , code := run (t , "link" , "Market" , "--dir" , dir , "--server-url" , api .URL )
500+ if code != 2 || ! strings .Contains (stderr , "more than one project" ) {
501+ t .Fatalf ("exit %d: %s" , code , stderr )
502+ }
503+ }
504+
505+ func TestLinkAsksBeforeReplacingAnotherProjectsKey (t * testing.T ) {
506+ dir := setup (t )
507+ api := newFakeAPI (t )
508+ api .projects = twoProjects
509+ t .Setenv ("MODAPI_ORGANIZATION_KEY" , "sk_org_test" )
510+ // The account endpoint says sk_proj_existing belongs to proj_1.
511+ writeEnv (t , dir , ".env.local" , "MODAPI_SECRET_KEY=sk_proj_existing\n " )
512+
513+ _ , stderr , code := run (t , "link" , "proj_2" , "--dir" , dir , "--server-url" , api .URL )
514+ if code != 2 || ! strings .Contains (stderr , "--force" ) {
515+ t .Fatalf ("exit %d: %s" , code , stderr )
516+ }
517+ if env := readEnv (t , filepath .Join (dir , ".env.local" )); ! strings .Contains (env , "sk_proj_existing" ) {
518+ t .Fatalf ("the key was replaced without --force: %q" , env )
519+ }
520+
521+ stdout , stderr , code := run (t , "link" , "proj_2" , "--dir" , dir , "--server-url" , api .URL , "--force" , "-o" , "json" )
522+ if code != 0 || ! strings .Contains (stdout , `"status":"linked"` ) {
523+ t .Fatalf ("exit %d\n stdout: %s\n stderr: %s" , code , stdout , stderr )
524+ }
525+ if env := readEnv (t , filepath .Join (dir , ".env.local" )); ! strings .Contains (env , "MODAPI_SECRET_KEY=sk_proj_linked" ) {
526+ t .Fatalf (".env.local = %q" , env )
527+ }
528+ }
529+
530+ func TestLinkSameProjectChangesNothing (t * testing.T ) {
531+ dir := setup (t )
532+ api := newFakeAPI (t )
533+ api .projects = twoProjects
534+ t .Setenv ("MODAPI_ORGANIZATION_KEY" , "sk_org_test" )
535+ writeEnv (t , dir , ".env.local" , "MODAPI_SECRET_KEY=sk_proj_existing\n " )
536+
537+ stdout , stderr , code := run (t , "link" , "Market" , "--dir" , dir , "--server-url" , api .URL , "-o" , "json" )
538+ if code != 0 || ! strings .Contains (stdout , `"status":"already_set_up"` ) {
539+ t .Fatalf ("exit %d\n stdout: %s\n stderr: %s" , code , stdout , stderr )
540+ }
541+ for _ , p := range api .paths () {
542+ if strings .HasSuffix (p , "/api-keys" ) {
543+ t .Fatalf ("made a key for the project it is already linked to: %v" , api .paths ())
544+ }
545+ }
546+ }
0 commit comments