diff --git a/.gitignore b/.gitignore index 5fa69e9..8ec46e3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ announcement.txt coverage doc pkg +/examples/cucumber/greenletters.log diff --git a/History.txt b/History.txt index dece2b7..4edd9df 100644 --- a/History.txt +++ b/History.txt @@ -1,4 +1,4 @@ -== 1.0.0 / 2010-07-03 +== 0.0.1 / 2010-07-19 * 1 major enhancement * Birthday! diff --git a/README.org b/README.org new file mode 100644 index 0000000..56d403b --- /dev/null +++ b/README.org @@ -0,0 +1,78 @@ +#+Title: Greenletters README +#+AUTHOR: Avdi Grimm +#+EMAIL: avdi@avdi.org + +# Configuration: +#+STARTUP: odd +#+STARTUP: hi +#+STARTUP: hidestars + + +* Synopsis + +#+begin_src ruby + require 'greenletters' + adv = Greenletters::Process.new("adventure", :transcript => $stdout) + adv.on(:output, /welcome to adventure/i) do |process, match_data| + adv << "no\n" + end + + puts "Starting adventure..." + adv.start! + adv.wait_for(:output, /you are standing at the end of a road/i) + adv << "east\n" + adv.wait_for(:output, /inside a building/i) + adv << "quit\n" + adv.wait_for(:output, /really want to quit/i) + adv << "yes\n" + adv.wait_for(:exit) + puts "Adventure has exited." +#+end_src + +* What + + Greenletters is a console interaction automation library similar to [[http://directory.fsf.org/project/expect/][GNU + Expect]]. You can use it to script interactions with command-line programs. + +* Why + Because Ruby's built-in expect.rb is pretty underpowered and I wanted to drive + command-line applications from Ruby, not TCL. + +* Who + Greenletters is by [[mailto:avdi@avdi.org][Avdi Grimm]]. + +* Where + http://github.com/avdi/greenletters + +* How + Greenletters uses the pty.rb library under the covers to create a UNIX + pseudoterminal under Ruby's control. Of course, this means that it is + *NIX-only; Windows users need not apply. + + The advantage of using a PTY is that *any* output - inclding output written to + the console instead of STDOUT/STDERR - will be captured by Greenletters. + +* LICENSE + +(The MIT License) + +Copyright (c) 2010 Avdi Grimm + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.txt b/README.txt deleted file mode 100644 index fe1e9f3..0000000 --- a/README.txt +++ /dev/null @@ -1,48 +0,0 @@ -greenletters - by FIXME (your name) - FIXME (url) - -== DESCRIPTION: - -FIXME (describe your package) - -== FEATURES/PROBLEMS: - -* FIXME (list of features or problems) - -== SYNOPSIS: - - FIXME (code sample of usage) - -== REQUIREMENTS: - -* FIXME (list of requirements) - -== INSTALL: - -* FIXME (sudo gem install, anything else) - -== LICENSE: - -(The MIT License) - -Copyright (c) 2009 FIXME (different license?) - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Rakefile b/Rakefile index a0635fc..3cc57c3 100644 --- a/Rakefile +++ b/Rakefile @@ -9,10 +9,13 @@ task :default => 'test:run' task 'gem:release' => 'test:run' Bones { - name 'greenletters' - authors 'Avdi Grimm' - email 'avdi@avdi.org' - url 'http://github.com/avdi/greenletters' + name 'greenletters' + authors 'Avdi Grimm' + email 'avdi@avdi.org' + url 'http://github.com/avdi/greenletters' ignore_file '.gitignore' + readme_file 'README.org' + + summary 'A Ruby command-line automation framework a la Expect' } diff --git a/examples/adventure.rb b/examples/adventure.rb new file mode 100644 index 0000000..da49bf1 --- /dev/null +++ b/examples/adventure.rb @@ -0,0 +1,30 @@ +# This demo interacts with the classic Collossal Cave Adventure game. To install +# the game on Debian-based systems (Ubuntu, etc), execute: +# +# sudo apt-get install bsdgames +# +$:.unshift(File.expand_path('../lib', File.dirname(__FILE__))) +require 'greenletters' +require 'logger' + +logger = ::Logger.new($stdout) +logger.level = ::Logger::INFO +# logger.level = ::Logger::DEBUG +adv = Greenletters::Process.new("adventure", + :logger => logger, + :transcript => $stdout) +adv.on(:output, /welcome to adventure/i) do |process, match_data| + adv << "no\n" +end + +puts "Starting aadventure..." +adv.start! +adv.wait_for(:output, /you are standing at the end of a road/i) +adv << "east\n" +adv.wait_for(:output, /inside a building/i) +adv << "quit\n" +adv.wait_for(:output, /really want to quit/i) +adv << "yes\n" +adv.wait_for(:exit) +puts "Adventure has exited." + diff --git a/examples/cucumber/adventure.feature b/examples/cucumber/adventure.feature new file mode 100644 index 0000000..0c454de --- /dev/null +++ b/examples/cucumber/adventure.feature @@ -0,0 +1,67 @@ +Feature: play adventure + As a nerd + I want to play a text adventure game + Because I'm old-skool + + Scenario: play first few rooms (named process) + Given process activity is logged to "greenletters.log" + Given a process "adventure" from command "adventure" + Given I reply "no" to output "Would you like instructions?" from process "adventure" + Given I reply "yes" to output "Do you really want to quit" from process "adventure" + When I execute the process "adventure" + Then I should see the following output from process "adventure": + """ + You are standing at the end of a road before a small brick building. + Around you is a forest. A small stream flows out of the building and + down a gully. + """ + When I enter "east" into process "adventure" + Then I should see the following output from process "adventure": + """ + You are inside a building, a well house for a large spring. + """ + When I enter "west" into process "adventure" + Then I should see the following output from process "adventure": + """ + You're at end of road again. + """ + When I enter "south" into process "adventure" + Then I should see the following output from process "adventure": + """ + You are in a valley in the forest beside a stream tumbling along a + rocky bed. + """ + When I enter "quit" into process "adventure" + Then the process "adventure" should exit succesfully + + Scenario: play first few rooms (default process) + Given process activity is logged to "greenletters.log" + Given a process from command "adventure" + Given I reply "no" to output "Would you like instructions?" + Given I reply "yes" to output "Do you really want to quit" + When I execute the process + Then I should see the following output: + """ + You are standing at the end of a road before a small brick building. + Around you is a forest. A small stream flows out of the building and + down a gully. + """ + When I enter "east" + Then I should see the following output: + """ + You are inside a building, a well house for a large spring. + """ + When I enter "west" + Then I should see the following output: + """ + You're at end of road again. + """ + When I enter "south" + Then I should see the following output: + """ + You are in a valley in the forest beside a stream tumbling along a + rocky bed. + """ + When I enter "quit" + Then the process should exit succesfully + diff --git a/examples/cucumber/support/env.rb b/examples/cucumber/support/env.rb new file mode 100644 index 0000000..481896d --- /dev/null +++ b/examples/cucumber/support/env.rb @@ -0,0 +1,4 @@ +$:.unshift(File.expand_path('../../../lib', File.dirname(__FILE__))) +require 'greenletters' +require 'greenletters/cucumber_steps' + diff --git a/lib/greenletters.rb b/lib/greenletters.rb index 8031fa8..091474e 100644 --- a/lib/greenletters.rb +++ b/lib/greenletters.rb @@ -1,4 +1,29 @@ +require 'logger' +require 'pty' +require 'forwardable' +require 'stringio' +require 'shellwords' +require 'rbconfig' +require 'strscan' +# A better expect.rb +# +# Implementation note: because of the way PTY is implemented in Ruby, it is +# possible when executing a quick non-interactive command for PTY::ChildExited +# to be raised before ever getting input/output handles to the child +# process. Without an output handle, it's not possible to read any output the +# process produced. This is obviously undesirable, especially since when a +# command is unexpectedly quick and noninteractive it's usually because there +# was an error and you really want to be able to see what the problem was. +# +# Greenletters' solution to this problem is to wrap every command in a short +# script. The script executes the passed command and on termination, outputs an +# easily recognizable marker string. Then it waits for acknowledgment (a +# newline) before exiting. When Greenletters sees the marker string in the +# output, it automatically performs the acknowledgement and allows the child +# process to finish. By forcing the child process to wait for acknowledgement, +# we guarantee that the child will never exit before we have a chance to look at +# the output. module Greenletters # :stopdoc: @@ -56,10 +81,486 @@ def self.require_all_libs_relative_to( fname, dir = nil ) search_me = ::File.expand_path( ::File.join(::File.dirname(fname), dir, '**', '*.rb')) - Dir.glob(search_me).sort.each {|rb| require rb} + Dir.glob(search_me).reject{|fn| fn =~ /cucumber_steps.rb$/}.sort.each {|rb| require rb} end -end # module Greenletters + LogicError = Class.new(::Exception) + SystemError = Class.new(RuntimeError) + TimeoutError = Class.new(SystemError) + ClientError = Class.new(RuntimeError) + StateError = Class.new(ClientError) + + # This class offers a pass-through << operator and saves the most recent 256 + # bytes which have passed through. + class TranscriptHistoryBuffer + attr_reader :buffer + + def initialize(transcript) + @buffer = String.new + @transcript = transcript + end + + def <<(output) + @buffer << output + @transcript << output + length = [@buffer.length, 256].min + @buffer = @buffer[-length, length] + self + end + end + + def Trigger(event, *args, &block) + klass = trigger_class_for_event(event) + klass.new(*args, &block) + end + + def trigger_class_for_event(event) + ::Greenletters.const_get("#{event.to_s.capitalize}Trigger") + end + + class Trigger + attr_accessor :time_to_live + attr_accessor :exclusive + attr_accessor :logger + attr_accessor :interruption + + alias_method :exclusive?, :exclusive + + def initialize(options={}, &block) + @block = block || lambda{} + @exclusive = options.fetch(:exclusive) { false } + @logger = ::Logger.new($stdout) + @interruption = :none + end + + def call(process) + @block.call(process) + true + end + end + + class OutputTrigger < Trigger + def initialize(pattern=//, options={}, &block) + super(options, &block) + @pattern = pattern + end + + def to_s + "output matching #{@pattern.inspect}" + end + + def call(process) + scanner = process.output_buffer + @logger.debug "matching #{@pattern.inspect} against #{scanner.rest.inspect}" + if scanner.scan_until(@pattern) + @logger.debug "matched #{@pattern.inspect}" + @block.call(process, scanner) + true + else + false + end + end + end + + class TimeoutTrigger < Trigger + def to_s + "timeout" + end + + def call(process) + @block.call(process, process.blocker) + true + end + end + + class ExitTrigger < Trigger + attr_reader :pattern + + def initialize(pattern=0, options={}, &block) + super(options, &block) + @pattern = pattern + end + + def call(process) + if pattern === process.status.exitstatus + @block.call(process, process.status) + true + else + false + end + end + + def to_s + "exit with status #{pattern}" + end + end + + class UnsatisfiedTrigger < Trigger + def to_s + "unsatisfied wait" + end + + def call(process) + @block.call(process, process.interruption, process.blocker) + true + end + end + + class Process + END_MARKER = '__GREENLETTERS_PROCESS_ENDED__' + + # Shamelessly stolen from Rake + RUBY_EXT = + ((Config::CONFIG['ruby_install_name'] =~ /\.(com|cmd|exe|bat|rb|sh)$/) ? + "" : + Config::CONFIG['EXEEXT']) + RUBY = File.join( + Config::CONFIG['bindir'], + Config::CONFIG['ruby_install_name'] + RUBY_EXT). + sub(/.*\s.*/m, '"\&"') + + extend Forwardable + include ::Greenletters + + attr_reader :command # Command to run in a subshell + attr_accessor :blocker # The Trigger currently being waited for, if any + attr_reader :input_buffer # Input waiting to be written to process + attr_reader :output_buffer # Output ready to be read from process + attr_reader :status # :not_started -> :running -> :ended -> :exited + attr_reader :cwd # Working directory for the command + + def_delegators :input_buffer, :puts, :write, :print, :printf, :<< + def_delegators :output_buffer, :read, :readpartial, :read_nonblock, :gets, + :getline + def_delegators :blocker, :interruption, :interruption= + + def initialize(*args) + options = if args.last.is_a?(Hash) then args.pop else {} end + @command = args + @triggers = [] + @blocker = nil + @input_buffer = StringIO.new + @output_buffer = StringScanner.new("") + @env = options.fetch(:env) {{}} + @cwd = options.fetch(:cwd) {Dir.pwd} + @logger = options.fetch(:logger) { + l = ::Logger.new($stdout) + l.level = ::Logger::WARN + l + } + @state = :not_started + @shell = options.fetch(:shell) { '/bin/sh' } + @transcript = options.fetch(:transcript) { + t = Object.new + def t.<<(*) + # NOOP + end + t + } + @history = TranscriptHistoryBuffer.new(@transcript) + end + + def on(event, *args, &block) + t = add_trigger(event, *args, &block) + end + + def wait_for(event, *args, &block) + raise "Already waiting for #{blocker}" if blocker + t = add_blocking_trigger(event, *args, &block) + process_events + rescue + unblock! + triggers.delete(t) + raise + end + + def add_trigger(event, *args, &block) + t = Trigger(event, *args, &block) + t.logger = @logger + triggers << t + @logger.debug "added trigger on #{t}" + t + end + + def prepend_trigger(event, *args, &block) + t = Trigger(event, *args, &block) + t.logger = @logger + triggers.unshift(t) + @logger.debug "prepended trigger on #{t}" + t + end + + + def add_blocking_trigger(event, *args, &block) + t = add_trigger(event, *args, &block) + t.time_to_live = 1 + @logger.debug "waiting for #{t}" + self.blocker = t + t + end + + def start! + raise StateError, "Already started!" unless not_started? + @logger.debug "installing end marker handler for #{END_MARKER}" + prepend_trigger(:output, /#{END_MARKER}/, :exclusive => false, :time_to_live => 1) do |process, data| + handle_end_marker + end + handle_child_exit do + cmd = wrapped_command + @logger.debug "executing #{cmd.join(' ')}" + merge_environment(@env) do + @logger.debug "command environment:\n#{ENV.inspect}" + @output, @input, @pid = PTY.spawn(*cmd) + end + @state = :running + @logger.debug "spawned pid #{@pid}" + end + end + + def flush_output_buffer! + @logger.debug "flushing output buffer" + @output_buffer.terminate + end + + def alive? + ::Process.kill(0, @pid) + true + rescue Errno::ESRCH, Errno::ENOENT + false + end + + def blocked? + @blocker + end + + def running? + @state == :running + end + + def not_started? + @state == :not_started + end + + def exited? + @state == :exited + end + + # Have we seen the end marker yet? + def ended? + @state == :ended + end + + private + + attr_reader :triggers + + def wrapped_command + [RUBY, + '-C', cwd, + '-e', "system(*#{command.inspect})", + '-e', "puts(#{END_MARKER.inspect})", + '-e', "gets", + '-e', "exit $?.exitstatus" + ] + end + + def process_events + raise StateError, "Process not started!" if not_started? + handle_child_exit do + while blocked? + @logger.debug "select()" + input_handles = input_buffer.string.empty? ? [] : [@input] + output_handles = [@output] + error_handles = [@input, @output] + @logger.debug "select() on #{[output_handles, input_handles, error_handles].inspect}" + ready_handles = IO.select( + output_handles, input_handles, error_handles, 1.0) + if ready_handles.nil? + process_timeout + else + ready_outputs, ready_inputs, ready_errors = *ready_handles + ready_errors.each do |handle| process_error(handle) end + ready_outputs.each do |handle| process_output(handle) end + ready_inputs.each do |handle| process_input(handle) end + end + end + end + end + + def process_input(handle) + @logger.debug "input ready #{handle.inspect}" + handle.write(input_buffer.string) + @logger.debug format_output_for_log(input_buffer.string) + @logger.debug "wrote #{input_buffer.string.size} bytes" + input_buffer.string = "" + end + + def process_output(handle) + @logger.debug "output ready #{handle.inspect}" + data = handle.readpartial(1024) + output_buffer << data + @history << data + @logger.debug format_input_for_log(data) + @logger.debug "read #{data.size} bytes" + handle_triggers(:output) + flush_triggers!(OutputTrigger) if ended? + # flush_output_buffer! unless ended? + end + + def collect_remaining_output + if @output.nil? + @logger.debug "unable to collect output for missing output handle" + return + end + @logger.debug "collecting remaining output" + while data = @output.read_nonblock(1024) + output_buffer << data + @logger.debug "read #{data.size} bytes" + end + rescue EOFError, Errno::EIO => error + @logger.debug error.message + end + + def wait_for_child_to_die + # Soon we should get a PTY::ChildExited + while running? || ended? + @logger.debug "waiting for child #{@pid} to die" + sleep 0.1 + end + end + + def process_error(handle) + @logger.debug "error on #{handle.inspect}" + raise NotImplementedError, "process_error()" + end + + def process_timeout + @logger.debug "timeout" + handle_triggers(:timeout) + process_interruption(:timeout) + end + + def handle_exit(status=status_from_waitpid) + return false if exited? + @logger.debug "handling exit of process #{@pid}" + @state = :exited + @status = status + handle_triggers(:exit) + if status == 0 + process_interruption(:exit) + else + process_interruption(:abnormal_exit) + end + end + + def status_from_waitpid + @logger.debug "waiting for exist status of #{@pid}" + ::Process.waitpid2(@pid)[1] + end + + def handle_triggers(event) + klass = trigger_class_for_event(event) + matches = 0 + triggers.grep(klass).each do |t| + @logger.debug "checking #{event} against #{t}" + if t.call(self) # match + matches += 1 + @logger.debug "match trigger #{t}" + if blocker.equal?(t) + unblock! + end + if t.time_to_live + if t.time_to_live > 1 + t.time_to_live -= 1 + @logger.debug "trigger ttl reduced to #{t.time_to_live}" + else + triggers.delete(t) + @logger.debug "trigger removed" + end + end + break if t.exclusive? + else + @logger.debug "no match" + end + end + matches > 0 + end + + def handle_end_marker + return false if ended? + @logger.debug "end marker found" + output_buffer.string.gsub!(/#{END_MARKER}\s*/, '') + output_buffer.unscan + @state = :ended + @logger.debug "end marker expunged from output buffer" + @logger.debug "acknowledging end marker" + self.puts + end + + def unblock! + @logger.debug "unblocked" + triggers.delete(@blocker) + @blocker = nil + end + + def handle_child_exit + handle_eio do + yield + end + rescue PTY::ChildExited => error + @logger.debug "caught PTY::ChildExited" + collect_remaining_output + handle_exit(error.status) + end + + def handle_eio + yield + rescue Errno::EIO => error + @logger.debug "Errno::EIO caught" + wait_for_child_to_die + end + + def flush_triggers!(kind) + @logger.debug "flushing triggers matching #{kind}" + triggers.delete_if{|t| kind === t} + end + + def merge_environment(new_env) + old_env = new_env.inject({}) do |old, (key, value)| + old[key] = ENV[key] + ENV[key] = value + old + end + yield + ensure + old_env.each_pair do |key, value| + if value.nil? then ENV.delete(key) else ENV[key] = value end + end + end + + def process_interruption(reason) + if blocked? + self.interruption = reason + unless handle_triggers(:unsatisfied) + raise SystemError, + "Interrupted (#{reason}) while waiting for #{blocker}.\n" \ + "Recent activity:\n" + + @history.buffer + end + unblock! + end + end + + def format_output_for_log(text) + "\n" + text.split("\n").map{|l| ">> #{l}"}.join("\n") + end + + def format_input_for_log(text) + "\n" + text.split("\n").map{|l| "<< #{l}"}.join("\n") + end + + end +end Greenletters.require_all_libs_relative_to(__FILE__) diff --git a/lib/greenletters/cucumber_steps.rb b/lib/greenletters/cucumber_steps.rb new file mode 100644 index 0000000..352a9d9 --- /dev/null +++ b/lib/greenletters/cucumber_steps.rb @@ -0,0 +1,68 @@ +require 'cucumber' + +module Greenletters + module CucumberHelpers + def greenletters_prepare_entry(text) + text.chomp + "\n" + end + def greenletters_massage_pattern(text) + Regexp.new(Regexp.escape(text.strip.tr_s(" \r\n\t", " ")).gsub('\ ', '\s+')) + end + end +end + +World(Greenletters::CucumberHelpers) + +Before do + @greenletters_process_table = Hash.new {|h,k| + raise "No such process defined: #{k}" + } +end + +Given /^process activity is logged to "([^\"]*)"$/ do |filename| + logger = ::Logger.new(open(filename, 'w+')) + #logger.level = ::Logger::INFO + logger.level = ::Logger::DEBUG + @greenletters_process_log = logger +end + +Given /^a process (?:"([^\"]*)" )?from command "([^\"]*)"$/ do |name, command| + name ||= "default" + options = { + } + options[:logger] = @greenletters_process_log if @greenletters_process_log + @greenletters_process_table[name] = Greenletters::Process.new(command, options) +end + +Given /^I reply "([^\"]*)" to output "([^\"]*)"(?: from process "([^\"]*)")?$/ do + |reply, pattern, name| + name ||= "default" + pattern = greenletters_massage_pattern(pattern) + @greenletters_process_table[name].on(:output, pattern) do |process, match_data| + process << greenletters_prepare_entry(reply) + end +end + +When /^I execute the process(?: "([^\"]*)")?$/ do |name| + name ||= "default" + @greenletters_process_table[name].start! +end + +Then /^I should see the following output(?: from process "([^\"]*)")?:$/ do + |name, pattern| + name ||= "default" + pattern = greenletters_massage_pattern(pattern) + @greenletters_process_table[name].wait_for(:output, pattern) +end + +When /^I enter "([^\"]*)"(?: into process "([^\"]*)")?$/ do + |input, name| + name ||= "default" + @greenletters_process_table[name] << greenletters_prepare_entry(input) +end + +Then /^the process(?: "([^\"]*)")? should exit succesfully$/ do |name| + name ||= "default" + @greenletters_process_table[name].wait_for(:exit, 0) +end + diff --git a/script/console b/script/console new file mode 100755 index 0000000..f7c26cf --- /dev/null +++ b/script/console @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +$:.unshift(File.expand_path("../lib", File.dirname(__FILE__))) +require 'greenletters' +require 'irb' +IRB.start diff --git a/version.txt b/version.txt index 77d6f4c..8acdd82 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.0.0 +0.0.1