avo 4.3.9 → 4.3.10
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 +4 -4
- data/Gemfile.lock +1 -1
- data/app/assets/builds/avo/application.css +12 -0
- data/app/assets/builds/avo/application.js +54 -54
- data/app/assets/builds/avo/application.js.map +3 -3
- data/app/components/avo/view_types/base_view_type_component.rb +44 -0
- data/app/components/avo/view_types/grid_component.html.erb +65 -11
- data/app/components/avo/view_types/table_component.rb +0 -44
- data/app/javascript/js/controllers/item_select_all_controller.js +24 -4
- data/lib/avo/base_action.rb +7 -3
- data/lib/avo/skills/avo-setup/SKILL.md +1 -1
- data/lib/avo/skills/avo-troubleshoot/SKILL.md +6 -1
- data/lib/avo/version.rb +1 -1
- data/lib/tasks/avo_tasks.rake +30 -2
- metadata +1 -1
|
@@ -28,4 +28,48 @@ class Avo::ViewTypes::BaseViewTypeComponent < Avo::BaseComponent
|
|
|
28
28
|
field_per_page: field&.per_page
|
|
29
29
|
)
|
|
30
30
|
end
|
|
31
|
+
|
|
32
|
+
def selected_page_label
|
|
33
|
+
if @resource.pagination_type.countless?
|
|
34
|
+
t "avo.x_records_selected_from_page_html", selected: @pagy.in
|
|
35
|
+
else
|
|
36
|
+
t "avo.x_records_selected_from_a_total_of_x_html", selected: @pagy.in, count: @pagy.count
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def selected_all_label
|
|
41
|
+
if @resource.pagination_type.countless?
|
|
42
|
+
t "avo.records_selected_from_all_pages_html"
|
|
43
|
+
else
|
|
44
|
+
t "avo.x_records_selected_from_all_pages_html", count: @pagy.count
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def encrypted_query
|
|
49
|
+
# A page cut by its source is all that is held, so there is nothing to select across pages
|
|
50
|
+
return :select_all_disabled if @query.is_a?(Avo::Resources::ArrayResource::Page)
|
|
51
|
+
|
|
52
|
+
# TODO: move this to the resource where we can apply the adapter pattern
|
|
53
|
+
if Module.const_defined?("Ransack::Search") && @query.instance_of?(Ransack::Search)
|
|
54
|
+
@query = @query.result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Avo::Services::EncryptionService.encrypt(message: @query, purpose: :select_all, serializer: Marshal)
|
|
58
|
+
rescue
|
|
59
|
+
disable_select_all
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def disable_select_all
|
|
65
|
+
if Rails.env.development?
|
|
66
|
+
Avo.error_manager.add({
|
|
67
|
+
url: "https://docs.avohq.io/4.0/select-all.html#serialization-known-issues",
|
|
68
|
+
target: "_blank",
|
|
69
|
+
message: "An error occurred while serializing the query object. The Select All feature has been disabled because it depends on successful query serialization. For more details and troubleshooting steps, click here."
|
|
70
|
+
})
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
:select_all_disabled
|
|
74
|
+
end
|
|
31
75
|
end
|
|
@@ -1,21 +1,75 @@
|
|
|
1
1
|
<turbo-frame id="<%= list_frame_id %>" target="_top" class="relative flex w-full flex-col @container">
|
|
2
|
-
<%#
|
|
3
|
-
<%#
|
|
2
|
+
<%# The selection controller wraps the bar and the cards. The reorder gem sorts the grid wrapper's children, %>
|
|
3
|
+
<%# so the bar can't sit inside it. %>
|
|
4
4
|
<%= content_tag :div,
|
|
5
|
-
class: "
|
|
6
|
-
tabindex: (@reflection.present? ? nil : "-1"),
|
|
5
|
+
class: "flex w-full flex-col gap-3",
|
|
7
6
|
data: {
|
|
8
|
-
component_name: self.class.to_s.underscore,
|
|
9
7
|
controller: "item-select-all",
|
|
10
8
|
resource_name: resource_name_for(@resource),
|
|
9
|
+
item_select_all_selected_all_value: false,
|
|
10
|
+
item_select_all_selected_all_query_value: encrypted_query,
|
|
11
|
+
item_select_all_page_count_value: @pagy.pages,
|
|
11
12
|
selected_resources_name: resource_name_for(@resource),
|
|
12
|
-
selected_resources: []
|
|
13
|
-
**(@reflection.present? ? {} : {"content-focus": ""}),
|
|
14
|
-
**(try(:drag_reorder_data_attributes) || {})
|
|
13
|
+
selected_resources: []
|
|
15
14
|
} do %>
|
|
16
|
-
<% @
|
|
17
|
-
|
|
18
|
-
<%=
|
|
15
|
+
<% if @resource.record_selector %>
|
|
16
|
+
<div class="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-1 px-2 text-sm" data-component-name="avo/view_types/grid_select_all">
|
|
17
|
+
<%= content_tag :label, class: "flex items-center gap-2 cursor-pointer text-content-secondary" do %>
|
|
18
|
+
<%= tag :input,
|
|
19
|
+
type: "checkbox",
|
|
20
|
+
name: t("avo.select_all"),
|
|
21
|
+
autocomplete: :off,
|
|
22
|
+
data: {
|
|
23
|
+
action: "input->item-select-all#toggle",
|
|
24
|
+
item_select_all_target: "checkbox"
|
|
25
|
+
} %>
|
|
26
|
+
<%= t "avo.select_all" %>
|
|
27
|
+
<% end %>
|
|
28
|
+
|
|
29
|
+
<% if @pagy.pages > 1 %>
|
|
30
|
+
<div class="hidden border-s border-tertiary ps-3" data-item-select-all-target="selectAllOverlay">
|
|
31
|
+
<div data-item-select-all-target="unselectedMessage">
|
|
32
|
+
<%= selected_page_label %>
|
|
33
|
+
|
|
34
|
+
<%= a_link request.url,
|
|
35
|
+
size: :xs,
|
|
36
|
+
color: :primary,
|
|
37
|
+
class: "relative -top-[0.1rem] ms-2 cursor-pointer text-primary-500",
|
|
38
|
+
data: {action: "click->item-select-all#selectAll"} do %>
|
|
39
|
+
<%= t "avo.select_all_matching" %>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="hidden" data-item-select-all-target="selectedMessage">
|
|
44
|
+
<%= selected_all_label %>
|
|
45
|
+
|
|
46
|
+
<%= a_link request.url,
|
|
47
|
+
size: :xs,
|
|
48
|
+
color: :primary,
|
|
49
|
+
class: "relative -top-[0.1rem] ms-2 cursor-pointer text-primary-500",
|
|
50
|
+
data: {action: "click->item-select-all#selectAll"} do %>
|
|
51
|
+
<%= t("avo.undo").humanize %>
|
|
52
|
+
<% end %>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
|
|
59
|
+
<%# tabindex=-1 makes the wrapper a Shift+T focus anchor (top-level grids only, %>
|
|
60
|
+
<%# never a has-many grid on a show page); from here Tab steps through the cards. %>
|
|
61
|
+
<%= content_tag :div,
|
|
62
|
+
class: "grid-wrapper",
|
|
63
|
+
tabindex: (@reflection.present? ? nil : "-1"),
|
|
64
|
+
data: {
|
|
65
|
+
component_name: self.class.to_s.underscore,
|
|
66
|
+
**(@reflection.present? ? {} : {"content-focus": ""}),
|
|
67
|
+
**(try(:drag_reorder_data_attributes) || {})
|
|
68
|
+
} do %>
|
|
69
|
+
<% @resources.each_with_index do |resource, index| %>
|
|
70
|
+
<% cache_if Avo.configuration.cache_resources_on_index_view, resource.index_cache_key(@parent_record, cache_context:), expires_in: 1.day do %>
|
|
71
|
+
<%= render(resource.resolve_component(Avo::Index::GridItemComponent).new(resource: resource, reflection: @reflection, parent_record: @parent_record, parent_resource: @parent_resource, actions: actions)) %>
|
|
72
|
+
<% end %>
|
|
19
73
|
<% end %>
|
|
20
74
|
<% end %>
|
|
21
75
|
<% end %>
|
|
@@ -24,36 +24,6 @@ class Avo::ViewTypes::TableComponent < Avo::ViewTypes::BaseViewTypeComponent
|
|
|
24
24
|
}
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def encrypted_query
|
|
28
|
-
# A page cut by its source is all that is held, so there is nothing to select across pages
|
|
29
|
-
return :select_all_disabled if @query.is_a?(Avo::Resources::ArrayResource::Page)
|
|
30
|
-
|
|
31
|
-
# TODO: move this to the resource where we can apply the adapter pattern
|
|
32
|
-
if Module.const_defined?("Ransack::Search") && @query.instance_of?(Ransack::Search)
|
|
33
|
-
@query = @query.result
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
Avo::Services::EncryptionService.encrypt(message: @query, purpose: :select_all, serializer: Marshal)
|
|
37
|
-
rescue
|
|
38
|
-
disable_select_all
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def selected_page_label
|
|
42
|
-
if @resource.pagination_type.countless?
|
|
43
|
-
t "avo.x_records_selected_from_page_html", selected: @pagy.in
|
|
44
|
-
else
|
|
45
|
-
t "avo.x_records_selected_from_a_total_of_x_html", selected: @pagy.in, count: @pagy.count
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def selected_all_label
|
|
50
|
-
if @resource.pagination_type.countless?
|
|
51
|
-
t "avo.records_selected_from_all_pages_html"
|
|
52
|
-
else
|
|
53
|
-
t "avo.x_records_selected_from_all_pages_html", count: @pagy.count
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
27
|
# Table rows are not fragment-cached. A `cache_if` wrapped this until it was
|
|
58
28
|
# removed: a `return` inside its block unwound past Action View's
|
|
59
29
|
# `write_fragment`, so nothing was ever written, and a hit would have handed
|
|
@@ -97,18 +67,4 @@ class Avo::ViewTypes::TableComponent < Avo::ViewTypes::BaseViewTypeComponent
|
|
|
97
67
|
|
|
98
68
|
[header_fields, table_row_components]
|
|
99
69
|
end
|
|
100
|
-
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
def disable_select_all
|
|
104
|
-
if Rails.env.development?
|
|
105
|
-
Avo.error_manager.add({
|
|
106
|
-
url: "https://docs.avohq.io/4.0/select-all.html#serialization-known-issues",
|
|
107
|
-
target: "_blank",
|
|
108
|
-
message: "An error occurred while serializing the query object. The Select All feature has been disabled because it depends on successful query serialization. For more details and troubleshooting steps, click here."
|
|
109
|
-
})
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
:select_all_disabled
|
|
113
|
-
end
|
|
114
70
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AttributeObserver, Controller } from '@hotwired/stimulus'
|
|
2
2
|
import { setSelection, takeSelection } from '../index_state'
|
|
3
3
|
|
|
4
|
+
const MAX_ACTION_URL_LENGTH = 6000
|
|
5
|
+
|
|
4
6
|
export default class extends Controller {
|
|
5
7
|
static targets = [
|
|
6
8
|
'itemCheckbox',
|
|
@@ -24,6 +26,10 @@ export default class extends Controller {
|
|
|
24
26
|
this.selectedResourcesObserver = new AttributeObserver(this.element, 'data-selected-resources', this)
|
|
25
27
|
this.selectedResourcesObserver.start()
|
|
26
28
|
|
|
29
|
+
// The action links carry the index query from the start, so an action's fields and messages can read it
|
|
30
|
+
// even when nothing is selected.
|
|
31
|
+
this.updateLinks('resourceIds')
|
|
32
|
+
|
|
27
33
|
// Wait for the rows' item-selector controllers to connect; they apply the selection.
|
|
28
34
|
if (rememberedIds.length > 0) {
|
|
29
35
|
Promise.resolve().then(() => this.restoreSelection(rememberedIds))
|
|
@@ -58,7 +64,8 @@ export default class extends Controller {
|
|
|
58
64
|
if (!this.hasCheckboxTarget) return
|
|
59
65
|
|
|
60
66
|
// If all are selected, mark the checkbox as checked.
|
|
61
|
-
|
|
67
|
+
// Table rows and grid cards both carry an item checkbox.
|
|
68
|
+
const rowCount = this.itemCheckboxTargets.length
|
|
62
69
|
// Reset the checkbox
|
|
63
70
|
this.checkboxTarget.indeterminate = false
|
|
64
71
|
this.checkboxTarget.checked = false
|
|
@@ -151,11 +158,19 @@ export default class extends Controller {
|
|
|
151
158
|
url.searchParams.set('fields[avo_resource_ids]', resourceIds)
|
|
152
159
|
url.searchParams.set('fields[avo_selected_all]', 'false')
|
|
153
160
|
} else if (param === 'selectedQuery') {
|
|
154
|
-
const selectedQuery = this.element.dataset.itemSelectAllSelectedAllQueryValue
|
|
155
|
-
url.searchParams.set('fields[avo_index_query]', selectedQuery)
|
|
156
161
|
url.searchParams.set('fields[avo_selected_all]', 'true')
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
if (this.indexQueryAvailable()) {
|
|
165
|
+
url.searchParams.set('fields[avo_index_query]', this.selectedAllQueryValue)
|
|
166
|
+
|
|
167
|
+
// Puma and nginx reject request lines around 8-10 KB. Only select all needs the query to open the
|
|
168
|
+
// modal; without it, the modal texts see no index_query and handle still gets it from the form.
|
|
169
|
+
if (param !== 'selectedQuery' && url.toString().length > MAX_ACTION_URL_LENGTH) {
|
|
170
|
+
url.searchParams.delete('fields[avo_index_query]')
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
link.href = url.toString()
|
|
160
175
|
} catch (error) {
|
|
161
176
|
console.error('Error updating link:', link, error)
|
|
@@ -179,6 +194,11 @@ export default class extends Controller {
|
|
|
179
194
|
|
|
180
195
|
// True if there are more pages available and if query encryption run successfully
|
|
181
196
|
selectAllEnabled() {
|
|
182
|
-
return this.pageCountValue > 1 && this.
|
|
197
|
+
return this.pageCountValue > 1 && this.indexQueryAvailable()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A view type renders 'select_all_disabled' when it can't encrypt the query, and a custom one may render none
|
|
201
|
+
indexQueryAvailable() {
|
|
202
|
+
return this.selectedAllQueryValue !== '' && this.selectedAllQueryValue !== 'select_all_disabled'
|
|
183
203
|
}
|
|
184
204
|
}
|
data/lib/avo/base_action.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Avo
|
|
|
41
41
|
attr_reader :appended_turbo_streams
|
|
42
42
|
attr_reader :records_to_reload
|
|
43
43
|
attr_reader :query
|
|
44
|
+
attr_reader :index_query
|
|
44
45
|
|
|
45
46
|
# TODO: find a differnet way to delegate this to the uninitialized Current variable
|
|
46
47
|
delegate :context, to: Avo::Current
|
|
@@ -130,7 +131,8 @@ module Avo
|
|
|
130
131
|
record: @record,
|
|
131
132
|
view: @view,
|
|
132
133
|
arguments: @arguments,
|
|
133
|
-
query: @query
|
|
134
|
+
query: @query,
|
|
135
|
+
index_query: @index_query
|
|
134
136
|
).handle
|
|
135
137
|
end
|
|
136
138
|
|
|
@@ -388,7 +390,8 @@ module Avo
|
|
|
388
390
|
record: @record,
|
|
389
391
|
view: @view,
|
|
390
392
|
arguments:,
|
|
391
|
-
query: @query
|
|
393
|
+
query: @query,
|
|
394
|
+
index_query: @index_query
|
|
392
395
|
).handle
|
|
393
396
|
end
|
|
394
397
|
|
|
@@ -406,7 +409,8 @@ module Avo
|
|
|
406
409
|
record: @record,
|
|
407
410
|
view: @view,
|
|
408
411
|
arguments: @arguments,
|
|
409
|
-
query: @query
|
|
412
|
+
query: @query,
|
|
413
|
+
index_query: @index_query
|
|
410
414
|
).handle
|
|
411
415
|
end
|
|
412
416
|
|
|
@@ -221,7 +221,7 @@ A license authorizes **one app, one production URL** (`Rails.env.production?`).
|
|
|
221
221
|
|
|
222
222
|
Verify at the **status page**: `https://yourapp.com/<mount-path>/avo_private/status` (e.g. `.../avo/avo_private/status` or `.../admin/avo_private/status`). It shows whether the license authenticated and what the checking server returned; the key is masked unless you set `config.exclude_from_status = []`. The viewing user must be an Avo admin. Deep failure diagnosis (unlicensed after deploy, timeouts, test-suite blocking the check host) is the **avo-troubleshoot** skill.
|
|
223
223
|
|
|
224
|
-
**Trials are full licenses.** Every add-on a trial covers behaves exactly as it does on a paid license while the trial runs — nothing is gated or degraded. The **Avo Status** indicator in the sidebar footer is green when all is well, **amber** when a trial needs attention, and orange when the license is invalid. Amber is not a failure
|
|
224
|
+
**Trials are full licenses.** Every add-on a trial covers behaves exactly as it does on a paid license while the trial runs — nothing is gated or degraded. The **Avo Status** indicator in the sidebar footer is green when all is well, **amber** when a trial needs attention, and orange when the license is invalid. Amber is not a failure: the status page names why, and the fix is on https://avohq.io/licenses. The **avo-troubleshoot** skill lists each case and its fix. Avo caches the license response between checks, so press **Refresh license** on the status page afterwards or the app keeps showing the older billing state. Trial state needs `avo-licensing` **4.0.10+**; the amber indicator needs Avo **4.0.20+** (an older Avo renders an uncolored dot rather than breaking).
|
|
225
225
|
|
|
226
226
|
### 7. (Optional) Append your own routes inside the engine
|
|
227
227
|
|
|
@@ -210,7 +210,12 @@ self.search = {
|
|
|
210
210
|
1. **Key not set on the server.** The most frequent cause. Confirm `config.license_key = ENV["AVO_LICENSE_KEY"]` and that the env var is actually present in **production** (not just locally).
|
|
211
211
|
2. **Check the status page.** Every Avo app exposes `https://yourapp.com/<mount>/avo_private/status` — e.g. `/admin/avo_private/status` if you mounted Avo at `admin`. It shows whether the license authenticated and the raw response from the check server. The viewing user must be an Avo admin.
|
|
212
212
|
3. **Key hidden on the status page.** The key is redacted by default; set `exclude_from_status = []` in the initializer if you need to see it while debugging.
|
|
213
|
-
4. **The sidebar indicator is amber, not orange.** That is not a license failure — it is a running trial that needs attention
|
|
213
|
+
4. **The sidebar indicator is amber, not orange.** That is not a license failure — it is a running trial that needs attention, and the status page shows an **Action needed before <date>** tag below the license name, the date being when access stops. Access continues until the date the status page reports. Each case is fixed on https://avohq.io/licenses:
|
|
214
|
+
- No payment method is on file: add one.
|
|
215
|
+
- The subscription behind the trial was cancelled: resume it. A payment method won't help.
|
|
216
|
+
- An Avo 4 trial runs beside an Avo 3 subscription: press **Migrate to Avo 4** before the trial's end date, or Avo 4 stops working in the app when the trial ends (the Avo 3 license stays active, so the app can move back to Avo 3). A payment method won't help. An older `avo-licensing` shows add-a-payment-method steps for this case; migrating is still the fix.
|
|
217
|
+
|
|
218
|
+
Then press **Refresh license** on the status page, since Avo caches the license response between checks.
|
|
214
219
|
|
|
215
220
|
→ `license-troubleshooting.html`
|
|
216
221
|
|
data/lib/avo/version.rb
CHANGED
data/lib/tasks/avo_tasks.rake
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
desc "Updates all Avo gems; set AVO_UPDATE_PUSH=1 to commit Gemfile.lock and push."
|
|
2
4
|
task "avo:update" => :environment do
|
|
5
|
+
push = ENV["AVO_UPDATE_PUSH"] == "1"
|
|
6
|
+
lockfile = Bundler.default_lockfile.to_s
|
|
7
|
+
|
|
8
|
+
if push
|
|
9
|
+
status, result = Open3.capture2("git", "status", "--porcelain", "--untracked-files=no")
|
|
10
|
+
abort "[Avo->] AVO_UPDATE_PUSH requires a Git repository." unless result.success?
|
|
11
|
+
abort "[Avo->] AVO_UPDATE_PUSH requires no changes to tracked files." unless status.empty?
|
|
12
|
+
unless system("git", "rev-parse", "--verify", "@{upstream}", out: File::NULL, err: File::NULL)
|
|
13
|
+
abort "[Avo->] AVO_UPDATE_PUSH requires a branch with an existing upstream."
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
3
17
|
# Start with avo core (it doesn't register itself as a plugin), then resolve
|
|
4
18
|
# every registered plugin to its real gem name. Plugins register under
|
|
5
19
|
# nicknames (e.g. `avo-rhino_field` registers as `:rhino`), so we ask each
|
|
@@ -20,7 +34,21 @@ task "avo:update" => :environment do
|
|
|
20
34
|
# `bundle exec` settings (BUNDLE_GEMFILE, BUNDLE_FROZEN/--deployment, RUBYOPT),
|
|
21
35
|
# which can make the update silently refuse. Exported credentials survive
|
|
22
36
|
# because they're part of the original env Bundler snapshotted at boot.
|
|
23
|
-
Bundler.with_original_env { system cmd }
|
|
37
|
+
abort "[Avo->] Gem update failed; nothing committed or pushed." unless Bundler.with_original_env { system cmd }
|
|
38
|
+
|
|
39
|
+
if push
|
|
40
|
+
abort "[Avo->] Could not stage #{lockfile}; nothing pushed." unless system("git", "add", "--", lockfile)
|
|
41
|
+
_, result = Open3.capture2("git", "diff", "--cached", "--quiet", "--", lockfile)
|
|
42
|
+
if result.success?
|
|
43
|
+
puts "[Avo->] Gemfile.lock is unchanged; nothing committed or pushed."
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
abort "[Avo->] Could not inspect Gemfile.lock; nothing pushed." unless result.exitstatus == 1
|
|
47
|
+
unless system("git", "commit", "--only", "-m", "Update Avo gems", "--", lockfile)
|
|
48
|
+
abort "[Avo->] Commit failed; nothing pushed."
|
|
49
|
+
end
|
|
50
|
+
abort "[Avo->] Push failed; the update commit is available locally." unless system("git", "push")
|
|
51
|
+
end
|
|
24
52
|
end
|
|
25
53
|
|
|
26
54
|
desc "Builds Avo (just assets for now)"
|