From f1432d1cd3324527f78e42e1ab797b3288108896 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 6 Jul 2026 15:37:25 +0200 Subject: [PATCH] Sprint B.5: add-LICENSE action (MIT/Apache-2.0) in [m] menu + vulp repo license - src/license/: embedded templates, holder resolution (config > git user.name > account username), write path shared by TUI and CLI - TUI: bulk-aware [m] entry over action_targets(), kind select + confirm, point-updates has_license/is_dirty, logs to activity (new License kind) - CLI: vulp repo license ...|--all [--kind] [--holder] [--json], skips already-licensed repos, exit 2/4 contract kept - config: license_holder + default_license fields (TUI-editable) - never overwrites an existing license, never auto-commits --- docs/cli.md | 12 +- src/activity/mod.rs | 5 +- src/cli.rs | 11 ++ src/cli_cmds/mod.rs | 2 +- src/cli_cmds/remote.rs | 69 ++++++++++- src/config/mod.rs | 15 +++ src/hub/modals_confirm_sync.rs | 76 ++++++++++++- src/hub/modals_spawn.rs | 6 + src/hub/state_modal.rs | 6 + src/hub/tabs_overview.rs | 1 + src/hub/tabs_stats.rs | 1 + src/license/APACHE-2.0.txt | 201 +++++++++++++++++++++++++++++++++ src/license/MIT.txt | 21 ++++ src/license/mod.rs | 86 ++++++++++++++ src/main.rs | 1 + 15 files changed, 506 insertions(+), 7 deletions(-) create mode 100644 src/license/APACHE-2.0.txt create mode 100644 src/license/MIT.txt create mode 100644 src/license/mod.rs diff --git a/docs/cli.md b/docs/cli.md index 38a9463..77e3bb6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -13,7 +13,7 @@ vulp [--json] # non-interactive (scripts/AI) - **`--json`** (global flag): machine-readable JSON on stdout. Available on: `list`, `status`, `scan`, `stats`, `activity`, `todo list`, `pr list`, - `issue list`, `project list`, `accounts`, `refresh`. + `issue list`, `project list`, `accounts`, `refresh`, `repo license`. - **No prompts, ever.** Missing input = error + non-zero exit. - **stdout** = results only. **stderr** = progress and errors. - **Exit codes:** `0` ok · `1` execution error · `2` bad usage · @@ -30,7 +30,7 @@ vulp scan # repo count + dirty/behind/ahead summa vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] vulp status # branch, dirty, ahead/behind, last commit... vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period -vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit] +vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit|license] ``` `vulp list --json` returns an array of repo objects: @@ -96,8 +96,16 @@ vulp repo label "Display Name" # empty name clears the label vulp repo bookmark # toggle vulp repo pin # toggle vulp repo collab [--role pull|push|admin] +vulp repo license ... [--kind mit|apache-2.0] [--holder NAME] +vulp repo license --all # every repo missing a license ``` +`repo license` writes a `LICENSE` file into each target repo. It never +overwrites an existing license (reported as skipped) and never commits — +review and commit the file yourself. Defaults come from config: +`default_license` (mit) and `license_holder` (falls back to +`git config user.name`, then the account username). + ## Recipes for AI agents ```bash diff --git a/src/activity/mod.rs b/src/activity/mod.rs index 60890d0..5ccbf13 100644 --- a/src/activity/mod.rs +++ b/src/activity/mod.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; /// The kind of action recorded in one activity log entry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ActivityKind { Commit, Push, Pull, Release, NewRepo, Todo, Pr, Issue } +pub enum ActivityKind { Commit, Push, Pull, Release, NewRepo, Todo, Pr, Issue, License } impl ActivityKind { /// Display label shown in the Activity tab's table. @@ -22,6 +22,7 @@ impl ActivityKind { Self::Todo => "todo", Self::Pr => "pr", Self::Issue => "issue", + Self::License => "license", } } @@ -30,7 +31,7 @@ impl ActivityKind { pub fn cycle() -> &'static [ActivityKind] { &[ Self::Commit, Self::Push, Self::Pull, Self::Release, - Self::NewRepo, Self::Todo, Self::Pr, Self::Issue, + Self::NewRepo, Self::Todo, Self::Pr, Self::Issue, Self::License, ] } } diff --git a/src/cli.rs b/src/cli.rs index 05c8d32..85d5f1e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -208,6 +208,17 @@ pub enum RepoCmd { /// pull | push | admin #[arg(long, default_value = "push")] role: String, }, + /// Write a LICENSE file into repos that lack one (never overwrites, never commits) + License { + /// Repo names (omit with --all) + repos: Vec, + /// Every workspace repo missing a license + #[arg(long)] all: bool, + /// mit | apache-2.0 (default: config `default_license`) + #[arg(long)] kind: Option, + /// Copyright holder (default: config `license_holder`, then git user.name) + #[arg(long)] holder: Option, + }, } #[derive(Args)] diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index fcb7754..724b8da 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -34,7 +34,7 @@ pub async fn run(cmd: Commands, json: bool, cfg: &AppConfig) -> Result<()> { Commands::Pr { action } => remote::pr(cfg, json, action).await, Commands::Issue { action } => remote::issue(cfg, json, action).await, - Commands::Repo { action } => remote::repo_cmd(cfg, action).await, + Commands::Repo { action } => remote::repo_cmd(cfg, json, action).await, Commands::Init { name, private, description } => remote::init(cfg, &name, private, &description).await, Commands::Accounts => remote::accounts(cfg, json), diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs index 717c59d..80541d0 100644 --- a/src/cli_cmds/remote.rs +++ b/src/cli_cmds/remote.rs @@ -103,7 +103,7 @@ pub async fn release(cfg: &AppConfig, repo: &str, tag: &str, notes: &str) -> Res Ok(()) } -pub async fn repo_cmd(cfg: &AppConfig, cmd: RepoCmd) -> Result<()> { +pub async fn repo_cmd(cfg: &AppConfig, json: bool, cmd: RepoCmd) -> Result<()> { match cmd { RepoCmd::Url { repo } => { let r = find_repo(cfg, &repo); @@ -170,9 +170,76 @@ pub async fn repo_cmd(cfg: &AppConfig, cmd: RepoCmd) -> Result<()> { Err(e) => die(1, &format!("invite failed: {e:#}")), } } + RepoCmd::License { repos, all, kind, holder } => { + license(cfg, json, repos, all, kind, holder) + } } } +/// `vulp repo license` — writes a LICENSE into each target repo that lacks +/// one. Explicit names that already have a license are reported as skipped, +/// not errors; `--all` targets every unlicensed workspace repo. +fn license(cfg: &AppConfig, json: bool, repos: Vec, all: bool, + kind: Option, holder: Option) -> Result<()> { + use crate::license::{LicenseKind, WriteOutcome}; + + if repos.is_empty() && !all { + die(2, "pass repo names or --all"); + } + let kind = match kind { + Some(s) => match LicenseKind::parse(&s) { + Some(k) => k, + None => die(2, "kind must be mit | apache-2.0"), + }, + None => crate::license::default_kind(cfg), + }; + let holder = holder.unwrap_or_else(|| crate::license::holder_for(cfg)); + if holder.trim().is_empty() { + die(2, "no copyright holder — pass --holder or set license_holder/username in config"); + } + + let workspace = super::scan_repos(cfg); + let targets: Vec<&crate::status::RepoSummary> = if all { + workspace.iter().filter(|r| !r.has_license).collect() + } else { + repos.iter().map(|name| { + let needle = name.to_lowercase(); + workspace.iter().find(|r| { + r.name.to_lowercase() == needle + || r.path.file_name().map(|b| b.to_string_lossy().to_lowercase() == needle).unwrap_or(false) + }).unwrap_or_else(|| die(4, &format!("repo '{name}' not found in {}", cfg.workspace_root.display()))) + }).collect() + }; + + let year = chrono::Datelike::year(&chrono::Utc::now()); + let (mut written, mut skipped, mut failed) = (Vec::new(), Vec::new(), Vec::new()); + for r in targets { + match crate::license::write_license(&r.path, kind, &holder, year) { + Ok(WriteOutcome::Written) => { + log_activity(ActivityKind::License, &r.name, format!("{} ({})", kind.label(), holder)); + written.push(r.name.clone()); + } + Ok(WriteOutcome::AlreadyLicensed) => skipped.push(r.name.clone()), + Err(e) => { + eprintln!("error: writing LICENSE to {} failed: {e:#}", r.name); + failed.push(r.name.clone()); + } + } + } + + if json { + #[derive(serde::Serialize)] + struct Out { kind: String, holder: String, written: Vec, skipped: Vec, failed: Vec } + out_json(&Out { kind: kind.label().into(), holder, written, skipped, failed: failed.clone() }); + } else { + for n in &written { println!("LICENSE ({}) written to {n}", kind.label()); } + for n in &skipped { println!("skipped {n} (already has a license)"); } + if written.is_empty() && skipped.is_empty() { println!("nothing to do"); } + } + if !failed.is_empty() { std::process::exit(1); } + Ok(()) +} + pub async fn init(cfg: &AppConfig, name: &str, private: bool, description: &str) -> Result<()> { let path = cfg.workspace_root.join(name); if path.exists() { diff --git a/src/config/mod.rs b/src/config/mod.rs index 15642b1..c0772ac 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -37,8 +37,17 @@ pub struct AppConfig { /// Count lines-of-code during workspace scan (slow for large repos; off by default). #[serde(default)] pub show_locs: bool, + /// Copyright holder written into generated LICENSE files + /// (empty = fall back to `git config user.name`, then `username`). + #[serde(default)] + pub license_holder: String, + /// Default template for the add-LICENSE action: "mit" or "apache-2.0". + #[serde(default = "default_license_kind")] + pub default_license: String, } +fn default_license_kind() -> String { "mit".into() } + /// Picks the first available clipboard tool on PATH as the default /// `clipboard_cmd`, so a fresh install works without manual configuration /// on most systems. @@ -71,6 +80,8 @@ impl Default for AppConfig { workspace_groups: vec![], clipboard_cmd: default_clipboard_cmd(), show_locs: false, + license_holder: String::new(), + default_license: default_license_kind(), } } } @@ -94,6 +105,8 @@ impl AppConfig { ("timezone", self.timezone.clone()), ("vsc_data_dir", self.vsc_data_dir.to_string_lossy().into()), ("workspace_groups", self.workspace_groups.join(", ")), + ("license_holder", self.license_holder.clone()), + ("default_license", self.default_license.clone()), ] } @@ -115,6 +128,8 @@ impl AppConfig { "share_stats" => self.share_stats = value == "true", "timezone" => self.timezone = value, "vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value), + "license_holder" => self.license_holder = value, + "default_license" => self.default_license = value, "workspace_groups" => { self.workspace_groups = value .split(',') diff --git a/src/hub/modals_confirm_sync.rs b/src/hub/modals_confirm_sync.rs index 9a30350..9c6d3bc 100644 --- a/src/hub/modals_confirm_sync.rs +++ b/src/hub/modals_confirm_sync.rs @@ -6,7 +6,7 @@ use super::modals_spawn::{ spawn_convention_preview, spawn_create_issue, spawn_create_pr, spawn_find_usages, spawn_list_functions, spawn_repo_stats, spawn_rename_preview, }; -use super::state::{HubState, MenuAction, Modal, PhaseResult, SelectTag, SortMode, TextTag}; +use super::state::{ConfirmTag, HubState, MenuAction, Modal, PhaseResult, SelectTag, SortMode, TextTag}; /// Handles one step of a sort/filter, repo-creation, sync, Quick Actions, or /// code-intel modal chain. Returns `None` once `modal` has been consumed by @@ -157,6 +157,25 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option { } } Some(MenuAction::CodeIntelPicker) => super::modals_spawn::open_codeintel_picker(state), + Some(MenuAction::AddLicense) => { + let targets: Vec = state.action_targets().into_iter() + .filter(|&ri| !state.repos[ri].has_license) + .collect(); + if targets.is_empty() { + state.status_msg = Some("All targeted repos already have a LICENSE".into()); + } else { + let cursor = match crate::license::default_kind(&state.cfg) { + crate::license::LicenseKind::Apache2 => 1, + crate::license::LicenseKind::Mit => 0, + }; + state.modal = Modal::Select { + title: format!("Add LICENSE — {} repo{}", targets.len(), if targets.len() == 1 { "" } else { "s" }), + options: vec!["MIT".into(), "Apache-2.0".into()], + cursor, + tag: SelectTag::LicenseKind { targets }, + }; + } + } Some(MenuAction::StashSave(ri)) | Some(MenuAction::StashPop(ri)) => { let ri = *ri; let repo = state.repos.get(ri)?; @@ -187,6 +206,61 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option { None } + // ── Add LICENSE ([m] action, bulk over action_targets) ──────────────── + Modal::Select { cursor, tag: SelectTag::LicenseKind { targets }, .. } => { + let kind = if cursor == 1 { crate::license::LicenseKind::Apache2 } else { crate::license::LicenseKind::Mit }; + let names: Vec<&str> = targets.iter().take(6) + .filter_map(|&ri| state.repos.get(ri).map(|r| r.name.as_str())) + .collect(); + let more = targets.len().saturating_sub(names.len()); + let list = if more > 0 { + format!("{} +{} more", names.join(", "), more) + } else { + names.join(", ") + }; + state.modal = Modal::Confirm { + message: format!( + "Write {} LICENSE to {} repo{}? ({}) — not committed", + kind.label(), targets.len(), if targets.len() == 1 { "" } else { "s" }, list, + ), + tag: ConfirmTag::WriteLicense { kind, targets }, + }; + None + } + Modal::Confirm { tag: ConfirmTag::WriteLicense { kind, targets }, .. } => { + let holder = crate::license::holder_for(&state.cfg); + let year = chrono::Datelike::year(&chrono::Utc::now()); + let (mut written, mut skipped, mut failed) = (0usize, 0usize, 0usize); + for ri in targets { + let Some(repo) = state.repos.get(ri) else { continue }; + let (path, name) = (repo.path.clone(), repo.name.clone()); + match crate::license::write_license(&path, kind, &holder, year) { + Ok(crate::license::WriteOutcome::Written) => { + written += 1; + state.repos[ri].has_license = true; + // New untracked LICENSE file — the repo is dirty now. + state.repos[ri].is_dirty = true; + crate::activity::log(&crate::activity::ActivityEntry { + ts: chrono::Utc::now().timestamp(), + kind: crate::activity::ActivityKind::License, + repo: name, + detail: format!("{} ({})", kind.label(), holder), + }); + } + Ok(crate::license::WriteOutcome::AlreadyLicensed) => { + skipped += 1; + state.repos[ri].has_license = true; + } + Err(_) => failed += 1, + } + } + let mut msg = format!("LICENSE ({}): {} written", kind.label(), written); + if skipped > 0 { msg.push_str(&format!(", {} already licensed", skipped)); } + if failed > 0 { msg.push_str(&format!(", {} FAILED", failed)); } + state.status_msg = Some(msg); + None + } + Modal::Text { value, tag: TextTag::PrRepo, .. } => { let repo = value.trim().to_string(); if repo.is_empty() { return None; } diff --git a/src/hub/modals_spawn.rs b/src/hub/modals_spawn.rs index fe3a208..8d9ea41 100644 --- a/src/hub/modals_spawn.rs +++ b/src/hub/modals_spawn.rs @@ -609,6 +609,12 @@ pub fn open_action_menu(state: &mut HubState) { entries.push(entry("Delete project", "Del", kc(KeyCode::Delete))); } + // Bulk-aware (multi-select > cursor repo > project): only shown when at + // least one targeted repo is missing a license file. + if state.action_targets().iter().any(|&ri| !state.repos[ri].has_license) { + entries.push(entry("Add LICENSE (MIT/Apache)", "", MenuAction::AddLicense)); + } + match state.tab { Tab::Todos => { entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' ')))); diff --git a/src/hub/state_modal.rs b/src/hub/state_modal.rs index dc6044e..ffb4069 100644 --- a/src/hub/state_modal.rs +++ b/src/hub/state_modal.rs @@ -81,6 +81,8 @@ pub enum MenuAction { /// `git stash` / `git stash pop` on the repo under the cursor. StashSave(usize), StashPop(usize), + /// Write a LICENSE file into every `action_targets()` repo missing one. + AddLicense, } /// What a `Modal::Select` list is *for*, mirroring `TextTag`. @@ -121,6 +123,8 @@ pub enum SelectTag { ProfileScope(ProfileScopeAction), // Contextual `[m]` action menu — options paired 1:1 with the actions ActionMenu(Vec), + // add-LICENSE flow: MIT vs Apache-2.0, for the captured target repos + LicenseKind { targets: Vec }, // Code intelligence (Stats tab, [m]) CodeIntelRepoPick, CodeIntelAction { repo: std::path::PathBuf, name: String }, @@ -154,6 +158,8 @@ pub enum ConfirmTag { DeleteTodo(usize), RemoveGitAll, DeleteTemplate(usize), + // write a LICENSE (of the chosen kind) into each target repo + WriteLicense { kind: crate::license::LicenseKind, targets: Vec }, } /// Outcome of one key event while a modal is open, returned by diff --git a/src/hub/tabs_overview.rs b/src/hub/tabs_overview.rs index 65babe6..9324180 100644 --- a/src/hub/tabs_overview.rs +++ b/src/hub/tabs_overview.rs @@ -27,6 +27,7 @@ fn kind_color(kind: crate::activity::ActivityKind) -> Color { K::Todo => Color::Yellow, K::Pr => Color::Cyan, K::Issue => Color::Yellow, + K::License => Color::Magenta, } } diff --git a/src/hub/tabs_stats.rs b/src/hub/tabs_stats.rs index 6559217..468a8f9 100644 --- a/src/hub/tabs_stats.rs +++ b/src/hub/tabs_stats.rs @@ -185,6 +185,7 @@ pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) { crate::activity::ActivityKind::Todo => Style::default().fg(Color::Yellow), crate::activity::ActivityKind::Pr => Style::default().fg(Color::Cyan), crate::activity::ActivityKind::Issue => Style::default().fg(Color::Yellow), + crate::activity::ActivityKind::License => Style::default().fg(Color::Magenta), }; let detail = e.detail.chars().take(detail_w).collect::(); Row::new(vec![ diff --git a/src/license/APACHE-2.0.txt b/src/license/APACHE-2.0.txt new file mode 100644 index 0000000..9260da6 --- /dev/null +++ b/src/license/APACHE-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {year} {holder} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/license/MIT.txt b/src/license/MIT.txt new file mode 100644 index 0000000..ac93d0c --- /dev/null +++ b/src/license/MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {year} {holder} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/license/mod.rs b/src/license/mod.rs new file mode 100644 index 0000000..e26fb23 --- /dev/null +++ b/src/license/mod.rs @@ -0,0 +1,86 @@ +//! LICENSE file generation: embedded MIT / Apache-2.0 templates, holder +//! resolution (config override → git user.name → account username), and the +//! shared write path used by both the hub's [m] action and `vulp repo +//! license`. Writing never overwrites an existing license and never commits. + +use crate::config::AppConfig; +use anyhow::Result; +use std::path::Path; + +/// Which license template to write. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LicenseKind { Mit, Apache2 } + +impl LicenseKind { + /// Display / SPDX-style label. + pub fn label(&self) -> &'static str { + match self { + Self::Mit => "MIT", + Self::Apache2 => "Apache-2.0", + } + } + + /// Parses a user-supplied kind string ("mit", "apache-2.0", "apache2"...). + pub fn parse(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "mit" => Some(Self::Mit), + "apache-2.0" | "apache2" | "apache-2" | "apache" => Some(Self::Apache2), + _ => None, + } + } + + fn template(&self) -> &'static str { + match self { + Self::Mit => include_str!("MIT.txt"), + Self::Apache2 => include_str!("APACHE-2.0.txt"), + } + } + + /// The full LICENSE file body with `{year}`/`{holder}` filled in. + pub fn render(&self, holder: &str, year: i32) -> String { + self.template() + .replace("{year}", &year.to_string()) + .replace("{holder}", holder) + } +} + +/// Outcome of one `write_license` call, so callers can report per-repo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteOutcome { Written, AlreadyLicensed } + +/// Writes a `LICENSE` file at `repo`'s root. Skips (never overwrites) when +/// the repo already has a recognizable license file. Does NOT commit — +/// the user reviews and commits the file like any other change. +pub fn write_license(repo: &Path, kind: LicenseKind, holder: &str, year: i32) -> Result { + if crate::status::detect_license(repo) { + return Ok(WriteOutcome::AlreadyLicensed); + } + std::fs::write(repo.join("LICENSE"), kind.render(holder, year))?; + Ok(WriteOutcome::Written) +} + +/// Resolves the copyright holder: `license_holder` from config when set, +/// otherwise global `git config user.name`, otherwise the configured +/// account username. +pub fn holder_for(cfg: &AppConfig) -> String { + let configured = cfg.license_holder.trim(); + if !configured.is_empty() { + return configured.to_string(); + } + if let Ok(out) = std::process::Command::new("git") + .args(["config", "--get", "user.name"]) + .output() + { + let name = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !name.is_empty() { + return name; + } + } + cfg.username.clone() +} + +/// The configured default kind ("mit" unless overridden), falling back to +/// MIT on an unparsable config value rather than erroring. +pub fn default_kind(cfg: &AppConfig) -> LicenseKind { + LicenseKind::parse(&cfg.default_license).unwrap_or(LicenseKind::Mit) +} diff --git a/src/main.rs b/src/main.rs index 09c1da8..cfd988d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod detail; mod hub; mod labels; mod legacy; +mod license; mod profile; mod projects; mod providers;