diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go index e8bee8163..f7bcb0520 100644 --- a/shortcuts/apps/apps_init.go +++ b/shortcuts/apps/apps_init.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "unicode" @@ -35,6 +36,7 @@ const ( const ( scaffoldKindInit = "init" scaffoldKindUpgrade = "upgrade" + scaffoldKindSkipped = "skipped" ) const ( @@ -85,7 +87,7 @@ var AppsInit = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID := strings.TrimSpace(rctx.Str("app-id")) - template := resolveTemplate(rctx, appID) + template := resolveTemplate(rctx, nil) dry := common.NewDryRunAPI(). Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)"). Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)). @@ -123,16 +125,20 @@ func defaultCloneDir(appID string) string { } // resolveTemplate returns the scaffold template for an empty-repo `app init`. -// An explicit --template wins. When omitted, it should be derived from the -// app's tech stack. -// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list` -// or a get-app endpoint), read its tech stack, and map tech-stack -> template -// through a (future) enum. Until that lands, fall back to defaultTemplate. -func resolveTemplate(rctx *common.RuntimeContext, appID string) string { +// An explicit --template wins. When meta is available the template is derived +// from app_type/arch_type; otherwise it falls back to defaultTemplate. +func resolveTemplate(rctx *common.RuntimeContext, meta *appMeta) string { if t := strings.TrimSpace(rctx.Str("template")); t != "" { return t } - // TODO(apps-init): derive from app tech stack (apps API + enum mapping). + if meta != nil { + switch { + case meta.AppType == 7 && meta.ArchType == 3: + return "" + case meta.AppType == 7 && meta.ArchType == 4: + return "vite-react" + } + } return defaultTemplate } @@ -325,17 +331,18 @@ func isEmptyRepo(ctx context.Context, dir string) (bool, error) { // runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir). // Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch + -// conditional `skills sync`. Returns "init" or "upgrade". -func runScaffold(ctx context.Context, dir, appID, template string) (string, error) { +// conditional `skills sync`. Returns "init", "upgrade", or "skipped". +func runScaffold(ctx context.Context, dir, appID string, meta *appMeta, explicitTemplate string) (string, error) { + if isStaticHtml(meta) { + return scaffoldKindSkipped, nil + } empty, err := isEmptyRepo(ctx, dir) if err != nil { return "", err } if empty { - // isEmptyRepo treats a repo with no tracked files — or only the backend's - // seed README.md — as empty. If other seed files (e.g. .gitignore) can - // appear, extend isEmptyRepo's allow-list accordingly. - if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil { + args := scaffoldInitArgs(meta, appID, explicitTemplate) + if _, stderr, err := initRunner.Run(ctx, dir, "npx", args...); err != nil { return "", appsExternalToolError(err, "npx app init failed: %s", gitErr(stderr, err)) } return scaffoldKindInit, nil @@ -354,6 +361,24 @@ func runScaffold(ctx context.Context, dir, appID, template string) (string, erro return scaffoldKindUpgrade, nil } +// scaffoldInitArgs builds the npx argument list for `app init`. An explicit +// template wins; otherwise, when meta is available, --app-type/--arch-type are +// passed so miaoda-cli picks the right scaffold; nil meta falls back to +// --template defaultTemplate. +func scaffoldInitArgs(meta *appMeta, appID, explicitTemplate string) []string { + base := []string{"-y", "--prefer-online", miaodaCLIPkg, "app", "init"} + if explicitTemplate != "" { + return append(base, "--template", explicitTemplate, "--app-id", appID) + } + if meta != nil { + return append(base, + "--app-type", strconv.Itoa(meta.AppType), + "--arch-type", strconv.Itoa(meta.ArchType), + "--app-id", appID) + } + return append(base, "--template", defaultTemplate, "--app-id", appID) +} + // parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON // envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name // matches the contract emitted by `apps +git-credential-init`. @@ -445,9 +470,12 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { return err } + meta := queryAppMeta(ctx, rctx, appID) + // Already-initialized short-circuit: a dir containing .spark/meta.json is an // initialized app repo -> skip clone/scaffold/commit, but still refresh // the local env so a re-run picks up the latest startup env vars. + // Static HTML apps skip env-pull (they have no env vars). if isAlreadyInitialized(dir) { initLogf(rctx, "Already initialized at %s — refreshing local environment", dir) out := map[string]interface{}{ @@ -457,6 +485,20 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { "committed": false, "pushed": false, } + if meta != nil { + out["app_type"] = meta.AppType + out["arch_type"] = meta.ArchType + } + if isStaticHtml(meta) { + out["env_pulled"] = false + out["env_pull_skipped"] = true + out["message"] = "Repository already initialized (static HTML app — no env pull needed)." + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Already initialized at %s\n", dir) + fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。") + }) + return nil + } initLogf(rctx, "Pulling local environment variables...") envFile, envPullErr := pullEnv(ctx, rctx, appID, dir) envPulled := envPullErr == "" @@ -487,9 +529,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { return appsFailedPreconditionError("git executable not found on PATH"). WithHint("install git and ensure it is on your PATH") } - if _, err := exec.LookPath("npx"); err != nil { - return appsFailedPreconditionError("npx executable not found on PATH"). - WithHint("install Node.js (which provides npx) and ensure it is on your PATH") + if !isStaticHtml(meta) { + if _, err := exec.LookPath("npx"); err != nil { + return appsFailedPreconditionError("npx executable not found on PATH"). + WithHint("install Node.js (which provides npx) and ensure it is on your PATH") + } } if err := ensureEmptyDir(dir); err != nil { @@ -515,7 +559,8 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { } initLogf(rctx, "Initializing app code (running miaoda-cli)...") - scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID)) + explicitTemplate := strings.TrimSpace(rctx.Str("template")) + scaffold, err := runScaffold(ctx, dir, appID, meta, explicitTemplate) if err != nil { return err } @@ -530,15 +575,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { initLogf(rctx, "Working tree clean — skipped commit/push") } - initLogf(rctx, "Pulling local environment variables...") - envFile, envPullErr := pullEnv(ctx, rctx, appID, dir) - envPulled := envPullErr == "" - if envPulled { - initLogf(rctx, "Local environment written to %s", envFile) - } else { - initLogf(rctx, "Could not pull local env vars: %s", envPullErr) - } - out := map[string]interface{}{ "app_id": appID, "repository_url": redactURLCredentials(repoURL), @@ -547,21 +583,43 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { "scaffold": scaffold, "committed": committed, "pushed": pushed, - "env_pulled": envPulled, "message": "Repository initialized. You can start developing.", } - if envPulled { - out["env_file"] = envFile - } else { - out["env_pull_error"] = envPullErr - out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID) + if meta != nil { + out["app_type"] = meta.AppType + out["arch_type"] = meta.ArchType } + + var envFile string + var envPulled bool + if isStaticHtml(meta) { + out["env_pulled"] = false + out["env_pull_skipped"] = true + } else { + initLogf(rctx, "Pulling local environment variables...") + var envPullErr string + envFile, envPullErr = pullEnv(ctx, rctx, appID, dir) + envPulled = envPullErr == "" + out["env_pulled"] = envPulled + if envPulled { + initLogf(rctx, "Local environment written to %s", envFile) + out["env_file"] = envFile + } else { + initLogf(rctx, "Could not pull local env vars: %s", envPullErr) + out["env_pull_error"] = envPullErr + out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID) + } + } + rctx.OutFormat(out, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir) fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold) - if envPulled { + if isStaticHtml(meta) { + fmt.Fprintln(w, " (static HTML app — env pull skipped)") + } else if envPulled { fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile) } else { + envPullErr := out["env_pull_error"] fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr) fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID) } diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go index 7845bba92..7e79881e9 100644 --- a/shortcuts/apps/apps_init_test.go +++ b/shortcuts/apps/apps_init_test.go @@ -48,14 +48,14 @@ func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeC } func TestResolveTemplate(t *testing.T) { - if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" { + if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), nil); got != "foo" { t.Errorf("explicit --template = %q, want foo", got) } - if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate { + if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), nil); got != defaultTemplate { t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate) } // Whitespace-only --template is treated as omitted -> fallback. - if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate { + if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), nil); got != defaultTemplate { t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate) } } @@ -261,7 +261,7 @@ func TestRunScaffold_EmptyRepo(t *testing.T) { t.Run("ls="+ls, func(t *testing.T) { f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}} withFakeRunner(t, f) - kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack") + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "nestjs-react-fullstack") if err != nil || kind != "init" { t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err) } @@ -280,7 +280,7 @@ func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) { dir := t.TempDir() // no steering dir, no meta.json f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} withFakeRunner(t, f) - kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack") + kind, err := runScaffold(context.Background(), dir, "app_x", nil, "nestjs-react-fullstack") if err != nil || kind != "upgrade" { t.Fatalf("kind=%q err=%v, want upgrade", kind, err) } @@ -299,7 +299,7 @@ func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) { os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755) f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} withFakeRunner(t, f) - if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil { + if _, err := runScaffold(context.Background(), dir, "app_x", nil, "nestjs-react-fullstack"); err != nil { t.Fatal(err) } if findCallArg(f.calls, "npx", "skills", "sync") != nil { @@ -313,7 +313,7 @@ func TestRunScaffold_AppInitFailure(t *testing.T) { "npx -y": {stderr: "boom", err: errors.New("exit 1")}, }} withFakeRunner(t, f) - if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil { + if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "nestjs-react-fullstack"); err == nil { t.Error("app init failure must propagate") } } @@ -1250,7 +1250,7 @@ func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) { "git ls-files": {stdout: "src/x.ts\n"}, "npx -y": {err: errors.New("sync boom")}, }}) - if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil { + if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "tpl"); err == nil { t.Error("npx app sync failure must surface as an error") } } @@ -1630,7 +1630,7 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) { "git ls-files": {stderr: "fatal: not a git repository", err: cause}, }} withFakeRunner(t, f) - _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack") + _, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "nestjs-react-fullstack") if err == nil { t.Fatalf("expected error from failing git subprocess") } @@ -1645,3 +1645,84 @@ func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) { t.Fatalf("cause chain not preserved: %v", err) } } + +func TestRunScaffold_StaticHtmlSkipped(t *testing.T) { + f := &fakeCommandRunner{} + withFakeRunner(t, f) + meta := &appMeta{AppType: 7, ArchType: 3} + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if kind != scaffoldKindSkipped { + t.Errorf("kind = %q, want %q", kind, scaffoldKindSkipped) + } + if len(f.calls) != 0 { + t.Errorf("expected no calls for static html, got %d: %v", len(f.calls), f.calls) + } +} + +func TestRunScaffold_DoubaoHtmlPassesTypes(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}} + withFakeRunner(t, f) + meta := &appMeta{AppType: 7, ArchType: 4} + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if kind != scaffoldKindInit { + t.Errorf("kind = %q, want %q", kind, scaffoldKindInit) + } + c := findCall(f.calls, "npx", "-y") + if c == nil { + t.Fatal("npx not called") + } + if !containsAll(c, "--app-type", "7", "--arch-type", "4") { + t.Errorf("expected --app-type 7 --arch-type 4 in args: %v", c) + } + if containsAll(c, "--template") { + t.Errorf("should not contain --template when meta is present: %v", c) + } +} + +func TestRunScaffold_NilMetaFallback(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}} + withFakeRunner(t, f) + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", nil, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if kind != scaffoldKindInit { + t.Errorf("kind = %q, want %q", kind, scaffoldKindInit) + } + c := findCall(f.calls, "npx", "-y") + if c == nil { + t.Fatal("npx not called") + } + if !containsAll(c, "--template", defaultTemplate) { + t.Errorf("expected --template %s in args: %v", defaultTemplate, c) + } +} + +func TestRunScaffold_ExplicitTemplateOverride(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}} + withFakeRunner(t, f) + meta := &appMeta{AppType: 7, ArchType: 4} + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", meta, "custom-template") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if kind != scaffoldKindInit { + t.Errorf("kind = %q, want %q", kind, scaffoldKindInit) + } + c := findCall(f.calls, "npx", "-y") + if c == nil { + t.Fatal("npx not called") + } + if !containsAll(c, "--template", "custom-template") { + t.Errorf("expected --template custom-template in args: %v", c) + } + if containsAll(c, "--app-type") { + t.Errorf("--template should override --app-type: %v", c) + } +}