@wpmoo/odoo 0.8.34 → 0.8.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,6 +171,12 @@ npx @wpmoo/odoo remove-module \
171
171
  --module odoo_sample_module_base
172
172
  ```
173
173
 
174
+ Check that a generated environment is structurally ready:
175
+
176
+ ```bash
177
+ npx @wpmoo/odoo doctor
178
+ ```
179
+
174
180
  Refresh generated environment files without deleting module source code:
175
181
 
176
182
  ```bash
@@ -180,19 +186,35 @@ npx @wpmoo/odoo reset
180
186
  Run daily local development actions from a generated environment root:
181
187
 
182
188
  ```bash
189
+ npx @wpmoo/odoo start
183
190
  npx @wpmoo/odoo logs odoo
184
191
  npx @wpmoo/odoo restart
192
+ npx @wpmoo/odoo stop
185
193
  npx @wpmoo/odoo shell
186
194
  npx @wpmoo/odoo psql devel
187
195
  npx @wpmoo/odoo install sale devel
188
196
  npx @wpmoo/odoo update sale devel
189
197
  npx @wpmoo/odoo test sale --db devel --mode update --tags /sale
198
+ npx @wpmoo/odoo resetdb devel sale
199
+ npx @wpmoo/odoo snapshot devel before-update
200
+ npx @wpmoo/odoo restore-snapshot before-update devel
201
+ npx @wpmoo/odoo lint
202
+ npx @wpmoo/odoo pot sale devel i18n/sale.pot
190
203
  ```
191
204
 
205
+ The doctor command must be run from a generated environment root containing
206
+ `.wpmoo/odoo.json`. It checks metadata, selected compose files, daily scripts,
207
+ source repo paths, `.env` ports, and Docker CLI access.
208
+
192
209
  Daily actions require `.wpmoo/odoo.json` in the current directory and delegate to
193
210
  fixed scripts under `./scripts`; they do not search parent directories or accept
194
211
  arbitrary script names.
195
212
 
213
+ Generated environments also include a local `./moo` shortcut for local compose
214
+ daily commands such as `./moo start`, `./moo restart`, and `./moo stop`. The
215
+ shortcut supports the same daily action arguments as `npx @wpmoo/odoo`. It also
216
+ falls back to `npx @wpmoo/odoo@latest doctor` for `./moo doctor`.
217
+
196
218
  ## Defaults
197
219
 
198
220
  Each source repo can contain one or many Odoo modules. For example:
package/dist/args.js CHANGED
@@ -12,6 +12,7 @@ const commandNames = new Set([
12
12
  'add-module',
13
13
  'remove-module',
14
14
  'reset',
15
+ 'doctor',
15
16
  ...dailyActionCommands,
16
17
  ]);
17
18
  const internalFlags = new Set(['--no-update-check']);
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { detectDevelopmentEnvironment } from './environment.js';
6
6
  import { commandOdooVersion } from './environment-version.js';
7
7
  import { defaultAgentSkillsTemplateUrl } from './external-templates.js';
8
8
  import { isDailyActionCommand, runDailyAction } from './daily-actions.js';
9
+ import { runDoctor } from './doctor.js';
9
10
  import { getOriginUrl, realGit } from './git.js';
10
11
  import { renderHelp } from './help.js';
11
12
  import { addModuleToSourceRepo, listModulesInSourceRepo, removeModuleFromSourceRepo, } from './module-actions.js';
@@ -676,6 +677,14 @@ async function main() {
676
677
  outro(`Safe reset refreshed generated environment files in ${options.target}.`);
677
678
  return;
678
679
  }
