From d3f09788dc2d5624f4cb95aef64c9f049e50e8bb Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 5 Jun 2026 10:54:51 +0100 Subject: [PATCH] feat: add dev open scripts and shared CheckoutKit::CLI lib --- .dev.env.example | 16 +++ .gitignore | 3 + dev.yml | 51 +++++++ scripts/dev-open | 44 ++++++ scripts/dev_open/commands/android.rb | 29 ++++ scripts/dev_open/commands/base.rb | 57 ++++++++ scripts/dev_open/commands/react_native.rb | 33 +++++ scripts/dev_open/commands/swift.rb | 29 ++++ scripts/dev_open/commands/web.rb | 29 ++++ scripts/dev_open/support/editor.rb | 27 ++++ scripts/dev_open/support/error.rb | 9 ++ scripts/lib/checkout_kit/cli.rb | 20 +++ .../lib/checkout_kit/cli/config_summary.rb | 40 ++++++ scripts/lib/checkout_kit/cli/dotenv.rb | 45 ++++++ scripts/lib/checkout_kit/cli/env.rb | 25 ++++ scripts/lib/checkout_kit/cli/error.rb | 9 ++ scripts/lib/checkout_kit/cli/json_file.rb | 17 +++ scripts/lib/checkout_kit/cli/redaction.rb | 21 +++ scripts/lib/checkout_kit/cli/shell.rb | 128 ++++++++++++++++++ 19 files changed, 632 insertions(+) create mode 100644 .dev.env.example create mode 100755 scripts/dev-open create mode 100644 scripts/dev_open/commands/android.rb create mode 100644 scripts/dev_open/commands/base.rb create mode 100644 scripts/dev_open/commands/react_native.rb create mode 100644 scripts/dev_open/commands/swift.rb create mode 100644 scripts/dev_open/commands/web.rb create mode 100644 scripts/dev_open/support/editor.rb create mode 100644 scripts/dev_open/support/error.rb create mode 100644 scripts/lib/checkout_kit/cli.rb create mode 100644 scripts/lib/checkout_kit/cli/config_summary.rb create mode 100644 scripts/lib/checkout_kit/cli/dotenv.rb create mode 100644 scripts/lib/checkout_kit/cli/env.rb create mode 100644 scripts/lib/checkout_kit/cli/error.rb create mode 100644 scripts/lib/checkout_kit/cli/json_file.rb create mode 100644 scripts/lib/checkout_kit/cli/redaction.rb create mode 100644 scripts/lib/checkout_kit/cli/shell.rb diff --git a/.dev.env.example b/.dev.env.example new file mode 100644 index 00000000..8d977a71 --- /dev/null +++ b/.dev.env.example @@ -0,0 +1,16 @@ +### Local developer tooling overrides for `dev` commands. +### +### Enable overrides: `cp .dev.env.example .dev.env` +### +### This file is NOT for sample app configuration. Keep storefront values in .env. +### +### Unlike .env, this file is never generated or rewritten by tooling, so your +### overrides here are safe. +### ------------------------------------------------------------------------------ + +### Editor used by `dev react-native open` and `dev web open` to open JS +### workspaces. This file is the only source for DEV_EDITOR; setting it inline +### (e.g. `DEV_EDITOR=code dev rn open`) is NOT picked up. Accepts any command on +### your PATH, e.g. code, cursor, subl, zed, webstorm, nvim. When unset, `dev` +### falls back to `open` (macOS default app). +DEV_EDITOR=code diff --git a/.gitignore b/.gitignore index a9ad5a15..ec801816 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ captures/ # Android local config / secrets local.properties .env + +# Local developer tooling preferences (see .dev.env.example) +.dev.env upload-keystore.jks diff --git a/dev.yml b/dev.yml index ea48140c..ae359b63 100644 --- a/dev.yml +++ b/dev.yml @@ -190,6 +190,18 @@ commands: desc: Clean Android Gradle build outputs run: cd platforms/android && ./gradlew clean + open: + desc: Open the Android library or sample app in Android Studio + long_desc: | + Opens an Android project in Android Studio. + + dev android open Open the demo app (default) + dev android open demo Open the demo app (aliases: sample, samples) + dev android open lib Open the Android SDK library project (alias: library) + syntax: + optional: "[lib|demo]" + run: ./scripts/dev-open android "$@" + api: desc: Validate or update the public API baseline (lib/api/lib.api) run: | @@ -263,6 +275,18 @@ commands: desc: Run Swift lint checks run: /opt/dev/bin/dev swift lint + open: + desc: Open the Swift package or sample app in Xcode + long_desc: | + Opens a Swift workspace in Xcode via `xed`. + + dev swift open Open the demo app workspace (default) + dev swift open demo Open the demo app workspace (aliases: sample, samples) + dev swift open lib Open the ShopifyCheckoutKit Swift package (aliases: library, package) + syntax: + optional: "[lib|demo]" + run: ./scripts/dev-open swift "$@" + clean: desc: Clean Swift packages and sample app build artifacts run: | @@ -416,6 +440,20 @@ commands: optional: --local run: cd platforms/react-native && pnpm run pod-install -- "$@" + open: + desc: Open the React Native workspace root or native sample projects + long_desc: | + Opens platforms/react-native in the editor set by DEV_EDITOR in + .dev.env, otherwise `open` (see .dev.env.example). The --ios and + --android flags open the sample app's native projects. + + dev rn open Open platforms/react-native + dev rn open --ios Open the sample iOS workspace in Xcode + dev rn open --android Open the sample Android project in Android Studio + syntax: + optional: "[--ios|--android]" + run: ./scripts/dev-open react-native "$@" + rn: desc: "Alias for React Native Checkout Kit commands" long_desc: | @@ -484,3 +522,16 @@ commands: verify: desc: Run Web package verification run: cd platforms/web && pnpm verify + + open: + desc: Open the Web package or sample app in an editor + long_desc: | + Opens a Web workspace in the editor set by DEV_EDITOR in .dev.env, + otherwise `open` (see .dev.env.example). + + dev web open Open the sample app (default) + dev web open demo Open the sample app (aliases: sample, samples) + dev web open lib Open the @shopify/checkout-kit package (aliases: library, package) + syntax: + optional: "[lib|demo]" + run: ./scripts/dev-open web "$@" diff --git a/scripts/dev-open b/scripts/dev-open new file mode 100755 index 00000000..a9819670 --- /dev/null +++ b/scripts/dev-open @@ -0,0 +1,44 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'English' + +$LOAD_PATH.unshift(File.expand_path('lib', __dir__)) + +require 'checkout_kit/cli' + +require_relative 'dev_open/commands/swift' +require_relative 'dev_open/commands/android' +require_relative 'dev_open/commands/react_native' +require_relative 'dev_open/commands/web' + +module DevOpen + COMMANDS = { + 'swift' => Commands::Swift, + 'android' => Commands::Android, + 'react-native' => Commands::ReactNative, + 'rn' => Commands::ReactNative, + 'web' => Commands::Web, + }.freeze +end + +repo_root = File.expand_path('..', __dir__) +platform = ARGV.shift +command = DevOpen::COMMANDS[platform] + +begin + if command.nil? + warn "Usage: #{File.basename($PROGRAM_NAME)} [lib|demo] [flags]" + exit 1 + end + + command.run(ARGV, repo_root: repo_root) +rescue DevOpen::UsageError => e + warn e.message + exit 1 +rescue Interrupt + exit 130 +rescue CheckoutKit::CLI::Error => e + warn "ERROR: #{e.message}" + exit 1 +end diff --git a/scripts/dev_open/commands/android.rb b/scripts/dev_open/commands/android.rb new file mode 100644 index 00000000..0c0ed704 --- /dev/null +++ b/scripts/dev_open/commands/android.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DevOpen + module Commands + # Opens the Android library or sample app in Android Studio. + class Android < Base + DEMO = 'platforms/android/samples/CheckoutKitAndroidDemo' + LIB = 'platforms/android' + + def run(argv) + arg = argv.shift + raise UsageError, usage unless argv.empty? + + case target(arg) + when :demo then open_in_android_studio(DEMO) + when :lib then open_in_android_studio(LIB) + end + end + + private + + def usage + 'Usage: dev android open [lib|demo]' + end + end + end +end diff --git a/scripts/dev_open/commands/base.rb b/scripts/dev_open/commands/base.rb new file mode 100644 index 00000000..a7e63ad1 --- /dev/null +++ b/scripts/dev_open/commands/base.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'checkout_kit/cli' + +require_relative '../support/error' +require_relative '../support/editor' + +module DevOpen + module Commands + # Shared behaviour for `dev open` commands: target aliasing, + # path resolution against the repo root, and the open mechanisms. + class Base + DEMO_ALIASES = %w[demo sample samples].freeze + LIB_ALIASES = %w[lib library package module].freeze + + def self.run(argv, repo_root:) + new(repo_root).run(argv) + end + + def initialize(repo_root) + @repo_root = repo_root + end + + private + + attr_reader :repo_root + + # Maps a positional argument to :demo (default) or :lib. + def target(arg) + return :demo if arg.nil? || DEMO_ALIASES.include?(arg) + return :lib if LIB_ALIASES.include?(arg) + + raise UsageError, usage + end + + # Resolves a path relative to the repo root, ensuring it exists. + def path(relative) + absolute = File.join(repo_root, relative) + raise CheckoutKit::CLI::Error, "Path not found: #{relative}" unless File.exist?(absolute) + + absolute + end + + def open_in_editor(relative) + CheckoutKit::CLI::Shell.launch(*Editor.command(repo_root), path(relative)) + end + + def open_in_xcode(relative) + CheckoutKit::CLI::Shell.launch('xed', path(relative)) + end + + def open_in_android_studio(relative) + CheckoutKit::CLI::Shell.launch('open', '-a', 'Android Studio', path(relative)) + end + end + end +end diff --git a/scripts/dev_open/commands/react_native.rb b/scripts/dev_open/commands/react_native.rb new file mode 100644 index 00000000..11acbf10 --- /dev/null +++ b/scripts/dev_open/commands/react_native.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DevOpen + module Commands + # Opens the React Native workspace root in the configured editor, or the + # sample app's native project in Xcode / Android Studio. + class ReactNative < Base + WORKSPACE = 'platforms/react-native' + SAMPLE = 'platforms/react-native/sample' + SAMPLE_IOS = "#{SAMPLE}/ios/CheckoutKitReactNativeDemo.xcworkspace".freeze + SAMPLE_ANDROID = "#{SAMPLE}/android".freeze + + def run(argv) + raise UsageError, usage if argv.size > 1 + + case argv.first + when nil then open_in_editor(WORKSPACE) + when '--ios' then open_in_xcode(SAMPLE_IOS) + when '--android' then open_in_android_studio(SAMPLE_ANDROID) + else raise UsageError, usage + end + end + + private + + def usage + 'Usage: dev rn open [--ios|--android]' + end + end + end +end diff --git a/scripts/dev_open/commands/swift.rb b/scripts/dev_open/commands/swift.rb new file mode 100644 index 00000000..91fad3ef --- /dev/null +++ b/scripts/dev_open/commands/swift.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DevOpen + module Commands + # Opens the Swift package or sample app workspace in Xcode via `xed`. + class Swift < Base + DEMO = 'platforms/swift/Samples/Samples.xcworkspace' + LIB = 'platforms/swift' + + def run(argv) + arg = argv.shift + raise UsageError, usage unless argv.empty? + + case target(arg) + when :demo then open_in_xcode(DEMO) + when :lib then open_in_xcode(LIB) + end + end + + private + + def usage + 'Usage: dev swift open [lib|demo]' + end + end + end +end diff --git a/scripts/dev_open/commands/web.rb b/scripts/dev_open/commands/web.rb new file mode 100644 index 00000000..5d37f7f5 --- /dev/null +++ b/scripts/dev_open/commands/web.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative 'base' + +module DevOpen + module Commands + # Opens the Web package or sample app in the configured editor. + class Web < Base + DEMO = 'platforms/web/sample' + LIB = 'platforms/web' + + def run(argv) + arg = argv.shift + raise UsageError, usage unless argv.empty? + + case target(arg) + when :demo then open_in_editor(DEMO) + when :lib then open_in_editor(LIB) + end + end + + private + + def usage + 'Usage: dev web open [lib|demo]' + end + end + end +end diff --git a/scripts/dev_open/support/editor.rb b/scripts/dev_open/support/editor.rb new file mode 100644 index 00000000..013c4425 --- /dev/null +++ b/scripts/dev_open/support/editor.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'shellwords' +require 'checkout_kit/cli' + +module DevOpen + # Resolves the editor used to open JS workspaces (React Native, Web). + # + # Configured solely via DEV_EDITOR in /.dev.env (see .dev.env.example). + # Setting DEV_EDITOR inline (`DEV_EDITOR=code dev rn open`) is intentionally + # NOT consulted, so behaviour is consistent regardless of how `dev` is invoked. + module Editor + DEFAULT = 'open' + + module_function + + # The editor as an argv array, e.g. ["code"] or ["code", "-n"]. + def command(repo_root) + Shellwords.split(resolve(repo_root)) + end + + def resolve(repo_root) + value = CheckoutKit::CLI::Dotenv.read(File.join(repo_root, '.dev.env'))['DEV_EDITOR'] + value.nil? || value.empty? ? DEFAULT : value + end + end +end diff --git a/scripts/dev_open/support/error.rb b/scripts/dev_open/support/error.rb new file mode 100644 index 00000000..14822ffe --- /dev/null +++ b/scripts/dev_open/support/error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'checkout_kit/cli' + +module DevOpen + # Raised when arguments are invalid; the message is the command usage line. + # Inherits the shared CLI error so the entrypoint can rescue a single type. + UsageError = Class.new(CheckoutKit::CLI::Error) +end diff --git a/scripts/lib/checkout_kit/cli.rb b/scripts/lib/checkout_kit/cli.rb new file mode 100644 index 00000000..ee5564cd --- /dev/null +++ b/scripts/lib/checkout_kit/cli.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Shared support modules for Checkout Kit's Ruby CLIs (scripts/dev-open, +# platforms/react-native/scripts/validate_release, ...). +# +# Add scripts/lib to the load path from an entrypoint, then `require +# 'checkout_kit/cli'` to pull in everything below. + +require_relative 'cli/error' +require_relative 'cli/env' +require_relative 'cli/dotenv' +require_relative 'cli/redaction' +require_relative 'cli/shell' +require_relative 'cli/json_file' +require_relative 'cli/config_summary' + +module CheckoutKit + module CLI + end +end diff --git a/scripts/lib/checkout_kit/cli/config_summary.rb b/scripts/lib/checkout_kit/cli/config_summary.rb new file mode 100644 index 00000000..97ea80a0 --- /dev/null +++ b/scripts/lib/checkout_kit/cli/config_summary.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative 'redaction' +require_relative 'shell' + +module CheckoutKit + module CLI + module ConfigSummary + SENSITIVE_KEY_PATTERN = /(ACCESS_TOKEN|TOKEN|SECRET|PASSWORD|CHECKOUT_URL|MERCHANT_IDENTIFIER|SHOP_ID|ACCOUNT_ID|CLIENT_ID|AUTH|KEY)/i + + module_function + + def print(title, values:) + return unless Shell.verbose? + + Shell.section(title) + puts 'Resolved values:' + values.each do |key, value| + puts " #{key}: #{format_value(key, value)}" + end + end + + def format_value(key, value) + return 'missing' if value.nil? || value == '' + + return value.map { |item| format_value(key, item) }.join(', ') if value.is_a?(Array) + return value.to_h { |nested_key, nested_value| [nested_key, format_value(nested_key, nested_value)] }.inspect if value.is_a?(Hash) + + string_value = value.to_s + return string_value unless sensitive_key?(key) + + Redaction.configured_summary(string_value, label: key.to_s) + end + + def sensitive_key?(key) + key.to_s.match?(SENSITIVE_KEY_PATTERN) + end + end + end +end diff --git a/scripts/lib/checkout_kit/cli/dotenv.rb b/scripts/lib/checkout_kit/cli/dotenv.rb new file mode 100644 index 00000000..3f1a99c0 --- /dev/null +++ b/scripts/lib/checkout_kit/cli/dotenv.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module CheckoutKit + module CLI + # Minimal reader for KEY=value files such as .env / .dev.env. Comments, blank + # lines, `export ` prefixes, and surrounding quotes are handled; everything + # else is left untouched. + module Dotenv + module_function + + def read(path) + return {} unless File.file?(path) + + File.readlines(path, chomp: true).each_with_object({}) do |line, values| + key, value = parse_line(line) + values[key] = value if key + end + end + + def parse_line(line) + stripped = line.strip + return nil if stripped.empty? || stripped.start_with?('#') + + stripped = stripped.delete_prefix('export ').strip + key, value = stripped.split('=', 2) + return nil unless key && value + + key = key.strip + return nil unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) + + [key, unquote(value.strip)] + end + + def unquote(value) + return value[1...-1] if quoted?(value, '"') || quoted?(value, "'") + + value + end + + def quoted?(value, char) + value.length >= 2 && value.start_with?(char) && value.end_with?(char) + end + end + end +end diff --git a/scripts/lib/checkout_kit/cli/env.rb b/scripts/lib/checkout_kit/cli/env.rb new file mode 100644 index 00000000..cfc03c81 --- /dev/null +++ b/scripts/lib/checkout_kit/cli/env.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative 'error' + +module CheckoutKit + module CLI + module Env + TRUE_VALUES = %w[1 true yes on].freeze + FALSE_VALUES = %w[0 false no off].freeze + + module_function + + def bool(name, default) + value = ENV[name] + return default if value.nil? || value.empty? + + normalized = value.downcase + return true if TRUE_VALUES.include?(normalized) + return false if FALSE_VALUES.include?(normalized) + + raise Error, "#{name} must be one of #{(TRUE_VALUES + FALSE_VALUES).join(', ')}" + end + end + end +end diff --git a/scripts/lib/checkout_kit/cli/error.rb b/scripts/lib/checkout_kit/cli/error.rb new file mode 100644 index 00000000..ad6a1207 --- /dev/null +++ b/scripts/lib/checkout_kit/cli/error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module CheckoutKit + module CLI + # Base error for CLI failures. CLIs can rescue this to print a friendly + # message and exit non-zero. + Error = Class.new(StandardError) + end +end diff --git a/scripts/lib/checkout_kit/cli/json_file.rb b/scripts/lib/checkout_kit/cli/json_file.rb new file mode 100644 index 00000000..a370b4f5 --- /dev/null +++ b/scripts/lib/checkout_kit/cli/json_file.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'json' + +module CheckoutKit + module CLI + module JsonFile + module_function + + def update(path) + data = JSON.parse(File.read(path)) + yield data + File.write(path, "#{JSON.pretty_generate(data)}\n") + end + end + end +end diff --git a/scripts/lib/checkout_kit/cli/redaction.rb b/scripts/lib/checkout_kit/cli/redaction.rb new file mode 100644 index 00000000..29e06ddb --- /dev/null +++ b/scripts/lib/checkout_kit/cli/redaction.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'digest' + +module CheckoutKit + module CLI + module Redaction + module_function + + def configured_summary(value, label: 'value') + return 'missing' if value.nil? || value.empty? + + "configured (#{label} redacted, sha256:#{Digest::SHA256.hexdigest(value)[0, 8]})" + end + + def presence(value) + value.nil? || value.empty? ? 'missing' : 'configured' + end + end + end +end diff --git a/scripts/lib/checkout_kit/cli/shell.rb b/scripts/lib/checkout_kit/cli/shell.rb new file mode 100644 index 00000000..02df7aea --- /dev/null +++ b/scripts/lib/checkout_kit/cli/shell.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'English' +require 'fileutils' +require 'shellwords' +require 'tmpdir' + +require_relative 'error' + +module CheckoutKit + module CLI + # Process helpers shared across the CLIs. + # + # - `launch` spawns an interactive command (editor/IDE) inheriting stdio. + # - `run` / `capture` run commands, streaming to a log file unless verbose. + # + # The log directory is configurable so callers (e.g. validate_release) can + # route logs into their own workspace; it defaults to a temp location. + module Shell + TRUE_VALUES = %w[1 true yes on].freeze + + @verbose = TRUE_VALUES.include?(ENV.fetch('VERBOSE', '').downcase) + @log_dir = nil + + module_function + + def verbose=(value) + @verbose = value + end + + def verbose? + @verbose + end + + def log_dir=(path) + @log_dir = path + end + + def log_dir + @log_dir ||= File.join(Dir.tmpdir, 'checkout-kit-cli', 'logs') + end + + def section(message) + puts "\n==> #{message}" + end + + def detail(message) + puts message if verbose? + end + + def command_string(command) + command.map { |part| part.to_s.shellescape }.join(' ') + end + + # Launches an interactive command (inherits stdio) and returns once it has + # been spawned. GUI openers return immediately; terminal editors block, + # which is the expected behaviour for the user who chose them. + def launch(*command) + puts "==> #{command_string(command)}" + return if system(*command.map(&:to_s)) + + raise Error, "Command failed: #{command_string(command)}" + end + + def run(*command, chdir: nil, env: {}, display: nil) + if verbose? + puts "$ #{display || command_string(command)}" + ok = run_command(command, chdir: chdir, env: env) + else + ok = run_quietly(command, chdir: chdir, env: env) + end + return if ok + + status = $CHILD_STATUS&.exitstatus + suffix = status ? " (exit #{status})" : '' + raise Error, "Command failed#{suffix}: #{display || command_string(command)}" + end + + def capture(*command, chdir: nil, env: {}) + output = if chdir + IO.popen(env, command.map(&:to_s), chdir: chdir.to_s, err: %i[child out], &:read) + else + IO.popen(env, command.map(&:to_s), err: %i[child out], &:read) + end + status = $CHILD_STATUS + return output if status.success? + + raise Error, "Command failed (exit #{status.exitstatus}): #{command_string(command)}\n#{output}" + end + + def command?(binary) + system('command', '-v', binary.to_s, out: File::NULL, err: File::NULL) + end + + def require_command!(binary, message = nil) + return if command?(binary) + + raise(Error, message || "Required command not found: #{binary}") + end + + def run_command(command, chdir:, env: {}, out: nil, err: nil) + options = {} + options[:chdir] = chdir.to_s if chdir + options[:out] = out if out + options[:err] = err if err + system(env, *command.map(&:to_s), **options) + end + + def run_quietly(command, chdir:, env: {}) + FileUtils.mkdir_p(log_dir) + stdout_path = File.join(log_dir, "stdout.#{$PROCESS_ID}.#{rand(1_000_000)}.log") + ok = nil + + File.open(stdout_path, 'w') do |stdout| + ok = run_command(command, chdir: chdir, env: env, out: stdout, err: STDERR) + end + + if ok + FileUtils.rm_f(stdout_path) + else + warn "Command stdout was written to: #{stdout_path}" + end + + ok + end + end + end +end