Fixes #6216 - Packages> Integrate API-based package management into core.

Approved-by: Dusan Vuckovic <dv@zammad.com>

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
This commit is contained in:
Rolf Schmidt
2026-07-06 15:32:53 +02:00
parent 3cbdbadf25
commit dbc8e128cf
41 changed files with 971 additions and 77 deletions

View File

@@ -89,8 +89,11 @@ RUN apt-get update -qq && \
apt-get upgrade -y && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# ZAMMAD_DOCKER is used by Zammad to take decisions for containerized environments,
# e.g. hiding the package management interface because installed packages do not persist.
# Application variables with defaults matching the Zammad docker stack.
ENV POSTGRESQL_DB=zammad_production \
ENV ZAMMAD_DOCKER=true \
POSTGRESQL_DB=zammad_production \
POSTGRESQL_HOST=zammad-postgresql \
POSTGRESQL_PORT=5432 \
POSTGRESQL_USER=zammad \

View File

@@ -8,7 +8,10 @@ class Package extends App.ControllerSubContent
events:
'change .js-fileUpload': 'selectFile'
'click .package-action[data-type="uninstall"]': 'action'
'click .js-packageSettings': 'packageSettings'
'click .js-packageAction[data-table-action="uninstall"]': 'uninstall'
'click .js-packageAction[data-table-action="update_api"]': 'update_api'
'click .js-packageAction[data-table-action="install_api"]': 'install_api'
constructor: ->
super
@@ -24,6 +27,9 @@ class Package extends App.ControllerSubContent
@packages = data.packages
@package_installation = data.package_installation
@local_gemfiles = data.local_gemfiles
@token_setting_id = data.token_setting_id
@token_present = data.token_present
@api_package_metas = data.api_package_metas || {}
@render()
)
@@ -38,15 +44,28 @@ class Package extends App.ControllerSubContent
item.action = ['install']
else if item.state == 'deactivate'
item.action = ['uninstall', 'activate']
item.action = ['uninstall']
if @api_package_metas[item.name] && item.vendor is @api_package_metas[item.name].vendor
if @isNewerVersion(item.version, @api_package_metas[item.name].version)
item.action.unshift('update_api')
api_packages_installable = []
for name, data of @api_package_metas
continue if _.find(@packages, (row) -> row.name is name)
api_packages_installable.push(name)
@html App.view('package')(
head: __('Dashboard')
packages: @packages
package_installation: @package_installation
local_gemfiles: @local_gemfiles
token_present: @token_present
api_package_metas: @api_package_metas
api_packages_installable: api_packages_installable
)
action: (e) ->
uninstall: (e) ->
e.preventDefault()
id = $(e.target).parents('[data-id]').data('id')
@@ -66,10 +85,126 @@ class Package extends App.ControllerSubContent
)
)
isNewerVersion: (local, remote) ->
local_parts = local.split('.').map((n) -> parseInt(n))
remote_parts = remote.split('.').map((n) -> parseInt(n))
local_parts[0] * 1000000 + local_parts[1] * 1000 + local_parts[2] < remote_parts[0] * 1000000 + remote_parts[1] * 1000 + remote_parts[2]
selectFile: (e) ->
if !_.isEmpty(@fileUpload.val())
@packageSubmit.prop('disabled', false)
else
@packageSubmit.prop('disabled', true)
App.Config.set('Packages', { prio: 3700, name: __('Packages'), parent: '#system', target: '#system/package', controller: Package, permission: ['admin.package'] }, 'NavBarAdmin')
update_api: (e) ->
e.preventDefault()
id = $(e.target).parents('[data-id]').data('id')
@ajax(
id: 'packages'
type: 'PUT'
url: "#{@apiPath}/packages/api",
data: JSON.stringify(id: id)
processData: false
success: =>
@notify(
type: 'success'
msg: __('Package updated successfully.')
removeAll: true
)
@load()
)
install_api: (e) ->
e.preventDefault()
id = $(e.target).parents('[data-id]').data('id')
@ajax(
id: 'packages'
type: 'POST'
url: "#{@apiPath}/packages/api",
data: JSON.stringify(id: id)
processData: false
success: =>
@notify(
type: 'success'
msg: __('Package installed successfully.')
removeAll: true
)
@load()
)
packageSettings: (e) ->
e.preventDefault()
e.stopPropagation()
new PackageSettingsModal(
token_setting_id: @token_setting_id
token_present: @token_present
parent: @
)
class PackageSettingsModal extends App.ControllerModal
buttonClose: true
buttonCancel: true
buttonSubmit: true
head: __('Configure')
events:
'click .js-reset': 'resetToDefault'
constructor: (params) ->
if params.token_present
@centerButtons = [
{
text: __('Reset to default settings')
className: 'btn--danger js-reset'
}
]
super
content: ->
token_value = ''
token_value = 'present' if @token_present
configureAttributes = [
{ name: 'token', display: __('API Token'), tag: 'input', type: 'password', autocomplete: 'one-time-code', single: true, null: true, value: token_value, help: __('Please enter the API token you received from Zammad.') },
]
@controller = new App.ControllerForm(
model:
configure_attributes: configureAttributes
className: ''
autofocus: true
)
@controller.form
resetToDefault: =>
return if !@token_present
target = @controller.form[0]
$(target).find('[name="token"]').val('')
@onSubmit(new Event('submit', { cancelable: true, bubbles: true, target: target }))
onSubmit: (e) =>
params = @formParam(e.target)
return @close() if params.token is 'present'
@ajax(
type: 'PUT'
url: "#{@apiPath}/settings/#{@token_setting_id}"
data: JSON.stringify({ state_current: { value: params.token } }),
success: (data, status, xhr) =>
@notify(
type: 'success'
msg: __('Update successful.')
removeAll: true
)
@close()
)
close: =>
super
@parent.load()
App.Config.set('Packages', { prio: 3700, name: __('Packages'), parent: '#system', target: '#system/package', controller: Package, permission: (controller) -> App.Config.get('admin_packages') && controller.permissionCheck('admin.package') }, 'NavBarAdmin')

View File

