@wpmoo/toolkit 0.9.22 → 0.9.24

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
@@ -20,7 +20,8 @@ import { getDoctorReport, runDoctor } from './doctor.js';
20
20
  import { getOriginUrl, realGit } from './git.js';
21
21
  import { renderHelp } from './help.js';
22
22
  import { runLocalCockpit } from './local-cockpit.js';
23
- import { addModuleToSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
23
+ import { addModuleToSourceRepo, listModulesInEnvironment, removeModuleFromSourceRepo, } from './module-actions.js';
24
+ import { resolveModuleTarget } from './module-target-resolver.js';
24
25
  import { supportedOdooVersions } from './odoo-versions.js';
25
26
  import { renderRepositorySetupNote } from './prompt-copy.js';
26
27
  import { promptRepositoryUrl } from './prompt-repositories.js';
@@ -38,7 +39,7 @@ import { confirmPrompt, introPrompt, isPromptCancel, notePrompt, outroPrompt, se
38
39
  import { renderBanner } from './templates.js';
39
40
  import { checkForUpdate, isUpdateCheckSkipped, restartCli } from './update-check.js';
40
41
  import { packageName, packageVersion, renderVersion, renderVersionTag } from './version.js';
41
- import { environmentStatusJson, getEnvironmentStatus, renderEnvironmentStatusForTarget, renderEnvironmentStatusSummary, } from './status.js';
42
+ import { environmentStatusJson, getEnvironmentStatus, environmentBannerSummaryLine, renderEnvironmentStatusForTarget, } from './status.js';
42
43
  import { getGitHubAccounts, getGitHubRepositoryStatus, githubRepositoryUrl, realGitHub, createGitHubRepository, } from './github.js';
43
44
  import { environmentGitHubOwner } from './environment-context.js';
44
45
  import { handlePromptCancel, handleUnavailableMenuChoice, installPromptCancelKeyTracker, isMenuBackSignal, MenuBackSignal, menuIntroTitle, menuPromptMessage, } from './menu-navigation.js';
@@ -184,27 +185,16 @@ function validateRepoName(value) {
184
185
  function startupVersionLine(latestVersion) {
185
186
  return `v${packageVersion()}${latestVersion ? ` -> v${latestVersion} available` : ''}`;
186
187
  }
187
- function pluralize(count, singular, plural) {
188
- return `${count} ${count === 1 ? singular : plural}`;
189
- }
190
- function renderStartupEnvironmentLine(status) {
191
- if (status.kind !== 'environment') {
192
- return `Environment: ${renderEnvironmentStatusSummary(status)}`;
193
- }
194
- const issueCount = status.composeErrors.length + status.invalidSourceRepoPaths.length + status.missingCoreFiles.length;
195
- const issueSuffix = issueCount > 0 ? ` · ${pluralize(issueCount, 'issue', 'issues')}` : '';
196
- return [
197
- `Environment: Odoo ${status.odooVersion}`,
198
- pluralize(status.sourceRepoCount, 'repo', 'repos'),
199
- pluralize(status.moduleCandidateCount, 'module', 'modules'),
200
- ].join(' · ') + issueSuffix;
201
- }
202
188
  function renderStartupBanner(details, latestVersion) {
203
189
  const versionLine = startupVersionLine(latestVersion);
204
190
  return renderBanner(details?.(versionLine), details ? { version: versionLine } : undefined);
205
191
  }
206
192
  function renderCockpitStatusLines(status, serviceStatus, lastStatus) {
207
- return [renderStartupEnvironmentLine(status), renderServiceRuntimeStatusLine(serviceStatus), lastStatus];
193
+ return [
194
+ environmentBannerSummaryLine(status),
195
+ renderServiceRuntimeStatusLine(serviceStatus),
196
+ lastStatus,
197
+ ];
208
198
  }
209
199
  function renderLastCommandStatus(command) {
210
200
  return `Last: ${command.label} ✓ completed`;
@@ -1120,6 +1110,46 @@ function dailyActionSelectedLabel(command, argv) {
1120
1110
  }
1121
1111
  return undefined;
1122
1112
  }
1113
+ function dailyActionModuleArgIndex(command) {
1114
+ return ['install', 'update', 'test', 'pot'].includes(command) ? 0 : undefined;
1115
+ }
1116
+ function moduleTargetLabel(module) {
1117
+ return `${module.moduleName} (${module.sourceType}/${module.repoPath})`;
1118
+ }
1119
+ function moduleTargetResolutionError(resolution) {
1120
+ const candidates = resolution.candidates.map(moduleTargetLabel).join(', ');
1121
+ if (resolution.kind === 'ambiguous') {
1122
+ return new Error(`Ambiguous module target "${resolution.query}": ${candidates}.`);
1123
+ }
1124
+ return new Error(candidates
1125
+ ? `No module matches "${resolution.query}". Did you mean: ${candidates}?`
1126
+ : `No module matches "${resolution.query}".`);
1127
+ }
1128
+ async function resolveDailyActionModuleTargets(command, argv, cwd) {
1129
+ const moduleArgIndex = dailyActionModuleArgIndex(command);
1130
+ if (moduleArgIndex === undefined) {
1131
+ return [...argv];
1132
+ }
1133
+ const moduleArg = argv[moduleArgIndex];
1134
+ if (!moduleArg || moduleArg.startsWith('-')) {
1135
+ return [...argv];
1136
+ }
1137
+ const modules = await listModulesInEnvironment(cwd);
1138
+ if (modules.length === 0) {
1139
+ return [...argv];
1140
+ }
1141
+ const resolvedModuleNames = moduleArg.split(',').map((query) => {
1142
+ const trimmedQuery = query.trim();
1143
+ const resolution = resolveModuleTarget(trimmedQuery, modules);
1144
+ if (resolution.kind !== 'exact') {
1145
+ throw moduleTargetResolutionError(resolution);
1146
+ }
1147
+ return resolution.module.moduleName;
1148
+ });
1149
+ const resolvedArgv = [...argv];
1150
+ resolvedArgv[moduleArgIndex] = resolvedModuleNames.join(',');
1151
+ return resolvedArgv;
1152
+ }
1123
1153
  async function selectDatabaseArg(cwd, message, fallback, options = {}) {
1124
1154
  const databaseResult = normalizeDatabaseListResult(await listEnvironmentDatabases(cwd, options));
1125
1155
  const databases = databaseResult.databases;
@@ -1596,7 +1626,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1596
1626
  }
1597
1627
  if (isDailyActionCommand(route.command)) {
1598
1628
  console.log(renderBanner());
1599
- await runDailyAction(route.command, route.argv, cwd);
1629
+ await runDailyAction(route.command, await resolveDailyActionModuleTargets(route.command, route.argv, cwd), cwd);
1600
1630
  return;
1601
1631
  }
1602
1632
  const options = optionsFromArgs(route.argv);
@@ -29,41 +29,40 @@ function internalCommand(id, category, label, description, aliases = []) {
29
29
  };
30
30
  }
31
31
  export const cockpitCommands = [
32
- dailyCommand('start', 'services', 'Start services', 'Start the Odoo development services.', ['up', 'compose up']),
33
- dailyCommand('stop', 'services', 'Stop services', 'Stop the Odoo development services.', ['down', 'compose down']),
34
- dailyCommand('restart', 'services', 'Restart services', 'Restart the Odoo development services.', ['reload']),
35
- dailyCommand('logs', 'services', 'View logs', 'Stream logs for an Odoo environment service.', ['log', 'tail']),
36
- dailyCommand('shell', 'services', 'Open shell', 'Open a shell inside the Odoo service container.', ['bash', 'terminal']),
32
+ dailyCommand('start', 'services', 'Start services', 'Start Odoo services.', ['up', 'compose up']),
33
+ dailyCommand('stop', 'services', 'Stop services', 'Stop Odoo services.', ['down', 'compose down']),
34
+ dailyCommand('restart', 'services', 'Restart services', 'Restart Odoo services.', ['reload']),
35
+ dailyCommand('logs', 'services', 'View logs', 'Tail service logs.', ['log', 'tail']),
36
+ dailyCommand('shell', 'services', 'Open shell', 'Open a service shell.', ['bash', 'terminal']),
37
37
  internalCommand('list-modules', 'modules', 'List modules', 'Browse detected Odoo modules by source category.', [
38
38
  'modules list',
39
39
  'browse modules',
40
+ '/module',
41
+ 'module',
40
42
  ]),
41
- dailyCommand('install', 'modules', 'Install module', 'Install one or more Odoo modules into a database.', ['install module']),
42
- dailyCommand('update', 'modules', 'Update module', 'Update one or more Odoo modules in a database.', ['upgrade']),
43
- dailyCommand('test', 'modules', 'Run tests', 'Run Odoo tests for one or more modules.', ['tests', 'pytest']),
44
- dailyCommand('lint', 'modules', 'Run environment lint', 'Run the configured environment lint checks.', ['check', 'quality']),
45
- dailyCommand('pot', 'modules', 'Generate POT', 'Generate translation template files for a module.', ['translation', 'i18n']),
46
- dailyCommand('psql', 'database', 'Open psql', 'Open a PostgreSQL prompt for an environment database.', ['postgres', 'sql']),
47
- dailyCommand('snapshot', 'database', 'Create snapshot', 'Create a database snapshot.', ['backup', 'dump']),
48
- dailyCommand('restore-snapshot', 'database', 'Restore snapshot', 'Restore a database from a named snapshot.', ['restore', 'snapshot restore']),
49
- dailyCommand('resetdb', 'database', 'Reset database', 'Reset an environment database.', ['reset db', 'database reset']),
43
+ dailyCommand('install', 'modules', 'Install module', 'Install modules in the database.', ['install module', 'module']),
44
+ dailyCommand('update', 'modules', 'Update module', 'Update modules in the database.', ['upgrade', 'module']),
45
+ dailyCommand('test', 'modules', 'Run tests', 'Run tests for selected modules.', ['tests', 'pytest', 'module']),
46
+ dailyCommand('lint', 'modules', 'Run environment lint', 'Run environment lint checks.', ['check', 'quality']),
47
+ dailyCommand('pot', 'modules', 'Generate POT', 'Generate module translation templates.', ['translation', 'i18n']),
48
+ dailyCommand('psql', 'database', 'Open psql', 'Open PostgreSQL prompt.', ['postgres', 'sql', '/db']),
49
+ dailyCommand('snapshot', 'database', 'Create snapshot', 'Create a database snapshot.', ['backup', 'dump', '/snapshot']),
50
+ dailyCommand('restore-snapshot', 'database', 'Restore snapshot', 'Restore a named snapshot.', ['restore', 'snapshot restore']),
51
+ dailyCommand('resetdb', 'database', 'Reset database', 'Reset the environment database.', ['reset db', 'database reset']),
50
52
  internalCommand('status', 'diagnostics', 'Environment status', 'Show a summary of the current environment state.', [
51
53
  'state',
52
54
  'summary',
53
55
  ]),
54
- internalCommand('doctor', 'diagnostics', 'Run doctor', 'Run environment diagnostics and report actionable issues.', [
55
- 'diagnose',
56
- 'health',
57
- ]),
58
- internalCommand('add-repo', 'repositories', 'Add source repo', 'Add a source repository as an environment submodule.', [
56
+ internalCommand('doctor', 'diagnostics', 'Run doctor', 'Run environment diagnostics.', ['diagnose', 'health']),
57
+ internalCommand('add-repo', 'repositories', 'Add source repo', 'Add a source repository.', [
59
58
  'repository add',
60
59
  'source add',
61
60
  ]),
62
- internalCommand('remove-repo', 'repositories', 'Remove source repo', 'Remove a source repository from the environment.', ['repository remove', 'source remove']),
63
- internalCommand('add-module', 'modules', 'Add module', 'Add a module folder to a source repository.', ['module add']),
64
- internalCommand('remove-module', 'modules', 'Remove module', 'Remove a module folder from a source repository.', ['module remove']),
65
- internalCommand('safe-reset', 'maintenance', 'Safe reset environment', 'Refresh generated environment files while preserving source repositories.', ['reset', 'refresh']),
66
- internalCommand('exit', 'maintenance', 'Exit', 'Leave the command palette.', ['quit', 'back']),
61
+ internalCommand('remove-repo', 'repositories', 'Remove source repo', 'Remove a source repository.', ['repository remove', 'source remove']),
62
+ internalCommand('add-module', 'modules', 'Add module', 'Add a module to a source repository.', ['module add']),
63
+ internalCommand('remove-module', 'modules', 'Remove module', 'Remove a module from a source repository.', ['module remove']),
64
+ internalCommand('safe-reset', 'maintenance', 'Safe reset environment', 'Refresh generated files only.', ['reset', 'refresh', '/safe']),
65
+ internalCommand('exit', 'maintenance', 'Exit', 'Close the command palette.', ['quit', 'back']),
67
66
  ];
68
67
  const defaultCommandIds = new Set(['start', 'logs', 'test', 'status', 'doctor', 'exit']);
69
68
  export function normalizeCockpitSearchTerm(term) {
@@ -33,6 +33,21 @@ function categoryHeading(category) {
33
33
  function commandName(command) {
34
34
  return `${rgb(226, 184, 96, ` ${command.label.padEnd(topLevelCommandLabelWidth)}`)}${dim(` ${command.description}`)}`;
35
35
  }
36
+ const disabledReasonNextStep = {
37
+ 'No modules found.': 'Next: choose "Add module" first.',
38
+ 'Services stopped.': 'Next: choose "Start services" first.',
39
+ 'Already running.': 'Next: choose "Stop services" or "Restart services".',
40
+ 'Docker not running.': 'Next: start Docker, then choose "Start services".',
41
+ 'No source repos found.': 'Next: choose "Add source repo" first.',
42
+ };
43
+ function disabledError(reason) {
44
+ const base = 'This option is disabled and cannot be selected.';
45
+ if (!reason) {
46
+ return base;
47
+ }
48
+ const nextStep = disabledReasonNextStep[reason];
49
+ return nextStep ? `${base}\nReason: ${reason}\n${nextStep}` : `${base}\nReason: ${reason}`;
50
+ }
36
51
  function serviceDisabledReason(command, serviceStatus) {
37
52
  if (command.category !== 'services' || !serviceStatus)
38
53
  return undefined;
@@ -56,9 +71,6 @@ function disabledReason(command, serviceStatus, moduleCount, sourceRepoCount) {
56
71
  moduleDisabledReason(command, moduleCount) ??
57
72
  sourceRepoDisabledReason(command, sourceRepoCount));
58
73
  }
59
- function disabledError() {
60
- return 'This option is disabled and cannot be selected.';
61
- }
62
74
  function commandDisabledValue(reason) {
63
75
  if (!reason) {
64
76
  return undefined;
@@ -131,7 +143,7 @@ export async function selectCockpitTopLevelMenu(options = {}) {
131
143
  pageSize: topLevelPageSize(choices.length),
132
144
  loop: false,
133
145
  hideMessage: true,
134
- disabledError: disabledError(),
146
+ disabledError,
135
147
  navigationWarning: options.navigationWarning,
136
148
  escapeBehavior: 'ignore',
137
149
  });
@@ -0,0 +1,298 @@
1
+ function createParserState(content) {
2
+ return {
3
+ content,
4
+ parser: {
5
+ index: 0,
6
+ line: 1,
7
+ column: 1,
8
+ },
9
+ };
10
+ }
11
+ function makeError(state, message) {
12
+ return { message, index: state.index, line: state.line, column: state.column };
13
+ }
14
+ function throwParseError(state, message) {
15
+ const { line, column } = makeError(state, message);
16
+ throw new Error(`Parse error at ${line}:${column}: ${message}`);
17
+ }
18
+ function isWhitespace(char) {
19
+ return /\s/u.test(char);
20
+ }
21
+ function peek(state, content, offset = 0) {
22
+ return content[state.index + offset] ?? '';
23
+ }
24
+ function consumeChar(state, content) {
25
+ const char = peek(state, content);
26
+ if (char === '') {
27
+ return '';
28
+ }
29
+ state.index += 1;
30
+ if (char === '\n') {
31
+ state.line += 1;
32
+ state.column = 1;
33
+ }
34
+ else {
35
+ state.column += 1;
36
+ }
37
+ return char;
38
+ }
39
+ function skipWhitespaceAndComments(state, content) {
40
+ while (state.index < content.length) {
41
+ const char = peek(state, content);
42
+ if (isWhitespace(char)) {
43
+ consumeChar(state, content);
44
+ continue;
45
+ }
46
+ if (char === '#') {
47
+ while (state.index < content.length && peek(state, content) !== '\n') {
48
+ consumeChar(state, content);
49
+ }
50
+ continue;
51
+ }
52
+ break;
53
+ }
54
+ }
55
+ function parseString(state, content) {
56
+ const quote = consumeChar(state, content);
57
+ const chars = [];
58
+ while (state.index < content.length) {
59
+ const char = consumeChar(state, content);
60
+ if (char === '\\') {
61
+ const escaped = consumeChar(state, content);
62
+ if (!escaped) {
63
+ throwParseError(state, 'unterminated string escape');
64
+ }
65
+ if (escaped === 'n') {
66
+ chars.push('\n');
67
+ }
68
+ else if (escaped === 'r') {
69
+ chars.push('\r');
70
+ }
71
+ else if (escaped === 't') {
72
+ chars.push('\t');
73
+ }
74
+ else if (escaped === quote) {
75
+ chars.push(quote);
76
+ }
77
+ else if (escaped === '\\') {
78
+ chars.push('\\');
79
+ }
80
+ else {
81
+ chars.push(escaped);
82
+ }
83
+ continue;
84
+ }
85
+ if (char === quote) {
86
+ return chars.join('');
87
+ }
88
+ if (char === '\n' || char === '\r') {
89
+ throwParseError(state, 'unterminated string literal');
90
+ }
91
+ chars.push(char);
92
+ }
93
+ throwParseError(state, 'unterminated string literal');
94
+ }
95
+ function parseIdentifier(state, content) {
96
+ const chars = [];
97
+ const start = state.index;
98
+ const first = peek(state, content);
99
+ if (!/[A-Za-z_]/u.test(first)) {
100
+ throwParseError(state, 'expected identifier');
101
+ }
102
+ chars.push(consumeChar(state, content));
103
+ while (state.index < content.length) {
104
+ const char = peek(state, content);
105
+ if (/[A-Za-z0-9_]/u.test(char)) {
106
+ chars.push(consumeChar(state, content));
107
+ continue;
108
+ }
109
+ break;
110
+ }
111
+ if (chars.length === 0) {
112
+ throwParseError(state, 'expected identifier at ' + start);
113
+ }
114
+ return chars.join('');
115
+ }
116
+ function parseNumber(state, content) {
117
+ const chars = [];
118
+ if (peek(state, content) === '-') {
119
+ chars.push(consumeChar(state, content));
120
+ }
121
+ while (/[0-9]/u.test(peek(state, content))) {
122
+ chars.push(consumeChar(state, content));
123
+ }
124
+ if (peek(state, content) === '.') {
125
+ chars.push(consumeChar(state, content));
126
+ if (!/[0-9]/u.test(peek(state, content))) {
127
+ throwParseError(state, 'invalid numeric literal');
128
+ }
129
+ while (/[0-9]/u.test(peek(state, content))) {
130
+ chars.push(consumeChar(state, content));
131
+ }
132
+ }
133
+ const value = Number(chars.join(''));
134
+ if (!Number.isFinite(value)) {
135
+ throwParseError(state, 'invalid numeric literal');
136
+ }
137
+ return value;
138
+ }
139
+ function parseValue(state, content) {
140
+ skipWhitespaceAndComments(state, content);
141
+ const char = peek(state, content);
142
+ if (char === '{') {
143
+ return parseObject(state, content);
144
+ }
145
+ if (char === '[') {
146
+ return parseList(state, content);
147
+ }
148
+ if (char === '"' || char === "'") {
149
+ return parseString(state, content);
150
+ }
151
+ if (char === '-' || /[0-9]/u.test(char)) {
152
+ return parseNumber(state, content);
153
+ }
154
+ if (/[A-Za-z_]/u.test(char)) {
155
+ const identifier = parseIdentifier(state, content);
156
+ if (identifier === 'True')
157
+ return true;
158
+ if (identifier === 'False')
159
+ return false;
160
+ if (identifier === 'None')
161
+ return undefined;
162
+ throwParseError(state, `unsupported identifier '${identifier}'`);
163
+ }
164
+ throwParseError(state, `unexpected character '${char || 'EOF'}'`);
165
+ }
166
+ function expectChar(state, content, expected) {
167
+ skipWhitespaceAndComments(state, content);
168
+ const char = consumeChar(state, content);
169
+ if (char !== expected) {
170
+ throwParseError(state, `expected '${expected}' but found '${char || 'EOF'}'`);
171
+ }
172
+ }
173
+ function parseList(state, content) {
174
+ expectChar(state, content, '[');
175
+ const values = [];
176
+ skipWhitespaceAndComments(state, content);
177
+ if (peek(state, content) === ']') {
178
+ consumeChar(state, content);
179
+ return values;
180
+ }
181
+ while (state.index < content.length) {
182
+ const value = parseValue(state, content);
183
+ values.push(value);
184
+ skipWhitespaceAndComments(state, content);
185
+ if (peek(state, content) === ',') {
186
+ consumeChar(state, content);
187
+ skipWhitespaceAndComments(state, content);
188
+ if (peek(state, content) === ']') {
189
+ consumeChar(state, content);
190
+ return values;
191
+ }
192
+ continue;
193
+ }
194
+ if (peek(state, content) === ']') {
195
+ consumeChar(state, content);
196
+ return values;
197
+ }
198
+ throwParseError(state, "expected ',' or ']'");
199
+ }
200
+ throwParseError(state, 'unterminated list literal');
201
+ }
202
+ function parseObject(state, content) {
203
+ expectChar(state, content, '{');
204
+ const manifest = {};
205
+ skipWhitespaceAndComments(state, content);
206
+ if (peek(state, content) === '}') {
207
+ consumeChar(state, content);
208
+ return manifest;
209
+ }
210
+ while (state.index < content.length) {
211
+ skipWhitespaceAndComments(state, content);
212
+ const key = parseManifestKey(state, content);
213
+ skipWhitespaceAndComments(state, content);
214
+ expectChar(state, content, ':');
215
+ const value = parseValue(state, content);
216
+ manifest[key] = value;
217
+ skipWhitespaceAndComments(state, content);
218
+ if (peek(state, content) === ',') {
219
+ consumeChar(state, content);
220
+ skipWhitespaceAndComments(state, content);
221
+ if (peek(state, content) === '}') {
222
+ consumeChar(state, content);
223
+ return manifest;
224
+ }
225
+ continue;
226
+ }
227
+ if (peek(state, content) === '}') {
228
+ consumeChar(state, content);
229
+ return manifest;
230
+ }
231
+ throwParseError(state, "expected ',' or '}'");
232
+ }
233
+ throwParseError(state, 'unterminated object literal');
234
+ }
235
+ function parseManifestKey(state, content) {
236
+ skipWhitespaceAndComments(state, content);
237
+ const char = peek(state, content);
238
+ if (char !== '"' && char !== "'") {
239
+ throwParseError(state, 'manifest keys must be quoted');
240
+ }
241
+ return parseString(state, content);
242
+ }
243
+ function validateManifest(manifest) {
244
+ if (manifest.name !== undefined && typeof manifest.name !== 'string') {
245
+ throw new Error('invalid manifest: name must be a string');
246
+ }
247
+ if (manifest.version !== undefined && typeof manifest.version !== 'string') {
248
+ throw new Error('invalid manifest: version must be a string');
249
+ }
250
+ if (manifest.license !== undefined && typeof manifest.license !== 'string') {
251
+ throw new Error('invalid manifest: license must be a string');
252
+ }
253
+ if (manifest.application !== undefined && typeof manifest.application !== 'boolean') {
254
+ throw new Error('invalid manifest: application must be a boolean');
255
+ }
256
+ if (manifest.installable !== undefined && typeof manifest.installable !== 'boolean') {
257
+ throw new Error('invalid manifest: installable must be a boolean');
258
+ }
259
+ if (manifest.depends !== undefined && !Array.isArray(manifest.depends)) {
260
+ throw new Error('invalid manifest: depends must be a list of strings');
261
+ }
262
+ if (manifest.data !== undefined && !Array.isArray(manifest.data)) {
263
+ throw new Error('invalid manifest: data must be a list of strings');
264
+ }
265
+ if (Array.isArray(manifest.depends) && !manifest.depends.every((entry) => typeof entry === 'string')) {
266
+ throw new Error('invalid manifest: depends must be a list of strings');
267
+ }
268
+ if (Array.isArray(manifest.data) && !manifest.data.every((entry) => typeof entry === 'string')) {
269
+ throw new Error('invalid manifest: data must be a list of strings');
270
+ }
271
+ return manifest;
272
+ }
273
+ export function parseOdooManifest(content) {
274
+ try {
275
+ const { content: sourceContent, parser } = createParserState(content);
276
+ const manifest = parseValue(parser, sourceContent);
277
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
278
+ return { ok: false, error: 'Invalid manifest: top-level value must be a dictionary literal' };
279
+ }
280
+ skipWhitespaceAndComments(parser, sourceContent);
281
+ if (parser.index < sourceContent.length) {
282
+ return {
283
+ ok: false,
284
+ error: `Invalid manifest: trailing content after top-level object at line ${parser.line}, column ${parser.column}`,
285
+ };
286
+ }
287
+ return {
288
+ ok: true,
289
+ manifest: validateManifest(manifest),
290
+ };
291
+ }
292
+ catch (error) {
293
+ if (error instanceof Error) {
294
+ return { ok: false, error: error.message };
295
+ }
296
+ return { ok: false, error: 'Invalid manifest content' };
297
+ }
298
+ }
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { basename, join, relative } from 'node:path';
3
+ import { parseOdooManifest } from './module-manifest.js';
3
4
  export function emptyModuleQualitySummary() {
4
5
  return {
5
6
  totalModules: 0,
@@ -11,7 +12,8 @@ export function emptyModuleQualitySummary() {
11
12
  };
12
13
  }
13
14
  export function isInstallableManifest(content) {
14
- return /["']installable["']\s*:\s*(?:True|true)\b/u.test(content);
15
+ const parsed = parseOdooManifest(content);
16
+ return parsed.ok && parsed.manifest.installable !== false;
15
17
  }
16
18
  export function hasActionableMenuXml(content, moduleName) {
17
19
  const actionId = `action_${moduleName}`;
@@ -31,32 +33,144 @@ async function readMenusXml(modulePath) {
31
33
  return [];
32
34
  }
33
35
  }
36
+ async function readPythonModelFiles(modulePath) {
37
+ try {
38
+ const entries = await readdir(join(modulePath, 'models'), { withFileTypes: true });
39
+ return entries
40
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.py') && entry.name !== '__init__.py')
41
+ .map((entry) => entry.name);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ async function readViewXmlFiles(modulePath) {
48
+ try {
49
+ const entries = await readdir(join(modulePath, 'views'), { withFileTypes: true });
50
+ return entries
51
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.xml') && !entry.name.endsWith('_menus.xml'))
52
+ .map((entry) => entry.name);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ async function directoryExists(path) {
59
+ try {
60
+ return (await stat(path)).isDirectory();
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ async function fileExists(path) {
67
+ try {
68
+ return (await stat(path)).isFile();
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ async function readOptionalFile(path) {
75
+ try {
76
+ return await readFile(path, 'utf8');
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ function moduleIssue(moduleName, path, issue) {
83
+ return { moduleName, path, issue };
84
+ }
85
+ function manifestData(manifest) {
86
+ return Array.isArray(manifest?.data) ? manifest.data : [];
87
+ }
88
+ function manifestDepends(manifest) {
89
+ return Array.isArray(manifest?.depends) ? manifest.depends : [];
90
+ }
91
+ function dataIncludesAccessCsv(data) {
92
+ return data.includes('security/ir.model.access.csv');
93
+ }
94
+ function dataIncludesViewXml(data) {
95
+ return data.some((entry) => entry.startsWith('views/') && entry.endsWith('.xml') && !entry.endsWith('_menus.xml'));
96
+ }
97
+ function moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data) {
98
+ return (modelFiles.length > 0 ||
99
+ viewFiles.length > 0 ||
100
+ menuXml.length > 0 ||
101
+ data.some((entry) => entry.startsWith('security/') || entry.startsWith('views/')));
102
+ }
103
+ function pythonImportPresent(content, importName) {
104
+ if (!content)
105
+ return false;
106
+ return new RegExp(`^\\s*from\\s+\\.\\s+import\\s+.*\\b${importName}\\b`, 'mu').test(content);
107
+ }
34
108
  export async function analyzeModuleDirectory(modulePath, moduleName = basename(modulePath), relativePath = modulePath) {
35
109
  const issues = [];
110
+ let manifest;
36
111
  let installable = false;
37
- try {
38
- installable = isInstallableManifest(await readFile(join(modulePath, '__manifest__.py'), 'utf8'));
112
+ const manifestContent = await readOptionalFile(join(modulePath, '__manifest__.py'));
113
+ if (!manifestContent) {
114
+ issues.push(moduleIssue(moduleName, relativePath, 'missing __manifest__.py'));
39
115
  }
40
- catch {
41
- installable = false;
116
+ else {
117
+ const parsedManifest = parseOdooManifest(manifestContent);
118
+ if (parsedManifest.ok) {
119
+ manifest = parsedManifest.manifest;
120
+ installable = manifest.installable !== false;
121
+ }
122
+ else {
123
+ issues.push(moduleIssue(moduleName, relativePath, `invalid manifest syntax: ${parsedManifest.error}`));
124
+ }
42
125
  }
43
- if (!installable) {
44
- issues.push({
45
- moduleName,
46
- path: relativePath,
47
- issue: 'missing installable=True in __manifest__.py',
48
- });
126
+ if (manifest?.installable === false) {
127
+ issues.push(moduleIssue(moduleName, relativePath, 'installable is false in __manifest__.py'));
128
+ }
129
+ if (manifest && !manifest.license) {
130
+ issues.push(moduleIssue(moduleName, relativePath, 'missing license in __manifest__.py'));
131
+ }
132
+ if (manifest && manifest.depends === undefined) {
133
+ issues.push(moduleIssue(moduleName, relativePath, 'missing depends in __manifest__.py'));
49
134
  }
50
135
  const menuXml = await readMenusXml(modulePath);
136
+ const modelFiles = await readPythonModelFiles(modulePath);
137
+ const viewFiles = await readViewXmlFiles(modulePath);
138
+ const depends = manifestDepends(manifest);
139
+ const data = manifestData(manifest);
140
+ const hasOdooStructures = moduleHasOdooStructures(modelFiles, viewFiles, menuXml, data);
141
+ if (hasOdooStructures && !depends.includes('base')) {
142
+ issues.push(moduleIssue(moduleName, relativePath, 'missing base dependency for model-based module'));
143
+ }
144
+ if (modelFiles.length > 0) {
145
+ const rootInit = await readOptionalFile(join(modulePath, '__init__.py'));
146
+ if (!pythonImportPresent(rootInit, 'models')) {
147
+ issues.push(moduleIssue(moduleName, relativePath, 'missing __init__.py models import'));
148
+ }
149
+ const modelsInit = await readOptionalFile(join(modulePath, 'models/__init__.py'));
150
+ const missingModelImport = modelFiles
151
+ .map((fileName) => fileName.replace(/\.py$/u, ''))
152
+ .some((modelImport) => !pythonImportPresent(modelsInit, modelImport));
153
+ if (missingModelImport) {
154
+ issues.push(moduleIssue(moduleName, relativePath, 'missing models/__init__.py model import'));
155
+ }
156
+ if (!(await fileExists(join(modulePath, 'security/ir.model.access.csv')))) {
157
+ issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv'));
158
+ }
159
+ }
160
+ if (hasOdooStructures && !dataIncludesAccessCsv(data)) {
161
+ issues.push(moduleIssue(moduleName, relativePath, 'missing security/ir.model.access.csv in manifest data'));
162
+ }
163
+ if (hasOdooStructures && viewFiles.length === 0 && !dataIncludesViewXml(data)) {
164
+ issues.push(moduleIssue(moduleName, relativePath, 'missing views XML under views/'));
165
+ }
166
+ if (!(await directoryExists(join(modulePath, 'tests')))) {
167
+ issues.push(moduleIssue(moduleName, relativePath, 'missing tests directory'));
168
+ }
51
169
  const hasMenuAction = menuXml.some((content) => hasActionableMenuXml(content, moduleName));
52
170
  if (!hasMenuAction) {
53
- issues.push({
54
- moduleName,
55
- path: relativePath,
56
- issue: 'missing actionable menu XML',
57
- });
171
+ issues.push(moduleIssue(moduleName, relativePath, 'missing actionable menu XML'));
58
172
  }
59
- return { moduleName, relativePath, installable, hasMenuAction, issues };
173
+ return { moduleName, relativePath, installable, hasMenuAction, depends, ...(manifest ? { manifest } : {}), issues };
60
174
  }
61
175
  export function addModuleQualityResult(summary, result) {
62
176
  return {
@@ -69,15 +183,86 @@ export function addModuleQualityResult(summary, result) {
69
183
  };
70
184
  }
71
185
  export function mergeModuleQualitySummaries(left, right) {
186
+ const missingDependencies = [
187
+ ...(left.dependencyGraph?.missingDependencies ?? []),
188
+ ...(right.dependencyGraph?.missingDependencies ?? []),
189
+ ];
190
+ const dependencies = [...(left.dependencyGraph?.dependencies ?? []), ...(right.dependencyGraph?.dependencies ?? [])];
191
+ const cycles = [...(left.dependencyGraph?.cycles ?? []), ...(right.dependencyGraph?.cycles ?? [])];
192
+ const dependencyGraph = dependencies.length > 0 || missingDependencies.length > 0 || cycles.length > 0
193
+ ? { dependencies, missingDependencies, cycles }
194
+ : undefined;
72
195
  return {
73
196
  totalModules: left.totalModules + right.totalModules,
74
197
  installableModules: left.installableModules + right.installableModules,
75
198
  nonInstallableModules: left.nonInstallableModules + right.nonInstallableModules,
76
199
  modulesWithMenuActions: left.modulesWithMenuActions + right.modulesWithMenuActions,
77
200
  modulesMissingMenuActions: left.modulesMissingMenuActions + right.modulesMissingMenuActions,
201
+ ...(dependencyGraph ? { dependencyGraph } : {}),
78
202
  issues: [...left.issues, ...right.issues],
79
203
  };
80
204
  }
205
+ function firstToken(value) {
206
+ return value.split(/[_-]+/u, 1)[0] ?? value;
207
+ }
208
+ function looksLikeMissingLocalDependency(moduleName, dependency) {
209
+ if (dependency === 'base')
210
+ return false;
211
+ const namespace = firstToken(moduleName);
212
+ const generatedOrProjectNamespaces = new Set(['custom', 'demo', 'module', 'odoo', 'wpmoo']);
213
+ return Boolean(namespace) && generatedOrProjectNamespaces.has(namespace) && dependency.startsWith(`${namespace}_`);
214
+ }
215
+ function findCycleFrom(start, current, graph, path = [start]) {
216
+ for (const dependency of graph.get(current) ?? []) {
217
+ if (dependency === start) {
218
+ return [...path, start];
219
+ }
220
+ if (path.includes(dependency)) {
221
+ continue;
222
+ }
223
+ const found = findCycleFrom(start, dependency, graph, [...path, dependency]);
224
+ if (found)
225
+ return found;
226
+ }
227
+ return undefined;
228
+ }
229
+ function moduleDependencyGraph(results) {
230
+ const byName = new Map(results.map((result) => [result.moduleName, result]));
231
+ const localModuleNames = new Set(byName.keys());
232
+ const dependencyEdges = new Map();
233
+ const missingDependencies = [];
234
+ const dependencies = [];
235
+ const issues = [];
236
+ for (const result of results) {
237
+ const localDependencies = result.depends.filter((dependency) => localModuleNames.has(dependency));
238
+ dependencyEdges.set(result.moduleName, localDependencies);
239
+ for (const dependency of result.depends) {
240
+ if (localModuleNames.has(dependency)) {
241
+ dependencies.push({ moduleName: result.moduleName, dependency, kind: 'local' });
242
+ continue;
243
+ }
244
+ if (looksLikeMissingLocalDependency(result.moduleName, dependency)) {
245
+ dependencies.push({ moduleName: result.moduleName, dependency, kind: 'unresolved' });
246
+ missingDependencies.push({ moduleName: result.moduleName, dependency });
247
+ issues.push(moduleIssue(result.moduleName, result.relativePath, `missing local dependency ${dependency}`));
248
+ continue;
249
+ }
250
+ dependencies.push({ moduleName: result.moduleName, dependency, kind: 'external' });
251
+ }
252
+ }
253
+ const cycles = [];
254
+ for (const moduleName of localModuleNames) {
255
+ const cycle = findCycleFrom(moduleName, moduleName, dependencyEdges);
256
+ if (cycle) {
257
+ cycles.push(cycle);
258
+ const result = byName.get(moduleName);
259
+ if (result) {
260
+ issues.push(moduleIssue(moduleName, result.relativePath, `dependency cycle detected: ${cycle.join(' -> ')}`));
261
+ }
262
+ }
263
+ }
264
+ return { graph: { dependencies, missingDependencies, cycles }, issues };
265
+ }
81
266
  export async function scanModuleQuality(root, target) {
82
267
  try {
83
268
  const rootStat = await stat(root);
@@ -89,6 +274,7 @@ export async function scanModuleQuality(root, target) {
89
274
  }
90
275
  let summary = emptyModuleQualitySummary();
91
276
  const stack = [root];
277
+ const results = [];
92
278
  while (stack.length > 0) {
93
279
  const current = stack.pop();
94
280
  if (!current)
@@ -104,8 +290,16 @@ export async function scanModuleQuality(root, target) {
104
290
  }
105
291
  }
106
292
  if (hasManifest) {
107
- summary = addModuleQualityResult(summary, await analyzeModuleDirectory(current, basename(current), relative(target, current)));
293
+ const result = await analyzeModuleDirectory(current, basename(current), relative(target, current));
294
+ results.push(result);
295
+ summary = addModuleQualityResult(summary, result);
108
296
  }
109
297
  }
110
- return summary;
298
+ const dependencyGraph = moduleDependencyGraph(results);
299
+ const hasDependencyGraphIssues = dependencyGraph.graph.missingDependencies.length > 0 || dependencyGraph.graph.cycles.length > 0;
300
+ return {
301
+ ...summary,
302
+ ...(hasDependencyGraphIssues ? { dependencyGraph: dependencyGraph.graph } : {}),
303
+ issues: [...summary.issues, ...dependencyGraph.issues],
304
+ };
111
305
  }
@@ -0,0 +1,91 @@
1
+ function normalizeQuery(query) {
2
+ return query.trim().toLowerCase();
3
+ }
4
+ function moduleMatchesExact(query, module) {
5
+ return module.moduleName.toLowerCase() === query;
6
+ }
7
+ function moduleMatchesPartial(query, module) {
8
+ return module.moduleName.toLowerCase().includes(query);
9
+ }
10
+ function tokenizeModuleName(moduleName) {
11
+ return moduleName.toLowerCase().split(/[_-]+/g).filter(Boolean);
12
+ }
13
+ function levenshteinDistance(a, b) {
14
+ if (a === b) {
15
+ return 0;
16
+ }
17
+ if (!a) {
18
+ return b.length;
19
+ }
20
+ if (!b) {
21
+ return a.length;
22
+ }
23
+ const rows = a.length + 1;
24
+ const cols = b.length + 1;
25
+ const matrix = Array.from({ length: rows }, (_, rowIndex) => {
26
+ const row = new Array(cols);
27
+ if (rowIndex === 0) {
28
+ for (let col = 0; col < cols; col += 1) {
29
+ row[col] = col;
30
+ }
31
+ }
32
+ else {
33
+ row[0] = rowIndex;
34
+ }
35
+ return row;
36
+ });
37
+ for (let row = 1; row < rows; row += 1) {
38
+ for (let col = 1; col < cols; col += 1) {
39
+ const cost = a[row - 1] === b[col - 1] ? 0 : 1;
40
+ const substitutions = matrix[row - 1][col - 1] + cost;
41
+ const insertions = matrix[row][col - 1] + 1;
42
+ const deletions = matrix[row - 1][col] + 1;
43
+ matrix[row][col] = Math.min(substitutions, insertions, deletions);
44
+ }
45
+ }
46
+ return matrix[a.length][b.length];
47
+ }
48
+ function nearestCandidates(query, modules, maxItems = 3) {
49
+ const queryNormalized = query;
50
+ const scoredModules = modules
51
+ .map((module) => {
52
+ const fullMatchDistance = levenshteinDistance(module.moduleName.toLowerCase(), queryNormalized);
53
+ const tokenMatchDistance = tokenizeModuleName(module.moduleName).reduce((best, token) => Math.min(best, levenshteinDistance(token, queryNormalized)), Number.POSITIVE_INFINITY);
54
+ const distance = Math.min(fullMatchDistance, tokenMatchDistance);
55
+ return { module, distance };
56
+ })
57
+ .filter((entry) => entry.distance <= 4);
58
+ const scoredWithIndex = scoredModules.map((entry, index) => ({ ...entry, index }));
59
+ scoredWithIndex.sort((left, right) => {
60
+ if (left.distance !== right.distance) {
61
+ return left.distance - right.distance;
62
+ }
63
+ return left.index - right.index;
64
+ });
65
+ const topDistance = scoredWithIndex[0]?.distance ?? Number.POSITIVE_INFINITY;
66
+ return scoredWithIndex
67
+ .filter((entry) => entry.distance <= topDistance + 1)
68
+ .slice(0, maxItems)
69
+ .map((entry) => entry.module);
70
+ }
71
+ export function resolveModuleTarget(query, modules) {
72
+ const normalizedQuery = normalizeQuery(query);
73
+ if (!normalizedQuery) {
74
+ return { kind: 'no-match', query, candidates: [] };
75
+ }
76
+ const exactMatches = modules.filter((module) => moduleMatchesExact(normalizedQuery, module));
77
+ if (exactMatches.length === 1) {
78
+ return { kind: 'exact', query, module: exactMatches[0] };
79
+ }
80
+ if (exactMatches.length > 1) {
81
+ return { kind: 'ambiguous', query, candidates: exactMatches };
82
+ }
83
+ const partialMatches = normalizedQuery.length >= 3 ? modules.filter((module) => moduleMatchesPartial(normalizedQuery, module)) : [];
84
+ if (partialMatches.length === 1) {
85
+ return { kind: 'exact', query, module: partialMatches[0] };
86
+ }
87
+ if (partialMatches.length > 1) {
88
+ return { kind: 'ambiguous', query, candidates: partialMatches };
89
+ }
90
+ return { kind: 'no-match', query, candidates: nearestCandidates(normalizedQuery, modules) };
91
+ }
@@ -171,10 +171,13 @@ function hiddenSelectTheme(disabledError, navigationHelp = 'exit', navigationWar
171
171
  };
172
172
  }
173
173
  function disabledErrorI18n(disabledError, activeReason) {
174
- const i18n = { disabledError };
174
+ const i18n = typeof disabledError === 'string' ? { disabledError } : { disabledError: disabledError(undefined) };
175
175
  Object.defineProperty(i18n, 'disabledError', {
176
176
  get: () => {
177
177
  const reason = activeReason();
178
+ if (typeof disabledError === 'function') {
179
+ return disabledError(reason);
180
+ }
178
181
  return reason ? `${disabledError}\nReason: ${reason}` : disabledError;
179
182
  },
180
183
  });
package/dist/status.js CHANGED
@@ -5,6 +5,10 @@ import { defaultOdooVersion, markerPath } from './environment.js';
5
5
  import { emptyModuleQualitySummary, mergeModuleQualitySummaries, scanModuleQuality, } from './module-quality.js';
6
6
  import { isValidPathSegment, validateRepoPath } from './path-validation.js';
7
7
  const validSourceTypes = ['private', 'oca', 'external'];
8
+ const summarySeparator = ' \u00B7 ';
9
+ function pluralize(count, singular, plural) {
10
+ return `${count} ${count === 1 ? singular : plural}`;
11
+ }
8
12
  function normalizeSourceType(sourceType) {
9
13
  if (typeof sourceType === 'string' && validSourceTypes.includes(sourceType)) {
10
14
  return sourceType;
@@ -85,6 +89,18 @@ async function missingCoreFiles(target, odooVersion) {
85
89
  missing.push(...composeLayout.missingFiles);
86
90
  return { missing, composeFiles: composeLayout.files, composeErrors: composeLayout.errors };
87
91
  }
92
+ export function environmentBannerSummaryLine(status) {
93
+ if (status.kind !== 'environment') {
94
+ return `Environment: ${summaryText(status)}`;
95
+ }
96
+ const issueCount = status.composeErrors.length + status.invalidSourceRepoPaths.length + status.missingCoreFiles.length;
97
+ const issueSuffix = issueCount > 0 ? `${summarySeparator}${pluralize(issueCount, 'issue', 'issues')}` : '';
98
+ return [
99
+ `Environment: Odoo ${status.odooVersion}`,
100
+ pluralize(status.sourceRepoCount, 'repo', 'repos'),
101
+ pluralize(status.moduleCandidateCount, 'module', 'modules'),
102
+ ].join(summarySeparator) + issueSuffix;
103
+ }
88
104
  function summaryText(status) {
89
105
  if (status.kind === 'no_environment')
90
106
  return 'No WPMoo environment detected.';
package/dist/templates.js CHANGED
@@ -828,7 +828,7 @@ function emptyModuleQuality() {
828
828
  }
829
829
 
830
830
  function manifestIsInstallable(content) {
831
- return /["']installable["']\\s*:\\s*(?:True|true)\\b/.test(content);
831
+ return !/["']installable["']\\s*:\\s*(?:False|false)\\b/.test(content);
832
832
  }
833
833
 
834
834
  function menuXmlHasAction(content, moduleName) {
@@ -867,7 +867,7 @@ async function analyzeModule(modulePath) {
867
867
  issues.push({
868
868
  moduleName,
869
869
  path: moduleRelativePath,
870
- issue: 'missing installable=True in __manifest__.py',
870
+ issue: 'installable is false in __manifest__.py',
871
871
  });
872
872
  }
873
873
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/toolkit",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
4
4
  "description": "WPMoo Toolkit for development, staging, and production lifecycle workflows.",
5
5
  "type": "module",
6
6
  "repository": {