nitpm: install packages listed in package.ini by default
[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 [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)
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)
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)
158 do
159 check_git
160
161 var target_dir = picnit_lib_dir / name
162 if target_dir.file_exists then
163 print_error "Already installed"
164 exit 1
165 end
166
167 var cmd = "git clone {git_repo.escape_to_sh} {target_dir.escape_to_sh}"
168 if verbose then print "+ {cmd}"
169
170 if "NIT_TESTING".environ == "true" then
171 # Silence git output when testing
172 cmd += " 2> /dev/null"
173 end
174
175 var proc = new Process("sh", "-c", cmd)
176 proc.wait
177
178 if proc.status != 0 then
179 print_error "Install failed"
180 exit 1
181 end
182 end
183 end
184
185 # Upgrade a package
186 class CommandUpgrade
187 super Command
188
189 redef fun name do return "upgrade"
190 redef fun usage do return "picnit upgrade <package>"
191 redef fun description do return "Upgrade a package"
192
193 redef fun apply(args)
194 do
195 if args.length != 1 then
196 print_local_help
197 exit 1
198 end
199
200 var name = args.first
201 var target_dir = picnit_lib_dir / name
202
203 if not target_dir.file_exists or not target_dir.to_path.is_dir then
204 print_error "Package not found"
205 exit 1
206 end
207
208 check_git
209
210 var cmd = "cd {target_dir.escape_to_sh}; git pull"
211 if verbose then print "+ {cmd}"
212
213 var proc = new Process("sh", "-c", cmd)
214 proc.wait
215
216 if proc.status != 0 then
217 print_error "Upgrade failed"
218 exit 1
219 end
220 end
221 end
222
223 # Uninstall a package
224 class CommandUninstall
225 super Command
226
227 redef fun name do return "uninstall"
228 redef fun usage do return "picnit uninstall <package>"
229 redef fun description do return "Uninstall a package"
230
231 redef fun apply(args)
232 do
233 if args.length != 1 then
234 print_local_help
235 exit 1
236 end
237
238 var name = args.first
239 var target_dir = picnit_lib_dir / name
240
241 if not target_dir.file_exists or not target_dir.to_path.is_dir then
242 print_error "Package not found"
243 exit 1
244 end
245
246 # Ask confirmation
247 var response = prompt("Delete {target_dir.escape_to_sh}? [Y/n] ")
248 var accept = response != null and
249 (response.to_lower == "y" or response.to_lower == "yes" or response == "")
250 if not accept then return
251
252 var cmd = "rm -rf {target_dir.escape_to_sh}"
253 if verbose then print "+ {cmd}"
254
255 var proc = new Process("sh", "-c", cmd)
256 proc.wait
257
258 if proc.status != 0 then
259 print_error "Uninstall failed"
260 exit 1
261 end
262 end
263 end
264
265 # List all installed packages
266 class CommandList
267 super Command
268
269 redef fun name do return "list"
270 redef fun usage do return "picnit list"
271 redef fun description do return "List installed packages"
272
273 redef fun apply(args)
274 do
275 var files = picnit_lib_dir.files
276 for file in files do
277 var ini_path = picnit_lib_dir / file / "package.ini"
278 if verbose then print "- Reading ini file at {ini_path}"
279 var ini = new ConfigTree(ini_path)
280 var tags = ini["package.tags"]
281
282 if tags != null then
283 print "{file.justify(15, 0.0)} {tags}"
284 else
285 print file
286 end
287 end
288 end
289 end
290
291 # Show general help or help specific to a command
292 class CommandHelp
293 super Command
294
295 redef fun name do return "help"
296 redef fun usage do return "picnit help [command]"
297 redef fun description do return "Show general help message or the help for a command"
298
299 redef fun apply(args)
300 do
301 # Try first to help about a valid action
302 if args.length == 1 then
303 var command = commands.get_or_null(args.first)
304 if command != null then
305 command.print_local_help
306 return
307 end
308 end
309
310 print_help
311 end
312 end
313
314 redef class Sys
315
316 # General command line options
317 var opts = new OptionContext
318
319 # Help option
320 var opt_help = new OptionBool("Show this help message", "--help", "-h")
321
322 # Verbose mode option
323 var opt_verbose = new OptionBool("Print more information", "--verbose", "-v")
324 private fun verbose: Bool do return opt_verbose.value
325
326 # All command line actions, mapped to their short `name`
327 var commands = new Map[String, Command]
328
329 private var command_install = new CommandInstall(commands)
330 private var command_list = new CommandList(commands)
331 private var command_update = new CommandUpgrade(commands)
332 private var command_uninstall = new CommandUninstall(commands)
333 private var command_help = new CommandHelp(commands)
334 end
335
336 redef fun picnit_lib_dir
337 do
338 if "NIT_TESTING".environ == "true" then
339 return "/tmp/picnit-test-" + "NIT_TESTING_ID".environ
340 else return super
341 end
342
343 # Print the general help message
344 private fun print_help
345 do
346 print "usage: picnit <command> [options]"
347 print ""
348
349 print "commands:"
350 for command in commands.values do
351 print " {command.name.justify(11, 0.0)} {command.description}"
352 end
353 print ""
354
355 print "options:"
356 opts.usage
357 end
358
359 # Check if `git` is available, exit if not
360 private fun check_git
361 do
362 var proc = new ProcessReader("git", "--version")
363 proc.wait
364 proc.close
365
366 if proc.status != 0 then
367 print_error "Please install `git`"
368 exit 1
369 end
370 end
371
372 # Parse main options
373 opts.add_option(opt_help, opt_verbose)
374 opts.parse
375 var rest = opts.rest
376
377 if opt_help.value then
378 print_help
379 exit 0
380 end
381
382 if opts.errors.not_empty then
383 for error in opts.errors do print error
384 print ""
385 print_help
386 exit 1
387 end
388
389 if rest.is_empty then
390 print_help
391 exit 1
392 end
393
394 # Find and apply action
395 var action_name = rest.shift
396 var action = commands.get_or_null(action_name)
397 if action != null then
398 action.apply rest
399 else
400 print_help
401 exit 1
402 end