@@ -1,5 +1,10 @@
<div class="page-header-title">
<h1><%- @T( 'Package' ) %> <small><%- @T('Management') %></small></h1>
<div class="page-header">
<div class="page-header-title">
<h1><%- @T( 'Package' ) %> <small><%- @T('Management') %></small></h1>
</div>
<div class="page-header-meta">
<a data-type="settings" class="btn js-packageSettings" href="#"><%- @T('Configure') %></a>
</div>
</div>
<div class="page-content">
@@ -41,26 +46,111 @@
</form>
</p>
<table class="table table-striped table-hover">
<h2 class="package-table-heading"><%- @T('Installed packages') %></h2>
<% if _.isEmpty(@packages): %>
<table class="table table--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
<% else: %>
<table class="table table-striped">
<thead>
<tr>
<th><%- @T('Name') %></th>
<th><%- @T('Version') %></th>
<th><%- @T('Vendor') %></th>
<th><%- @T('State') %></th>
<th><%- @T('Action') %></th>
<th class="package-action-header"><%- @T('Action') %></th>
</tr>
</thead>
<tbody>
<% for item in @packages: %>
<tr data-id="<%= item.id %>">
<td><%= item.name %></td>
<td><%= item.version %></td>
<% meta = @api_package_metas[item.name]; lastLog = meta && meta.change_log && meta.change_log.length && meta.change_log[meta.change_log.length - 1] %>
<td><%= item.version %><% if _.contains(item.action, 'update_api'): %> <span class="badge badge--closed"<% if lastLog: %> title="<%= lastLog.content %> (<%- @Ttimestamp(lastLog.date) %>)"<% end %>><%- @T('Update to %s', meta.version) %></span><% end %></td>
<td><% if item.url: %><a href="<%= item.url %>" target="_blank"><% end %><%= item.vendor %><% if item.url: %></a><% end %></td>
<td><%- @T(item.state) %></td>
<td><% for action in item.action: %><a href="#" class="package-action btn btn--action<%= ' btn--danger' if action == 'uninstall' %>" data-type="<%= action %>"><%- @T(action) %></a><br/><% end %></td>
<td class="package-action-cell no-padding">
<div class="dropdown dropdown--actions">
<div class="btn btn--table btn--text btn--secondary js-action package-action-toggle" data-toggle="dropdown">
<%- @Icon('overflow-button') %>
</div>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<% for action in item.action: %>
<li role="presentation" class="js-packageAction" data-table-action="<%= action %>">
<a role="menuitem" tabindex="-1">
<% if action is 'update_api': %>
<span class="dropdown-iconSpacer">
<%- @Icon('reload') %>
</span>
<%- @T('Update package') %>
<% else if action is 'uninstall': %>
<span class="dropdown-iconSpacer">
<%- @Icon('trash') %>
</span>
<%- @T('Uninstall package') %>
<% else: %>
<%- @T(action) %>
<% end %>
</a>
</li>
<% end %>
</ul>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% if @token_present: %>
<h2 class="package-table-heading"><%- @T('Available packages') %></h2>
<% if _.isEmpty(@api_packages_installable): %>
<table class="table table--placeholder">
<thead><tr><th><%- @T('No Entries') %>
</table>
<% else: %>
<table class="table table-striped">
<thead>
<tr>
<th><%- @T('Name') %></th>
<th><%- @T('Version') %></th>
<th><%- @T('Vendor') %></th>
<th><%- @T('State') %></th>
<th class="package-action-header"><%- @T('Action') %></th>
</tr>
</thead>
<tbody>
<% for name in @api_packages_installable: %>
<% item = @api_package_metas[name] %>
<tr data-id="<%= item.name %>">
<td><%= item.name %></td>
<td><%= item.version %></td>
<td><% if item.url: %><a href="<%= item.url %>" target="_blank"><% end %><%= item.vendor %><% if item.url: %></a><% end %></td>
<td><%- @T('not installed') %></td>
<td class="package-action-cell no-padding">
<div class="dropdown dropdown--actions">
<div class="btn btn--table btn--text btn--secondary js-action package-action-toggle" data-toggle="dropdown">
<%- @Icon('overflow-button') %>
</div>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li role="presentation" class="js-packageAction" data-table-action="install_api">
<a role="menuitem" tabindex="-1">
<span class="dropdown-iconSpacer">
<%- @Icon('package') %>
</span>
<%- @T('Install package') %>
</a>
</li>
</ul>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% end %>
</div>

View File

@@ -16201,3 +16201,23 @@ div.accounted-time-value {
margin: 0;
}
}
.package {
&-table-heading {
margin-top: 30px;
}
&-action-header {
width: 100px;
}
&-action-cell {
overflow: visible !important;
text-align: right;
width: 50px;
}
&-action-toggle {
padding: 0;
}
}

View File

@@ -9,6 +9,9 @@ class PackagesController < ApplicationController
packages: Package.reorder('name'),
package_installation: Package.app_package_installation?,
local_gemfiles: Package.gem_files?,
token_setting_id: Setting.find_by(name: 'packages_token').id,
token_present: Setting.get('packages_token').present?,
api_package_metas: api_package_metas,
}
end
@@ -27,4 +30,40 @@ class PackagesController < ApplicationController
}
end
# POST /api/v1/packages/api
def install_api
api_package = api_packages[params[:id]]
raise "Can not find package '#{params[:id]}'!" if api_package.blank?
Package.install(string: api_package.to_json)
render json: {
success: true
}
end
# PUT /api/v1/packages/api
def update_api
package = Package.find(params[:id])
api_package = api_packages[package.name]
raise "Can not find package '#{package.name}'!" if api_package.blank?
Package.install(string: api_package.to_json)
render json: {
success: true
}
end
private
def api_packages
@api_packages ||= Package.api_packages_hash({ version_name: Package.api_version_name })
end
def api_package_metas
api_packages.transform_values do |value|
value.slice('name', 'version', 'vendor', 'license', 'url', 'change_log', 'description')
end
end
end

View File

@@ -336,6 +336,9 @@ class SessionsController < ApplicationController
# Announce Elasticsearch availability to the front end.
config['es_enabled'] = SearchIndexBackend.enabled?
# Hide the admin packages interface in environments like docker, where installed packages do not persist.
config['admin_packages'] = ENV['ZAMMAD_DOCKER'].blank?
# remember if we can switch back to user
if session[:switched_from_user_id]
config['switch_back_to_possible'] = true

