featureparity 0.0.21 → 0.0.23

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.
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp showroom --project <slug> - the client showroom, as a client will see it
6
+ #
7
+ # The showroom (Fizzy #1479) is the one page a client gets: the welcome note, then
8
+ # a card per surface with a link, a button label, and instructions. Its content is
9
+ # written through `fp projects edit --showroom-welcome` and `fp surfaces edit
10
+ # --showroom-*`, and this command is how an agent checks the result without a
11
+ # browser: the same payload the client is served, plus the surfaces that are *not*
12
+ # on it and why, so a card that silently dropped out (both fields cleared) is
13
+ # noticed.
14
+ #
15
+ # Whether the share link is on is a human's business — the API refuses an agent's
16
+ # key — so it is reported as unknown rather than guessed at.
17
+ #
18
+ # `fp showroom media ...` manages the screenshots and videos on the showroom — a
19
+ # surface's card, or the gallery under the welcome note — and is handled by
20
+ # ShowroomMedia; its flags are documented here so `fp showroom --help` covers both.
21
+ class Showroom < Base
22
+ help_summary 'Preview the client showroom exactly as a client will see it, and manage its media'
23
+ help_usage 'fp showroom --project <slug>',
24
+ 'fp showroom media --project <slug>',
25
+ 'fp showroom media add <file> --project <slug> [--surface <key>] [--caption "..."] [--position N]',
26
+ 'fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]',
27
+ 'fp showroom media remove <id> --project <slug> [--yes]'
28
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
29
+ help_flag :surface, "media add: the surface whose card shows it (omit for the project gallery)"
30
+ help_flag :caption, 'media add/edit: text shown under the image or video (edit: empty clears)'
31
+ help_flag :position, 'media add/edit: slot within its card or the gallery, 1 is first; others shift (edit: empty moves it last)'
32
+ help_flag :yes, 'media remove: skip the typed-filename confirmation'
33
+ help_example 'fp showroom --project stowzilla'
34
+ help_example 'fp showroom media add e2e/checkout.webm --project stowzilla --surface web --caption "Checking out"'
35
+ help_example 'fp showroom media add overview.png --project stowzilla --position 1'
36
+ help_example 'fp showroom media edit <id> --project stowzilla --caption ""'
37
+ help_example 'fp showroom media remove <id> --project stowzilla --yes'
38
+ help_note "Prints the welcome note, the gallery, and each surface's card, then the surfaces that are NOT shown and why.\n" \
39
+ "Write the content with `fp projects edit --showroom-welcome` and `fp surfaces edit --showroom-*`.\n" \
40
+ "Media: png, jpg, gif, webp, mp4, webm, mov; max 100 MB. SVG and PDF are refused.\n" \
41
+ 'The share link is reported as unknown: only a person can see or change it.'
42
+ def run(args)
43
+ if args.first == 'media'
44
+ return ShowroomMedia.new(client: client, output: output, config: config).run(args.drop(1))
45
+ end
46
+
47
+ opts, = parse_flags(args)
48
+ require_project!(opts)
49
+
50
+ project_id = resolve_project_id(opts[:project])
51
+
52
+ result = client.get_showroom(project_id)
53
+ unless result[:ok]
54
+ output.error(result[:error], status: result[:status])
55
+ exit 1
56
+ end
57
+
58
+ showroom = result[:data]['showroom'] || {}
59
+ shown_ids = (showroom['surfaces'] || []).map { |surface| surface['id'] }
60
+ hidden = hidden_surfaces(project_id, shown_ids)
61
+ share = share_link_status(project_id)
62
+
63
+ output.success({ showroom: showroom, hidden: hidden, share_link: share, project: opts[:project] }) do
64
+ print_showroom(showroom, hidden, share, project: opts[:project])
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # Surfaces with no link, no instructions, and no media. The showroom payload leaves
71
+ # them out (and includes a media-only surface), so whatever the payload doesn't
72
+ # carry is hidden; they are read from the surface list.
73
+ def hidden_surfaces(project_id, shown_ids)
74
+ result = client.list_surfaces(project_id)
75
+ return [] unless result[:ok]
76
+
77
+ (result[:data]['surfaces'] || []).reject { |surface| shown_ids.include?(surface['id']) }.map do |surface|
78
+ { 'key' => surface['key'], 'name' => surface['name'], 'kind' => surface['kind'],
79
+ 'reason' => 'no showroom link or instructions, and no media' }
80
+ end
81
+ end
82
+
83
+ # { 'state' => 'on' | 'off' | 'unknown', 'token' => ..., 'reason' => ... }
84
+ def share_link_status(project_id)
85
+ result = client.get_showroom_share_link(project_id)
86
+ if result[:ok]
87
+ link = result[:data]['share_link']
88
+ link ? { 'state' => 'on', 'token' => link['token'], 'created_by' => link['created_by'] } : { 'state' => 'off' }
89
+ elsif result[:status] == 403
90
+ { 'state' => 'unknown', 'reason' => 'only a person can see or change the share link (Project Settings → Showroom)' }
91
+ else
92
+ { 'state' => 'unknown', 'reason' => result[:error] }
93
+ end
94
+ end
95
+
96
+ def print_showroom(showroom, hidden, share, project:)
97
+ info = showroom['project'] || {}
98
+ title = "Showroom: #{info['name'] || project}"
99
+ puts title
100
+ puts '=' * title.length
101
+ puts
102
+
103
+ welcome = text_or_nil(info['welcome'])
104
+ if welcome
105
+ puts 'Welcome:'
106
+ welcome.each_line { |line| puts " #{line.rstrip}" }
107
+ else
108
+ puts 'Welcome: (none) — set one with: fp projects edit ' \
109
+ "#{info['slug'] || project} --showroom-welcome \"...\""
110
+ end
111
+ puts
112
+
113
+ gallery = showroom['gallery'] || []
114
+ unless gallery.empty?
115
+ puts "Gallery (#{media_summary(gallery)}):"
116
+ print_media(gallery, indent: ' ')
117
+ puts
118
+ end
119
+
120
+ surfaces = showroom['surfaces'] || []
121
+ if surfaces.empty?
122
+ puts "Surfaces shown: none. The client sees only the welcome note#{gallery.empty? ? '' : ' and the gallery'}."
123
+ else
124
+ puts "Surfaces shown (#{surfaces.size}):"
125
+ surfaces.each { |surface| print_card(surface) }
126
+ end
127
+
128
+ unless hidden.empty?
129
+ puts
130
+ puts "Not shown (#{hidden.size}):"
131
+ hidden.each do |surface|
132
+ puts " - #{surface['name'] || surface['key']} (#{surface['key']}): #{surface['reason']}"
133
+ end
134
+ puts " Add one with: fp surfaces edit <key> --project #{project} --showroom-url ... " \
135
+ '--showroom-instructions-file ...'
136
+ puts " or: fp showroom media add <file> --project #{project} --surface <key>"
137
+ end
138
+
139
+ puts
140
+ case share['state']
141
+ when 'on'
142
+ puts "Share link: on (created by #{share['created_by'] || 'unknown'}) — the content above is live."
143
+ when 'off'
144
+ puts 'Share link: off — only signed-in clients and the team can see this.'
145
+ else
146
+ puts "Share link: unknown — #{share['reason']}"
147
+ end
148
+ end
149
+
150
+ def print_card(surface)
151
+ puts
152
+ puts " #{surface['name']} (#{surface['kind']})"
153
+ url = text_or_nil(surface['url'])
154
+ label = text_or_nil(surface['label'])
155
+ puts " Link: #{url ? "#{url}#{label ? " [#{label}]" : ''}" : '(none)'}"
156
+ instructions = text_or_nil(surface['instructions'])
157
+ if instructions
158
+ puts ' Instructions:'
159
+ instructions.each_line { |line| puts " #{line.rstrip}" }
160
+ else
161
+ puts ' Instructions: (none)'
162
+ end
163
+ media = surface['media'] || []
164
+ if media.empty?
165
+ puts ' Media: (none)'
166
+ else
167
+ puts " Media: #{media_summary(media)}"
168
+ print_media(media, indent: ' ')
169
+ end
170
+ end
171
+
172
+ # "2 images, 1 video"
173
+ def media_summary(media)
174
+ counts = media.group_by { |item| item['media_kind'] == 'video' ? 'video' : 'image' }
175
+ %w[image video].filter_map do |kind|
176
+ count = counts.fetch(kind, []).size
177
+ "#{count} #{kind}#{count == 1 ? '' : 's'}" if count.positive?
178
+ end.join(', ')
179
+ end
180
+
181
+ def print_media(media, indent:)
182
+ media.each do |item|
183
+ caption = text_or_nil(item['caption'])
184
+ puts "#{indent}- #{item['media_kind'] || 'image'}: #{caption || '(no caption)'}"
185
+ end
186
+ end
187
+
188
+ def text_or_nil(value)
189
+ text = value.to_s.strip
190
+ text.empty? ? nil : text
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'attach'
5
+
6
+ module Fp
7
+ module Commands
8
+ # fp showroom media [--project <slug>] - list the showroom's media
9
+ # fp showroom media add <file> [--surface <key>] [--caption] [--position N]
10
+ # fp showroom media edit <id> [--caption "..."] [--position N]
11
+ # fp showroom media remove <id> [--yes]
12
+ #
13
+ # Plenty of clients never open the demo, so the showroom can show them the thing
14
+ # instead: screenshots or a short clip on a surface's card, or in the project gallery
15
+ # under the welcome note (no --surface). The agent that ran the e2e suite already has
16
+ # the captures, so it puts them there rather than leaving a human to re-upload them.
17
+ #
18
+ # Dispatched from `fp showroom media ...`; its help lives on `fp showroom --help`.
19
+ # Uploads reuse attach's flow (presign, PUT the bytes, confirm) and its visual file
20
+ # types — images and videos only. SVG can carry script and PDFs don't render inline,
21
+ # so both are refused before anything is sent.
22
+ class ShowroomMedia < Base
23
+ CONTENT_TYPES = Attach::CONTENT_TYPES.slice(*Attach::VISUAL_EXTENSIONS).freeze
24
+ MAX_FILE_SIZE = Attach::MAX_FILE_SIZE
25
+ MAX_CAPTION_LENGTH = 500
26
+
27
+ LIST_FLAGS = { project: :string }.freeze
28
+ ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string }.freeze
29
+ EDIT_FLAGS = { project: :string, caption: :string, position: :string }.freeze
30
+ REMOVE_FLAGS = { project: :string, yes: :boolean }.freeze
31
+
32
+ def run(args)
33
+ case args.first
34
+ when 'add'
35
+ add(args[1..])
36
+ when 'edit'
37
+ edit(args[1..])
38
+ when 'remove', 'rm', 'delete'
39
+ remove(args[1..])
40
+ else
41
+ list(args)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Gallery first, then each surface's media in the order a client sees the cards.
48
+ def list(args)
49
+ opts, = parse_flags(args, LIST_FLAGS)
50
+ require_project!(opts)
51
+ project_id = resolve_project_id(opts[:project])
52
+
53
+ media = fetch!(client.list_showroom_media(project_id))['media'] || []
54
+ groups = group_media(media, surfaces_for(project_id))
55
+
56
+ flat = groups.flat_map do |group|
57
+ group[:items].map { |item| item.merge('surface_key' => group[:key]) }
58
+ end
59
+
60
+ output.success({ media: flat, project: opts[:project] }) do
61
+ if media.empty?
62
+ puts "No showroom media in project '#{opts[:project]}'."
63
+ puts "Add some with: fp showroom media add <file> --project #{opts[:project]} [--surface <key>]"
64
+ else
65
+ puts "Showroom media for '#{opts[:project]}' (#{media.size}):"
66
+ groups.each do |group|
67
+ puts
68
+ puts "#{group[:title]} (#{group[:items].size}):"
69
+ group[:items].each { |item| print_item_line(item) }
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ def add(args)
76
+ opts, positional = parse_flags(args, ADD_FLAGS)
77
+ path = positional.first
78
+ if path.nil? || path.strip.empty?
79
+ output.error('Usage: fp showroom media add <file> --project <slug> [--surface <key>] ' \
80
+ '[--caption "..."] [--position N]')
81
+ exit 1
82
+ end
83
+
84
+ # Everything that can be checked locally is checked before a byte is sent.
85
+ file = prepare_file!(path)
86
+ caption = flag_text(opts[:caption]).strip if opts.key?(:caption)
87
+ caption = nil if caption&.empty?
88
+ check_caption!(caption) if caption
89
+ position = position!(opts[:position], clear: nil) if opts.key?(:position)
90
+ if opts.key?(:surface) && flag_text(opts[:surface]).strip.empty?
91
+ output.error('--surface needs a surface key. Omit it to add to the project gallery.')
92
+ exit 1
93
+ end
94
+
95
+ require_project!(opts)
96
+ project_id = resolve_project_id(opts[:project])
97
+ surface = opts[:surface] ? find_surface!(project_id, opts[:surface]) : nil
98
+
99
+ presign = fetch!(client.presign_showroom_media(
100
+ project_id,
101
+ filename: file[:filename], content_type: file[:content_type],
102
+ size_bytes: file[:size], surface_id: surface&.dig('id')
103
+ ), prefix: 'Failed to get upload URL')['presign'] || {}
104
+
105
+ uploaded = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
106
+ fetch!(uploaded, prefix: 'Upload failed')
107
+
108
+ item = fetch!(client.create_showroom_media(
109
+ project_id,
110
+ media_id: presign['media_id'], s3_key: presign['s3_key'],
111
+ filename: file[:filename], content_type: file[:content_type], size_bytes: file[:size],
112
+ surface_id: surface&.dig('id'), caption: caption, position: position
113
+ ), prefix: 'Failed to confirm upload')['media'] || {}
114
+
115
+ where = surface ? "surface '#{surface['key']}'" : 'the project gallery'
116
+ output.success({ media: item, surface: surface&.dig('key'), project: opts[:project] }) do
117
+ puts "Added #{file[:filename]} to #{where} in project '#{opts[:project]}'."
118
+ print_item_detail(item)
119
+ end
120
+ end
121
+
122
+ def edit(args)
123
+ opts, positional = parse_flags(args, EDIT_FLAGS)
124
+ media_id = positional.first
125
+ if media_id.nil? || media_id.strip.empty?
126
+ output.error('Usage: fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]')
127
+ exit 1
128
+ end
129
+
130
+ # Only what was passed is sent. Empty caption clears it. Positions are kept dense
131
+ # (1..n) per card or gallery: N inserts at slot N and shifts the rest, and an empty
132
+ # value — sent as 0, since the API types it as an integer — moves the item to the end.
133
+ params = {}
134
+ if opts.key?(:caption)
135
+ params[:caption] = flag_text(opts[:caption]).strip
136
+ check_caption!(params[:caption])
137
+ end
138
+ params[:position] = position!(opts[:position], clear: 0) if opts.key?(:position)
139
+
140
+ if params.empty?
141
+ output.error('Nothing to change. Pass --caption and/or --position (an empty value clears either).')
142
+ exit 1
143
+ end
144
+
145
+ require_project!(opts)
146
+ project_id = resolve_project_id(opts[:project])
147
+ item = fetch!(client.update_showroom_media(project_id, media_id, params))['media'] || {}
148
+
149
+ output.success({ media: item, project: opts[:project] }) do
150
+ puts "Updated showroom media #{media_id} in project '#{opts[:project]}'."
151
+ print_item_detail(item)
152
+ end
153
+ end
154
+
155
+ def remove(args)
156
+ opts, positional = parse_flags(args, REMOVE_FLAGS)
157
+ media_id = positional.first
158
+ if media_id.nil? || media_id.strip.empty?
159
+ output.error('Usage: fp showroom media remove <id> --project <slug> [--yes]')
160
+ exit 1
161
+ end
162
+
163
+ require_project!(opts)
164
+ project_id = resolve_project_id(opts[:project])
165
+
166
+ # Read it first: the confirmation names the file, and an unknown id fails here
167
+ # rather than as a bare 404 from the delete.
168
+ media = fetch!(client.list_showroom_media(project_id))['media'] || []
169
+ item = media.find { |m| m['id'] == media_id }
170
+ unless item
171
+ output.error("Showroom media '#{media_id}' not found in project '#{opts[:project]}'. " \
172
+ "List it with: fp showroom media --project #{opts[:project]}", status: 404)
173
+ exit 1
174
+ end
175
+
176
+ confirm_remove!(item, opts)
177
+ fetch!(client.delete_showroom_media(project_id, media_id), prefix: 'Failed to remove showroom media')
178
+
179
+ output.success({ deleted: true, media: { 'id' => media_id, 'filename' => item['filename'] },
180
+ project: opts[:project] }) do
181
+ puts "Removed #{item['filename']} (#{media_id}) from the showroom of project '#{opts[:project]}'."
182
+ end
183
+ end
184
+
185
+ # Same guard as `fp surfaces delete`: --yes skips the prompt, an interactive
186
+ # terminal types the filename back, and anything else is refused.
187
+ def confirm_remove!(item, opts)
188
+ return if opts[:yes]
189
+
190
+ unless interactive?
191
+ output.error("Refusing to remove '#{item['filename']}' without confirmation. " \
192
+ 'Re-run with --yes to confirm from a non-interactive session.')
193
+ exit 1
194
+ end
195
+
196
+ warn "This will remove '#{item['filename']}' from the showroom of project '#{opts[:project]}'. " \
197
+ 'Clients stop seeing it at once.'
198
+ $stderr.print "Type '#{item['filename']}' to confirm: "
199
+ $stderr.flush
200
+
201
+ typed = $stdin.gets&.strip
202
+ return if typed == item['filename']
203
+
204
+ output.error('Aborted. The filename did not match; nothing was removed.')
205
+ exit 1
206
+ end
207
+
208
+ def interactive?
209
+ $stdin.respond_to?(:tty?) && $stdin.tty?
210
+ end
211
+
212
+ # { path:, filename:, content_type:, size: }, or exit with the reason it can't go up.
213
+ def prepare_file!(path)
214
+ unless File.file?(path)
215
+ output.error("File not found: #{path}")
216
+ exit 1
217
+ end
218
+
219
+ filename = File.basename(path)
220
+ extension = File.extname(filename).delete('.').downcase
221
+ content_type = CONTENT_TYPES[extension]
222
+ unless content_type
223
+ why = extension == 'svg' ? ' (SVG can carry script)' : ''
224
+ output.error("Unsupported file type: .#{extension}#{why}. The showroom takes images " \
225
+ "(png, jpg, jpeg, gif, webp) and videos (mp4, webm, mov) only.")
226
+ exit 1
227
+ end
228
+
229
+ size = File.size(path)
230
+ if size.zero?
231
+ output.error("File is empty: #{path}")
232
+ exit 1
233
+ end
234
+ if size > MAX_FILE_SIZE
235
+ output.error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
236
+ exit 1
237
+ end
238
+
239
+ { path: path, filename: filename, content_type: content_type, size: size }
240
+ end
241
+
242
+ def check_caption!(caption)
243
+ return if caption.length <= MAX_CAPTION_LENGTH
244
+
245
+ output.error("--caption is #{caption.length} characters; the limit is #{MAX_CAPTION_LENGTH}.")
246
+ exit 1
247
+ end
248
+
249
+ # 1 is first. An empty value returns `clear` (nil on add — it goes at the end
250
+ # anyway — and 0 on edit, which moves it to the end); anything else must be a whole
251
+ # number of 1 or more.
252
+ def position!(value, clear:)
253
+ text = flag_text(value).strip
254
+ return clear if text.empty?
255
+
256
+ position = Integer(text, 10, exception: false)
257
+ return position if position&.positive?
258
+
259
+ output.error("Invalid --position '#{text}'. Must be a whole number of 1 or more; " \
260
+ 'pass an empty value to clear it.')
261
+ exit 1
262
+ end
263
+
264
+ # A string flag given with no value parses as `true`; treat that as empty.
265
+ def flag_text(value)
266
+ value == true ? '' : value.to_s
267
+ end
268
+
269
+ def surfaces_for(project_id)
270
+ result = client.list_surfaces(project_id)
271
+ result[:ok] ? (result[:data]['surfaces'] || []) : []
272
+ end
273
+
274
+ def find_surface!(project_id, key)
275
+ fetch!(client.find_surface_by_key(project_id, key))['surface']
276
+ end
277
+
278
+ # [{ title:, key:, items: }] — the gallery, then each surface with media in showroom
279
+ # order (placed surfaces lowest first, then by name, as Surface#showroom_sort_key
280
+ # does). Within a group the server's order stands: it already sorts by position.
281
+ def group_media(media, surfaces)
282
+ groups = []
283
+ gallery = media.select { |m| m['surface_id'].to_s.empty? }
284
+ groups << { title: 'Gallery (under the welcome note)', key: nil, items: gallery } unless gallery.empty?
285
+
286
+ by_surface = media.reject { |m| m['surface_id'].to_s.empty? }.group_by { |m| m['surface_id'] }
287
+ surfaces.sort_by { |s| surface_sort_key(s) }.each do |surface|
288
+ items = by_surface.delete(surface['id'])
289
+ next unless items
290
+
291
+ groups << { title: "#{surface['name'] || surface['key']} (#{surface['key']})", key: surface['key'],
292
+ items: items }
293
+ end
294
+ by_surface.each do |surface_id, items|
295
+ groups << { title: "Unknown surface #{surface_id}", key: nil, items: items }
296
+ end
297
+ groups
298
+ end
299
+
300
+ def surface_sort_key(surface)
301
+ position = surface['showroom_position'].to_i
302
+ position = nil if position < 1
303
+ [position ? 0 : 1, position || 0, surface['name'].to_s.downcase, surface['id'].to_s]
304
+ end
305
+
306
+ def print_item_line(item)
307
+ position = item['position'].to_i.positive? ? "##{item['position'].to_i}" : '-'
308
+ caption = item['caption'].to_s.strip
309
+ line = " #{item['id']} #{position.ljust(3)} #{item['media_kind'].to_s.ljust(5)} #{item['filename']}"
310
+ line += " \"#{caption}\"" unless caption.empty?
311
+ puts line
312
+ end
313
+
314
+ def print_item_detail(item)
315
+ puts
316
+ puts " ID: #{item['id']}"
317
+ puts " Kind: #{item['media_kind']} (#{item['content_type']})"
318
+ puts " File: #{item['filename']}#{item['size_bytes'] ? " (#{format_size(item['size_bytes'].to_i)})" : ''}"
319
+ caption = item['caption'].to_s.strip
320
+ puts " Caption: #{caption.empty? ? '(none)' : caption}"
321
+ position = item['position'].to_i
322
+ puts " Position: #{position.positive? ? position : '(last)'}"
323
+ end
324
+
325
+ # The response data, or exit with the API's error.
326
+ def fetch!(result, prefix: nil)
327
+ return result[:data] || {} if result[:ok]
328
+
329
+ output.error([prefix, result[:error]].compact.join(': '), status: result[:status])
330
+ exit 1
331
+ end
332
+ end
333
+ end
334
+ end