@wpmoo/toolkit 0.9.29 → 0.9.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -4
- package/dist/approval-ledger.js +74 -0
- package/dist/cli-routes/doctor.js +20 -0
- package/dist/cli-routes/options.js +36 -0
- package/dist/cli-routes/reset.js +11 -0
- package/dist/cli-routes/source.js +123 -0
- package/dist/cli.js +31 -173
- package/dist/cockpit/command-registry.js +9 -3
- package/dist/cockpit/daily-prompts.js +33 -6
- package/dist/cockpit/menu.js +13 -8
- package/dist/cockpit/module-browser.js +79 -2
- package/dist/daily-actions.js +55 -12
- package/dist/databases.js +223 -36
- package/dist/doctor.js +188 -20
- package/dist/environment-policy.js +6 -6
- package/dist/external-templates.js +7 -1
- package/dist/github.js +11 -2
- package/dist/help.js +8 -6
- package/dist/module-actions.js +61 -16
- package/dist/module-manifest.js +6 -0
- package/dist/module-quality.js +154 -0
- package/dist/postgres-diagnostics.js +48 -10
- package/dist/repo-url.js +4 -7
- package/dist/safe-reset.js +21 -12
- package/dist/source-actions.js +90 -1
- package/dist/source-manifest.js +2 -2
- package/dist/templates.js +257 -19
- package/docs/1-0-readiness.md +37 -12
- package/docs/command-reference.md +22 -3
- package/docs/generated-environment-verification.md +23 -2
- package/docs/handoff.md +14 -2
- package/docs/lifecycle-recipes.md +6 -1
- package/docs/troubleshooting.md +29 -1
- package/package.json +1 -1
package/dist/source-actions.js
CHANGED
|
@@ -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);
|
package/dist/source-manifest.js
CHANGED
|
@@ -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)\/(
|
|
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=
|
|
212
|
-
require
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
|
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
|
\`\`\`
|
package/docs/1-0-readiness.md
CHANGED
|
@@ -65,8 +65,9 @@ Ready:
|
|
|
65
65
|
|
|
66
66
|
Ready:
|
|
67
67
|
|
|
68
|
-
- Stage `install` and `
|
|
69
|
-
|
|
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
|
-
-
|
|
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-
|
|
98
|
+
Last updated: 2026-05-22.
|
|
98
99
|
|
|
99
100
|
Completed checks:
|
|
100
101
|
|
|
101
|
-
- Full local gate
|
|
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-
|
|
104
|
-
statements, 87.
|
|
105
|
-
-
|
|
106
|
-
|
|
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;
|
|
121
|
-
|
|
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 `
|
|
83
|
-
| `WPMOO_ALLOW_PROD_LIFECYCLE=1` | Allows `install`, `update`, and `
|
|
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 |
|