View File

@@ -357,6 +357,60 @@ subsequently in a separate step.
File.exist?('/usr/bin/zammad')
end
def self.api_token
ENV['PACKAGES_TOKEN'] || Setting.get('packages_token')
end
def self.api_version_name
Rails.root.join('VERSION').read.chomp.split('.').tap { |row| row[2] = 'x' }[0..2].join('.')
end
def self.api_packages(params)
return [] if api_token.blank?
cache_key = "PackagesController/api_packages/#{api_token}/#{params.to_json}"
cache = Rails.cache.read(cache_key)
return cache if !cache.nil?
zip_file = api_zip_packages(params)
return [] if zip_file.blank?
result = []
begin
Zip::File.open(zip_file.path) do |zip|
zip.sort.each do |entry|
next if !entry.name.end_with?('.zpm')
content = entry.get_input_stream.read
data = JSON.parse(content)
result << data
end
end
ensure
zip_file.close!
end
Rails.cache.write(cache_key, result, expires_in: 1.hour)
result
end
def self.api_packages_hash(params)
api_packages(params).index_by { |row| row['name'] }
end
def self.api_zip_packages(params)
require 'zip'
response = UserAgent.get('https://support.zammad.com/api/v1/addon_releases/download/organization', params, { bearer_token: api_token })
return if !response.code.starts_with?('2')
zip_file = Tempfile.new
zip_file.binmode
zip_file.write(response.body)
zip_file.rewind
zip_file
end
=begin
reinstall package

View File

@@ -7,5 +7,7 @@ Zammad::Application.routes.draw do
match api_path + '/packages', to: 'packages#index', via: :get
match api_path + '/packages', to: 'packages#install', via: :post
match api_path + '/packages', to: 'packages#uninstall', via: :delete
match api_path + '/packages/api', to: 'packages#update_api', via: :put
match api_path + '/packages/api', to: 'packages#install_api', via: :post
end

View File

@@ -0,0 +1,20 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
class AddPackagesTokenSetting < ActiveRecord::Migration[7.0]
def up
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
Setting.create_if_not_exists(
title: 'Packages Token',
name: 'packages_token',
area: 'Core',
description: 'This setting defines the token to access the support.zammad.com instance for package remote commands.',
options: {},
state: '',
preferences: { online_service_disable: true, permission: ['admin.package'] },
frontend: false
)
end
end

View File

@@ -6393,3 +6393,14 @@ Setting.create_if_not_exists(
},
frontend: true,
)
Setting.create_if_not_exists(
title: __('Packages Token'),
name: 'packages_token',
area: 'Core',
description: __('This setting defines the token to access the support.zammad.com instance for package remote commands.'),
options: {},
state: '',
preferences: { online_service_disable: true, permission: ['admin.package'] },
frontend: false,
)

View File

@@ -839,6 +839,7 @@ msgstr ""
msgid "API Password Access"
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:170
#: app/assets/javascripts/app/views/integration/placetel.jst.eco:38
msgid "API Token"
msgstr ""
@@ -1027,7 +1028,7 @@ msgstr ""
#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco:10
#: app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco:9
#: app/assets/javascripts/app/views/object_manager/index.jst.eco:59
#: app/assets/javascripts/app/views/package.jst.eco:51
#: app/assets/javascripts/app/views/package.jst.eco:63
#: app/assets/javascripts/app/views/profile/password.jst.eco:32
#: app/assets/javascripts/app/views/session.jst.eco:13
#: app/assets/javascripts/app/views/tag/table.jst.eco:6
@@ -1042,7 +1043,7 @@ msgstr ""
msgid "Action execution"
msgstr ""
#: app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue:129
#: app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue:131
msgid "Action menu button"
msgstr ""
@@ -1495,7 +1496,7 @@ msgstr ""
msgid "After approval, please re-connect your Microsoft365 mailbox."
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:12
#: app/assets/javascripts/app/views/package.jst.eco:17
msgid "After installing, updating, or uninstalling packages the following commands need to be executed on the server:"
msgstr ""
@@ -2457,6 +2458,10 @@ msgstr ""
msgid "Available methods"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:109
msgid "Available packages"
msgstr ""
#: app/models/ai/agent/type/ticket_tagger.rb:119
msgid "Available tags are limited to existing in the manage tags section. AI agent won't be able to generate new ones. %l"
msgstr ""
@@ -3736,8 +3741,10 @@ msgstr ""
msgid "Configuration of performance settings via environment variables."
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:150
#: app/assets/javascripts/app/views/integration/exchange.jst.eco:25
#: app/assets/javascripts/app/views/integration/ldap.jst.eco:6
#: app/assets/javascripts/app/views/package.jst.eco:6
msgid "Configure"
msgstr ""
@@ -4020,6 +4027,7 @@ msgstr ""
msgid "Copy current object number (e. g. Ticket#) to clipboard"
msgstr ""
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailTopBar/TopBarHeaderCompact.vue:34
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailTopBar/TopBarHeaderFull.vue:47
msgid "Copy organization display name"
msgstr ""
@@ -4032,8 +4040,8 @@ msgstr ""
msgid "Copy text"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderCompact.vue:56
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderFull.vue:73
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderCompact.vue:34
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderFull.vue:54
msgid "Copy ticket number"
msgstr ""
@@ -4057,7 +4065,6 @@ msgstr ""
msgid "Copy token"
msgstr ""
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailTopBar/TopBarHeaderCompact.vue:34
#: app/frontend/apps/desktop/pages/user/components/UserDetailTopBar/TopBarHeaderCompact.vue:34
#: app/frontend/apps/desktop/pages/user/components/UserDetailTopBar/TopBarHeaderFull.vue:43
msgid "Copy user display name"
@@ -4612,7 +4619,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_plugin/keyboard_shortcuts.coffee:147
#: app/assets/javascripts/app/controllers/dashboard.coffee:28
#: app/assets/javascripts/app/controllers/package.coffee:43
#: app/assets/javascripts/app/controllers/package.coffee:59
#: app/frontend/apps/desktop/pages/dashboard/routes.ts:25
msgid "Dashboard"
msgstr ""
@@ -5539,7 +5546,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/data_privacy.coffee:188
#: app/assets/javascripts/app/controllers/knowledge_base/base_form.coffee:78
#: app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee:9
#: app/assets/javascripts/app/controllers/package.coffee:54
#: app/assets/javascripts/app/controllers/package.coffee:73
#: app/assets/javascripts/app/controllers/user.coffee:102
#: app/assets/javascripts/app/controllers/user_profile/action_row.coffee:90
#: app/assets/javascripts/app/lib/mixins/knowledge_base_actions.coffee:26
@@ -6326,8 +6333,8 @@ msgstr ""
msgid "Edit security keys"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderCompact.vue:117
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderFull.vue:161
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderCompact.vue:95
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/TopBarHeaderFull.vue:142
msgid "Edit ticket title"
msgstr ""
@@ -8454,7 +8461,7 @@ msgstr ""
msgid "Highlight icon"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:96
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:100
msgid "Highlight options"
msgstr ""
@@ -8466,15 +8473,15 @@ msgstr ""
msgid "Highlighted text"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:89
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:93
msgid "Highlighter color: %s"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:157
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:161
msgid "Highlighting is active. Select content in the ticket article to apply."
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:159
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:163
msgid "Highlighting is inactive."
msgstr ""
@@ -9104,14 +9111,22 @@ msgstr ""
msgid "Install App"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:40
#: app/assets/javascripts/app/views/package.jst.eco:45
msgid "Install Package"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:32
#: app/assets/javascripts/app/views/package.jst.eco:144
msgid "Install package"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:37
msgid "Installed"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:49
msgid "Installed packages"
msgstr ""
#: app/models/ai/agent/type/custom_ai_agent.rb:34
msgid "Instruction"
msgstr ""
@@ -10461,7 +10476,7 @@ msgstr ""
#: app/assets/javascripts/app/views/api.jst.eco:2
#: app/assets/javascripts/app/views/calendar/index.jst.eco:3
#: app/assets/javascripts/app/views/data_privacy/index.jst.eco:3
#: app/assets/javascripts/app/views/package.jst.eco:2
#: app/assets/javascripts/app/views/package.jst.eco:3
#: app/assets/javascripts/app/views/sla/index.jst.eco:3
#: app/assets/javascripts/app/views/translation/index.jst.eco:4
#: app/assets/javascripts/app/views/user.jst.eco:3
@@ -10632,7 +10647,7 @@ msgstr ""
#: app/assets/javascripts/app/views/popover/organization.jst.eco:6
#: app/assets/javascripts/app/views/widget/organization.jst.eco:29
#: app/frontend/apps/desktop/components/Organization/OrganizationPopoverWithTrigger/OrganizationPopover.vue:79
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailViewContent.vue:142
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailViewContent.vue:145
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarOrganization/TicketSidebarOrganizationContent.vue:71
#: app/frontend/apps/mobile/components/Organization/OrganizationMembersList.vue:36
msgid "Members"
@@ -11061,7 +11076,7 @@ msgstr ""
#: app/assets/javascripts/app/views/integration/pgp_list.jst.eco:11
#: app/assets/javascripts/app/views/knowledge_base_video_servers/index.jst.eco:10
#: app/assets/javascripts/app/views/object_manager/index.jst.eco:56
#: app/assets/javascripts/app/views/package.jst.eco:47
#: app/assets/javascripts/app/views/package.jst.eco:59
#: app/assets/javascripts/app/views/profile/devices.jst.eco:12
#: app/assets/javascripts/app/views/profile/password.jst.eco:30
#: app/assets/javascripts/app/views/profile/token_access.jst.eco:16
@@ -11464,6 +11479,7 @@ msgstr ""
#: app/assets/javascripts/app/views/integration/pgp_list.jst.eco:6
#: app/assets/javascripts/app/views/integration/smime_group.jst.eco:12
#: app/assets/javascripts/app/views/integration/smime_list.jst.eco:6
#: app/assets/javascripts/app/views/package.jst.eco:53
#: app/assets/javascripts/app/views/ssl_certificates_list.jst.eco:6
#: app/assets/javascripts/app/views/time_accounting/by_activity.jst.eco:3
#: app/assets/javascripts/app/views/time_accounting/by_customer.jst.eco:3
@@ -12206,7 +12222,7 @@ msgstr ""
msgid "Only available for users with shared organizations"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:9
#: app/assets/javascripts/app/views/package.jst.eco:14
msgid "Only packages from known, trusted, and verified sources should be installed."
msgstr ""
@@ -12439,7 +12455,7 @@ msgstr ""
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailTopBar/TopBarHeaderFull.vue:28
#: app/frontend/apps/desktop/pages/organization/routes.ts:9
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/plugins/organization.ts:13
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:83
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:84
#: app/frontend/apps/mobile/pages/organization/routes.ts:8
#: app/frontend/apps/mobile/pages/organization/views/OrganizationDetailView.vue:30
#: app/frontend/apps/mobile/pages/ticket/views/TicketInformation/plugins/organization.ts:6
@@ -12484,7 +12500,7 @@ msgstr ""
msgid "Organization support"
msgstr ""
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailViewContent.vue:191
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailViewContent.vue:194
#: app/frontend/apps/mobile/pages/user/views/UserDetailView.vue:79
msgid "Organization tickets"
msgstr ""
@@ -12717,15 +12733,27 @@ msgstr ""
msgid "POP3"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:2
#: app/assets/javascripts/app/views/package.jst.eco:3
msgid "Package"
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:131
msgid "Package installed successfully."
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:112
msgid "Package updated successfully."
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:3
#: db/seeds/permissions.rb:326
msgid "Packages"
msgstr ""
#: db/seeds/settings.rb:6390
msgid "Packages Token"
msgstr ""
#: app/assets/javascripts/app/views/generic/table_pager.jst.eco:3
msgid "Page %s"
msgstr ""
@@ -13081,6 +13109,10 @@ msgstr ""
msgid "Please enter a valid email address."
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:170
msgid "Please enter the API token you received from Zammad."
msgstr ""
#: app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco:14
msgid "Please enter the endpoint for accessing Exchange Web Services."
msgstr ""
@@ -13758,7 +13790,7 @@ msgstr ""
#: app/assets/javascripts/app/views/link/ticket/list.jst.eco:1
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent.vue:142
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:177
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:180
msgid "Related tickets"
msgstr ""
@@ -13873,7 +13905,7 @@ msgstr ""
msgid "Remove formatting"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:89
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:93
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/composables/useHighlightMenuState.ts:21
msgid "Remove highlight"
msgstr ""
@@ -14130,6 +14162,7 @@ msgstr ""
msgid "Reset to default"
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:158
#: app/assets/javascripts/app/views/profile/notification.jst.eco:75
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingNotifications.vue:223
msgid "Reset to default settings"
@@ -14660,7 +14693,7 @@ msgstr ""
msgid "Search URL is unsafe."
msgstr ""
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailViewContent.vue:172
#: app/frontend/apps/desktop/pages/organization/components/OrganizationDetailViewContent.vue:175
#: app/frontend/apps/desktop/pages/organization/components/OrganizationRelatedTickets/OrganizationTicketList.vue:94
#: app/frontend/apps/desktop/pages/user/components/UserRelatedCustomerTickets/CustomerTicketList.vue:100
msgid "Search all"
@@ -14851,7 +14884,7 @@ msgstr ""
#: app/frontend/apps/desktop/components/User/UserPopoverWithTrigger/UserPopover.vue:80
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarCustomer/TicketSidebarCustomerContent.vue:112
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:146
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:149
#: app/frontend/apps/mobile/pages/ticket/views/TicketInformation/TicketInformationCustomer.vue:94
#: app/frontend/apps/mobile/pages/user/views/UserDetailView.vue:133
#: db/seeds/object_manager_attributes.rb:1187
@@ -15073,7 +15106,7 @@ msgstr ""
msgid "Selected conditions"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:155
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketDetailTopBar/components/HighlightMenu.vue:159
msgid "Selected highlight color: %s"
msgstr ""
@@ -16077,7 +16110,7 @@ msgstr ""
#: app/assets/javascripts/app/models/ticket.coffee:12
#: app/assets/javascripts/app/views/agent_ticket_view/detail.jst.eco:23
#: app/assets/javascripts/app/views/data_privacy/tasks.jst.eco:31
#: app/assets/javascripts/app/views/package.jst.eco:50
#: app/assets/javascripts/app/views/package.jst.eco:62
#: app/graphql/gql/types/overview_type.rb:97
#: app/views/mailer/ticket_create/fr.html.erb:10
#: app/views/mailer/ticket_create/zh-cn.html.erb:10
@@ -16164,7 +16197,7 @@ msgstr ""
msgid "Storage Method"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:33
#: app/assets/javascripts/app/views/package.jst.eco:38
msgid "Store"
msgstr ""
@@ -17213,7 +17246,7 @@ msgstr ""
msgid "The included security token is invalid. This may be just a temporary error, please reload your browser, and try again."
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:7
#: app/assets/javascripts/app/views/package.jst.eco:12
msgid "The installation of packages comes with security implications, because arbitrary code will be executed in the context of the Zammad application."
msgstr ""
@@ -17967,7 +18000,7 @@ msgstr ""
msgid "There is no rollback of this deletion possible. If you are sure that you wish to proceed, please type \"%s\" into the input."
msgstr ""
#: app/assets/javascripts/app/controllers/package.coffee:54
#: app/assets/javascripts/app/controllers/package.coffee:73
msgid "There is no rollback of this deletion. If you are sure that you wish to proceed, please type \"%s\" into the input. All related data to this package will be lost."
msgstr ""
@@ -18416,6 +18449,10 @@ msgstr ""
msgid "This service shows you contacts of incoming calls and a caller list in realtime."
msgstr ""
#: db/seeds/settings.rb:6393
msgid "This setting defines the token to access the support.zammad.com instance for package remote commands."
msgstr ""
#: app/assets/javascripts/app/models/group.coffee:13
msgid "This signature is inactive, it won't be included in the reply."
msgstr ""
@@ -19578,6 +19615,10 @@ msgstr ""
msgid "Uncheck item"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:92
msgid "Uninstall package"
msgstr ""
#: app/assets/javascripts/app/views/time_accounting/settings.jst.eco:12
#: app/frontend/apps/desktop/components/Form/fields/FieldFilterSelector/operators/within-last-relative.ts:45
msgid "Unit"
@@ -19755,6 +19796,10 @@ msgstr ""
msgid "Update existing records with the attributes specified in the import data."
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:87
msgid "Update package"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarSharedDraftStart/TicketSidebarSharedDraftStartContent.vue:204
msgid "Update shared draft"
msgstr ""
@@ -19771,6 +19816,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_settings/area_ticket_number.coffee:123
#: app/assets/javascripts/app/controllers/_settings/form.coffee:81
#: app/assets/javascripts/app/controllers/maintenance.coffee:74
#: app/assets/javascripts/app/controllers/package.coffee:200
#: app/assets/javascripts/app/models/setting.coffee:20
msgid "Update successful."
msgstr ""
@@ -19784,6 +19830,10 @@ msgstr ""
msgid "Update time"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:71
msgid "Update to %s"
msgstr ""
#: app/assets/javascripts/app/models/ai_agent.coffee:11
#: app/assets/javascripts/app/models/application.coffee:11
#: app/assets/javascripts/app/models/calendar.coffee:14
@@ -20043,7 +20093,7 @@ msgstr ""
#: app/frontend/apps/desktop/entities/channel-email/composables/useEmailInboundForm.ts:117
#: app/frontend/apps/desktop/entities/channel-email/composables/useEmailOutboundForm.ts:73
#: app/frontend/apps/desktop/pages/user/components/UserDetailTopBar/TopBarHeaderFull.vue:24
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:41
#: app/frontend/apps/desktop/pages/user/components/UserDetailViewContent.vue:42
#: app/frontend/apps/desktop/pages/user/routes.ts:9
#: app/frontend/apps/mobile/pages/user/routes.ts:7
#: app/frontend/apps/mobile/pages/user/views/UserDetailView.vue:32
@@ -20296,7 +20346,7 @@ msgstr ""
msgid "Vector DB knowledge base chunking strategy"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:49
#: app/assets/javascripts/app/views/package.jst.eco:61
msgid "Vendor"
msgstr ""
@@ -20351,7 +20401,7 @@ msgid "Verifying…"
msgstr ""
#: app/assets/javascripts/app/controllers/version.coffee:3
#: app/assets/javascripts/app/views/package.jst.eco:48
#: app/assets/javascripts/app/views/package.jst.eco:60
#: app/assets/javascripts/app/views/version.jst.eco:2
#: app/frontend/apps/mobile/pages/account/views/AccountOverview.vue:165
#: lib/excel_sheet/ai/analytics/errors.rb:25
@@ -22381,6 +22431,10 @@ msgstr ""
msgid "none"
msgstr ""
#: app/assets/javascripts/app/views/package.jst.eco:132
msgid "not installed"
msgstr ""
#: app/helpers/knowledge_base_visibility_note_helper.rb:27
msgid "not published"
msgstr ""
@@ -22449,7 +22503,7 @@ msgstr ""
msgid "or sign in using"
msgstr ""
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleReply.vue:101
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ArticleReply.vue:109
msgid "or use the reply actions on articles."
msgstr ""

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/install.rb'
require 'tasks/zammad/package/install'
Tasks::Zammad::Package::Install.register_rake_task

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class Install < Tasks::Zammad::Command
class Install < Tasks::Zammad::PackageCommand
def self.usage
"#{super} /path/to/package.zpm"

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package/install_api'
Tasks::Zammad::Package::InstallApi.register_rake_task

