@wpmoo/toolkit 0.9.24 → 0.9.25
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/cli.js +11 -1
- package/dist/cockpit/daily-prompts.js +15 -1
- package/dist/daily-actions.js +51 -10
- package/dist/databases.js +24 -0
- package/dist/help.js +2 -2
- 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 +108 -6
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -271,7 +271,17 @@ async function showStartup(argv, skipUpdateCheck, details) {
|
|
|
271
271
|
console.log();
|
|
272
272
|
}
|
|
273
273
|
async function selectCockpitCommandFromMenu(serviceStatus, moduleCount, sourceRepoCount) {
|
|
274
|
-
const
|
|
274
|
+
const legacyServiceStatus = serviceStatus.kind === 'services-running' ||
|
|
275
|
+
serviceStatus.kind === 'db-ready' ||
|
|
276
|
+
serviceStatus.kind === 'odoo-not-ready' ||
|
|
277
|
+
serviceStatus.kind === 'fully-ready'
|
|
278
|
+
? { kind: 'running' }
|
|
279
|
+
: serviceStatus;
|
|
280
|
+
const selection = await selectCockpitTopLevelMenu({
|
|
281
|
+
serviceStatus: legacyServiceStatus,
|
|
282
|
+
moduleCount,
|
|
283
|
+
sourceRepoCount,
|
|
284
|
+
});
|
|
275
285
|
if (selection.kind === 'exit') {
|
|
276
286
|
return 'exit';
|
|
277
287
|
}
|
|
@@ -83,6 +83,18 @@ async function optionalTextArg(deps, message, fallback) {
|
|
|
83
83
|
placeholder: fallback,
|
|
84
84
|
}), fallback, deps);
|
|
85
85
|
}
|
|
86
|
+
async function optionalTextArgOrUndefined(deps, message, placeholder) {
|
|
87
|
+
const value = await deps.text({
|
|
88
|
+
message: menuPromptMessage(message, 'back'),
|
|
89
|
+
placeholder,
|
|
90
|
+
});
|
|
91
|
+
deps.handleCancel(value, 'back');
|
|
92
|
+
if (typeof value !== 'string') {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const trimmed = value.trim();
|
|
96
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
97
|
+
}
|
|
86
98
|
async function databaseArg(cwd, deps, message, fallback, options = {}) {
|
|
87
99
|
const databaseResult = normalizeDatabaseListResult(await deps.databases(cwd, options));
|
|
88
100
|
const databases = databaseResult.databases;
|
|
@@ -139,7 +151,9 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
|
|
|
139
151
|
return [];
|
|
140
152
|
}
|
|
141
153
|
if (command === 'logs') {
|
|
142
|
-
|
|
154
|
+
const service = await optionalTextArg(deps, 'Service', 'odoo');
|
|
155
|
+
const tail = await optionalTextArgOrUndefined(deps, 'Tail line count (optional)', '100');
|
|
156
|
+
return tail ? [service, tail] : [service];
|
|
143
157
|
}
|
|
144
158
|
if (command === 'psql') {
|
|
145
159
|
return [await databaseArg(cwd, deps, 'Database', 'postgres', { includeMaintenance: true })];
|
package/dist/daily-actions.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { access } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
5
|
+
import { normalizeDatabaseName } from './databases.js';
|
|
5
6
|
import { markerPath } from './environment.js';
|
|
6
7
|
export const dailyActionCommands = [
|
|
7
8
|
'start',
|
|
@@ -49,7 +50,7 @@ function usage(command) {
|
|
|
49
50
|
if (command === 'stop')
|
|
50
51
|
return 'Usage: wpmoo stop';
|
|
51
52
|
if (command === 'logs')
|
|
52
|
-
return 'Usage: wpmoo logs [service]';
|
|
53
|
+
return 'Usage: wpmoo logs [service] [tail-lines]';
|
|
53
54
|
if (command === 'restart')
|
|
54
55
|
return 'Usage: wpmoo restart';
|
|
55
56
|
if (command === 'shell')
|
|
@@ -86,7 +87,7 @@ function moduleArgs(command, argv) {
|
|
|
86
87
|
const [modules, db, ...rest] = argv;
|
|
87
88
|
if (!modules || modules.startsWith('-') || rest.length > 0)
|
|
88
89
|
throw new Error(usage(command));
|
|
89
|
-
return db ? [modules, db] : [modules];
|
|
90
|
+
return db ? [modules, normalizeDatabaseName(db)] : [modules];
|
|
90
91
|
}
|
|
91
92
|
function positionalArgs(command, argv, min, max) {
|
|
92
93
|
if (argv.length < min || argv.length > max || argv.some((arg) => arg.startsWith('-'))) {
|
|
@@ -94,6 +95,32 @@ function positionalArgs(command, argv, min, max) {
|
|
|
94
95
|
}
|
|
95
96
|
return argv;
|
|
96
97
|
}
|
|
98
|
+
function logsArgs(argv) {
|
|
99
|
+
if (argv.length > 2 || argv.some((arg) => arg.startsWith('-'))) {
|
|
100
|
+
throw new Error(usage('logs'));
|
|
101
|
+
}
|
|
102
|
+
const [service = 'odoo', tail] = argv;
|
|
103
|
+
if (tail === undefined) {
|
|
104
|
+
return [service];
|
|
105
|
+
}
|
|
106
|
+
if (!/^[1-9][0-9]*$/u.test(tail)) {
|
|
107
|
+
throw new Error('Invalid logs tail count: expected a positive integer.');
|
|
108
|
+
}
|
|
109
|
+
return [service, tail];
|
|
110
|
+
}
|
|
111
|
+
function validateDatabaseArg(args, index) {
|
|
112
|
+
if (args[index] === undefined) {
|
|
113
|
+
return args;
|
|
114
|
+
}
|
|
115
|
+
const nextArgs = [...args];
|
|
116
|
+
nextArgs[index] = normalizeDatabaseName(nextArgs[index]);
|
|
117
|
+
return nextArgs;
|
|
118
|
+
}
|
|
119
|
+
function rejectLeadingHyphenDatabaseArg(args) {
|
|
120
|
+
if (args[0]?.startsWith('-')) {
|
|
121
|
+
normalizeDatabaseName(args[0]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
97
124
|
function restoreSnapshotArgs(argv) {
|
|
98
125
|
const args = [...argv];
|
|
99
126
|
const dryRun = args[0] === '--dry-run';
|
|
@@ -103,7 +130,8 @@ function restoreSnapshotArgs(argv) {
|
|
|
103
130
|
if (args.length < 1 || args.length > 2 || args.some((arg) => arg.startsWith('-'))) {
|
|
104
131
|
throw new Error(usage('restore-snapshot'));
|
|
105
132
|
}
|
|
106
|
-
|
|
133
|
+
const validatedArgs = args.length === 2 ? validateDatabaseArg(args, 1) : args;
|
|
134
|
+
return dryRun ? ['--dry-run', ...validatedArgs] : validatedArgs;
|
|
107
135
|
}
|
|
108
136
|
function testArgs(argv) {
|
|
109
137
|
const [modules, ...rest] = argv;
|
|
@@ -119,6 +147,9 @@ function testArgs(argv) {
|
|
|
119
147
|
if (option === '--mode' && value !== 'auto' && value !== 'init' && value !== 'update') {
|
|
120
148
|
throw new Error('Invalid value for --mode: expected auto, init, or update');
|
|
121
149
|
}
|
|
150
|
+
if (option === '--db') {
|
|
151
|
+
normalizeDatabaseName(value);
|
|
152
|
+
}
|
|
122
153
|
index += 1;
|
|
123
154
|
}
|
|
124
155
|
return argv;
|
|
@@ -129,26 +160,30 @@ function scriptArgs(command, argv) {
|
|
|
129
160
|
if (command === 'stop')
|
|
130
161
|
return ensureNoArgs(command, argv);
|
|
131
162
|
if (command === 'logs')
|
|
132
|
-
return
|
|
163
|
+
return logsArgs(argv);
|
|
133
164
|
if (command === 'restart')
|
|
134
165
|
return ensureNoArgs(command, argv);
|
|
135
166
|
if (command === 'shell')
|
|
136
167
|
return ensureNoArgs(command, argv);
|
|
137
168
|
if (command === 'psql')
|
|
138
|
-
return optionalSingleArg(command, argv, 'postgres');
|
|
169
|
+
return optionalSingleArg(command, argv, 'postgres').map(normalizeDatabaseName);
|
|
139
170
|
if (command === 'install' || command === 'update')
|
|
140
171
|
return moduleArgs(command, argv);
|
|
141
172
|
if (command === 'test')
|
|
142
173
|
return testArgs(argv);
|
|
143
|
-
if (command === 'resetdb')
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
if (command === 'resetdb') {
|
|
175
|
+
rejectLeadingHyphenDatabaseArg(argv);
|
|
176
|
+
return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
|
|
177
|
+
}
|
|
178
|
+
if (command === 'snapshot') {
|
|
179
|
+
rejectLeadingHyphenDatabaseArg(argv);
|
|
180
|
+
return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
|
|
181
|
+
}
|
|
147
182
|
if (command === 'restore-snapshot')
|
|
148
183
|
return restoreSnapshotArgs(argv);
|
|
149
184
|
if (command === 'lint')
|
|
150
185
|
return ensureNoArgs(command, argv);
|
|
151
|
-
return positionalArgs(command, argv, 1, 3);
|
|
186
|
+
return validateDatabaseArg(positionalArgs(command, argv, 1, 3), 1);
|
|
152
187
|
}
|
|
153
188
|
function isDestructiveCommand(command, args) {
|
|
154
189
|
if (command === 'resetdb')
|
|
@@ -263,6 +298,12 @@ function renderDailyActionOutputLine(line) {
|
|
|
263
298
|
if (line === "Running as user 'root' is a security risk.") {
|
|
264
299
|
return `${ANSI_DIM_INFO}${line}${ANSI_RESET}`;
|
|
265
300
|
}
|
|
301
|
+
if (line.includes('psycopg2.OperationalError')) {
|
|
302
|
+
return [
|
|
303
|
+
line,
|
|
304
|
+
`${ANSI_DIM_INFO}NOTE: PostgreSQL connection failed. Check ./moo status, database service readiness, and credentials before retrying.${ANSI_RESET}`,
|
|
305
|
+
].join('\n');
|
|
306
|
+
}
|
|
266
307
|
return line;
|
|
267
308
|
}
|
|
268
309
|
export function renderDailyActionOutput(output) {
|
package/dist/databases.js
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
const maintenanceDatabases = new Set(['postgres']);
|
|
3
|
+
const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
|
|
4
|
+
export function isValidDatabaseName(value) {
|
|
5
|
+
const normalized = value.trim();
|
|
6
|
+
return (normalized.length > 0 &&
|
|
7
|
+
!normalized.startsWith('-') &&
|
|
8
|
+
databaseNamePattern.test(normalized) &&
|
|
9
|
+
!/\s/u.test(value));
|
|
10
|
+
}
|
|
11
|
+
export function normalizeDatabaseName(value) {
|
|
12
|
+
const normalized = value.trim();
|
|
13
|
+
if (!normalized) {
|
|
14
|
+
throw new Error('Invalid database name: value is required.');
|
|
15
|
+
}
|
|
16
|
+
if (/\s/u.test(value)) {
|
|
17
|
+
throw new Error('Invalid database name: whitespace is not allowed.');
|
|
18
|
+
}
|
|
19
|
+
if (normalized.startsWith('-')) {
|
|
20
|
+
throw new Error('Invalid database name: leading hyphens are not allowed.');
|
|
21
|
+
}
|
|
22
|
+
if (!databaseNamePattern.test(normalized)) {
|
|
23
|
+
throw new Error('Invalid database name: use letters, digits, underscores, dots, or hyphens without shell metacharacters or path characters.');
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
3
27
|
const listDatabasesQuery = [
|
|
4
28
|
'SELECT datname',
|
|
5
29
|
'FROM pg_database',
|
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:
|
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) },
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
const odooHttpReadyUrl = 'http://127.0.0.1:8069';
|
|
3
|
+
const odooHttpProbeTimeoutMs = 1_000;
|
|
2
4
|
function run(command, args, options) {
|
|
3
5
|
return new Promise((resolve, reject) => {
|
|
4
6
|
execFile(command, args, { cwd: options.cwd }, (error, stdout) => {
|
|
@@ -10,11 +12,36 @@ function run(command, args, options) {
|
|
|
10
12
|
});
|
|
11
13
|
});
|
|
12
14
|
}
|
|
15
|
+
async function fetchOdooHttpReady() {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timeout = setTimeout(() => controller.abort(), odooHttpProbeTimeoutMs);
|
|
18
|
+
try {
|
|
19
|
+
return await fetch(odooHttpReadyUrl, { signal: controller.signal });
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
clearTimeout(timeout);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
13
25
|
export function renderServiceRuntimeStatusLine(status) {
|
|
14
|
-
if (status.kind === 'running'
|
|
26
|
+
if (status.kind === 'running' ||
|
|
27
|
+
status.kind === 'services-running' ||
|
|
28
|
+
status.kind === 'odoo-not-ready' ||
|
|
29
|
+
status.kind === 'fully-ready' ||
|
|
30
|
+
status.kind === 'db-ready') {
|
|
31
|
+
if (status.kind === 'db-ready') {
|
|
32
|
+
return 'Status: ● DB ready';
|
|
33
|
+
}
|
|
34
|
+
if (status.kind === 'odoo-not-ready') {
|
|
35
|
+
return 'Status: ● Odoo not ready';
|
|
36
|
+
}
|
|
37
|
+
if (status.kind === 'fully-ready') {
|
|
38
|
+
return 'Status: ● Fully ready';
|
|
39
|
+
}
|
|
15
40
|
return 'Status: ● Services running';
|
|
16
|
-
|
|
41
|
+
}
|
|
42
|
+
if (status.kind === 'docker-not-running') {
|
|
17
43
|
return 'Status: ● Docker not running';
|
|
44
|
+
}
|
|
18
45
|
return 'Status: ● Services stopped';
|
|
19
46
|
}
|
|
20
47
|
export async function getServiceRuntimeStatus(target, environmentStatus, runner = run) {
|
|
@@ -44,5 +71,40 @@ export async function getServiceRuntimeStatus(target, environmentStatus, runner
|
|
|
44
71
|
catch {
|
|
45
72
|
return { kind: 'stopped' };
|
|
46
73
|
}
|
|
47
|
-
|
|
74
|
+
if (!result.stdout.trim()) {
|
|
75
|
+
return { kind: 'stopped' };
|
|
76
|
+
}
|
|
77
|
+
const runningServices = result.stdout
|
|
78
|
+
.trim()
|
|
79
|
+
.split('\n')
|
|
80
|
+
.map((service) => service.trim())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
if (!runningServices.includes('db')) {
|
|
83
|
+
return { kind: 'services-running' };
|
|
84
|
+
}
|
|
85
|
+
const dbProbeArgs = [
|
|
86
|
+
'compose',
|
|
87
|
+
...environmentStatus.composeFiles.flatMap((file) => ['-f', file]),
|
|
88
|
+
'exec',
|
|
89
|
+
'-T',
|
|
90
|
+
'db',
|
|
91
|
+
'pg_isready',
|
|
92
|
+
'-U',
|
|
93
|
+
'odoo',
|
|
94
|
+
'-d',
|
|
95
|
+
'postgres',
|
|
96
|
+
];
|
|
97
|
+
try {
|
|
98
|
+
await runner('docker', dbProbeArgs, { cwd: target });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { kind: 'services-running' };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetchOdooHttpReady();
|
|
105
|
+
return response.ok ? { kind: 'fully-ready' } : { kind: 'odoo-not-ready' };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { kind: 'db-ready' };
|
|
109
|
+
}
|
|
48
110
|
}
|
package/dist/templates.js
CHANGED
|
@@ -226,8 +226,8 @@ exposes them through \`/mnt/wpmoo-addons\`.
|
|
|
226
226
|
\`./moo\` routes day-to-day service and module workflows to local scripts in
|
|
227
227
|
\`./scripts/\` (for example \`start\`, \`logs\`, \`update\`, \`test\`, \`snapshot\`).
|
|
228
228
|
\`./moo status\` runs local offline metadata checks without needing network access.
|
|
229
|
-
\`./moo doctor\`
|
|
230
|
-
\`npx --yes ${fallbackPackageSpec()} doctor\`.
|
|
229
|
+
\`./moo doctor\` runs local checks first and uses the package fallback only for
|
|
230
|
+
advanced usage (for example \`--help\`) via \`npx --yes ${fallbackPackageSpec()} doctor\`.
|
|
231
231
|
|
|
232
232
|
### Start And Inspect Services
|
|
233
233
|
|
|
@@ -449,7 +449,7 @@ usage() {
|
|
|
449
449
|
case "$1" in
|
|
450
450
|
"start") echo "Usage: ./moo start" ;;
|
|
451
451
|
"stop") echo "Usage: ./moo stop" ;;
|
|
452
|
-
"logs") echo "Usage: ./moo logs [service]" ;;
|
|
452
|
+
"logs") echo "Usage: ./moo logs [service] [tail-lines]" ;;
|
|
453
453
|
"restart") echo "Usage: ./moo restart" ;;
|
|
454
454
|
"shell") echo "Usage: ./moo shell" ;;
|
|
455
455
|
"psql") echo "Usage: ./moo psql [db]" ;;
|
|
@@ -657,7 +657,14 @@ case "$command" in
|
|
|
657
657
|
fi
|
|
658
658
|
run_package_command "$command" "$@"
|
|
659
659
|
;;
|
|
660
|
-
"
|
|
660
|
+
"doctor")
|
|
661
|
+
shift
|
|
662
|
+
if [[ "$#" -eq 0 && -x ./scripts/doctor.sh ]]; then
|
|
663
|
+
run_script ./scripts/doctor.sh
|
|
664
|
+
fi
|
|
665
|
+
run_package_command "$command" "$@"
|
|
666
|
+
;;
|
|
667
|
+
"create"|"add-repo"|"remove-repo"|"add-module"|"remove-module"|"source"|"reset")
|
|
661
668
|
run_package_command "$@"
|
|
662
669
|
;;
|
|
663
670
|
"start")
|
|
@@ -672,7 +679,17 @@ case "$command" in
|
|
|
672
679
|
;;
|
|
673
680
|
"logs")
|
|
674
681
|
shift
|
|
675
|
-
|
|
682
|
+
if [[ "$#" -gt 2 || "\${1:-}" == -* || "\${2:-}" == -* ]]; then
|
|
683
|
+
fail_usage "$command"
|
|
684
|
+
fi
|
|
685
|
+
service="\${1:-odoo}"
|
|
686
|
+
if [[ "$#" -eq 2 ]]; then
|
|
687
|
+
if [[ ! "$2" =~ ^[1-9][0-9]*$ ]]; then
|
|
688
|
+
echo "Invalid logs tail count: expected a positive integer." >&2
|
|
689
|
+
exit 2
|
|
690
|
+
fi
|
|
691
|
+
run_script ./scripts/logs.sh "$service" "$2"
|
|
692
|
+
fi
|
|
676
693
|
run_script ./scripts/logs.sh "$service"
|
|
677
694
|
;;
|
|
678
695
|
"restart")
|
|
@@ -753,6 +770,91 @@ case "$command" in
|
|
|
753
770
|
esac
|
|
754
771
|
`;
|
|
755
772
|
}
|
|
773
|
+
export function renderDoctorScript() {
|
|
774
|
+
return `#!/usr/bin/env bash
|
|
775
|
+
set -euo pipefail
|
|
776
|
+
|
|
777
|
+
script_dir="$(cd -- "$(dirname -- "\${BASH_SOURCE[0]}")" && pwd)"
|
|
778
|
+
root_dir="$(cd -- "$script_dir/.." && pwd)"
|
|
779
|
+
cd "$root_dir"
|
|
780
|
+
|
|
781
|
+
echo "WPMoo doctor"
|
|
782
|
+
|
|
783
|
+
issues=()
|
|
784
|
+
warnings=()
|
|
785
|
+
|
|
786
|
+
required_files=(
|
|
787
|
+
"moo"
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
required_scripts=(
|
|
791
|
+
"up.sh"
|
|
792
|
+
"down.sh"
|
|
793
|
+
"logs.sh"
|
|
794
|
+
"restart.sh"
|
|
795
|
+
"shell.sh"
|
|
796
|
+
"psql.sh"
|
|
797
|
+
"install.sh"
|
|
798
|
+
"update.sh"
|
|
799
|
+
"test.sh"
|
|
800
|
+
"resetdb.sh"
|
|
801
|
+
"snapshot.sh"
|
|
802
|
+
"restore-snapshot.sh"
|
|
803
|
+
"lint.sh"
|
|
804
|
+
"pot.sh"
|
|
805
|
+
"status.sh"
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
for file in "\${required_files[@]}"; do
|
|
809
|
+
if [[ ! -f "$file" ]]; then
|
|
810
|
+
issues+=("missing required file: $file")
|
|
811
|
+
fi
|
|
812
|
+
done
|
|
813
|
+
|
|
814
|
+
for script in "\${required_scripts[@]}"; do
|
|
815
|
+
script_path="scripts/$script"
|
|
816
|
+
if [[ ! -f "$script_path" ]]; then
|
|
817
|
+
issues+=("missing required script: $script_path")
|
|
818
|
+
continue
|
|
819
|
+
fi
|
|
820
|
+
if [[ ! -x "$script_path" ]]; then
|
|
821
|
+
issues+=("not executable: $script_path")
|
|
822
|
+
fi
|
|
823
|
+
done
|
|
824
|
+
|
|
825
|
+
if [[ ! -d scripts ]]; then
|
|
826
|
+
issues+=("missing scripts directory")
|
|
827
|
+
fi
|
|
828
|
+
|
|
829
|
+
if [[ ! -d odoo/custom/src ]]; then
|
|
830
|
+
warnings+=("odoo/custom/src is missing; add source repositories before running module workflows.")
|
|
831
|
+
fi
|
|
832
|
+
|
|
833
|
+
if [[ ! -f .wpmoo/odoo.json ]]; then
|
|
834
|
+
warnings+=("missing .wpmoo/odoo.json; run ./moo reset to initialize environment metadata.")
|
|
835
|
+
fi
|
|
836
|
+
|
|
837
|
+
if (( \${#issues[@]} > 0 )); then
|
|
838
|
+
echo "Doctor checks found issues."
|
|
839
|
+
for issue in "\${issues[@]}"; do
|
|
840
|
+
echo " - $issue"
|
|
841
|
+
done
|
|
842
|
+
if (( \${#warnings[@]} > 0 )); then
|
|
843
|
+
for warning in "\${warnings[@]}"; do
|
|
844
|
+
echo " - warning: $warning"
|
|
845
|
+
done
|
|
846
|
+
fi
|
|
847
|
+
exit 1
|
|
848
|
+
fi
|
|
849
|
+
|
|
850
|
+
echo "Doctor checks passed."
|
|
851
|
+
if (( \${#warnings[@]} > 0 )); then
|
|
852
|
+
for warning in "\${warnings[@]}"; do
|
|
853
|
+
echo " - warning: $warning"
|
|
854
|
+
done
|
|
855
|
+
fi
|
|
856
|
+
`;
|
|
857
|
+
}
|
|
756
858
|
export function renderStatusScript() {
|
|
757
859
|
return `#!/usr/bin/env bash
|
|
758
860
|
set -euo pipefail
|
|
@@ -1360,7 +1462,7 @@ Useful maintenance commands:
|
|
|
1360
1462
|
Daily script delegation vs package fallback:
|
|
1361
1463
|
- \`./moo start\`, \`logs\`, \`install\`, \`update\`, \`test\`, \`snapshot\`, and related runtime tasks delegate to local \`./scripts/*.sh\`.
|
|
1362
1464
|
- \`./moo status\` runs local offline metadata checks through \`./scripts/status.sh\`.
|
|
1363
|
-
- \`./moo doctor\`
|
|
1465
|
+
- \`./moo doctor\` runs local checks first and uses package fallback for advanced usage, routed via \`npx --yes ${fallbackPackageSpec()} doctor\`.
|
|
1364
1466
|
|
|
1365
1467
|
Only report completion after the relevant update/test/lint command exits cleanly.
|
|
1366
1468
|
`;
|