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