View File

@@ -0,0 +1,55 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class InstallApi < Tasks::Zammad::PackageCommand
def self.usage
"#{super} Zammad-Packages 7.0.x dry\n#{super} Zammad-Packages 7.0.x prod"
end
def self.description
'Install packages remotely via API to a certain version.'
end
ARGUMENT_COUNT = 3
def self.task_handler
package_name = ArgvHelper.argv[1]
version_name = ArgvHelper.argv[2]
prod_mode = ArgvHelper.argv[3] == 'prod'
validate_args!(version_name, ArgvHelper.argv[3])
puts "Executing rake task in #{prod_mode ? 'prod mode' : 'dry mode'}..."
show_post_install_hint = false
::Package.api_packages({ version_name: version_name }).each do |package_data|
next if package_name != 'all' && package_data['name'] != package_name
next if package_up_to_date?(package_data)
show_post_install_hint |= install_package(package_data, prod_mode)
end
return if !show_post_install_hint || !prod_mode
puts "Please run package migrations now via 'zammad:package:post_install'."
end
def self.install_package(package_data, prod_mode)
puts "Installing #{package_data['name']} #{package_data['version']}..."
::Package.install(string: package_data.to_json) if prod_mode
puts 'done.'
prod_mode
end
def self.package_up_to_date?(package_data)
existing = ::Package.find_by(name: package_data['name'])
existing.present? && Gem::Version.new(existing.version) >= Gem::Version.new(package_data['version'])
end
end
end
end
end

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/list.rb'
require 'tasks/zammad/package/list'
Tasks::Zammad::Package::List.register_rake_task

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class List < Tasks::Zammad::Command
class List < Tasks::Zammad::PackageCommand
def self.description
'List all installed Zammad addon packages'

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package/list_api'
Tasks::Zammad::Package::ListApi.register_rake_task

