cht-conf 6.4.1 → 6.5.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [6.5.1](https://github.com/medic/cht-conf/compare/v6.5.0...v6.5.1) (2026-07-15)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **#830:** backup-form should not crash if form has no attachments ([#831](https://github.com/medic/cht-conf/issues/831)) ([48ada02](https://github.com/medic/cht-conf/commit/48ada020f4c1df2e896da7e64a55b71779845bbb)), closes [#830](https://github.com/medic/cht-conf/issues/830)
7
+
8
+ # [6.5.0](https://github.com/medic/cht-conf/compare/v6.4.1...v6.5.0) (2026-06-23)
9
+
10
+
11
+ ### Features
12
+
13
+ * **#10224:** add actions for upload/delete UI extensions ([#823](https://github.com/medic/cht-conf/issues/823)) ([178bbfc](https://github.com/medic/cht-conf/commit/178bbfc9f412a2ea6c7ee2fe9d0610a18c2a50e3)), closes [#10224](https://github.com/medic/cht-conf/issues/10224)
14
+
1
15
  ## [6.4.1](https://github.com/medic/cht-conf/compare/v6.4.0...v6.4.1) (2026-05-12)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "6.4.1",
3
+ "version": "6.5.1",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
@@ -20,7 +20,7 @@ module.exports = {
20
20
  return db.get(form.id, { attachments: true, binary: true })
21
21
  .then(form => {
22
22
  fs.writeJson(`${backupDir}/context.json`, form.context);
23
- Object.keys(form._attachments).forEach(name => {
23
+ Object.keys(form._attachments || {}).forEach(name => {
24
24
  const att = form._attachments[name];
25
25
  const destination = fs.path.join(backupDir, name);
26
26
  if (fs.path.dirname(destination) !== backupDir) {
@@ -0,0 +1,12 @@
1
+ const uiExtensionsLib = require('../lib/ui-extensions');
2
+ const environment = require('../lib/environment');
3
+
4
+ const executeDeleteUiExtensions = async () => {
5
+ const specifiedExtensions = environment.extraArgs?.filter(arg => !arg.startsWith('--')) ?? [];
6
+ await uiExtensionsLib.deleteUiExtensions(specifiedExtensions);
7
+ };
8
+
9
+ module.exports = {
10
+ requiresInstance: true,
11
+ execute: executeDeleteUiExtensions
12
+ };
@@ -0,0 +1,15 @@
1
+ const projectPaths = require('../lib/project-paths');
2
+ const uploadUiExtensionsLib = require('../lib/ui-extensions');
3
+ const environment = require('../lib/environment');
4
+
5
+
6
+ const executeUploadUiExtensions = async () => {
7
+ const uiExtensionsDir = `${environment.pathToProject}/${projectPaths.UI_EXTENSIONS_PATH}`;
8
+ const specifiedExtensions = environment.extraArgs?.filter(arg => !arg.startsWith('--')) ?? [];
9
+ await uploadUiExtensionsLib.uploadUiExtensions(uiExtensionsDir, specifiedExtensions);
10
+ };
11
+
12
+ module.exports = {
13
+ requiresInstance: true,
14
+ execute: executeUploadUiExtensions
15
+ };
package/src/lib/main.js CHANGED
@@ -38,6 +38,7 @@ const defaultActions = [
38
38
  'upload-custom-translations',
39
39
  'upload-privacy-policies',
40
40
  'upload-extension-libs',
41
+ 'upload-ui-extensions',
41
42
  'upload-database-indexes',
42
43
  ];
43
44
  const defaultArchiveActions = [
@@ -61,6 +62,7 @@ const defaultArchiveActions = [
61
62
  'upload-custom-translations',
62
63
  'upload-privacy-policies',
63
64
  'upload-extension-libs',
65
+ 'upload-ui-extensions',
64
66
  'upload-database-indexes',
65
67
  ];
66
68
 
@@ -11,4 +11,5 @@ module.exports = {
11
11
  RESOURCE_CONFIG_PATH: 'resources.json',
12
12
  RESOURCES_DIR_PATH: 'resources',
13
13
  EXTENSION_LIBS_PATH: 'extension-libs',
14
+ UI_EXTENSIONS_PATH: 'ui-extensions',
14
15
  };
@@ -0,0 +1,166 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const Joi = require('joi');
4
+ const environment = require('./environment');
5
+ const log = require('./log');
6
+ const insertOrReplace = require('./insert-or-replace');
7
+ const warnUploadOverwrite = require('./warn-upload-overwrite');
8
+ const pouch = require('./db');
9
+ const attachmentFromFile = require('./attachment-from-file');
10
+
11
+ const TYPE = 'ui-extension';
12
+ const ID_PREFIX = `${TYPE}:`;
13
+ const ATTACHMENT_NAME = 'extension.js';
14
+
15
+ const schema = Joi.object({
16
+ extension_type: Joi.string().valid('header_tab', 'sidebar_tab').required(),
17
+ title: Joi.string().required(),
18
+ icon: Joi.string().pattern(/^fa-/).optional(),
19
+ resource_icon: Joi.string().optional(),
20
+ roles: Joi.array().items(Joi.string()),
21
+ accent_color: Joi.string().optional(),
22
+ weight: Joi.number().optional(),
23
+ config: Joi.object().unknown(true)
24
+ }).oxor('icon', 'resource_icon');
25
+
26
+ // Name will be used in Couch _id and in web component name.
27
+ const isValidExtensionName = (name) => /^[a-zA-Z0-9_.-]+$/.test(name);
28
+
29
+ const getNamesToUpload = (uiExtensionsDir) => {
30
+ const allFiles = fs.readdirSync(uiExtensionsDir);
31
+ const extensionNames = new Set(
32
+ allFiles
33
+ .filter(f => f.endsWith('.js') || f.endsWith('.properties.json'))
34
+ .map(f => f.replace(/(\.properties\.json|\.js)$/, ''))
35
+ );
36
+
37
+ return Array.from(extensionNames);
38
+ };
39
+
40
+ const readPropertiesFile = (propsPath) => {
41
+ try {
42
+ const rawProps = fs.readFileSync(propsPath, 'utf-8');
43
+ return JSON.parse(rawProps);
44
+ } catch (err) {
45
+ throw new Error(
46
+ `Failed to parse ${propsPath.split('/').at(-1)} - Invalid JSON format: ${err.message}`
47
+ );
48
+ }
49
+ };
50
+
51
+ const getExtensionDoc = (uiExtensionsDir, name) => {
52
+ if (!isValidExtensionName(name)) {
53
+ throw new Error(
54
+ `UI Extension name "${name}" is invalid. It must contain only letters, digits, hyphens, ` +
55
+ 'periods, or underscores.'
56
+ );
57
+ }
58
+
59
+ const jsPath = path.join(uiExtensionsDir, `${name}.js`);
60
+ const propsPath = path.join(uiExtensionsDir, `${name}.properties.json`);
61
+
62
+ if (!fs.existsSync(jsPath) || !fs.existsSync(propsPath)) {
63
+ throw new Error(`UI Extension "${name}" is missing either its .js or .properties.json file.`);
64
+ }
65
+
66
+ const propsContent = readPropertiesFile(propsPath);
67
+
68
+ const validation = schema.validate(propsContent);
69
+ if (validation.error) {
70
+ throw new Error(`Validation error for UI extension "${name}": ${validation.error.message}`);
71
+ }
72
+
73
+ return {
74
+ _id: `${ID_PREFIX}${name}`,
75
+ ...propsContent,
76
+ type: TYPE,
77
+ _attachments: {
78
+ [ATTACHMENT_NAME]: attachmentFromFile(jsPath)
79
+ }
80
+ };
81
+ };
82
+
83
+ const uploadDocToDb = async (db, doc, name) => {
84
+ const changes = await warnUploadOverwrite.preUploadDoc(db, doc);
85
+ if (!changes) {
86
+ log.info(`UI Extension "${name}" not uploaded as already up to date`);
87
+ return;
88
+ }
89
+
90
+ await insertOrReplace(db, doc);
91
+ log.info(`UI Extension "${name}" upload complete`);
92
+ await warnUploadOverwrite.postUploadDoc(db, doc);
93
+ };
94
+
95
+ const uploadUiExtensions = async (uiExtensionsDir, specificExtensions = []) => {
96
+ if (!fs.existsSync(uiExtensionsDir)) {
97
+ log.info(`No directory found at "${uiExtensionsDir}" - not uploading ui-extensions`);
98
+ return;
99
+ }
100
+
101
+ // if specific extensions are provided, bypass directory reading
102
+ // missing files will be caught by getExtensionDoc
103
+ const namesToUpload = specificExtensions.length ? specificExtensions : getNamesToUpload(uiExtensionsDir);
104
+
105
+ if (!namesToUpload.length) {
106
+ log.info('No UI extensions to upload.');
107
+ return;
108
+ }
109
+
110
+ log.info(`Found UI extensions: ${namesToUpload.join(', ')}`);
111
+
112
+ // process all docs before uploading any to ensure validation passes for everything
113
+ const namesWithDocs = namesToUpload.map(name => [name, getExtensionDoc(uiExtensionsDir, name)]);
114
+
115
+ const db = pouch(environment.apiUrl);
116
+
117
+ for (const [name, doc] of namesWithDocs) {
118
+ await uploadDocToDb(db, doc, name);
119
+ }
120
+ };
121
+
122
+ const getDocsToDelete = async (db, specifiedExtensions) => {
123
+ const keys = specifiedExtensions.map(name => `${ID_PREFIX}${name}`);
124
+ const opts = keys.length ? { keys }: {
125
+ startkey: ID_PREFIX,
126
+ endkey: `${ID_PREFIX}\ufff0`
127
+ };
128
+ const result = await db.allDocs({
129
+ ...opts,
130
+ include_docs: true
131
+ });
132
+ const docs = result.rows
133
+ .map(row => row.doc)
134
+ .filter(Boolean);
135
+ keys
136
+ .filter(key => !docs.some(({ _id }) => _id === key))
137
+ .forEach(name => log.warn(`UI Extension "${name.slice(ID_PREFIX.length)}" not found in database. Skipping.`));
138
+ return docs;
139
+ };
140
+
141
+ const deleteUiExtensions = async (specifiedExtensions = []) => {
142
+ const db = pouch(environment.apiUrl);
143
+
144
+ const docs = await getDocsToDelete(db, specifiedExtensions);
145
+
146
+ if (!docs.length) {
147
+ log.info('No UI extensions found to delete.');
148
+ return;
149
+ }
150
+
151
+ log.info(`Deleting ${docs.length} UI extension(s)...`);
152
+
153
+ for (const doc of docs) {
154
+ try {
155
+ await db.remove(doc);
156
+ log.info(`Deleted UI Extension: ${doc._id.slice(ID_PREFIX.length)}`);
157
+ } catch (err) {
158
+ throw new Error(`Failed to delete ${doc._id}: ${err.message}`);
159
+ }
160
+ }
161
+ };
162
+
163
+ module.exports = {
164
+ uploadUiExtensions,
165
+ deleteUiExtensions
166
+ };