@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.
@@ -1,6 +1,6 @@
1
1
  import { defaultOdooVersion, readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
2
2
  import { realGit, stageAll } from './git.js';
3
- import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
3
+ import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceManifestPath, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
4
4
  function cloneSourceEntries(entries) {
5
5
  return entries.map((entry) => ({
6
6
  ...entry,
@@ -47,6 +47,95 @@ export function sourceSyncJson(entries, target) {
47
47
  sources: cloneSourceEntries(entries),
48
48
  };
49
49
  }
50
+ function sourceKey(entry) {
51
+ return `${entry.type}/${entry.path}`;
52
+ }
53
+ function sourceMap(entries) {
54
+ return new Map(entries.map((entry) => [sourceKey(entry), entry]));
55
+ }
56
+ function addonsEqual(left, right) {
57
+ return left.length === right.length && left.every((value, index) => value === right[index]);
58
+ }
59
+ function sourceDriftChanges(file, currentEntries, nextEntries) {
60
+ const changes = [];
61
+ const current = sourceMap(currentEntries);
62
+ const next = sourceMap(nextEntries);
63
+ for (const [key, nextEntry] of next) {
64
+ const currentEntry = current.get(key);
65
+ if (!currentEntry) {
66
+ changes.push({ file, kind: 'add', source: key, after: cloneSourceEntries([nextEntry])[0] });
67
+ continue;
68
+ }
69
+ for (const field of ['url', 'branch']) {
70
+ const before = currentEntry[field] ?? '';
71
+ const after = nextEntry[field] ?? '';
72
+ if (before !== after) {
73
+ changes.push({ file, kind: 'update', source: key, field, before, after });
74
+ }
75
+ }
76
+ if (!addonsEqual(currentEntry.addons, nextEntry.addons)) {
77
+ changes.push({
78
+ file,
79
+ kind: 'update',
80
+ source: key,
81
+ field: 'addons',
82
+ before: [...currentEntry.addons],
83
+ after: [...nextEntry.addons],
84
+ });
85
+ }
86
+ }
87
+ for (const [key, currentEntry] of current) {
88
+ if (!next.has(key)) {
89
+ changes.push({ file, kind: 'remove', source: key, before: cloneSourceEntries([currentEntry])[0] });
90
+ }
91
+ }
92
+ return changes;
93
+ }
94
+ export async function sourceSyncPlan(target) {
95
+ const metadata = await readEnvironmentMetadata(target);
96
+ const manifest = await readSourceManifest(target);
97
+ const gitmodules = await listGitmoduleSources(target);
98
+ const fallbackBranch = metadata?.odooVersion ?? defaultOdooVersion;
99
+ const baseRepos = metadata?.sourceRepos.length ? metadata.sourceRepos : sourceReposFromManifest(manifest.sources);
100
+ const sources = syncManifestFromMetadataAndGitmodules(baseRepos, fallbackBranch, gitmodules);
101
+ return {
102
+ target,
103
+ sources,
104
+ changes: sourceDriftChanges(sourceManifestPath, manifest.sources, sources),
105
+ };
106
+ }
107
+ function renderValue(value) {
108
+ if (Array.isArray(value))
109
+ return `[${value.join(', ')}]`;
110
+ if (typeof value === 'object' && value)
111
+ return JSON.stringify(value);
112
+ return `"${value ?? ''}"`;
113
+ }
114
+ export function renderSourceSyncPlan(plan) {
115
+ if (plan.changes.length === 0) {
116
+ return 'Source manifest already in sync.';
117
+ }
118
+ return plan.changes
119
+ .map((change) => {
120
+ if (change.kind === 'add')
121
+ return `source manifest add ${change.source}`;
122
+ if (change.kind === 'remove')
123
+ return `source manifest remove ${change.source}`;
124
+ return `source manifest update ${change.source} ${change.field}: ${renderValue(change.before)} -> ${renderValue(change.after)}`;
125
+ })
126
+ .join('\n');
127
+ }
128
+ export function sourceSyncPlanJson(plan) {
129
+ return {
130
+ schemaVersion: 1,
131
+ command: 'source sync preview',
132
+ ok: true,
133
+ target: plan.target,
134
+ dryRun: true,
135
+ sources: cloneSourceEntries(plan.sources),
136
+ changes: plan.changes.map((change) => ({ ...change })),
137
+ };
138
+ }
50
139
  export async function syncSources(options, git = realGit) {
51
140
  const metadata = await readEnvironmentMetadata(options.target);
52
141
  const manifest = await readSourceManifest(options.target);
@@ -273,13 +273,13 @@ export async function listGitmoduleSources(target) {
273
273
  const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
274
274
  const lines = gitmodules.split(/\r?\n/);
275
275
  const locations = [];
276
- const pathRegex = /^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(.+)\s*$/;
276
+ const pathRegex = /^\s*path\s*=\s*odoo\/custom\/src\/(?:(private|oca|external)\/)?([^/\s]+)\s*$/;
277
277
  const urlRegex = /^\s*url\s*=\s*(.+)\s*$/;
278
278
  let pending;
279
279
  for (const line of lines) {
280
280
  const parsedPath = line.match(pathRegex);
281
281
  if (parsedPath) {
282
- const sourceType = parsedPath[1];
282
+ const sourceType = (parsedPath[1] ?? 'private');
283
283
  const repoPath = parsedPath[2]?.trim() ?? '';
284
284
  if (!repoPath || !isValidPathSegment(repoPath)) {
285
285
  pending = undefined;
package/dist/templates.js CHANGED
@@ -208,10 +208,14 @@ resources/odoo/entrypoint.sh
208
208
 
209
209
  Development uses compose.yaml plus compose/dev.yaml by default.
210
210
  Set WPMOO_ENV=stage or WPMOO_ENV=prod only after providing production-grade secrets and volumes.
211
- In WPMOO_ENV=prod, module lifecycle commands such as install, update, and test
212
- require WPMOO_ALLOW_PROD_LIFECYCLE=1. Destructive database commands such as
211
+ In WPMOO_ENV=stage, lifecycle commands such as install, update, stop, and
212
+ restart require WPMOO_ALLOW_STAGE_LIFECYCLE=1. In WPMOO_ENV=prod, lifecycle
213
+ commands such as install, update, test, stop, and restart require
214
+ WPMOO_ALLOW_PROD_LIFECYCLE=1. Destructive database commands such as
213
215
  resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage
214
216
  and prod. restore-snapshot --dry-run remains available for preview.
217
+ For short-lived local approvals, add JSONL entries to \`.wpmoo/approvals.jsonl\`;
218
+ generated \`.gitignore\` keeps that ledger out of Git.
215
219
 
216
220
  If copied from the standalone resource, additional compose notes are in
217
221
  \`docs/compose.md\`.
@@ -415,6 +419,7 @@ __pycache__/
415
419
  .env.*
416
420
  !.env.example
417
421
  *.local
422
+ .wpmoo/approvals.jsonl
418
423
 
419
424
  # Local generated files
420
425
  *.code-workspace
@@ -457,7 +462,7 @@ usage() {
457
462
  "update") echo "Usage: ./moo update <module[,module]> [db]" ;;
458
463
  "test") echo "Usage: ./moo test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]" ;;
459
464
  "resetdb") echo "Usage: ./moo resetdb [db] [module[,module]]" ;;
460
- "snapshot") echo "Usage: ./moo snapshot [db] [snapshot-name]" ;;
465
+ "snapshot") echo "Usage: ./moo snapshot [--list] [db] [snapshot-name]" ;;
461
466
  "restore-snapshot") echo "Usage: ./moo restore-snapshot [--dry-run] <snapshot-name> [db]" ;;
462
467
  "lint") echo "Usage: ./moo lint" ;;
463
468
  "pot") echo "Usage: ./moo pot <module[,module]> [db] [output]" ;;
@@ -574,9 +579,51 @@ selected_env() {
574
579
  printf '%s\\n' "\${value:-dev}"
575
580
  }
576
581
 
582
+ approval_active() {
583
+ local scope="$1"
584
+ local command="$2"
585
+ local env_name="$3"
586
+ [[ -f .wpmoo/approvals.jsonl ]] || return 1
587
+
588
+ node --input-type=module - "$scope" "$command" "$env_name" <<'NODE'
589
+ import { readFileSync } from 'node:fs';
590
+
591
+ const [scope, command, envName] = process.argv.slice(2);
592
+
593
+ try {
594
+ const content = readFileSync('.wpmoo/approvals.jsonl', 'utf8');
595
+ for (const rawLine of content.split(/\\r?\\n/)) {
596
+ const line = rawLine.trim();
597
+ if (!line) continue;
598
+
599
+ let entry;
600
+ try {
601
+ entry = JSON.parse(line);
602
+ } catch {
603
+ continue;
604
+ }
605
+
606
+ if (!entry || typeof entry !== 'object') continue;
607
+ if (entry.scope !== scope || entry.environment !== envName) continue;
608
+ if (typeof entry.command === 'string' && entry.command !== command) continue;
609
+
610
+ const expiresAt = Date.parse(entry.expiresAt);
611
+ if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
612
+ process.exit(0);
613
+ }
614
+ }
615
+ } catch {
616
+ }
617
+
618
+ process.exit(1);
619
+ NODE
620
+ }
621
+
577
622
  allow_destructive() {
623
+ local command="$1"
624
+ local env_name="\${2:-$(selected_env)}"
578
625
  local value="\${WPMOO_ALLOW_DESTRUCTIVE:-$(env_file_value WPMOO_ALLOW_DESTRUCTIVE)}"
579
- [[ "$value" == "1" ]]
626
+ [[ "$value" == "1" ]] || approval_active "destructive" "$command" "$env_name"
580
627
  }
581
628
 
582
629
  require_destructive_allowed() {
@@ -584,7 +631,7 @@ require_destructive_allowed() {
584
631
  local env_name
585
632
  env_name="$(selected_env)"
586
633
  if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
587
- if ! allow_destructive; then
634
+ if ! allow_destructive "$command" "$env_name"; then
588
635
  echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally." >&2
589
636
  exit 1
590
637
  fi
@@ -592,28 +639,36 @@ require_destructive_allowed() {
592
639
  }
593
640
 
594
641
  allow_prod_lifecycle() {
642
+ local command="$1"
643
+ local env_name="\${2:-$(selected_env)}"
595
644
  local value="\${WPMOO_ALLOW_PROD_LIFECYCLE:-$(env_file_value WPMOO_ALLOW_PROD_LIFECYCLE)}"
596
- [[ "$value" == "1" ]]
645
+ [[ "$value" == "1" ]] || approval_active "prod-lifecycle" "$command" "$env_name"
597
646
  }
598
647
 
599
648
  allow_stage_lifecycle() {
649
+ local command="$1"
650
+ local env_name="\${2:-$(selected_env)}"
600
651
  local value="\${WPMOO_ALLOW_STAGE_LIFECYCLE:-$(env_file_value WPMOO_ALLOW_STAGE_LIFECYCLE)}"
601
- [[ "$value" == "1" ]]
652
+ [[ "$value" == "1" ]] || approval_active "stage-lifecycle" "$command" "$env_name"
602
653
  }
603
654
 
604
655
  allow_no_recent_snapshot() {
656
+ local command="$1"
657
+ local env_name="\${2:-$(selected_env)}"
605
658
  local value="\${WPMOO_ALLOW_NO_RECENT_SNAPSHOT:-$(env_file_value WPMOO_ALLOW_NO_RECENT_SNAPSHOT)}"
606
- [[ "$value" == "1" ]]
659
+ [[ "$value" == "1" ]] || approval_active "no-recent-snapshot" "$command" "$env_name"
607
660
  }
608
661
 
609
662
  allow_migrations() {
663
+ local command="$1"
664
+ local env_name="\${2:-$(selected_env)}"
610
665
  local value="\${WPMOO_ALLOW_MIGRATIONS:-$(env_file_value WPMOO_ALLOW_MIGRATIONS)}"
611
- [[ "$value" == "1" ]]
666
+ [[ "$value" == "1" ]] || approval_active "migration-risk" "$command" "$env_name"
612
667
  }
613
668
 
614
669
  has_recent_snapshot() {
615
670
  local dir
616
- for dir in backups backup snapshots; do
671
+ for dir in backups/snapshots backups backup snapshots; do
617
672
  [[ -d "$dir" ]] || continue
618
673
  if find "$dir" -type f \\( -name "*.dump" -o -name "*.sql" -o -name "*.sql.gz" -o -name "*.zip" -o -name "*.tar" -o -name "*.tar.gz" \\) -mtime -1 -print -quit 2>/dev/null | grep -q .; then
619
674
  return 0
@@ -622,12 +677,140 @@ has_recent_snapshot() {
622
677
  return 1
623
678
  }
624
679
 
680
+ snapshot_stem() {
681
+ local file="$1"
682
+ file="\${file##*/}"
683
+ file="\${file%.filestore.tar.gz}"
684
+ file="\${file%.sql.gz}"
685
+ file="\${file%.tar.gz}"
686
+ file="\${file%.dump}"
687
+ file="\${file%.sql}"
688
+ file="\${file%.zip}"
689
+ file="\${file%.tar}"
690
+ printf '%s' "$file"
691
+ }
692
+
693
+ json_string_value() {
694
+ local key="$1"
695
+ local file="$2"
696
+ [[ -f "$file" ]] || return 0
697
+ sed -n "s/.*\\"$key\\"[[:space:]]*:[[:space:]]*\\"\\([^\\"]*\\)\\".*/\\1/p" "$file" | head -n 1
698
+ }
699
+
700
+ list_snapshots() {
701
+ local found=0 dir dump stem manifest database created filestore
702
+ for dir in backups/snapshots backups backup snapshots; do
703
+ [[ -d "$dir" ]] || continue
704
+ while IFS= read -r dump; do
705
+ [[ -n "$dump" ]] || continue
706
+ found=1
707
+ stem="$(snapshot_stem "$dump")"
708
+ manifest="$dir/$stem.json"
709
+ database="$(json_string_value database "$manifest")"
710
+ created="$(json_string_value created_at "$manifest")"
711
+ filestore="$dir/$stem.filestore.tar.gz"
712
+ echo "- $stem"
713
+ [[ -n "$created" ]] && echo " Created: $created"
714
+ echo " Database: \${database:-unknown}"
715
+ echo " Dump: $dump"
716
+ if [[ -f "$filestore" ]]; then
717
+ echo " Filestore: $filestore (found)"
718
+ else
719
+ echo " Filestore: missing (missing)"
720
+ fi
721
+ done < <(find "$dir" -maxdepth 1 -type f \\( -name "*.dump" -o -name "*.sql" -o -name "*.sql.gz" \\) | sort)
722
+ done
723
+
724
+ if [[ "$found" -eq 0 ]]; then
725
+ echo "No database snapshots found."
726
+ echo "Next: run ./moo snapshot [db] [snapshot-name]."
727
+ fi
728
+ }
729
+
730
+ list_snapshots_json() {
731
+ node --input-type=module <<'NODE'
732
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
733
+ import { join } from 'node:path';
734
+
735
+ const directories = ['backups/snapshots', 'backups', 'backup', 'snapshots'];
736
+ const extensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
737
+ const now = Date.now();
738
+
739
+ function snapshotStem(file) {
740
+ return file
741
+ .replace(/\\.filestore\\.tar\\.gz$/, '')
742
+ .replace(/\\.sql\\.gz$/, '')
743
+ .replace(/\\.tar\\.gz$/, '')
744
+ .replace(/\\.dump$/, '')
745
+ .replace(/\\.sql$/, '')
746
+ .replace(/\\.zip$/, '')
747
+ .replace(/\\.tar$/, '');
748
+ }
749
+
750
+ function hasSnapshotExtension(file) {
751
+ return extensions.some((extension) => file.endsWith(extension));
752
+ }
753
+
754
+ function readManifest(path) {
755
+ if (!existsSync(path)) return undefined;
756
+ try {
757
+ return JSON.parse(readFileSync(path, 'utf8'));
758
+ } catch {
759
+ return undefined;
760
+ }
761
+ }
762
+
763
+ function manifestString(manifest, key) {
764
+ return manifest && typeof manifest[key] === 'string' ? manifest[key] : undefined;
765
+ }
766
+
767
+ const snapshots = [];
768
+ for (const directory of directories) {
769
+ if (!existsSync(directory)) continue;
770
+
771
+ for (const file of readdirSync(directory).sort()) {
772
+ if (file.endsWith('.filestore.tar.gz') || !hasSnapshotExtension(file)) continue;
773
+
774
+ const dumpPath = join(directory, file);
775
+ const stats = statSync(dumpPath);
776
+ if (!stats.isFile()) continue;
777
+
778
+ const name = snapshotStem(file);
779
+ const manifestPath = join(directory, name + '.json');
780
+ const manifest = readManifest(manifestPath);
781
+ const manifestDump = manifestString(manifest, 'dump');
782
+ const manifestFilestore = manifestString(manifest, 'filestore');
783
+ const filestorePath = join(directory, manifestFilestore || name + '.filestore.tar.gz');
784
+ const createdAtMs = Date.parse(manifestString(manifest, 'created_at') || '');
785
+ const effectiveCreatedAtMs = Number.isFinite(createdAtMs) ? createdAtMs : stats.mtimeMs;
786
+
787
+ snapshots.push({
788
+ name: manifestString(manifest, 'name') || name,
789
+ path: dumpPath,
790
+ dumpPath: manifestDump ? join(directory, manifestDump) : dumpPath,
791
+ ...(existsSync(manifestPath) ? { manifestPath } : {}),
792
+ ...(manifestString(manifest, 'database') ? { databaseName: manifestString(manifest, 'database') } : {}),
793
+ createdAtMs: effectiveCreatedAtMs,
794
+ createdAt: new Date(effectiveCreatedAtMs).toISOString(),
795
+ mtimeMs: stats.mtimeMs,
796
+ ageMs: Math.max(0, now - effectiveCreatedAtMs),
797
+ filestorePath,
798
+ filestoreStatus: existsSync(filestorePath) ? 'found' : 'missing',
799
+ });
800
+ }
801
+ }
802
+
803
+ snapshots.sort((left, right) => right.createdAtMs - left.createdAtMs || left.path.localeCompare(right.path));
804
+ console.log(JSON.stringify({ schemaVersion: 1, command: 'snapshot list', ok: true, snapshots }, null, 2));
805
+ NODE
806
+ }
807
+
625
808
  require_recent_snapshot_or_override() {
626
809
  local command="$1"
627
810
  local env_name
628
811
  env_name="$(selected_env)"
629
812
  if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
630
- if ! allow_no_recent_snapshot && ! has_recent_snapshot; then
813
+ if ! allow_no_recent_snapshot "$command" "$env_name" && ! has_recent_snapshot; then
631
814
  echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name without a recent database snapshot. Create a snapshot first or set WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1 to run it intentionally." >&2
632
815
  exit 1
633
816
  fi
@@ -650,7 +833,7 @@ require_migrations_allowed() {
650
833
  local env_name
651
834
  env_name="$(selected_env)"
652
835
  if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
653
- if ! allow_migrations && has_migration_risk; then
836
+ if ! allow_migrations "$command" "$env_name" && has_migration_risk; then
654
837
  echo "Refusing migration-risk command '$command' in WPMOO_ENV=$env_name. Review detected migration scripts or set WPMOO_ALLOW_MIGRATIONS=1 to run it intentionally." >&2
655
838
  exit 1
656
839
  fi
@@ -662,7 +845,7 @@ require_stage_lifecycle_allowed() {
662
845
  local env_name
663
846
  env_name="$(selected_env)"
664
847
  if [[ "$env_name" == "stage" ]]; then
665
- if ! allow_stage_lifecycle; then
848
+ if ! allow_stage_lifecycle "$command" "$env_name"; then
666
849
  echo "Refusing stage lifecycle command '$command' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally." >&2
667
850
  exit 1
668
851
  fi
@@ -674,7 +857,7 @@ require_prod_lifecycle_allowed() {
674
857
  local env_name
675
858
  env_name="$(selected_env)"
676
859
  if [[ "$env_name" == "prod" ]]; then
677
- if ! allow_prod_lifecycle; then
860
+ if ! allow_prod_lifecycle "$command" "$env_name"; then
678
861
  echo "Refusing production lifecycle command '$command' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally." >&2
679
862
  exit 1
680
863
  fi
@@ -731,6 +914,8 @@ case "$command" in
731
914
  "stop")
732
915
  shift
733
916
  require_no_args "$command" "$@"
917
+ require_stage_lifecycle_allowed "$command"
918
+ require_prod_lifecycle_allowed "$command"
734
919
  run_script ./scripts/down.sh
735
920
  ;;
736
921
  "logs")
@@ -751,6 +936,8 @@ case "$command" in
751
936
  "restart")
752
937
  shift
753
938
  require_no_args "$command" "$@"
939
+ require_stage_lifecycle_allowed "$command"
940
+ require_prod_lifecycle_allowed "$command"
754
941
  run_script ./scripts/restart.sh
755
942
  ;;
756
943
  "shell")
@@ -795,6 +982,34 @@ case "$command" in
795
982
  ;;
796
983
  "snapshot")
797
984
  shift
985
+ if [[ "\${1:-}" == "--list" || "\${1:-}" == "--json" ]]; then
986
+ list_requested=0
987
+ json_requested=0
988
+ while [[ "$#" -gt 0 ]]; do
989
+ case "$1" in
990
+ "--list")
991
+ list_requested=1
992
+ shift
993
+ ;;
994
+ "--json")
995
+ json_requested=1
996
+ shift
997
+ ;;
998
+ *)
999
+ fail_usage "$command"
1000
+ ;;
1001
+ esac
1002
+ done
1003
+ if [[ "$list_requested" -ne 1 ]]; then
1004
+ fail_usage "$command"
1005
+ fi
1006
+ if [[ "$json_requested" -eq 1 ]]; then
1007
+ list_snapshots_json
1008
+ else
1009
+ list_snapshots
1010
+ fi
1011
+ exit 0
1012
+ fi
798
1013
  positional_args "$command" 0 2 "$@"
799
1014
  run_script ./scripts/snapshot.sh "$@"
800
1015
  ;;
@@ -929,12 +1144,35 @@ import { access, readdir, readFile, stat } from 'node:fs/promises';
929
1144
  import { basename, isAbsolute, join, relative } from 'node:path';
930
1145
 
931
1146
  const args = process.argv.slice(2);
932
- if (!args.every((arg) => arg === '--json')) {
933
- console.error('Usage: ./moo status [--json]');
934
- process.exit(2);
1147
+ function parseJsonOption(argv) {
1148
+ let json = false;
1149
+ for (const arg of argv) {
1150
+ if (arg === '--json') {
1151
+ json = true;
1152
+ continue;
1153
+ }
1154
+
1155
+ if (arg.startsWith('--json=')) {
1156
+ const value = arg.slice('--json='.length).toLowerCase().trim();
1157
+ if (['true', '1', 'yes', 'y'].includes(value)) {
1158
+ json = true;
1159
+ continue;
1160
+ }
1161
+ if (['false', '0', 'no', 'n'].includes(value)) {
1162
+ json = false;
1163
+ continue;
1164
+ }
1165
+ console.error('Invalid boolean value for --json: ' + arg.slice('--json='.length));
1166
+ process.exit(2);
1167
+ }
1168
+
1169
+ console.error('Usage: ./moo status [--json]');
1170
+ process.exit(2);
1171
+ }
1172
+ return json;
935
1173
  }
936
1174
 
937
- const json = args.includes('--json');
1175
+ const json = parseJsonOption(args);
938
1176
  const target = process.cwd();
939
1177
  const metadataPath = '.wpmoo/odoo.json';
940
1178
  const validSourceTypes = new Set(['private', 'oca', 'external']);
@@ -1515,7 +1753,7 @@ Useful maintenance commands:
1515
1753
  \`\`\`bash
1516
1754
  ./moo lint
1517
1755
  ./moo resetdb [db] [module[,module]]
1518
- ./moo snapshot [db] [snapshot-name]
1756
+ ./moo snapshot [--list] [db] [snapshot-name]
1519
1757
  ./moo restore-snapshot [--dry-run] <snapshot-name> [db]
1520
1758
  ./moo pot <module[,module]> [db] [output]
1521
1759
  \`\`\`
@@ -65,8 +65,9 @@ Ready:
65
65
 
66
66
  Ready:
67
67
 
68
- - Stage `install` and `update` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
69
- - Production `install`, `update`, and `test` require
68
+ - Stage `install`, `update`, `stop`, and `restart` require
69
+ `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
70
+ - Production `install`, `update`, `test`, `stop`, and `restart` require
70
71
  `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
71
72
  - Destructive database commands require `WPMOO_ALLOW_DESTRUCTIVE=1` in stage
72
73
  and production.
@@ -74,8 +75,8 @@ Ready:
74
75
  preview/read-only paths.
75
76
  - Migration-risk lifecycle commands can require `WPMOO_ALLOW_MIGRATIONS=1`.
76
77
  - Environment-variable approvals remain supported through 1.x.
77
- - Timestamped approval files may be added as an additive safety layer, not as a
78
- replacement for env flags in 1.x.
78
+ - `.wpmoo/approvals.jsonl` adds time-bounded local approvals as an additive
79
+ safety layer, not as a replacement for env flags in 1.x.
79
80
 
80
81
  ## Release Artifact Policy
81
82
 
@@ -94,19 +95,43 @@ Ready:
94
95
 
95
96
  ## Current Audit
96
97
 
97
- Last updated: 2026-05-21.
98
+ Last updated: 2026-05-22.
98
99
 
99
100
  Completed checks:
100
101
 
101
- - Full local gate for Train 8 passed on 2026-05-21:
102
+ - Full local gate through Train 19 passed on 2026-05-22:
102
103
  `npm run typecheck`, `npm test`, `npm run build`, and `git diff --check`.
103
- - Coverage passed on 2026-05-21: 72 test files, 682 tests, 92.32%
104
- statements, 87.56% branches, 96.74% functions, and 92.32% lines.
105
- - Published CLI smoke passed against `@wpmoo/toolkit@0.9.27` with `npm exec`
106
- for `wpmoo --version`, `wpmoo --help`, and `wpmoo doctor --help`.
104
+ - Coverage passed on 2026-05-22: 75 test files, 722 tests, 92.00%
105
+ statements, 87.59% branches, 96.30% functions, and 92.00% lines.
106
+ - Local release packaging passed for the 1.0 release-candidate surface with
107
+ `npm --cache /private/tmp/wpmoo-npm-cache pack --dry-run`; the package
108
+ contained 70 files and the expected `dist`, `docs/assets`, `docs/*.md`,
109
+ `README.md`, `LICENSE`, and `package.json` entries.
110
+ - Local dist CLI smoke passed for `node dist/cli.js --version`, `--help`,
111
+ `create --help`, `doctor --help`, and `status --help`.
112
+ - The generated-environment acceptance flow remains covered by
113
+ `test/smoke-published-script.test.ts`, including create, source list/sync,
114
+ safe reset preview, `doctor --fix`, snapshot, and restore dry-run steps.
107
115
  - Placeholder review found no `TODO`, `FIXME`, `TBD`, or `coming soon`
108
116
  markers in `README.md`, `docs`, or `src`.
109
117
  - Local markdown link review passed across `README.md` and `docs/*.md`.
118
+ - Published CLI smoke previously passed against `@wpmoo/toolkit@0.9.27` with
119
+ `npm exec` for `wpmoo --version`, `wpmoo --help`, and
120
+ `wpmoo doctor --help`.
121
+
122
+ Release-candidate notes:
123
+
124
+ - Local `npm exec --package file:...` smoke against the generated tarball did
125
+ not complete in this restricted environment because package installation and
126
+ dependency resolution timed out before the CLI could run. This is not treated
127
+ as a product regression, but it prevents treating the current line as final
128
+ `1.0.0` evidence before a registry-backed smoke completes.
129
+ - The next patch release should be published as `0.9.30`. Do not tag `1.0.0`
130
+ until `WPMOO_SMOKE_ENVIRONMENT=1 npm run smoke:published -- "$VERSION"`
131
+ passes against a registry-resolved package.
132
+ - After a registry-backed generated-environment smoke passes, the remaining
133
+ 1.0 decision is procedural: bump to `1.0.0`, rerun the full gate, rerun
134
+ `npm run release:check`, tag, and verify the required scoped artifacts.
110
135
 
111
136
  Final policy decisions:
112
137
 
@@ -117,7 +142,7 @@ Final policy decisions:
117
142
  - Pre-1.0 generated environments are supported through safe reset and
118
143
  doctor-guided generated-file migration checks that preserve source code and
119
144
  local runtime data.
120
- - Environment-variable approvals remain supported through 1.x; timestamped
121
- approval files may be added only as an additive safety layer.
145
+ - Environment-variable approvals remain supported through 1.x;
146
+ `.wpmoo/approvals.jsonl` is additive and local-only.
122
147
  - Generated-environment acceptance smoke is mandatory for a `1.0.0` release
123
148
  candidate.
@@ -42,7 +42,7 @@ Run these from the generated environment root:
42
42
  | `./moo test <module[,module]> --db <db>` | Modules -> Run tests | Yes in prod | Runs Odoo tests for modules. |
43
43
  | `./moo lint` | Modules -> Run environment lint | No | Runs configured environment lint checks. |
44
44
  | `./moo pot <module[,module]> [db] [output]` | Modules -> Generate POT | No | Generates translation template files. |
45
- | `./moo snapshot [db] [name]` | Database -> Create snapshot | No | Creates a database and filestore snapshot. |
45
+ | `./moo snapshot [--list] [db] [name]` | Database -> Create snapshot | No | Creates a database and filestore snapshot, or lists known snapshots with `--list`. |
46
46
  | `./moo restore-snapshot --dry-run <name> [db]` | Database -> Restore snapshot | Preview only | Prints a restore preview without changing data. |
47
47
  | `./moo restore-snapshot <name> [db]` | Database -> Restore snapshot | Yes | Restores database and filestore from a snapshot. |
48
48
  | `./moo resetdb [db] [module[,module]]` | Database -> Reset database | Yes | Destructive database reset. |
@@ -79,8 +79,8 @@ Current JSON contract notes:
79
79
  | Variable | Purpose |
80
80
  | --- | --- |
81
81
  | `WPMOO_ENV=dev|stage|prod` | Selects environment safety policy. Missing value behaves like development for local workflows. |
82
- | `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Allows `install` and `update` in stage. |
83
- | `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, and `test` in production. |
82
+ | `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Allows `install`, `update`, `stop`, and `restart` in stage. |
83
+ | `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, `test`, `stop`, and `restart` in production. |
84
84
  | `WPMOO_ALLOW_DESTRUCTIVE=1` | Allows destructive database commands in stage/prod. |
85
85
  | `WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1` | Allows destructive commands without a recent snapshot when that extra guard applies. |
86
86
  | `WPMOO_ALLOW_MIGRATIONS=1` | Allows lifecycle commands when migration scripts are detected. |
@@ -88,6 +88,24 @@ Current JSON contract notes:
88
88
 
89
89
  Process environment values take precedence over `.env` values for safety flags.
90
90
 
91
+ ## Approval Ledger
92
+
93
+ For time-bounded local approvals, add JSONL entries to `.wpmoo/approvals.jsonl`.
94
+ Generated `.gitignore` ignores this file, and it should not be committed.
95
+ Existing `WPMOO_ALLOW_*` flags remain supported; ledger entries are an additive
96
+ way to make short-lived intent explicit.
97
+
98
+ Each line is one JSON object:
99
+
100
+ ```json
101
+ {"scope":"stage-lifecycle","environment":"stage","command":"install","expiresAt":"2026-05-21T12:30:00.000Z","reason":"release rehearsal"}
102
+ ```
103
+
104
+ Supported `scope` values are `stage-lifecycle`, `prod-lifecycle`,
105
+ `destructive`, `no-recent-snapshot`, and `migration-risk`. `environment` must be
106
+ `stage` or `prod`. `command` is optional; omit it only for a deliberately broad
107
+ approval. Expired, malformed, or mismatched entries are ignored.
108
+
91
109
  ## Exit Behavior
92
110
 
93
111
  - Successful commands exit `0`.
@@ -103,6 +121,7 @@ Process environment values take precedence over `.env` values for safety flags.
103
121
  | Command Family | Stage | Production |
104
122
  | --- | --- | --- |
105
123
  | `install`, `update` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
124
+ | `stop`, `restart` | Requires `WPMOO_ALLOW_STAGE_LIFECYCLE=1` | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
106
125
  | `test` | Allowed | Requires `WPMOO_ALLOW_PROD_LIFECYCLE=1` |
107
126
  | `resetdb`, real `restore-snapshot` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` | Requires `WPMOO_ALLOW_DESTRUCTIVE=1` |
108
127
  | `restore-snapshot --dry-run` | Allowed | Allowed |