@cyberismo/data-handler 0.0.16 → 0.0.17

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 (71) hide show
  1. package/dist/command-handler.js +5 -7
  2. package/dist/command-handler.js.map +1 -1
  3. package/dist/command-manager.js +4 -4
  4. package/dist/command-manager.js.map +1 -1
  5. package/dist/commands/create.js +1 -1
  6. package/dist/commands/create.js.map +1 -1
  7. package/dist/commands/fetch.d.ts +8 -0
  8. package/dist/commands/fetch.js +101 -23
  9. package/dist/commands/fetch.js.map +1 -1
  10. package/dist/commands/import.d.ts +5 -2
  11. package/dist/commands/import.js +12 -3
  12. package/dist/commands/import.js.map +1 -1
  13. package/dist/commands/remove.d.ts +3 -1
  14. package/dist/commands/remove.js +7 -1
  15. package/dist/commands/remove.js.map +1 -1
  16. package/dist/commands/rename.js +5 -0
  17. package/dist/commands/rename.js.map +1 -1
  18. package/dist/commands/show.d.ts +4 -2
  19. package/dist/commands/show.js +8 -2
  20. package/dist/commands/show.js.map +1 -1
  21. package/dist/commands/validate.js +3 -5
  22. package/dist/commands/validate.js.map +1 -1
  23. package/dist/containers/card-container.d.ts +7 -5
  24. package/dist/containers/card-container.js +30 -5
  25. package/dist/containers/card-container.js.map +1 -1
  26. package/dist/containers/project/project-paths.d.ts +2 -0
  27. package/dist/containers/project/project-paths.js +6 -0
  28. package/dist/containers/project/project-paths.js.map +1 -1
  29. package/dist/containers/project/resource-cache.js +9 -7
  30. package/dist/containers/project/resource-cache.js.map +1 -1
  31. package/dist/containers/project.d.ts +11 -2
  32. package/dist/containers/project.js +54 -8
  33. package/dist/containers/project.js.map +1 -1
  34. package/dist/containers/template.js +4 -4
  35. package/dist/containers/template.js.map +1 -1
  36. package/dist/interfaces/command-options.d.ts +3 -1
  37. package/dist/interfaces/project-interfaces.d.ts +5 -5
  38. package/dist/interfaces/project-interfaces.js.map +1 -1
  39. package/dist/project-settings.d.ts +5 -0
  40. package/dist/project-settings.js +12 -0
  41. package/dist/project-settings.js.map +1 -1
  42. package/dist/resources/resource-object.d.ts +1 -0
  43. package/dist/resources/resource-object.js +52 -1
  44. package/dist/resources/resource-object.js.map +1 -1
  45. package/dist/utils/configuration-logger.d.ts +91 -0
  46. package/dist/utils/configuration-logger.js +151 -0
  47. package/dist/utils/configuration-logger.js.map +1 -0
  48. package/dist/utils/constants.d.ts +1 -1
  49. package/dist/utils/constants.js +5 -3
  50. package/dist/utils/constants.js.map +1 -1
  51. package/package.json +4 -4
  52. package/src/command-handler.ts +17 -9
  53. package/src/command-manager.ts +4 -4
  54. package/src/commands/create.ts +1 -1
  55. package/src/commands/fetch.ts +143 -34
  56. package/src/commands/import.ts +13 -1
  57. package/src/commands/remove.ts +10 -1
  58. package/src/commands/rename.ts +15 -0
  59. package/src/commands/show.ts +11 -3
  60. package/src/commands/validate.ts +3 -7
  61. package/src/containers/card-container.ts +37 -5
  62. package/src/containers/project/project-paths.ts +8 -0
  63. package/src/containers/project/resource-cache.ts +12 -9
  64. package/src/containers/project.ts +76 -9
  65. package/src/containers/template.ts +4 -4
  66. package/src/interfaces/command-options.ts +3 -1
  67. package/src/interfaces/project-interfaces.ts +5 -5
  68. package/src/project-settings.ts +13 -0
  69. package/src/resources/resource-object.ts +73 -1
  70. package/src/utils/configuration-logger.ts +206 -0
  71. package/src/utils/constants.ts +5 -3
@@ -13,16 +13,28 @@
13
13
 