View File

@@ -0,0 +1,26 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class ListApi < Tasks::Zammad::PackageCommand
def self.description
'List packages remotely via API to a certain version.'
end
def self.task_handler
abort "Please set a token to access addons on support.zammad.com via:\n\nroot> zammad rails r \"Setting.set('packages_token', 'xxx')\"\n\n" if ::Package.api_token.blank?
puts 'Package'.ljust(50) + 'Version'.ljust(20) + 'Installed'.ljust(10) + 'Newest'.ljust(20)
::Package.api_packages({ branch_name: 'master' }).each do |package_data|
existing_data = ::Package.find_by(name: package_data['name'])
puts package_data['name'].ljust(50) + (existing_data ? existing_data['version'] : '').ljust(20) + (existing_data ? 'Yes' : 'No').ljust(10) + package_data['version'].ljust(20)
end
end
end
end
end
end

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/migrate.rb'
require 'tasks/zammad/package/migrate'
Tasks::Zammad::Package::Migrate.register_rake_task

View File

@@ -1,6 +1,6 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
@@ -8,7 +8,7 @@ module Tasks
# Package migrations must not be executed in the same process that also executed
# Package.install or Package.link, as the codebase is in an inconsistent state.
# This is enforced by Tasks:Zammad::Command which prevents command chaining.
class Migrate < Tasks::Zammad::Command
class Migrate < Tasks::Zammad::PackageCommand
def self.description
'Execute all pending Zammad addon package migrations'

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/post_install.rb'
require 'tasks/zammad/package/post_install'
Tasks::Zammad::Package::PostInstall.register_rake_task

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class PostInstall < Tasks::Zammad::Command
class PostInstall < Tasks::Zammad::PackageCommand
def self.description
'Runs all steps to finalize package installation.'
end

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/precompile.rb'
require 'tasks/zammad/package/precompile'
Tasks::Zammad::Package::Precompile.register_rake_task

View File

@@ -1,6 +1,6 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
@@ -8,7 +8,7 @@ module Tasks
# Package migrations must not be executed in the same process that also executed
# Package.install or Package.link, as the codebase is in an inconsistent state.
# This is enforced by Tasks:Zammad::Command which prevents command chaining.
class Precompile < Tasks::Zammad::Command
class Precompile < Tasks::Zammad::PackageCommand
def self.description
'Execute all package related precompilations.'

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/reinstall.rb'
require 'tasks/zammad/package/reinstall'
Tasks::Zammad::Package::Reinstall.register_rake_task

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class Reinstall < Tasks::Zammad::Command
class Reinstall < Tasks::Zammad::PackageCommand
def self.usage
"#{super} MyPackage"

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/reinstall_all.rb'
require 'tasks/zammad/package/reinstall_all'
Tasks::Zammad::Package::ReinstallAll.register_rake_task

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class ReinstallAll < Tasks::Zammad::Command
class ReinstallAll < Tasks::Zammad::PackageCommand
def self.description
'Reinstall all installed Zammad addon packages'

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/uninstall.rb'
require 'tasks/zammad/package/uninstall'
Tasks::Zammad::Package::Uninstall.register_rake_task

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class Uninstall < Tasks::Zammad::Command
class Uninstall < Tasks::Zammad::PackageCommand
def self.usage
"#{super} MyPackage"

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/uninstall_all_files.rb'
require 'tasks/zammad/package/uninstall_all_files'
Tasks::Zammad::Package::UninstallAllFiles.register_rake_task

View File

@@ -1,11 +1,11 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class UninstallAllFiles < Tasks::Zammad::Command
class UninstallAllFiles < Tasks::Zammad::PackageCommand
def self.description
'Uninstall all package files in the filesystem only without executing migrations'
end

View File

@@ -0,0 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package/update_api'
Tasks::Zammad::Package::UpdateApi.register_rake_task

View File

@@ -0,0 +1,53 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class UpdateApi < Tasks::Zammad::PackageCommand
def self.usage
"#{super} all 7.0.x dry\n#{super} all 7.0.x prod"
end
def self.description
'Update packages remotely via API to a certain version.'
end
ARGUMENT_COUNT = 3
def self.task_handler
package_name = ArgvHelper.argv[1]
version_name = ArgvHelper.argv[2]
prod_mode = ArgvHelper.argv[3] == 'prod'
validate_args!(version_name, ArgvHelper.argv[3])
puts "Executing rake task in #{prod_mode ? 'prod mode' : 'dry mode'}..."
show_post_install_hint = false
::Package.api_packages({ version_name: version_name }).each do |package_data|
next if package_name != 'all' && package_data['name'] != package_name
show_post_install_hint |= update_package(package_data, prod_mode)
end
return if !show_post_install_hint || !prod_mode
puts "Please run package migrations now via 'zammad:package:post_install'."
end
def self.update_package(package_data, prod_mode)
existing = ::Package.find_by(name: package_data['name'])
return false if existing.blank?
return false if Gem::Version.new(existing.version) >= Gem::Version.new(package_data['version'])
puts "Updating #{package_data['name']} from version #{existing.version} to #{package_data['version']}..."
::Package.install(string: package_data.to_json) if prod_mode
puts 'done.'
true
end
end
end
end
end

View File

@@ -1,4 +1,4 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require_dependency 'tasks/zammad/package/verify.rb'
require 'tasks/zammad/package/verify'
Tasks::Zammad::Package::Verify.register_rake_task

View File

@@ -1,14 +1,14 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
require 'tasks/zammad/package_command'
module Tasks
module Zammad
module Package
class Verify < Tasks::Zammad::Command
class Verify < Tasks::Zammad::PackageCommand
def self.description
'Verfies all installed Zammad addon packages'
'Verifies all installed Zammad addon packages'
end
def self.task_handler

View File

@@ -0,0 +1,28 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'tasks/zammad/command'
class Tasks::Zammad::PackageCommand < Tasks::Zammad::Command
def self.package_token
ENV['PACKAGES_TOKEN'] || Setting.get('packages_token')
end
def self.validate_args!(version_name, mode)
abort "Error: Invalid parameter version name '#{version_name}'!" if version_name.blank? || version_name !~ %r{^(\d+)\.(\d+)\.x\.?$}
abort "Error: Invalid parameter execution mode '#{mode}'!" if %w[dry prod].exclude?(mode)
abort "Please set a token to access addons on support.zammad.com via:\n\nroot> zammad rails r \"Setting.set('packages_token', 'xxx')\"\n\n" if ::Package.api_token.blank?
end
def self.request_zip_file(params)
require 'zip'
response = UserAgent.get('https://support.zammad.com/api/v1/addon_releases/download/organization', params, { bearer_token: package_token })
abort 'Error: Download of organization package files failed!' if !response.code.starts_with?('2')
zip_file = Tempfile.new
zip_file.binmode
zip_file.write(response.body)
zip_file.rewind
zip_file
end
end

View File

@@ -0,0 +1,112 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Tasks::Zammad::Package::InstallApi do
describe '.description' do
it 'returns the description' do
expect(described_class.description).to eq('Install packages remotely via API to a certain version.')
end
end
describe '.task_handler' do
before do
allow(ArgvHelper).to receive(:argv).and_return(argv)
allow(Package).to receive_messages(api_token: token, api_packages: api_packages)
end
let(:token) { 'secret' }
let(:api_packages) { [] }
context 'with invalid version name' do
let(:argv) { %w[zammad:package:install_api all invalid dry] }
it 'aborts with error' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output("Error: Invalid parameter version name 'invalid'!\n").to_stderr
end
end
context 'with invalid execution mode' do
let(:argv) { %w[zammad:package:install_api all 7.0.x execute] }
it 'aborts with error' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output("Error: Invalid parameter execution mode 'execute'!\n").to_stderr
end
end
context 'without token' do
let(:argv) { %w[zammad:package:install_api all 7.0.x dry] }
let(:token) { nil }
it 'aborts with hint' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output(%r{Please set a token}).to_stderr
end
end
context 'with valid arguments in dry mode' do
let(:argv) { %w[zammad:package:install_api all 7.0.x dry] }
let(:api_packages) do
[{ 'name' => 'TestPackage', 'version' => '7.0.1' }]
end
it 'does not call Package.install', :aggregate_failures do
allow(Package).to receive(:install)
expect { described_class.task_handler }.to output(%r{dry mode}).to_stdout
expect(Package).not_to have_received(:install)
end
end
context 'with valid arguments in prod mode' do
let(:argv) { %w[zammad:package:install_api all 7.0.x prod] }
let(:api_packages) do
[{ 'name' => 'TestPackage', 'version' => '7.0.1' }]
end
before { allow(Package).to receive(:find_by).and_return(nil) }
it 'calls Package.install and outputs progress', :aggregate_failures do
allow(Package).to receive(:install)
expect { described_class.task_handler }
.to output(%r{Installing TestPackage 7\.0\.1}).to_stdout
expect(Package).to have_received(:install).with(string: api_packages.first.to_json)
end
end
context 'when package is already up-to-date' do
let(:argv) { %w[zammad:package:install_api all 7.0.x prod] }
let(:api_packages) do
[{ 'name' => 'TestPackage', 'version' => '7.0.1' }]
end
let(:existing) { instance_double(Package, version: '7.0.1') }
before { allow(Package).to receive(:find_by).and_return(existing) }
it 'skips the package' do
allow(Package).to receive(:install)
described_class.task_handler
expect(Package).not_to have_received(:install)
end
end
context 'when filtering by package name' do
let(:argv) { %w[zammad:package:install_api OtherPackage 7.0.x prod] }
let(:api_packages) do
[{ 'name' => 'TestPackage', 'version' => '7.0.1' }]
end
before { allow(Package).to receive(:find_by).and_return(nil) }
it 'skips packages that do not match' do
allow(Package).to receive(:install)
described_class.task_handler
expect(Package).not_to have_received(:install)
end
end
end
end

