nitpm: support installing a specific package version
[nit.git] / src / picnit.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 picnit
17
18 import opts
19 import prompt
20 import ini
21 import curl
22
23 import picnit_shared
24
25 # Command line action, passed after `picnit`
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 "picnit 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 redef fun apply(args)
62 do
63 if args.not_empty then
64 # Install each package
65 for arg in args do
66 # Parse each arg as an import string, with versions and commas
67 install_packages arg
68 end
69 else
70 # Install packages from local package.ini
71 var ini_path = "package.ini"
72 if not ini_path.file_exists then
73 print_error "Local `package.ini` not found."
74 print_local_help
75 exit 1
76 end
77
78 var ini = new ConfigTree(ini_path)
79 var import_line = ini["package.import"]
80 if import_line == null then
81 print_error "The local `package.ini` declares no external dependencies."
82 exit 0
83 abort
84 end
85
86 install_packages import_line
87 end
88 end
89
90 # Install packages defined by the `import_line`
91 private fun install_packages(import_line: String)
92 do
93 var imports = import_line.parse_import
94 for name, ext_package in imports do
95 install_package(ext_package.id, ext_package.version)
96 end
97 end
98
99 # Install the `package_id` at `version`
100 private fun install_package(package_id: String, version: nullable String)
101 do
102 if package_id.is_package_name then
103 # Ask a centralized server
104 # TODO choose a future safe URL
105 # TODO customizable server list
106 # TODO parse ini file in memory
107
108 var url = "https://xymus.net/picnit/{package_id}.ini"
109 var ini_path = "/tmp/{package_id}.ini"
110
111 if verbose then print "Looking for a package description at '{url}'"
112
113 var request = new CurlHTTPRequest(url)
114 request.verbose = verbose
115 var response = request.download_to_file(ini_path)
116
117 if response isa CurlResponseFailed then
118 print_error "Failed to contact the remote server at '{url}': {response.error_msg} ({response.error_code})"
119 exit 1
120 end
121
122 assert response isa CurlFileResponseSuccess
123 if response.status_code == 404 then
124 print_error "Package not found by the server"
125 exit 1
126 else if response.status_code != 200 then
127 print_error "Server side error: {response.status_code}"
128 exit 1
129 end
130
131 if verbose then
132 print "Found a package description:"
133 print ini_path.to_path.read_all
134 end
135
136 var ini = new ConfigTree(ini_path)
137 var git_repo = ini["upstream.git"]
138 if git_repo == null then
139 print_error "Package description invalid, or it does not declare a Git repository"
140 exit 1
141 abort
142 end
143
144 install_from_git(git_repo, package_id, version)
145 else
146 var name = package_id.git_name
147 if name != null and name != "." and not name.is_empty then
148 name = name.to_lower
149 install_from_git(package_id, name, version)
150 else
151 print_error "Failed to infer the package name"
152 exit 1
153 end
154 end
155 end
156
157 private fun install_from_git(git_repo, name: String, version: nullable String)
158 do
159 check_git
160
161 var target_dir = picnit_lib_dir / name
162 if version != null then target_dir += "=" + version
163 if target_dir.file_exists then
164 print_error "Already installed"
165 exit 1
166 end
167
168 var cmd_branch = ""
169 if version != null then cmd_branch = "--branch '{version}'"
170
171 var cmd = "git clone --depth 1 {cmd_branch} {git_repo.escape_to_sh} {target_dir.escape_to_sh}"
172 if verbose then print "+ {cmd}"
173
174 if "NIT_TESTING".environ == "true" then
175 # Silence git output when testing
176 cmd += " 2> /dev/null"
177 end
178
179 var proc = new Process("sh", "-c", cmd)
180 proc.wait
181
182 if proc.status != 0 then
183 print_error "Install failed"
184 exit 1
185 end
186 end
187 end
188
189 # Upgrade a package
190 class CommandUpgrade
191 super Command
192
193 redef fun name do return "upgrade"
194 redef fun usage do return "picnit upgrade <package>"
195 redef fun description do return "Upgrade a package"
196
197 redef fun apply(args)
198 do
199 if args.length != 1 then
200 print_local_help
201 exit 1
202 end
203
204 var name = args.first
205 var target_dir = picnit_lib_dir / name
206
207 if not target_dir.file_exists or not target_dir.to_path.is_dir then
208 print_error "Package not found"
209 exit 1
210 end
211
212 check_git
213
214 var cmd = "cd {target_dir.escape_to_sh}; git pull"
215 if verbose then print "+ {cmd}"
216
217 var proc = new Process("sh", "-c", cmd)
218 proc.wait
219
220 if proc.status != 0 then
221 print_error "Upgrade failed"
222 exit 1
223 end
224 end
225 end
226
227 # Uninstall a package
228 class CommandUninstall
229 super Command
230
231 redef fun name do return "uninstall"
232 redef fun usage do return "picnit uninstall <package>"
233 redef fun description do return "Uninstall a package"
234
235 redef fun apply(args)
236 do
237 if args.length != 1 then
238 print_local_help
239 exit 1
240 end
241
242 var name = args.first
243 var target_dir = picnit_lib_dir / name
244
245 if not target_dir.file_exists or not target_dir.to_path.is_dir then
246 print_error "Package not found"
247 exit 1
248 end
249
250 # Ask confirmation
251 var response = prompt("Delete {target_dir.escape_to_sh}? [Y/n] ")
252 var accept = response != null and
253 (response.to_lower == "y" or response.to_lower == "yes" or response == "")
254 if not accept then return
255
256 var cmd = "rm -rf {target_dir.escape_to_sh}"
257 if verbose then print "+ {cmd}"
258
259 var proc = new Process("sh", "-c", cmd)
260 proc.wait
261
262 if proc.status != 0 then
263 print_error "Uninstall failed"
264 exit 1
265 end
266 end
267 end
268
269 # List all installed packages
270 class CommandList
271 super Command
272
273 redef fun name do return "list"
274 redef fun usage do return "picnit list"
275 redef fun description do return "List installed packages"
276
277 redef fun apply(args)
278 do
279 var files = picnit_lib_dir.files
280 for file in files do
281 var ini_path = picnit_lib_dir / file / "package.ini"
282 if verbose then print "- Reading ini file at {ini_path}"
283 var ini = new ConfigTree(ini_path)
284 var tags = ini["package.tags"]
285
286 if tags != null then
287 print "{file.justify(15, 0.0)} {tags}"
288 else
289 print file
290 end
291 end
292 end
293 end
294
295 # Show general help or help specific to a command
296 class CommandHelp
297 super Command
298
299 redef fun name do return "help"
300 redef fun usage do return "picnit help [command]"
301 redef fun description do return "Show general help message or the help for a command"
302
303 redef fun apply(args)
304 do
305 # Try first to help about a valid action
306 if args.length == 1 then
307 var command = commands.get_or_null(args.first)
308 if command != null then
309 command.print_local_help
310 return
311 end
312 end
313
314 print_help
315 end
316 end
317
318 redef class Sys
319
320 # General command line options
321 var opts = new OptionContext
322
323 # Help option
324 var opt_help = new OptionBool("Show this help message", "--help", "-h")
325
326 # Verbose mode option
327 var opt_verbose = new OptionBool("Print more information", "--verbose", "-v")
328 private fun verbose: Bool do return opt_verbose.value
329
330 # All command line actions, mapped to their short `name`
331 var commands = new Map[String, Command]
332
333 private var command_install = new CommandInstall(commands)
334 private var command_list = new CommandList(commands)
335 private var command_update = new CommandUpgrade(commands)
336 private var command_uninstall = new CommandUninstall(commands)
337 private var command_help = new CommandHelp(commands)
338 end
339
340 redef fun picnit_lib_dir
341 do
342 if "NIT_TESTING".environ == "true" then
343 return "/tmp/picnit-test-" + "NIT_TESTING_ID".environ
344 else return super
345 end
346
347 # Print the general help message
348 private fun print_help
349 do
350 print "usage: picnit <command> [options]"
351 print ""
352
353 print "commands:"
354 for command in commands.values do
355 print " {command.name.justify(11, 0.0)} {command.description}"
356 end
357 print ""
358
359 print "options:"
360 opts.usage
361 end
362
363 # Check if `git` is available, exit if not
364 private fun check_git
365 do
366 var proc = new ProcessReader("git", "--version")
367 proc.wait
368 proc.close
369
370 if proc.status != 0 then
371 print_error "Please install `git`"
372 exit 1
373 end
374 end
375
376 # Parse main options
377 opts.add_option(opt_help, opt_verbose)
378 opts.parse
379 var rest = opts.rest
380
381 if opt_help.value then
382 print_help
383 exit 0
384 end
385
386 if opts.errors.not_empty then
387 for error in opts.errors do print error
388 print ""
389 print_help
390 exit 1
391 end
392
393 if rest.is_empty then
394 print_help
395 exit 1
396 end
397
398 # Find and apply action
399 var action_name = rest.shift
400 var action = commands.get_or_null(action_name)
401 if action != null then
402 action.apply rest
403 else
404 print_help
405 exit 1
406 end