14
14
  import { mkdir } from 'node:fs/promises';
15
15
  import { resolve, sep } from 'node:path';
16
- import type { Project } from '../containers/project.js';
17
16
 
18
- import { writeJsonFile } from '../utils/json.js';
19
- import { validateJson } from '../utils/validate.js';
20
- import { type ModuleSetting } from '../interfaces/project-interfaces.js';
21
- import { errorFunction } from '../utils/error-utils.js';
22
17
  import { getChildLogger } from '../utils/log-utils.js';
18
+ import { readJsonFile, writeJsonFile } from '../utils/json.js';
19
+ import { validateJson } from '../utils/validate.js';
20
+
21
+ import type { ModuleSetting } from '../interfaces/project-interfaces.js';
22
+ import type { Project } from '../containers/project.js';
23
23
 
24
- const FETCH_TIMEOUT = 30000; // 30s timeout for fetching a hub file.
25
- const MAX_RESPONSE_SIZE = 1024 * 1024; // 1MB limit for safety
24
+ // Hub structure
25
+ interface HubVersionInfo {
26
+ location: string;
27
+ version: number;
28
+ }
29
+
30
+ // Structure of .temp/moduleList.json file.
31
+ interface ModuleListFile {
32
+ modules: ModuleSetting[];
33
+ hubs: HubVersionInfo[];
34
+ }
35
+
36
+ const FETCH_TIMEOUT_MS = 30 * 1000; // 30s timeout for fetching a hub file.
37
+ const MAX_RESPONSE_SIZE_MB = 1024 * 1024; // 1MB limit for safety
26
38
  const HUB_SCHEMA = 'hubSchema';
27
39
  const MODULE_LIST_FILE = 'moduleList.json';
28
40
  const TEMP_FOLDER = `.temp`;
@@ -30,7 +42,10 @@ const TEMP_FOLDER = `.temp`;
30
42
  export const MODULE_LIST_FULL_PATH = `${TEMP_FOLDER}/${MODULE_LIST_FILE}`;
31
43
 
