briefcase 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.rdoc +104 -0
- data/Rakefile +9 -0
- data/bin/briefcase +3 -0
- data/briefcase.gemspec +33 -0
- data/lib/briefcase.rb +42 -0
- data/lib/briefcase/commands.rb +6 -0
- data/lib/briefcase/commands/base.rb +94 -0
- data/lib/briefcase/commands/core/files.rb +81 -0
- data/lib/briefcase/commands/core/output.rb +24 -0
- data/lib/briefcase/commands/core/secrets.rb +50 -0
- data/lib/briefcase/commands/generate.rb +65 -0
- data/lib/briefcase/commands/git.rb +37 -0
- data/lib/briefcase/commands/import.rb +81 -0
- data/lib/briefcase/commands/redact.rb +76 -0
- data/lib/briefcase/commands/sync.rb +66 -0
- data/lib/briefcase/main.rb +44 -0
- data/lib/briefcase/version.rb +3 -0
- data/spec/bin/editor +7 -0
- data/spec/generate_spec.rb +78 -0
- data/spec/git_spec.rb +21 -0
- data/spec/helpers/assertions.rb +84 -0
- data/spec/helpers/commands.rb +42 -0
- data/spec/helpers/files.rb +56 -0
- data/spec/helpers/stubbing.rb +25 -0
- data/spec/import_spec.rb +145 -0
- data/spec/spec_helper.rb +17 -0
- data/spec/sync_spec.rb +52 -0
- metadata +183 -0
@@ -0,0 +1,65 @@
|
|
1
|
+
module Briefcase
|
2
|
+
module Commands
|
3
|
+
|
4
|
+
# Generate looks through through the dotfiles directory for any redacted
|
5
|
+
# dotfiles. It attempts to generate a normal version of each file it finds,
|
6
|
+
# using the values stored in the .briefcase_secrets file.
|
7
|
+
class Generate < Base
|
8
|
+
|
9
|
+
def execute
|
10
|
+
intro "Generating redacted dotfiles in #{dotfiles_path}"
|
11
|
+
|
12
|
+
Dir.glob(File.join(dotfiles_path, "*.#{REDACTED_EXTENSION}")) do |path|
|
13
|
+
generate_file_for_path(path)
|
14
|
+
end
|
15
|
+
|
16
|
+
write_secrets
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
# Generates a standard dotfile from a redacted dotfile
|
22
|
+
#
|
23
|
+
# This method will attempt to find secret values in the secrets file. If
|
24
|
+
# any aren't found, it will print a message about the offending key and
|
25
|
+
# add that key to the secrets file without a value (to make it easier for
|
26
|
+
# users to fill in the missing value).
|
27
|
+
#
|
28
|
+
# path - the path to the redacted dotfile
|
29
|
+
def generate_file_for_path(path)
|
30
|
+
static_path = path.gsub(/.#{REDACTED_EXTENSION}$/, '')
|
31
|
+
basename = File.basename(static_path)
|
32
|
+
dotfile_path = generate_dotfile_path(basename)
|
33
|
+
|
34
|
+
if !File.exist?(dotfile_path) || overwrite_file?(dotfile_path)
|
35
|
+
info "Generating %s", dotfile_path
|
36
|
+
content = File.read(path)
|
37
|
+
edited_content = content.gsub(COMMENT_REPLACEMENT_REGEX) do |match|
|
38
|
+
key = $2
|
39
|
+
if (replacement = get_secret(static_path, key))
|
40
|
+
info "Restoring secret value for key: #{key}"
|
41
|
+
$1 + replacement
|
42
|
+
else
|
43
|
+
info "Secret missing for key: #{key}"
|
44
|
+
add_secret(static_path, key, '')
|
45
|
+
match
|
46
|
+
end
|
47
|
+
end
|
48
|
+
write_file(dotfile_path, edited_content)
|
49
|
+
else
|
50
|
+
info "Skipping %s as there is already a file at %s", path, dotfile_path
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# TODO consolidate this method and Import#overwrite_file?
|
55
|
+
def overwrite_file?(path)
|
56
|
+
decision = choose("#{path} already exists as a dotfile. Do you want to replace it?", 'replace', 'skip') do |menu|
|
57
|
+
menu.index = :letter
|
58
|
+
menu.layout = :one_line
|
59
|
+
end
|
60
|
+
decision == 'replace'
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Briefcase
|
2
|
+
module Commands
|
3
|
+
|
4
|
+
# Run git commands in the dotfiles directory.
|
5
|
+
#
|
6
|
+
# This command simply passes everything passed to it on to the git
|
7
|
+
# executable on the user's PATH.
|
8
|
+
#
|
9
|
+
# A basic equivalent of this command:
|
10
|
+
#
|
11
|
+
# briefcase git status
|
12
|
+
#
|
13
|
+
# Would be:
|
14
|
+
#
|
15
|
+
# cd ~/.dotfiles && git status
|
16
|
+
class Git < Base
|
17
|
+
|
18
|
+
# Execute a git command in the dotfiles directory. Will prompt the user
|
19
|
+
# to create a dotfiles directory if it does not exist, which will also
|
20
|
+
# create an empty git repository.
|
21
|
+
def execute
|
22
|
+
verify_dotfiles_directory_exists
|
23
|
+
command = @args.join(' ')
|
24
|
+
intro("Running git %s in %s", command, dotfiles_path)
|
25
|
+
run_git_command(command)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def run_git_command(command)
|
31
|
+
puts `cd #{dotfiles_path} && git #{command}`
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
|
3
|
+
module Briefcase
|
4
|
+
module Commands
|
5
|
+
|
6
|
+
# Import copies a dotfile into the dotfiles directory and created a
|
7
|
+
# symlink in the file's previous location, pointing to its new location.
|
8
|
+
#
|
9
|
+
# The file's name in the repository will be the dotfile's name with the
|
10
|
+
# leading period removed. So, importing '~/.bashrc' will move that file to
|
11
|
+
# ~/.dotfiles/bashrc and then create a symlink at ~/.bashrc pointing to
|
12
|
+
# ~/.dotfiles/bashrc.
|
13
|
+
#
|
14
|
+
# If there was already a file located at ~/.dotfiles/bashrc, the user will
|
15
|
+
# be asked if it is OK to mvoe the existing file aside. If the user accepts
|
16
|
+
# the move, the existing file is renamed to bashrc.old.1 before the new
|
17
|
+
# file is imported, and a message would be shown indicating this has
|
18
|
+
# occurred.
|
19
|
+
class Import < Base
|
20
|
+
|
21
|
+
# Execute verifies that the dotfiles directory exists before attempting
|
22
|
+
# the import.
|
23
|
+
#
|
24
|
+
# Raises an error if the specified path does not exist.
|
25
|
+
def execute
|
26
|
+
verify_dotfiles_directory_exists
|
27
|
+
|
28
|
+
@path = File.expand_path(@args.first)
|
29
|
+
raise UnrecoverableError.new("#{@path} does not exist") unless File.exist?(@path)
|
30
|
+
|
31
|
+
intro("Importing %s into %s", @path, dotfiles_path)
|
32
|
+
import_file
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# Import a file. Creates the dotfiles directory if it doesn't exist
|
38
|
+
# before attempting the import. Prompts the user when there is a naming
|
39
|
+
# collision between an existing dotfile and the file to be imported.
|
40
|
+
#
|
41
|
+
# Raises CommandAborted if there is a colision and the user declines to
|
42
|
+
# move the existing file.
|
43
|
+
def import_file
|
44
|
+
collision = dotfile_exists?(@path)
|
45
|
+
if !collision || overwrite_file?
|
46
|
+
|
47
|
+
mkdir_p(dotfiles_path)
|
48
|
+
destination = generate_dotfile_path(@path)
|
49
|
+
|
50
|
+
if collision
|
51
|
+
existing = Dir.glob("#{destination}.old.*").size
|
52
|
+
sideline = "#{destination}.old.#{existing+1}"
|
53
|
+
info "Moving %s to %s", destination, sideline
|
54
|
+
mv(destination, sideline)
|
55
|
+
end
|
56
|
+
|
57
|
+
move(@path, destination)
|
58
|
+
symlink(destination, @path)
|
59
|
+
else
|
60
|
+
raise CommandAborted.new('Cancelled.')
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Ask the user if it is OK to move an existing file so a new file can be
|
65
|
+
# imported.
|
66
|
+
#
|
67
|
+
# Returns whether the user accepts the move or not as a Boolean.
|
68
|
+
#
|
69
|
+
# TODO: Rename this method as it doesn't overwrite anything.
|
70
|
+
def overwrite_file?
|
71
|
+
decision = choose("#{@path} already exists as a dotfile. Do you want to replace it? Your original file will be renamed.", 'replace', 'abort') do |menu|
|
72
|
+
menu.index = :letter
|
73
|
+
menu.layout = :one_line
|
74
|
+
end
|
75
|
+
|
76
|
+
decision == 'replace'
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Briefcase
|
2
|
+
module Commands
|
3
|
+
|
4
|
+
# Redact is similar to Import, but it will also prompt the user to replace
|
5
|
+
# any secure information in the file with a special replacement syntax. Any
|
6
|
+
# values replaces in this way will be stored in the secrets file, and the
|
7
|
+
# dotfile will be imported sans secrets.
|
8
|
+
class Redact < Import
|
9
|
+
|
10
|
+
EDITING_HELP_TEXT = <<-TEXT
|
11
|
+
# Edit the file below, replacing and sensitive information to turn this:
|
12
|
+
#
|
13
|
+
# password: superSecretPassword
|
14
|
+
#
|
15
|
+
# Into:
|
16
|
+
#
|
17
|
+
# password: # briefcase(password)
|
18
|
+
#
|
19
|
+
########################################################################
|
20
|
+
TEXT
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def import_file
|
25
|
+
super
|
26
|
+
create_redacted_version
|
27
|
+
end
|
28
|
+
|
29
|
+
# Copy the file to be imported into the dotfiles directory and append the
|
30
|
+
# redacted extension to its name. This file is then opened in an
|
31
|
+
# editor, where the user has a chance to replace sensitive information.
|
32
|
+
# After saving and closing the file, the differences are examined and the
|
33
|
+
# replaces values are detected. These values and their replacement keys
|
34
|
+
# are stored in the secrets file.
|
35
|
+
def create_redacted_version
|
36
|
+
destination = generate_dotfile_path(@path)
|
37
|
+
redacted_path = destination + ".#{REDACTED_EXTENSION}"
|
38
|
+
info "Creating redacted version at #{redacted_path}"
|
39
|
+
|
40
|
+
content_to_edit = original_content = File.read(destination)
|
41
|
+
|
42
|
+
unless Briefcase.testing?
|
43
|
+
content_to_edit = EDITING_HELP_TEXT + content_to_edit
|
44
|
+
end
|
45
|
+
|
46
|
+
write_file(redacted_path, content_to_edit)
|
47
|
+
edited_content = edit_file_with_editor(redacted_path).gsub!(EDITING_HELP_TEXT, '')
|
48
|
+
write_file(redacted_path, edited_content)
|
49
|
+
|
50
|
+
edited_content.lines.each_with_index do |line, line_index|
|
51
|
+
if line =~ COMMENT_REPLACEMENT_REGEX
|
52
|
+
key = $2
|
53
|
+
mask = %r{^#{$1}(.*)$}
|
54
|
+
value = original_content.lines.to_a[line_index].match(mask)[1]
|
55
|
+
info "Storing secret value for key: #{key}"
|
56
|
+
add_secret(destination, key, value)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
write_secrets
|
61
|
+
add_to_git_ignore(visible_name(destination))
|
62
|
+
end
|
63
|
+
|
64
|
+
# Open a file with an editor. The editor can be specified using the
|
65
|
+
# EDITOR environment variable, with vim being the current default.
|
66
|
+
#
|
67
|
+
# Returns the content of the file after the editor is closed.
|
68
|
+
def edit_file_with_editor(path)
|
69
|
+
editor_command = ENV['BRIEFCASE_EDITOR'] || 'vim'
|
70
|
+
system(editor_command, path)
|
71
|
+
File.read(path)
|
72
|
+
end
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Briefcase
|
2
|
+
module Commands
|
3
|
+
|
4
|
+
# Sync scans the dotfiles directory for dotfiles, and creates any missing
|
5
|
+
# symlinks in the user's home directory.
|
6
|
+
#
|
7
|
+
# A message is printed out for each file, indicating if a matching symlink
|
8
|
+
# was found in the home directory or if one was created.
|
9
|
+
#
|
10
|
+
# If there is a symlink in the user's home directory with a dotfile's name
|
11
|
+
# but it is pointing to the wrong location, it is removed and a new symlink
|
12
|
+
# is created in its place.
|
13
|
+
class Sync < Base
|
14
|
+
|
15
|
+
# Scan the dotfiles directory for files, and process each one. Files
|
16
|
+
# with names containing '.old' are ignored and a warning is show to alert
|
17
|
+
# the user of their presence.
|
18
|
+
def execute
|
19
|
+
intro "Synchronizing dotfiles between #{dotfiles_path} and #{home_path}"
|
20
|
+
|
21
|
+
Dir.glob(File.join(dotfiles_path, '*')) do |path|
|
22
|
+
basename = File.basename(path)
|
23
|
+
next if %w{. ..}.include?(basename)
|
24
|
+
next if basename =~ /.#{REDACTED_EXTENSION}$/
|
25
|
+
|
26
|
+
if basename.include?('.old')
|
27
|
+
warn "Skipping %s, you may want to remove it.", path
|
28
|
+
else
|
29
|
+
create_or_verify_symlink(basename)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
# Verifies if a symlink following briefcase's conventions exists for the
|
37
|
+
# supplied filename. Creates a symlink if it doesn't exist, or if there
|
38
|
+
# is a corectly named symlink with the wrong target.
|
39
|
+
#
|
40
|
+
# basename - The String filename to use when building paths
|
41
|
+
def create_or_verify_symlink(basename)
|
42
|
+
dotfile_name = ".#{basename}"
|
43
|
+
symlink_path = File.join(home_path, dotfile_name)
|
44
|
+
dotfile_path = generate_dotfile_path(basename)
|
45
|
+
|
46
|
+
if File.exist?(symlink_path)
|
47
|
+
if File.symlink?(symlink_path)
|
48
|
+
if File.readlink(symlink_path) == dotfile_path
|
49
|
+
info "Symlink verified: %s -> %s", symlink_path, dotfile_path
|
50
|
+
return
|
51
|
+
else
|
52
|
+
info "Removing outdated symlink %s", symlink_path
|
53
|
+
rm(symlink_path)
|
54
|
+
end
|
55
|
+
else
|
56
|
+
info "Found normal file at %s, skipping...", symlink_path
|
57
|
+
return
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
symlink(dotfile_path, symlink_path)
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'commander/import'
|
2
|
+
require File.expand_path('../briefcase', File.dirname(__FILE__))
|
3
|
+
|
4
|
+
program :name, 'Briefcase'
|
5
|
+
program :version, Briefcase::VERSION
|
6
|
+
program :description, 'Makes it easier to keep dotfiles in git'
|
7
|
+
|
8
|
+
command :import do |c|
|
9
|
+
c.syntax = 'briefcase import PATH'
|
10
|
+
c.description = 'Move PATH to the version controlled directory and symlink its previous location to its new one.'
|
11
|
+
c.when_called Briefcase::Commands::Import
|
12
|
+
end
|
13
|
+
|
14
|
+
command :redact do |c|
|
15
|
+
c.syntax = 'briefcase redact PATH'
|
16
|
+
c.description = 'Edit PATH to remove sensitive information, save the edited version to the version controlled directory, and symlink its previous location to its new one, and add to .gitignore.'
|
17
|
+
c.when_called Briefcase::Commands::Redact
|
18
|
+
end
|
19
|
+
|
20
|
+
command :sync do |c|
|
21
|
+
c.syntax = 'briefcase sync'
|
22
|
+
c.description = 'Updates all symlinks for files included in ~/.dotfiles'
|
23
|
+
c.when_called Briefcase::Commands::Sync
|
24
|
+
end
|
25
|
+
|
26
|
+
command :generate do |c|
|
27
|
+
c.syntax = 'briefcase generate'
|
28
|
+
c.description = 'Generates static versions of all redacted dotfiles in ~/.dotfiles'
|
29
|
+
c.when_called Briefcase::Commands::Generate
|
30
|
+
end
|
31
|
+
|
32
|
+
command :git do |c|
|
33
|
+
c.syntax = 'briefcase git [options]'
|
34
|
+
c.description = 'Run a git command in the dotfiles directory'
|
35
|
+
c.when_called Briefcase::Commands::Git
|
36
|
+
end
|
37
|
+
|
38
|
+
default_command :help
|
39
|
+
|
40
|
+
# command :suggest do |c|
|
41
|
+
# c.syntax = 'briefcase suggest'
|
42
|
+
# c.description 'List dotfiles that are in your home directory and not in ~/.dotfiles'
|
43
|
+
# c.when_called Briefcase::Commands::Suggest
|
44
|
+
# end
|
data/spec/bin/editor
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
responses_path = File.expand_path('/tmp/briefcase_spec_work/briefcase_editor_responses', File.dirname(__FILE__))
|
4
|
+
response_file = ARGV[0].gsub(/\//, '_')
|
5
|
+
File.open(ARGV[0], 'w') do |file|
|
6
|
+
file.write(File.read(File.join(responses_path, response_file)))
|
7
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Briefcase::Commands::Generate do
|
4
|
+
|
5
|
+
describe "with an existing dotfiles directory" do
|
6
|
+
|
7
|
+
before do
|
8
|
+
create_dotfiles_directory
|
9
|
+
create_home_directory
|
10
|
+
end
|
11
|
+
|
12
|
+
after do
|
13
|
+
cleanup_dotfiles_directory
|
14
|
+
cleanup_home_directory
|
15
|
+
end
|
16
|
+
|
17
|
+
it "generates a static version of a redacted dotfile" do
|
18
|
+
static_path = File.join(dotfiles_path, 'test')
|
19
|
+
redacted_path = File.join(dotfiles_path, 'test.redacted')
|
20
|
+
|
21
|
+
create_secrets('test' => {'email' => 'google@internet.com'})
|
22
|
+
create_file redacted_path, <<-TEXT
|
23
|
+
username: # briefcase(email)
|
24
|
+
favorite_color: blue
|
25
|
+
TEXT
|
26
|
+
|
27
|
+
run_command("generate")
|
28
|
+
|
29
|
+
output_must_contain(/Generating/, /Loading existing secrets/, /Restoring secret value/)
|
30
|
+
|
31
|
+
file_must_contain static_path, <<-TEXT
|
32
|
+
username: google@internet.com
|
33
|
+
favorite_color: blue
|
34
|
+
TEXT
|
35
|
+
end
|
36
|
+
|
37
|
+
it "create a secrets file and adds discovered secrets to it" do
|
38
|
+
static_path = File.join(dotfiles_path, 'test')
|
39
|
+
redacted_path = File.join(dotfiles_path, 'test.redacted')
|
40
|
+
|
41
|
+
create_file redacted_path, <<-TEXT
|
42
|
+
username: # briefcase(email)
|
43
|
+
TEXT
|
44
|
+
|
45
|
+
run_command("generate")
|
46
|
+
|
47
|
+
output_must_contain(/Generating/, /Secret missing for key: email/)
|
48
|
+
|
49
|
+
file_must_contain static_path, <<-TEXT
|
50
|
+
username: # briefcase(email)
|
51
|
+
TEXT
|
52
|
+
|
53
|
+
secret_must_be_stored('test', 'email', '')
|
54
|
+
end
|
55
|
+
|
56
|
+
it "adds discovered secrets to the secrets file without values" do
|
57
|
+
static_path = File.join(dotfiles_path, 'test')
|
58
|
+
redacted_path = File.join(dotfiles_path, 'test.redacted')
|
59
|
+
|
60
|
+
create_file redacted_path, <<-TEXT
|
61
|
+
username: # briefcase(email)
|
62
|
+
TEXT
|
63
|
+
create_secrets
|
64
|
+
|
65
|
+
run_command("generate")
|
66
|
+
|
67
|
+
output_must_contain(/Generating/, /Secret missing for key: email/)
|
68
|
+
|
69
|
+
file_must_contain static_path, <<-TEXT
|
70
|
+
username: # briefcase(email)
|
71
|
+
TEXT
|
72
|
+
|
73
|
+
secret_must_be_stored('test', 'email', '')
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|