|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "os" |
| 8 | + "path/filepath" |
| 9 | + "strings" |
| 10 | + |
| 11 | + "github.com/spf13/cobra" |
| 12 | +) |
| 13 | + |
| 14 | +func newInitCommand() *cobra.Command { |
| 15 | + cmd := &cobra.Command{ |
| 16 | + Use: "init [path]", |
| 17 | + Short: "Generate .licscan.yml and/or GitHub Actions workflow", |
| 18 | + Long: `Interactive setup wizard that generates configuration files: |
| 19 | +
|
| 20 | + 1. .licscan.yml — license policy, manufacturer & product metadata |
| 21 | + 2. .github/workflows/licscan.yml — CI workflow with optional SARIF + CRA |
| 22 | +
|
| 23 | +Run in your project root. Existing files are never overwritten without confirmation.`, |
| 24 | + Args: cobra.MaximumNArgs(1), |
| 25 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 26 | + path := "." |
| 27 | + if len(args) == 1 { |
| 28 | + path = args[0] |
| 29 | + } |
| 30 | + return runInit(cmd, path) |
| 31 | + }, |
| 32 | + } |
| 33 | + return cmd |
| 34 | +} |
| 35 | + |
| 36 | +func runInit(cmd *cobra.Command, path string) error { |
| 37 | + absPath, err := filepath.Abs(path) |
| 38 | + if err != nil { |
| 39 | + return fmt.Errorf("resolve path %q: %w", path, err) |
| 40 | + } |
| 41 | + |
| 42 | + r := bufio.NewReader(cmd.InOrStdin()) |
| 43 | + w := cmd.ErrOrStderr() |
| 44 | + |
| 45 | + _, _ = fmt.Fprintln(w) |
| 46 | + _, _ = fmt.Fprintln(w, " licscan init — interactive setup") |
| 47 | + _, _ = fmt.Fprintln(w, " ────────────────────────────────") |
| 48 | + _, _ = fmt.Fprintln(w) |
| 49 | + |
| 50 | + genPolicy := askYN(r, w, "Generate .licscan.yml (license policy)?", true) |
| 51 | + genAction := askYN(r, w, "Generate .github/workflows/licscan.yml (CI workflow)?", true) |
| 52 | + |
| 53 | + if !genPolicy && !genAction { |
| 54 | + _, _ = fmt.Fprintln(w, "\nNothing to generate. Done.") |
| 55 | + return nil |
| 56 | + } |
| 57 | + |
| 58 | + if genPolicy { |
| 59 | + if err := generatePolicy(r, w, absPath); err != nil { |
| 60 | + return err |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + if genAction { |
| 65 | + if err := generateAction(r, w, absPath); err != nil { |
| 66 | + return err |
| 67 | + } |
| 68 | + } |
| 69 | + |
| 70 | + _, _ = fmt.Fprintln(w, "\n ✓ Done. Run `licscan scan .` to test your setup.") |
| 71 | + return nil |
| 72 | +} |
| 73 | + |
| 74 | +func generatePolicy(r *bufio.Reader, w io.Writer, root string) error { |
| 75 | + _, _ = fmt.Fprintln(w, "\n── .licscan.yml ──") |
| 76 | + |
| 77 | + projectLicense := askString(r, w, "Project license (SPDX, e.g. MIT, Apache-2.0)", "") |
| 78 | + |
| 79 | + _, _ = fmt.Fprintln(w, "\nDefault policy: deny GPL/AGPL/SSPL/BUSL, warn LGPL/MPL/EPL/CDDL.") |
| 80 | + useDefault := askYN(r, w, "Use default deny/warn lists?", true) |
| 81 | + |
| 82 | + var denyList, warnList string |
| 83 | + if !useDefault { |
| 84 | + denyList = askString(r, w, "Deny list (comma-separated SPDX IDs)", "GPL-3.0-or-later, AGPL-3.0-or-later, SSPL-1.0, BUSL-1.1") |
| 85 | + warnList = askString(r, w, "Warn list (comma-separated SPDX IDs)", "LGPL-3.0-or-later, MPL-2.0, EPL-2.0") |
| 86 | + } |
| 87 | + |
| 88 | + enableCRA := askYN(r, w, "Add EU CRA manufacturer/product metadata?", false) |
| 89 | + |
| 90 | + var mfgName, mfgEmail, mfgURL, mfgCountry string |
| 91 | + var prodName, prodVersion string |
| 92 | + if enableCRA { |
| 93 | + _, _ = fmt.Fprintln(w, "\n Manufacturer (CRA Art. 13):") |
| 94 | + mfgName = askString(r, w, " Company name", "") |
| 95 | + mfgEmail = askString(r, w, " Contact email", "") |
| 96 | + mfgURL = askString(r, w, " Website URL", "") |
| 97 | + mfgCountry = askString(r, w, " Country (ISO 3166)", "") |
| 98 | + |
| 99 | + _, _ = fmt.Fprintln(w, "\n Product:") |
| 100 | + prodName = askString(r, w, " Product name", "") |
| 101 | + prodVersion = askString(r, w, " Version", "") |
| 102 | + } |
| 103 | + |
| 104 | + // Build YAML |
| 105 | + var b strings.Builder |
| 106 | + b.WriteString("# licscan policy — generated by `licscan init`\n") |
| 107 | + b.WriteString("# Docs: https://github.com/codelake-dev/licscan#policy-engine\n\n") |
| 108 | + |
| 109 | + if projectLicense != "" { |
| 110 | + fmt.Fprintf(&b, "project_license: %s\n\n", projectLicense) |
| 111 | + } |
| 112 | + |
| 113 | + if useDefault { |
| 114 | + b.WriteString("# Using default deny/warn lists.\n") |
| 115 | + b.WriteString("# Uncomment and customise to override:\n") |
| 116 | + b.WriteString("# deny:\n") |
| 117 | + b.WriteString("# - GPL-3.0-or-later\n") |
| 118 | + b.WriteString("# - AGPL-3.0-or-later\n") |
| 119 | + b.WriteString("# - SSPL-1.0\n") |
| 120 | + b.WriteString("# - BUSL-1.1\n") |
| 121 | + b.WriteString("#\n") |
| 122 | + b.WriteString("# warn:\n") |
| 123 | + b.WriteString("# - LGPL-3.0-or-later\n") |
| 124 | + b.WriteString("# - MPL-2.0\n") |
| 125 | + b.WriteString("# - EPL-2.0\n") |
| 126 | + } else { |
| 127 | + b.WriteString("deny:\n") |
| 128 | + for _, id := range splitCSV(denyList) { |
| 129 | + fmt.Fprintf(&b, " - %s\n", id) |
| 130 | + } |
| 131 | + b.WriteString("\nwarn:\n") |
| 132 | + for _, id := range splitCSV(warnList) { |
| 133 | + fmt.Fprintf(&b, " - %s\n", id) |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + b.WriteString("\n# allow_exceptions:\n") |
| 138 | + b.WriteString("# - package: github.com/some/lib\n") |
| 139 | + b.WriteString("# reason: \"vendored, isolated in tests\"\n") |
| 140 | + |
| 141 | + if enableCRA { |
| 142 | + b.WriteString("\nmanufacturer:\n") |
| 143 | + writeYAMLField(&b, " name", mfgName) |
| 144 | + writeYAMLField(&b, " email", mfgEmail) |
| 145 | + writeYAMLField(&b, " url", mfgURL) |
| 146 | + writeYAMLField(&b, " country", mfgCountry) |
| 147 | + |
| 148 | + b.WriteString("\nproduct:\n") |
| 149 | + writeYAMLField(&b, " name", prodName) |
| 150 | + writeYAMLField(&b, " version", prodVersion) |
| 151 | + } |
| 152 | + |
| 153 | + outPath := filepath.Join(root, ".licscan.yml") |
| 154 | + if err := writeFileWithConfirm(r, w, outPath, b.String()); err != nil { |
| 155 | + return err |
| 156 | + } |
| 157 | + |
| 158 | + _, _ = fmt.Fprintf(w, " ✓ %s written\n", outPath) |
| 159 | + return nil |
| 160 | +} |
| 161 | + |
| 162 | +func generateAction(r *bufio.Reader, w io.Writer, root string) error { |
| 163 | + _, _ = fmt.Fprintln(w, "\n── .github/workflows/licscan.yml ──") |
| 164 | + |
| 165 | + failOnViolation := askYN(r, w, "Fail the build on policy violation?", true) |
| 166 | + prComment := askYN(r, w, "Post scan report as PR comment?", true) |
| 167 | + enableCRA := askYN(r, w, "Enable EU CRA evidence (PDF + JSON)?", false) |
| 168 | + enableSARIF := askYN(r, w, "Upload SARIF to GitHub Code Scanning?", false) |
| 169 | + uploadArtifact := askYN(r, w, "Upload report as workflow artifact?", true) |
| 170 | + |
| 171 | + var b strings.Builder |
| 172 | + b.WriteString("# License compliance — generated by `licscan init`\n") |
| 173 | + b.WriteString("# Docs: https://github.com/codelake-dev/licscan-action\n\n") |
| 174 | + b.WriteString("name: License compliance\n\n") |
| 175 | + b.WriteString("on:\n") |
| 176 | + b.WriteString(" pull_request:\n") |
| 177 | + b.WriteString(" push:\n") |
| 178 | + b.WriteString(" branches: [main]\n\n") |
| 179 | + |
| 180 | + if enableSARIF { |
| 181 | + b.WriteString("permissions:\n") |
| 182 | + b.WriteString(" contents: read\n") |
| 183 | + b.WriteString(" pull-requests: write\n") |
| 184 | + b.WriteString(" security-events: write\n\n") |
| 185 | + } |
| 186 | + |
| 187 | + b.WriteString("jobs:\n") |
| 188 | + b.WriteString(" licscan:\n") |
| 189 | + b.WriteString(" runs-on: ubuntu-latest\n") |
| 190 | + b.WriteString(" steps:\n") |
| 191 | + b.WriteString(" - uses: actions/checkout@v4\n\n") |
| 192 | + b.WriteString(" - uses: codelake-dev/licscan-action@v1\n") |
| 193 | + b.WriteString(" with:\n") |
| 194 | + fmt.Fprintf(&b, " fail-on-violation: %s\n", boolStr(failOnViolation)) |
| 195 | + fmt.Fprintf(&b, " pr-comment: %s\n", boolStr(prComment)) |
| 196 | + fmt.Fprintf(&b, " cra: %s\n", boolStr(enableCRA)) |
| 197 | + fmt.Fprintf(&b, " upload-artifact: %s\n", boolStr(uploadArtifact)) |
| 198 | + |
| 199 | + if enableSARIF { |
| 200 | + b.WriteString("\n - name: Generate SARIF\n") |
| 201 | + b.WriteString(" run: licscan scan . --format sarif > results.sarif\n\n") |
| 202 | + b.WriteString(" - uses: github/codeql-action/upload-sarif@v3\n") |
| 203 | + b.WriteString(" with:\n") |
| 204 | + b.WriteString(" sarif_file: results.sarif\n") |
| 205 | + } |
| 206 | + |
| 207 | + outDir := filepath.Join(root, ".github", "workflows") |
| 208 | + if err := os.MkdirAll(outDir, 0o755); err != nil { |
| 209 | + return fmt.Errorf("create %s: %w", outDir, err) |
| 210 | + } |
| 211 | + |
| 212 | + outPath := filepath.Join(outDir, "licscan.yml") |
| 213 | + if err := writeFileWithConfirm(r, w, outPath, b.String()); err != nil { |
| 214 | + return err |
| 215 | + } |
| 216 | + |
| 217 | + _, _ = fmt.Fprintf(w, " ✓ %s written\n", outPath) |
| 218 | + return nil |
| 219 | +} |
| 220 | + |
| 221 | +func askYN(r *bufio.Reader, w io.Writer, question string, defaultYes bool) bool { |
| 222 | + hint := "[Y/n]" |
| 223 | + if !defaultYes { |
| 224 | + hint = "[y/N]" |
| 225 | + } |
| 226 | + _, _ = fmt.Fprintf(w, " %s %s ", question, hint) |
| 227 | + line, _ := r.ReadString('\n') |
| 228 | + line = strings.TrimSpace(strings.ToLower(line)) |
| 229 | + if line == "" { |
| 230 | + return defaultYes |
| 231 | + } |
| 232 | + return line == "y" || line == "yes" |
| 233 | +} |
| 234 | + |
| 235 | +func askString(r *bufio.Reader, w io.Writer, question, defaultVal string) string { |
| 236 | + if defaultVal != "" { |
| 237 | + _, _ = fmt.Fprintf(w, " %s [%s]: ", question, defaultVal) |
| 238 | + } else { |
| 239 | + _, _ = fmt.Fprintf(w, " %s: ", question) |
| 240 | + } |
| 241 | + line, _ := r.ReadString('\n') |
| 242 | + line = strings.TrimSpace(line) |
| 243 | + if line == "" { |
| 244 | + return defaultVal |
| 245 | + } |
| 246 | + return line |
| 247 | +} |
| 248 | + |
| 249 | +func writeFileWithConfirm(r *bufio.Reader, w io.Writer, path, content string) error { |
| 250 | + if _, err := os.Stat(path); err == nil { |
| 251 | + overwrite := askYN(r, w, fmt.Sprintf(" %s already exists. Overwrite?", path), false) |
| 252 | + if !overwrite { |
| 253 | + _, _ = fmt.Fprintf(w, " ⏭ Skipped %s\n", path) |
| 254 | + return nil |
| 255 | + } |
| 256 | + } |
| 257 | + return os.WriteFile(path, []byte(content), 0o600) |
| 258 | +} |
| 259 | + |
| 260 | +func writeYAMLField(b *strings.Builder, key, value string) { |
| 261 | + if value != "" { |
| 262 | + fmt.Fprintf(b, "%s: %s\n", key, value) |
| 263 | + } |
| 264 | +} |
| 265 | + |
| 266 | +func splitCSV(s string) []string { |
| 267 | + parts := strings.Split(s, ",") |
| 268 | + result := make([]string, 0, len(parts)) |
| 269 | + for _, p := range parts { |
| 270 | + p = strings.TrimSpace(p) |
| 271 | + if p != "" { |
| 272 | + result = append(result, p) |
| 273 | + } |
| 274 | + } |
| 275 | + return result |
| 276 | +} |
| 277 | + |
| 278 | +func boolStr(b bool) string { |
| 279 | + if b { |
| 280 | + return "true" |
| 281 | + } |
| 282 | + return "false" |
| 283 | +} |
0 commit comments