@wpmoo/toolkit 0.9.24 → 0.9.26
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/audit-log.js +78 -0
- package/dist/cli.js +11 -1
- package/dist/cockpit/daily-prompts.js +15 -1
- package/dist/daily-actions.js +162 -76
- package/dist/databases.js +81 -0
- package/dist/environment-policy.js +219 -0
- package/dist/help.js +2 -2
- package/dist/migrations.js +112 -0
- package/dist/safe-reset.js +244 -17
- package/dist/scaffold.js +2 -1
- package/dist/service-runtime-status.js +65 -3
- package/dist/templates.js +169 -6
- package/package.json +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
export const dailyActionPolicyCommands = [
|
|
2
|
+
'start',
|
|
3
|
+
'stop',
|
|
4
|
+
'logs',
|
|
5
|
+
'restart',
|
|
6
|
+
'shell',
|
|
7
|
+
'psql',
|
|
8
|
+
'install',
|
|
9
|
+
'update',
|
|
10
|
+
'test',
|
|
11
|
+
'resetdb',
|
|
12
|
+
'snapshot',
|
|
13
|
+
'restore-snapshot',
|
|
14
|
+
'lint',
|
|
15
|
+
'pot',
|
|
16
|
+
];
|
|
17
|
+
function normalizeFlag(value) {
|
|
18
|
+
return value?.trim() ?? '';
|
|
19
|
+
}
|
|
20
|
+
function parseFlag(value) {
|
|
21
|
+
return normalizeFlag(value) === '1';
|
|
22
|
+
}
|
|
23
|
+
export const dailyActionPolicyTable = {
|
|
24
|
+
start: {
|
|
25
|
+
isDestructive: () => false,
|
|
26
|
+
isDryRunAllowed: false,
|
|
27
|
+
requiresStageLifecycleApproval: false,
|
|
28
|
+
requiresProdLifecycleApproval: false,
|
|
29
|
+
isAuditWorthy: () => false,
|
|
30
|
+
},
|
|
31
|
+
stop: {
|
|
32
|
+
isDestructive: () => false,
|
|
33
|
+
isDryRunAllowed: false,
|
|
34
|
+
requiresStageLifecycleApproval: false,
|
|
35
|
+
requiresProdLifecycleApproval: false,
|
|
36
|
+
isAuditWorthy: () => false,
|
|
37
|
+
},
|
|
38
|
+
logs: {
|
|
39
|
+
isDestructive: () => false,
|
|
40
|
+
isDryRunAllowed: false,
|
|
41
|
+
requiresStageLifecycleApproval: false,
|
|
42
|
+
requiresProdLifecycleApproval: false,
|
|
43
|
+
isAuditWorthy: () => false,
|
|
44
|
+
},
|
|
45
|
+
restart: {
|
|
46
|
+
isDestructive: () => false,
|
|
47
|
+
isDryRunAllowed: false,
|
|
48
|
+
requiresStageLifecycleApproval: false,
|
|
49
|
+
requiresProdLifecycleApproval: false,
|
|
50
|
+
isAuditWorthy: () => false,
|
|
51
|
+
},
|
|
52
|
+
shell: {
|
|
53
|
+
isDestructive: () => false,
|
|
54
|
+
isDryRunAllowed: false,
|
|
55
|
+
requiresStageLifecycleApproval: false,
|
|
56
|
+
requiresProdLifecycleApproval: false,
|
|
57
|
+
isAuditWorthy: () => false,
|
|
58
|
+
},
|
|
59
|
+
psql: {
|
|
60
|
+
isDestructive: () => false,
|
|
61
|
+
isDryRunAllowed: false,
|
|
62
|
+
requiresStageLifecycleApproval: false,
|
|
63
|
+
requiresProdLifecycleApproval: false,
|
|
64
|
+
isAuditWorthy: () => false,
|
|
65
|
+
},
|
|
66
|
+
install: {
|
|
67
|
+
isDestructive: () => false,
|
|
68
|
+
isDryRunAllowed: false,
|
|
69
|
+
requiresStageLifecycleApproval: true,
|
|
70
|
+
requiresProdLifecycleApproval: true,
|
|
71
|
+
isAuditWorthy: () => true,
|
|
72
|
+
},
|
|
73
|
+
update: {
|
|
74
|
+
isDestructive: () => false,
|
|
75
|
+
isDryRunAllowed: false,
|
|
76
|
+
requiresStageLifecycleApproval: true,
|
|
77
|
+
requiresProdLifecycleApproval: true,
|
|
78
|
+
isAuditWorthy: () => true,
|
|
79
|
+
},
|
|
80
|
+
test: {
|
|
81
|
+
isDestructive: () => false,
|
|
82
|
+
isDryRunAllowed: false,
|
|
83
|
+
requiresStageLifecycleApproval: false,
|
|
84
|
+
requiresProdLifecycleApproval: true,
|
|
85
|
+
isAuditWorthy: () => true,
|
|
86
|
+
},
|
|
87
|
+
resetdb: {
|
|
88
|
+
isDestructive: () => true,
|
|
89
|
+
isDryRunAllowed: false,
|
|
90
|
+
requiresStageLifecycleApproval: false,
|
|
91
|
+
requiresProdLifecycleApproval: false,
|
|
92
|
+
isAuditWorthy: () => true,
|
|
93
|
+
},
|
|
94
|
+
snapshot: {
|
|
95
|
+
isDestructive: () => false,
|
|
96
|
+
isDryRunAllowed: false,
|
|
97
|
+
requiresStageLifecycleApproval: false,
|
|
98
|
+
requiresProdLifecycleApproval: false,
|
|
99
|
+
isAuditWorthy: () => false,
|
|
100
|
+
},
|
|
101
|
+
'restore-snapshot': {
|
|
102
|
+
isDestructive: (args) => args[0] !== '--dry-run',
|
|
103
|
+
isDryRunAllowed: true,
|
|
104
|
+
requiresStageLifecycleApproval: false,
|
|
105
|
+
requiresProdLifecycleApproval: false,
|
|
106
|
+
isAuditWorthy: (args) => args[0] !== '--dry-run',
|
|
107
|
+
},
|
|
108
|
+
lint: {
|
|
109
|
+
isDestructive: () => false,
|
|
110
|
+
isDryRunAllowed: false,
|
|
111
|
+
requiresStageLifecycleApproval: false,
|
|
112
|
+
requiresProdLifecycleApproval: false,
|
|
113
|
+
isAuditWorthy: () => false,
|
|
114
|
+
},
|
|
115
|
+
pot: {
|
|
116
|
+
isDestructive: () => false,
|
|
117
|
+
isDryRunAllowed: false,
|
|
118
|
+
requiresStageLifecycleApproval: false,
|
|
119
|
+
requiresProdLifecycleApproval: false,
|
|
120
|
+
isAuditWorthy: () => false,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
export function parseEnvironmentKind(rawEnvName) {
|
|
124
|
+
const normalized = rawEnvName?.trim().toLowerCase();
|
|
125
|
+
if (normalized === 'stage' || normalized === 'prod') {
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
128
|
+
return 'dev';
|
|
129
|
+
}
|
|
130
|
+
export function isDailyActionPolicyCommand(value) {
|
|
131
|
+
return dailyActionPolicyCommands.includes(value);
|
|
132
|
+
}
|
|
133
|
+
export function isRestoreSnapshotDryRun(args) {
|
|
134
|
+
return args[0] === '--dry-run';
|
|
135
|
+
}
|
|
136
|
+
function denyMessage(kind, command, env) {
|
|
137
|
+
if (kind === 'destructive') {
|
|
138
|
+
return `Refusing destructive command '${command}' in WPMOO_ENV=${env}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
|
|
139
|
+
}
|
|
140
|
+
if (kind === 'stage-lifecycle') {
|
|
141
|
+
return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
|
|
142
|
+
}
|
|
143
|
+
return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
|
|
144
|
+
}
|
|
145
|
+
export function renderPolicyDenyMessage(deny) {
|
|
146
|
+
return denyMessage(deny.kind, deny.command, deny.env);
|
|
147
|
+
}
|
|
148
|
+
export function evaluateDailyActionPolicy(command, args, flags) {
|
|
149
|
+
const env = parseEnvironmentKind(flags.envName);
|
|
150
|
+
const policy = dailyActionPolicyTable[command];
|
|
151
|
+
const isDestructive = policy.isDestructive(args);
|
|
152
|
+
const isDryRunPreview = command === 'restore-snapshot' && isRestoreSnapshotDryRun(args);
|
|
153
|
+
const isAuditWorthy = policy.isAuditWorthy(args);
|
|
154
|
+
if (policy.requiresStageLifecycleApproval && env === 'stage' && !parseFlag(flags.allowStageLifecycle)) {
|
|
155
|
+
const deny = {
|
|
156
|
+
kind: 'stage-lifecycle',
|
|
157
|
+
command,
|
|
158
|
+
env,
|
|
159
|
+
requiredFlag: 'WPMOO_ALLOW_STAGE_LIFECYCLE',
|
|
160
|
+
requiredValue: '1',
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
allowed: false,
|
|
164
|
+
command,
|
|
165
|
+
env,
|
|
166
|
+
isDestructive,
|
|
167
|
+
isDryRunPreview,
|
|
168
|
+
isAuditWorthy,
|
|
169
|
+
deny,
|
|
170
|
+
message: renderPolicyDenyMessage(deny),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (policy.requiresProdLifecycleApproval && env === 'prod' && !parseFlag(flags.allowProdLifecycle)) {
|
|
174
|
+
const deny = {
|
|
175
|
+
kind: 'prod-lifecycle',
|
|
176
|
+
command,
|
|
177
|
+
env,
|
|
178
|
+
requiredFlag: 'WPMOO_ALLOW_PROD_LIFECYCLE',
|
|
179
|
+
requiredValue: '1',
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
allowed: false,
|
|
183
|
+
command,
|
|
184
|
+
env,
|
|
185
|
+
isDestructive,
|
|
186
|
+
isDryRunPreview,
|
|
187
|
+
isAuditWorthy,
|
|
188
|
+
deny,
|
|
189
|
+
message: renderPolicyDenyMessage(deny),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (isDestructive && (env === 'stage' || env === 'prod') && !parseFlag(flags.allowDestructive)) {
|
|
193
|
+
const deny = {
|
|
194
|
+
kind: 'destructive',
|
|
195
|
+
command,
|
|
196
|
+
env,
|
|
197
|
+
requiredFlag: 'WPMOO_ALLOW_DESTRUCTIVE',
|
|
198
|
+
requiredValue: '1',
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
allowed: false,
|
|
202
|
+
command,
|
|
203
|
+
env,
|
|
204
|
+
isDestructive,
|
|
205
|
+
isDryRunPreview,
|
|
206
|
+
isAuditWorthy,
|
|
207
|
+
deny,
|
|
208
|
+
message: renderPolicyDenyMessage(deny),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
allowed: true,
|
|
213
|
+
command,
|
|
214
|
+
env,
|
|
215
|
+
isDestructive,
|
|
216
|
+
isDryRunPreview,
|
|
217
|
+
isAuditWorthy,
|
|
218
|
+
};
|
|
219
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -24,7 +24,7 @@ Usage:
|
|
|
24
24
|
npx @wpmoo/toolkit doctor --json [--postgres]
|
|
25
25
|
npx @wpmoo/toolkit start
|
|
26
26
|
npx @wpmoo/toolkit stop
|
|
27
|
-
npx @wpmoo/toolkit logs [service]
|
|
27
|
+
npx @wpmoo/toolkit logs [service] [tail-lines]
|
|
28
28
|
npx @wpmoo/toolkit restart
|
|
29
29
|
npx @wpmoo/toolkit shell
|
|
30
30
|
npx @wpmoo/toolkit psql [db]
|
|
@@ -148,7 +148,7 @@ Task recipes:
|
|
|
148
148
|
npx @wpmoo/toolkit status
|
|
149
149
|
npx @wpmoo/toolkit doctor
|
|
150
150
|
npx @wpmoo/toolkit doctor --fix
|
|
151
|
-
npx @wpmoo/toolkit logs [service]
|
|
151
|
+
npx @wpmoo/toolkit logs [service] [tail-lines]
|
|
152
152
|
npx @wpmoo/toolkit restart
|
|
153
153
|
|
|
154
154
|
Machine-readable JSON output:
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
const sourceRepoTypes = ['private', 'oca', 'external'];
|
|
4
|
+
const sourceRepoBase = ['odoo', 'custom', 'src'];
|
|
5
|
+
const migrationFolders = ['migrations', 'migration'];
|
|
6
|
+
const versionedMigrationFiles = ['pre-migration.py', 'post-migration.py', 'end-migration.py'];
|
|
7
|
+
const scriptMigrationFiles = ['migrate.py', 'migration.py'];
|
|
8
|
+
async function isDirectory(path) {
|
|
9
|
+
try {
|
|
10
|
+
const entry = await lstat(path);
|
|
11
|
+
return entry.isDirectory() && !entry.isSymbolicLink();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function isFile(path) {
|
|
21
|
+
try {
|
|
22
|
+
const entry = await lstat(path);
|
|
23
|
+
return entry.isFile() && !entry.isSymbolicLink();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function listDirectoryNames(path) {
|
|
33
|
+
try {
|
|
34
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
35
|
+
const filtered = entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.startsWith('.'));
|
|
36
|
+
return filtered.map((entry) => entry.name).sort();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function listMigrationFiles(modulePath, migrationFolder) {
|
|
46
|
+
const found = [];
|
|
47
|
+
const versionRoot = join(modulePath, migrationFolder);
|
|
48
|
+
if (!(await isDirectory(versionRoot))) {
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
const versions = await listDirectoryNames(versionRoot);
|
|
52
|
+
for (const version of versions) {
|
|
53
|
+
const versionPath = join(versionRoot, version);
|
|
54
|
+
if (!(await isDirectory(versionPath))) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (const file of versionedMigrationFiles) {
|
|
58
|
+
const path = join(versionPath, file);
|
|
59
|
+
if (await isFile(path)) {
|
|
60
|
+
found.push(path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
async function scanModule(modulePath) {
|
|
67
|
+
const found = [];
|
|
68
|
+
for (const folder of migrationFolders) {
|
|
69
|
+
found.push(...(await listMigrationFiles(modulePath, folder)));
|
|
70
|
+
}
|
|
71
|
+
const scriptsPath = join(modulePath, 'scripts');
|
|
72
|
+
if (!(await isDirectory(scriptsPath))) {
|
|
73
|
+
return found.sort();
|
|
74
|
+
}
|
|
75
|
+
for (const migrationScript of scriptMigrationFiles) {
|
|
76
|
+
const candidate = join(scriptsPath, migrationScript);
|
|
77
|
+
if (await isFile(candidate)) {
|
|
78
|
+
found.push(candidate);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
export async function scanMigrationRisks(target) {
|
|
84
|
+
const root = resolve(target);
|
|
85
|
+
const foundPaths = [];
|
|
86
|
+
const srcRoot = join(root, ...sourceRepoBase);
|
|
87
|
+
for (const sourceType of sourceRepoTypes) {
|
|
88
|
+
const typeRoot = join(srcRoot, sourceType);
|
|
89
|
+
if (!(await isDirectory(typeRoot))) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const repoNames = await listDirectoryNames(typeRoot);
|
|
93
|
+
for (const repoName of repoNames) {
|
|
94
|
+
const repoPath = join(typeRoot, repoName);
|
|
95
|
+
if (!(await isDirectory(repoPath))) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const moduleNames = await listDirectoryNames(repoPath);
|
|
99
|
+
for (const moduleName of moduleNames) {
|
|
100
|
+
const modulePath = join(repoPath, moduleName);
|
|
101
|
+
const modulePaths = await scanModule(modulePath);
|
|
102
|
+
foundPaths.push(...modulePaths);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
foundPaths.sort();
|
|
107
|
+
return {
|
|
108
|
+
foundPaths,
|
|
109
|
+
count: foundPaths.length,
|
|
110
|
+
risk: foundPaths.length > 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/dist/safe-reset.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
1
3
|
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
4
|
import { basename, join } from 'node:path';
|
|
3
5
|
import { environmentMetadata, readEnvironmentMetadata } from './environment.js';
|
|
@@ -29,35 +31,258 @@ const safeResetProtectedGeneratedReadmes = new Set([
|
|
|
29
31
|
function isProtectedGeneratedFile(filePath) {
|
|
30
32
|
return safeResetProtectedGeneratedReadmes.has(filePath);
|
|
31
33
|
}
|
|
32
|
-
function
|
|
34
|
+
function mergeEnvironmentMetadataSync(target, options) {
|
|
33
35
|
const generated = environmentMetadata(options);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
.then((existing) => {
|
|
36
|
+
try {
|
|
37
|
+
const existing = parseMetadataFromPath(target);
|
|
37
38
|
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
|
38
39
|
return `${JSON.stringify(generated, null, 2)}\n`;
|
|
39
40
|
}
|
|
40
41
|
return `${JSON.stringify({ ...existing, ...generated, sourceRepos: generated.sourceRepos }, null, 2)}\n`;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return `${JSON.stringify(generated, null, 2)}\n`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function parseMetadataFromPath(target) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(join(target, '.wpmoo/odoo.json'), 'utf8');
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parseAddonsFile(target) {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(join(target, 'odoo/custom/src/addons.yaml'), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function parseGitmodules(target) {
|
|
69
|
+
try {
|
|
70
|
+
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
71
|
+
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
72
|
+
return sections.flatMap((section) => {
|
|
73
|
+
const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/([^\s]+)\s*$/m);
|
|
74
|
+
if (!pathMatch) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const source = section.match(/^\s*url\s*=\s*(.+)\s*$/m)?.[1]?.trim();
|
|
78
|
+
if (!source) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
const sourceType = pathMatch[1];
|
|
82
|
+
return [{ path: pathMatch[2], sourceType, url: source }];
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function inferOptionsForPreviewSync(target) {
|
|
90
|
+
const metadata = parseMetadataFromPath(target);
|
|
91
|
+
const addonsYaml = parseAddonsFile(target);
|
|
92
|
+
const gitmoduleSources = parseGitmodules(target);
|
|
93
|
+
const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
|
|
94
|
+
const sourceByKey = new Map();
|
|
95
|
+
const sourceReposFromMetadata = Array.isArray(metadata?.sourceRepos)
|
|
96
|
+
? metadata.sourceRepos
|
|
97
|
+
: [];
|
|
98
|
+
for (const repo of sourceReposFromMetadata) {
|
|
99
|
+
if (!repo?.path || !isValidPathSegment(repo.path)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
sourceByKey.set(`${repo.sourceType ?? 'private'}:${repo.path}`, {
|
|
103
|
+
sourceType: repo.sourceType ?? 'private',
|
|
104
|
+
path: validateRepoPath(repo.path),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const repo of gitmoduleSources) {
|
|
108
|
+
sourceByKey.set(`${repo.sourceType}:${repo.path}`, repo);
|
|
109
|
+
}
|
|
110
|
+
for (const repoPath of addonRepos) {
|
|
111
|
+
sourceByKey.set(`private:${repoPath}`, {
|
|
112
|
+
sourceType: 'private',
|
|
113
|
+
path: repoPath,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const sourceLocations = [...sourceByKey.values()].sort((left, right) => {
|
|
117
|
+
if (left.sourceType !== right.sourceType) {
|
|
118
|
+
return left.sourceType.localeCompare(right.sourceType);
|
|
119
|
+
}
|
|
120
|
+
return left.path.localeCompare(right.path);
|
|
121
|
+
});
|
|
122
|
+
const productFromMetadata = typeof metadata?.product === 'string' ? metadata.product : undefined;
|
|
123
|
+
const composeTemplateUrl = typeof metadata?.composeTemplateUrl === 'string' ? metadata.composeTemplateUrl : undefined;
|
|
124
|
+
const composeTemplateRef = typeof metadata?.composeTemplateRef === 'string' ? metadata.composeTemplateRef : undefined;
|
|
125
|
+
const agentSkillsTemplateUrl = typeof metadata?.agentSkillsTemplateUrl === 'string'
|
|
126
|
+
? metadata.agentSkillsTemplateUrl
|
|
127
|
+
: undefined;
|
|
128
|
+
const agentSkillsTemplateRef = typeof metadata?.agentSkillsTemplateRef === 'string'
|
|
129
|
+
? metadata.agentSkillsTemplateRef
|
|
130
|
+
: undefined;
|
|
131
|
+
const odooVersion = typeof metadata?.odooVersion === 'string' ? metadata.odooVersion : undefined;
|
|
132
|
+
const devRepo = typeof metadata?.devRepo === 'string' ? metadata.devRepo : undefined;
|
|
133
|
+
const devRepoUrl = typeof metadata?.devRepoUrl === 'string' ? metadata.devRepoUrl : undefined;
|
|
134
|
+
const postgresVersion = typeof metadata?.postgresVersion === 'string' ? metadata.postgresVersion : undefined;
|
|
135
|
+
const httpPort = typeof metadata?.httpPort === 'string' ? metadata.httpPort : undefined;
|
|
136
|
+
const geventPort = typeof metadata?.geventPort === 'string' ? metadata.geventPort : undefined;
|
|
137
|
+
const product = productFromMetadata && isValidPathSegment(productFromMetadata) ? productFromMetadata
|
|
138
|
+
: sourceLocations[0]?.path ?? titleFromTarget(target);
|
|
139
|
+
return {
|
|
140
|
+
product,
|
|
141
|
+
odooVersion: odooVersion ?? '19.0',
|
|
142
|
+
devRepo: devRepo ?? basename(target),
|
|
143
|
+
devRepoUrl: devRepoUrl ?? target,
|
|
144
|
+
sourceRepos: sourceLocations.map(({ sourceType, path }) => {
|
|
145
|
+
const metadataMatch = sourceReposFromMetadata.find((repo) => isValidPathSegment(repo.path ?? '') && validateRepoPath(repo.path ?? '') === path && (repo.sourceType ?? 'private') === sourceType);
|
|
146
|
+
const gitmoduleMatch = gitmoduleSources.find((repo) => repo.path === path && repo.sourceType === sourceType);
|
|
147
|
+
return {
|
|
148
|
+
sourceType,
|
|
149
|
+
path,
|
|
150
|
+
url: metadataMatch?.url?.trim() ||
|
|
151
|
+
gitmoduleMatch?.url ||
|
|
152
|
+
readSubmoduleUrlFromPath(target, path, sourceType),
|
|
153
|
+
addons: parseAddonsForRepo(addonsYaml, path),
|
|
154
|
+
};
|
|
155
|
+
}),
|
|
156
|
+
engine: 'compose',
|
|
157
|
+
composeTemplateUrl,
|
|
158
|
+
composeTemplateRef,
|
|
159
|
+
agentSkillsTemplateUrl,
|
|
160
|
+
agentSkillsTemplateRef,
|
|
161
|
+
postgresVersion,
|
|
162
|
+
httpPort,
|
|
163
|
+
geventPort,
|
|
164
|
+
target,
|
|
165
|
+
dryRun: false,
|
|
166
|
+
initEmptyRepos: false,
|
|
167
|
+
stage: false,
|
|
168
|
+
skipSubmodules: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function readSubmoduleUrlFromPath(target, repoPath, sourceType) {
|
|
172
|
+
try {
|
|
173
|
+
const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
|
|
174
|
+
const escapedPath = `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
175
|
+
const sections = gitmodules.split(/\n(?=\[submodule )/);
|
|
176
|
+
const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
|
|
177
|
+
const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
178
|
+
return url || `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return `odoo/custom/src/${sourceType}/${repoPath}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function safeResetTargetFileDiffs(scaffoldOptions, target) {
|
|
185
|
+
const files = generatedFiles(scaffoldOptions);
|
|
186
|
+
const candidates = [
|
|
187
|
+
...files,
|
|
188
|
+
{ path: '.env.example', content: renderComposeEnvExample(scaffoldOptions) },
|
|
189
|
+
{ path: '.wpmoo/odoo.json', content: mergeEnvironmentMetadataSync(target, scaffoldOptions) },
|
|
190
|
+
];
|
|
191
|
+
const changedPaths = candidates
|
|
192
|
+
.filter((file) => {
|
|
193
|
+
if (file.path === 'odoo/custom/src/addons.yaml' || isProtectedGeneratedFile(file.path)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const existing = readTextForPreview(target, file.path);
|
|
197
|
+
if (existing === undefined) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return existing !== file.content;
|
|
41
201
|
})
|
|
42
|
-
.
|
|
202
|
+
.map((file) => file.path);
|
|
203
|
+
return [...new Set(changedPaths)].sort();
|
|
204
|
+
}
|
|
205
|
+
function buildSafeResetSourceRepoLines(sourceRepos) {
|
|
206
|
+
const lines = [...new Set(sourceRepos.map((repo) => `${repo.sourceType ?? 'private'}/${repo.path}`))].sort();
|
|
207
|
+
if (lines.length === 0) {
|
|
208
|
+
return ['- (none detected)'];
|
|
209
|
+
}
|
|
210
|
+
return lines.map((repo) => `- ${repo}`);
|
|
211
|
+
}
|
|
212
|
+
function detectDirtyGeneratedFiles(target, candidatePaths) {
|
|
213
|
+
try {
|
|
214
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
215
|
+
cwd: target,
|
|
216
|
+
encoding: 'utf8',
|
|
217
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
218
|
+
});
|
|
219
|
+
if (!status.trim()) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const dirty = new Set();
|
|
223
|
+
const trackedCandidates = [...candidatePaths];
|
|
224
|
+
for (const entry of status.split(/\r?\n/)) {
|
|
225
|
+
if (!entry.trim()) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const pathSection = entry.slice(3).trim();
|
|
229
|
+
const normalized = pathSection.includes(' -> ') ? pathSection.split(' -> ')[1] ?? '' : pathSection;
|
|
230
|
+
if (!normalized) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (trackedCandidates.some((candidate) => normalized === candidate || normalized.startsWith(`${candidate}/`))) {
|
|
234
|
+
dirty.add(normalized);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return [...dirty].sort();
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function describeDirtyWarning(target, candidatePaths) {
|
|
244
|
+
const dirtyFiles = detectDirtyGeneratedFiles(target, candidatePaths);
|
|
245
|
+
if (dirtyFiles.length === 0) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
return [
|
|
249
|
+
'Warning: the following generated files are dirty and may be overwritten by safe reset:',
|
|
250
|
+
...dirtyFiles.map((path) => `- ${path}`),
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
function readTextForPreview(target, path) {
|
|
254
|
+
try {
|
|
255
|
+
return readFileSync(join(target, path), 'utf8');
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
43
260
|
}
|
|
44
261
|
export function renderSafeResetPreview(target, stage) {
|
|
262
|
+
const options = inferOptionsForPreviewSync(target);
|
|
263
|
+
const externalAssets = safeResetExternalAssetOptions(options);
|
|
264
|
+
const changedPaths = safeResetTargetFileDiffs(options, target);
|
|
265
|
+
const externalAssetLines = [];
|
|
266
|
+
if (externalAssets.some((asset) => asset.label === 'compose')) {
|
|
267
|
+
externalAssetLines.push('- External compose template assets');
|
|
268
|
+
}
|
|
269
|
+
if (externalAssets.some((asset) => asset.label === 'agent-skills')) {
|
|
270
|
+
externalAssetLines.push('- External agent skill assets when configured');
|
|
271
|
+
}
|
|
272
|
+
const generatedSection = changedPaths.length
|
|
273
|
+
? ['Generated files that would change:', ...changedPaths.map((file) => `- ${file}`)]
|
|
274
|
+
: ['Generated files that would change:', '- No generated files differ from the rendered safe-reset output.'];
|
|
45
275
|
return [
|
|
46
|
-
'Safe reset
|
|
276
|
+
'Safe reset preview (dry-run): generated WPMoo files and source repo protections are listed.',
|
|
47
277
|
'',
|
|
48
278
|
'Target:',
|
|
49
279
|
target,
|
|
50
280
|
'',
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'
|
|
54
|
-
'
|
|
55
|
-
|
|
56
|
-
'- README.md',
|
|
57
|
-
'- AGENTS.md',
|
|
58
|
-
'- docs/appstore-release.md',
|
|
59
|
-
'- External compose template assets',
|
|
60
|
-
'- External agent skill assets when configured',
|
|
281
|
+
...generatedSection,
|
|
282
|
+
...externalAssetLines,
|
|
283
|
+
'',
|
|
284
|
+
'Source repositories that will remain untouched:',
|
|
285
|
+
...buildSafeResetSourceRepoLines(options.sourceRepos),
|
|
61
286
|
'',
|
|
62
287
|
'Will not touch:',
|
|
63
288
|
'- source repo folders under odoo/custom/src/private',
|
|
@@ -67,6 +292,8 @@ export function renderSafeResetPreview(target, stage) {
|
|
|
67
292
|
'- custom source layout directories (oca, external, patches, manifests)',
|
|
68
293
|
'- Legacy compose template files may remain until manually removed: docs/assets/, test/, .github/',
|
|
69
294
|
'',
|
|
295
|
+
...describeDirtyWarning(target, changedPaths),
|
|
296
|
+
'',
|
|
70
297
|
'Preview-only output; files are not changed until reset is executed.',
|
|
71
298
|
'',
|
|
72
299
|
stage ? 'Generated changes will be staged with git add .' : 'Generated changes will not be staged.',
|
|
@@ -209,7 +436,7 @@ export async function safeResetEnvironment(options, git = realGit) {
|
|
|
209
436
|
for (const assetOptions of externalAssets) {
|
|
210
437
|
await applyExternalAsset(assetOptions, git);
|
|
211
438
|
}
|
|
212
|
-
await writeTextFile(join(options.target, '.wpmoo/odoo.json'),
|
|
439
|
+
await writeTextFile(join(options.target, '.wpmoo/odoo.json'), mergeEnvironmentMetadataSync(options.target, scaffoldOptions));
|
|
213
440
|
await writeTextFile(join(options.target, '.env.example'), renderComposeEnvExample(scaffoldOptions));
|
|
214
441
|
if (options.stage) {
|
|
215
442
|
await stageAll(git, options.target);
|
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, renderStatusScript, } from './templates.js';
|
|
7
|
+
import { renderAgents, renderAppstoreRelease, renderDoctorScript, 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) {
|
|
@@ -58,6 +58,7 @@ export function generatedFiles(options) {
|
|
|
58
58
|
{ path: markerPath, content: renderEnvironmentMetadata(safeOptions) },
|
|
59
59
|
{ path: 'moo', content: renderMooDelegationScript(), mode: 0o755 },
|
|
60
60
|
{ path: 'scripts/status.sh', content: renderStatusScript(), mode: 0o755 },
|
|
61
|
+
{ path: 'scripts/doctor.sh', content: renderDoctorScript(), mode: 0o755 },
|
|
61
62
|
{ path: '.gitignore', content: renderGitignore() },
|
|
62
63
|
{ path: 'README.md', content: renderReadme(safeOptions) },
|
|
63
64
|
{ path: 'AGENTS.md', content: renderAgents(safeOptions) },
|