@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 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 selection = await selectCockpitTopLevelMenu({ serviceStatus, moduleCount, sourceRepoCount });
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
- return [await optionalTextArg(deps, 'Service', 'odoo')];
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 })];
@@ -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
- return dryRun ? ['--dry-run', ...args] : args;
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 optionalSingleArg(command, argv, 'odoo');
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
- return positionalArgs(command, argv, 0, 2);
145
- if (command === 'snapshot')
146
- return positionalArgs(command, argv, 0, 2);
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:
@@ -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 mergeEnvironmentMetadata(target, options) {
34
+ function mergeEnvironmentMetadataSync(target, options) {
33
35
  const generated = environmentMetadata(options);
34
- return readFile(join(target, '.wpmoo/odoo.json'), 'utf8')
35
- .then((content) => JSON.parse(content))
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
- .catch(() => `${JSON.stringify(generated, null, 2)}\n`);
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 will refresh generated WPMoo environment files.',
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
- 'Will update:',
52
- '- .wpmoo/odoo.json',
53
- '- moo',
54
- '- .gitignore',
55
- '- .env.example',
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'), await mergeEnvironmentMetadata(options.target, scaffoldOptions));
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
- if (status.kind === 'docker-not-running')
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
- return result.stdout.trim() ? { kind: 'running' } : { kind: 'stopped' };
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\` remains the package fallback command and runs via
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
- "create"|"add-repo"|"remove-repo"|"add-module"|"remove-module"|"source"|"reset"|"doctor")
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
- service="$(optional_single_arg "$command" "odoo" "$@")"
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\` remains a package fallback command routed to \`npx --yes ${fallbackPackageSpec()} 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
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.24",
3
+ "version": "0.9.25",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {