@wpmoo/toolkit 0.9.12 → 0.9.13

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/scaffold.js CHANGED
@@ -4,7 +4,7 @@ import { applyExternalAsset, renderExternalAssetCommand, writeTextFile } from '.
4
4
  import { markerPath, renderEnvironmentMetadata } from './environment.js';
5
5
  import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
6
6
  import { cloneRepository, ensureSubmodule, ensureRemoteHasBranch, realGit, stageAll, syncSubmodules, } from './git.js';
7
- import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, } from './templates.js';
7
+ import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, renderStatusScript, } from './templates.js';
8
8
  import { validateAddonName, validateRepoPath } from './path-validation.js';
9
9
  import { renderSourceManifest, sourceManifestEntriesFromMetadata } from './source-manifest.js';
10
10
  function validateSourceRepo(repo) {
@@ -57,6 +57,7 @@ export function generatedFiles(options) {
57
57
  const files = [
58
58
  { path: markerPath, content: renderEnvironmentMetadata(safeOptions) },
59
59
  { path: 'moo', content: renderMooDelegationScript(), mode: 0o755 },
60
+ { path: 'scripts/status.sh', content: renderStatusScript(), mode: 0o755 },
60
61
  { path: '.gitignore', content: renderGitignore() },
61
62
  { path: 'README.md', content: renderReadme(safeOptions) },
62
63
  { path: 'AGENTS.md', content: renderAgents(safeOptions) },
package/dist/templates.js CHANGED
@@ -225,8 +225,9 @@ exposes them through \`/mnt/wpmoo-addons\`.
225
225
 
226
226
  \`./moo\` routes day-to-day service and module workflows to local scripts in
227
227
  \`./scripts/\` (for example \`start\`, \`logs\`, \`update\`, \`test\`, \`snapshot\`).
228
- \`./moo status\` and \`./moo doctor\` are package fallback commands that run via
229
- \`npx --yes ${fallbackPackageSpec()} ...\`.
228
+ \`./moo status\` runs local offline metadata checks without needing network access.
229
+ \`./moo doctor\` remains the package fallback command and runs via
230
+ \`npx --yes ${fallbackPackageSpec()} doctor\`.
230
231
 
231
232
  ### Start And Inspect Services
232
233
 
@@ -472,7 +473,10 @@ Daily commands:
472
473
  install, update, test, resetdb, snapshot, restore-snapshot, lint, pot
473
474
 
474
475
  Management commands:
475
- status, source, add-repo, remove-repo, add-module, remove-module, reset, doctor
476
+ source, add-repo, remove-repo, add-module, remove-module, reset, doctor
477
+
478
+ Local diagnostics:
479
+ status [--json]
476
480
 
477
481
  Run ./moo <command> with invalid arguments to see command-specific usage.
478
482
  HELP
@@ -629,7 +633,14 @@ case "$command" in
629
633
  "--version"|"-v"|"version")
630
634
  run_package_command "$@"
631
635
  ;;
632
- "create"|"status"|"add-repo"|"remove-repo"|"add-module"|"remove-module"|"source"|"reset"|"doctor")
636
+ "status")
637
+ shift
638
+ if [[ -x ./scripts/status.sh ]]; then
639
+ run_script ./scripts/status.sh "$@"
640
+ fi
641
+ run_package_command "$command" "$@"
642
+ ;;
643
+ "create"|"add-repo"|"remove-repo"|"add-module"|"remove-module"|"source"|"reset"|"doctor")
633
644
  run_package_command "$@"
634
645
  ;;
635
646
  "start")
@@ -723,6 +734,347 @@ case "$command" in
723
734
  esac
724
735
  `;
725
736
  }
737
+ export function renderStatusScript() {
738
+ return `#!/usr/bin/env bash
739
+ set -euo pipefail
740
+
741
+ script_dir="$(cd -- "$(dirname -- "\${BASH_SOURCE[0]}")" && pwd)"
742
+ root_dir="$(cd -- "$script_dir/.." && pwd)"
743
+ cd "$root_dir"
744
+
745
+ node --input-type=module - "$@" <<'NODE'
746
+ import { access, readdir, readFile, stat } from 'node:fs/promises';
747
+ import { isAbsolute, join } from 'node:path';
748
+
749
+ const args = process.argv.slice(2);
750
+ if (!args.every((arg) => arg === '--json')) {
751
+ console.error('Usage: ./moo status [--json]');
752
+ process.exit(2);
753
+ }
754
+
755
+ const json = args.includes('--json');
756
+ const target = process.cwd();
757
+ const metadataPath = '.wpmoo/odoo.json';
758
+ const validSourceTypes = new Set(['private', 'oca', 'external']);
759
+
760
+ async function exists(path) {
761
+ try {
762
+ await access(path);
763
+ return true;
764
+ } catch {
765
+ return false;
766
+ }
767
+ }
768
+
769
+ async function isDirectory(path) {
770
+ try {
771
+ return (await stat(path)).isDirectory();
772
+ } catch {
773
+ return false;
774
+ }
775
+ }
776
+
777
+ function isRecord(value) {
778
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
779
+ }
780
+
781
+ function isValidPathSegment(value) {
782
+ const normalized = typeof value === 'string' ? value.trim() : '';
783
+ return Boolean(
784
+ normalized &&
785
+ normalized !== '.' &&
786
+ normalized !== '..' &&
787
+ !normalized.includes('/') &&
788
+ !normalized.includes('\\\\') &&
789
+ !normalized.includes('\\0') &&
790
+ !normalized.includes(':') &&
791
+ !isAbsolute(normalized) &&
792
+ !/^[a-zA-Z]:/.test(normalized),
793
+ );
794
+ }
795
+
796
+ function normalizeSourceType(sourceType) {
797
+ return typeof sourceType === 'string' && validSourceTypes.has(sourceType) ? sourceType : 'private';
798
+ }
799
+
800
+ async function countModuleCandidates(root) {
801
+ if (!(await isDirectory(root))) return 0;
802
+ const stack = [root];
803
+ let count = 0;
804
+
805
+ while (stack.length > 0) {
806
+ const current = stack.pop();
807
+ const entries = await readdir(current, { withFileTypes: true });
808
+ let hasManifest = false;
809
+
810
+ for (const entry of entries) {
811
+ if (entry.isFile() && entry.name === '__manifest__.py') {
812
+ hasManifest = true;
813
+ } else if (entry.isDirectory()) {
814
+ stack.push(join(current, entry.name));
815
+ }
816
+ }
817
+
818
+ if (hasManifest) count += 1;
819
+ }
820
+
821
+ return count;
822
+ }
823
+
824
+ function parseEnvContent(content) {
825
+ const values = new Map();
826
+ for (const rawLine of content.split(/\\r?\\n/)) {
827
+ const line = rawLine.trim();
828
+ if (!line || line.startsWith('#')) continue;
829
+ const separator = line.indexOf('=');
830
+ if (separator === -1) continue;
831
+ const key = line.slice(0, separator).trim();
832
+ let value = line.slice(separator + 1).trim();
833
+ if (
834
+ (value.startsWith('"') && value.endsWith('"')) ||
835
+ (value.startsWith("'") && value.endsWith("'"))
836
+ ) {
837
+ value = value.slice(1, -1);
838
+ }
839
+ values.set(key, value);
840
+ }
841
+ return values;
842
+ }
843
+
844
+ async function readEnvFile() {
845
+ if (!(await exists(join(target, '.env')))) return undefined;
846
+ return parseEnvContent(await readFile(join(target, '.env'), 'utf8'));
847
+ }
848
+
849
+ function selectedComposeEnvironment(env) {
850
+ const envName = env?.get('WPMOO_ENV')?.trim();
851
+ return envName || 'dev';
852
+ }
853
+
854
+ function isValidComposeEnvironmentName(value) {
855
+ return /^[A-Za-z0-9_-]+$/.test(value);
856
+ }
857
+
858
+ function isValidOdooVersion(value) {
859
+ return /^\\d+\\.\\d+$/.test(value);
860
+ }
861
+
862
+ function compactOverlayError(envName, overlayFile) {
863
+ if (envName === 'dev') return 'Missing compact compose overlay: ' + overlayFile;
864
+ return 'Missing compact compose overlay for WPMOO_ENV=' + envName + ': ' + overlayFile;
865
+ }
866
+
867
+ async function detectComposeLayout(odooVersion) {
868
+ const envName = selectedComposeEnvironment(await readEnvFile());
869
+ if (!isValidComposeEnvironmentName(envName)) {
870
+ return {
871
+ files: [],
872
+ missingFiles: [],
873
+ errors: ['Invalid WPMOO_ENV in .env: expected a simple compose overlay name, got ' + envName],
874
+ };
875
+ }
876
+
877
+ const compactBase = 'compose.yaml';
878
+ const compactOverlay = 'compose/' + envName + '.yaml';
879
+ const hasCompactBase = await exists(join(target, compactBase));
880
+ const hasCompactOverlay = await exists(join(target, compactOverlay));
881
+
882
+ if (hasCompactBase && hasCompactOverlay) {
883
+ return { files: [compactBase, compactOverlay], missingFiles: [], errors: [] };
884
+ }
885
+
886
+ if (hasCompactBase || hasCompactOverlay) {
887
+ const errors = [];
888
+ const missingFiles = [];
889
+ if (!hasCompactBase) {
890
+ missingFiles.push(compactBase);
891
+ errors.push('Missing compact compose base: ' + compactBase);
892
+ }
893
+ if (!hasCompactOverlay) {
894
+ missingFiles.push(compactOverlay);
895
+ errors.push(compactOverlayError(envName, compactOverlay));
896
+ }
897
+ return { files: [], missingFiles, errors };
898
+ }
899
+
900
+ if (!isValidOdooVersion(odooVersion)) {
901
+ return {
902
+ files: [],
903
+ missingFiles: [],
904
+ errors: ['Invalid Odoo version for compose file: ' + odooVersion],
905
+ };
906
+ }
907
+
908
+ const legacyFile = 'docker-compose_' + odooVersion + '.yml';
909
+ if (await exists(join(target, legacyFile))) {
910
+ return { files: [legacyFile], missingFiles: [], errors: [] };
911
+ }
912
+
913
+ return {
914
+ files: [],
915
+ missingFiles: [legacyFile],
916
+ errors: ['Missing compose file: ' + legacyFile],
917
+ };
918
+ }
919
+
920
+ async function coreFileIssues(odooVersion) {
921
+ const missing = [];
922
+ for (const check of [
923
+ { label: 'moo', path: 'moo' },
924
+ { label: 'README.md', path: 'README.md' },
925
+ { label: 'AGENTS.md', path: 'AGENTS.md' },
926
+ ]) {
927
+ if (!(await exists(join(target, check.path)))) missing.push(check.label);
928
+ }
929
+ if (!(await isDirectory(join(target, 'scripts')))) missing.push('scripts/');
930
+
931
+ const composeLayout = await detectComposeLayout(odooVersion);
932
+ missing.push(...composeLayout.missingFiles);
933
+ return { missing, composeFiles: composeLayout.files, composeErrors: composeLayout.errors };
934
+ }
935
+
936
+ function summaryText(status) {
937
+ if (status.kind === 'no_environment') return 'No WPMoo environment detected.';
938
+ if (status.kind === 'invalid_metadata') return 'Environment metadata is invalid.';
939
+ const needsAttention =
940
+ status.missingCoreFiles.length > 0 ||
941
+ status.invalidSourceRepoPaths.length > 0 ||
942
+ status.composeErrors.length > 0;
943
+ const prefix = needsAttention ? 'Environment needs attention' : 'Environment ready';
944
+ return (
945
+ prefix +
946
+ ': Odoo ' +
947
+ status.odooVersion +
948
+ ', source repos ' +
949
+ status.sourceRepoCount +
950
+ ', module candidates ' +
951
+ status.moduleCandidateCount +
952
+ '.'
953
+ );
954
+ }
955
+
956
+ function isHealthy(status) {
957
+ return (
958
+ status.kind === 'environment' &&
959
+ status.missingCoreFiles.length === 0 &&
960
+ status.invalidSourceRepoPaths.length === 0 &&
961
+ status.composeErrors.length === 0
962
+ );
963
+ }
964
+
965
+ function renderStatus(status) {
966
+ const lines = ['Status: ' + summaryText(status)];
967
+ if (status.kind === 'no_environment') {
968
+ lines.push('Metadata: missing ' + status.metadataPath);
969
+ lines.push('Next: ' + status.recommendedNextAction);
970
+ return lines.join('\\n');
971
+ }
972
+ if (status.kind === 'invalid_metadata') {
973
+ lines.push('Metadata: invalid ' + status.metadataPath);
974
+ lines.push('Error: ' + status.metadataError);
975
+ lines.push('Next: ' + status.recommendedNextAction);
976
+ return lines.join('\\n');
977
+ }
978
+ lines.push('Metadata: ' + status.metadataPath);
979
+ lines.push('Odoo: ' + status.odooVersion);
980
+ lines.push('Compose files: ' + (status.composeFiles.length > 0 ? status.composeFiles.join(', ') : '(missing)'));
981
+ if (status.composeErrors.length > 0) lines.push('Compose errors: ' + status.composeErrors.join(', '));
982
+ lines.push('Source repos: ' + status.sourceRepoCount);
983
+ lines.push('Source repo paths: ' + (status.sourceRepoPaths.length > 0 ? status.sourceRepoPaths.join(', ') : '(none configured)'));
984
+ if (status.invalidSourceRepoPaths.length > 0) {
985
+ lines.push('Invalid source repo paths: ' + status.invalidSourceRepoPaths.join(', '));
986
+ }
987
+ lines.push('Module candidates: ' + status.moduleCandidateCount);
988
+ lines.push('Missing core files: ' + (status.missingCoreFiles.length > 0 ? status.missingCoreFiles.join(', ') : '(none)'));
989
+ lines.push('Next: ' + status.recommendedNextAction);
990
+ return lines.join('\\n');
991
+ }
992
+
993
+ async function getStatus() {
994
+ if (!(await exists(join(target, metadataPath)))) {
995
+ return {
996
+ kind: 'no_environment',
997
+ target,
998
+ metadataPath,
999
+ recommendedNextAction: 'Run npx @wpmoo/toolkit create ...',
1000
+ };
1001
+ }
1002
+
1003
+ let metadata;
1004
+ try {
1005
+ const parsed = JSON.parse(await readFile(join(target, metadataPath), 'utf8'));
1006
+ if (!isRecord(parsed)) throw new Error('metadata is not an object');
1007
+ metadata = parsed;
1008
+ } catch (error) {
1009
+ return {
1010
+ kind: 'invalid_metadata',
1011
+ target,
1012
+ metadataPath,
1013
+ metadataError: error instanceof Error ? error.message : String(error),
1014
+ recommendedNextAction: 'Fix .wpmoo/odoo.json or run ./moo reset from a valid environment.',
1015
+ };
1016
+ }
1017
+
1018
+ const odooVersion =
1019
+ typeof metadata.odooVersion === 'string' && metadata.odooVersion.trim() ? metadata.odooVersion.trim() : '19.0';
1020
+ const sourceRepoPaths = [];
1021
+ const sourceRepoLocations = [];
1022
+ const invalidSourceRepoPaths = [];
1023
+
1024
+ for (const repo of Array.isArray(metadata.sourceRepos) ? metadata.sourceRepos : []) {
1025
+ const path = isRecord(repo) && typeof repo.path === 'string' ? repo.path.trim() : '';
1026
+ if (!path) continue;
1027
+ if (!isValidPathSegment(path)) {
1028
+ invalidSourceRepoPaths.push(path);
1029
+ continue;
1030
+ }
1031
+ const sourceType = normalizeSourceType(isRecord(repo) ? repo.sourceType : undefined);
1032
+ sourceRepoPaths.push(path);
1033
+ sourceRepoLocations.push({ sourceType, path });
1034
+ }
1035
+
1036
+ let moduleCandidateCount = 0;
1037
+ for (const repo of sourceRepoLocations) {
1038
+ moduleCandidateCount += await countModuleCandidates(join(target, 'odoo/custom/src', repo.sourceType, repo.path));
1039
+ }
1040
+
1041
+ const { missing, composeFiles, composeErrors } = await coreFileIssues(odooVersion);
1042
+ let recommendedNextAction = 'Run ./moo doctor for deep checks or ./moo start.';
1043
+ if (invalidSourceRepoPaths.length > 0) {
1044
+ recommendedNextAction = 'Fix invalid source repo paths in .wpmoo/odoo.json, then run ./moo doctor.';
1045
+ } else if (missing.length > 0) {
1046
+ recommendedNextAction = 'Run ./moo reset, then ./moo doctor.';
1047
+ } else if (composeErrors.length > 0) {
1048
+ recommendedNextAction = 'Fix compose layout errors, then run ./moo doctor.';
1049
+ } else if (sourceRepoPaths.length === 0) {
1050
+ recommendedNextAction = 'Run ./moo add-repo ...';
1051
+ }
1052
+
1053
+ return {
1054
+ kind: 'environment',
1055
+ target,
1056
+ metadataPath,
1057
+ odooVersion,
1058
+ sourceRepoCount: sourceRepoPaths.length,
1059
+ sourceRepoPaths,
1060
+ invalidSourceRepoPaths,
1061
+ moduleCandidateCount,
1062
+ composeFiles,
1063
+ composeErrors,
1064
+ missingCoreFiles: missing,
1065
+ recommendedNextAction,
1066
+ };
1067
+ }
1068
+
1069
+ const status = await getStatus();
1070
+ if (json) {
1071
+ console.log(JSON.stringify({ schemaVersion: 1, command: 'status', ok: isHealthy(status), status }, null, 2));
1072
+ } else {
1073
+ console.log(renderStatus(status));
1074
+ }
1075
+ NODE
1076
+ `;
1077
+ }
726
1078
  export function renderAddonsYaml(options) {
727
1079
  return `# Addons activated from source submodules.
728
1080
  #
@@ -876,7 +1228,8 @@ Useful maintenance commands:
876
1228
 
877
1229
  Daily script delegation vs package fallback:
878
1230
  - \`./moo start\`, \`logs\`, \`install\`, \`update\`, \`test\`, \`snapshot\`, and related runtime tasks delegate to local \`./scripts/*.sh\`.
879
- - \`./moo status\` and \`./moo doctor\` are package fallback commands routed to \`npx --yes ${fallbackPackageSpec()} ...\`.
1231
+ - \`./moo status\` runs local offline metadata checks through \`./scripts/status.sh\`.
1232
+ - \`./moo doctor\` remains a package fallback command routed to \`npx --yes ${fallbackPackageSpec()} doctor\`.
880
1233
 
881
1234
  Only report completion after the relevant update/test/lint command exits cleanly.
882
1235
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {