newo 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +139 -1
  2. package/dist/api.d.ts +14 -1
  3. package/dist/api.js +74 -0
  4. package/dist/cli/commands/help.js +20 -1
  5. package/dist/cli/commands/list-actions.d.ts +3 -0
  6. package/dist/cli/commands/list-actions.js +89 -0
  7. package/dist/cli/commands/profile.d.ts +3 -0
  8. package/dist/cli/commands/profile.js +62 -0
  9. package/dist/cli/commands/pull-akb.d.ts +3 -0
  10. package/dist/cli/commands/pull-akb.js +19 -0
  11. package/dist/cli/commands/pull-attributes.js +7 -0
  12. package/dist/cli/commands/pull-integrations.d.ts +3 -0
  13. package/dist/cli/commands/pull-integrations.js +19 -0
  14. package/dist/cli/commands/push-akb.d.ts +3 -0
  15. package/dist/cli/commands/push-akb.js +19 -0
  16. package/dist/cli/commands/push-integrations.d.ts +3 -0
  17. package/dist/cli/commands/push-integrations.js +19 -0
  18. package/dist/cli.js +24 -0
  19. package/dist/sync/akb.d.ts +14 -0
  20. package/dist/sync/akb.js +175 -0
  21. package/dist/sync/attributes.d.ts +19 -0
  22. package/dist/sync/attributes.js +221 -2
  23. package/dist/sync/integrations.d.ts +23 -0
  24. package/dist/sync/integrations.js +340 -0
  25. package/dist/sync/projects.js +171 -1
  26. package/dist/sync/push.js +15 -0
  27. package/dist/sync/skill-files.js +1 -1
  28. package/dist/sync/status.js +4 -2
  29. package/dist/types.d.ts +128 -0
  30. package/package.json +11 -3
  31. package/src/api.ts +132 -1
  32. package/src/cli/commands/help.ts +20 -1
  33. package/src/cli/commands/list-actions.ts +112 -0
  34. package/src/cli/commands/profile.ts +79 -0
  35. package/src/cli/commands/pull-akb.ts +27 -0
  36. package/src/cli/commands/pull-attributes.ts +8 -0
  37. package/src/cli/commands/pull-integrations.ts +27 -0
  38. package/src/cli/commands/push-akb.ts +27 -0
  39. package/src/cli/commands/push-integrations.ts +27 -0
  40. package/src/cli.ts +30 -0
  41. package/src/sync/akb.ts +205 -0
  42. package/src/sync/attributes.ts +269 -2
  43. package/src/sync/integrations.ts +403 -0
  44. package/src/sync/projects.ts +207 -1
  45. package/src/sync/push.ts +17 -0
  46. package/src/sync/skill-files.ts +1 -1
  47. package/src/sync/status.ts +4 -2
  48. package/src/types.ts +150 -0
package/src/cli.ts CHANGED
@@ -29,6 +29,12 @@ import { handleCreateParameterCommand } from './cli/commands/create-parameter.js
29
29
  import { handleCreatePersonaCommand } from './cli/commands/create-persona.js';
30
30
  import { handleCreateAttributeCommand } from './cli/commands/create-attribute.js';
31
31
  import { handleSandboxCommand } from './cli/commands/sandbox.js';
32
+ import { handlePullIntegrationsCommand } from './cli/commands/pull-integrations.js';
33
+ import { handlePushIntegrationsCommand } from './cli/commands/push-integrations.js';
34
+ import { handleListActionsCommand } from './cli/commands/list-actions.js';
35
+ import { handleProfileCommand } from './cli/commands/profile.js';
36
+ import { handlePullAkbCommand } from './cli/commands/pull-akb.js';
37
+ import { handlePushAkbCommand } from './cli/commands/push-akb.js';
32
38
  import type { CliArgs, NewoApiError } from './types.js';
33
39
 
34
40
  dotenv.config();
