diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..94a2072 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,95 @@ +name: Build + +on: + workflow_call: + inputs: + version: + description: 'Version to embed in binary' + required: false + type: string + default: 'dev' + upload-artifacts: + description: 'Upload build artifacts' + required: false + type: boolean + default: true + retention-days: + description: 'Artifact retention days' + required: false + type: number + default: 1 + workflow_dispatch: + inputs: + version: + description: 'Version to embed in binary' + required: false + type: string + default: 'dev' + upload-artifacts: + description: 'Upload build artifacts' + required: false + type: boolean + default: true + retention-days: + description: 'Artifact retention days' + required: false + type: number + default: 1 + +jobs: + build: + name: Build ${{ matrix.goos }}-${{ matrix.goarch }} + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + cache: true + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + # Set binary name + VERSION="${{ inputs.version }}" + EXT="" + [ "$GOOS" = "windows" ] && EXT=".exe" + BINARY_NAME="sym-$GOOS-$GOARCH$EXT" + + # Build + go build \ + -ldflags "-s -w -X main.Version=$VERSION" \ + -trimpath \ + -o "$BINARY_NAME" \ + ./cmd/sym + + echo "Built $BINARY_NAME" + + - name: Upload artifact + if: inputs.upload-artifacts + uses: actions/upload-artifact@v4 + with: + name: sym-${{ matrix.goos }}-${{ matrix.goarch }} + path: sym-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c96d28 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + schedule: + # 매일 한국 시간 오전 9시 (UTC 0시) + - cron: '0 0 * * *' + pull_request: + branches: + - main + workflow_call: + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + actions: write + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + cache: true + + - name: Run go vet + run: go vet ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.4.0 + args: --timeout=5m + + test: + name: Unit Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + cache: true + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run tests + run: go test -short -v -race -coverprofile=coverage.out -covermode=atomic ./... + + - name: Generate coverage report + run: go tool cover -html=coverage.out -o coverage.html + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage.out + coverage.html + retention-days: 1 + + build: + name: Build + needs: [lint, test] + uses: ./.github/workflows/build.yml + with: + version: 'ci-build' + upload-artifacts: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..caccddd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,228 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +jobs: + extract-version: + name: Extract version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + should_release: ${{ steps.check.outputs.should_release }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Extract version from package.json + id: version + run: | + VERSION=$(node -p "require('./npm/package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + + - name: Check if release exists + id: check + run: | + VERSION="${{ steps.version.outputs.version }}" + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists, skipping release" + echo "should_release=false" >> $GITHUB_OUTPUT + else + echo "Tag v$VERSION does not exist, proceeding with release" + echo "should_release=true" >> $GITHUB_OUTPUT + fi + + ci: + name: Run CI + needs: extract-version + if: needs.extract-version.outputs.should_release == 'true' + uses: ./.github/workflows/ci.yml + + build: + name: Build ${{ matrix.goos }}-${{ matrix.goarch }} + needs: [extract-version, ci] + if: needs.extract-version.outputs.should_release == 'true' + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.1' + cache: true + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION="${{ needs.extract-version.outputs.version }}" + EXT="" + [ "$GOOS" = "windows" ] && EXT=".exe" + BINARY_NAME="sym-$GOOS-$GOARCH$EXT" + + go build \ + -ldflags "-s -w -X main.Version=$VERSION" \ + -trimpath \ + -o "$BINARY_NAME" \ + ./cmd/sym + + echo "Built $BINARY_NAME" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: sym-${{ matrix.goos }}-${{ matrix.goarch }} + path: sym-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} + if-no-files-found: error + retention-days: 1 + + release: + name: Create GitHub Release + needs: [extract-version, ci, build] + if: needs.extract-version.outputs.should_release == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release assets + run: | + mkdir -p release-assets + find artifacts -type f -name 'sym-*' -exec mv {} release-assets/ \; + + # Verify binary count + BINARY_COUNT=$(ls -1 release-assets/ | wc -l) + echo "Found $BINARY_COUNT binaries" + + if [ "$BINARY_COUNT" -ne 5 ]; then + echo "Error: Expected 5 binaries, found $BINARY_COUNT" + ls -lh release-assets/ + exit 1 + fi + + echo "All 5 binaries prepared successfully" + ls -lh release-assets/ + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ needs.extract-version.outputs.version }} + name: Release v${{ needs.extract-version.outputs.version }} + files: release-assets/* + generate_release_notes: true + draft: false + prerelease: false + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + npm: + name: Publish to npm + needs: [extract-version, ci, build, release] + if: needs.extract-version.outputs.should_release == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Copy binaries to npm package + run: | + mkdir -p npm/bin + find artifacts -type f -name 'sym-*' -exec cp {} npm/bin/ \; + + # Verify binary count + BINARY_COUNT=$(find npm/bin -name 'sym-*' -type f | wc -l) + echo "Found $BINARY_COUNT binaries in npm/bin/" + + if [ "$BINARY_COUNT" -ne 5 ]; then + echo "Error: Expected 5 binaries, found $BINARY_COUNT" + ls -lh npm/bin/ + exit 1 + fi + + echo "All 5 binaries copied to npm package successfully" + ls -lh npm/bin/ + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Publish to npm + working-directory: npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # deploy-coverage: + # name: Deploy Coverage to GitHub Pages + # needs: [extract-version, ci] + # if: needs.extract-version.outputs.should_release == 'true' + # runs-on: ubuntu-latest + # permissions: + # contents: write + + # steps: + # - name: Download coverage artifact + # uses: actions/download-artifact@v4 + # with: + # name: coverage-report + # path: coverage-report + + # - name: Deploy to GitHub Pages + # uses: peaceiris/actions-gh-pages@v3 + # with: + # github_token: ${{ secrets.GITHUB_TOKEN }} + # publish_dir: ./coverage-report + # publish_branch: gh-pages + # destination_dir: coverage + # keep_files: true + # enable_jekyll: false + # commit_message: 'Deploy coverage for v${{ needs.extract-version.outputs.version }}' + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 21a9d2d..7b04681 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ .claude/ bin/ +!npm/bin/ +!npm/bin/*.js +npm/bin/sym-* + +# Test coverage coverage.out coverage.html node_modules/ .sym/ +*.coverprofile +coverage.txt diff --git a/Makefile b/Makefile index fe79c83..940fd31 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # symphonyclient integration: Added CSS build targets -.PHONY: build test install clean fmt lint tidy help build-all setup build-css install-css watch-css +.PHONY: build test install clean fmt lint tidy help build-all setup coverage-check build-css install-css watch-css BINARY_NAME=sym BUILD_DIR=bin @@ -67,6 +67,20 @@ test: @go tool cover -html=coverage.out -o coverage.html @echo "Test complete. Coverage report: coverage.html" +coverage-check: + @echo "Checking coverage threshold..." + @go test -coverprofile=coverage.out ./... > /dev/null 2>&1 + @COVERAGE=$$(go tool cover -func=coverage.out | grep total | awk '{print $$3}' | sed 's/%//'); \ + THRESHOLD=80; \ + echo "Current coverage: $$COVERAGE%"; \ + echo "Required threshold: $$THRESHOLD%"; \ + if [ "$$(echo "$$COVERAGE < $$THRESHOLD" | bc -l 2>/dev/null || awk "BEGIN {print ($$COVERAGE < $$THRESHOLD)}")" -eq 1 ]; then \ + echo "❌ Coverage $$COVERAGE% is below threshold $$THRESHOLD%"; \ + exit 1; \ + else \ + echo "✅ Coverage $$COVERAGE% meets threshold $$THRESHOLD%"; \ + fi + install: @echo "Installing $(BINARY_NAME)..." @go install $(MAIN_PATH) @@ -86,11 +100,8 @@ fmt: lint: @echo "Running linter..." - @if command -v golangci-lint > /dev/null; then \ - golangci-lint run ./...; \ - else \ - echo "golangci-lint not installed. Install: https://golangci-lint.run/usage/install/"; \ - fi + golangci-lint run + @echo "Linter complete" tidy: @echo "Tidying dependencies..." @@ -101,7 +112,7 @@ tidy: setup: tidy install-css @echo "Setting up development environment..." @go mod download - @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 @echo "Development environment setup complete" dev-deps: diff --git a/README.md b/README.md index cbe1895..0676777 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ GitHub Repository Role & Policy Management Tool with Code Convention Validation Symphony는 GitHub OAuth 인증을 통한 역할 기반 파일 접근 권한 및 코딩 정책 관리를 위한 하이브리드 CLI/Web 애플리케이션입니다. 자연어로 정의된 컨벤션을 검증하는 LLM 친화적 linter 기능을 포함합니다. +[![Test Coverage](https://img.shields.io/badge/coverage-view%20report-blue)](https://devsymphony.github.io/sym-cli/coverage.html) + +## 개요 > **✨ 빠른 시작:** `sym login` 한 번이면 끝! OAuth App 설정 불필요. @@ -40,6 +43,61 @@ Symphony는 GitHub OAuth 인증을 통한 역할 기반 파일 접근 권한 및 ## 📦 설치 +### MCP 서버로 설치 (권장 - AI 코딩 도구) + +**Claude Code 원클릭 설치**: +```bash +claude mcp add symphony npx @dev-symphony/sym@latest mcp +``` + +**수동 MCP 설정** (Claude Desktop / Cursor / Continue.dev): + +config 파일 위치: +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%/Claude/claude_desktop_config.json` +- Linux: `~/.config/Claude/claude_desktop_config.json` + +설정 추가: +```json +{ + "mcpServers": { + "symphony": { + "command": "npx", + "args": ["-y", "@dev-symphony/sym@latest", "mcp"], + "env": { + "SYM_POLICY_PATH": "${workspaceFolder}/.sym/user-policy.json" + } + } + } +} +``` + +Claude Desktop 재시작 후 사용 가능! + +### npm 글로벌 설치 + +```bash +npm install -g @dev-symphony/sym +``` + +### 바이너리 다운로드 + +GitHub Releases 페이지에서 플랫폼에 맞는 바이너리를 다운로드할 수 있습니다. + +#### GPG 서명 검증 (권장) + +릴리스 바이너리는 GPG로 서명됩니다. 다운로드한 파일의 무결성을 검증하려면: + +```bash +# 1. GPG 공개키 가져오기 (최초 1회) +gpg --keyserver keys.openpgp.org --recv-keys [GPG_KEY_ID] + +# 2. 서명 검증 +gpg --verify sym-linux-amd64.asc sym-linux-amd64 +``` + +서명이 유효하면 `Good signature from "DevSymphony"` 메시지가 표시됩니다. + ### 소스에서 빌드 ```bash @@ -74,6 +132,30 @@ go install github.com/DevSymphony/sym-cli/cmd/sym@latest ## 🚀 빠른 시작 +### MCP 서버 모드 (AI 코딩 도구와 함께) + +Symphony를 MCP 서버로 실행하여 Claude, Cursor, Continue.dev 등과 함께 사용: + +```bash +# stdio 모드 (기본 - AI 도구 연동용) +sym mcp + +# HTTP 모드 (디버깅/테스트용) +sym mcp --port 4000 + +# 커스텀 정책 파일 지정 +sym mcp --config ./custom-policy.json +``` + +**Claude에게 물어보기**: +- "이 프로젝트의 네이밍 컨벤션은 뭐야?" +- "이 코드가 컨벤션을 지키는지 검증해줘" +- "Go 코드 작성 시 주의할 점은?" + +MCP 설치 방법은 [설치](#-설치) 섹션 참고. + +--- + ### 1. 초기 설정 및 로그인 ```bash @@ -221,6 +303,8 @@ go test ./internal/engine/pattern/... -v go test ./tests/integration/... -v ``` +테스트 커버리지 리포트는 [여기](https://devsymphony.github.io/sym-cli/coverage.html)에서 확인할 수 있습니다. + ### 코드 품질 ```bash diff --git a/cmd/sym/main.go b/cmd/sym/main.go index be1ca28..2102c20 100644 --- a/cmd/sym/main.go +++ b/cmd/sym/main.go @@ -4,7 +4,13 @@ import ( "github.com/DevSymphony/sym-cli/internal/cmd" ) +// Version is set by build -ldflags "-X main.Version=x.y.z" +var Version = "dev" + func main() { + // Set version for version command + cmd.SetVersion(Version) + // symphonyclient integration: Execute() doesn't return error cmd.Execute() } diff --git a/go.mod b/go.mod index 5c093de..914557a 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,11 @@ module github.com/DevSymphony/sym-cli go 1.25.1 require ( + github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // symphonyclient integration: browser automation for OAuth github.com/spf13/cobra v1.10.1 ) -require github.com/bmatcuk/doublestar/v4 v4.9.1 - require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/internal/adapter/eslint/adapter_test.go b/internal/adapter/eslint/adapter_test.go index 9fdbd24..2dda823 100644 --- a/internal/adapter/eslint/adapter_test.go +++ b/internal/adapter/eslint/adapter_test.go @@ -58,7 +58,7 @@ func TestInitPackageJSON(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter(tmpDir, "") @@ -100,7 +100,7 @@ func TestInstall(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter(tmpDir, "") diff --git a/internal/adapter/eslint/executor.go b/internal/adapter/eslint/executor.go index 98b38e8..c0ca2e0 100644 --- a/internal/adapter/eslint/executor.go +++ b/internal/adapter/eslint/executor.go @@ -24,7 +24,7 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* if err != nil { return nil, fmt.Errorf("failed to write config: %w", err) } - defer os.Remove(configPath) + defer func() { _ = os.Remove(configPath) }() // Get command and arguments eslintCmd, args := a.getExecutionArgs(configPath, files) @@ -87,10 +87,10 @@ func (a *Adapter) writeConfigFile(config []byte) (string, error) { if err != nil { return "", err } - defer tmpFile.Close() + defer func() { _ = tmpFile.Close() }() if _, err := tmpFile.Write(config); err != nil { - os.Remove(tmpFile.Name()) + _ = os.Remove(tmpFile.Name()) return "", err } diff --git a/internal/adapter/eslint/executor_test.go b/internal/adapter/eslint/executor_test.go index a9668a4..22f8891 100644 --- a/internal/adapter/eslint/executor_test.go +++ b/internal/adapter/eslint/executor_test.go @@ -14,7 +14,7 @@ func TestExecute_FileCreation(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter("", tmpDir) @@ -22,7 +22,7 @@ func TestExecute_FileCreation(t *testing.T) { config := []byte(`{"rules": {"semi": [2, "always"]}}`) files := []string{"test.js"} - _, err = a.execute(ctx, config, files) + _, _ = a.execute(ctx, config, files) configPath := filepath.Join(tmpDir, ".symphony-eslintrc.json") if _, err := os.Stat(configPath); !os.IsNotExist(err) { @@ -108,7 +108,7 @@ func TestWriteConfigFile(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter("", tmpDir) @@ -118,7 +118,7 @@ func TestWriteConfigFile(t *testing.T) { if err != nil { t.Fatalf("writeConfigFile() error = %v", err) } - defer os.Remove(configPath) + defer func() { _ = os.Remove(configPath) }() if _, err := os.Stat(configPath); os.IsNotExist(err) { t.Error("Config file was not created") @@ -143,7 +143,7 @@ func TestExecute_Integration(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() // Create a test file testFile := filepath.Join(tmpDir, "test.js") diff --git a/internal/adapter/prettier/adapter_test.go b/internal/adapter/prettier/adapter_test.go index 75f715e..a5558f8 100644 --- a/internal/adapter/prettier/adapter_test.go +++ b/internal/adapter/prettier/adapter_test.go @@ -58,7 +58,7 @@ func TestInitPackageJSON(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter(tmpDir, "") @@ -88,7 +88,7 @@ func TestInstall(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter(tmpDir, "") diff --git a/internal/adapter/prettier/executor.go b/internal/adapter/prettier/executor.go index bbff327..20846de 100644 --- a/internal/adapter/prettier/executor.go +++ b/internal/adapter/prettier/executor.go @@ -21,7 +21,7 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string, mo if err != nil { return nil, fmt.Errorf("failed to write config: %w", err) } - defer os.Remove(configPath) + defer func() { _ = os.Remove(configPath) }() // Determine Prettier command prettierCmd := a.getPrettierCommand() @@ -31,9 +31,10 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string, mo "--config", configPath, } - if mode == "check" { + switch mode { + case "check": args = append(args, "--check") - } else if mode == "write" { + case "write": args = append(args, "--write") } @@ -70,10 +71,10 @@ func (a *Adapter) writeConfigFile(config []byte) (string, error) { if err != nil { return "", err } - defer tmpFile.Close() + defer func() { _ = tmpFile.Close() }() if _, err := tmpFile.Write(config); err != nil { - os.Remove(tmpFile.Name()) + _ = os.Remove(tmpFile.Name()) return "", err } diff --git a/internal/adapter/prettier/executor_test.go b/internal/adapter/prettier/executor_test.go index 8ec994c..f101bd0 100644 --- a/internal/adapter/prettier/executor_test.go +++ b/internal/adapter/prettier/executor_test.go @@ -12,7 +12,7 @@ func TestExecute_FileCreation(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter("", tmpDir) @@ -20,7 +20,7 @@ func TestExecute_FileCreation(t *testing.T) { config := []byte(`{"semi": true}`) files := []string{"test.js"} - _, err = a.execute(ctx, config, files, "check") + _, _ = a.execute(ctx, config, files, "check") configPath := filepath.Join(tmpDir, ".symphony-prettierrc.json") if _, err := os.Stat(configPath); !os.IsNotExist(err) { @@ -60,7 +60,7 @@ func TestWriteConfigFile(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter("", tmpDir) @@ -70,7 +70,7 @@ func TestWriteConfigFile(t *testing.T) { if err != nil { t.Fatalf("writeConfigFile() error = %v", err) } - defer os.Remove(configPath) + defer func() { _ = os.Remove(configPath) }() if _, err := os.Stat(configPath); os.IsNotExist(err) { t.Error("Config file was not created") @@ -95,7 +95,7 @@ func TestExecute_Integration(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() // Create a test file with bad formatting testFile := filepath.Join(tmpDir, "test.js") diff --git a/internal/adapter/tsc/adapter_test.go b/internal/adapter/tsc/adapter_test.go index aadcec1..2d815e9 100644 --- a/internal/adapter/tsc/adapter_test.go +++ b/internal/adapter/tsc/adapter_test.go @@ -59,7 +59,7 @@ func TestInitPackageJSON(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() adapter := NewAdapter(tmpDir, "") @@ -113,7 +113,7 @@ func TestInstall_MissingNPM(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() a := NewAdapter(tmpDir, "") @@ -139,7 +139,7 @@ func TestExecute_FileCreation(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() adapter := NewAdapter("", tmpDir) @@ -148,7 +148,7 @@ func TestExecute_FileCreation(t *testing.T) { files := []string{"test.ts"} // Execute (will fail because tsc not installed, but we can test config file creation) - _, err = adapter.Execute(ctx, config, files) + _, _ = adapter.Execute(ctx, config, files) // Config file should have been created and cleaned up configPath := filepath.Join(tmpDir, ".symphony-tsconfig.json") diff --git a/internal/adapter/tsc/executor.go b/internal/adapter/tsc/executor.go index fa08aeb..8c4fd74 100644 --- a/internal/adapter/tsc/executor.go +++ b/internal/adapter/tsc/executor.go @@ -16,7 +16,7 @@ func (a *Adapter) execute(ctx context.Context, config []byte, files []string) (* if err := os.WriteFile(configPath, config, 0644); err != nil { return nil, fmt.Errorf("failed to write tsconfig: %w", err) } - defer os.Remove(configPath) + defer func() { _ = os.Remove(configPath) }() // Determine tsc binary path tscPath := a.getTSCPath() diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index a34d0e7..e7eae87 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -47,7 +47,7 @@ func StartOAuthFlow() error { // Send success message to browser using embedded HTML w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(loginSuccessHTML)) + _, _ = w.Write([]byte(loginSuccessHTML)) }) // Serve the Tailwind CSS file @@ -87,17 +87,17 @@ func StartOAuthFlow() error { case code = <-codeChan: // Success case err := <-errChan: - server.Shutdown(context.Background()) + _ = server.Shutdown(context.Background()) return err case <-time.After(5 * time.Minute): - server.Shutdown(context.Background()) + _ = server.Shutdown(context.Background()) return fmt.Errorf("authentication timeout") } // Shutdown server ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - server.Shutdown(ctx) + _ = server.Shutdown(ctx) // Exchange code for token fmt.Println("Exchanging code for access token...") diff --git a/internal/auth/server.go b/internal/auth/server.go index 8c397c9..aab1031 100644 --- a/internal/auth/server.go +++ b/internal/auth/server.go @@ -80,7 +80,7 @@ func startAuthSession(serverURL string) (*SessionResponse, error) { if err != nil { return nil, fmt.Errorf("failed to connect to auth server: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -99,21 +99,19 @@ func startAuthSession(serverURL string) (*SessionResponse, error) { func pollForToken(serverURL, sessionCode string, expiresIn int) (string, string, error) { url := fmt.Sprintf("%s/authStatus/%s", serverURL, sessionCode) - // Calculate timeout - timeout := time.Now().Add(time.Duration(expiresIn) * time.Second) - // Poll every 3 seconds ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() + // Setup timeout timer + timeoutTimer := time.After(time.Duration(expiresIn) * time.Second) + for { select { - case <-ticker.C: - // Check if timeout - if time.Now().After(timeout) { - return "", "", fmt.Errorf("authentication timeout (%d초). 다시 시도해주세요", expiresIn) - } + case <-timeoutTimer: + return "", "", fmt.Errorf("authentication timeout (%d초). 다시 시도해주세요", expiresIn) + case <-ticker.C: // Check status status, err := checkAuthStatus(url) if err != nil { @@ -154,7 +152,7 @@ func checkAuthStatus(url string) (*StatusResponse, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("invalid session code") @@ -163,14 +161,14 @@ func checkAuthStatus(url string) (*StatusResponse, error) { if resp.StatusCode == http.StatusGone { // Session expired var status StatusResponse - json.NewDecoder(resp.Body).Decode(&status) + _ = json.NewDecoder(resp.Body).Decode(&status) return &status, nil } if resp.StatusCode == http.StatusForbidden { // Denied var status StatusResponse - json.NewDecoder(resp.Body).Decode(&status) + _ = json.NewDecoder(resp.Body).Decode(&status) return &status, nil } diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index b40e332..8e79952 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -24,7 +24,7 @@ Tools provided by MCP server: By default, communicates via stdio. If --port is specified, starts HTTP server.`, Example: ` sym mcp sym mcp --config code-policy.json - sym mcp --host 0.0.0.0 --port 8080`, + sym mcp --port 4000 --host 0.0.0.0`, RunE: runMCP, } @@ -33,7 +33,7 @@ func init() { mcpCmd.Flags().StringVarP(&mcpConfig, "config", "c", "", "policy file path (code-policy.json)") mcpCmd.Flags().StringVar(&mcpHost, "host", "127.0.0.1", "server host (HTTP mode only)") - mcpCmd.Flags().IntVarP(&mcpPort, "port", "p", 4000, "server port (0 = stdio mode, >0 = HTTP mode)") + mcpCmd.Flags().IntVarP(&mcpPort, "port", "p", 0, "server port (0 = stdio mode, >0 = HTTP mode)") } func runMCP(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/my_role.go b/internal/cmd/my_role.go index bd5b171..9c9498e 100644 --- a/internal/cmd/my_role.go +++ b/internal/cmd/my_role.go @@ -32,7 +32,7 @@ func runMyRole(cmd *cobra.Command, args []string) { if !config.IsLoggedIn() { if myRoleJSON { output := map[string]string{"error": "not logged in"} - json.NewEncoder(os.Stdout).Encode(output) + _ = json.NewEncoder(os.Stdout).Encode(output) } else { fmt.Println("❌ Not logged in") fmt.Println("Run 'sym login' first") @@ -44,7 +44,7 @@ func runMyRole(cmd *cobra.Command, args []string) { if !git.IsGitRepo() { if myRoleJSON { output := map[string]string{"error": "not a git repository"} - json.NewEncoder(os.Stdout).Encode(output) + _ = json.NewEncoder(os.Stdout).Encode(output) } else { fmt.Println("❌ Not a git repository") fmt.Println("Navigate to a git repository before running this command") @@ -93,7 +93,7 @@ func runMyRole(cmd *cobra.Command, args []string) { "owner": owner, "repo": repo, } - json.NewEncoder(os.Stdout).Encode(output) + _ = json.NewEncoder(os.Stdout).Encode(output) } else { fmt.Printf("Repository: %s/%s\n", owner, repo) fmt.Printf("User: %s\n", user.Login) @@ -109,7 +109,7 @@ func runMyRole(cmd *cobra.Command, args []string) { func handleError(msg string, err error, jsonMode bool) { if jsonMode { output := map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)} - json.NewEncoder(os.Stdout).Encode(output) + _ = json.NewEncoder(os.Stdout).Encode(output) } else { fmt.Printf("❌ %s: %v\n", msg, err) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bf783ad..01f1228 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -45,7 +45,7 @@ func init() { rootCmd.AddCommand(myRoleCmd) rootCmd.AddCommand(whoamiCmd) rootCmd.AddCommand(policyCmd) - rootCmd.AddCommand(mcpCmd) + // Note: mcpCmd is registered in mcp.go's init() // sym-cli core commands (in development) rootCmd.AddCommand(convertCmd) diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..8b2335f --- /dev/null +++ b/internal/cmd/version.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// version will be set by build flags from cmd/sym/main.go +var version = "dev" + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number", + Long: `Print the version number of sym CLI.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("sym version %s\n", version) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +// SetVersion sets the version string (called from main.go) +func SetVersion(v string) { + version = v +} diff --git a/internal/cmd/whoami.go b/internal/cmd/whoami.go index a082db5..c7602de 100644 --- a/internal/cmd/whoami.go +++ b/internal/cmd/whoami.go @@ -30,7 +30,7 @@ func runWhoami(cmd *cobra.Command, args []string) { if !config.IsLoggedIn() { if whoamiJSON { output := map[string]string{"error": "not logged in"} - json.NewEncoder(os.Stdout).Encode(output) + _ = json.NewEncoder(os.Stdout).Encode(output) } else { fmt.Println("❌ Not logged in") fmt.Println("Run 'sym login' first") @@ -65,7 +65,7 @@ func runWhoami(cmd *cobra.Command, args []string) { "email": user.Email, "id": user.ID, } - json.NewEncoder(os.Stdout).Encode(output) + _ = json.NewEncoder(os.Stdout).Encode(output) } else { fmt.Printf("Username: %s\n", user.Login) if user.Name != "" { @@ -82,7 +82,7 @@ func runWhoami(cmd *cobra.Command, args []string) { func handleWhoamiError(msg string, err error, jsonMode bool) { if jsonMode { output := map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)} - json.NewEncoder(os.Stdout).Encode(output) + _ = json.NewEncoder(os.Stdout).Encode(output) } else { fmt.Printf("❌ %s: %v\n", msg, err) } diff --git a/internal/engine/ast/engine.go b/internal/engine/ast/engine.go index 4ecc9bb..4d5cfdf 100644 --- a/internal/engine/ast/engine.go +++ b/internal/engine/ast/engine.go @@ -41,13 +41,19 @@ func (e *Engine) Init(ctx context.Context, config core.EngineConfig) error { // Validate checks files against AST structure rules. func (e *Engine) Validate(ctx context.Context, rule core.Rule, files []string) (*core.ValidationResult, error) { - if e.eslint == nil { - return nil, fmt.Errorf("AST engine not initialized") - } - + // Filter files first - empty file list is valid without initialization files = e.filterFiles(files, rule.When) if len(files) == 0 { - return &core.ValidationResult{Violations: []core.Violation{}}, nil + return &core.ValidationResult{ + RuleID: rule.ID, + Passed: true, + Violations: []core.Violation{}, + }, nil + } + + // Check initialization only when we have files to process + if e.eslint == nil { + return nil, fmt.Errorf("AST engine not initialized") } // Parse AST query diff --git a/internal/engine/core/selector_test.go b/internal/engine/core/selector_test.go index 4ec9ade..77acd7c 100644 --- a/internal/engine/core/selector_test.go +++ b/internal/engine/core/selector_test.go @@ -546,7 +546,7 @@ func TestFilterFiles(t *testing.T) { // Benchmark tests func BenchmarkMatchGlob(b *testing.B) { for i := 0; i < b.N; i++ { - MatchGlob("src/foo/bar/baz/test.go", "src/**/*.go") + _, _ = MatchGlob("src/foo/bar/baz/test.go", "src/**/*.go") } } diff --git a/internal/github/client.go b/internal/github/client.go index 56310eb..4f2e1f4 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -56,7 +56,7 @@ func (c *Client) GetCurrentUser() (*User, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -93,7 +93,7 @@ func ExchangeCodeForToken(host, clientID, clientSecret, code string) (string, er if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e737e22..0b2391d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -87,10 +87,13 @@ func (s *Server) startHTTPServer() error { func (s *Server) handleHealthCheck(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ + if err := json.NewEncoder(w).Encode(map[string]interface{}{ "status": "ok", "version": "1.0.0", - }) + }); err != nil { + // Log error but don't fail - headers already sent + _ = err + } } // handleHTTPRequest handles HTTP JSON-RPC requests. @@ -104,7 +107,7 @@ func (s *Server) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(JSONRPCResponse{ + _ = json.NewEncoder(w).Encode(JSONRPCResponse{ JSONRPC: "2.0", Error: &RPCError{ Code: -32700, @@ -143,7 +146,7 @@ func (s *Server) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { } else { w.WriteHeader(http.StatusOK) } - json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) } // JSONRPCRequest is a JSON-RPC 2.0 request. diff --git a/internal/policy/history.go b/internal/policy/history.go index 39d445e..499f1e6 100644 --- a/internal/policy/history.go +++ b/internal/policy/history.go @@ -55,7 +55,10 @@ func GetPolicyHistory(customPath string, limit int) ([]PolicyCommit, error) { } var timestamp int64 - fmt.Sscanf(parts[3], "%d", ×tamp) + if _, err := fmt.Sscanf(parts[3], "%d", ×tamp); err != nil { + // Skip malformed timestamp + continue + } commit := PolicyCommit{ Hash: parts[0], diff --git a/internal/server/server.go b/internal/server/server.go index 877047f..aa05372 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -227,7 +227,7 @@ func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // handleRoles handles GET and POST requests for roles @@ -251,7 +251,7 @@ func (s *Server) handleGetRoles(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(rolesData) + _ = json.NewEncoder(w).Encode(rolesData) } // handleUpdateRoles updates the roles (requires editRoles permission) @@ -296,7 +296,7 @@ func (s *Server) handleUpdateRoles(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + _ = json.NewEncoder(w).Encode(map[string]string{ "status": "success", "message": "Roles updated successfully", }) @@ -321,7 +321,7 @@ func (s *Server) handleRepoInfo(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + _ = json.NewEncoder(w).Encode(response) } // handlePolicy handles GET and POST requests for policy @@ -345,7 +345,7 @@ func (s *Server) handleGetPolicy(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(policyData) + _ = json.NewEncoder(w).Encode(policyData) } // handleSavePolicy saves the policy (requires editPolicy permission) @@ -390,7 +390,7 @@ func (s *Server) handleSavePolicy(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + _ = json.NewEncoder(w).Encode(map[string]string{ "status": "success", "message": "Policy saved successfully", }) @@ -401,7 +401,7 @@ func (s *Server) handlePolicyPath(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + _ = json.NewEncoder(w).Encode(map[string]string{ "policyPath": s.cfg.PolicyPath, }) case http.MethodPost: @@ -449,7 +449,7 @@ func (s *Server) handleSetPolicyPath(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ + _ = json.NewEncoder(w).Encode(map[string]string{ "status": "success", "message": "Policy path updated successfully", }) @@ -469,7 +469,7 @@ func (s *Server) handlePolicyHistory(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(history) + _ = json.NewEncoder(w).Encode(history) } // handlePolicyTemplates returns the list of available templates @@ -486,7 +486,7 @@ func (s *Server) handlePolicyTemplates(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(templates) + _ = json.NewEncoder(w).Encode(templates) } // handlePolicyTemplateDetail returns a specific template @@ -510,7 +510,7 @@ func (s *Server) handlePolicyTemplateDetail(w http.ResponseWriter, r *http.Reque } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(template) + _ = json.NewEncoder(w).Encode(template) } // handleUsers returns all users from roles.json @@ -553,5 +553,5 @@ func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(users) + _ = json.NewEncoder(w).Encode(users) } diff --git a/npm/.npmignore b/npm/.npmignore new file mode 100644 index 0000000..d082109 --- /dev/null +++ b/npm/.npmignore @@ -0,0 +1,25 @@ +# Development files +*.log +*.tmp +.DS_Store + +# Node modules +node_modules/ + +# Test files +test/ +tests/ +*.test.js +*.spec.js + +# CI/CD +.github/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Downloaded binaries (will be downloaded during postinstall) +bin/sym-* diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..26c9526 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,84 @@ +# Symphony MCP Server + +LLM-friendly convention linter for AI coding tools. + +## Installation + +### One-line MCP Setup + +```bash +claude mcp add symphony npx @dev-symphony/sym@latest mcp +``` + +### Direct Installation + +```bash +npm install -g @dev-symphony/sym +``` + +## Usage + +### MCP Configuration + +Add to your MCP config file: + +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%/Claude/claude_desktop_config.json` +- Linux: `~/.config/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "symphony": { + "command": "npx", + "args": ["-y", "@dev-symphony/sym@latest", "mcp"] + } + } +} +``` + +### Available Tools + +**query_conventions** +- Query project conventions by category, files, or languages +- All parameters are optional + +**validate_code** +- Validate code against defined conventions +- Parameters: files (required) + +## Policy File + +Create `.sym/user-policy.json` in your project root: + +```json +{ + "version": "1.0.0", + "rules": [ + { + "say": "Functions should be documented", + "category": "documentation" + }, + { + "say": "Lines should be less than 100 characters", + "category": "formatting", + "params": { "max": 100 } + } + ] +} +``` + +## Requirements + +- Node.js >= 16.0.0 +- Policy file: `.sym/user-policy.json` + +## Supported Platforms + +- macOS (Intel, Apple Silicon) +- Linux (x64, ARM64) +- Windows (x64) + +## License + +MIT diff --git a/npm/bin/sym.js b/npm/bin/sym.js new file mode 100644 index 0000000..e3ad820 --- /dev/null +++ b/npm/bin/sym.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +/** + * Get the platform-specific binary name + */ +function getPlatformBinary() { + const platform = process.platform; + const arch = process.arch; + + const binaryMap = { + 'darwin-arm64': 'sym-darwin-arm64', + 'darwin-x64': 'sym-darwin-amd64', + 'linux-x64': 'sym-linux-amd64', + 'linux-arm64': 'sym-linux-arm64', + 'win32-x64': 'sym-windows-amd64.exe', + }; + + const key = `${platform}-${arch}`; + const binaryName = binaryMap[key]; + + if (!binaryName) { + console.error(`Error: Unsupported platform: ${platform}-${arch}`); + console.error('Supported platforms:'); + Object.keys(binaryMap).forEach(k => console.error(` - ${k}`)); + process.exit(1); + } + + return path.join(__dirname, binaryName); +} + +/** + * Main execution + */ +function main() { + const binaryPath = getPlatformBinary(); + + if (!fs.existsSync(binaryPath)) { + console.error(`Error: Binary not found at ${binaryPath}`); + console.error(''); + console.error('This usually means the binary download failed during installation.'); + console.error('Please try reinstalling:'); + console.error(' npm uninstall @dev-symphony/sym'); + console.error(' npm install @dev-symphony/sym'); + console.error(''); + console.error('If the problem persists, please report an issue at:'); + console.error(' https://github.com/DevSymphony/sym-cli/issues'); + process.exit(1); + } + + // Make sure the binary is executable (Unix systems) + if (process.platform !== 'win32') { + try { + fs.chmodSync(binaryPath, '755'); + } catch (err) { + // Ignore chmod errors, might not have permission + } + } + + // Pass all arguments to the binary + const args = process.argv.slice(2); + + const child = spawn(binaryPath, args, { + stdio: 'inherit', + windowsHide: true, + }); + + child.on('error', (err) => { + console.error(`Error executing binary: ${err.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code || 0); + } + }); +} + +main(); diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 0000000..4782abc --- /dev/null +++ b/npm/package.json @@ -0,0 +1,46 @@ +{ + "name": "@dev-symphony/sym", + "version": "0.1.0", + "description": "Symphony - LLM-friendly convention linter for AI coding assistants", + "keywords": [ + "mcp", + "linter", + "conventions", + "llm", + "claude", + "ai-coding", + "code-quality" + ], + "bin": { + "sym": "./bin/sym.js" + }, + "files": [ + "bin/", + "README.md" + ], + "engines": { + "node": ">=16.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/DevSymphony/sym-cli.git" + }, + "bugs": { + "url": "https://github.com/DevSymphony/sym-cli/issues" + }, + "homepage": "https://github.com/DevSymphony/sym-cli#readme", + "author": "DevSymphony", + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^7.0.2" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ] +} diff --git a/tests/integration/ast_integration_test.go b/tests/integration/ast_integration_test.go index 5c42bcd..02c6736 100644 --- a/tests/integration/ast_integration_test.go +++ b/tests/integration/ast_integration_test.go @@ -27,7 +27,7 @@ func TestASTEngine_CallExpression_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: detect console.log calls rule := core.Rule{ @@ -82,7 +82,7 @@ func TestASTEngine_ClassDeclaration_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: detect class declarations rule := core.Rule{ diff --git a/tests/integration/length_integration_test.go b/tests/integration/length_integration_test.go index 96ab1c9..3747af4 100644 --- a/tests/integration/length_integration_test.go +++ b/tests/integration/length_integration_test.go @@ -27,7 +27,7 @@ func TestLengthEngine_LineLengthViolations_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: max line length 100 rule := core.Rule{ @@ -86,7 +86,7 @@ func TestLengthEngine_MaxParams_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: max 4 parameters rule := core.Rule{ @@ -145,7 +145,7 @@ func TestLengthEngine_ValidFile_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: max line length 100 rule := core.Rule{ diff --git a/tests/integration/pattern_integration_test.go b/tests/integration/pattern_integration_test.go index fbe334b..241d5db 100644 --- a/tests/integration/pattern_integration_test.go +++ b/tests/integration/pattern_integration_test.go @@ -28,7 +28,7 @@ func TestPatternEngine_NamingViolations_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: class names must be PascalCase rule := core.Rule{ @@ -87,7 +87,7 @@ func TestPatternEngine_SecurityViolations_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: no hardcoded secrets rule := core.Rule{ @@ -147,7 +147,7 @@ func TestPatternEngine_ValidFile_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: class names must be PascalCase rule := core.Rule{ diff --git a/tests/integration/style_integration_test.go b/tests/integration/style_integration_test.go index 94cc44e..dcc001c 100644 --- a/tests/integration/style_integration_test.go +++ b/tests/integration/style_integration_test.go @@ -27,7 +27,7 @@ func TestStyleEngine_IndentViolations_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: indent with 2 spaces rule := core.Rule{ @@ -87,7 +87,7 @@ func TestStyleEngine_ValidFile_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: ESLint not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: indent with 2 spaces rule := core.Rule{ diff --git a/tests/integration/typechecker_integration_test.go b/tests/integration/typechecker_integration_test.go index dbb746f..37f93e6 100644 --- a/tests/integration/typechecker_integration_test.go +++ b/tests/integration/typechecker_integration_test.go @@ -27,7 +27,7 @@ func TestTypeChecker_TypeErrors_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: TypeScript not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: type checking with strict mode rule := core.Rule{ @@ -85,7 +85,7 @@ func TestTypeChecker_StrictModeErrors_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: TypeScript not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: strict mode violations rule := core.Rule{ @@ -145,7 +145,7 @@ func TestTypeChecker_ValidFile_Integration(t *testing.T) { if err := engine.Init(ctx, config); err != nil { t.Skipf("Skipping test: TypeScript not available: %v", err) } - defer engine.Close() + defer func() { _ = engine.Close() }() // Test rule: type checking rule := core.Rule{