@wpmoo/odoo 0.8.58 → 0.8.60
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 +61 -6
- package/dist/args.js +1 -0
- package/dist/cli.js +83 -2
- package/dist/doctor.js +156 -5
- package/dist/environment.js +79 -4
- package/dist/help.js +10 -2
- package/dist/repo-actions.js +74 -22
- package/dist/safe-reset.js +88 -21
- package/dist/scaffold.js +12 -3
- package/dist/source-actions.js +42 -0
- package/dist/source-manifest.js +338 -0
- package/dist/templates.js +15 -6
- package/docs/generated-environment-verification.md +49 -1
- package/package.json +1 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isValidPathSegment, validateRepoPath } from './path-validation.js';
|
|
4
|
+
const validSourceTypes = ['private', 'oca', 'external'];
|
|
5
|
+
export const sourceManifestPath = 'odoo/custom/manifests/sources.yaml';
|
|
6
|
+
function fail(message) {
|
|
7
|
+
throw new Error(`Invalid source manifest ${sourceManifestPath}: ${message}`);
|
|
8
|
+
}
|
|
9
|
+
export function normalizeSourceType(value) {
|
|
10
|
+
return validSourceTypes.includes(value) ? value : 'private';
|
|
11
|
+
}
|
|
12
|
+
function dedupeAndSort(entries) {
|
|
13
|
+
const uniqueByTypePath = new Map();
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
uniqueByTypePath.set(`${entry.type}:${entry.path}`, entry);
|
|
16
|
+
}
|
|
17
|
+
return [...uniqueByTypePath.values()].sort((left, right) => {
|
|
18
|
+
const typeOrder = left.type.localeCompare(right.type);
|
|
19
|
+
if (typeOrder !== 0)
|
|
20
|
+
return typeOrder;
|
|
21
|
+
return left.path.localeCompare(right.path);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function stripInlineComment(raw) {
|
|
25
|
+
let inSingle = false;
|
|
26
|
+
let inDouble = false;
|
|
27
|
+
let escaped = false;
|
|
28
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
29
|
+
const char = raw[index];
|
|
30
|
+
if (escaped) {
|
|
31
|
+
escaped = false;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (char === '\\') {
|
|
35
|
+
escaped = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (char === "'" && !inDouble) {
|
|
39
|
+
inSingle = !inSingle;
|
|
40
|
+
}
|
|
41
|
+
else if (char === '"' && !inSingle) {
|
|
42
|
+
inDouble = !inDouble;
|
|
43
|
+
}
|
|
44
|
+
else if (char === '#' && !inSingle && !inDouble) {
|
|
45
|
+
return raw.slice(0, index).trimEnd();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
function parseScalar(raw) {
|
|
51
|
+
const trimmed = raw.trim();
|
|
52
|
+
if (!trimmed)
|
|
53
|
+
return '';
|
|
54
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
55
|
+
return JSON.parse(trimmed);
|
|
56
|
+
}
|
|
57
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
58
|
+
return trimmed.slice(1, -1).replace(/\\'/g, "'");
|
|
59
|
+
}
|
|
60
|
+
return trimmed;
|
|
61
|
+
}
|
|
62
|
+
function leadingSpaces(line) {
|
|
63
|
+
return line.length - line.trimStart().length;
|
|
64
|
+
}
|
|
65
|
+
function parseSourcesBlock(content) {
|
|
66
|
+
const lines = content.split(/\r?\n/).map((line, index) => ({
|
|
67
|
+
lineNumber: index + 1,
|
|
68
|
+
line: line.replace(/\t/g, ' '),
|
|
69
|
+
trimmedLine: line.replace(/\t/g, ' ').trim(),
|
|
70
|
+
}));
|
|
71
|
+
const sourcesKeywordLine = lines.find((line) => /^\s*sources\s*:\s*(?:\[[^\]]*\])?\s*$/.test(stripInlineComment(line.line)));
|
|
72
|
+
if (!sourcesKeywordLine) {
|
|
73
|
+
fail('Missing top-level sources entry.');
|
|
74
|
+
}
|
|
75
|
+
const rawSourcesValue = stripInlineComment(sourcesKeywordLine.line).replace(/^\s*sources\s*:\s*/, '');
|
|
76
|
+
if (rawSourcesValue === '[]') {
|
|
77
|
+
return { sources: [] };
|
|
78
|
+
}
|
|
79
|
+
if (rawSourcesValue && rawSourcesValue !== '') {
|
|
80
|
+
fail(`Unexpected non-list value on line ${sourcesKeywordLine.lineNumber}: sources`);
|
|
81
|
+
}
|
|
82
|
+
const sourceLines = lines.slice(sourcesKeywordLine.lineNumber);
|
|
83
|
+
const parsed = [];
|
|
84
|
+
let index = 0;
|
|
85
|
+
while (index < sourceLines.length) {
|
|
86
|
+
const headerLine = sourceLines[index];
|
|
87
|
+
const noCommentHeader = stripInlineComment(headerLine.line);
|
|
88
|
+
if (!noCommentHeader.trim()) {
|
|
89
|
+
index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const itemMatch = /^\s*-\s*type:\s*(.+)\s*$/.exec(noCommentHeader);
|
|
93
|
+
if (!itemMatch) {
|
|
94
|
+
index += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const item = {
|
|
98
|
+
type: normalizeSourceType(parseScalar(itemMatch[1])),
|
|
99
|
+
path: '',
|
|
100
|
+
url: '',
|
|
101
|
+
addons: [],
|
|
102
|
+
};
|
|
103
|
+
index += 1;
|
|
104
|
+
while (index < sourceLines.length) {
|
|
105
|
+
const rawLine = sourceLines[index];
|
|
106
|
+
const noComment = stripInlineComment(rawLine.line);
|
|
107
|
+
const trimmed = noComment.trim();
|
|
108
|
+
if (!trimmed) {
|
|
109
|
+
index += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (/^\s*-\s*type:\s*/.test(noComment)) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
const pathMatch = /^\s*path:\s*(.+)\s*$/.exec(noComment);
|
|
116
|
+
if (pathMatch) {
|
|
117
|
+
item.path = validateRepoPath(parseScalar(pathMatch[1]));
|
|
118
|
+
index += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const urlMatch = /^\s*url:\s*(.+)\s*$/.exec(noComment);
|
|
122
|
+
if (urlMatch) {
|
|
123
|
+
item.url = parseScalar(urlMatch[1]);
|
|
124
|
+
index += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const branchMatch = /^\s*branch:\s*(.+)\s*$/.exec(noComment);
|
|
128
|
+
if (branchMatch) {
|
|
129
|
+
item.branch = parseScalar(branchMatch[1]);
|
|
130
|
+
index += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const addonsLine = /^\s*addons:\s*$/.exec(noComment);
|
|
134
|
+
if (addonsLine) {
|
|
135
|
+
const baseIndent = leadingSpaces(rawLine.line) + 2;
|
|
136
|
+
index += 1;
|
|
137
|
+
while (index < sourceLines.length) {
|
|
138
|
+
const addonRaw = stripInlineComment(sourceLines[index].line);
|
|
139
|
+
const addonTrimmed = addonRaw.trim();
|
|
140
|
+
if (!addonTrimmed) {
|
|
141
|
+
index += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const addonMatch = /^\s*-\s*(.+)\s*$/.exec(addonRaw);
|
|
145
|
+
if (!addonMatch) {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
if (leadingSpaces(addonRaw) < baseIndent) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
const addon = parseScalar(addonMatch[1]);
|
|
152
|
+
if (addon) {
|
|
153
|
+
item.addons.push(addon);
|
|
154
|
+
}
|
|
155
|
+
index += 1;
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
fail(`Unexpected source entry field on line ${rawLine.lineNumber}: ${trimmed}`);
|
|
160
|
+
}
|
|
161
|
+
if (!item.path) {
|
|
162
|
+
fail(`Manifest entry missing path at line ${headerLine.lineNumber}`);
|
|
163
|
+
}
|
|
164
|
+
if (!item.url) {
|
|
165
|
+
fail(`Manifest entry missing url for ${item.type}:${item.path} at line ${headerLine.lineNumber}`);
|
|
166
|
+
}
|
|
167
|
+
if (!isValidPathSegment(item.path)) {
|
|
168
|
+
fail(`Invalid manifest path at line ${headerLine.lineNumber}: ${item.path}`);
|
|
169
|
+
}
|
|
170
|
+
if (item.addons.length === 0) {
|
|
171
|
+
item.addons.push(item.path);
|
|
172
|
+
}
|
|
173
|
+
item.addons = [...new Set(item.addons.map((addon) => validateRepoPath(addon)))].sort();
|
|
174
|
+
parsed.push(item);
|
|
175
|
+
}
|
|
176
|
+
return { sources: dedupeAndSort(parsed.filter((entry) => isValidPathSegment(entry.path))) };
|
|
177
|
+
}
|
|
178
|
+
export async function readSourceManifest(target) {
|
|
179
|
+
try {
|
|
180
|
+
const content = await readFile(join(target, sourceManifestPath), 'utf8');
|
|
181
|
+
return parseSourcesBlock(content);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error.code === 'ENOENT') {
|
|
185
|
+
return { sources: [] };
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function renderQuoted(value) {
|
|
191
|
+
return JSON.stringify(value);
|
|
192
|
+
}
|
|
193
|
+
export function renderSourceManifest(entries) {
|
|
194
|
+
const normalized = dedupeAndSort(entries).map((entry) => {
|
|
195
|
+
const addons = [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))].sort();
|
|
196
|
+
return {
|
|
197
|
+
type: entry.type,
|
|
198
|
+
path: validateRepoPath(entry.path),
|
|
199
|
+
url: entry.url.trim(),
|
|
200
|
+
branch: entry.branch?.trim(),
|
|
201
|
+
addons: addons.length ? addons : [validateRepoPath(entry.path)],
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
if (normalized.length === 0) {
|
|
205
|
+
return 'sources: []\n';
|
|
206
|
+
}
|
|
207
|
+
const body = normalized
|
|
208
|
+
.map((entry) => {
|
|
209
|
+
const lines = [
|
|
210
|
+
` - type: ${renderQuoted(entry.type)}`,
|
|
211
|
+
` path: ${renderQuoted(entry.path)}`,
|
|
212
|
+
` url: ${renderQuoted(entry.url)}`,
|
|
213
|
+
];
|
|
214
|
+
lines.push(` branch: ${renderQuoted(entry.branch ?? '')}`);
|
|
215
|
+
lines.push(' addons:');
|
|
216
|
+
for (const addon of entry.addons) {
|
|
217
|
+
lines.push(` - ${renderQuoted(addon)}`);
|
|
218
|
+
}
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
})
|
|
221
|
+
.join('\n');
|
|
222
|
+
return `sources:\n${body}\n`;
|
|
223
|
+
}
|
|
224
|
+
export async function writeSourceManifest(target, entries) {
|
|
225
|
+
const content = renderSourceManifest(entries);
|
|
226
|
+
const path = join(target, sourceManifestPath);
|
|
227
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
228
|
+
await writeFile(path, content, 'utf8');
|
|
229
|
+
}
|
|
230
|
+
function entryKey(type, path) {
|
|
231
|
+
return `${type}:${path}`;
|
|
232
|
+
}
|
|
233
|
+
export async function upsertSourceManifestEntry(target, entry) {
|
|
234
|
+
const manifest = await readSourceManifest(target);
|
|
235
|
+
const normalized = {
|
|
236
|
+
...entry,
|
|
237
|
+
type: normalizeSourceType(entry.type),
|
|
238
|
+
path: validateRepoPath(entry.path),
|
|
239
|
+
};
|
|
240
|
+
const next = dedupeAndSort(manifest.sources.filter((current) => entryKey(current.type, current.path) !== entryKey(normalized.type, normalized.path)));
|
|
241
|
+
next.push(normalized);
|
|
242
|
+
await writeSourceManifest(target, next);
|
|
243
|
+
}
|
|
244
|
+
export async function removeSourceManifestEntry(target, type, path) {
|
|
245
|
+
const manifest = await readSourceManifest(target);
|
|
246
|
+
const key = entryKey(normalizeSourceType(type), validateRepoPath(path));
|
|
247
|
+
const next = manifest.sources.filter((entry) => entryKey(entry.type, entry.path) !== key);
|
|
248
|
+
await writeSourceManifest(target, next);
|
|
249
|
+
}
|
|
250
|
+
export function sourceManifestEntriesFromMetadata(sourceRepos, fallbackBranch) {
|
|
251
|
+
return sourceRepos.map((repo) => ({
|
|
252
|
+
type: normalizeSourceType(repo.sourceType),
|
|
253
|
+
path: validateRepoPath(repo.path),
|
|
254
|
+
url: repo.url.trim(),
|
|
255
|
+
branch: fallbackBranch,
|
|
256
|
+
addons: repo.addons.length ? [...new Set(repo.addons.map((addon) => validateRepoPath(addon)))] : [validateRepoPath(repo.path)],
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
export async function listGitmoduleSources(target) {
|
|
260
|
+
try {
|
|
261
|
+
const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
|
|
262
|
+
const lines = gitmodules.split(/\r?\n/);
|
|
263
|
+
const locations = [];
|
|
264
|
+
const pathRegex = /^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/(.+)\s*$/;
|
|
265
|
+
const urlRegex = /^\s*url\s*=\s*(.+)\s*$/;
|
|
266
|
+
let pending;
|
|
267
|
+
for (const line of lines) {
|
|
268
|
+
const parsedPath = line.match(pathRegex);
|
|
269
|
+
if (parsedPath) {
|
|
270
|
+
const sourceType = parsedPath[1];
|
|
271
|
+
const repoPath = parsedPath[2]?.trim() ?? '';
|
|
272
|
+
if (!repoPath || !isValidPathSegment(repoPath)) {
|
|
273
|
+
pending = undefined;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
pending = {
|
|
277
|
+
type: sourceType,
|
|
278
|
+
path: validateRepoPath(repoPath),
|
|
279
|
+
url: '',
|
|
280
|
+
};
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const parsedUrl = line.match(urlRegex);
|
|
284
|
+
if (!parsedUrl || !pending) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const url = parseScalar(parsedUrl[1]);
|
|
288
|
+
if (url) {
|
|
289
|
+
locations.push({ ...pending, url });
|
|
290
|
+
}
|
|
291
|
+
pending = undefined;
|
|
292
|
+
}
|
|
293
|
+
return locations;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export function syncManifestFromMetadataAndGitmodules(sourceRepos, fallbackBranch, gitmodules = []) {
|
|
300
|
+
const byGitmodule = new Map();
|
|
301
|
+
for (const location of gitmodules) {
|
|
302
|
+
byGitmodule.set(`${normalizeSourceType(location.type)}:${location.path}`, location);
|
|
303
|
+
}
|
|
304
|
+
const entries = [];
|
|
305
|
+
for (const repo of sourceRepos) {
|
|
306
|
+
const normalized = {
|
|
307
|
+
type: normalizeSourceType(repo.sourceType),
|
|
308
|
+
path: validateRepoPath(repo.path),
|
|
309
|
+
url: repo.url.trim() || byGitmodule.get(`${normalizeSourceType(repo.sourceType)}:${repo.path}`)?.url || '',
|
|
310
|
+
branch: fallbackBranch,
|
|
311
|
+
addons: repo.addons.map(validateRepoPath),
|
|
312
|
+
};
|
|
313
|
+
entries.push(normalized);
|
|
314
|
+
}
|
|
315
|
+
for (const location of gitmodules) {
|
|
316
|
+
const key = `${location.type}:${location.path}`;
|
|
317
|
+
if (entries.some((entry) => `${entry.type}:${entry.path}` === key)) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
entries.push({
|
|
321
|
+
type: location.type,
|
|
322
|
+
path: location.path,
|
|
323
|
+
url: location.url,
|
|
324
|
+
branch: fallbackBranch,
|
|
325
|
+
addons: [location.path],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return dedupeAndSort(entries);
|
|
329
|
+
}
|
|
330
|
+
export function sourceReposFromManifest(entries) {
|
|
331
|
+
const normalized = dedupeAndSort(entries);
|
|
332
|
+
return normalized.map((entry) => ({
|
|
333
|
+
sourceType: entry.type,
|
|
334
|
+
path: validateRepoPath(entry.path),
|
|
335
|
+
url: entry.url,
|
|
336
|
+
addons: entry.addons.length ? [...new Set(entry.addons.map((addon) => validateRepoPath(addon)))] : [validateRepoPath(entry.path)],
|
|
337
|
+
}));
|
|
338
|
+
}
|
package/dist/templates.js
CHANGED
|
@@ -20,6 +20,12 @@ function allAddons(options) {
|
|
|
20
20
|
function hasSourceRepos(options) {
|
|
21
21
|
return options.sourceRepos.length > 0;
|
|
22
22
|
}
|
|
23
|
+
function sourceTypeOf(repo) {
|
|
24
|
+
return repo.sourceType ?? 'private';
|
|
25
|
+
}
|
|
26
|
+
function sourceRepoRelativePath(repo) {
|
|
27
|
+
return `odoo/custom/src/${sourceTypeOf(repo)}/${repo.path}`;
|
|
28
|
+
}
|
|
23
29
|
function repositoryLayout(options) {
|
|
24
30
|
const sourceRepoRows = hasSourceRepos(options)
|
|
25
31
|
? options.sourceRepos
|
|
@@ -91,11 +97,14 @@ ${repo.url}
|
|
|
91
97
|
Submodule path:
|
|
92
98
|
|
|
93
99
|
\`\`\`text
|
|
94
|
-
|
|
100
|
+
${sourceRepoRelativePath(repo)}
|
|
95
101
|
\`\`\`
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
Source manifest entry:
|
|
104
|
+
|
|
105
|
+
\`\`\`text
|
|
106
|
+
odoo/custom/manifests/sources.yaml
|
|
107
|
+
\`\`\`
|
|
99
108
|
|
|
100
109
|
Expected addon layout:
|
|
101
110
|
|
|
@@ -542,7 +551,7 @@ export function renderAddonsYaml(options) {
|
|
|
542
551
|
# Source repos are managed as Git submodules under odoo/custom/src/private.
|
|
543
552
|
# Do not duplicate these same repos in repos.yaml.
|
|
544
553
|
|
|
545
|
-
${options.sourceRepos.map((repo) =>
|
|
554
|
+
${options.sourceRepos.map((repo) => `${sourceTypeOf(repo)}/${repo.path}:\n${yamlList(repo.addons)}`).join('\n\n')}
|
|
546
555
|
`;
|
|
547
556
|
}
|
|
548
557
|
export function renderReposYaml(options) {
|
|
@@ -551,7 +560,7 @@ export function renderReposYaml(options) {
|
|
|
551
560
|
# Project source repositories are intentionally not listed here because
|
|
552
561
|
# they are pinned as Git submodules:
|
|
553
562
|
#
|
|
554
|
-
${options.sourceRepos.map((repo) => `# -
|
|
563
|
+
${options.sourceRepos.map((repo) => `# - ${sourceTypeOf(repo)}/${repo.path}`).join('\n')}
|
|
555
564
|
#
|
|
556
565
|
# Keep this file for upstream/OCA repositories that should be aggregated.
|
|
557
566
|
|
|
@@ -628,7 +637,7 @@ export function renderAgents(options) {
|
|
|
628
637
|
source directory below for this environment:
|
|
629
638
|
|
|
630
639
|
\`\`\`text
|
|
631
|
-
${options.sourceRepos.map(
|
|
640
|
+
${options.sourceRepos.map(sourceRepoRelativePath).join('\n')}
|
|
632
641
|
\`\`\`
|
|
633
642
|
|
|
634
643
|
${repoDuplicationNote()}`
|
|
@@ -21,9 +21,11 @@ not validate staging or production deployments.
|
|
|
21
21
|
| Compose resource files | Compact compose layout is present (`compose.yaml` + environment overlays under `compose/`), plus config/resources/scripts. | `npx @wpmoo/odoo create ...` |
|
|
22
22
|
| `./moo` delegation | `./moo` dispatches fixed daily actions to the matching script and preserves argument pass-through. | `./moo <action> ...` |
|
|
23
23
|
| Doctor checks | Metadata, compose files, scripts, source repo paths, and local tooling checks behave as expected. | `npx @wpmoo/odoo doctor` or `./moo doctor` |
|
|
24
|
+
| Generated Postgres checks | For PostgreSQL 18 environments, doctor validates db mount targets avoid old PG image-specific paths. | `npx @wpmoo/odoo doctor` |
|
|
24
25
|
| Source repo add/remove | Source repository registration and submodule lifecycle behave correctly. | `npx @wpmoo/odoo add-repo ...`, `npx @wpmoo/odoo remove-repo ...` |
|
|
26
|
+
| Source manifest sync | Source repo metadata, `.gitmodules`, and `odoo/custom/manifests/sources.yaml` stay aligned. | `npx @wpmoo/odoo source list`, `npx @wpmoo/odoo source sync` |
|
|
25
27
|
| Module add/remove | Module registration changes are applied to the selected source repo config. | `npx @wpmoo/odoo add-module ...`, `npx @wpmoo/odoo remove-module ...` |
|
|
26
|
-
| Safe reset | Generated files are refreshed without deleting source module code.
|
|
28
|
+
| Safe reset | Generated files are refreshed (including `compose.yaml` overlays and env example) without deleting source module code. Local runtime/data directories and custom source layout content are preserved; legacy user-editable paths from older templates may remain and are reported for manual cleanup. | `npx @wpmoo/odoo reset` |
|
|
27
29
|
| Snapshot/restore and lint/pot | These actions are delegated by `./moo` to compose scripts without extra package-side logic. | `./moo snapshot ...`, `./moo restore-snapshot ...`, `./moo lint`, `./moo pot ...` |
|
|
28
30
|
|
|
29
31
|
## Compact compose checks
|
|
@@ -43,6 +45,17 @@ Default local development uses `compose.yaml` plus `compose/dev.yaml`.
|
|
|
43
45
|
`WPMOO_ENV=stage` or `WPMOO_ENV=prod` must only be used after production-grade
|
|
44
46
|
secrets and volumes are configured.
|
|
45
47
|
|
|
48
|
+
For PostgreSQL 18 environments (including `POSTGRES_IMAGE=postgres:18`), ensure db
|
|
49
|
+
volume and tmpfs mount targets use `/var/lib/postgresql` directly:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
- volumes:
|
|
53
|
+
- db_data:/var/lib/postgresql
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Paths such as `/var/lib/postgresql/data` and `/var/lib/postgresql/18/docker` are
|
|
57
|
+
no longer accepted by the package `doctor` check.
|
|
58
|
+
|
|
46
59
|
## Safe reset policy
|
|
47
60
|
|
|
48
61
|
Safe reset intentionally avoids deleting user-editable legacy paths from old
|
|
@@ -54,6 +67,41 @@ test/
|
|
|
54
67
|
.github/
|
|
55
68
|
```
|
|
56
69
|
|
|
70
|
+
In addition, safe reset preserves local runtime and source-data state while refreshing
|
|
71
|
+
generated and compose assets:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
.env
|
|
75
|
+
data/
|
|
76
|
+
backups/
|
|
77
|
+
odoo/custom/src/oca/
|
|
78
|
+
odoo/custom/src/external/
|
|
79
|
+
odoo/custom/patches/
|
|
80
|
+
odoo/custom/manifests/
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Source manifest checks
|
|
84
|
+
|
|
85
|
+
Generated environments include `odoo/custom/manifests/sources.yaml`. The manifest
|
|
86
|
+
records each source repository's type (`private`, `oca`, or `external`), path,
|
|
87
|
+
URL, Odoo branch, and addon boundaries.
|
|
88
|
+
|
|
89
|
+
Use `source list` to inspect the current manifest view:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx @wpmoo/odoo source list
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use `source sync` after manual submodule or metadata repair to regenerate the
|
|
96
|
+
manifest and normalize `.wpmoo/odoo.json` source entries:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx @wpmoo/odoo source sync
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`doctor` fails when manifest entries, metadata entries, and source submodule
|
|
103
|
+
paths diverge.
|
|
104
|
+
|
|
57
105
|
## Local verification commands
|
|
58
106
|
|
|
59
107
|
Run from the `wpmoo-odoo` repository root:
|