| 1 | module main |
| 2 | |
| 3 | import os |
| 4 | import v.vmod |
| 5 | import v.help |
| 6 | |
| 7 | enum InstallResult { |
| 8 | installed |
| 9 | failed |
| 10 | skipped |
| 11 | } |
| 12 | |
| 13 | fn vpm_install(query []string) { |
| 14 | if settings.is_help { |
| 15 | help.print_and_exit('vpm') |
| 16 | } |
| 17 | |
| 18 | mut selector := new_install_server_selector() |
| 19 | mut modules := parse_query(if query.len == 0 { |
| 20 | if os.exists('./v.mod') { |
| 21 | // Case: `v install` was run in a directory of another V-module to install its dependencies |
| 22 | // - without additional module arguments. |
| 23 | println('Detected v.mod file inside the project directory. Using it...') |
| 24 | manifest := vmod.from_file('./v.mod') or { panic(err) } |
| 25 | if manifest.dependencies.len == 0 { |
| 26 | println('Nothing to install.') |
| 27 | exit(0) |
| 28 | } |
| 29 | manifest.dependencies |
| 30 | } else { |
| 31 | vpm_error('specify at least one module for installation.', |
| 32 | details: 'example: `v install publisher.package` or `v install https://github.com/owner/repository`' |
| 33 | ) |
| 34 | exit(2) |
| 35 | } |
| 36 | } else { |
| 37 | query |
| 38 | }, mut selector) |
| 39 | |
| 40 | installed_modules := get_installed_modules() |
| 41 | |
| 42 | vpm_log(@FILE_LINE, @FN, 'Queried Modules: ${modules}') |
| 43 | vpm_log(@FILE_LINE, @FN, 'Installed modules: ${installed_modules}') |
| 44 | |
| 45 | if installed_modules.len > 0 && settings.is_once { |
| 46 | num_to_install := modules.len |
| 47 | mut already_installed := []string{} |
| 48 | if modules.len > 0 { |
| 49 | mut i_deleted := []int{} |
| 50 | for i, m in modules { |
| 51 | if m.name in installed_modules { |
| 52 | already_installed << m.name |
| 53 | i_deleted << i |
| 54 | } |
| 55 | } |
| 56 | for i in i_deleted.reverse() { |
| 57 | modules.delete(i) |
| 58 | } |
| 59 | } |
| 60 | if already_installed.len > 0 { |
| 61 | verbose_println('Already installed modules: ${already_installed}') |
| 62 | if already_installed.len == num_to_install { |
| 63 | println('All modules are already installed.') |
| 64 | exit(0) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | install_modules(modules, selector.selected_url) |
| 70 | } |
| 71 | |
| 72 | fn install_modules(modules []Module, selected_server_url string) { |
| 73 | vpm_log(@FILE_LINE, @FN, 'modules: ${modules}') |
| 74 | mut errors := 0 |
| 75 | for m in modules { |
| 76 | vpm_log(@FILE_LINE, @FN, 'module: ${m}') |
| 77 | match m.install() { |
| 78 | .installed {} |
| 79 | .failed { |
| 80 | errors++ |
| 81 | continue |
| 82 | } |
| 83 | .skipped { |
| 84 | continue |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | if !m.is_external { |
| 89 | increment_module_download_count(m.name, selected_server_url) or { |
| 90 | vpm_error('failed to increment the download count for `${m.name}`', |
| 91 | details: err.msg() |
| 92 | ) |
| 93 | errors++ |
| 94 | } |
| 95 | } |
| 96 | println('Installed `${m.name}` in ${m.install_path_fmted} .') |
| 97 | } |
| 98 | if errors > 0 { |
| 99 | exit(1) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | fn (m Module) install() InstallResult { |
| 104 | defer { |
| 105 | os.rmdir_all(m.tmp_path) or {} |
| 106 | } |
| 107 | // Run this check unconditionally — `m.is_installed` is computed via |
| 108 | // `git ls-remote`, which itself fails when `.git` is corrupted or |
| 109 | // inaccessible, so relying on it here would skip the guard in exactly |
| 110 | // the cases we most need to fail closed. |
| 111 | reason := local_git_changes_reason(m.install_path) |
| 112 | if reason != '' { |
| 113 | vpm_error('refusing to install `${m.name}`: `${m.install_path_fmted}` has local git work that would be lost (${reason}). Commit and push your changes, or remove the directory manually before retrying.') |
| 114 | exit(1) |
| 115 | } |
| 116 | if m.is_installed { |
| 117 | // Case: installed, but not an explicit version. Update instead of continuing the installation. |
| 118 | if m.version == '' && m.installed_version == '' { |
| 119 | if m.is_external && m.url.starts_with('http://') { |
| 120 | vpm_update([m.install_path.all_after(settings.vmodules_path).trim_left(os.path_separator).replace(os.path_separator, |
| 121 | '.')]) |
| 122 | } else { |
| 123 | vpm_update([m.name]) |
| 124 | } |
| 125 | return .skipped |
| 126 | } |
| 127 | // Case: installed, but conflicting. Confirmation or -[-f]orce flag required. |
| 128 | if settings.is_force || m.confirm_install() { |
| 129 | m.remove() or { |
| 130 | vpm_error('failed to remove `${m.name}`.', details: err.msg()) |
| 131 | return .failed |
| 132 | } |
| 133 | } else { |
| 134 | return .skipped |
| 135 | } |
| 136 | } |
| 137 | println('Installing `${m.name}`...') |
| 138 | // When the module should be relocated into a subdirectory we need to make sure |
| 139 | // it exists to not run into permission errors. |
| 140 | parent_dir := m.install_path.all_before_last(os.path_separator) |
| 141 | if !os.exists(parent_dir) { |
| 142 | os.mkdir_all(parent_dir) or { |
| 143 | vpm_error('failed to create module directory for `${m.name}`.', details: err.msg()) |
| 144 | return .failed |
| 145 | } |
| 146 | } |
| 147 | os.mv(m.tmp_path, m.install_path) or { |
| 148 | vpm_error('failed to install `${m.name}`.', details: err.msg()) |
| 149 | return .failed |
| 150 | } |
| 151 | return .installed |
| 152 | } |
| 153 | |
| 154 | fn (m Module) confirm_install() bool { |
| 155 | if m.installed_version == m.version { |
| 156 | println('Module `${m.name}${at_version(m.installed_version)}` is already installed, use --force to overwrite.') |
| 157 | return false |
| 158 | } else { |
| 159 | println('Module `${m.name}${at_version(m.installed_version)}` is already installed at `${m.install_path_fmted}`.') |
| 160 | if settings.fail_on_prompt { |
| 161 | vpm_error('VPM should not have entered a confirmation prompt.') |
| 162 | exit(1) |
| 163 | } |
| 164 | install_version := at_version(if m.version == '' { 'latest' } else { m.version }) |
| 165 | input := os.input('Replace it with `${m.name}${install_version}`? [Y/n]: ') |
| 166 | match input.trim_space().to_lower() { |
| 167 | '', 'y' { |
| 168 | return true |
| 169 | } |
| 170 | else { |
| 171 | verbose_println('Skipping `${m.name}`.') |
| 172 | return false |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // local_git_changes_reason returns a non-empty reason string if `path` is a |
| 179 | // git repository whose contents should not be silently overwritten — either |
| 180 | // because it has uncommitted/unpushed work, or because git could not be |
| 181 | // queried at all (in which case we fail closed rather than risk data loss). |
| 182 | // Returns '' when the path is safe to overwrite (not a git repo, or a clean |
| 183 | // repo fully in sync with its remote). |
| 184 | fn local_git_changes_reason(path string) string { |
| 185 | if !os.exists(os.join_path(path, '.git')) { |
| 186 | return '' |
| 187 | } |
| 188 | quoted := os.quoted_path(path) |
| 189 | status := os.execute_opt('git -C ${quoted} status --porcelain') or { |
| 190 | return 'failed to run `git status`: ${err.msg()}' |
| 191 | } |
| 192 | if status.output.trim_space() != '' { |
| 193 | return 'uncommitted changes detected' |
| 194 | } |
| 195 | // Include `HEAD` so commits made on a detached HEAD (e.g. after |
| 196 | // `git clone -b <tag>`, the layout vpm uses for versioned installs) are |
| 197 | // also detected. `--branches` alone only walks local branch refs. |
| 198 | // Negate `--tags` as well: vpm's versioned installs clone with `-b <tag>`, |
| 199 | // which leaves HEAD detached at a tag without creating a remote tracking |
| 200 | // branch, so HEAD would otherwise appear as unpushed even on a pristine |
| 201 | // clone. |
| 202 | unpushed := os.execute_opt('git -C ${quoted} rev-list HEAD --branches --not --remotes --tags') or { |
| 203 | return 'failed to run `git rev-list`: ${err.msg()}' |
| 204 | } |
| 205 | if unpushed.output.trim_space() != '' { |
| 206 | return 'unpushed local commits detected' |
| 207 | } |
| 208 | return '' |
| 209 | } |
| 210 | |
| 211 | fn (m Module) remove() ! { |
| 212 | verbose_println('Removing `${m.name}` from `${m.install_path_fmted}`...') |
| 213 | rmdir_all(m.install_path)! |
| 214 | verbose_println('Removed `${m.name}`.') |
| 215 | } |
| 216 | |