Skip to content

Commit 4acb462

Browse files
committed
feat: add licscan init interactive setup wizard
Generates .licscan.yml (policy, project_license, CRA metadata) and .github/workflows/licscan.yml (CI workflow with optional SARIF upload, CRA evidence, PR comments) via interactive prompts. Existing files are never overwritten without confirmation.
1 parent 1b4ff8b commit 4acb462

2 files changed

Lines changed: 284 additions & 0 deletions

File tree

internal/cli/init.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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+
}

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func NewRootCommand() *cobra.Command {
4040
root.AddCommand(
4141
newScanCommand(),
4242
newNoticeCommand(),
43+
newInitCommand(),
4344
newAboutCommand(),
4445
)
4546

0 commit comments

Comments
 (0)