shiftcare-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8197a28b179e8ad2102311ada3a7b15cfe01f95a0a81ff97937e3d9d682fef09
4
+ data.tar.gz: f490ba1cb22ae90e4b2dda411477193c835260b26c6ebb347e3a3e6c3ce2f06f
5
+ SHA512:
6
+ metadata.gz: 5307120569d6c5d3fc493a13a71384e25f77533156b2658ef6695326fcf732ed88259f3f31f011e74146600d89654b35f21e85358c71b032bbbbf5b7c0be518b
7
+ data.tar.gz: 05baed828c4b22ebdf1941ca0e4d26ee31a8382b62bf1da38b7f6ae9b3bb7ee5ee9479c9cac916617221ef840c2265da0d9b74362f1d7ca2faf0a82f6ff780a3
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-02
4
+
5
+ - First release: `setup`, `regions` (`status`), `list`, `show`, `create`, `update` and `copy`
6
+ against `/internal/product_releases` in au, us, uk, ca and custom regions.
7
+ - Custom `User-Agent`, 1 request/second per region, bounded retry on 429.
8
+ - Config in `~/.config/shiftcare-toolkit/config.yml` (0600), `SHIFTCARE_TOOLKIT_TOKEN_<REGION>` overrides.
data/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # shiftcare-toolkit
2
+
3
+ Internal ShiftCare command-line tools, one namespace per tool. Region tokens are set up
4
+ once and shared by every tool.
5
+
6
+ | Command | What it does |
7
+ |---|---|
8
+ | `shiftcare-toolkit setup` / `regions` | Save and check a token per region (au, us, uk, ca or custom) |
9
+ | `shiftcare-toolkit releases ...` | Product Releases API (`/internal/product_releases`): list, show, create, update, copy between regions |
10
+
11
+ Runtime is the Ruby standard library only. Ruby 3.1 or later.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ gem install shiftcare-toolkit
17
+ shiftcare-toolkit setup
18
+ ```
19
+
20
+ From the git repo:
21
+
22
+ ```sh
23
+ git clone https://github.com/shiftcare/shiftcare-toolkit.git
24
+ cd shiftcare-toolkit
25
+ gem build shiftcare-toolkit.gemspec
26
+ gem install ./shiftcare-toolkit-*.gem --local
27
+ shiftcare-toolkit --help
28
+ ```
29
+
30
+ If someone hands you a built `.gem`, `gem install ./shiftcare-toolkit-0.1.0.gem --local` is enough.
31
+
32
+ To run from a checkout without installing: `bundle install && bundle exec exe/shiftcare-toolkit --help`.
33
+
34
+ ## Regions
35
+
36
+ | Region | Base URL |
37
+ |---|---|
38
+ | `au` (default) | https://app.shiftcare.com |
39
+ | `us` | https://us.shiftcare.com |
40
+ | `uk` | https://uk.shiftcare.com |
41
+ | `ca` | https://ca.shiftcare.com |
42
+
43
+ Add a custom region, such as staging or local, with `setup --region NAME --url URL`.
44
+ Every command takes `--region R`. Without it the CLI uses `default_region` from the
45
+ config file (`au` until you change it).
46
+
47
+ ## Tokens
48
+
49
+ Each region issues its own tokens, and **a token works only in the region that issued it**.
50
+ You need one token per region you want to use.
51
+
52
+ To create one, sign in to that region's super admin console and open
53
+ **Admin -> Admin API tokens** (the key icon in the header) -> **Create token**:
54
+
55
+ - Name: say what and where, for example `Release bot AU (yourname)`.
56
+ - Scope: `product_releases:write`.
57
+ - Expiry: 30 or 90 days.
58
+
59
+ The token (`scadm_...`) is shown once. Copy it straight into `setup`.
60
+ When it expires or is revoked, the CLI reports `401`; create a new one and run `setup` again.
61
+
62
+ ## Setup
63
+
64
+ Interactive, walks au, us, uk and ca. Input is hidden; press Enter to skip a region.
65
+ Each token is checked against the API before it is saved.
66
+
67
+ ```console
68
+ $ shiftcare-toolkit setup
69
+ Paste a token for each region (input is hidden), or press Enter to skip.
70
+ au (https://app.shiftcare.com) token:
71
+ au: ok, saved scadm_7Hk2pQ…
72
+ us (https://us.shiftcare.com) token:
73
+ us: skipped
74
+ ...
75
+ ```
76
+
77
+ Non-interactive, one region at a time. The token comes from stdin, never from an argument,
78
+ so it stays out of your shell history:
79
+
80
+ ```sh
81
+ pbpaste | shiftcare-toolkit setup --region us --token-stdin
82
+ shiftcare-toolkit setup --region uk --token-stdin < uk-token.txt
83
+ ```
84
+
85
+ Custom region:
86
+
87
+ ```sh
88
+ shiftcare-toolkit setup --region staging --url https://staging.example.com
89
+ pbpaste | shiftcare-toolkit setup --region staging --token-stdin
90
+ ```
91
+
92
+ The config lives in `~/.config/shiftcare-toolkit/config.yml` (or
93
+ `$XDG_CONFIG_HOME/shiftcare-toolkit/config.yml`), mode 0600 in a 0700 directory:
94
+
95
+ ```yaml
96
+ default_region: au # edit to change the default
97
+ regions:
98
+ au:
99
+ token: scadm_...
100
+ staging:
101
+ url: https://staging.example.com
102
+ token: scadm_...
103
+ ```
104
+
105
+ `SHIFTCARE_TOOLKIT_TOKEN_<REGION>` overrides the stored token, for example
106
+ `SHIFTCARE_TOOLKIT_TOKEN_AU` or `SHIFTCARE_TOOLKIT_TOKEN_STAGING`. Use it in CI.
107
+
108
+ The CLI never prints a full token: output, errors and `--verbose` logs show the prefix only.
109
+
110
+ ## Commands
111
+
112
+ Run `shiftcare-toolkit help COMMAND` or `shiftcare-toolkit COMMAND --help` for details.
113
+
114
+ Global options: `--region R`, `--verbose` (logs each request to stderr), `--version`.
115
+
116
+ ### regions (alias: status)
117
+
118
+ Every region with its URL, masked token, where the token came from, and a live check
119
+ (`ok`, `401`, `403`, `unreachable`, `no token`).
120
+
121
+ ```console
122
+ $ shiftcare-toolkit regions
123
+ REGION URL TOKEN CHECK
124
+ au https://app.shiftcare.com scadm_7Hk2pQ… (config) ok (42 releases)
125
+ us https://us.shiftcare.com scadm_Lm9xQa… (env) 401
126
+ uk https://uk.shiftcare.com - no token
127
+ ca https://ca.shiftcare.com - no token
128
+ default region: au
129
+ ```
130
+
131
+ ### releases list
132
+
133
+ ```sh
134
+ shiftcare-toolkit releases list # first page, table
135
+ shiftcare-toolkit releases list --region us --status dev --visibility hidden
136
+ shiftcare-toolkit releases list --page 2
137
+ shiftcare-toolkit releases list --all --json > au-releases.json # every page
138
+ ```
139
+
140
+ The table shows key, status, visibility, updated_at and title. 50 releases per page.
141
+
142
+ ### releases show
143
+
144
+ ```sh
145
+ shiftcare-toolkit releases show shift_swaps
146
+ shiftcare-toolkit releases show shift_swaps --region uk --json
147
+ ```
148
+
149
+ ### releases create
150
+
151
+ ```sh
152
+ shiftcare-toolkit releases create --region au --file release.json
153
+ shiftcare-toolkit releases show shift_swaps --json | shiftcare-toolkit releases create --region us --file -
154
+ ```
155
+
156
+ `release.json` holds `key` plus any writable fields. The file can be bare fields,
157
+ `{"product_release": {...}}`, or `show --json` output; other fields are dropped.
158
+
159
+ ```json
160
+ {
161
+ "key": "shift_swaps",
162
+ "title": "Shift swaps",
163
+ "summary": "Workers can swap shifts without calling the office.",
164
+ "categories": ["scheduling"],
165
+ "change_items": ["Request a swap from the app", "Approve swaps in one click"],
166
+ "links": [{ "label": "Help article", "url": "https://help.shiftcare.com/..." }]
167
+ }
168
+ ```
169
+
170
+ New releases are always `status: dev`, `visibility: hidden`. `409` if the key exists
171
+ (including soft-deleted releases), `422` if the release is invalid.
172
+
173
+ ### releases update
174
+
175
+ ```sh
176
+ shiftcare-toolkit releases update shift_swaps --set title="Shift swaps v2"
177
+ shiftcare-toolkit releases update shift_swaps --set change_items="Swap a shift" --set change_items="Approve a swap"
178
+ shiftcare-toolkit releases update shift_swaps --set 'links=[{"label":"Help","url":"https://help.shiftcare.com"}]'
179
+ shiftcare-toolkit releases update shift_swaps --region us --file patch.json
180
+ ```
181
+
182
+ Array fields (`categories`, `change_items`, `flag_keys`) take one item per `--set`, or a JSON
183
+ array. `links` takes a JSON array. `video_duration_seconds` takes an integer. `--set` overrides
184
+ fields from `--file`. Only hidden dev drafts can be updated; anything else is a `409`.
185
+
186
+ ### releases copy
187
+
188
+ Copies releases (key + writable fields) from one region to others.
189
+
190
+ ```sh
191
+ shiftcare-toolkit releases copy --from au --to us,uk,ca --dry-run # see what would change
192
+ shiftcare-toolkit releases copy --from au --to us,uk,ca # create what is missing
193
+ shiftcare-toolkit releases copy --from au --to us,uk,ca --key shift_swaps --key rosters_v2
194
+ shiftcare-toolkit releases copy --from au --to us --update-existing # also update existing drafts
195
+ ```
196
+
197
+ - Keys missing in the target are created as hidden dev drafts.
198
+ - Keys that exist are skipped, unless `--update-existing`: then they are updated if their
199
+ fields differ. A target that is no longer a hidden dev draft answers `409`; it is reported and
200
+ skipped, so published releases are never overwritten.
201
+ - Empty (null) source fields are not sent, so copy never clears a field on the target.
202
+ - It ends with a summary per region and exits 1 if anything failed:
203
+
204
+ ```text
205
+ us: created 3, updated 0, skipped 1, failed 0
206
+ uk: created 4, updated 0, skipped 0, failed 0
207
+ ```
208
+
209
+ ## Writable fields
210
+
211
+ `title summary rich_body owner icon tone help_url video_url video_title
212
+ video_duration_seconds categories[] change_items[] flag_keys[] links[{label,url}]`
213
+
214
+ ## Limits
215
+
216
+ - No delete, no publish, no status or visibility changes, no screenshots or announcements
217
+ through the API. Do those in the super admin console.
218
+ - 60 requests per minute per token. The CLI sends at most one request per second per region
219
+ and retries `429` responses with backoff (up to 5 times).
220
+ - Every write is recorded against the admin who created the token.
221
+
222
+ ## Errors
223
+
224
+ | Code | Meaning |
225
+ |---|---|
226
+ | 401 | Token bad, expired or revoked, or used in the wrong region |
227
+ | 403 | Token lacks the `product_releases:write` scope, or Cloudflare blocked the request (error 1010) |
228
+ | 404 | No release with that key (or it was deleted) |
229
+ | 409 | Key already taken (create), or the release is not a hidden dev draft (update) |
230
+ | 422 | Invalid release or filter; the message lists the errors |
231
+
232
+ ## Development
233
+
234
+ ```sh
235
+ bundle install
236
+ bundle exec rake # runs the tests; none touch the network
237
+ ```
238
+
239
+ The HTTP layer is injected (`Client.new(http: ->(uri, request) { ... })`), and
240
+ `test/test_helper.rb` has a small in-memory fake of the API.
241
+
242
+ ## Adding a tool
243
+
244
+ Add a top-level entry to `COMMANDS` in `lib/shiftcare_toolkit/cli.rb` and dispatch its
245
+ subcommands the way `releases` does (`RELEASE_COMMANDS`). Reuse `Config` for region URLs
246
+ and tokens and `Client` for HTTP (user agent, throttle, 429 retry).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "shiftcare_toolkit"
5
+
6
+ exit ShiftcareToolkit::CLI.new.run(ARGV)
@@ -0,0 +1,577 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "json"
5
+ require "optparse"
6
+ require_relative "client"
7
+ require_relative "config"
8
+ require_relative "version"
9
+
10
+ module ShiftcareToolkit
11
+ class CLI
12
+ class Error < StandardError; end
13
+
14
+ COMMANDS = {
15
+ "setup" => "Save a token per region, verified against the API first",
16
+ "regions" => "List regions, their URLs, masked tokens and a live check (alias: status)",
17
+ "releases" => "Product releases: list, show, create, update, copy",
18
+ }.freeze
19
+ RELEASE_COMMANDS = {
20
+ "list" => "List releases in a region",
21
+ "show" => "Show one release",
22
+ "create" => "Create a hidden dev release from a JSON file",
23
+ "update" => "Update a hidden dev release",
24
+ "copy" => "Copy releases from one region to others",
25
+ }.freeze
26
+ ARRAY_FIELDS = %w[categories change_items flag_keys links].freeze
27
+
28
+ def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr, config: nil, http: nil, min_interval: 1.0,
29
+ sleeper: ->(s) { sleep(s) })
30
+ @stdin = stdin
31
+ @stdout = stdout
32
+ @stderr = stderr
33
+ @config = config
34
+ @http = http
35
+ @min_interval = min_interval
36
+ @sleeper = sleeper
37
+ @clients = {}
38
+ end
39
+
40
+ def run(argv)
41
+ argv = argv.dup
42
+ @opts = { region: nil, verbose: false }
43
+ while argv.first&.start_with?("-")
44
+ case argv.shift
45
+ when "-v", "--version" then return out(VERSION)
46
+ when "-h", "--help" then return out(usage)
47
+ when "--verbose" then @opts[:verbose] = true
48
+ when "--region" then @opts[:region] = argv.shift
49
+ else return fail_with("unknown option. Run `shiftcare-toolkit help`.", 2)
50
+ end
51
+ end
52
+
53
+ command = argv.shift
54
+ command = "regions" if command == "status"
55
+ return help(*argv.first(2)) if command.nil? || command == "help"
56
+ return out(VERSION) if command == "version"
57
+ return fail_with("unknown command #{command}. Run `shiftcare-toolkit help`.", 2) unless COMMANDS.key?(command)
58
+
59
+ if command == "releases"
60
+ command = argv.shift
61
+ return out(releases_usage) if command.nil? || %w[help -h --help].include?(command)
62
+ unless RELEASE_COMMANDS.key?(command)
63
+ return fail_with("unknown command releases #{command}. Run `shiftcare-toolkit releases help`.", 2)
64
+ end
65
+ end
66
+
67
+ parser = parser_for(command)
68
+ args = parser.parse(argv)
69
+ return out(parser.help) if @opts[:help]
70
+
71
+ send("cmd_#{command}", args)
72
+ rescue OptionParser::ParseError => e
73
+ fail_with("#{e.message}. Run `shiftcare-toolkit #{RELEASE_COMMANDS.key?(command) ? "releases " : ""}#{command} --help`.", 2)
74
+ rescue Error, Client::Error, Config::Error, JSON::ParserError, SystemCallError => e
75
+ fail_with(e.message, 1)
76
+ rescue Interrupt
77
+ fail_with("interrupted", 130)
78
+ end
79
+
80
+ private
81
+
82
+ # ---- commands ----
83
+
84
+ def cmd_setup(args)
85
+ raise Error, "setup takes no arguments; pipe the token in with --token-stdin" if args.any?
86
+
87
+ region = @opts[:region]
88
+ unless region
89
+ @stderr.puts "Paste a token for each region (input is hidden), or press Enter to skip."
90
+ @stderr.puts "Create tokens in each region's admin console: Admin -> Admin API tokens."
91
+ config.regions.each { |r| setup_interactive(r) }
92
+ return out("Saved to #{config.path}")
93
+ end
94
+
95
+ config.set(region, url: @opts[:url]) if @opts[:url]
96
+ config.url(region) # fails early for a custom region without a URL
97
+ if @opts[:token_stdin]
98
+ token = @stdin.read.to_s.strip
99
+ raise Error, "no token on stdin" if token.empty?
100
+
101
+ verify(region, token)
102
+ config.set(region, token: token)
103
+ config.save
104
+ out("#{region}: token #{Config.mask(token)} verified and saved to #{config.path}")
105
+ elsif @opts[:url]
106
+ config.save
107
+ out("#{region}: URL #{config.url(region)} saved to #{config.path}")
108
+ else
109
+ setup_interactive(region)
110
+ 0
111
+ end
112
+ end
113
+
114
+ def cmd_regions(_args)
115
+ rows = config.regions.map do |region|
116
+ url = begin
117
+ config.url(region)
118
+ rescue Config::Error
119
+ "-"
120
+ end
121
+ token = config.token(region)
122
+ [region, url, token ? "#{Config.mask(token)} (#{config.token_source(region)})" : "-", check(region, token)]
123
+ end
124
+ table(%w[REGION URL TOKEN CHECK], rows)
125
+ @stdout.puts "default region: #{config.default_region}"
126
+ 0
127
+ end
128
+
129
+ def cmd_list(_args)
130
+ filters = { status: @opts[:status], visibility: @opts[:visibility] }
131
+ if @opts[:all]
132
+ releases = client(region).list_all(**filters)
133
+ total = releases.size
134
+ else
135
+ body = client(region).list(page: @opts[:page] || 1, **filters)
136
+ releases = body["data"]
137
+ meta = body["meta"]
138
+ total = meta["total"]
139
+ end
140
+ return out(JSON.pretty_generate(releases)) if @opts[:json]
141
+
142
+ table(%w[KEY STATUS VISIBILITY UPDATED_AT TITLE],
143
+ releases.map { |r| [r["key"], r["status"], r["visibility"], r["updated_at"], truncate(r["title"], 60)] })
144
+ if meta && meta["page"] * meta["per_page"] < total
145
+ @stdout.puts "page #{meta["page"]}, #{releases.size} of #{total}. Use --page N or --all for more."
146
+ end
147
+ 0
148
+ end
149
+
150
+ def cmd_show(args)
151
+ release = client(region).get(one_key(args))
152
+ return out(JSON.pretty_generate(release)) if @opts[:json]
153
+
154
+ release.each do |field, value|
155
+ @stdout.puts "#{field}: #{value.is_a?(Array) || value.is_a?(Hash) ? JSON.generate(value) : value}"
156
+ end
157
+ 0
158
+ end
159
+
160
+ def cmd_create(args)
161
+ raise Error, "create takes no arguments; put the key in the file" if args.any?
162
+ raise Error, "--file is required" unless @opts[:file]
163
+
164
+ fields = read_release_file(@opts[:file])
165
+ raise Error, "the file needs a \"key\"" if fields["key"].to_s.empty?
166
+
167
+ release = client(region).create(release_fields(fields).merge("key" => fields["key"]))
168
+ out("#{region}: created #{release["key"]} (#{release["status"]}, #{release["visibility"]})")
169
+ end
170
+
171
+ def cmd_update(args)
172
+ key = one_key(args)
173
+ fields = @opts[:file] ? release_fields(read_release_file(@opts[:file])) : {}
174
+ items = Hash.new { |h, k| h[k] = [] }
175
+ (@opts[:set] || []).each do |field, value|
176
+ fields[field] = ARRAY_FIELDS.include?(field) && !value.is_a?(Array) ? (items[field] << value) : value
177
+ end
178
+ raise Error, "nothing to update: pass --file or --set" if fields.empty?
179
+
180
+ release = client(region).update(key, fields)
181
+ out("#{region}: updated #{release["key"]}")
182
+ end
183
+
184
+ def cmd_copy(_args)
185
+ from = @opts[:from] || raise(Error, "--from is required")
186
+ targets = @opts[:to] || raise(Error, "--to is required")
187
+ raise Error, "--to must not include the source region #{from}" if targets.include?(from)
188
+
189
+ source = client(from)
190
+ releases = if @opts[:keys]
191
+ @opts[:keys].map do |key|
192
+ source.get(key)
193
+ rescue Client::Error => e
194
+ raise Error, "#{key}: #{e.message}"
195
+ end
196
+ else
197
+ source.list_all
198
+ end
199
+ @stdout.puts "#{@opts[:dry_run] ? "[dry run] " : ""}copying #{releases.size} release(s) from #{from} to #{targets.join(", ")}"
200
+
201
+ summaries = targets.map { |target| [target, copy_to(target, releases)] }
202
+ @stdout.puts
203
+ summaries.each do |target, c|
204
+ @stdout.puts "#{target}: created #{c[:created]}, updated #{c[:updated]}, skipped #{c[:skipped]}, failed #{c[:failed]}"
205
+ end
206
+ summaries.sum { |_, c| c[:failed] }.zero? ? 0 : 1
207
+ end
208
+
209
+ # ---- copy ----
210
+
211
+ def copy_to(target, releases)
212
+ counts = { created: 0, updated: 0, skipped: 0, failed: 0 }
213
+ existing = begin
214
+ client(target).list_all.to_h { |r| [r["key"], r] }
215
+ rescue Client::Error, Config::Error => e
216
+ @stderr.puts "#{target}: #{e.message}"
217
+ counts[:failed] = releases.size
218
+ return counts
219
+ end
220
+
221
+ releases.each do |release|
222
+ key = release["key"]
223
+ fields = release_fields(release)
224
+ current = existing[key]
225
+ if current.nil?
226
+ write(target, key, counts, :created, "create") { client(target).create(fields.merge("key" => key)) }
227
+ elsif !@opts[:update_existing]
228
+ skip(target, key, counts, "exists (use --update-existing to overwrite)")
229
+ elsif release_fields(current) == fields
230
+ skip(target, key, counts, "unchanged")
231
+ else
232
+ write(target, key, counts, :updated, "update") { client(target).update(key, fields) }
233
+ end
234
+ end
235
+ counts
236
+ end
237
+
238
+ def write(target, key, counts, result, verb)
239
+ if @opts[:dry_run]
240
+ @stdout.puts " #{target} #{key}: would #{verb}"
241
+ else
242
+ yield
243
+ @stdout.puts " #{target} #{key}: #{result}"
244
+ end
245
+ counts[result] += 1
246
+ rescue Client::Error => e
247
+ if e.status == 409
248
+ skip(target, key, counts, "not a hidden dev draft, edit it in the super admin console")
249
+ else
250
+ @stdout.puts " #{target} #{key}: FAILED #{e.message}"
251
+ counts[:failed] += 1
252
+ end
253
+ end
254
+
255
+ def skip(target, key, counts, reason)
256
+ @stdout.puts " #{target} #{key}: skipped, #{reason}"
257
+ counts[:skipped] += 1
258
+ end
259
+
260
+ # ---- helpers ----
261
+
262
+ def setup_interactive(region)
263
+ url = config.url(region)
264
+ loop do
265
+ current = config.token(region)
266
+ @stderr.print "#{region} (#{url}) token#{current ? " [current #{Config.mask(current)}]" : ""}: "
267
+ token = read_secret
268
+ if token.empty?
269
+ @stderr.puts " #{region}: skipped"
270
+ return
271
+ end
272
+ begin
273
+ verify(region, token)
274
+ config.set(region, token: token)
275
+ config.save
276
+ @stderr.puts " #{region}: ok, saved #{Config.mask(token)}"
277
+ return
278
+ rescue Client::Error => e
279
+ @stderr.puts " #{e.message}. Not saved; try again or press Enter to skip."
280
+ end
281
+ end
282
+ end
283
+
284
+ def read_secret
285
+ line = @stdin.tty? && @stdin.respond_to?(:getpass) ? @stdin.getpass("") : @stdin.gets
286
+ line.to_s.strip
287
+ end
288
+
289
+ def verify(region, token)
290
+ @clients.delete(region)
291
+ build_client(region, token).list(page: 1)
292
+ end
293
+
294
+ def check(region, token)
295
+ return "no token" unless token
296
+
297
+ total = client(region).list(page: 1).dig("meta", "total")
298
+ "ok (#{total} releases)"
299
+ rescue Client::Error => e
300
+ e.status ? e.status.to_s : "unreachable"
301
+ rescue Config::Error
302
+ "no url"
303
+ end
304
+
305
+ def region
306
+ @opts[:region] || config.default_region
307
+ end
308
+
309
+ def client(region)
310
+ @clients[region] ||= build_client(region, config.token(region))
311
+ end
312
+
313
+ def build_client(region, token)
314
+ log = @opts[:verbose] ? ->(message) { @stderr.puts(message) } : nil
315
+ Client.new(base_url: config.url(region), token: token, region: region, http: @http,
316
+ min_interval: @min_interval, sleeper: @sleeper, log: log)
317
+ end
318
+
319
+ def config
320
+ @config ||= Config.new
321
+ end
322
+
323
+ def one_key(args)
324
+ raise Error, "expected exactly one KEY" unless args.size == 1
325
+
326
+ args.first
327
+ end
328
+
329
+ # Accepts bare fields, {"product_release": {...}} or `show --json` output.
330
+ def read_release_file(path)
331
+ json = JSON.parse(path == "-" ? @stdin.read : File.read(path))
332
+ json = json["product_release"] || json["data"] || json if json.is_a?(Hash)
333
+ raise Error, "#{path} must hold a JSON object" unless json.is_a?(Hash)
334
+
335
+ json
336
+ end
337
+
338
+ # ponytail: nil fields are dropped, so copy never clears a field on the target.
339
+ def release_fields(release)
340
+ fields = release.slice(*Client::WRITABLE).compact
341
+ fields["links"] = fields["links"].map { |l| l.slice("label", "url") } if fields["links"].is_a?(Array)
342
+ fields
343
+ end
344
+
345
+ def parse_set(pair)
346
+ field, value = pair.split("=", 2)
347
+ raise OptionParser::InvalidArgument, "#{pair} (expected field=value)" if value.nil?
348
+ unless Client::WRITABLE.include?(field)
349
+ raise OptionParser::InvalidArgument, "#{field} (writable: #{Client::WRITABLE.join(", ")})"
350
+ end
351
+
352
+ [field, coerce(field, value)]
353
+ end
354
+
355
+ def coerce(field, value)
356
+ return value.empty? ? nil : Integer(value) if field == "video_duration_seconds"
357
+ return JSON.parse(value) if field == "links" || (ARRAY_FIELDS.include?(field) && value.start_with?("["))
358
+
359
+ value
360
+ rescue ArgumentError, JSON::ParserError
361
+ raise OptionParser::InvalidArgument, "#{field}=#{value}"
362
+ end
363
+
364
+ def table(headers, rows)
365
+ widths = headers.each_index.map { |i| ([headers[i]] + rows.map { |r| r[i].to_s }).map(&:length).max }
366
+ ([headers] + rows).each do |row|
367
+ @stdout.puts row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip
368
+ end
369
+ end
370
+
371
+ def truncate(text, max)
372
+ text = text.to_s
373
+ text.length > max ? "#{text[0, max - 1]}…" : text
374
+ end
375
+
376
+ def out(text)
377
+ @stdout.puts text
378
+ 0
379
+ end
380
+
381
+ def fail_with(message, code)
382
+ @stderr.puts "error: #{message}"
383
+ code
384
+ end
385
+
386
+ def help(command = nil, sub = nil)
387
+ command = "regions" if command == "status"
388
+ return out(usage) unless command
389
+ return fail_with("unknown command #{command}", 2) unless COMMANDS.key?(command)
390
+ return out(parser_for(command).help) unless command == "releases"
391
+ return out(releases_usage) unless sub
392
+ return fail_with("unknown command releases #{sub}", 2) unless RELEASE_COMMANDS.key?(sub)
393
+
394
+ out(parser_for(sub).help)
395
+ end
396
+
397
+ def releases_usage
398
+ <<~TEXT
399
+ Usage: shiftcare-toolkit releases COMMAND [options]
400
+
401
+ Calls the internal Product Releases API (/internal/product_releases).
402
+
403
+ Commands:
404
+ #{RELEASE_COMMANDS.map { |name, text| " #{name.ljust(9)} #{text}" }.join("\n")}
405
+
406
+ Help for one command: shiftcare-toolkit releases copy --help
407
+ TEXT
408
+ end
409
+
410
+ def usage
411
+ <<~TEXT
412
+ Usage: shiftcare-toolkit [--region R] [--verbose] COMMAND [options]
413
+
414
+ Internal ShiftCare tools. Region tokens (au, us, uk, ca or custom) are shared by every command.
415
+
416
+ Commands:
417
+ #{COMMANDS.map { |name, text| " #{name.ljust(9)} #{text}" }.join("\n")}
418
+ help Show help for a command: shiftcare-toolkit help releases copy
419
+
420
+ Global options:
421
+ --region R Region to use (default from config, initially au)
422
+ --verbose Log each request to stderr (tokens are never logged)
423
+ --version Print the version
424
+
425
+ Start with: shiftcare-toolkit setup
426
+ TEXT
427
+ end
428
+
429
+ # ---- option parsers ----
430
+
431
+ def parser_for(command)
432
+ OptionParser.new do |o|
433
+ o.program_name = "shiftcare-toolkit"
434
+ o.require_exact = true # so --token is not read as --token-stdin
435
+ send("options_#{command}", o)
436
+ o.separator ""
437
+ o.separator "Common options:"
438
+ o.on("--region R", "Region (au, us, uk, ca or custom; default #{default_region_label})") { |v| @opts[:region] = v }
439
+ o.on("--verbose", "Log each request to stderr") { @opts[:verbose] = true }
440
+ o.on("-h", "--help", "Show this help") { @opts[:help] = true }
441
+ end
442
+ end
443
+
444
+ def default_region_label
445
+ config.default_region
446
+ rescue StandardError
447
+ "au"
448
+ end
449
+
450
+ def options_setup(o)
451
+ o.banner = "Usage: shiftcare-toolkit setup [--region R (--token-stdin | --url URL)]"
452
+ o.separator <<~TEXT
453
+
454
+ Saves one token per region to #{Config.default_path} (mode 0600), after
455
+ checking it against the API. With no --region it walks au, us, uk and ca and asks
456
+ for each token with hidden input; press Enter to skip a region.
457
+
458
+ A token works only in the region that issued it. Create one in each region's
459
+ admin console: Admin -> Admin API tokens, scope product_releases:write.
460
+
461
+ Examples:
462
+ shiftcare-toolkit setup
463
+ pbpaste | shiftcare-toolkit setup --region us --token-stdin
464
+ shiftcare-toolkit setup --region staging --url https://staging.example.com
465
+ shiftcare-toolkit setup --region staging --token-stdin < token.txt
466
+
467
+ Options:
468
+ TEXT
469
+ o.on("--token-stdin", "Read the token from stdin (tokens are never taken as arguments)") { @opts[:token_stdin] = true }
470
+ o.on("--url URL", "Base URL, for a custom region such as staging or local") { |v| @opts[:url] = v }
471
+ end
472
+
473
+ def options_regions(o)
474
+ o.banner = "Usage: shiftcare-toolkit regions"
475
+ o.separator <<~TEXT
476
+
477
+ Lists every region with its base URL, masked token and where it came from
478
+ (config or env), and a live check: ok, 401, 403 or unreachable.
479
+
480
+ Example:
481
+ shiftcare-toolkit regions
482
+ TEXT
483
+ end
484
+
485
+ def options_list(o)
486
+ o.banner = "Usage: shiftcare-toolkit releases list [--status S] [--visibility V] [--page N | --all] [--json]"
487
+ o.separator <<~TEXT
488
+
489
+ Examples:
490
+ shiftcare-toolkit releases list
491
+ shiftcare-toolkit releases list --region us --status dev --visibility hidden
492
+ shiftcare-toolkit releases list --all --json > au.json
493
+
494
+ Options:
495
+ TEXT
496
+ o.on("--status S", "Filter by status, e.g. dev, beta, live") { |v| @opts[:status] = v }
497
+ o.on("--visibility V", "Filter by visibility, e.g. hidden, public") { |v| @opts[:visibility] = v }
498
+ o.on("--page N", Integer, "Page to fetch (50 per page)") { |v| @opts[:page] = v }
499
+ o.on("--all", "Follow pagination and fetch every page") { @opts[:all] = true }
500
+ o.on("--json", "Print JSON instead of a table") { @opts[:json] = true }
501
+ end
502
+
503
+ def options_show(o)
504
+ o.banner = "Usage: shiftcare-toolkit releases show KEY [--json]"
505
+ o.separator <<~TEXT
506
+
507
+ Examples:
508
+ shiftcare-toolkit releases show shift_swaps
509
+ shiftcare-toolkit releases show shift_swaps --region uk --json
510
+
511
+ Options:
512
+ TEXT
513
+ o.on("--json", "Print JSON") { @opts[:json] = true }
514
+ end
515
+
516
+ def options_create(o)
517
+ o.banner = "Usage: shiftcare-toolkit releases create --file FILE"
518
+ o.separator <<~TEXT
519
+
520
+ Creates a release from a JSON object holding "key" plus writable fields:
521
+ #{Client::WRITABLE.join(", ")}.
522
+ New releases are always status dev and visibility hidden. 409 if the key exists.
523
+
524
+ Examples:
525
+ shiftcare-toolkit releases create --region au --file release.json
526
+ shiftcare-toolkit releases show shift_swaps --json | shiftcare-toolkit releases create --region us --file -
527
+
528
+ Options:
529
+ TEXT
530
+ o.on("--file FILE", "JSON file, or - for stdin") { |v| @opts[:file] = v }
531
+ end
532
+
533
+ def options_update(o)
534
+ o.banner = "Usage: shiftcare-toolkit releases update KEY (--file FILE | --set field=value ...)"
535
+ o.separator <<~TEXT
536
+
537
+ Updates writable fields of a hidden dev draft. 409 once it is published or visible.
538
+ Array fields (categories, change_items, flag_keys) take one item per --set, or a
539
+ JSON array. links takes a JSON array of {"label","url"}.
540
+
541
+ Examples:
542
+ shiftcare-toolkit releases update shift_swaps --set title="Shift swaps v2"
543
+ shiftcare-toolkit releases update shift_swaps --set change_items="Swap a shift" --set change_items="Approve a swap"
544
+ shiftcare-toolkit releases update shift_swaps --set 'links=[{"label":"Help","url":"https://help.shiftcare.com"}]'
545
+ shiftcare-toolkit releases update shift_swaps --region us --file patch.json
546
+
547
+ Options:
548
+ TEXT
549
+ o.on("--file FILE", "JSON file with fields to change, or - for stdin") { |v| @opts[:file] = v }
550
+ o.on("--set FIELD=VALUE", "Set one field (repeatable)") { |v| (@opts[:set] ||= []) << parse_set(v) }
551
+ end
552
+
553
+ def options_copy(o)
554
+ o.banner = "Usage: shiftcare-toolkit releases copy --from R --to R1,R2 [--key K ...] [--update-existing] [--dry-run]"
555
+ o.separator <<~TEXT
556
+
557
+ Copies releases (key + writable fields) from one region to others. Missing keys are
558
+ created as hidden dev drafts. Existing keys are skipped unless --update-existing,
559
+ which updates them; a target that is no longer a hidden draft (409) is reported and
560
+ skipped. Ends with a created/updated/skipped/failed summary per region and exits 1
561
+ if anything failed.
562
+
563
+ Examples:
564
+ shiftcare-toolkit releases copy --from au --to us,uk,ca --dry-run
565
+ shiftcare-toolkit releases copy --from au --to us,uk,ca --key shift_swaps --key rosters_v2
566
+ shiftcare-toolkit releases copy --from au --to us --update-existing
567
+
568
+ Options:
569
+ TEXT
570
+ o.on("--from R", "Source region") { |v| @opts[:from] = v }
571
+ o.on("--to R1,R2", Array, "Target regions, comma separated") { |v| @opts[:to] = v }
572
+ o.on("--key K", "Copy only this key (repeatable; default: every release)") { |v| (@opts[:keys] ||= []) << v }
573
+ o.on("--update-existing", "Update keys that already exist in the target") { @opts[:update_existing] = true }
574
+ o.on("--dry-run", "Show what would change without writing") { @opts[:dry_run] = true }
575
+ end
576
+ end
577
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require_relative "version"
8
+
9
+ module ShiftcareToolkit
10
+ # One region's /internal/product_releases API. `http` is any callable taking
11
+ # (uri, request) and returning something with #code, #body and #[] (header),
12
+ # so tests can pass a fake instead of Net::HTTP.
13
+ class Client
14
+ WRITABLE = %w[
15
+ title summary rich_body owner icon tone help_url video_url video_title
16
+ video_duration_seconds categories change_items flag_keys links
17
+ ].freeze
18
+ MAX_RETRIES = 5
19
+ NETWORK_ERRORS = [
20
+ SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError,
21
+ ].freeze
22
+
23
+ class Error < StandardError
24
+ attr_reader :status
25
+
26
+ def initialize(message, status: nil)
27
+ super(message)
28
+ @status = status
29
+ end
30
+ end
31
+
32
+ # min_interval keeps one client under the 60 req/min per-token limit.
33
+ def initialize(base_url:, token:, region: nil, http: nil, min_interval: 1.0, sleeper: ->(s) { sleep(s) }, log: nil)
34
+ @base_url = base_url.chomp("/")
35
+ @token = token
36
+ @region = region || base_url
37
+ @http = http || method(:net_http)
38
+ @min_interval = min_interval
39
+ @sleeper = sleeper
40
+ @log = log
41
+ @last_request_at = nil
42
+ end
43
+
44
+ def list(page: 1, status: nil, visibility: nil)
45
+ query = { page: page, status: status, visibility: visibility }.compact
46
+ request(:get, "/internal/product_releases?#{URI.encode_www_form(query)}")
47
+ end
48
+
49
+ def list_all(status: nil, visibility: nil)
50
+ releases = []
51
+ (1..).each do |page|
52
+ body = list(page: page, status: status, visibility: visibility)
53
+ releases.concat(body["data"])
54
+ meta = body["meta"]
55
+ break if body["data"].empty? || page * meta["per_page"] >= meta["total"]
56
+ end
57
+ releases
58
+ end
59
+
60
+ def get(key)
61
+ request(:get, path_for(key))["data"]
62
+ end
63
+
64
+ def create(attrs)
65
+ request(:post, "/internal/product_releases", { product_release: attrs })["data"]
66
+ end
67
+
68
+ def update(key, attrs)
69
+ request(:patch, path_for(key), { product_release: attrs })["data"]
70
+ end
71
+
72
+ private
73
+
74
+ def path_for(key)
75
+ "/internal/product_releases/#{URI.encode_www_form_component(key)}"
76
+ end
77
+
78
+ def request(verb, path, payload = nil)
79
+ raise Error, "no token for #{@region}: run `shiftcare-toolkit setup --region #{@region}`" if @token.to_s.empty?
80
+
81
+ uri = URI("#{@base_url}#{path}")
82
+ attempt = 0
83
+ loop do
84
+ response = perform(verb, uri, payload)
85
+ code = response.code.to_i
86
+ if code == 429 && attempt < MAX_RETRIES
87
+ attempt += 1
88
+ wait = retry_after(response, attempt)
89
+ @log&.call("#{@region}: rate limited, retry #{attempt}/#{MAX_RETRIES} in #{wait}s")
90
+ @sleeper.call(wait)
91
+ next
92
+ end
93
+ return parse(response.body) if code.between?(200, 299)
94
+
95
+ raise Error.new(error_message(code, response.body), status: code)
96
+ end
97
+ end
98
+
99
+ def perform(verb, uri, payload)
100
+ throttle
101
+ req = Net::HTTP.const_get(verb.capitalize).new(uri)
102
+ req["Authorization"] = "Bearer #{@token}"
103
+ req["User-Agent"] = "shiftcare-toolkit/#{VERSION}"
104
+ req["Accept"] = "application/json"
105
+ if payload
106
+ req["Content-Type"] = "application/json"
107
+ req.body = JSON.generate(payload)
108
+ end
109
+ started = Time.now
110
+ response = @http.call(uri, req)
111
+ @log&.call("#{verb.upcase} #{uri} -> #{response.code} (#{((Time.now - started) * 1000).round}ms)")
112
+ response
113
+ rescue *NETWORK_ERRORS => e
114
+ raise Error, "#{@region} unreachable (#{uri.host}): #{e.class}: #{e.message}"
115
+ end
116
+
117
+ def throttle
118
+ if @last_request_at && @min_interval.positive?
119
+ wait = @min_interval - (Time.now - @last_request_at)
120
+ @sleeper.call(wait) if wait.positive?
121
+ end
122
+ @last_request_at = Time.now
123
+ end
124
+
125
+ # The API sends no Retry-After today; fall back to 5, 10, 20, 40, 60 s.
126
+ def retry_after(response, attempt)
127
+ header = response["Retry-After"].to_s
128
+ header.match?(/\A\d+\z/) ? header.to_i : [5 * (2**(attempt - 1)), 60].min
129
+ end
130
+
131
+ def parse(body)
132
+ body.to_s.empty? ? {} : JSON.parse(body)
133
+ rescue JSON::ParserError
134
+ raise Error, "#{@region}: response was not JSON"
135
+ end
136
+
137
+ def error_message(code, body)
138
+ detail = begin
139
+ json = JSON.parse(body.to_s)
140
+ json["error"] || Array(json["errors"]).join("; ")
141
+ rescue JSON::ParserError
142
+ nil
143
+ end
144
+ case code
145
+ when 401 then "#{@region}: 401 token rejected (bad, expired or revoked). Tokens work only in the region that issued them."
146
+ when 403
147
+ if detail.nil? && body.to_s.include?("1010")
148
+ "#{@region}: 403 blocked by Cloudflare (error 1010)"
149
+ else
150
+ "#{@region}: 403 #{detail || "forbidden"} (the token needs the product_releases:write scope)"
151
+ end
152
+ when 429 then "#{@region}: 429 still rate limited after #{MAX_RETRIES} retries"
153
+ else "#{@region}: #{code} #{detail.to_s.empty? ? "request failed" : detail}"
154
+ end
155
+ end
156
+
157
+ def net_http(uri, req)
158
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
159
+ http.request(req)
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module ShiftcareToolkit
7
+ # Per-region base URLs and tokens, stored in a 0600 YAML file:
8
+ #
9
+ # default_region: au
10
+ # regions:
11
+ # au: { token: scadm_... }
12
+ # staging: { url: https://staging.example.com, token: scadm_... }
13
+ class Config
14
+ REGIONS = {
15
+ "au" => "https://app.shiftcare.com",
16
+ "us" => "https://us.shiftcare.com",
17
+ "uk" => "https://uk.shiftcare.com",
18
+ "ca" => "https://ca.shiftcare.com",
19
+ }.freeze
20
+ REGION_FORMAT = /\A[a-z0-9_-]+\z/
21
+
22
+ class Error < StandardError; end
23
+
24
+ attr_reader :path
25
+
26
+ def self.default_path(env = ENV)
27
+ base = env["XDG_CONFIG_HOME"].to_s.empty? ? File.join(Dir.home, ".config") : env["XDG_CONFIG_HOME"]
28
+ File.join(base, "shiftcare-toolkit", "config.yml")
29
+ end
30
+
31
+ # Shows the prefix only, the part the admin console also shows.
32
+ def self.mask(token)
33
+ return "-" if token.to_s.empty?
34
+
35
+ "#{token[0, [12, token.length / 3].min]}…"
36
+ end
37
+
38
+ def initialize(path = self.class.default_path, env: ENV)
39
+ @path = path
40
+ @env = env
41
+ @data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
42
+ @data["regions"] ||= {}
43
+ end
44
+
45
+ def default_region
46
+ @data["default_region"] || "au"
47
+ end
48
+
49
+ # Built-in regions first, then custom ones from the file.
50
+ def regions
51
+ (REGIONS.keys + @data["regions"].keys).uniq
52
+ end
53
+
54
+ def url(region)
55
+ url = @data.dig("regions", region, "url") || REGIONS[region]
56
+ raise Error, "unknown region #{region}: run `shiftcare-toolkit setup --region #{region} --url URL`" unless url
57
+
58
+ url.chomp("/")
59
+ end
60
+
61
+ def token(region)
62
+ env_token(region) || @data.dig("regions", region, "token")
63
+ end
64
+
65
+ def token_source(region)
66
+ return "env" if env_token(region)
67
+
68
+ @data.dig("regions", region, "token") ? "config" : nil
69
+ end
70
+
71
+ def set(region, url: nil, token: nil)
72
+ raise Error, "bad region name #{region.inspect}: use lowercase letters, digits, - or _" unless region.match?(REGION_FORMAT)
73
+
74
+ entry = (@data["regions"][region] ||= {})
75
+ entry["url"] = url.chomp("/") if url
76
+ entry["token"] = token if token
77
+ end
78
+
79
+ def save
80
+ dir = File.dirname(path)
81
+ FileUtils.mkdir_p(dir, mode: 0o700)
82
+ File.chmod(0o700, dir)
83
+ tmp = "#{path}.tmp"
84
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(YAML.dump(@data)) }
85
+ File.chmod(0o600, tmp)
86
+ File.rename(tmp, path)
87
+ end
88
+
89
+ private
90
+
91
+ def env_token(region)
92
+ value = @env["SHIFTCARE_TOOLKIT_TOKEN_#{region.upcase.tr("-", "_")}"]
93
+ value unless value.to_s.empty?
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShiftcareToolkit
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shiftcare_toolkit/version"
4
+ require_relative "shiftcare_toolkit/config"
5
+ require_relative "shiftcare_toolkit/client"
6
+ require_relative "shiftcare_toolkit/cli"
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shiftcare-toolkit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ShiftCare
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Internal ShiftCare tools. First tool: list, show, create, update and
14
+ copy product releases across the au, us, uk and ca regions.'
15
+ email:
16
+ executables:
17
+ - shiftcare-toolkit
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - README.md
23
+ - exe/shiftcare-toolkit
24
+ - lib/shiftcare_toolkit.rb
25
+ - lib/shiftcare_toolkit/cli.rb
26
+ - lib/shiftcare_toolkit/client.rb
27
+ - lib/shiftcare_toolkit/config.rb
28
+ - lib/shiftcare_toolkit/version.rb
29
+ homepage: https://github.com/shiftcare/shiftcare-toolkit
30
+ licenses:
31
+ - Nonstandard
32
+ metadata:
33
+ allowed_push_host: https://rubygems.org
34
+ source_code_uri: https://github.com/shiftcare/shiftcare-toolkit
35
+ changelog_uri: https://github.com/shiftcare/shiftcare-toolkit/blob/main/CHANGELOG.md
36
+ post_install_message:
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.1'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.4.19
52
+ signing_key:
53
+ specification_version: 4
54
+ summary: Internal ShiftCare command-line tools
55
+ test_files: []