@wpmoo/toolkit 0.9.11 → 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
 
@@ -463,6 +464,24 @@ usage() {
463
464
  esac
464
465
  }
465
466
 
467
+ show_help() {
468
+ cat <<'HELP'
469
+ Usage: ./moo <command> [args]
470
+
471
+ Daily commands:
472
+ start, stop, logs, restart, shell, psql
473
+ install, update, test, resetdb, snapshot, restore-snapshot, lint, pot
474
+
475
+ Management commands:
476
+ source, add-repo, remove-repo, add-module, remove-module, reset, doctor
477
+
478
+ Local diagnostics:
479
+ status [--json]
480
+
481
+ Run ./moo <command> with invalid arguments to see command-specific usage.
482
+ HELP
483
+ }
484
+
466
485
  fail_usage() {
467
486
  usage "$1" >&2
468
487
  exit 2
@@ -599,8 +618,31 @@ run_script() {
599
618
  exec "$script" "$@"
600
619
  }
601
620
 
621
+ run_package_command() {
622
+ exec npx --yes ${fallbackPackageSpec()} "$@"
623
+ }
624
+
602
625
  command="\${1:-}"
603
626
  case "$command" in
627
+ "")
628
+ run_package_command "$@"
629
+ ;;
630
+ "--help"|"-h"|"help")
631
+ show_help
632
+ ;;
633
+ "--version"|"-v"|"version")
634
+ run_package_command "$@"
635
+ ;;
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")
644
+ run_package_command "$@"
645
+ ;;
604
646
  "start")
605
647
  shift
606
648
  require_no_args "$command" "$@"
@@ -685,11 +727,354 @@ case "$command" in
685
727
  run_script ./scripts/pot.sh "$@"
686
728
  ;;
687
729
  *)
688
- exec npx --yes ${fallbackPackageSpec()} "$@"
730
+ echo "Unknown ./moo command: $command" >&2
731
+ echo "Run ./moo --help to see supported commands." >&2
732
+ exit 2
689
733
  ;;
690
734
  esac
691
735
  `;
692
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
+ }
693
1078
  export function renderAddonsYaml(options) {
694
1079
  return `# Addons activated from source submodules.
695
1080
  #
@@ -843,7 +1228,8 @@ Useful maintenance commands:
843
1228
 
844
1229
  Daily script delegation vs package fallback:
845
1230
  - \`./moo start\`, \`logs\`, \`install\`, \`update\`, \`test\`, \`snapshot\`, and related runtime tasks delegate to local \`./scripts/*.sh\`.
846
- - \`./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\`.
847
1233
 
848
1234
  Only report completion after the relevant update/test/lint command exits cleanly.
849
1235
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {