Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions shortcuts/apps/apps_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var AppsCreate = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type (currently only: HTML)", Required: true},
{Name: "app-type", Desc: "app type (HTML or fullstack)", Required: true},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
Expand All @@ -37,7 +37,7 @@ var AppsCreate = common.Shortcut{
return output.ErrValidation("--app-type is required")
}
if !validAppTypes[appType] {
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType))
return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML, fullstack)", appType))
}
return nil
},
Expand All @@ -59,15 +59,17 @@ var AppsCreate = common.Shortcut{
},
}

// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)
// 应用类型枚举。大小写敏感精确匹配
var validAppTypes = map[string]bool{
"HTML": true,
"HTML": true,
"fullstack": true,
}

func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
appType := strings.TrimSpace(rctx.Str("app-type"))
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
"app_type": strings.TrimSpace(rctx.Str("app-type")),
"app_type": appType,
}
if desc := strings.TrimSpace(rctx.Str("description")); desc != "" {
body["description"] = desc
Expand Down
66 changes: 66 additions & 0 deletions shortcuts/apps/apps_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,24 @@ func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected unsupported app-type error, got %v", err)
}
if !strings.Contains(err.Error(), "fullstack") {
t.Fatalf("expected allow-list error to mention \"fullstack\", got %v", err)
}
}

func TestAppsCreate_RejectsWrongCaseFullstack(t *testing.T) {
cases := []string{"FULLSTACK", "Fullstack", "FullStack"}
for _, appType := range cases {
t.Run(appType, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected case-sensitive rejection of %q, got %v", appType, err)
}
})
}
}

func TestAppsCreate_DryRun(t *testing.T) {
Expand All @@ -187,3 +205,51 @@ func TestAppsCreate_DryRun(t *testing.T) {
t.Fatalf("dry-run missing app_type: %s", got)
}
}

func TestAppsCreate_FullstackSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"app": map[string]interface{}{"app_id": "app_fs", "name": "Demo"},
},
},
}
reg.Register(stub)

if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "fullstack", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}

var sent map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["app_type"] != "fullstack" {
t.Fatalf("body.app_type = %v (want fullstack)", sent["app_type"])
}
if _, present := sent["message"]; present {
t.Fatalf("message should never be sent: %v", sent)
}
}

func TestAppsCreate_FullstackDryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "fullstack", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"app_type": "fullstack"`) {
t.Fatalf("dry-run missing app_type fullstack: %s", got)
}
if strings.Contains(got, `"message"`) {
t.Fatalf("dry-run should not contain message: %s", got)
}
}
129 changes: 129 additions & 0 deletions shortcuts/apps/storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package apps

import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"

"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"

Check failure on line 15 in shortcuts/apps/storage.go

View workflow job for this annotation

GitHub Actions / lint

import 'github.com/larksuite/cli/internal/vfs' is not allowed from list 'shortcuts-no-vfs': shortcuts must not import internal/vfs directly. Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. (depguard)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep shortcuts/ off internal/vfs.

This new package lives under shortcuts/, so importing internal/vfs here breaks the repo’s shortcut/runtime boundary. Please either move this storage layer out of shortcuts/ or route these filesystem operations through the existing runtime/file-IO abstraction instead of depending on internal/vfs directly.

Based on learnings: In the larksuite/cli repo, the golangci-lint depguard rule shortcuts-no-vfs prohibits anything under shortcuts/ from importing internal/vfs directly; shortcuts should use runtime.FileIO() / runtime.ValidatePath() instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/apps/storage.go` at line 15, The storage.go file under shortcuts
should not import internal/vfs; remove the
"github.com/larksuite/cli/internal/vfs" import and replace direct vfs calls in
the storage implementation with the runtime file-IO abstraction: call
runtime.FileIO() for filesystem operations and runtime.ValidatePath() for path
checks (or move this storage package out of shortcuts if you intend to keep
direct internal/vfs usage). Update any functions/methods in storage.go that
reference vfs (e.g., any Read/Write/Delete helpers) to use the runtime FileIO
interface methods instead so the shortcuts/ → runtime boundary is preserved.

)

// storageRoot is the per-domain local-storage directory name under the config dir.
const storageRoot = "spark"

// checkSeg validates a value used as a single path segment (appID or key).
// It rejects empty, "..", "." , URL metacharacters, control and dangerous
// Unicode via validate.ResourceName — defense-in-depth alongside the
// EncodePathSegment escaping applied when building the path, so neither value
// can traverse out of the storage directory.
func checkSeg(name, what string) error {
if err := validate.ResourceName(name, what); err != nil {
return fmt.Errorf("apps storage: %w", err)
}
if name == "." {
return fmt.Errorf("apps storage: %s must not be \".\"", what)
}
return nil
}

// appDir returns the storage directory for one app: ~/.lark-cli/spark/<esc(appID)>/
// (workspace-aware).
func appDir(appID string) string {
return filepath.Join(core.GetConfigDir(), storageRoot, validate.EncodePathSegment(appID))
}

// appKeyPath returns the file path for one (appID, key).
func appKeyPath(appID, key string) string {
return filepath.Join(appDir(appID), validate.EncodePathSegment(key))
}

// Read returns the bytes stored under (appID, key). A missing file returns
// (nil, nil). Content is opaque — callers own the format. Note: an empty stored
// value is indistinguishable from a missing key (both yield nil), so this store
// is unsuitable as an existence flag.
func Read(appID, key string) ([]byte, error) {
if err := checkSeg(appID, "appID"); err != nil {
return nil, err
}
if err := checkSeg(key, "key"); err != nil {
return nil, err
}
data, err := vfs.ReadFile(appKeyPath(appID, key))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("apps storage: read: %w", err)

Check warning on line 63 in shortcuts/apps/storage.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/storage.go#L63

Added line #L63 was not covered by tests
}
return data, nil
}

// Write atomically stores data under (appID, key): file 0600, dir 0700. It is a
// create-or-replace upsert for that key; content is written verbatim in
// plaintext. 0600 only guards against other local OS users — it does not protect
// against this user's processes, backups, or synced folders. appID and key are
// opaque strings: any "/" is escaped into a single path segment, never treated
// as a directory separator.
func Write(appID, key string, data []byte) error {
if err := checkSeg(appID, "appID"); err != nil {
return err
}
if err := checkSeg(key, "key"); err != nil {
return err
}
if err := vfs.MkdirAll(appDir(appID), 0700); err != nil {
return fmt.Errorf("apps storage: create dir: %w", err)

Check warning on line 82 in shortcuts/apps/storage.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/storage.go#L82

Added line #L82 was not covered by tests
}
if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil {
return fmt.Errorf("apps storage: write: %w", err)

Check warning on line 85 in shortcuts/apps/storage.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/storage.go#L85

Added line #L85 was not covered by tests
}
return nil
}

// Delete removes the file under (appID, key). A missing file is not an error.
func Delete(appID, key string) error {
if err := checkSeg(appID, "appID"); err != nil {
return err
}
if err := checkSeg(key, "key"); err != nil {
return err

Check warning on line 96 in shortcuts/apps/storage.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/storage.go#L96

Added line #L96 was not covered by tests
}
if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("apps storage: delete: %w", err)

Check warning on line 99 in shortcuts/apps/storage.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/storage.go#L99

Added line #L99 was not covered by tests
}
return nil
}

// List returns the keys stored under appID, skipping subdirectories and names
// that fail to unescape. A missing app directory yields an empty list.
func List(appID string) ([]string, error) {
if err := checkSeg(appID, "appID"); err != nil {
return nil, err
}
entries, err := vfs.ReadDir(appDir(appID))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, fmt.Errorf("apps storage: read dir: %w", err)

Check warning on line 115 in shortcuts/apps/storage.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/storage.go#L115

Added line #L115 was not covered by tests
}
keys := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
key, err := url.PathUnescape(e.Name())
if err != nil {
continue

Check warning on line 124 in shortcuts/apps/storage.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/apps/storage.go#L124

Added line #L124 was not covered by tests
}
keys = append(keys, key)
Comment on lines +122 to +126
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Skip decoded names that fail checkSeg.

After url.PathUnescape, List can currently return ".", "..", or other decoded keys that Write would never accept. That makes List non-round-trippable if the storage dir is polluted with stray percent-encoded filenames.

Suggested fix
 		key, err := url.PathUnescape(e.Name())
 		if err != nil {
 			continue
 		}
+		if err := checkSeg(key, "key"); err != nil {
+			continue
+		}
 		keys = append(keys, key)
📝 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
key, err := url.PathUnescape(e.Name())
if err != nil {
continue
}
keys = append(keys, key)
key, err := url.PathUnescape(e.Name())
if err != nil {
continue
}
if err := checkSeg(key, "key"); err != nil {
continue
}
keys = append(keys, key)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/apps/storage.go` around lines 122 - 126, List currently appends
decoded filenames from url.PathUnescape without validating them, allowing values
like "." or ".." (or other segments Write would reject) to surface; update the
List implementation to call checkSeg on the decoded key (the result of
url.PathUnescape) and skip (continue) any keys that fail checkSeg, ensuring List
only returns keys that Write would accept.

}
return keys, nil
}
Loading
Loading