@wpmoo/toolkit 0.9.29 → 0.9.31

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/dist/help.js CHANGED
@@ -32,7 +32,7 @@ Usage:
32
32
  npx @wpmoo/toolkit update <module[,module]> [db]
33
33
  npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
34
34
  npx @wpmoo/toolkit resetdb [db] [module[,module]]
35
- npx @wpmoo/toolkit snapshot [db] [snapshot-name]
35
+ npx @wpmoo/toolkit snapshot [--list] [db] [snapshot-name]
36
36
  npx @wpmoo/toolkit restore-snapshot [--dry-run] <snapshot-name> [db]
37
37
  npx @wpmoo/toolkit lint
38
38
  npx @wpmoo/toolkit pot <module[,module]> [db] [output]
@@ -87,15 +87,17 @@ Daily actions:
87
87
  Use ./moo or npx @wpmoo/toolkit with the same daily action arguments.
88
88
 
89
89
  Lifecycle command guards:
90
- In WPMOO_ENV=stage, install/update require WPMOO_ALLOW_STAGE_LIFECYCLE=1.
91
- In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
90
+ In WPMOO_ENV=stage, install/update/stop/restart require WPMOO_ALLOW_STAGE_LIFECYCLE=1.
91
+ In WPMOO_ENV=prod, install/update/test/stop/restart require WPMOO_ALLOW_PROD_LIFECYCLE=1.
92
92
  resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
93
93
  restore-snapshot --dry-run remains allowed for preview.
94
+ Time-bounded local approvals may also be recorded in .wpmoo/approvals.jsonl.
94
95
 
95
96
  Cockpit:
96
97
  Run npx @wpmoo/toolkit inside a generated environment to open the cockpit.
97
- Use Command palette / to search slash commands across services, modules, database,
98
- diagnostics, repositories, and maintenance categories.
98
+ Use Command palette / to search slash commands such as /test, /modules,
99
+ /install-module, /doctor, and /safe-reset.
100
+ Large module lists switch to searchable selection by module, repo, or source type.
99
101
  Direct commands such as npx @wpmoo/toolkit status and npx @wpmoo/toolkit test remain available.
100
102
 
101
103
  Wizard local-only path:
@@ -140,7 +142,7 @@ Task recipes:
140
142
  Run tests:
141
143
  npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
142
144
  Safe reset and recover:
143
- npx @wpmoo/toolkit snapshot [db] [snapshot-name]
145
+ npx @wpmoo/toolkit snapshot [--list] [db] [snapshot-name]
144
146
  npx @wpmoo/toolkit reset --dry-run
145
147
  npx @wpmoo/toolkit reset
146
148
  npx @wpmoo/toolkit restore-snapshot --dry-run <snapshot-name> [db]
@@ -41,6 +41,26 @@ function validateSupportedOdooVersion(value) {
41
41
  function sourceRepoPath(target, sourceType, repoPath) {
42
42
  return pathUnderBase(join(target, `odoo/custom/src/${sourceType}`), repoPath, 'repo path');
43
43
  }
44
+ async function readableSourceRepoPath(target, sourceType, repoPath) {
45
+ const primary = sourceRepoPath(target, sourceType, repoPath);
46
+ try {
47
+ await readdir(primary);
48
+ return primary;
49
+ }
50
+ catch {
51
+ if (sourceType !== 'private') {
52
+ return primary;
53
+ }
54
+ }
55
+ const legacy = pathUnderBase(join(target, 'odoo/custom/src'), repoPath, 'repo path');
56
+ try {
57
+ await readdir(legacy);
58
+ return legacy;
59
+ }
60
+ catch {
61
+ return primary;
62
+ }
63
+ }
44
64
  function modulePath(target, sourceType, repoPath, moduleName) {
45
65
  return pathUnderBase(sourceRepoPath(target, sourceType, repoPath), moduleName, 'module name');
46
66
  }
@@ -233,8 +253,12 @@ async function moduleScaffoldChecks(target, sourceType, repoPath, moduleName, in
233
253
  id: 'tests',
234
254
  label: 'tests',
235
255
  ok: (await fileContains(join(destination, 'tests/__init__.py'), `from . import test_${moduleName}`)) &&
236
- (await fileContains(join(destination, `tests/test_${moduleName}.py`), '')),
237
- details: 'missing generated test file',
256
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), 'TransactionCase')) &&
257
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), '@tagged("post_install", "-at_install")')) &&
258
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), `class Test${moduleClassName(moduleName)}(TransactionCase):`)) &&
259
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), 'def test_create_record(self):')) &&
260
+ (await fileContains(join(destination, `tests/test_${moduleName}.py`), `self.env["${technicalName}"]`)),
261
+ details: 'missing generated TransactionCase test markers',
238
262
  },
239
263
  ];
240
264
  if (includeRegistration) {
@@ -355,23 +379,20 @@ async function assertModuleCleanBeforeDelete(target, sourceType, repoPath, modul
355
379
  const repoRoot = sourceRepoPath(target, sourceType, repoPath);
356
380
  try {
357
381
  const result = await git.run(repoRoot, ['status', '--short', '--', moduleName]);
358
- if (result.stdout.trim() && (await moduleHasCommittedFiles(repoRoot, moduleName, git))) {
359
- throw new Error(`Refusing to delete module ${moduleName} because it has dirty git changes in source repo ${repoPath}.`);
382
+ const status = result.stdout.trimEnd();
383
+ if (status.trim()) {
384
+ const hasUntrackedOrStaged = status
385
+ .split(/\r?\n/u)
386
+ .some((line) => line.startsWith('??') || /^[A-Z][A-Z ]\s/u.test(line));
387
+ const reason = hasUntrackedOrStaged ? 'uncommitted git changes' : 'dirty git changes';
388
+ throw new Error(`Refusing to delete module ${moduleName} because it has ${reason} in source repo ${repoPath}.`);
360
389
  }
361
390
  }
362
391
  catch (error) {
363
392
  if (error instanceof Error && error.message.startsWith('Refusing to delete module ')) {
364
393
  throw error;
365
394
  }
366
- }
367
- }
368
- async function moduleHasCommittedFiles(repoRoot, moduleName, git) {
369
- try {
370
- const result = await git.run(repoRoot, ['ls-tree', '-r', '--name-only', 'HEAD', '--', moduleName]);
371
- return Boolean(result.stdout.trim());
372
- }
373
- catch {
374
- return false;
395
+ throw new Error(`Refusing to delete module ${moduleName} because git status could not be verified in source repo ${repoPath}.`);
375
396
  }
376
397
  }
377
398
  export async function addModuleToSourceRepo(options, git = realGit) {
@@ -410,12 +431,13 @@ export async function listModulesInSourceRepo(target, repoPath, sourceType) {
410
431
  const safeRepoPath = validateRepoPath(repoPath);
411
432
  const resolvedSourceType = normalizeSourceType(sourceType);
412
433
  try {
413
- const entries = await readdir(sourceRepoPath(target, resolvedSourceType, safeRepoPath), { withFileTypes: true });
434
+ const repoRoot = await readableSourceRepoPath(target, resolvedSourceType, safeRepoPath);
435
+ const entries = await readdir(repoRoot, { withFileTypes: true });
414
436
  const modules = await Promise.all(entries
415
437
  .filter((entry) => entry.isDirectory())
416
438
  .map(async (entry) => {
417
439
  try {
418
- await readFile(join(sourceRepoPath(target, resolvedSourceType, safeRepoPath), entry.name, '__manifest__.py'), 'utf8');
440
+ await readFile(join(repoRoot, entry.name, '__manifest__.py'), 'utf8');
419
441
  return entry.name;
420
442
  }
421
443
  catch {
@@ -462,6 +484,19 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
462
484
  const repoPath = validateRepoPath(options.repoPath);
463
485
  const moduleName = validateModuleName(options.moduleName);
464
486
  const sourceType = normalizeSourceType(options.sourceType);
487
+ const destination = modulePath(options.target, sourceType, repoPath, moduleName);
488
+ if (options.dryRun) {
489
+ return {
490
+ moduleName,
491
+ repoPath,
492
+ sourceType,
493
+ path: destination,
494
+ deleteFiles: options.deleteFiles,
495
+ dryRun: true,
496
+ ...(options.deleteFiles ? { wouldDeletePath: destination } : {}),
497
+ summary: `Previewed removal of module ${moduleName} from source repo ${repoPath}.`,
498
+ };
499
+ }
465
500
  if (options.deleteFiles) {
466
501
  await assertModuleCleanBeforeDelete(options.target, sourceType, repoPath, moduleName, git);
467
502
  }
@@ -471,7 +506,7 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
471
506
  }
472
507
  await updateModuleRegistration(options.target, sourceType, repoPath, moduleName, 'remove');
473
508
  if (options.deleteFiles) {
474
- await rm(modulePath(options.target, sourceType, repoPath, moduleName), { recursive: true, force: true });
509
+ await rm(destination, { recursive: true, force: true });
475
510
  }
476
511
  if (options.stage) {
477
512
  if (options.deleteFiles) {
@@ -479,4 +514,14 @@ export async function removeModuleFromSourceRepo(options, git = realGit) {
479
514
  }
480
515
  await stageAll(git, options.target);
481
516
  }
517
+ return {
518
+ moduleName,
519
+ repoPath,
520
+ sourceType,
521
+ path: destination,
522
+ deleteFiles: options.deleteFiles,
523
+ dryRun: false,
524
+ ...(options.deleteFiles ? { wouldDeletePath: destination } : {}),
525
+ summary: `Removed module ${moduleName} from source repo ${repoPath}.`,
526
+ };
482
527
  }
@@ -262,12 +262,18 @@ function validateManifest(manifest) {
262
262
  if (manifest.data !== undefined && !Array.isArray(manifest.data)) {
263
263
  throw new Error('invalid manifest: data must be a list of strings');
264
264
  }
265
+ if (manifest.demo !== undefined && !Array.isArray(manifest.demo)) {
266
+ throw new Error('invalid manifest: demo must be a list of strings');
267
+ }
265
268
  if (Array.isArray(manifest.depends) && !manifest.depends.every((entry) => typeof entry === 'string')) {
266
269
  throw new Error('invalid manifest: depends must be a list of strings');
267
270
  }
268
271
  if (Array.isArray(manifest.data) && !manifest.data.every((entry) => typeof entry === 'string')) {
269
272
  throw new Error('invalid manifest: data must be a list of strings');
270
273
  }
274
+ if (Array.isArray(manifest.demo) && !manifest.demo.every((entry) => typeof entry === 'string')) {
275
+ throw new Error('invalid manifest: demo must be a list of strings');
276
+ }
271
277
  return manifest;
272
278
  }
273
279
  export function parseOdooManifest(content) {
@@ -82,9 +82,15 @@ async function readOptionalFile(path) {
82
82
  function moduleIssue(moduleName, path, issue) {
83
83
  return { moduleName, path, issue };
84
84
  }
85
+ function moduleQualityIssue(moduleName, path, issue, severity) {
86
+ return { moduleName, path, issue, severity };
87
+ }
85
88
  function manifestData(manifest) {
86
89
  return Array.isArray(manifest?.data) ? manifest.data : [];
87
90
  }
91
+ function manifestDemo(manifest) {
92
+ return Array.isArray(manifest?.demo) ? manifest.demo : [];
93
+ }
88
94
  function manifestDepends(manifest) {
89
95
  return Array.isArray(manifest?.depends) ? manifest.depends : [];
90
96
  }
@@ -105,6 +111,113 @@ function pythonImportPresent(content, importName) {
105
111
  return false;
106
112
  return new RegExp(`^\\s*from\\s+\\.\\s+import\\s+.*\\b${importName}\\b`, 'mu').test(content);
107
113
  }
114
+ function xmlAttributeValues(content, attributeName) {
115
+ const values = [];
116
+ const pattern = new RegExp(`\\b${attributeName}=(["'])(.*?)\\1`, 'gsu');
117
+ let match;
118
+ while ((match = pattern.exec(content))) {
119
+ const value = match[2]?.trim();
120
+ if (value)
121
+ values.push(value);
122
+ }
123
+ return values;
124
+ }
125
+ function declaredActionIds(xmlFiles) {
126
+ const ids = new Set();
127
+ for (const content of xmlFiles) {
128
+ for (const record of content.matchAll(/<record\b[^>]*\bmodel=(["'])ir\.actions\.act_window\1[^>]*>/gsu)) {
129
+ for (const id of xmlAttributeValues(record[0], 'id')) {
130
+ ids.add(id);
131
+ }
132
+ }
133
+ }
134
+ return ids;
135
+ }
136
+ function menuActionReferences(xmlFiles) {
137
+ const refs = [];
138
+ for (const content of xmlFiles) {
139
+ for (const menu of content.matchAll(/<menuitem\b[^>]*>/gsu)) {
140
+ refs.push(...xmlAttributeValues(menu[0], 'action'));
141
+ }
142
+ }
143
+ return refs;
144
+ }
145
+ function declaredModelIds(modelFileContents) {
146
+ const modelIds = new Set();
147
+ for (const content of modelFileContents) {
148
+ for (const match of content.matchAll(/^\s*_name\s*=\s*["']([^"']+)["']/gmu)) {
149
+ const modelName = match[1]?.trim();
150
+ if (modelName) {
151
+ modelIds.add(`model_${modelName.replace(/\./gu, '_')}`);
152
+ }
153
+ }
154
+ }
155
+ return modelIds;
156
+ }
157
+ function declaredModelNames(modelFileContents) {
158
+ const modelNames = new Set();
159
+ for (const content of modelFileContents) {
160
+ for (const match of content.matchAll(/^\s*_name\s*=\s*["']([^"']+)["']/gmu)) {
161
+ const modelName = match[1]?.trim();
162
+ if (modelName) {
163
+ modelNames.add(modelName);
164
+ }
165
+ }
166
+ for (const match of content.matchAll(/^\s*_inherit\s*=\s*["']([^"']+)["']/gmu)) {
167
+ const modelName = match[1]?.trim();
168
+ if (modelName) {
169
+ modelNames.add(modelName);
170
+ }
171
+ }
172
+ }
173
+ return modelNames;
174
+ }
175
+ function escapeRegExp(value) {
176
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
177
+ }
178
+ function xmlRecordFieldValues(xmlFiles, recordModel, fieldName) {
179
+ const values = [];
180
+ const recordPattern = new RegExp(`<record\\b[^>]*\\bmodel=(["'])${escapeRegExp(recordModel)}\\1[^>]*>(.*?)</record>`, 'gsu');
181
+ const fieldPattern = new RegExp(`<field\\b[^>]*\\bname=(["'])${escapeRegExp(fieldName)}\\1[^>]*>(.*?)</field>`, 'gsu');
182
+ for (const content of xmlFiles) {
183
+ let recordMatch;
184
+ while ((recordMatch = recordPattern.exec(content))) {
185
+ const body = recordMatch[2] ?? '';
186
+ let fieldMatch;
187
+ while ((fieldMatch = fieldPattern.exec(body))) {
188
+ const value = fieldMatch[2]?.replace(/<[^>]+>/gu, '').trim();
189
+ if (value) {
190
+ values.push(value);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ return values;
196
+ }
197
+ function looksLikePlainModelName(value) {
198
+ return /^[a-zA-Z_][\w]*(?:\.[a-zA-Z_][\w]*)+$/u.test(value);
199
+ }
200
+ function accessCsvModelIds(content) {
201
+ if (!content)
202
+ return [];
203
+ const lines = content
204
+ .split(/\r?\n/u)
205
+ .map((line) => line.trim())
206
+ .filter((line) => line && !line.startsWith('#'));
207
+ if (lines.length < 2)
208
+ return [];
209
+ const header = lines[0]?.split(',').map((value) => value.trim()) ?? [];
210
+ const modelColumn = header.findIndex((value) => value === 'model_id:id' || value === 'model_id');
211
+ if (modelColumn < 0)
212
+ return [];
213
+ return lines
214
+ .slice(1)
215
+ .map((line) => line.split(',')[modelColumn]?.trim())
216
+ .filter((value) => Boolean(value));
217
+ }
218
+ async function readModelFileContents(modulePath, modelFiles) {
219
+ return Promise.all(modelFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'models', fileName))) ?? ''));
220
+ }
108
221
  export async function analyzeModuleDirectory(modulePath, moduleName = basename(modulePath), relativePath = modulePath) {
109
222
  const issues = [];
110
223
  let manifest;
@@ -135,9 +248,22 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
135
248
  const menuXml = await readMenusXml(modulePath);
136
249
  const modelFiles = await readPythonModelFiles(modulePath);
137
250
  const viewFiles = await readViewXmlFiles(modulePath);
251
+ const viewXml = await Promise.all(viewFiles.map(async (fileName) => (await readOptionalFile(join(modulePath, 'views', fileName))) ?? ''));
252
+ const allViewXml = [...viewXml, ...menuXml];
138
253
  const depends = manifestDepends(manifest);
139
254
  const data = manifestData(manifest);
255
+ const demo = manifestDemo(manifest);
140
256
  const hasOdooStructures = moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data);
257
+ for (const entry of data) {
258
+ if (!(await fileExists(join(modulePath, entry)))) {
259
+ issues.push(moduleQualityIssue(moduleName, relativePath, `missing manifest data file: ${entry}`, 'error'));
260
+ }
261
+ }
262
+ for (const entry of demo) {
263
+ if (!(await fileExists(join(modulePath, entry)))) {
264
+ issues.push(moduleQualityIssue(moduleName, relativePath, `missing manifest demo file: ${entry}`, 'warning'));
265
+ }
266
+ }
141
267
  if (hasOdooStructures && !depends.includes('base')) {
142
268
  issues.push(moduleIssue(moduleName, relativePath, 'missing base dependency for model-based module'));
143
269
  }
@@ -157,6 +283,28 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
157
283
  issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv'));
158
284
  }
159
285
  }
286
+ const modelFileContents = await readModelFileContents(modulePath, modelFiles);
287
+ const modelIds = declaredModelIds(modelFileContents);
288
+ const modelNames = declaredModelNames(modelFileContents);
289
+ if (modelIds.size > 0) {
290
+ for (const modelId of accessCsvModelIds(await readOptionalFile(join(modulePath, 'security/ir.model.access.csv')))) {
291
+ if (!modelIds.has(modelId)) {
292
+ issues.push(moduleQualityIssue(moduleName, relativePath, `access CSV references unknown model id: ${modelId}`, 'error'));
293
+ }
294
+ }
295
+ }
296
+ if (modelNames.size > 0) {
297
+ for (const modelName of new Set(xmlRecordFieldValues(viewXml, 'ir.ui.view', 'model'))) {
298
+ if (looksLikePlainModelName(modelName) && !modelNames.has(modelName)) {
299
+ issues.push(moduleQualityIssue(moduleName, relativePath, `view XML references unknown model name: ${modelName}`, 'error'));
300
+ }
301
+ }
302
+ for (const modelName of new Set(xmlRecordFieldValues(allViewXml, 'ir.actions.act_window', 'res_model'))) {
303
+ if (looksLikePlainModelName(modelName) && !modelNames.has(modelName)) {
304
+ issues.push(moduleQualityIssue(moduleName, relativePath, `action XML references unknown res_model: ${modelName}`, 'error'));
305
+ }
306
+ }
307
+ }
160
308
  if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
161
309
  issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
162
310
  }
@@ -166,6 +314,12 @@ export async function analyzeModuleDirectory(modulePath, moduleName = basename(m
166
314
  if (!(await directoryExists(join(modulePath, 'tests')))) {
167
315
  issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
168
316
  }
317
+ const actionIds = declaredActionIds(allViewXml);
318
+ for (const actionRef of menuActionReferences(menuXml)) {
319
+ if (!actionIds.has(actionRef)) {
320
+ issues.push(moduleQualityIssue(moduleName, relativePath, `menu XML references missing action id: ${actionRef}`, 'error'));
321
+ }
322
+ }
169
323
  const hasMenuAction = menuXml.some((content) => hasActionableMenuXml(content, moduleName));
170
324
  if (!hasMenuAction) {
171
325
  issues.push(moduleIssue(moduleName, relativePath, 'missing actionable menu XML'));
@@ -124,13 +124,10 @@ index_health AS (
124
124
  wal_health AS (
125
125
  SELECT
126
126
  COALESCE((SELECT setting FROM pg_settings WHERE name = 'wal_level'), 'unavailable')::text AS wal_level,
127
- COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode,
128
- COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS wal_file_count,
129
- COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text AS wal_directory_size_bytes
127
+ COALESCE((SELECT setting FROM pg_settings WHERE name = 'archive_mode'), 'unavailable')::text AS wal_archive_mode
130
128
  ),
131
129
  capacity_health AS (
132
130
  SELECT
133
- COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text AS default_tablespace_size_bytes,
134
131
  COALESCE(
135
132
  (SELECT SUM(n_tup_ins + n_tup_upd + n_tup_del) FROM pg_stat_database WHERE datname IS NOT NULL),
136
133
  0
@@ -214,6 +211,21 @@ FROM (
214
211
  'unavailable'
215
212
  )
216
213
  UNION ALL
214
+ SELECT 'work_mem', COALESCE(
215
+ (SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'work_mem'),
216
+ 'unavailable'
217
+ )
218
+ UNION ALL
219
+ SELECT 'maintenance_work_mem', COALESCE(
220
+ (SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'maintenance_work_mem'),
221
+ 'unavailable'
222
+ )
223
+ UNION ALL
224
+ SELECT 'effective_cache_size', COALESCE(
225
+ (SELECT setting || COALESCE(unit, '') FROM pg_settings WHERE name = 'effective_cache_size'),
226
+ 'unavailable'
227
+ )
228
+ UNION ALL
217
229
  SELECT 'long_transaction_count', long_transaction_count FROM transaction_health
218
230
  UNION ALL
219
231
  SELECT 'oldest_long_transaction_age_seconds', oldest_long_transaction_age_seconds FROM transaction_health
@@ -240,16 +252,30 @@ FROM (
240
252
  UNION ALL
241
253
  SELECT 'wal_archive_mode', wal_archive_mode FROM wal_health
242
254
  UNION ALL
243
- SELECT 'wal_file_count', wal_file_count FROM wal_health
244
- UNION ALL
245
- SELECT 'wal_directory_size_bytes', wal_directory_size_bytes FROM wal_health
246
- UNION ALL
247
- SELECT 'default_tablespace_size_bytes', default_tablespace_size_bytes FROM capacity_health
248
- UNION ALL
249
255
  SELECT 'database_write_activity_rows', database_write_activity_rows FROM capacity_health
250
256
  ) metrics
251
257
  ORDER BY metric;
252
258
  `.trim();
259
+ export const POSTGRES_DIAGNOSTICS_OPTIONAL_QUERIES = [
260
+ {
261
+ id: 'wal-directory',
262
+ query: `
263
+ SELECT metric || '|' || value
264
+ FROM (
265
+ SELECT 'wal_file_count'::text AS metric, COALESCE((SELECT COUNT(*) FROM pg_ls_waldir()), 0)::text AS value
266
+ UNION ALL
267
+ SELECT 'wal_directory_size_bytes', COALESCE((SELECT SUM(size) FROM pg_ls_waldir()), 0)::text
268
+ ) metrics
269
+ ORDER BY metric;
270
+ `.trim(),
271
+ },
272
+ {
273
+ id: 'default-tablespace',
274
+ query: `
275
+ SELECT 'default_tablespace_size_bytes'::text || '|' || COALESCE((SELECT pg_tablespace_size('pg_default')), 0)::text;
276
+ `.trim(),
277
+ },
278
+ ];
253
279
  export const POSTGRES_DIAGNOSTIC_KEYS = [
254
280
  'database_count',
255
281
  'active_connections',
@@ -264,6 +290,9 @@ export const POSTGRES_DIAGNOSTIC_KEYS = [
264
290
  'pg_stat_statements_installed_version',
265
291
  'shared_preload_libraries',
266
292
  'shared_buffers',
293
+ 'work_mem',
294
+ 'maintenance_work_mem',
295
+ 'effective_cache_size',
267
296
  'long_transaction_count',
268
297
  'oldest_long_transaction_age_seconds',
269
298
  'idle_in_transaction_count',
@@ -591,6 +620,15 @@ export function structuredPostgresDiagnostics(diagnostics) {
591
620
  if (diagnostics.shared_buffers) {
592
621
  structured.sharedBuffers = diagnostics.shared_buffers;
593
622
  }
623
+ if (diagnostics.work_mem) {
624
+ structured.workMem = diagnostics.work_mem;
625
+ }
626
+ if (diagnostics.maintenance_work_mem) {
627
+ structured.maintenanceWorkMem = diagnostics.maintenance_work_mem;
628
+ }
629
+ if (diagnostics.effective_cache_size) {
630
+ structured.effectiveCacheSize = diagnostics.effective_cache_size;
631
+ }
594
632
  if (longTransactionCount !== undefined) {
595
633
  structured.longTransactionCount = longTransactionCount;
596
634
  }
package/dist/repo-url.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { basename } from 'node:path';
2
+ import { validateRepoPath } from './path-validation.js';
3
+ import { parseGitHubRepoUrl } from './github.js';
2
4
  export function normalizeRepositoryUrl(repoUrl) {
3
5
  const trimmed = repoUrl.trim();
4
6
  const withoutSuffix = trimmed.replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
@@ -15,13 +17,8 @@ export function inferRepoPath(repoUrl) {
15
17
  if (!withoutGit) {
16
18
  throw new Error(`Cannot infer repository path from URL: ${repoUrl}`);
17
19
  }
18
- return withoutGit;
20
+ return validateRepoPath(withoutGit);
19
21
  }
20
22
  export function inferGitHubOwner(repoUrl) {
21
- const normalized = normalizeRepositoryUrl(repoUrl);
22
- const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\//);
23
- if (httpsMatch)
24
- return httpsMatch[1];
25
- const sshMatch = normalized.match(/^git@github\.com:([^/]+)\//);
26
- return sshMatch?.[1];
23
+ return parseGitHubRepoUrl(normalizeRepositoryUrl(repoUrl))?.owner;
27
24
  }
@@ -70,7 +70,7 @@ function parseGitmodules(target) {
70
70
  const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
71
71
  const sections = gitmodules.split(/\n(?=\[submodule )/);
72
72
  return sections.flatMap((section) => {
73
- const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/([^\s]+)\s*$/m);
73
+ const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(?:(private|oca|external)\/)?([^\s/]+)\s*$/m);
74
74
  if (!pathMatch) {
75
75
  return [];
76
76
  }
@@ -78,7 +78,7 @@ function parseGitmodules(target) {
78
78
  if (!source) {
79
79
  return [];
80
80
  }
81
- const sourceType = pathMatch[1];
81
+ const sourceType = (pathMatch[1] ?? 'private');
82
82
  return [{ path: pathMatch[2], sourceType, url: source }];
83
83
  });
84
84
  }
@@ -150,7 +150,7 @@ function inferOptionsForPreviewSync(target) {
150
150
  url: metadataMatch?.url?.trim() ||
151
151
  gitmoduleMatch?.url ||
152
152
  readSubmoduleUrlFromPath(target, path, sourceType),
153
- addons: parseAddonsForRepo(addonsYaml, path),
153
+ addons: parseAddonsForRepo(addonsYaml, path, sourceType),
154
154
  };
155
155
  }),
156
156
  engine: 'compose',
@@ -171,9 +171,12 @@ function inferOptionsForPreviewSync(target) {
171
171
  function readSubmoduleUrlFromPath(target, repoPath, sourceType) {
172
172
  try {
173
173
  const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
174
- const escapedPath = `odoo/custom/src/${sourceType}/${repoPath}`;
174
+ const expectedPaths = [`odoo/custom/src/${sourceType}/${repoPath}`];
175
+ if (sourceType === 'private') {
176
+ expectedPaths.push(`odoo/custom/src/${repoPath}`);
177
+ }
175
178
  const sections = gitmodules.split(/\n(?=\[submodule )/);
176
- const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
179
+ const section = sections.find((value) => expectedPaths.some((path) => value.includes(`path = ${path}`)));
177
180
  const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
178
181
  return url || `odoo/custom/src/${sourceType}/${repoPath}`;
179
182
  }
@@ -311,11 +314,14 @@ function safeResetExternalAssetOptions(options) {
311
314
  ],
312
315
  }));
313
316
  }
314
- function parseAddonsForRepo(addonsYaml, repoPath) {
317
+ function parseAddonsForRepo(addonsYaml, repoPath, sourceType = 'private') {
315
318
  const safeRepoPath = validateRepoPath(repoPath);
316
319
  const lines = addonsYaml.split('\n');
317
- const header = `private/${safeRepoPath}:`;
318
- const start = lines.findIndex((line) => line.trim() === header);
320
+ const headers = [`${sourceType}/${safeRepoPath}:`];
321
+ if (sourceType === 'private') {
322
+ headers.push(`${safeRepoPath}:`);
323
+ }
324
+ const start = lines.findIndex((line) => headers.includes(line.trim()));
319
325
  if (start === -1)
320
326
  return [safeRepoPath];
321
327
  const addons = [];
@@ -332,7 +338,7 @@ function parseAddonsForRepo(addonsYaml, repoPath) {
332
338
  return addons.length ? addons : [safeRepoPath];
333
339
  }
334
340
  function parseRepoPathsFromAddonsYaml(addonsYaml) {
335
- return [...addonsYaml.matchAll(/^private\/(.+):$/gm)]
341
+ return [...addonsYaml.matchAll(/^(?:private\/)?([^/\s][^/:]*):$/gm)]
336
342
  .map((match) => match[1].trim())
337
343
  .filter((repoPath) => repoPath && isValidPathSegment(repoPath))
338
344
  .map(validateRepoPath);
@@ -341,9 +347,12 @@ async function readSubmoduleUrl(target, repoPath, sourceType) {
341
347
  const safeRepoPath = validateRepoPath(repoPath);
342
348
  try {
343
349
  const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
344
- const escapedPath = `odoo/custom/src/${sourceType}/${safeRepoPath}`;
350
+ const expectedPaths = [`odoo/custom/src/${sourceType}/${safeRepoPath}`];
351
+ if (sourceType === 'private') {
352
+ expectedPaths.push(`odoo/custom/src/${safeRepoPath}`);
353
+ }
345
354
  const sections = gitmodules.split(/\n(?=\[submodule )/);
346
- const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
355
+ const section = sections.find((value) => expectedPaths.some((path) => value.includes(`path = ${path}`)));
347
356
  const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
348
357
  return url || `odoo/custom/src/${sourceType}/${safeRepoPath}`;
349
358
  }
@@ -389,7 +398,7 @@ async function inferOptions(target) {
389
398
  ?.url.trim() ||
390
399
  gitmoduleSources.find((repo) => repo.path === path && repo.type === sourceType)?.url ||
391
400
  (await readSubmoduleUrl(target, path, sourceType)),
392
- addons: parseAddonsForRepo(addonsYaml, path),
401
+ addons: parseAddonsForRepo(addonsYaml, path, sourceType),
393
402
  })));
394
403
  return {
395
404
  product,