View File

@@ -0,0 +1,63 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Tasks::Zammad::Package::ListApi do
describe '.description' do
it 'returns the description' do
expect(described_class.description).to eq('List packages remotely via API to a certain version.')
end
end
describe '.task_handler' do
before do
allow(Package).to receive_messages(api_token: token, api_packages: api_packages)
end
let(:token) { 'secret' }
let(:api_packages) { [] }
context 'without token' do
let(:token) { nil }
it 'aborts with hint' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output(%r{Please set a token}).to_stderr
end
end
context 'with no packages available' do
it 'outputs only the header' do
expect { described_class.task_handler }
.to output(%r{Package.*Version.*Installed.*Newest}).to_stdout
end
end
context 'with a package that is not installed' do
let(:api_packages) { [{ 'name' => 'TestPackage', 'version' => '7.0.1' }] }
before { allow(Package).to receive(:find_by).and_return(nil) }
it 'shows the package as not installed' do
expect { described_class.task_handler }
.to output(%r{TestPackage.*No.*7.0.1}).to_stdout
end
end
context 'with a package that is installed' do
let(:api_packages) { [{ 'name' => 'TestPackage', 'version' => '7.0.2' }] }
let(:existing) { instance_double(Package, version: '7.0.1') }
before do
allow(Package).to receive(:find_by).and_return(existing)
allow(existing).to receive(:[]).with('version').and_return('7.0.1')
end
it 'shows the installed version and newest version' do
expect { described_class.task_handler }
.to output(%r{TestPackage.*7.0.1.*Yes.*7.0.2}).to_stdout
end
end
end
end

View File

@@ -0,0 +1,114 @@
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Tasks::Zammad::Package::UpdateApi do
describe '.description' do
it 'returns the description' do
expect(described_class.description).to eq('Update packages remotely via API to a certain version.')
end
end
describe '.task_handler' do
before do
allow(ArgvHelper).to receive(:argv).and_return(argv)
allow(Package).to receive_messages(api_token: token, api_packages: api_packages)
end
let(:token) { 'secret' }
let(:api_packages) { [] }
context 'with invalid version name' do
let(:argv) { %w[zammad:package:update_api all invalid dry] }
it 'aborts with error' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output("Error: Invalid parameter version name 'invalid'!\n").to_stderr
end
end
context 'with invalid execution mode' do
let(:argv) { %w[zammad:package:update_api all 7.0.x execute] }
it 'aborts with error' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output("Error: Invalid parameter execution mode 'execute'!\n").to_stderr
end
end
context 'without token' do
let(:argv) { %w[zammad:package:update_api all 7.0.x dry] }
let(:token) { nil }
it 'aborts with hint' do
expect { described_class.task_handler }
.to raise_error(SystemExit)
.and output(%r{Please set a token}).to_stderr
end
end
context 'when package is not installed' do
let(:argv) { %w[zammad:package:update_api all 7.0.x prod] }
let(:api_packages) { [{ 'name' => 'TestPackage', 'version' => '7.0.2' }] }
before { allow(Package).to receive(:find_by).and_return(nil) }
it 'skips the package' do
allow(Package).to receive(:install)
described_class.task_handler
expect(Package).not_to have_received(:install)
end
end
context 'when installed version is already newest' do
let(:argv) { %w[zammad:package:update_api all 7.0.x prod] }
let(:api_packages) { [{ 'name' => 'TestPackage', 'version' => '7.0.2' }] }
let(:existing) { instance_double(Package, version: '7.0.2') }
before { allow(Package).to receive(:find_by).and_return(existing) }
it 'skips the package' do
allow(Package).to receive(:install)
described_class.task_handler
expect(Package).not_to have_received(:install)
end
end
context 'when a newer version is available in prod mode' do
let(:argv) { %w[zammad:package:update_api all 7.0.x prod] }
let(:api_packages) { [{ 'name' => 'TestPackage', 'version' => '7.0.2' }] }
let(:existing) { instance_double(Package, version: '7.0.1') }
before do
allow(Package).to receive(:find_by).and_return(existing)
allow(existing).to receive(:[]).with('version').and_return('7.0.1')
end
it 'calls Package.install and outputs progress', :aggregate_failures do
allow(Package).to receive(:install)
expect { described_class.task_handler }
.to output(%r{Updating TestPackage from version 7\.0\.1 to 7\.0\.2}).to_stdout
expect(Package).to have_received(:install).with(string: api_packages.first.to_json)
end
end
context 'when a newer version is available in dry mode' do
let(:argv) { %w[zammad:package:update_api all 7.0.x dry] }
let(:api_packages) { [{ 'name' => 'TestPackage', 'version' => '7.0.2' }] }
let(:existing) { instance_double(Package, version: '7.0.1') }
before do
allow(Package).to receive(:find_by).and_return(existing)
allow(existing).to receive(:[]).with('version').and_return('7.0.1')
end
it 'does not call Package.install', :aggregate_failures do
allow(Package).to receive(:install)
expect { described_class.task_handler }.to output(%r{dry mode}).to_stdout
expect(Package).not_to have_received(:install)
end
end
end
end