nitpm: protect uninstall from deleting parent folders
[nit.git] / src / nitpm.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Nit package manager command line interface
16 module nitpm
17
18 import opts
19 import prompt
20 import ini
21 import curl
22
23 import nitpm_shared
24
25 # Command line action, passed after `nitpm`
26 abstract class Command
27
28 # Short name of the command, specified in the command line
29 fun name: String is abstract
30
31 # Short usage description
32 fun usage: String is abstract
33
34 # Command description
35 fun description: String is abstract
36
37 # Apply this command consiering the `args` that follow
38 fun apply(args: Array[String]) do end
39
40 private var all_commands: Map[String, Command]
41
42 init do all_commands[name] = self
43
44 # Print the help message for this command
45 fun print_local_help
46 do
47 print "usage: {usage}"
48 print ""
49 print " {description}"
50 end
51 end
52
53 # Install a new package
54 class CommandInstall
55 super Command
56
57 redef fun name do return "install"
58 redef fun usage do return "nitpm install [package0[=version] [package1 ...]]"
59 redef fun description do return "Install packages by name, Git repository address or from the local package.ini"
60
61 # Packages installed in this run (identified by the full path)
62 private var installed = new Array[String]
63
64 redef fun apply(args)
65 do
66 if args.not_empty then
67 # Install each package
68 for arg in args do
69 # Parse each arg as an import string, with versions and commas
70 install_packages arg
71 end
72 else
73 # Install packages from local package.ini
74 var ini_path = "package.ini"
75 if not ini_path.file_exists then
76 print_error "Local `package.ini` not found."
77 print_local_help
78 exit 1
79 end
80
81 var ini = new ConfigTree(ini_path)
82 var import_line = ini["package.import"]
83 if import_line == null then
84 print_error "The local `package.ini` declares no external dependencies."
85 exit 0
86 abort
87 end
88
89 install_packages import_line
90 end
91 end
92
93 # Install packages defined by the `import_line`
94 private fun install_packages(import_line: String)
95 do
96 var imports = import_line.parse_import
97 for name, ext_package in imports do
98 install_package(ext_package.id, ext_package.version)
99 end
100 end
101
102 # Install the `package_id` at `version`
103 private fun install_package(package_id: String, version: nullable String)
104 do
105 if package_id.is_package_name then
106 # Ask a centralized server
107 # TODO choose a future safe URL
108 # TODO customizable server list
109 # TODO parse ini file in memory
110
111 var url = "https://xymus.net/nitpm/{package_id}.ini"
112 var ini_path = "/tmp/{package_id}.ini"
113
114 if verbose then print "Looking for a package description at '{url}'"
115
116 var request = new CurlHTTPRequest(url)
117 request.verbose = verbose
118 var response = request.download_to_file(ini_path)
119
120 if response isa CurlResponseFailed then
121 print_error "Failed to contact the remote server at '{url}': {response.error_msg} ({response.error_code})"
122 exit 1
123 end
124
125 assert response isa CurlFileResponseSuccess
126 if response.status_code == 404 then
127 print_error "Package '{package_id}' not found on the server"
128 exit 1
129 else if response.status_code != 200 then
130 print_error "Server side error: {response.status_code}"
131 exit 1
132 end
133
134 if verbose then
135 print "Found a package description:"
136 print ini_path.to_path.read_all
137 end
138
139 var ini = new ConfigTree(ini_path)
140 var git_repo = ini["upstream.git"]
141 if git_repo == null then
142 print_error "Package description invalid, or it does not declare a Git repository"
143 exit 1
144 abort
145 end
146
147 install_from_git(git_repo, package_id, version)
148 else
149 var name = package_id.git_name
150 if name != null and name != "." and not name.is_empty then
151 name = name.to_lower
152 install_from_git(package_id, name, version)
153 else
154 print_error "Failed to infer the package name"
155 exit 1
156 end
157 end
158 end
159
160 private fun install_from_git(git_repo, name: String, version: nullable String)
161 do
162 check_git
163
164 var target_dir = nitpm_lib_dir / name
165 if version != null then target_dir += "=" + version
166 if installed.has(target_dir) then
167 # Ignore packages installed in this run
168 return
169 end
170 installed.add target_dir
171
172 if target_dir.file_exists then
173 # Warn about packages previously installed,
174 # install dependencies anyway in case of a previous error.
175 print_error "Package '{name}' is already installed"
176 else
177 # Actually install it
178 var cmd_branch = ""
179 if version != null then cmd_branch = "--branch '{version}'"
180
181 var cmd = "git clone --depth 1 {cmd_branch} {git_repo.escape_to_sh} {target_dir.escape_to_sh}"
182 if verbose then print "+ {cmd}"
183
184 if "NIT_TESTING".environ == "true" then
185 # Silence git output when testing
186 cmd += " 2> /dev/null"
187 end
188
189 var proc = new Process("sh", "-c", cmd)
190 proc.wait
191
192 if proc.status != 0 then
193 print_error "Install of '{name}' failed"
194 exit 1
195 end
196 end
197
198 # Recursive install
199 var ini = new ConfigTree(target_dir/"package.ini")
200 var import_line = ini["package.import"]
201 if import_line != null then
202 install_packages import_line
203 end
204 end
205 end
206
207 # Upgrade a package
208 class CommandUpgrade
209 super Command
210
211 redef fun name do return "upgrade"
212 redef fun usage do return "nitpm upgrade <package>"
213 redef fun description do return "Upgrade a package"
214
215 redef fun apply(args)
216 do
217 if args.length != 1 then
218 print_local_help
219 exit 1
220 end
221
222 var name = args.first
223 var target_dir = nitpm_lib_dir / name
224
225 if not target_dir.file_exists or not target_dir.to_path.is_dir then
226 print_error "Package not found"
227 exit 1
228 end
229
230 check_git
231
232 var cmd = "cd {target_dir.escape_to_sh}; git pull"
233 if verbose then print "+ {cmd}"
234
235 var proc = new Process("sh", "-c", cmd)
236 proc.wait
237
238 if proc.status != 0 then
239 print_error "Upgrade failed"
240 exit 1
241 end
242 end
243 end
244
245 # Uninstall a package
246 class CommandUninstall
247 super Command
248
249 redef fun name do return "uninstall"
250 redef fun usage do return "nitpm uninstall [-f] <package0>[=version] [package1 ...]"
251 redef fun description do return "Uninstall packages"
252
253 redef fun apply(args)
254 do
255 var opt_force = "-f"
256 var force = args.has(opt_force)
257 if force then args.remove(opt_force)
258
259 if args.is_empty then
260 print_local_help
261 exit 1
262 end
263
264 for name in args do
265
266 var clean_nitpm_lib_dir = nitpm_lib_dir.simplify_path
267 var target_dir = clean_nitpm_lib_dir / name
268
269 # Check validity of the package to delete
270 target_dir = target_dir.simplify_path
271 var within_dir = target_dir.has_prefix(clean_nitpm_lib_dir + "/") and
272 target_dir.length > clean_nitpm_lib_dir.length + 1
273 var valid_name = name.length > 0 and name.chars.first.is_lower
274 if not valid_name or not within_dir then
275 print_error "Package name '{name}' is invalid"
276 continue
277 end
278
279 if not target_dir.file_exists or not target_dir.to_path.is_dir then
280 print_error "Package not found"
281 exit 1
282 end
283
284 # Ask confirmation
285 if not force then
286 var response = prompt("Delete {target_dir.escape_to_sh}? [Y/n] ")
287 var accept = response != null and
288 (response.to_lower == "y" or response.to_lower == "yes" or response == "")
289 if not accept then return
290 end
291
292 var cmd = "rm -rf {target_dir.escape_to_sh}"
293 if verbose then print "+ {cmd}"
294
295 var proc = new Process("sh", "-c", cmd)
296 proc.wait
297
298 if proc.status != 0 then
299 print_error "Uninstall failed"
300 exit 1
301 end
302 end
303 end
304 end
305
306 # List all installed packages
307 class CommandList
308 super Command
309
310 redef fun name do return "list"
311 redef fun usage do return "nitpm list"
312 redef fun description do return "List installed packages"
313
314 redef fun apply(args)
315 do
316 var files = nitpm_lib_dir.files
317 var name_to_desc = new Map[String, nullable String]
318 var max_name_len = 0
319
320 # Collect package info
321 for file in files do
322 var ini_path = nitpm_lib_dir / file / "package.ini"
323 if verbose then print "- Reading ini file at {ini_path}"
324 var ini = new ConfigTree(ini_path)
325 var tags = ini["package.tags"]
326
327 name_to_desc[file] = tags
328 max_name_len = max_name_len.max(file.length)
329 end
330
331 # Sort in alphabetical order
332 var sorted_names = name_to_desc.keys.to_a
333 alpha_comparator.sort sorted_names
334
335 # Print with clear columns
336 for name in sorted_names do
337 var col0 = name.justify(max_name_len+1, 0.0)
338 var col1 = name_to_desc[name] or else ""
339 var line = col0 + col1
340 print line.trim
341 end
342 end
343 end
344
345 # Show general help or help specific to a command
346 class CommandHelp
347 super Command
348
349 redef fun name do return "help"
350 redef fun usage do return "nitpm help [command]"
351 redef fun description do return "Show general help message or the help for a command"
352
353 redef fun apply(args)
354 do
355 # Try first to help about a valid action
356 if args.length == 1 then
357 var command = commands.get_or_null(args.first)
358 if command != null then
359 command.print_local_help
360 return
361 end
362 end
363
364 print_help
365 end
366 end
367
368 redef class Sys
369
370 # General command line options
371 var opts = new OptionContext
372
373 # Help option
374 var opt_help = new OptionBool("Show this help message", "--help", "-h")
375
376 # Verbose mode option
377 var opt_verbose = new OptionBool("Print more information", "--verbose", "-v")
378 private fun verbose: Bool do return opt_verbose.value
379
380 # All command line actions, mapped to their short `name`
381 var commands = new Map[String, Command]
382
383 private var command_install = new CommandInstall(commands)
384 private var command_list = new CommandList(commands)
385 private var command_update = new CommandUpgrade(commands)
386 private var command_uninstall = new CommandUninstall(commands)
387 private var command_help = new CommandHelp(commands)
388 end
389
390 redef fun nitpm_lib_dir
391 do
392 if "NIT_TESTING".environ == "true" then
393 return "/tmp/nitpm-test-" + "NIT_TESTING_ID".environ
394 else return super
395 end
396
397 # Print the general help message
398 private fun print_help
399 do
400 print "usage: nitpm <command> [options]"
401 print ""
402
403 print "commands:"
404 for command in commands.values do
405 print " {command.name.justify(11, 0.0)} {command.description}"
406 end
407 print ""
408
409 print "options:"
410 opts.usage
411 end
412
413 # Check if `git` is available, exit if not
414 private fun check_git
415 do
416 var proc = new ProcessReader("git", "--version")
417 proc.wait
418 proc.close
419
420 if proc.status != 0 then
421 print_error "Please install `git`"
422 exit 1
423 end
424 end
425
426 # Parse main options
427 opts.add_option(opt_help, opt_verbose)
428 opts.parse
429 var rest = opts.rest
430
431 if opt_help.value then
432 print_help
433 exit 0
434 end
435
436 if opts.errors.not_empty then
437 for error in opts.errors do print error
438 print ""
439 print_help
440 exit 1
441 end
442
443 if rest.is_empty then
444 print_help
445 exit 1
446 end
447
448 # Find and apply action
449 var action_name = rest.shift
450 var action = commands.get_or_null(action_name)
451 if action != null then
452 action.apply rest
453 else
454 print_help
455 exit 1
456 end