32
44
  export class Fetch {
33
- constructor(private project: Project) {}
45
+ private moduleListPath;
46
+ constructor(private project: Project) {
47
+ this.moduleListPath = resolve(this.project.basePath, MODULE_LIST_FULL_PATH);
48
+ }
34
49
 
35
50
  private get logger() {
36
51
  return getChildLogger({
@@ -38,6 +53,38 @@ export class Fetch {
38
53
  });
39
54
  }
40
55
 
56
+ // Checks the version of the remote moduleList.json.
57
+ private async checkRemoteVersion(
58
+ location: string,
59
+ ): Promise<number | undefined> {
60
+ try {
61
+ const url = new URL(`${location}/${MODULE_LIST_FILE}`);
62
+ if (!['http:', 'https:'].includes(url.protocol)) {
63
+ return undefined;
64
+ }
65
+
66
+ const response = await fetch(url.toString(), {
67
+ method: 'GET',
68
+ headers: {
69
+ Accept: 'application/json',
70
+ 'User-Agent': 'Cyberismo/1.0',
71
+ },
72
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
73
+ });
74
+
75
+ if (!response.ok) {
76
+ return undefined;
77
+ }
78
+
79
+ const json = await response.json();
80
+ return json.version;
81
+ } catch (error) {
82
+ this.logger.error(error, `Could not check hub version for ${location} }`);
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ // Fetches one hub's data as JSON.
41
88
  private async fetchJSON(location: string, schemaId: string) {
42
89
  try {
43
90
  const url = new URL(`${location}/${MODULE_LIST_FILE}`);
@@ -47,14 +94,14 @@ export class Fetch {
47
94
  );
48
95
  }
49
96
 
50
- this.logger.info(`Fetching module list from: ${url.toString()}`);
97
+ this.logger.info(`Fetching module list from hub: ${url.toString()}`);
51
98
  const response = await fetch(url.toString(), {
52
99
  method: 'GET',
53
100
  headers: {
54
101
  Accept: 'application/json',
55
102
  'User-Agent': 'Cyberismo/1.0',
56
103
  },
57
- signal: AbortSignal.timeout(FETCH_TIMEOUT),
104
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
58
105
  });
59
106
 
60
107
  if (!response.ok) {
@@ -65,9 +112,9 @@ export class Fetch {
65
112
 
66
113
  // Check content length before downloading
67
114
  const contentLength = response.headers.get('content-length');
68
- if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
115
+ if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE_MB) {
69
116
  throw new Error(
70
- `Response too large: ${contentLength} bytes (max: ${MAX_RESPONSE_SIZE})`,
117
+ `Response too large: ${contentLength} bytes (max: ${MAX_RESPONSE_SIZE_MB})`,
71
118
  );
72
119
  }
73
120
 
@@ -79,14 +126,10 @@ export class Fetch {
79
126
  const json = await response.json();
80
127
  // Validate the incoming JSON before saving it into a file.
81
128
  await validateJson(json, { schemaId: schemaId });
82
-
83
- // Validate JSON structure and prevent prototype pollution
84
129
  if (typeof json !== 'object' || json === null || Array.isArray(json)) {
85
130
  throw new Error('Response must be a JSON object');
86
131
  }
87
-
88
- // Additional size check after JSON parsing
89
- if (JSON.stringify(json).length > MAX_RESPONSE_SIZE) {
132
+ if (JSON.stringify(json).length > MAX_RESPONSE_SIZE_MB) {
90
133
  throw new Error('JSON content too large after parsing');
91
134
  }
92
135
 
@@ -94,41 +137,109 @@ export class Fetch {
94
137
  } catch (error) {
95
138
  this.logger.error(
96
139
  error,
97
- `Failed to fetch module list from ${location}: ${errorFunction(error)}`,
140
+ `Failed to fetch module list from hub ${location}`,
98
141
  );
99
142
  throw error;
100
143
  }
101
144
  }
102
145
 
146
+ // Checks if the local moduleList.json needs to be updated by comparing
147
+ // each hub's version with the stored version.
148
+ private async fetchModuleList(): Promise<boolean> {
149
+ try {
150
+ const configuredHubs = this.project.configuration.hubs;
151
+ if (configuredHubs.length === 0) {
152
+ return false;
153
+ }
154
+
155
+ const localData = (await readJsonFile(
156
+ this.moduleListPath,
157
+ )) as ModuleListFile;
158
+ const localHubs = localData.hubs || [];
159
+ if (localHubs.length !== configuredHubs.length) {
160
+ this.logger.info('Hub configuration changed, fetching module list');
161
+ return true;
162
+ }
163
+
164
+ // Check each hub's version
165
+ for (const configHub of configuredHubs) {
166
+ const localHub = localHubs.find(
167
+ (hub) => hub.location === configHub.location,
168
+ );
169
+
170
+ if (!localHub) {
171
+ this.logger.info(
172
+ `New hub detected: ${configHub.location}, fetching module list`,
173
+ );
174
+ return true;
175
+ }
176
+
177
+ const remoteVersion = await this.checkRemoteVersion(configHub.location);
178
+ if (remoteVersion === undefined) {
179
+ const hubName = configHub.displayName || configHub.location;
180
+ this.logger.info(`Hub ${hubName} has no version data, skipped.`);
181
+ continue;
182
+ }
183
+
184
+ if (remoteVersion > localHub.version) {
185
+ this.logger.info(
186
+ `Hub ${configHub.location} has newer version (remote: ${remoteVersion}, local: ${localHub.version}), fetching module list`,
187
+ );
188
+ return true;
189
+ }
190
+ }
191
+
192
+ this.logger.info('Module list is up to date');
193
+ return false;
194
+ } catch (error) {
195
+ this.logger.error(
196
+ error,
197
+ `Error when checking versions for hub module list`,
198
+ );
199
+ return true;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Ensures the module list is up to date by fetching if needed.
205
+ */
206
+ public async ensureModuleListUpToDate() {
207
+ await this.fetchHubs();
208
+ }
209
+
103
210
  /**
104
211
  * Fetches modules from modules hub(s) and writes them to a file.
212
+ * Only fetches if the remote version is newer than the local version.
105
213
  */
106
214
  public async fetchHubs() {
107
- const hubs = this.project.configuration.hubs;
215
+ const needsFetch = await this.fetchModuleList();
216
+ if (!needsFetch) {
217
+ return;
218
+ }
108
219
 
220
+ const hubs = this.project.configuration.hubs;
109
221
  const moduleMap: Map<string, ModuleSetting> = new Map([]);
222
+ const hubVersions: HubVersionInfo[] = [];
110
223
 
111
224
  for (const hub of hubs) {
112
225
  const json = await this.fetchJSON(hub.location, HUB_SCHEMA);
113
226
  json.modules.forEach((module: ModuleSetting) => {
114
227
  if (!moduleMap.has(module.name)) {
115
228
  moduleMap.set(module.name, module);
116
- } else {
117
- this.logger.info(
118
- `Skipping module '${module.name}' since it was already listed.`,
119
- );
120
229
  }
121
230
  });
231
+
232
+ hubVersions.push({
233
+ location: hub.location,
234
+ version: json.version || 1,
235
+ });
122
236
  }
123
237
 
124
238
  try {
125
- const fullPath = resolve(this.project.basePath, MODULE_LIST_FULL_PATH);
126
239
  const normalizedBasePath = resolve(this.project.basePath);
127
-
128
- // Ensure the file is written within the project directory (prevent path traversal)
129
240
  if (
130
- !fullPath.startsWith(normalizedBasePath + sep) &&
131
- fullPath !== normalizedBasePath
241
+ !this.moduleListPath.startsWith(normalizedBasePath + sep) &&
242
+ this.moduleListPath !== normalizedBasePath
132
243
  ) {
133
244
  throw new Error(
134
245
  'Invalid file path: attempting to write outside project directory',
@@ -138,15 +249,13 @@ export class Fetch {
138
249
  await mkdir(resolve(this.project.basePath, TEMP_FOLDER), {
139
250
  recursive: true,
140
251
  });
141
- await writeJsonFile(fullPath, {
252
+ await writeJsonFile(this.moduleListPath, {
142
253
  modules: Array.from(moduleMap.values()),
254
+ hubs: hubVersions,
143
255
  });
144
- this.logger.info(`Module list written to: ${fullPath}`);
256
+ this.logger.info(`Module list written to: ${this.moduleListPath}`);
145
257
  } catch (error) {
146
- this.logger.error(
147
- error,
148
- `Failed to write module list to local file: ${errorFunction(error)}`,
149
- );
258
+ this.logger.error(error, `Failed to write module list to local file`);
150
259
  throw error;
151
260
  }
152
261
  }
@@ -20,6 +20,7 @@ import type {
20
20
  Credentials,
21
21
  ModuleSettingOptions,
22
22
  } from '../interfaces/project-interfaces.js';
23
+ import type { Fetch } from './fetch.js';
23
24
  import type { Project } from '../containers/project.js';
24
25
 
25
26
  /**
@@ -36,6 +37,7 @@ export class Import {
36
37
  constructor(
37
38
  private project: Project,
38
39
  private createCmd: Create,
40
+ private fetchCmd: Fetch,
39
41
  ) {
40
42
  this.moduleManager = new ModuleManager(this.project);
41
43
  }
@@ -133,12 +135,17 @@ export class Import {
133
135
  * @param options Additional options for module import. Optional.
134
136
  * branch: Git branch for module from Git.
135
137
  * private: If true, uses credentials to clone the repository
138
+ * @param skipMigrationLog If true, skip logging to migration log (used during project creation)
136
139
  */
137
140
  public async importModule(
138
141
  source: string,
139
142
  destination?: string,
140
143
  options?: ModuleSettingOptions,
144
+ skipMigrationLog = false,
141
145
  ) {
146
+ // Ensure module list is up to date before importing
147
+ await this.fetchCmd.ensureModuleListUpToDate();
148
+
142
149
  const beforeImportValidateErrors = await Validate.getInstance().validate(
143
150
  this.project.basePath,
144
151
  () => this.project,
@@ -168,7 +175,7 @@ export class Import {
168
175
  );
169
176
 
170
177
  // Add module as a dependency.
171
- await this.project.importModule(moduleSettings);
178
+ await this.project.importModule(moduleSettings, skipMigrationLog);
172
179
 
173
180
  // Validate the project after module has been imported
174
181
  const afterImportValidateErrors = await Validate.getInstance().validate(
@@ -189,6 +196,9 @@ export class Import {
189
196
  * @throws if module is not part of the project
190
197
  */
191
198
  public async updateModule(moduleName: string, credentials?: Credentials) {
199
+ // Ensure module list is up to date before updating
200
+ await this.fetchCmd.ensureModuleListUpToDate();
201
+
192
202
  const module = this.project.configuration.modules.find(
193
203
  (item) => item.name === moduleName,
194
204
  );
@@ -203,6 +213,8 @@ export class Import {
203
213
  * @param credentials Optional credentials for private modules.
204
214
  */
205
215
  public async updateAllModules(credentials?: Credentials) {
216
+ // Ensure module list is up to date before updating all modules
217
+ await this.fetchCmd.ensureModuleListUpToDate();
206
218
  return this.moduleManager.updateModules(credentials);
207
219
  }
208
220
  }
@@ -14,6 +14,7 @@
14
14
  import { ActionGuard } from '../permissions/action-guard.js';
15
15
  import { isModuleCard } from '../utils/card-utils.js';
16
16
  import { ModuleManager } from '../module-manager.js';
17
+ import type { Fetch } from './fetch.js';
17
18
  import type { Project } from '../containers/project.js';
18
19
  import type { RemovableResourceTypes } from '../interfaces/project-interfaces.js';
19
20
 
@@ -26,7 +27,10 @@ export class Remove {
26
27
  * Creates a new instance of Remove command.
27
28
  * @param project Project instance to use
28
29
  */
29
- constructor(private project: Project) {
30
+ constructor(
31
+ private project: Project,
32
+ private fetchCmd: Fetch,
33
+ ) {
30
34
  this.moduleManager = new ModuleManager(this.project);
31
35
  }
32
36
 
@@ -164,6 +168,11 @@ export class Remove {
164
168
  targetName: string,
165
169
  ...rest: any[] // eslint-disable-line @typescript-eslint/no-explicit-any
166
170
  ) {
171
+ // Ensure module list is up to date when removing modules
172
+ if (type === 'module') {
173
+ await this.fetchCmd.ensureModuleListUpToDate();
174
+ }
175
+
167
176
  if (type === 'attachment' && rest.length !== 1 && !rest[0]) {
168
177
  throw new Error(
169
178
  `Input validation error: must pass argument 'detail' if requesting to remove attachment`,
@@ -17,6 +17,10 @@ import { join } from 'node:path';
17
17
  import { rename, readdir, readFile, writeFile } from 'node:fs/promises';
18
18
 
19
19
  import type { Card } from '../interfaces/project-interfaces.js';
20
+ import {
21
+ ConfigurationLogger,
22
+ ConfigurationOperation,
23
+ } from '../utils/configuration-logger.js';
20
24
  import { isTemplateCard } from '../utils/card-utils.js';
21
25
  import { type Project, ResourcesFrom } from '../containers/project.js';
22
26
  import { resourceName } from '../utils/resource-utils.js';
@@ -273,6 +277,17 @@ export class Rename {
273
277
  this.project.resources.changed();
274
278
  console.info('Collected renamed resources');
275
279
 
280
+ // Remove these when operations properly update card cache
281
+ this.project.cardsCache.clear();
282
+ await this.project.populateCaches();
283
+
284
+ await ConfigurationLogger.log(
285
+ this.project.basePath,
286
+ ConfigurationOperation.PROJECT_RENAME,
287
+ this.to,
288
+ {},
289
+ );
290
+
276
291
  return this.project.calculationEngine.generate();
277
292
  }
278
293
  }
@@ -18,7 +18,7 @@ import { join, resolve } from 'node:path';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { writeFile } from 'node:fs/promises';
20
20
 
21
- import { MODULE_LIST_FULL_PATH } from './fetch.js';
21
+ import { type Fetch, MODULE_LIST_FULL_PATH } from './fetch.js';
22
22
 
23
23
  import type { attachmentPayload } from '../interfaces/request-status-interfaces.js';
24
24
  import type {
@@ -74,7 +74,10 @@ export class Show {
74
74
  workflows: (from) => this.resourceNames('workflows', from),
75
75
  };
76
76
 
77
- constructor(private project: Project) {}
77
+ constructor(
78
+ private project: Project,
79
+ private fetchCmd: Fetch,
80
+ ) {}
78
81
 
79
82
  private get logger() {
80
83
  return getChildLogger({
@@ -326,6 +329,9 @@ export class Show {
326
329
  showDetails?: boolean,
327
330
  ): Promise<ModuleSettingFromHub[]> {
328
331
  try {
332
+ // Ensure module list is up to date before showing
333
+ await this.fetchCmd.ensureModuleListUpToDate();
334
+
329
335
  const moduleList = (
330
336
  await readJsonFile(
331
337
  resolve(this.project.basePath, MODULE_LIST_FULL_PATH),
@@ -399,7 +405,9 @@ export class Show {
399
405
  * Shows hubs of the project.
400
406
  * @returns list of hubs.
401
407
  */
402
- public showHubs(): HubSetting[] {
408
+ public async showHubs(): Promise<HubSetting[]> {
409
+ // Ensure module list is up to date before showing
410
+ await this.fetchCmd.ensureModuleListUpToDate();
403
411
  return this.project.configuration.hubs;
404
412
  }
405
413
 
@@ -222,7 +222,7 @@ export class Validate {
222
222
  ): Promise<string[]> {
223
223
  const message: string[] = [];
224
224
  try {
225
- const prefixes = project.projectPrefixes();
225
+ const prefixes = project.allModulePrefixes();
226
226
  const files = await readdir(path, {
227
227
  withFileTypes: true,
228
228
  });
@@ -554,7 +554,7 @@ export class Validate {
554
554
  cards.push(...project.allTemplateCards());
555
555
 
556
556
  const cardIds = new Map<string, number>();
557
- const allPrefixes = await project.projectPrefixes();
557
+ const allPrefixes = project.allModulePrefixes();
558
558
 
559
559
  for (const card of cards) {
560
560
  if (cardIds.has(card.key)) {
@@ -819,11 +819,7 @@ export class Validate {
819
819
 
820
820
  // Validate that all metadata keys are either predefined fields or valid field type names
821
821
  for (const key of Object.keys(card.metadata)) {
822
- if (
823
- (isPredefinedField(key) as boolean) ||
824
- key === 'labels' ||
825
- key === 'links'
826
- ) {
822
+ if (isPredefinedField(key) as boolean) {
827
823
  continue;
828
824
  }
829
825
  try {
@@ -19,17 +19,19 @@ import { writeFile } from 'node:fs/promises';
19
19
  import { CardCache } from './project/card-cache.js';
20
20
  import { cardPathParts } from '../utils/card-utils.js';
21
21
  import { deleteDir } from '../utils/file-utils.js';
22
+ import { getChildLogger } from '../utils/log-utils.js';
22
23
  import { writeJsonFile } from '../utils/json.js';
23
24
 
24
25
  import type {
25
26
  CardAttachment,
26
27
  Card,
28
+ CardMetadata,
27
29
  FetchCardDetails,
28
30
  } from '../interfaces/project-interfaces.js';
29
31
 
30
32
  import asciidoctor from '@asciidoctor/core';
31
33
 
32
- import { ROOT } from '../utils/constants.js';
34
+ import { isPredefinedField, ROOT } from '../utils/constants.js';
33
35
 
34
36
  /**
35
37
  * Card container base class. Used for both Project and Template.
@@ -38,17 +40,19 @@ import { ROOT } from '../utils/constants.js';
38
40
  export class CardContainer {
39
41
  public basePath: string;
40
42
  protected cardCache: CardCache;
41
- protected containerName: string;
42
43
  protected prefix: string;
43
44
 
45
+ protected static get logger() {
46
+ return getChildLogger({ module: 'CardContainer' });
47
+ }
48
+
44
49
  static cardContentFile = 'index.adoc';
45
50
  static cardMetadataFile = 'index.json';
46
51
  static projectConfigFileName = 'cardsConfig.json';
47
52
  static schemaContentFile = '.schema';
48
53
 
49
- constructor(path: string, prefix: string, name: string) {
54
+ constructor(path: string, prefix: string) {
50
55
  this.basePath = path;
51
- this.containerName = name;
52
56
  this.prefix = prefix;
53
57
  this.cardCache = new CardCache(this.prefix);
54
58
  }
@@ -226,13 +230,41 @@ export class CardContainer {
226
230
  if (card.metadata != null) {
227
231
  const metadataFile = join(card.path, CardContainer.cardMetadataFile);
228
232
  card.metadata!.lastUpdated = new Date().toISOString();
229
- await writeJsonFile(metadataFile, card.metadata);
233
+
234
+ const sanitizedMetadata = CardContainer.sanitizeMetadata(card);
235
+ await writeJsonFile(metadataFile, sanitizedMetadata);
230
236
  return this.cardCache.updateCardMetadata(card.key, card.metadata);
231
237
  }
232
238
  return false;
233
239
  }
234
240
 
235
241
  /**
242
+ * Removes non-metadata fields that should not be persisted.
243
+ *
244
+ * @param metadata The metadata object to sanitize
245
+ * @returns Clean metadata object with only valid metadata fields
246
+ */
247
+ private static sanitizeMetadata(card: Card): CardMetadata {
248
+ const sanitized: Record<string, unknown> = {};
249
+
250
+ if (card.metadata) {
251
+ for (const [key, value] of Object.entries(card.metadata)) {
252
+ // Keys are not filtered out if they are: predefined, or field types
253
+ if (isPredefinedField(key) || key.includes('/')) {
254
+ sanitized[key] = value;
255
+ } else {
256
+ this.logger.warn(
257
+ `Card ${card.key} had extra metadata key ${key} with value ${value}. Key was removed`,
258
+ );
259
+ }
260
+ // Everything else is filtered out
261
+ }
262
+ }
263
+
264
+ return sanitized as CardMetadata;
265
+ }
266
+
267
+ /*
236
268
  * Show root cards from a given path.
237
269
  * @param path The path to get cards from
238
270
  * @returns an array of root-level cards (each with their children populated).
@@ -64,6 +64,10 @@ export class ProjectPaths {
64
64
  return join(this.resourcesFolder, 'cardsConfig.json');
65
65
  }
66
66
 
67
+ public get configurationChangesLog(): string {
68
+ return join(this.migrationLogFolder, 'current', 'migrationLog.jsonl');
69
+ }
70
+
67
71
  public get fieldTypesFolder(): string {
68
72
  return join(this.resourcesFolder, 'fieldTypes');
69
73
  }
@@ -84,6 +88,10 @@ export class ProjectPaths {
84
88
  return join(this.path, '.logs', 'cyberismo_data-handler.log');
85
89
  }
86
90
 
91
+ public get migrationLogFolder(): string {
92
+ return join(this.resourcesFolder, 'migrations');
93
+ }
94
+
87
95
  public get modulesFolder(): string {
88
96
  return join(this.path, '.cards', 'modules');
89
97
  }
@@ -194,15 +194,16 @@ export class ResourceCache {
194
194
  }
195
195
 
196
196
  // Collect all module resources from the filesystem
197
- // Only collects modules that are registered in the project configuration
198
- // todo: For future:
199
- // Should it also try to collect what is in .local/modules and then log for disparities?
200
197
  private collectModuleResources() {
201
198
  try {
202
- const registeredModules = this.project.configuration.modules.map(
203
- (m) => m.name,
204
- );
205
- if (registeredModules.length === 0) {
199
+ const moduleEntries = readdirSync(this.project.paths.modulesFolder, {
200
+ withFileTypes: true,
201
+ });
202
+ const moduleNames = moduleEntries
203
+ .filter((entry) => entry.isDirectory())
204
+ .map((entry) => entry.name);
205
+
206
+ if (moduleNames.length === 0) {
206
207
  return;
207
208
  }
208
209
 
@@ -218,13 +219,15 @@ export class ResourceCache {
218
219
  'workflows',
219
220
  ];
220
221
 
221
- for (const moduleName of registeredModules) {
222
+ for (const moduleName of moduleNames) {
222
223
  for (const type of resourceTypes) {
223
224
  this.collectResourcesOfType(type, 'module', moduleName);
224
225
  }
225
226
  }
226
227
  } catch {
227
- ResourceCache.logger.warn(`.cards/modules folder is missing`);
228
+ ResourceCache.logger.debug(
229
+ `.cards/modules folder is missing or inaccessible`,
230
+ );
228
231
  }
229
232
  }
230
233