guix/guix/scripts/shell.scm

401 lines
15 KiB
Scheme
Raw Normal View History

;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2021 Ludovic Courtès <ludo@gnu.org>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
(define-module (guix scripts shell)
#:use-module (guix ui)
#:use-module ((guix diagnostics) #:select (location))
#:use-module (guix scripts environment)
#:autoload (guix scripts build) (show-build-options-help)
#:autoload (guix transformations) (show-transformation-options-help)
#:use-module (guix scripts)
#:use-module (guix packages)
#:use-module (guix profiles)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-26)
#:use-module (srfi srfi-37)
#:use-module (srfi srfi-71)
#:use-module (ice-9 match)
#:autoload (ice-9 rdelim) (read-line)
#:autoload (guix base32) (bytevector->base32-string)
#:autoload (rnrs bytevectors) (string->utf8)
#:autoload (guix utils) (config-directory cache-directory)
#:autoload (guix describe) (current-channels)
#:autoload (guix channels) (channel-commit)
#:autoload (gcrypt hash) (sha256)
#:use-module ((guix build utils) #:select (mkdir-p))
#:use-module (guix cache)
#:use-module ((ice-9 ftw) #:select (scandir))
#:export (guix-shell))
(define (show-help)
(display (G_ "Usage: guix shell [OPTION] PACKAGES... [-- COMMAND...]
Build an environment that includes PACKAGES and execute COMMAND or an
interactive shell in that environment.\n"))
(newline)
;; These two options differ from 'guix environment'.
(display (G_ "
-D, --development include the development inputs of the next package"))
(display (G_ "
-f, --file=FILE create environment for the package that the code within
FILE evaluates to"))
(display (G_ "
-q inhibit loading of 'guix.scm' and 'manifest.scm'"))
(display (G_ "
--rebuild-cache rebuild cached environment, if any"))
(show-environment-options-help)
(newline)
(show-build-options-help)
(newline)
(show-transformation-options-help)
(newline)
(display (G_ "
-h, --help display this help and exit"))
(display (G_ "
-V, --version display version information and exit"))
(newline)
(show-bug-report-information))
(define (tag-package-arg opts arg)
"Return a two-element list with the form (TAG ARG) that tags ARG with either
'ad-hoc' in OPTS has the 'ad-hoc?' key set to #t, or 'inputs' otherwise."
(if (assoc-ref opts 'ad-hoc?)
`(ad-hoc-package ,arg)
`(package ,arg)))
(define (ensure-ad-hoc alist)
(if (assq-ref alist 'ad-hoc?)
alist
`((ad-hoc? . #t) ,@alist)))
(define (wrapped-option opt)
"Wrap OPT, a SRFI-37 option, such that its processor always adds the
'ad-hoc?' flag to the resulting alist."
(option (option-names opt)
(option-required-arg? opt)
(option-optional-arg? opt)
(compose ensure-ad-hoc (option-processor opt))))
(define %options
;; Specification of the command-line options.
(let ((to-remove '("ad-hoc" "inherit" "load" "help" "version")))
(append
(list (option '(#\h "help") #f #f
(lambda args
(show-help)
(exit 0)))
(option '(#\V "version") #f #f
(lambda args
(show-version-and-exit "guix shell")))
(option '(#\D "development") #f #f
(lambda (opt name arg result)
;; Temporarily remove the 'ad-hoc?' flag from result.
;; The next option will put it back thanks to
;; 'wrapped-option'.
(alist-delete 'ad-hoc? result)))
;; For consistency with 'guix package', support '-f' rather than
;; '-l' like 'guix environment' does.
(option '(#\f "file") #t #f
(lambda (opt name arg result)
(alist-cons 'load (tag-package-arg result arg)
result)))
(option '(#\q) #f #f
(lambda (opt name arg result)
(alist-cons 'explicit-loading? #t result)))
(option '("rebuild-cache") #f #f
(lambda (opt name arg result)
(alist-cons 'rebuild-cache? #t result))))
(filter-map (lambda (opt)
(and (not (any (lambda (name)
(member name to-remove))
(option-names opt)))
(wrapped-option opt)))
%environment-options))))
(define %default-options
`((ad-hoc? . #t) ;always true
,@%environment-default-options))
(define (parse-args args)
"Parse the list of command line arguments ARGS."
(define (handle-argument arg result)
(alist-cons 'package (tag-package-arg result arg)
(ensure-ad-hoc result)))
;; The '--' token is used to separate the command to run from the rest of
;; the operands.
(let ((args command (break (cut string=? "--" <>) args)))
(let ((opts (parse-command-line args %options (list %default-options)
#:argument-handler handle-argument)))
(options-with-caching
(auto-detect-manifest
(match command
(() opts)
(("--") opts)
(("--" command ...) (alist-cons 'exec command opts))))))))
(define (find-file-in-parent-directories candidates)
"Find one of CANDIDATES in the current directory or one of its ancestors."
(define start (getcwd))
(define device (stat:dev (stat start)))
(let loop ((directory start))
(let ((stat (stat directory)))
(and (= (stat:uid stat) (getuid))
(= (stat:dev stat) device)
(or (any (lambda (candidate)
(let ((candidate (string-append directory "/" candidate)))
(and (file-exists? candidate) candidate)))
candidates)
(and (not (string=? directory "/"))
(loop (dirname directory)))))))) ;lexical ".." resolution
(define (authorized-directory-file)
"Return the name of the file listing directories for which 'guix shell' may
automatically load 'guix.scm' or 'manifest.scm' files."
(string-append (config-directory) "/shell-authorized-directories"))
(define (authorized-shell-directory? directory)
"Return true if DIRECTORY is among the authorized directories for automatic
loading. The list of authorized directories is read from
'authorized-directory-file'; each line must be either: an absolute file name,
a hash-prefixed comment, or a blank line."
(catch 'system-error
(lambda ()
(call-with-input-file (authorized-directory-file)
(lambda (port)
(let loop ()
(match (read-line port)
((? eof-object?) #f)
((= string-trim line)
(cond ((string-prefix? "#" line) ;comment
(loop))
((string-prefix? "/" line) ;absolute file name
(or (string=? line directory)
(loop)))
((string-null? (string-trim-right line)) ;blank line
(loop))
(else ;bogus line
(let ((loc (location (port-filename port)
(port-line port)
(port-column port))))
(warning loc (G_ "ignoring invalid file name: '~a'~%")
line))))))))))
(const #f)))
(define (options-with-caching opts)
"If OPTS contains exactly one 'load' or one 'manifest' key, automatically
add a 'profile' key (when a profile for that file is already in cache) or a
'gc-root' key (to add the profile to cache)."
(define (single-file-for-caching opts)
(let loop ((opts opts)
(file #f))
(match opts
(() file)
((('package . _) . _) #f)
((('load . ('package candidate)) . rest)
(and (not file) (loop rest candidate)))
((('manifest . candidate) . rest)
(and (not file) (loop rest candidate)))
((('expression . _) . _) #f)
((_ . rest) (loop rest file)))))
;; Check whether there's a single 'load' or 'manifest' option. When that is
;; the case, arrange to automatically cache the resulting profile.
(match (single-file-for-caching opts)
(#f opts)
(file
(let* ((root (profile-cached-gc-root file))
(stat (and root (false-if-exception (lstat root)))))
(if (and (not (assoc-ref opts 'rebuild-cache?))
stat
(<= (stat:mtime ((@ (guile) stat) file))
(stat:mtime stat)))
(let ((now (current-time)))
;; Update the atime on ROOT to reflect usage.
(utime root
now (stat:mtime stat) 0 (stat:mtimensec stat)
AT_SYMLINK_NOFOLLOW)
(alist-cons 'profile root
(remove (match-lambda
(('load . _) #t)
(('manifest . _) #t)
(_ #f))
opts))) ;load right away
(if (and root (not (assq-ref opts 'gc-root)))
(begin
(if stat
(delete-file root)
(mkdir-p (dirname root)))
(alist-cons 'gc-root root opts))
opts))))))
(define (auto-detect-manifest opts)
"If OPTS do not specify packages or a manifest, load a \"guix.scm\" or
\"manifest.scm\" file from the current directory or one of its ancestors.
Return the modified OPTS."
(define (options-contain-payload? opts)
(match opts
(() #f)
((('package . _) . _) #t)
((('load . _) . _) #t)
((('manifest . _) . _) #t)
((('expression . _) . _) #t)
((_ . rest) (options-contain-payload? rest))))
(define interactive?
(not (assoc-ref opts 'exec)))
(define disallow-implicit-load?
(assoc-ref opts 'explicit-loading?))
(if (or (not interactive?)
disallow-implicit-load?
(options-contain-payload? opts))
opts
(match (find-file-in-parent-directories '("manifest.scm" "guix.scm"))
(#f
(warning (G_ "no packages specified; creating an empty environment~%"))
opts)
(file
(if (authorized-shell-directory? (dirname file))
(begin
(info (G_ "loading environment from '~a'...~%") file)
(match (basename file)
("guix.scm" (alist-cons 'load `(package ,file) opts))
("manifest.scm" (alist-cons 'manifest file opts))))
(begin
(report-error
(G_ "not loading '~a' because not authorized to do so~%")
file)
(display-hint (format #f (G_ "To allow automatic loading of
@file{~a} when running @command{guix shell}, you must explicitly authorize its
directory, like so:
@example
echo ~a >> ~a
@end example\n")
file
(dirname file)
(authorized-directory-file)))
(exit 1)))))))
;;;
;;; Profile cache.
;;;
(define %profile-cache-directory
;; Directory where profiles created by 'guix shell' alone (without extra
;; options) are cached.
(make-parameter (string-append (cache-directory #:ensure? #f)
"/profiles")))
(define (profile-cache-key file)
"Return the cache key for the profile corresponding to FILE, a 'guix.scm' or
'manifest.scm' file, or #f if we lack channel information."
(match (current-channels)
(() #f)
(((= channel-commit commits) ...)
(let ((stat (stat file)))
(bytevector->base32-string
;; Since FILE is not canonicalized, only include the device/inode
;; numbers. XXX: In some rare cases involving Btrfs and NFS, this can
;; be insufficient: <https://lwn.net/Articles/866582/>.
(sha256 (string->utf8
(string-append (string-join commits) ":"
(number->string (stat:dev stat)) ":"
(number->string (stat:ino stat))))))))))
(define (profile-cached-gc-root file)
"Return the cached GC root for FILE, a 'guix.scm' or 'manifest.scm' file, or
#f if we lack information to cache it."
(match (profile-cache-key file)
(#f #f)
(key (string-append (%profile-cache-directory) "/" key))))
;;;
;;; One-time hints.
;;;
(define (hint-directory)
"Return the directory name where previously given hints are recorded."
(string-append (cache-directory #:ensure? #f) "/hints"))
(define (hint-file hint)
"Return the name of the file that marks HINT as already printed."
(string-append (hint-directory) "/" (symbol->string hint)))
(define (record-hint hint)
"Mark HINT as already given."
(let ((file (hint-file hint)))
(mkdir-p (dirname file))
(close-fdes (open-fdes file (logior O_CREAT O_WRONLY)))))
(define (hint-given? hint)
"Return true if HINT was already given."
(file-exists? (hint-file hint)))
(define-command (guix-shell . args)
(category development)
(synopsis "spawn one-off software environments")
(define (cache-entries directory)
(filter-map (match-lambda
((or "." "..") #f)
(file (string-append directory "/" file)))
(or (scandir directory) '())))
(define* (entry-expiration file)
;; Return the time at which FILE, a cached profile, is considered expired.
(match (false-if-exception (lstat file))
(#f 0) ;FILE may have been deleted in the meantime
(st (+ (stat:atime st) (* 60 60 24 7)))))
(define opts
(parse-args args))
(define interactive?
(not (assoc-ref opts 'exec)))
(if (assoc-ref opts 'check?)
(record-hint 'shell-check)
(when (and interactive?
(not (hint-given? 'shell-check))
(not (assoc-ref opts 'container?))
(not (assoc-ref opts 'search-paths)))
(display-hint (G_ "Consider passing the @option{--check} option once
to make sure your shell does not clobber environment variables."))) )
;; Clean the cache in EXIT-HOOK so that (1) it happens after potential use
;; of cached profiles, and (2) cleanup actually happens, even when
;; 'guix-environment*' calls 'exit'.
(add-hook! exit-hook
(lambda _
(maybe-remove-expired-cache-entries
(%profile-cache-directory)
cache-entries
#:entry-expiration entry-expiration)))
(guix-environment* opts))