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