@@ -158,6 +164,30 @@ async function main(): Promise<void> {
158
164
  await handleCreateAttributeCommand(customerConfig, args, verbose);
159
165
  break;
160
166
 
167
+ case 'pull-integrations':
168
+ await handlePullIntegrationsCommand(customerConfig, args, verbose);
169
+ break;
170
+
171
+ case 'push-integrations':
172
+ await handlePushIntegrationsCommand(customerConfig, args, verbose);
173
+ break;
174
+
175
+ case 'list-actions':
176
+ await handleListActionsCommand(customerConfig, args, verbose);
177
+ break;
178
+
179
+ case 'profile':
180
+ await handleProfileCommand(customerConfig, args, verbose);
181
+ break;
182
+
183
+ case 'pull-akb':
184
+ await handlePullAkbCommand(customerConfig, args, verbose);
185
+ break;
186
+
187
+ case 'push-akb':
188
+ await handlePushAkbCommand(customerConfig, args, verbose);
189
+ break;
190
+
161
191
  default:
162
192
  console.error('Unknown command:', cmd);
163
193
  console.error('Run "newo --help" for usage information');
@@ -0,0 +1,205 @@
1
+ /**
2
+ * AKB (Knowledge Base) synchronization module
3
+ * Handles pull/push of AKB articles for personas with agents
4
+ */
5
+
6
+ import path from 'path';
7
+ import fs from 'fs-extra';
8
+ import yaml from 'js-yaml';
9
+ import type { AxiosInstance } from 'axios';
10
+ import { searchPersonas, getAkbTopics, importAkbArticle } from '../api.js';
11
+ import type {
12
+ Persona,
13
+ AkbTopicItem,
14
+ AkbYamlTopic,
15
+ AkbImportArticle
16
+ } from '../types.js';
17
+
18
+ /**
19
+ * Pull AKB articles for all personas linked to agents
20
+ */
21
+ export async function pullAkb(
22
+ client: AxiosInstance,
23
+ customerDir: string,
24
+ verbose: boolean = false
25
+ ): Promise<void> {
26
+ if (verbose) console.log('\n📚 Pulling AKB (Knowledge Base) from NEWO platform...\n');
27
+
28
+ // Create AKB directory
29
+ const akbDir = path.join(customerDir, 'akb');
30
+ await fs.ensureDir(akbDir);
31
+
32
+ // Fetch personas linked to agents
33
+ let allPersonas: Persona[] = [];
34
+ let page = 1;
35
+ const perPage = 30;
36
+
37
+ while (true) {
38
+ const response = await searchPersonas(client, true, page, perPage);
39
+ allPersonas = allPersonas.concat(response.items);
40
+
41
+ if (verbose) console.log(`✓ Fetched ${response.items.length} personas (page ${page}/${Math.ceil(response.metadata.total / perPage)})`);
42
+
43
+ if (response.items.length < perPage || allPersonas.length >= response.metadata.total) {
44
+ break;
45
+ }
46
+ page++;
47
+ }
48
+
49
+ if (verbose) console.log(`\n✓ Found ${allPersonas.length} personas linked to agents`);
50
+
51
+ let totalArticles = 0;
52
+
53
+ // Fetch AKB articles for each persona
54
+ for (const persona of allPersonas) {
55
+ if (verbose) console.log(`\n 📖 Processing persona: ${persona.name} (${persona.agent.idn})`);
56
+
57
+ // Fetch all AKB topics for this persona
58
+ let allTopicItems: AkbTopicItem[] = [];
59
+ let topicPage = 1;
60
+ const topicsPerPage = 100;
61
+
62
+ while (true) {
63
+ try {
64
+ const topicsResponse = await getAkbTopics(client, persona.id, topicPage, topicsPerPage);
65
+ allTopicItems = allTopicItems.concat(topicsResponse.items);
66
+
67
+ if (verbose) console.log(` ✓ Fetched ${topicsResponse.items.length} topics (page ${topicPage})`);
68
+
69
+ if (topicsResponse.items.length < topicsPerPage || allTopicItems.length >= topicsResponse.metadata.total) {
70
+ break;
71
+ }
72
+ topicPage++;
73
+ } catch (error: any) {
74
+ if (verbose) console.log(` ⚠ Could not fetch topics: ${error.message}`);
75
+ break;
76
+ }
77
+ }
78
+
79
+ if (allTopicItems.length > 0) {
80
+ // Convert to YAML format (extract topic from each item)
81
+ const yamlTopics: AkbYamlTopic[] = allTopicItems.map(item => ({
82
+ topic_name: item.topic.topic_name,
83
+ topic_facts: [...item.topic.topic_facts],
84
+ confidence: item.topic.confidence,
85
+ source: item.topic.source,
86
+ created_at: item.topic.created_at,
87
+ updated_at: item.topic.updated_at,
88
+ labels: [...item.topic.labels],
89
+ topic_summary: item.topic.topic_summary
90
+ }));
91
+
92
+ // Save to persona-specific YAML file using agent IDN
93
+ const agentIdn = persona.agent.idn;
94
+ const akbFile = path.join(akbDir, `${agentIdn}.yaml`);
95
+ await fs.writeFile(akbFile, yaml.dump(yamlTopics, { lineWidth: -1 }));
96
+
97
+ if (verbose) console.log(` ✓ Saved ${allTopicItems.length} articles → ${agentIdn}.yaml`);
98
+ totalArticles += allTopicItems.length;
99
+ } else {
100
+ if (verbose) console.log(` ℹ No AKB articles found for this persona`);
101
+ }
102
+ }
103
+
104
+ if (verbose) {
105
+ console.log(`\n✅ Saved AKB articles for ${allPersonas.length} personas`);
106
+ console.log(` Total articles: ${totalArticles}\n`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Push AKB articles from local files to NEWO platform
112
+ */
113
+ export async function pushAkb(
114
+ client: AxiosInstance,
115
+ customerDir: string,
116
+ verbose: boolean = false
117
+ ): Promise<void> {
118
+ if (verbose) console.log('\n📤 Pushing AKB articles to NEWO platform...\n');
119
+
120
+ const akbDir = path.join(customerDir, 'akb');
121
+
122
+ // Check if AKB directory exists
123
+ if (!await fs.pathExists(akbDir)) {
124
+ if (verbose) console.log('⚠ No akb directory found. Run pull-akb first.');
125
+ return;
126
+ }
127
+
128
+ // Get personas linked to agents for ID mapping
129
+ let allPersonas: Persona[] = [];
130
+ let page = 1;
131
+ const perPage = 30;
132
+
133
+ while (true) {
134
+ const response = await searchPersonas(client, true, page, perPage);
135
+ allPersonas = allPersonas.concat(response.items);
136
+ if (response.items.length < perPage || allPersonas.length >= response.metadata.total) {
137
+ break;
138
+ }
139
+ page++;
140
+ }
141
+
142
+ // Create persona mapping (agent.idn -> persona.id)
143
+ const personaMap = new Map<string, string>();
144
+ allPersonas.forEach(persona => {
145
+ personaMap.set(persona.agent.idn, persona.id);
146
+ personaMap.set(persona.name, persona.id); // Also map by name as fallback
147
+ });
148
+
149
+ // Read AKB files
150
+ const akbFiles = await fs.readdir(akbDir);
151
+ let totalImported = 0;
152
+ let totalFailed = 0;
153
+
154
+ for (const file of akbFiles) {
155
+ if (!file.endsWith('.yaml')) continue;
156
+
157
+ const fileBase = file.replace('.yaml', '');
158
+ const personaId = personaMap.get(fileBase);
159
+
160
+ if (!personaId) {
161
+ if (verbose) console.log(`⚠ Persona not found for file: ${file}, skipping...`);
162
+ continue;
163
+ }
164
+
165
+ if (verbose) console.log(`\n 📖 Processing: ${file}`);
166
+
167
+ // Read YAML file
168
+ const akbFile = path.join(akbDir, file);
169
+ const topics = yaml.load(await fs.readFile(akbFile, 'utf-8')) as AkbYamlTopic[];
170
+
171
+ if (!Array.isArray(topics)) {
172
+ if (verbose) console.log(` ⚠ Invalid YAML format, skipping...`);
173
+ continue;
174
+ }
175
+
176
+ // Import each article
177
+ for (const topic of topics) {
178
+ try {
179
+ const articleData: AkbImportArticle = {
180
+ topic_name: topic.topic_name,
181
+ topic_summary: topic.topic_summary,
182
+ topic_facts: topic.topic_facts,
183
+ confidence: topic.confidence,
184
+ source: topic.source,
185
+ labels: topic.labels,
186
+ persona_id: personaId
187
+ };
188
+
189
+ await importAkbArticle(client, articleData);
190
+ totalImported++;
191
+
192
+ if (verbose) console.log(` ✓ Imported: ${topic.topic_name}`);
193
+ } catch (error: any) {
194
+ totalFailed++;
195
+ if (verbose) console.error(` ❌ Failed to import ${topic.topic_name}: ${error.message}`);
196
+ }
197
+ }
198
+ }
199
+
200
+ if (verbose) {
201
+ console.log(`\n✅ Push completed:`);
202
+ console.log(` Imported: ${totalImported}`);
203
+ console.log(` Failed: ${totalFailed}\n`);
204
+ }
205
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Customer attributes synchronization module
2
+ * Customer and project attributes synchronization module
3
3
  */
4
- import { getCustomerAttributes } from '../api.js';
4
+ import { getCustomerAttributes, getProjectAttributes, listProjects, updateProjectAttribute } from '../api.js';
5
5
  import {
6
6
  writeFileSafe,
7
7
  customerAttributesPath,
8
8
  customerAttributesMapPath,
9
9
  customerAttributesBackupPath
10
10
  } from '../fsutil.js';
11
+ import path from 'path';
12
+ import fs from 'fs-extra';
11
13
  import yaml from 'js-yaml';
12
14
  import type { AxiosInstance } from 'axios';
13
15
  import type { CustomerConfig } from '../types.js';
@@ -107,4 +109,269 @@ export async function saveCustomerAttributes(
107
109
  console.error(`❌ Failed to save customer attributes for ${customer.idn}:`, error);
108
110
  throw error;
109
111
  }
112
+ }
113
+
114
+ /**
115
+ * Save project attributes to YAML format in project directory
116
+ */
117
+ export async function saveProjectAttributes(
118
+ client: AxiosInstance,
119
+ customer: CustomerConfig,
120
+ projectId: string,
121
+ projectIdn: string,
122
+ verbose: boolean = false
123
+ ): Promise<void> {
124
+ if (verbose) console.log(` 🔍 Fetching project attributes for ${projectIdn}...`);
125
+
126
+ try {
127
+ const response = await getProjectAttributes(client, projectId, true); // Include hidden attributes
128
+
129
+ // API returns { groups: [...], attributes: [...] }
130
+ const attributes = response.attributes || response;
131
+ if (verbose) console.log(` 📦 Found ${Array.isArray(attributes) ? attributes.length : 0} project attributes`);
132
+
133
+ if (!Array.isArray(attributes) || attributes.length === 0) {
134
+ if (verbose) console.log(` ℹ No project attributes found for ${projectIdn}`);
135
+ return;
136
+ }
137
+
138
+ // Create ID mapping for push operations
139
+ const idMapping: Record<string, string> = {};
140
+
141
+ // Transform attributes to match format (no ID fields)
142
+ const cleanAttributes = attributes.map(attr => {
143
+ // Store ID mapping
144
+ if (attr.id) {
145
+ idMapping[attr.idn] = attr.id;
146
+ }
147
+
148
+ // Special handling for complex JSON string values
149
+ let processedValue = attr.value;
150
+ if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) {
151
+ try {
152
+ const parsed = JSON.parse(attr.value);
153
+ processedValue = JSON.stringify(parsed, null, 0);
154
+ } catch (e) {
155
+ processedValue = attr.value;
156
+ }
157
+ }
158
+
159
+ return {
160
+ idn: attr.idn,
161
+ value: processedValue,
162
+ title: attr.title || "",
163
+ description: attr.description || "",
164
+ group: attr.group || "",
165
+ is_hidden: attr.is_hidden,
166
+ possible_values: attr.possible_values || [],
167
+ value_type: `__ENUM_PLACEHOLDER_${attr.value_type}__`
168
+ };
169
+ });
170
+
171
+ const attributesYaml = {
172
+ attributes: cleanAttributes
173
+ };
174
+
175
+ // Configure YAML output
176
+ let yamlContent = yaml.dump(attributesYaml, {
177
+ indent: 2,
178
+ quotingType: '"',
179
+ forceQuotes: false,
180
+ lineWidth: 80,
181
+ noRefs: true,
182
+ sortKeys: false,
183
+ flowLevel: -1
184
+ });
185
+
186
+ // Post-process to fix enum format
187
+ yamlContent = yamlContent.replace(/__ENUM_PLACEHOLDER_(\w+)__/g, '!enum "AttributeValueTypes.$1"');
188
+ yamlContent = yamlContent.replace(/\\"/g, '"');
189
+
190
+ // Save to project directory
191
+ const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn);
192
+ const projectDir = path.join(customerDir, 'projects', projectIdn);
193
+ await fs.ensureDir(projectDir);
194
+
195
+ const attributesFile = path.join(projectDir, 'attributes.yaml');
196
+ const attributesMapFile = path.join(customerDir, '.newo', customer.idn, `project_${projectIdn}_attributes-map.json`);
197
+
198
+ await writeFileSafe(attributesFile, yamlContent);
199
+ await fs.ensureDir(path.dirname(attributesMapFile));
200
+ await writeFileSafe(attributesMapFile, JSON.stringify(idMapping, null, 2));
201
+
202
+ if (verbose) {
203
+ console.log(` ✓ Saved project attributes to projects/${projectIdn}/attributes.yaml`);
204
+ console.log(` ✓ Saved attribute ID mapping`);
205
+ }
206
+ } catch (error: any) {
207
+ if (verbose) console.error(` ❌ Failed to fetch project attributes for ${projectIdn}:`, error.message);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Pull all project attributes for a customer
213
+ */
214
+ export async function pullAllProjectAttributes(
215
+ client: AxiosInstance,
216
+ customer: CustomerConfig,
217
+ verbose: boolean = false
218
+ ): Promise<void> {
219
+ if (verbose) console.log(`\n📋 Fetching project attributes...`);
220
+
221
+ try {
222
+ // Get all projects for this customer
223
+ const projects = await listProjects(client);
224
+ if (verbose) console.log(`✓ Found ${projects.length} projects\n`);
225
+
226
+ for (const project of projects) {
227
+ await saveProjectAttributes(client, customer, project.id, project.idn, verbose);
228
+ }
229
+
230
+ if (verbose) console.log(`\n✅ Completed project attributes sync for ${projects.length} projects\n`);
231
+ } catch (error) {
232
+ console.error(`❌ Failed to pull project attributes:`, error);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Push modified project attributes for a specific project
239
+ */
240
+ export async function pushProjectAttributes(
241
+ client: AxiosInstance,
242
+ customer: CustomerConfig,
243
+ projectId: string,
244
+ projectIdn: string,
245
+ verbose: boolean = false
246
+ ): Promise<number> {
247
+ const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn);
248
+ const attributesFile = path.join(customerDir, 'projects', projectIdn, 'attributes.yaml');
249
+ const attributesMapFile = path.join(customerDir, '.newo', customer.idn, `project_${projectIdn}_attributes-map.json`);
250
+
251
+ // Check if attributes file exists
252
+ if (!await fs.pathExists(attributesFile)) {
253
+ if (verbose) console.log(` ℹ No project attributes file for ${projectIdn}`);
254
+ return 0;
255
+ }
256
+
257
+ // Load local attributes
258
+ // Read as text and replace the custom !enum tags before parsing
259
+ let attributesContent = await fs.readFile(attributesFile, 'utf-8');
260
+ // Replace custom enum tags with the actual value
261
+ attributesContent = attributesContent.replace(/!enum "AttributeValueTypes\.(\w+)"/g, '$1');
262
+
263
+ const localData = yaml.load(attributesContent) as { attributes: any[] };
264
+ const localAttributes = localData.attributes || [];
265
+
266
+ // Load ID mapping
267
+ if (!await fs.pathExists(attributesMapFile)) {
268
+ if (verbose) console.log(` ⚠ No ID mapping found for project ${projectIdn}, skipping push`);
269
+ return 0;
270
+ }
271
+
272
+ const idMapping = JSON.parse(await fs.readFile(attributesMapFile, 'utf-8')) as Record<string, string>;
273
+
274
+ // Fetch current remote attributes for comparison
275
+ const remoteResponse = await getProjectAttributes(client, projectId, true);
276
+ const remoteAttributes = remoteResponse.attributes || [];
277
+
278
+ // Create map of remote attributes by IDN
279
+ const remoteMap = new Map<string, any>();
280
+ remoteAttributes.forEach(attr => remoteMap.set(attr.idn, attr));
281
+
282
+ let updatedCount = 0;
283
+
284
+ // Check each local attribute for changes
285
+ for (const localAttr of localAttributes) {
286
+ const attributeId = idMapping[localAttr.idn];
287
+ if (!attributeId) {
288
+ if (verbose) console.log(` ⚠ No ID mapping for attribute ${localAttr.idn}, skipping`);
289
+ continue;
290
+ }
291
+
292
+ const remoteAttr = remoteMap.get(localAttr.idn);
293
+ if (!remoteAttr) {
294
+ if (verbose) console.log(` ⚠ Attribute ${localAttr.idn} not found remotely, skipping`);
295
+ continue;
296
+ }
297
+
298
+ // Value type is already parsed (we removed !enum tags above)
299
+ const valueType = localAttr.value_type;
300
+
301
+ // Check if value changed
302
+ const localValue = String(localAttr.value || '');
303
+ const remoteValue = String(remoteAttr.value || '');
304
+
305
+ if (localValue !== remoteValue) {
306
+ if (verbose) console.log(` 🔄 Updating project attribute: ${localAttr.idn}`);
307
+
308
+ try {
309
+ const attributeToUpdate = {
310
+ id: attributeId,
311
+ idn: localAttr.idn,
312
+ value: localAttr.value,
313
+ title: localAttr.title,
314
+ description: localAttr.description,
315
+ group: localAttr.group,
316
+ is_hidden: localAttr.is_hidden,
317
+ possible_values: localAttr.possible_values,
318
+ value_type: valueType
319
+ };
320
+
321
+ await updateProjectAttribute(client, projectId, attributeToUpdate);
322
+ if (verbose) console.log(` ✅ Updated: ${localAttr.idn} (${localAttr.title})`);
323
+ updatedCount++;
324
+ } catch (error: any) {
325
+ const errorDetail = error.response?.data || error.message;
326
+ console.error(` ❌ Failed to update ${localAttr.idn}: ${JSON.stringify(errorDetail)}`);
327
+ if (verbose) {
328
+ console.error(` API response:`, error.response?.status, error.response?.statusText);
329
+ console.error(` Endpoint tried: PUT /api/v1/project/attributes/${attributeId}`);
330
+ }
331
+ }
332
+ } else if (verbose) {
333
+ console.log(` ✓ No changes: ${localAttr.idn}`);
334
+ }
335
+ }
336
+
337
+ return updatedCount;
338
+ }
339
+
340
+ /**
341
+ * Push all modified project attributes for all projects
342
+ */
343
+ export async function pushAllProjectAttributes(
344
+ client: AxiosInstance,
345
+ customer: CustomerConfig,
346
+ projectsMap: Record<string, { projectId: string; projectIdn: string }>,
347
+ verbose: boolean = false
348
+ ): Promise<number> {
349
+ if (verbose) console.log(`\n📋 Checking project attributes for changes...`);
350
+
351
+ let totalUpdated = 0;
352
+
353
+ for (const [projectIdn, projectInfo] of Object.entries(projectsMap)) {
354
+ if (!projectIdn) continue; // Skip empty project idn (legacy format)
355
+
356
+ if (verbose) console.log(`\n 📁 Project: ${projectIdn}`);
357
+
358
+ const updated = await pushProjectAttributes(
359
+ client,
360
+ customer,
361
+ projectInfo.projectId,
362
+ projectInfo.projectIdn || projectIdn,
363
+ verbose
364
+ );
365
+
366
+ totalUpdated += updated;
367
+ }
368
+
369
+ // Always show summary if changes were made
370
+ if (totalUpdated > 0) {
371
+ console.log(`\n✅ Updated ${totalUpdated} project attribute(s)`);
372
+ } else {
373
+ if (verbose) console.log(`\n✓ No project attribute changes to push`);
374
+ }
375
+
376
+ return totalUpdated;
110
377
  }