@wpmoo/odoo 0.8.63 → 0.8.65

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/README.md CHANGED
@@ -138,12 +138,15 @@ npx @wpmoo/odoo --help
138
138
  npx @wpmoo/odoo --version
139
139
 
140
140
  npx @wpmoo/odoo status
141
+ npx @wpmoo/odoo status --json
141
142
  npx @wpmoo/odoo doctor
143
+ npx @wpmoo/odoo doctor --json
142
144
  npx @wpmoo/odoo doctor --fix
145
+ npx @wpmoo/odoo source list --json
143
146
  npx @wpmoo/odoo add-repo --repo-url https://github.com/example-org/odoo_sample_module_reports.git
144
147
  npx @wpmoo/odoo remove-repo --repo odoo_sample_module_reports
145
- npx @wpmoo/odoo add-module --repo odoo_sample_module --module odoo_sample_module_base
146
- npx @wpmoo/odoo remove-module --repo odoo_sample_module --module odoo_sample_module_base
148
+ npx @wpmoo/odoo add-module --repo odoo_sample_module --module odoo_sample_module_base --source-type private
149
+ npx @wpmoo/odoo remove-module --repo odoo_sample_module --module odoo_sample_module_base --source-type private
147
150
  npx @wpmoo/odoo reset --dry-run
148
151
  npx @wpmoo/odoo reset
149
152
 
@@ -275,10 +278,13 @@ GitHub CLI is optional for repository setup. When it is available and authentica
275
278
 
276
279
  Add a minimal Odoo module skeleton to a source repository:
277
280
 
281
+ For module actions, `--source-type` selects the source directory (`private`, `oca`, or `external`). Default is `private`.
282
+
278
283
  ```bash
279
284
  npx @wpmoo/odoo add-module \
280
285
  --repo odoo_sample_module \
281
- --module odoo_sample_module_base
286
+ --module odoo_sample_module_base \
287
+ --source-type oca
282
288
  ```
283
289
 
284
290
  Remove a module registration while keeping files:
@@ -286,7 +292,8 @@ Remove a module registration while keeping files:
286
292
  ```bash
287
293
  npx @wpmoo/odoo remove-module \
288
294
  --repo odoo_sample_module \
289
- --module odoo_sample_module_base
295
+ --module odoo_sample_module_base \
296
+ --source-type oca
290
297
  ```
291
298
 
292
299
  Delete module files as well:
@@ -315,12 +322,14 @@ Inspect configured sources:
315
322
 
316
323
  ```bash
317
324
  npx @wpmoo/odoo source list
325
+ npx @wpmoo/odoo source list --json
318
326
  ```
319
327
 
320
328
  Regenerate the manifest and metadata from the current metadata/gitmodule state:
321
329
 
322
330
  ```bash
323
331
  npx @wpmoo/odoo source sync
332
+ npx @wpmoo/odoo source sync --json
324
333
  ```
325
334
 
326
335
  `source add` and `source remove` are direct aliases for the same repository
@@ -340,10 +349,23 @@ npx @wpmoo/odoo source remove --repo server-tools --source-type oca
340
349
 
341
350
  ```bash
342
351
  npx @wpmoo/odoo status
352
+ npx @wpmoo/odoo status --json
343
353
  ```
344
354
 
345
355
  It reports whether the environment is detected, which Odoo version is selected, how many source repos are configured, how many module candidates are present, which core files are missing, and the recommended next action.
346
356
 
357
+ For automation and VS Code cockpit integration, all of these commands also support
358
+ `--json`:
359
+
360
+ ```bash
361
+ npx @wpmoo/odoo status --json
362
+ npx @wpmoo/odoo source list --json
363
+ npx @wpmoo/odoo source sync --json
364
+ npx @wpmoo/odoo doctor --json
365
+ ```
366
+
367
+ JSON output is optional; human-readable output remains the default.
368
+
347
369
  `doctor` performs deeper checks:
348
370
 
349
371
  ```bash
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { detectDevelopmentEnvironment } from './environment.js';
12
12
  import { commandOdooVersion } from './environment-version.js';
13
13
  import { defaultAgentSkillsTemplateUrl } from './external-templates.js';
14
14
  import { isDailyActionCommand, runDailyAction } from './daily-actions.js';
15
- import { runDoctor } from './doctor.js';
15
+ import { getDoctorReport, runDoctor } from './doctor.js';
16
16
  import { getOriginUrl, realGit } from './git.js';
17
17
  import { renderHelp } from './help.js';
18
18
  import { addModuleToSourceRepo, listModulesInSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
@@ -22,13 +22,13 @@ import { promptRepositoryUrl } from './prompt-repositories.js';
22
22
  import { inferGitHubOwner, inferRepoPath, normalizeRepositoryUrl } from './repo-url.js';
23
23
  import { addModuleRepo, listModuleRepos, removeModuleRepo } from './repo-actions.js';
24
24
  import { renderSafeResetPreview, safeResetEnvironment } from './safe-reset.js';
25
- import { listSources, renderSourceList, syncSources } from './source-actions.js';
25
+ import { listSources, renderSourceList, sourceListJson, sourceSyncJson, syncSources, } from './source-actions.js';
26
26
  import { checkGitHubRepositories, createGitHubRepositories, manualCreateCommands, repositoryPreflightAvailable, } from './repository-preflight.js';
27
27
  import { scaffold } from './scaffold.js';
28
28
  import { renderBanner } from './templates.js';
29
29
  import { checkForUpdate, installLatestPackage, isUpdateCheckSkipped, restartCli } from './update-check.js';
30
30
  import { packageName, packageVersion, renderVersion, renderVersionTag } from './version.js';
31
- import { getEnvironmentStatus, renderEnvironmentStatusForTarget, renderEnvironmentStatusSummary, } from './status.js';
31
+ import { environmentStatusJson, getEnvironmentStatus, renderEnvironmentStatusForTarget, renderEnvironmentStatusSummary, } from './status.js';
32
32
  import { getGitHubAccounts, getGitHubRepositoryStatus, githubRepositoryUrl, realGitHub, createGitHubRepository, } from './github.js';
33
33
  import { environmentGitHubOwner } from './environment-context.js';
34
34
  import { handlePromptCancel, handleUnavailableMenuChoice, installPromptCancelKeyTracker, isMenuBackSignal, MenuBackSignal, menuIntroTitle, menuPromptMessage, } from './menu-navigation.js';
@@ -106,6 +106,12 @@ function booleanOption(values, key, fallback) {
106
106
  return false;
107
107
  throw new Error(`Invalid boolean value for --${key}: ${value}`);
108
108
  }
109
+ function jsonOption(values) {
110
+ return booleanOption(values, 'json', false);
111
+ }
112
+ function printJson(value) {
113
+ console.log(JSON.stringify(value));
114
+ }
109
115
  function yellow(value) {
110
116
  if (!process.stdout.isTTY || process.env.NO_COLOR !== undefined)
111
117
  return value;
@@ -399,21 +405,39 @@ async function ensureAddRepoGitHubRepository(options, cancelAction = 'exit') {
399
405
  await createGitHubRepository(realGitHub, options.repoUrl, visibility);
400
406
  }
401
407
  async function selectSourceRepo(target, cancelAction = 'exit') {
402
- const repos = await listModuleRepos(target);
403
- if (repos.length === 0) {
408
+ const repos = await listSources(target);
409
+ const repoOptions = repos.length > 0
410
+ ? repos.map((repo) => ({
411
+ value: { repoPath: repo.path, sourceType: repo.type },
412
+ label: `${repo.type}/${repo.path}`,
413
+ }))
414
+ : (await listModuleRepos(target)).map((repoPath) => ({
415
+ value: { repoPath, sourceType: 'private' },
416
+ label: `private/${repoPath}`,
417
+ }));
418
+ if (repoOptions.length === 0) {
404
419
  if (cancelAction === 'back') {
405
- note(`No source repos found under ${target}/odoo/custom/src/private.\nNext: choose "Add source repo" first.`, 'Nothing to select');
420
+ note(`No source repos found under ${target}/odoo/custom/src.\nNext: choose "Add source repo" first.`, 'Nothing to select');
406
421
  handleUnavailableMenuChoice(cancelAction);
407
422
  }
408
- throw new Error(`No source repos found under ${target}/odoo/custom/src/private`);
423
+ throw new Error(`No source repos found under ${target}/odoo/custom/src`);
409
424
  }
410
- const repoPath = await select({
425
+ const selected = await select({
411
426
  message: menuPromptMessage('Source repo', cancelAction),
412
- options: repos.map((repo) => ({ value: repo, label: repo })),
413
- initialValue: repos[0],
427
+ options: repoOptions,
428
+ initialValue: repoOptions[0].value,
414
429
  });
415
- handleCancel(repoPath, cancelAction);
416
- return String(repoPath);
430
+ handleCancel(selected, cancelAction);
431
+ if (typeof selected === 'string') {
432
+ return { repoPath: selected, sourceType: 'private' };
433
+ }
434
+ if (typeof selected === 'object' && selected !== null && 'repoPath' in selected && 'sourceType' in selected) {
435
+ return { repoPath: selected.repoPath, sourceType: selected.sourceType };
436
+ }
437
+ return { repoPath: String(selected), sourceType: 'private' };
438
+ }
439
+ function formatSourceRepoPromptPath(target, selected) {
440
+ return renderedSourceRepoPath(target, selected.sourceType, selected.repoPath);
417
441
  }
418
442
  function suggestedModuleName(repoPath) {
419
443
  return 'odoo_sample_module';
@@ -430,6 +454,7 @@ async function addModuleOptionsFromArgs(argv) {
430
454
  target,
431
455
  repoPath,
432
456
  moduleName,
457
+ sourceType: optionalSourceTypeValue(values),
433
458
  odooVersion: await commandOdooVersion(target, stringOption(values, 'odooVersion')),
434
459
  stage: booleanOption(values, 'stage', true),
435
460
  };
@@ -437,15 +462,16 @@ async function addModuleOptionsFromArgs(argv) {
437
462
  async function addModuleOptionsFromPrompts(showIntro = true, cancelAction = 'exit') {
438
463
  showSubmenuIntro('Add module to source repo', showIntro, cancelAction);
439
464
  const target = process.cwd();
440
- const repoPath = await selectSourceRepo(target, cancelAction);
465
+ const sourceRepo = await selectSourceRepo(target, cancelAction);
441
466
  const moduleName = asString(await text({
442
467
  message: menuPromptMessage('Module name', cancelAction),
443
- placeholder: suggestedModuleName(repoPath),
468
+ placeholder: suggestedModuleName(sourceRepo.repoPath),
444
469
  validate: (value) => (value.trim() ? undefined : 'Enter the module technical name.'),
445
- }), suggestedModuleName(repoPath), cancelAction);
470
+ }), suggestedModuleName(sourceRepo.repoPath), cancelAction);
446
471
  return {
447
472
  target,
448
- repoPath,
473
+ repoPath: sourceRepo.repoPath,
474
+ sourceType: sourceRepo.sourceType,
449
475
  moduleName,
450
476
  odooVersion: await commandOdooVersion(target),
451
477
  stage: true,
@@ -473,17 +499,19 @@ function resetCommandOptionsFromArgs(argv) {
473
499
  };
474
500
  }
475
501
  function doctorOptionsFromArgs(argv) {
476
- if (argv.length === 0) {
477
- return {};
478
- }
479
502
  const { values } = parseArgs(argv);
480
503
  const keys = Object.keys(values);
481
- if (keys.length !== 1 || !Object.hasOwn(values, 'fix')) {
504
+ const allowedKeys = new Set(['fix', 'json']);
505
+ if (!keys.every((key) => allowedKeys.has(key))) {
482
506
  throw new Error('Usage: wpmoo doctor');
483
507
  }
484
- return {
485
- fix: booleanOption(values, 'fix', false),
508
+ const options = {
509
+ json: jsonOption(values),
486
510
  };
511
+ if (Object.hasOwn(values, 'fix')) {
512
+ options.fix = booleanOption(values, 'fix', false);
513
+ }
514
+ return options;
487
515
  }
488
516
  function sourceUsage() {
489
517
  return 'Usage: wpmoo source <list|sync|add|remove> [options]';
@@ -493,11 +521,15 @@ function sourceSyncOptionsFromArgs(argv) {
493
521
  return {
494
522
  target: resolve(stringOption(values, 'target') ?? process.cwd()),
495
523
  stage: booleanOption(values, 'stage', true),
524
+ json: jsonOption(values),
496
525
  };
497
526
  }
498
- function sourceListTargetFromArgs(argv) {
527
+ function sourceListOptionsFromArgs(argv) {
499
528
  const { values } = parseArgs(argv);
500
- return resolve(stringOption(values, 'target') ?? process.cwd());
529
+ return {
530
+ target: resolve(stringOption(values, 'target') ?? process.cwd()),
531
+ json: jsonOption(values),
532
+ };
501
533
  }
502
534
  async function runSourceCommand(argv) {
503
535
  const [subcommand, ...subcommandArgv] = argv;
@@ -505,15 +537,24 @@ async function runSourceCommand(argv) {
505
537
  throw new Error(sourceUsage());
506
538
  }
507
539
  if (subcommand === 'list') {
540
+ const options = sourceListOptionsFromArgs(subcommandArgv);
541
+ const sources = await listSources(options.target);
542
+ if (options.json) {
543
+ printJson(sourceListJson(sources));
544
+ return;
545
+ }
508
546
  console.log(renderBanner());
509
- const target = sourceListTargetFromArgs(subcommandArgv);
510
- console.log(renderSourceList(await listSources(target)));
547
+ console.log(renderSourceList(sources));
511
548
  return;
512
549
  }
513
550
  if (subcommand === 'sync') {
514
- console.log(renderBanner());
515
551
  const options = sourceSyncOptionsFromArgs(subcommandArgv);
516
- await syncSources(options);
552
+ const sources = await syncSources({ target: options.target, stage: options.stage });
553
+ if (options.json) {
554
+ printJson(sourceSyncJson(sources, options.target));
555
+ return;
556
+ }
557
+ console.log(renderBanner());
517
558
  outro(`Synced source manifest in ${options.target}.`);
518
559
  return;
519
560
  }
@@ -587,6 +628,7 @@ function removeModuleOptionsFromArgs(argv) {
587
628
  target: resolve(stringOption(values, 'target') ?? process.cwd()),
588
629
  repoPath,
589
630
  moduleName,
631
+ sourceType: optionalSourceTypeValue(values),
590
632
  deleteFiles: booleanOption(values, 'deleteFiles', false),
591
633
  stage: booleanOption(values, 'stage', true),
592
634
  };
@@ -594,14 +636,14 @@ function removeModuleOptionsFromArgs(argv) {
594
636
  async function removeModuleOptionsFromPrompts(showIntro = true, cancelAction = 'exit') {
595
637
  showSubmenuIntro('Remove module from source repo', showIntro, cancelAction);
596
638
  const target = process.cwd();
597
- const repoPath = await selectSourceRepo(target, cancelAction);
598
- const modules = await listModulesInSourceRepo(target, repoPath);
639
+ const sourceRepo = await selectSourceRepo(target, cancelAction);
640
+ const modules = await listModulesInSourceRepo(target, sourceRepo.repoPath, sourceRepo.sourceType);
599
641
  if (modules.length === 0) {
600
642
  if (cancelAction === 'back') {
601
- note(`No Odoo modules found under ${target}/odoo/custom/src/private/${repoPath}.\nNext: choose "Add module to source repo" first.`, 'Nothing to remove');
643
+ note(`No Odoo modules found under ${formatSourceRepoPromptPath(target, sourceRepo)}.\nNext: choose "Add module to source repo" first.`, 'Nothing to remove');
602
644
  handleUnavailableMenuChoice(cancelAction);
603
645
  }
604
- throw new Error(`No Odoo modules found under ${target}/odoo/custom/src/private/${repoPath}`);
646
+ throw new Error(`No Odoo modules found under ${formatSourceRepoPromptPath(target, sourceRepo)}`);
605
647
  }
606
648
  const moduleName = await select({
607
649
  message: menuPromptMessage('Module to remove', cancelAction),
@@ -618,7 +660,8 @@ async function removeModuleOptionsFromPrompts(showIntro = true, cancelAction = '
618
660
  handleCancel(deleteFiles, cancelAction);
619
661
  return {
620
662
  target,
621
- repoPath,
663
+ repoPath: sourceRepo.repoPath,
664
+ sourceType: sourceRepo.sourceType,
622
665
  moduleName: String(moduleName),
623
666
  deleteFiles: Boolean(deleteFiles),
624
667
  stage: true,
@@ -885,14 +928,28 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
885
928
  }
886
929
  if (route.command === 'doctor') {
887
930
  const options = doctorOptionsFromArgs(route.argv);
931
+ const doctorOptions = {};
932
+ if (options.fix !== undefined) {
933
+ doctorOptions.fix = options.fix;
934
+ }
935
+ if (options.json) {
936
+ printJson(await getDoctorReport(cwd, doctorOptions));
937
+ return;
938
+ }
888
939
  console.log(renderBanner());
889
- console.log(options.fix === undefined ? await runDoctor(cwd) : await runDoctor(cwd, options));
940
+ console.log(options.fix === undefined ? await runDoctor(cwd) : await runDoctor(cwd, doctorOptions));
890
941
  return;
891
942
  }
892
943
  if (route.command === 'status') {
893
- if (route.argv.length > 0) {
944
+ const { values } = parseArgs(route.argv);
945
+ const keys = Object.keys(values);
946
+ if (!keys.every((key) => key === 'json')) {
894
947
  throw new Error('Usage: wpmoo status');
895
948
  }
949
+ if (jsonOption(values)) {
950
+ printJson(environmentStatusJson(await getEnvironmentStatus(cwd)));
951
+ return;
952
+ }
896
953
  console.log(renderBanner());
897
954
  console.log(await renderEnvironmentStatusForTarget(cwd));
898
955
  return;
@@ -1,6 +1,7 @@
1
1
  import { isCancel, select, text } from '@clack/prompts';
2
2
  import { listModulesInSourceRepo } from '../module-actions.js';
3
3
  import { listModuleRepos } from '../repo-actions.js';
4
+ import { listSources } from '../source-actions.js';
4
5
  import { handlePromptCancel, menuPromptMessage, } from '../menu-navigation.js';
5
6
  const manualModuleValue = '__wpmoo_manual_module_entry__';
6
7
  function defaultCancelHandler(value, action) {
@@ -27,10 +28,13 @@ function requiredString(value, message, deps) {
27
28
  }
28
29
  async function detectedModules(cwd) {
29
30
  try {
30
- const repos = await listModuleRepos(cwd);
31
+ const sources = await listSources(cwd);
32
+ const repos = sources.length > 0
33
+ ? sources.map((source) => ({ path: source.path, sourceType: source.type }))
34
+ : (await listModuleRepos(cwd)).map((path) => ({ path, sourceType: 'private' }));
31
35
  const modules = await Promise.all(repos.map(async (repo) => {
32
36
  try {
33
- return await listModulesInSourceRepo(cwd, repo);
37
+ return await listModulesInSourceRepo(cwd, repo.path, repo.sourceType);
34
38
  }
35
39
  catch {
36
40
  return [];
package/dist/doctor.js CHANGED
@@ -40,6 +40,11 @@ function isRecord(value) {
40
40
  function isDoctorOptions(value) {
41
41
  return isRecord(value);
42
42
  }
43
+ function isMetadataError(message) {
44
+ return (message.startsWith('Missing metadata file:') ||
45
+ message.startsWith('Invalid metadata JSON in .wpmoo/odoo.json') ||
46
+ message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
47
+ }
43
48
  const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
44
49
  function parsePostgresMajorFromValue(value) {
45
50
  if (!value)
@@ -280,24 +285,41 @@ async function repairSourceManifestFromDiscoveredState(target, sourceRepos, fall
280
285
  await writeSourceManifest(target, entries);
281
286
  await replaceSourceRepos(target, sourceReposFromManifest(entries));
282
287
  }
283
- export async function runDoctor(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
288
+ export async function getDoctorReport(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
284
289
  const actualRunner = isDoctorOptions(runnerOrOptions) ? realCommandRunner : runnerOrOptions;
285
290
  const actualOptions = isDoctorOptions(runnerOrOptions) ? runnerOrOptions : options;
286
- const appliedFixes = [];
287
- const lines = ['WPMoo doctor'];
288
291
  const errors = [];
289
292
  const warnings = [];
290
- const metadata = await readMetadata(target);
291
- lines.push(`OK metadata ${markerPath}`);
293
+ const checks = [];
294
+ const appliedFixes = [];
295
+ const report = {
296
+ schemaVersion: 1,
297
+ command: 'doctor',
298
+ ok: false,
299
+ target,
300
+ checks,
301
+ warnings,
302
+ errors,
303
+ appliedFixes,
304
+ };
305
+ let metadata;
306
+ try {
307
+ metadata = await readMetadata(target);
308
+ }
309
+ catch (error) {
310
+ errors.push(errorMessage(error));
311
+ return report;
312
+ }
313
+ checks.push(`OK metadata ${markerPath}`);
292
314
  const engine = metadataString(metadata, 'engine') ?? 'compose';
293
315
  if (engine !== 'compose') {
294
316
  errors.push(`Unsupported environment engine: ${engine}`);
295
317
  }
296
318
  else {
297
- lines.push('OK engine compose');
319
+ checks.push('OK engine compose');
298
320
  }
299
321
  const odooVersion = metadataString(metadata, 'odooVersion') ?? defaultOdooVersion;
300
- lines.push(`OK Odoo version ${odooVersion}`);
322
+ checks.push(`OK Odoo version ${odooVersion}`);
301
323
  const env = await readEnvFile(target);
302
324
  const composeVersions = new Set([odooVersion]);
303
325
  const envOdooVersion = env?.get('ODOO_VERSION')?.trim();
@@ -312,7 +334,7 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
312
334
  errors.push(...composeLayout.errors);
313
335
  }
314
336
  else {
315
- lines.push(`OK compose files ${composeLayout.files.join(', ')}`);
337
+ checks.push(`OK compose files ${composeLayout.files.join(', ')}`);
316
338
  const postgresVersion = inferPostgresVersion(metadata, odooVersion, env);
317
339
  if (postgresVersion === '18') {
318
340
  for (const file of composeLayout.files) {
@@ -351,16 +373,23 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
351
373
  }
352
374
  }
353
375
  if (errors.length === scriptErrorCount) {
354
- lines.push(`OK scripts ${scriptNames.length} checked`);
376
+ checks.push(`OK scripts ${scriptNames.length} checked`);
377
+ }
378
+ let sourceRepos;
379
+ try {
380
+ sourceRepos = sourceReposFromMetadata(metadata);
381
+ }
382
+ catch (error) {
383
+ errors.push(errorMessage(error));
384
+ return report;
355
385
  }
356
- const sourceRepos = sourceReposFromMetadata(metadata);
357
386
  for (const repo of sourceRepos) {
358
387
  const relativePath = sourceRepoPath(normalizeSourceType(repo.sourceType), repo.path);
359
388
  if (!(await exists(join(target, relativePath))) && repo.path) {
360
389
  errors.push(`Missing source repo path: ${relativePath}`);
361
390
  }
362
391
  }
363
- lines.push(`OK source repos ${sourceRepos.length} checked`);
392
+ checks.push(`OK source repos ${sourceRepos.length} checked`);
364
393
  const manifestPath = join(target, sourceManifestPath);
365
394
  const hasManifest = await exists(manifestPath);
366
395
  let manifestEntries = [];
@@ -409,19 +438,19 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
409
438
  errors.push('HTTP_PORT and GEVENT_PORT in .env must not be equal');
410
439
  }
411
440
  if (/^\d+$/.test(httpPort) && /^\d+$/.test(geventPort) && httpPort !== geventPort) {
412
- lines.push(`OK .env ports HTTP_PORT=${httpPort} GEVENT_PORT=${geventPort}`);
441
+ checks.push(`OK .env ports HTTP_PORT=${httpPort} GEVENT_PORT=${geventPort}`);
413
442
  }
414
443
  }
415
444
  try {
416
445
  await actualRunner('docker', ['version'], { cwd: target });
417
- lines.push('OK docker CLI');
446
+ checks.push('OK docker CLI');
418
447
  }
419
448
  catch (error) {
420
449
  errors.push(`Docker CLI check failed: ${errorMessage(error)}`);
421
450
  }
422
451
  try {
423
452
  await actualRunner('docker', ['compose', 'version'], { cwd: target });
424
- lines.push('OK docker compose');
453
+ checks.push('OK docker compose');
425
454
  }
426
455
  catch (error) {
427
456
  errors.push(`Docker Compose check failed: ${errorMessage(error)}`);
@@ -432,12 +461,12 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
432
461
  const submoduleErrors = sourceSubmoduleStatusErrors(result.stdout, sourceRepos);
433
462
  errors.push(...submoduleErrors);
434
463
  if (submoduleErrors.length === 0) {
435
- lines.push(`OK git submodules ${sourceRepos.length} checked`);
464
+ checks.push(`OK git submodules ${sourceRepos.length} checked`);
436
465
  }
437
466
  }
438
467
  catch (error) {
439
468
  if (isNotGitCheckoutError(error)) {
440
- lines.push('OK git submodules skipped (not a git checkout)');
469
+ checks.push('OK git submodules skipped (not a git checkout)');
441
470
  }
442
471
  else {
443
472
  errors.push(`Git submodule status check failed: ${errorMessage(error)}`);
@@ -446,32 +475,45 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
446
475
  }
447
476
  try {
448
477
  await actualRunner('gh', ['auth', 'status'], { cwd: target });
449
- lines.push('OK GitHub CLI auth');
478
+ checks.push('OK GitHub CLI auth');
450
479
  }
451
480
  catch (error) {
452
- warnings.push(`WARN GitHub CLI auth: ${errorMessage(error)}`);
481
+ warnings.push(`GitHub CLI auth: ${errorMessage(error)}`);
453
482
  }
454
- if (errors.length > 0) {
455
- if (actualOptions.fix && appliedFixes.length > 0) {
483
+ report.ok = errors.length === 0;
484
+ return report;
485
+ }
486
+ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
487
+ const report = await getDoctorReport(target, runnerOrOptions, options);
488
+ const actualRunner = isDoctorOptions(runnerOrOptions) ? realCommandRunner : runnerOrOptions;
489
+ const actualOptions = isDoctorOptions(runnerOrOptions) ? runnerOrOptions : options;
490
+ if (!report.ok) {
491
+ if (report.errors.some(isMetadataError)) {
492
+ throw new Error(report.errors[0]);
493
+ }
494
+ if (actualOptions.fix && report.appliedFixes.length > 0) {
456
495
  return [
457
496
  'Doctor auto-fixes were not enough to satisfy all checks.',
458
- ...appliedFixes.map((fix) => `- ${fix}`),
459
- renderFailure(errors),
497
+ ...report.appliedFixes.map((fix) => `- ${fix}`),
498
+ renderFailure(report.errors),
460
499
  ].join('\n');
461
500
  }
462
- throw new Error(renderFailure(errors));
501
+ throw new Error(renderFailure(report.errors));
463
502
  }
464
- lines.push(...warnings);
465
- lines.push('Doctor checks passed.');
466
- const report = lines.join('\n');
467
- if (actualOptions.fix && appliedFixes.length > 0) {
503
+ const renderedReport = [
504
+ 'WPMoo doctor',
505
+ ...report.checks,
506
+ ...report.warnings.map((warning) => `WARN ${warning}`),
507
+ 'Doctor checks passed.',
508
+ ];
509
+ if (report.appliedFixes.length > 0) {
468
510
  const postFixReport = await runDoctor(target, actualRunner, { ...actualOptions, fix: false });
469
511
  return [
470
512
  'Applied safe doctor fixes:',
471
- ...appliedFixes.map((fix) => `- ${fix}`),
513
+ ...report.appliedFixes.map((fix) => `- ${fix}`),
472
514
  '',
473
515
  postFixReport,
474
516
  ].join('\n');
475
517
  }
476
- return report;
518
+ return renderedReport.join('\n');
477
519
  }
package/dist/help.js CHANGED
@@ -7,16 +7,20 @@ Usage:
7
7
  npx @wpmoo/odoo
8
8
  npx @wpmoo/odoo create --product <slug> [--target <path>] --dev-repo-url <url> --source-repo-url <url>
9
9
  npx @wpmoo/odoo status
10
+ npx @wpmoo/odoo status --json
10
11
  npx @wpmoo/odoo add-repo --repo-url <url> [--source-type private|oca|external]
11
12
  npx @wpmoo/odoo remove-repo --repo <name>
12
13
  npx @wpmoo/odoo source list
14
+ npx @wpmoo/odoo source list --json
13
15
  npx @wpmoo/odoo source sync
16
+ npx @wpmoo/odoo source sync --json
14
17
  npx @wpmoo/odoo source add --repo-url <url> [--source-type private|oca|external]
15
18
  npx @wpmoo/odoo source remove --repo <name> [--source-type private|oca|external]
16
- npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
17
- npx @wpmoo/odoo remove-module --repo <source-repo> --module <module-name>
19
+ npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name> [--source-type <category>]
20
+ npx @wpmoo/odoo remove-module --repo <source-repo> --module <module-name> [--source-type <category>]
18
21
  npx @wpmoo/odoo reset [--dry-run]
19
22
  npx @wpmoo/odoo doctor [--fix]
23
+ npx @wpmoo/odoo doctor --json
20
24
  npx @wpmoo/odoo start
21
25
  npx @wpmoo/odoo stop
22
26
  npx @wpmoo/odoo logs [service]
@@ -48,8 +52,9 @@ Options:
48
52
  --postgres-version <value> PostgreSQL image version written to compose .env.example.
49
53
  --http-port <port> Host HTTP port written to .env.example.
50
54
  --gevent-port <port> Host gevent/live chat port written to .env.example.
55
+ --json Emit machine-readable JSON. Human-readable output remains the default.
51
56
  --repo-url <url> Source repo URL for add-repo.
52
- --source-type <category> Source repo category for add-repo/remove-repo. One of private, oca, external. Default: private.
57
+ --source-type <category> Source repo category for add-repo/remove-repo/add-module/remove-module. One of private, oca, external. Default: private.
53
58
  --repo <name> Source repo folder name for repo/module actions.
54
59
  --module <name> Odoo module technical name for module actions.
55
60
  --delete-files Also delete module files in remove-module. Default: false.
@@ -101,7 +106,11 @@ Task recipes:
101
106
  npx @wpmoo/odoo source list
102
107
  npx @wpmoo/odoo source sync
103
108
  Add module:
104
- npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
109
+ npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name> --source-type private|oca|external
110
+ Remove module:
111
+ npx @wpmoo/odoo remove-module --repo <source-repo> --module <module-name> --source-type private|oca|external
112
+ Add OCA module:
113
+ npx @wpmoo/odoo add-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
105
114
  Run tests:
106
115
  npx @wpmoo/odoo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
107
116
  Safe reset and recover:
@@ -117,6 +126,13 @@ Task recipes:
117
126
  npx @wpmoo/odoo logs [service]
118
127
  npx @wpmoo/odoo restart
119
128
 
129
+ Machine-readable JSON output:
130
+ for automation and VS Code cockpit integration while keeping default human-readable output.
131
+ npx @wpmoo/odoo status --json
132
+ npx @wpmoo/odoo source list --json
133
+ npx @wpmoo/odoo source sync --json
134
+ npx @wpmoo/odoo doctor --json
135
+
120
136
  Example:
121
137
  npx @wpmoo/odoo create \\
122
138
  --product odoo_sample_module \\
@@ -5,11 +5,15 @@ import { readEnvironmentMetadata } from './environment.js';
5
5
  import { realGit, stageAll } from './git.js';
6
6
  import { pathUnderBase, validateModuleName, validateRepoPath } from './path-validation.js';
7
7
  import { readAddonsYaml, writeAddonsYaml } from './repo-actions.js';
8
- function sourceRepoPath(target, repoPath) {
9
- return pathUnderBase(join(target, 'odoo/custom/src/private'), repoPath, 'repo path');
8
+ const validSourceTypes = ['private', 'oca', 'external'];
9
+ function normalizeSourceType(value) {
10
+ return validSourceTypes.includes(value) ? value : 'private';
10
11
  }
11
- function modulePath(target, repoPath, moduleName) {
12
- return pathUnderBase(sourceRepoPath(target, repoPath), moduleName, 'module name');
12
+ function sourceRepoPath(target, sourceType, repoPath) {
13
+ return pathUnderBase(join(target, `odoo/custom/src/${sourceType}`), repoPath, 'repo path');
14
+ }
15
+ function modulePath(target, sourceType, repoPath, moduleName) {
16
+ return pathUnderBase(sourceRepoPath(target, sourceType, repoPath), moduleName, 'module name');
13
17
  }
14
18
  function titleizeModule(moduleName) {
15
19
  return moduleName
@@ -49,7 +53,8 @@ async function usesAddonsYaml(target) {
49
53
  export async function addModuleToSourceRepo(options, git = realGit) {
50
54
  const repoPath = validateRepoPath(options.repoPath);
51
55
  const moduleName = validateModuleName(options.moduleName);
52
- const destination = modulePath(options.target, repoPath, moduleName);
56
+ const sourceType = normalizeSourceType(options.sourceType);
57
+ const destination = modulePath(options.target, sourceType, repoPath, moduleName);
53
58
  await mkdir(join(destination, 'models'), { recursive: true });
54
59
  await mkdir(join(destination, 'security'), { recursive: true });
55
60
  await mkdir(join(destination, 'views'), { recursive: true });
@@ -58,24 +63,25 @@ export async function addModuleToSourceRepo(options, git = realGit) {
58
63
  await writeIfMissing(join(destination, 'models/__init__.py'), '');
59
64
  await writeIfMissing(join(destination, 'security/ir.model.access.csv'), 'id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\n');
60
65
  await writeIfMissing(join(destination, 'views/.gitkeep'), '');
61
- if (await usesAddonsYaml(options.target)) {
66
+ if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
62
67
  const addonsYaml = await readAddonsYaml(options.target);
63
68
  await writeAddonsYaml(options.target, addModuleToSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
64
69
  }
65
70
  if (options.stage) {
66
- await stageAll(git, sourceRepoPath(options.target, repoPath));
71
+ await stageAll(git, sourceRepoPath(options.target, sourceType, repoPath));
67
72
  await stageAll(git, options.target);
68
73
  }
69
74
  }
70
- export async function listModulesInSourceRepo(target, repoPath) {
75
+ export async function listModulesInSourceRepo(target, repoPath, sourceType) {
71
76
  const safeRepoPath = validateRepoPath(repoPath);
77
+ const resolvedSourceType = normalizeSourceType(sourceType);
72
78
  try {
73
- const entries = await readdir(sourceRepoPath(target, safeRepoPath), { withFileTypes: true });
79
+ const entries = await readdir(sourceRepoPath(target, resolvedSourceType, safeRepoPath), { withFileTypes: true });
74
80
  const modules = await Promise.all(entries
75
81
  .filter((entry) => entry.isDirectory())
76
82
  .map(async (entry) => {
77
83
  try {
78
- await readFile(join(sourceRepoPath(target, safeRepoPath), entry.name, '__manifest__.py'), 'utf8');
84
+ await readFile(join(sourceRepoPath(target, resolvedSourceType, safeRepoPath), entry.name, '__manifest__.py'), 'utf8');
79
85
  return entry.name;
80
86
  }
81
87
  catch {
@@ -91,16 +97,17 @@ export async function listModulesInSourceRepo(target, repoPath) {
91
97
  export async function removeModuleFromSourceRepo(options, git = realGit) {
92
98
  const repoPath = validateRepoPath(options.repoPath);
93
99
  const moduleName = validateModuleName(options.moduleName);
94
- if (await usesAddonsYaml(options.target)) {
100
+ const sourceType = normalizeSourceType(options.sourceType);
101
+ if (sourceType === 'private' && (await usesAddonsYaml(options.target))) {
95
102
  const addonsYaml = await readAddonsYaml(options.target);
96
103
  await writeAddonsYaml(options.target, removeModuleFromSourceRepoInAddonsYaml(addonsYaml, repoPath, moduleName));
97
104
  }
98
105
  if (options.deleteFiles) {
99
- await rm(modulePath(options.target, repoPath, moduleName), { recursive: true, force: true });
106
+ await rm(modulePath(options.target, sourceType, repoPath, moduleName), { recursive: true, force: true });
100
107
  }
101
108
  if (options.stage) {
102
109
  if (options.deleteFiles) {
103
- await stageAll(git, sourceRepoPath(options.target, repoPath));
110
+ await stageAll(git, sourceRepoPath(options.target, sourceType, repoPath));
104
111
  }
105
112
  await stageAll(git, options.target);
106
113
  }
@@ -1,6 +1,12 @@
1
1
  import { defaultOdooVersion, readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
2
2
  import { realGit, stageAll } from './git.js';
3
3
  import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
4
+ function cloneSourceEntries(entries) {
5
+ return entries.map((entry) => ({
6
+ ...entry,
7
+ addons: [...entry.addons],
8
+ }));
9
+ }
4
10
  export async function listSources(target) {
5
11
  const metadata = await readEnvironmentMetadata(target);
6
12
  const manifest = await readSourceManifest(target);
@@ -24,6 +30,23 @@ export function renderSourceList(entries) {
24
30
  })
25
31
  .join('\n');
26
32
  }
33
+ export function sourceListJson(entries) {
34
+ return {
35
+ schemaVersion: 1,
36
+ command: 'source list',
37
+ ok: true,
38
+ sources: cloneSourceEntries(entries),
39
+ };
40
+ }
41
+ export function sourceSyncJson(entries, target) {
42
+ return {
43
+ schemaVersion: 1,
44
+ command: 'source sync',
45
+ ok: true,
46
+ target,
47
+ sources: cloneSourceEntries(entries),
48
+ };
49
+ }
27
50
  export async function syncSources(options, git = realGit) {
28
51
  const metadata = await readEnvironmentMetadata(options.target);
29
52
  const manifest = await readSourceManifest(options.target);
package/dist/status.js CHANGED
@@ -3,6 +3,16 @@ import { join } from 'node:path';
3
3
  import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
4
4
  import { defaultOdooVersion, markerPath } from './environment.js';
5
5
  import { isValidPathSegment, validateRepoPath } from './path-validation.js';
6
+ const validSourceTypes = ['private', 'oca', 'external'];
7
+ function normalizeSourceType(sourceType) {
8
+ if (typeof sourceType === 'string' && validSourceTypes.includes(sourceType)) {
9
+ return sourceType;
10
+ }
11
+ return 'private';
12
+ }
13
+ function sourceRepoPath(target, sourceType, path) {
14
+ return join(target, 'odoo/custom/src', sourceType, path);
15
+ }
6
16
  async function pathExists(path) {
7
17
  try {
8
18
  await access(path);
@@ -27,9 +37,11 @@ function parseMetadata(content) {
27
37
  }
28
38
  function sourceRepoPathsFromMetadata(metadata) {
29
39
  const sourceRepoPaths = [];
40
+ const sourceRepoLocations = [];
30
41
  const invalidSourceRepoPaths = [];
31
- if (!Array.isArray(metadata.sourceRepos))
32
- return { sourceRepoPaths, invalidSourceRepoPaths };
42
+ if (!Array.isArray(metadata.sourceRepos)) {
43
+ return { sourceRepoPaths, sourceRepoLocations, invalidSourceRepoPaths };
44
+ }
33
45
  for (const repo of metadata.sourceRepos) {
34
46
  const path = repo && typeof repo.path === 'string' ? repo.path.trim() : '';
35
47
  if (!path)
@@ -38,9 +50,12 @@ function sourceRepoPathsFromMetadata(metadata) {
38
50
  invalidSourceRepoPaths.push(path);
39
51
  continue;
40
52
  }
41
- sourceRepoPaths.push(validateRepoPath(path));
53
+ const sourceType = normalizeSourceType(typeof repo.sourceType === 'string' ? repo.sourceType : undefined);
54
+ const normalizedPath = validateRepoPath(path);
55
+ sourceRepoPaths.push(normalizedPath);
56
+ sourceRepoLocations.push({ sourceType, path: normalizedPath });
42
57
  }
43
- return { sourceRepoPaths, invalidSourceRepoPaths };
58
+ return { sourceRepoPaths, sourceRepoLocations, invalidSourceRepoPaths };
44
59
  }
45
60
  async function missingCoreFiles(target, odooVersion) {
46
61
  const missing = [];
@@ -108,6 +123,21 @@ function summaryText(status) {
108
123
  : 'Environment ready';
109
124
  return `${prefix}: Odoo ${status.odooVersion}, source repos ${status.sourceRepoCount}, module candidates ${status.moduleCandidateCount}.`;
110
125
  }
126
+ function isStatusHealthy(status) {
127
+ if (status.kind !== 'environment')
128
+ return false;
129
+ return (status.missingCoreFiles.length === 0 &&
130
+ status.invalidSourceRepoPaths.length === 0 &&
131
+ status.composeErrors.length === 0);
132
+ }
133
+ export function environmentStatusJson(status) {
134
+ return {
135
+ schemaVersion: 1,
136
+ command: 'status',
137
+ ok: isStatusHealthy(status),
138
+ status,
139
+ };
140
+ }
111
141
  export async function getEnvironmentStatus(target) {
112
142
  const metadataFullPath = join(target, markerPath);
113
143
  if (!(await pathExists(metadataFullPath))) {
@@ -135,8 +165,8 @@ export async function getEnvironmentStatus(target) {
135
165
  const odooVersion = typeof metadata.odooVersion === 'string' && metadata.odooVersion.trim()
136
166
  ? metadata.odooVersion.trim()
137
167
  : defaultOdooVersion;
138
- const { sourceRepoPaths, invalidSourceRepoPaths } = sourceRepoPathsFromMetadata(metadata);
139
- const repoRoots = sourceRepoPaths.map((path) => join(target, 'odoo/custom/src/private', path));
168
+ const { sourceRepoPaths, sourceRepoLocations, invalidSourceRepoPaths } = sourceRepoPathsFromMetadata(metadata);
169
+ const repoRoots = sourceRepoLocations.map(({ sourceType, path }) => sourceRepoPath(target, sourceType, path));
140
170
  let moduleCandidateCount = 0;
141
171
  for (const repoRoot of repoRoots) {
142
172
  moduleCandidateCount += await countModuleCandidatesInRepoPath(repoRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/odoo",
3
- "version": "0.8.63",
3
+ "version": "0.8.65",
4
4
  "description": "WPMoo Odoo lifecycle tooling for development, staging, and production workflows.",
5
5
  "type": "module",
6
6
  "repository": {