680
+ if (route.command === 'doctor') {
681
+ if (route.argv.length > 0) {
682
+ throw new Error('Usage: wpmoo doctor');
683
+ }
684
+ console.log(renderBanner());
685
+ console.log(await runDoctor(process.cwd()));
686
+ return;
687
+ }
679
688
  if (isDailyActionCommand(route.command)) {
680
689
  console.log(renderBanner());
681
690
  await runDailyAction(route.command, route.argv);
@@ -2,9 +2,26 @@ import { spawn } from 'node:child_process';
2
2
  import { access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { markerPath } from './environment.js';
5
- export const dailyActionCommands = ['logs', 'restart', 'shell', 'psql', 'install', 'update', 'test'];
5
+ export const dailyActionCommands = [
6
+ 'start',
7
+ 'stop',
8
+ 'logs',
9
+ 'restart',
10
+ 'shell',
11
+ 'psql',
12
+ 'install',
13
+ 'update',
14
+ 'test',
15
+ 'resetdb',
16
+ 'snapshot',
17
+ 'restore-snapshot',
18
+ 'lint',
19
+ 'pot',
20
+ ];
6
21
  const dailyActionCommandSet = new Set(dailyActionCommands);
7
- const scripts = {
22
+ export const dailyActionScripts = {
23
+ start: 'up.sh',
24
+ stop: 'down.sh',
8
25
  logs: 'logs.sh',
9
26
  restart: 'restart.sh',
10
27
  shell: 'shell.sh',
@@ -12,11 +29,20 @@ const scripts = {
12
29
  install: 'install.sh',
13
30
  update: 'update.sh',
14
31
  test: 'test.sh',
32
+ resetdb: 'resetdb.sh',
33
+ snapshot: 'snapshot.sh',
34
+ 'restore-snapshot': 'restore-snapshot.sh',
35
+ lint: 'lint.sh',
36
+ pot: 'pot.sh',
15
37
  };
16
38
  export function isDailyActionCommand(command) {
17
39
  return dailyActionCommandSet.has(command);
18
40
  }
19
41
  function usage(command) {
42
+ if (command === 'start')
43
+ return 'Usage: wpmoo start';
44
+ if (command === 'stop')
45
+ return 'Usage: wpmoo stop';
20
46
  if (command === 'logs')
21
47
  return 'Usage: wpmoo logs [service]';
22
48
  if (command === 'restart')
@@ -29,7 +55,17 @@ function usage(command) {
29
55
  return 'Usage: wpmoo install <module[,module]> [db]';
30
56
  if (command === 'update')
31
57
  return 'Usage: wpmoo update <module[,module]> [db]';
32
- return 'Usage: wpmoo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]';
58
+ if (command === 'test')
59
+ return 'Usage: wpmoo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]';
60
+ if (command === 'resetdb')
61
+ return 'Usage: wpmoo resetdb [db] [module[,module]]';
62
+ if (command === 'snapshot')
63
+ return 'Usage: wpmoo snapshot [db] [snapshot-name]';
64
+ if (command === 'restore-snapshot')
65
+ return 'Usage: wpmoo restore-snapshot <snapshot-name> [db]';
66
+ if (command === 'lint')
67
+ return 'Usage: wpmoo lint';
68
+ return 'Usage: wpmoo pot <module[,module]> [db] [output]';
33
69
  }
34
70
  function ensureNoArgs(command, argv) {
35
71
  if (argv.length > 0)
@@ -47,6 +83,12 @@ function moduleArgs(command, argv) {
47
83
  throw new Error(usage(command));
48
84
  return db ? [modules, db] : [modules];
49
85
  }
86
+ function positionalArgs(command, argv, min, max) {
87
+ if (argv.length < min || argv.length > max || argv.some((arg) => arg.startsWith('-'))) {
88
+ throw new Error(usage(command));
89
+ }
90
+ return argv;
91
+ }
50
92
  function testArgs(argv) {
51
93
  const [modules, ...rest] = argv;
52
94
  if (!modules || modules.startsWith('-'))
@@ -66,6 +108,10 @@ function testArgs(argv) {
66
108
  return argv;
67
109
  }
68
110
  function scriptArgs(command, argv) {
111
+ if (command === 'start')
112
+ return ensureNoArgs(command, argv);
113
+ if (command === 'stop')
114
+ return ensureNoArgs(command, argv);
69
115
  if (command === 'logs')
70
116
  return optionalSingleArg(command, argv, 'odoo');
71
117
  if (command === 'restart')
@@ -76,7 +122,17 @@ function scriptArgs(command, argv) {
76
122
  return optionalSingleArg(command, argv, 'postgres');
77
123
  if (command === 'install' || command === 'update')
78
124
  return moduleArgs(command, argv);
79
- return testArgs(argv);
125
+ if (command === 'test')
126
+ return testArgs(argv);
127
+ if (command === 'resetdb')
128
+ return positionalArgs(command, argv, 0, 2);
129
+ if (command === 'snapshot')
130
+ return positionalArgs(command, argv, 0, 2);
131
+ if (command === 'restore-snapshot')
132
+ return positionalArgs(command, argv, 1, 2);
133
+ if (command === 'lint')
134
+ return ensureNoArgs(command, argv);
135
+ return positionalArgs(command, argv, 1, 3);
80
136
  }
81
137
  async function assertEnvironmentRoot(cwd) {
82
138
  try {
@@ -98,7 +154,7 @@ async function assertScriptExists(cwd, script) {
98
154
  }
99
155
  export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
100
156
  await assertEnvironmentRoot(cwd);
101
- const scriptPath = await assertScriptExists(cwd, scripts[command]);
157
+ const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
102
158
  return {
103
159
  cwd,
104
160
  scriptPath,
package/dist/doctor.js ADDED
@@ -0,0 +1,168 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { execa } from 'execa';
4
+ import { dailyActionScripts } from './daily-actions.js';
5
+ import { defaultOdooVersion, markerPath } from './environment.js';
6
+ const realCommandRunner = async (command, args, options) => {
7
+ const result = await execa(command, args, { cwd: options.cwd });
8
+ return { stdout: result.stdout, stderr: result.stderr };
9
+ };
10
+ async function exists(path) {
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ function errorMessage(error) {
20
+ return error instanceof Error ? error.message : String(error);
21
+ }
22
+ function isRecord(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+ function sourceReposFromMetadata(metadata) {
26
+ const sourceRepos = metadata.sourceRepos;
27
+ if (!Array.isArray(sourceRepos))
28
+ return [];
29
+ return sourceRepos.map((repo, index) => {
30
+ if (!isRecord(repo) || typeof repo.path !== 'string' || !repo.path.trim()) {
31
+ throw new Error(`Invalid sourceRepos entry in .wpmoo/odoo.json at index ${index}`);
32
+ }
33
+ return {
34
+ url: typeof repo.url === 'string' ? repo.url : '',
35
+ path: repo.path.trim(),
36
+ addons: Array.isArray(repo.addons) ? repo.addons.filter((addon) => typeof addon === 'string') : [],
37
+ };
38
+ });
39
+ }
40
+ async function readMetadata(target) {
41
+ let content;
42
+ try {
43
+ content = await readFile(join(target, markerPath), 'utf8');
44
+ }
45
+ catch {
46
+ throw new Error(`Missing metadata file: ${markerPath}`);
47
+ }
48
+ try {
49
+ const parsed = JSON.parse(content);
50
+ if (!isRecord(parsed)) {
51
+ throw new Error('metadata is not an object');
52
+ }
53
+ return parsed;
54
+ }
55
+ catch (error) {
56
+ throw new Error(`Invalid metadata JSON in ${markerPath}: ${errorMessage(error)}`);
57
+ }
58
+ }
59
+ function metadataString(metadata, key) {
60
+ const value = metadata[key];
61
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
62
+ }
63
+ function parseEnv(content) {
64
+ const values = new Map();
65
+ for (const rawLine of content.split(/\r?\n/)) {
66
+ const line = rawLine.trim();
67
+ if (!line || line.startsWith('#'))
68
+ continue;
69
+ const separator = line.indexOf('=');
70
+ if (separator === -1)
71
+ continue;
72
+ const key = line.slice(0, separator).trim();
73
+ let value = line.slice(separator + 1).trim();
74
+ if ((value.startsWith('"') && value.endsWith('"')) ||
75
+ (value.startsWith("'") && value.endsWith("'"))) {
76
+ value = value.slice(1, -1);
77
+ }
78
+ values.set(key, value);
79
+ }
80
+ return values;
81
+ }
82
+ async function readEnv(target) {
83
+ const path = join(target, '.env');
84
+ if (!(await exists(path)))
85
+ return undefined;
86
+ return parseEnv(await readFile(path, 'utf8'));
87
+ }
88
+ function validatePort(name, env, errors) {
89
+ const value = env.get(name)?.trim() ?? '';
90
+ if (!/^\d+$/.test(value)) {
91
+ errors.push(`Invalid ${name} in .env: expected a non-empty numeric value`);
92
+ }
93
+ return value;
94
+ }
95
+ function renderFailure(errors) {
96
+ return ['WPMoo doctor failed:', ...errors.map((error) => `- ${error}`)].join('\n');
97
+ }
98
+ export async function runDoctor(target = process.cwd(), runner = realCommandRunner) {
99
+ const lines = ['WPMoo doctor'];
100
+ const errors = [];
101
+ const metadata = await readMetadata(target);
102
+ lines.push(`OK metadata ${markerPath}`);
103
+ const engine = metadataString(metadata, 'engine') ?? 'compose';
104
+ if (engine !== 'compose') {
105
+ errors.push(`Unsupported environment engine: ${engine}`);
106
+ }
107
+ else {
108
+ lines.push('OK engine compose');
109
+ }
110
+ const odooVersion = metadataString(metadata, 'odooVersion') ?? defaultOdooVersion;
111
+ lines.push(`OK Odoo version ${odooVersion}`);
112
+ const env = await readEnv(target);
113
+ const composeVersions = new Set([odooVersion]);
114
+ const envOdooVersion = env?.get('ODOO_VERSION')?.trim();
115
+ if (envOdooVersion) {
116
+ composeVersions.add(envOdooVersion);
117
+ }
118
+ for (const version of composeVersions) {
119
+ const composeFile = `docker-compose_${version}.yml`;
120
+ if (await exists(join(target, composeFile))) {
121
+ lines.push(`OK compose ${composeFile}`);
122
+ }
123
+ else {
124
+ errors.push(`Missing compose file: ${composeFile}`);
125
+ }
126
+ }
127
+ const scriptNames = Object.values(dailyActionScripts);
128
+ const scriptErrorCount = errors.length;
129
+ for (const script of scriptNames) {
130
+ const relativePath = `scripts/${script}`;
131
+ if (!(await exists(join(target, relativePath)))) {
132
+ errors.push(`Missing daily action script: ${relativePath}`);
133
+ }
134
+ }
135
+ if (errors.length === scriptErrorCount) {
136
+ lines.push(`OK scripts ${scriptNames.length} checked`);
137
+ }
138
+ const sourceRepos = sourceReposFromMetadata(metadata);
139
+ for (const repo of sourceRepos) {
140
+ const relativePath = `odoo/custom/src/private/${repo.path}`;
141
+ if (!(await exists(join(target, relativePath)))) {
142
+ errors.push(`Missing source repo path: ${relativePath}`);
143
+ }
144
+ }
145
+ lines.push(`OK source repos ${sourceRepos.length} checked`);
146
+ if (env) {
147
+ const httpPort = validatePort('HTTP_PORT', env, errors);
148
+ const geventPort = validatePort('GEVENT_PORT', env, errors);
149
+ if (httpPort && geventPort && httpPort === geventPort) {
150
+ errors.push('HTTP_PORT and GEVENT_PORT in .env must not be equal');
151
+ }
152
+ if (/^\d+$/.test(httpPort) && /^\d+$/.test(geventPort) && httpPort !== geventPort) {
153
+ lines.push(`OK .env ports HTTP_PORT=${httpPort} GEVENT_PORT=${geventPort}`);
154
+ }
155
+ }
156
+ try {
157
+ await runner('docker', ['version'], { cwd: target });
158
+ lines.push('OK docker CLI');
159
+ }
160
+ catch (error) {
161
+ errors.push(`Docker CLI check failed: ${errorMessage(error)}`);
162
+ }
163
+ if (errors.length > 0) {
164
+ throw new Error(renderFailure(errors));
165
+ }
166
+ lines.push('Doctor checks passed.');
167
+ return lines.join('\n');
168
+ }
package/dist/help.js CHANGED
@@ -11,6 +11,9 @@ Usage:
11
11
  npx @wpmoo/odoo add-module --repo <source-repo> --module <module-name>
12
12
  npx @wpmoo/odoo remove-module --repo <source-repo> --module <module-name>
13
13
  npx @wpmoo/odoo reset
14
+ npx @wpmoo/odoo doctor
15
+ npx @wpmoo/odoo start
16
+ npx @wpmoo/odoo stop
14
17
  npx @wpmoo/odoo logs [service]
15
18
  npx @wpmoo/odoo restart
16
19
  npx @wpmoo/odoo shell
@@ -18,6 +21,11 @@ Usage:
18
21
  npx @wpmoo/odoo install <module[,module]> [db]
19
22
  npx @wpmoo/odoo update <module[,module]> [db]
20
23
  npx @wpmoo/odoo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
24
+ npx @wpmoo/odoo resetdb [db] [module[,module]]
25
+ npx @wpmoo/odoo snapshot [db] [snapshot-name]
26
+ npx @wpmoo/odoo restore-snapshot <snapshot-name> [db]
27
+ npx @wpmoo/odoo lint
28
+ npx @wpmoo/odoo pot <module[,module]> [db] [output]
21
29
 
22
30
  Options:
23
31
  --product <slug> Product slug, for example my_odoo_module.
@@ -55,6 +63,12 @@ Options:
55
63
  Daily actions:
56
64
  Daily actions must be run from a generated environment root containing .wpmoo/odoo.json.
57
65
  They delegate to the fixed scripts copied from the compose resource under ./scripts.
66
+ Generated environments also include ./moo for local compose commands such as ./moo start.
67
+ Use ./moo or npx @wpmoo/odoo with the same daily action arguments.
68
+
69
+ Doctor:
70
+ Run npx @wpmoo/odoo doctor from a generated environment root to check metadata,
71
+ compose files, daily scripts, source repo paths, .env ports, and Docker CLI access.
58
72
 
59
73
  Example:
60
74
  npx @wpmoo/odoo create \\
@@ -1,6 +1,8 @@
1
1
  import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { basename, join } from 'node:path';
3
3
  import { readEnvironmentMetadata } from './environment.js';
4
+ import { applyExternalAsset, writeTextFile } from './external-assets.js';
5
+ import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
4
6
  import { realGit, stageAll } from './git.js';
5
7
  import { isValidPathSegment, validateAddonName, validateRepoPath } from './path-validation.js';
6
8
  import { listModuleRepos, readAddonsYaml } from './repo-actions.js';
@@ -16,10 +18,12 @@ export function renderSafeResetPreview(target, stage) {
16
18
  '- .wpmoo/odoo.json',
17
19
  '- moo',
18
20
  '- .gitignore',
21
+ '- .env.example',
19
22
  '- README.md',
20
23
  '- AGENTS.md',
21
24
  '- docs/appstore-release.md',
22
- '- Compose generated files',
25
+ '- External compose template assets',
26
+ '- External agent skill assets when configured',
23
27
  '',
24
28
  'Will not touch:',
25
29
  '- source repo folders under odoo/custom/src/private',
@@ -32,6 +36,17 @@ export function renderSafeResetPreview(target, stage) {
32
36
  function titleFromTarget(target) {
33
37
  return basename(target).replace(/_dev$/, '') || 'odoo_sample_module';
34
38
  }
39
+ function safeResetExternalAssetOptions(options) {
40
+ return plannedExternalAssetOptions(options).map((assetOptions) => ({
41
+ ...assetOptions,
42
+ exclude: [
43
+ ...(assetOptions.exclude ?? []),
44
+ '.env',
45
+ '.gitmodules',
46
+ 'odoo/custom/src/private',
47
+ ],
48
+ }));
49
+ }
35
50
  function parseAddonsForRepo(addonsYaml, repoPath) {
36
51
  const safeRepoPath = validateRepoPath(repoPath);
37
52
  const lines = addonsYaml.split('\n');
@@ -112,6 +127,7 @@ async function inferOptions(target) {
112
127
  export async function safeResetEnvironment(options, git = realGit) {
113
128
  const scaffoldOptions = await inferOptions(options.target);
114
129
  const files = generatedFiles(scaffoldOptions);
130
+ const externalAssets = safeResetExternalAssetOptions(scaffoldOptions);
115
131
  for (const file of files) {
116
132
  if (file.path === 'odoo/custom/src/addons.yaml') {
117
133
  continue;
@@ -123,6 +139,10 @@ export async function safeResetEnvironment(options, git = realGit) {
123
139
  await chmod(destination, file.mode);
124
140
  }
125
141
  }
142
+ for (const assetOptions of externalAssets) {
143
+ await applyExternalAsset(assetOptions, git);
144
+ }
145
+ await writeTextFile(join(options.target, '.env.example'), renderComposeEnvExample(scaffoldOptions));
126
146
  if (options.stage) {
127
147
  await stageAll(git, options.target);
128
148
  }
package/dist/templates.js CHANGED
@@ -22,6 +22,7 @@ function repositoryLayout(options) {
22
22
  ├── docker-compose_17.0.yml
23
23
  ├── docker-compose_18.0.yml
24
24
  ├── docker-compose_19.0.yml
25
+ ├── moo
25
26
  ├── scripts/
26
27
  ├── etc/
27
28
  ├── odoo/
@@ -100,7 +101,7 @@ function repoDuplicationNote() {
100
101
  }
101
102
  function verificationCommand(options) {
102
103
  const firstAddon = allAddons(options)[0] ?? options.product;
103
- return `./scripts/test.sh ${firstAddon}`;
104
+ return `./moo test ${firstAddon}`;
104
105
  }
105
106
  function environmentUsageDocs(options) {
106
107
  return `## Docker Compose Notes
@@ -125,16 +126,22 @@ Source repositories stay under \`odoo/custom/src/private\`. At container startup
125
126
 
126
127
  \`\`\`bash
127
128
  cp .env.example .env
128
- ./scripts/up.sh
129
- ./scripts/logs.sh
130
- ./scripts/shell.sh
131
- ./scripts/down.sh
129
+ ./moo start
130
+ ./moo logs
131
+ ./moo shell
132
+ ./moo stop
133
+ ./moo doctor
134
+ ./moo resetdb devel sale
135
+ ./moo snapshot devel before-update
136
+ ./moo restore-snapshot before-update devel
137
+ ./moo lint
138
+ ./moo pot sale devel i18n/sale.pot
132
139
  \`\`\`
133
140
 
134
141
  Run tests for one planned product addon:
135
142
 
136
143
  \`\`\`bash
137
- ./scripts/test.sh ${allAddons(options)[0] ?? options.product}
144
+ ./moo test ${allAddons(options)[0] ?? options.product}
138
145
  \`\`\`
139
146
  `;
140
147
  }
@@ -226,7 +233,191 @@ set -euo pipefail
226
233
  script_dir="$(cd -- "$(dirname -- "\${BASH_SOURCE[0]}")" && pwd)"
227
234
  cd "$script_dir"
228
235
 
229
- exec npx --yes @wpmoo/odoo@latest "$@"
236
+ usage() {
237
+ case "$1" in
238
+ "start") echo "Usage: ./moo start" ;;
239
+ "stop") echo "Usage: ./moo stop" ;;
240
+ "logs") echo "Usage: ./moo logs [service]" ;;
241
+ "restart") echo "Usage: ./moo restart" ;;
242
+ "shell") echo "Usage: ./moo shell" ;;
243
+ "psql") echo "Usage: ./moo psql [db]" ;;
244
+ "install") echo "Usage: ./moo install <module[,module]> [db]" ;;
245
+ "update") echo "Usage: ./moo update <module[,module]> [db]" ;;
246
+ "test") echo "Usage: ./moo test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]" ;;
247
+ "resetdb") echo "Usage: ./moo resetdb [db] [module[,module]]" ;;
248
+ "snapshot") echo "Usage: ./moo snapshot [db] [snapshot-name]" ;;
249
+ "restore-snapshot") echo "Usage: ./moo restore-snapshot <snapshot-name> [db]" ;;
250
+ "lint") echo "Usage: ./moo lint" ;;
251
+ "pot") echo "Usage: ./moo pot <module[,module]> [db] [output]" ;;
252
+ esac
253
+ }
254
+
255
+ fail_usage() {
256
+ usage "$1" >&2
257
+ exit 2
258
+ }
259
+
260
+ require_no_args() {
261
+ local command="$1"
262
+ shift
263
+ if [[ "$#" -ne 0 ]]; then
264
+ fail_usage "$command"
265
+ fi
266
+ }
267
+
268
+ optional_single_arg() {
269
+ local command="$1"
270
+ local fallback="$2"
271
+ shift 2
272
+ if [[ "$#" -gt 1 ]]; then
273
+ fail_usage "$command"
274
+ fi
275
+ printf '%s\\n' "\${1:-$fallback}"
276
+ }
277
+
278
+ require_module_args() {
279
+ local command="$1"
280
+ shift
281
+ if [[ "$#" -lt 1 || "\${1:-}" == -* || "$#" -gt 2 ]]; then
282
+ fail_usage "$command"
283
+ fi
284
+ }
285
+
286
+ positional_args() {
287
+ local command="$1"
288
+ local min="$2"
289
+ local max="$3"
290
+ shift 3
291
+ if [[ "$#" -lt "$min" || "$#" -gt "$max" ]]; then
292
+ fail_usage "$command"
293
+ fi
294
+ for arg in "$@"; do
295
+ if [[ "$arg" == -* ]]; then
296
+ fail_usage "$command"
297
+ fi
298
+ done
299
+ }
300
+
301
+ validate_test_args() {
302
+ if [[ "$#" -lt 1 || "\${1:-}" == -* ]]; then
303
+ fail_usage "test"
304
+ fi
305
+
306
+ shift
307
+ while [[ "$#" -gt 0 ]]; do
308
+ case "$1" in
309
+ "--db"|"--tags")
310
+ if [[ "$#" -lt 2 || "\${2:-}" == --* ]]; then
311
+ echo "Missing value for $1" >&2
312
+ exit 2
313
+ fi
314
+ shift 2
315
+ ;;
316
+ "--mode")
317
+ if [[ "$#" -lt 2 || "\${2:-}" == --* ]]; then
318
+ echo "Missing value for --mode" >&2
319
+ exit 2
320
+ fi
321
+ if [[ "$2" != "init" && "$2" != "update" ]]; then
322
+ echo "Invalid value for --mode: expected init or update" >&2
323
+ exit 2
324
+ fi
325
+ shift 2
326
+ ;;
327
+ *)
328
+ echo "Unknown option for ./moo test: $1" >&2
329
+ exit 2
330
+ ;;
331
+ esac
332
+ done
333
+ }
334
+
335
+ run_script() {
336
+ local script="$1"
337
+ shift
338
+ if [[ ! -x "$script" ]]; then
339
+ echo "Missing daily action script: \${script#./}" >&2
340
+ exit 1
341
+ fi
342
+ exec "$script" "$@"
343
+ }
344
+
345
+ command="\${1:-}"
346
+ case "$command" in
347
+ "start")
348
+ shift
349
+ require_no_args "$command" "$@"
350
+ run_script ./scripts/up.sh
351
+ ;;
352
+ "stop")
353
+ shift
354
+ require_no_args "$command" "$@"
355
+ run_script ./scripts/down.sh
356
+ ;;
357
+ "logs")
358
+ shift
359
+ service="$(optional_single_arg "$command" "odoo" "$@")"
360
+ run_script ./scripts/logs.sh "$service"
361
+ ;;
362
+ "restart")
363
+ shift
364
+ require_no_args "$command" "$@"
365
+ run_script ./scripts/restart.sh
366
+ ;;
367
+ "shell")
368
+ shift
369
+ require_no_args "$command" "$@"
370
+ run_script ./scripts/shell.sh
371
+ ;;
372
+ "psql")
373
+ shift
374
+ db="$(optional_single_arg "$command" "postgres" "$@")"
375
+ run_script ./scripts/psql.sh "$db"
376
+ ;;
377
+ "install")
378
+ shift
379
+ require_module_args "$command" "$@"
380
+ run_script ./scripts/install.sh "$@"
381
+ ;;
382
+ "update")
383
+ shift
384
+ require_module_args "$command" "$@"
385
+ run_script ./scripts/update.sh "$@"
386
+ ;;
387
+ "test")
388
+ shift
389
+ validate_test_args "$@"
390
+ run_script ./scripts/test.sh "$@"
391
+ ;;
392
+ "resetdb")
393
+ shift
394
+ positional_args "$command" 0 2 "$@"
395
+ run_script ./scripts/resetdb.sh "$@"
396
+ ;;
397
+ "snapshot")
398
+ shift
399
+ positional_args "$command" 0 2 "$@"
400
+ run_script ./scripts/snapshot.sh "$@"
401
+ ;;
402
+ "restore-snapshot")
403
+ shift
404
+ positional_args "$command" 1 2 "$@"
405
+ run_script ./scripts/restore-snapshot.sh "$@"
406
+ ;;
407
+ "lint")
408
+ shift
409
+ require_no_args "$command" "$@"
410
+ run_script ./scripts/lint.sh
411
+ ;;
412
+ "pot")
413
+ shift
414
+ positional_args "$command" 1 3 "$@"
415
+ run_script ./scripts/pot.sh "$@"
416
+ ;;
417
+ *)
418
+ exec npx --yes @wpmoo/odoo@latest "$@"
419
+ ;;
420
+ esac
230
421
  `;
231
422
  }
232
423
  export function renderAddonsYaml(options) {
@@ -291,16 +482,20 @@ git submodule update --init --recursive
291
482
 
292
483
  ## WPMoo CLI Shortcut
293
484
 
294
- This environment includes a local \`moo\` delegation script. From the repository
485
+ This environment includes a local \`moo\` shortcut script. From the repository
295
486
  root:
296
487
 
297
488
  \`\`\`bash
298
489
  ./moo
490
+ ./moo start
491
+ ./moo stop
492
+ ./moo restart
493
+ ./moo doctor
299
494
  ./moo add-module
300
495
  \`\`\`
301
496
 
302
- If this repository root is on your \`PATH\`, you can run \`moo ...\` from
303
- anywhere and the script will delegate back to this environment.
497
+ Optionally, if this repository root is on your \`PATH\`, you can run \`moo ...\`
498
+ from anywhere and the script will return to this environment root first.
304
499
  ${optionalAgentSkillsReadme(options)}
305
500
  ## Source Repositories
306
501
 
@@ -371,7 +566,17 @@ Use the environment's addon test/update command:
371
566
  ${verificationCommand(options)}
372
567
  \`\`\`
373
568
 
374
- Only report completion after the relevant update/test command exits cleanly.
569
+ Useful maintenance commands:
570
+
571
+ \`\`\`bash
572
+ ./moo lint
573
+ ./moo resetdb [db] [module[,module]]
574
+ ./moo snapshot [db] [snapshot-name]
575
+ ./moo restore-snapshot <snapshot-name> [db]
576
+ ./moo pot <module[,module]> [db] [output]
577
+ \`\`\`
578
+
579
+ Only report completion after the relevant update/test/lint command exits cleanly.
375
580
  `;
376
581
  }
377
582
  export function renderAppstoreRelease(options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpmoo/odoo",
3
- "version": "0.8.34",
3
+ "version": "0.8.36",
4
4
  "description": "WPMoo Odoo lifecycle tooling for development, staging, and production workflows.",
5
5
  "type": "module",
6
6
  "repository": {