@wpmoo/toolkit 0.9.0

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.
Files changed (46) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +519 -0
  3. package/dist/addons-yaml.js +59 -0
  4. package/dist/args.js +259 -0
  5. package/dist/cli.js +1039 -0
  6. package/dist/cockpit/command-palette.js +23 -0
  7. package/dist/cockpit/command-registry.js +91 -0
  8. package/dist/cockpit/daily-prompts.js +177 -0
  9. package/dist/cockpit/menu.js +99 -0
  10. package/dist/cockpit/safety.js +22 -0
  11. package/dist/compose-layout.js +118 -0
  12. package/dist/daily-actions.js +190 -0
  13. package/dist/doctor.js +519 -0
  14. package/dist/environment-context.js +10 -0
  15. package/dist/environment-version.js +5 -0
  16. package/dist/environment.js +136 -0
  17. package/dist/external-assets.js +153 -0
  18. package/dist/external-templates.js +86 -0
  19. package/dist/git.js +98 -0
  20. package/dist/github.js +87 -0
  21. package/dist/help.js +157 -0
  22. package/dist/menu-navigation.js +67 -0
  23. package/dist/module-actions.js +114 -0
  24. package/dist/odoo-versions.js +1 -0
  25. package/dist/path-validation.js +50 -0
  26. package/dist/prompt-copy.js +8 -0
  27. package/dist/prompt-repositories.js +34 -0
  28. package/dist/prompts/index.js +174 -0
  29. package/dist/repo-actions.js +158 -0
  30. package/dist/repo-url.js +27 -0
  31. package/dist/repository-preflight.js +46 -0
  32. package/dist/safe-reset.js +217 -0
  33. package/dist/scaffold.js +161 -0
  34. package/dist/source-actions.js +65 -0
  35. package/dist/source-manifest.js +338 -0
  36. package/dist/status.js +239 -0
  37. package/dist/templates.js +758 -0
  38. package/dist/types.js +1 -0
  39. package/dist/update-check.js +106 -0
  40. package/dist/version.js +19 -0
  41. package/docs/assets/patreon-donate.png +0 -0
  42. package/docs/assets/wpmoo-banner.png +0 -0
  43. package/docs/external-resources.md +136 -0
  44. package/docs/generated-environment-verification.md +140 -0
  45. package/docs/handoff.md +29 -0
  46. package/package.json +65 -0
@@ -0,0 +1,153 @@
1
+ import { cp, mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
+ import { realGit } from './git.js';
5
+ const defaultExcludes = ['.git', 'node_modules', '.DS_Store'];
6
+ async function pathExists(path) {
7
+ try {
8
+ await stat(path);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ async function statIfExists(path) {
16
+ try {
17
+ return await stat(path);
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ function expandHome(path) {
24
+ if (path === '~')
25
+ return process.env.HOME ?? path;
26
+ if (path.startsWith('~/'))
27
+ return join(process.env.HOME ?? '~', path.slice(2));
28
+ return path;
29
+ }
30
+ export function gitUrlFromSource(source) {
31
+ if (source.startsWith('gh:')) {
32
+ return `https://github.com/${source.slice(3).replace(/\.git$/, '')}.git`;
33
+ }
34
+ if (source.startsWith('git:github.com/')) {
35
+ return `https://github.com/${source.slice('git:github.com/'.length).replace(/\.git$/, '')}.git`;
36
+ }
37
+ if (/^(https?:|ssh:|git:)/.test(source) || /^[^\s@]+@[^\s:]+:.+/.test(source)) {
38
+ return source;
39
+ }
40
+ return undefined;
41
+ }
42
+ async function checkoutSource(git, source, ref) {
43
+ const gitUrl = gitUrlFromSource(source);
44
+ if (!gitUrl) {
45
+ return { root: resolve(expandHome(source)) };
46
+ }
47
+ const tempRoot = await mkdtemp(join(tmpdir(), 'wpmoo-external-'));
48
+ const cloneDir = join(tempRoot, 'source');
49
+ const cloneArgs = ['clone', '--depth', '1'];
50
+ if (ref) {
51
+ cloneArgs.push('--branch', ref);
52
+ }
53
+ cloneArgs.push(gitUrl, cloneDir);
54
+ try {
55
+ await git.run(tempRoot, cloneArgs);
56
+ }
57
+ catch (error) {
58
+ if (!ref) {
59
+ await rm(tempRoot, { recursive: true, force: true });
60
+ throw error;
61
+ }
62
+ await rm(cloneDir, { recursive: true, force: true });
63
+ await git.run(tempRoot, ['clone', gitUrl, cloneDir]);
64
+ await git.run(cloneDir, ['checkout', ref]);
65
+ }
66
+ return { root: cloneDir, cleanup: tempRoot };
67
+ }
68
+ function isExcluded(relativePath, excludes) {
69
+ const normalized = relativePath.split('\\').join('/');
70
+ return excludes.some((pattern) => normalized === pattern || normalized.startsWith(`${pattern}/`));
71
+ }
72
+ async function removeDestinationTypeConflicts(sourcePath, destinationPath, excludes) {
73
+ async function visit(source) {
74
+ const rel = relative(sourcePath, source);
75
+ if (rel && isExcluded(rel, excludes))
76
+ return;
77
+ const sourceStat = await stat(source);
78
+ const destination = rel ? join(destinationPath, rel) : destinationPath;
79
+ const destinationStat = await statIfExists(destination);
80
+ if (destinationStat && sourceStat.isDirectory() !== destinationStat.isDirectory()) {
81
+ await rm(destination, { recursive: true, force: true });
82
+ }
83
+ if (!sourceStat.isDirectory()) {
84
+ return;
85
+ }
86
+ const entries = await readdir(source);
87
+ await Promise.all(entries.map((entry) => visit(join(source, entry))));
88
+ }
89
+ await visit(sourcePath);
90
+ }
91
+ async function copyDirectory(options, checkedOut) {
92
+ const selectedSourceSubdir = await selectSourceSubdir(options, checkedOut.root);
93
+ const sourcePath = selectedSourceSubdir ? join(checkedOut.root, selectedSourceSubdir) : checkedOut.root;
94
+ const destinationPath = options.destinationSubdir
95
+ ? join(options.destination, options.destinationSubdir)
96
+ : options.destination;
97
+ if (!(await pathExists(sourcePath))) {
98
+ throw new Error(`External asset source path does not exist: ${sourcePath}`);
99
+ }
100
+ const excludes = [...defaultExcludes, ...(options.exclude ?? [])];
101
+ await mkdir(destinationPath, { recursive: true });
102
+ await removeDestinationTypeConflicts(sourcePath, destinationPath, excludes);
103
+ await cp(sourcePath, destinationPath, {
104
+ recursive: true,
105
+ force: true,
106
+ filter: (source) => {
107
+ const rel = relative(sourcePath, source);
108
+ return !rel || !isExcluded(rel, excludes);
109
+ },
110
+ });
111
+ if (options.readmeDestination) {
112
+ const selectedReadmePath = selectedSourceSubdir ? join(checkedOut.root, selectedSourceSubdir, 'README.md') : undefined;
113
+ const readmePath = selectedReadmePath && (await pathExists(selectedReadmePath))
114
+ ? selectedReadmePath
115
+ : join(checkedOut.root, 'README.md');
116
+ if (await pathExists(readmePath)) {
117
+ const destination = join(options.destination, options.readmeDestination);
118
+ await mkdir(dirname(destination), { recursive: true });
119
+ await cp(readmePath, destination, { force: true });
120
+ }
121
+ }
122
+ }
123
+ async function selectSourceSubdir(options, root) {
124
+ for (const candidate of options.sourceSubdirCandidates ?? []) {
125
+ if (await pathExists(join(root, candidate))) {
126
+ return candidate;
127
+ }
128
+ }
129
+ return options.sourceSubdir;
130
+ }
131
+ export function renderExternalAssetCommand(options) {
132
+ const sourcePath = options.sourceSubdir ? `${options.source}/${options.sourceSubdir}` : options.source;
133
+ const destinationPath = options.destinationSubdir
134
+ ? `${options.destination}/${options.destinationSubdir}`
135
+ : options.destination;
136
+ const ref = options.ref ? `#${options.ref}` : '';
137
+ return `copy external ${options.label}: ${sourcePath}${ref} -> ${destinationPath}`;
138
+ }
139
+ export async function applyExternalAsset(options, git = realGit) {
140
+ const checkedOut = await checkoutSource(git, options.source, options.ref);
141
+ try {
142
+ await copyDirectory(options, checkedOut);
143
+ }
144
+ finally {
145
+ if (checkedOut.cleanup) {
146
+ await rm(checkedOut.cleanup, { recursive: true, force: true });
147
+ }
148
+ }
149
+ }
150
+ export async function writeTextFile(path, content) {
151
+ await mkdir(dirname(path), { recursive: true });
152
+ await writeFile(path, content, 'utf8');
153
+ }
@@ -0,0 +1,86 @@
1
+ export const defaultComposeTemplateUrl = 'gh:wpmoo-org/odoo-docker-compose';
2
+ export const defaultAgentSkillsTemplateUrl = 'gh:wpmoo-org/odoo-skills';
3
+ function odooMajorVersion(odooVersion) {
4
+ return odooVersion.split('.', 1)[0] || odooVersion;
5
+ }
6
+ export function defaultPostgresVersion(odooVersion) {
7
+ const major = odooMajorVersion(odooVersion);
8
+ if (major === '19')
9
+ return '18';
10
+ if (major === '18')
11
+ return '17';
12
+ if (major === '17')
13
+ return '15';
14
+ if (major === '16')
15
+ return '14';
16
+ return '17';
17
+ }
18
+ export function defaultHttpPort(odooVersion) {
19
+ const major = odooMajorVersion(odooVersion).padStart(2, '0');
20
+ return `100${major}`;
21
+ }
22
+ export function defaultGeventPort(odooVersion) {
23
+ const major = odooMajorVersion(odooVersion).padStart(2, '0');
24
+ return `200${major}`;
25
+ }
26
+ export function defaultTestModule(options) {
27
+ return options.sourceRepos.flatMap((repo) => repo.addons)[0] ?? options.product;
28
+ }
29
+ export function renderComposeEnvExample(options) {
30
+ return [
31
+ '# Copy to .env and edit for local development.',
32
+ `ODOO_VERSION=${options.odooVersion}`,
33
+ `ODOO_IMAGE=odoo:${odooMajorVersion(options.odooVersion)}`,
34
+ `POSTGRES_IMAGE=postgres:${options.postgresVersion ?? defaultPostgresVersion(options.odooVersion)}`,
35
+ `HTTP_PORT=${options.httpPort ?? defaultHttpPort(options.odooVersion)}`,
36
+ `GEVENT_PORT=${options.geventPort ?? defaultGeventPort(options.odooVersion)}`,
37
+ 'POSTGRES_PASSWORD=odoo',
38
+ 'ODOO_MASTER_PASSWORD=admin',
39
+ `ODOO_TEST_MODULE=${defaultTestModule(options)}`,
40
+ 'WPMOO_ENV=dev',
41
+ 'WPMOO_SNAPSHOT_RETENTION_COUNT=0',
42
+ '',
43
+ '# Required only when intentionally running destructive database actions',
44
+ '# such as resetdb or restore-snapshot with WPMOO_ENV=stage or WPMOO_ENV=prod.',
45
+ '# WPMOO_ALLOW_DESTRUCTIVE=1',
46
+ '',
47
+ ].join('\n');
48
+ }
49
+ export function composeTemplateOptions(options) {
50
+ return {
51
+ label: 'compose',
52
+ source: options.composeTemplateUrl ?? defaultComposeTemplateUrl,
53
+ destination: options.target,
54
+ ref: options.composeTemplateRef,
55
+ sourceSubdirCandidates: ['resources/generated-env'],
56
+ exclude: [
57
+ '.github',
58
+ 'docs/assets',
59
+ 'test',
60
+ 'README.md',
61
+ 'README-template.md',
62
+ '.gitignore',
63
+ 'LICENSE',
64
+ 'package.json',
65
+ 'package-lock.json',
66
+ ],
67
+ readmeDestination: 'docs/compose.md',
68
+ };
69
+ }
70
+ export function agentSkillsTemplateOptions(options) {
71
+ if (!options.agentSkillsTemplateUrl) {
72
+ return undefined;
73
+ }
74
+ return {
75
+ label: 'agent-skills',
76
+ source: options.agentSkillsTemplateUrl,
77
+ destination: options.target,
78
+ ref: options.agentSkillsTemplateRef,
79
+ sourceSubdir: 'skills',
80
+ destinationSubdir: '.agents/skills',
81
+ exclude: ['package.json', 'package-lock.json'],
82
+ };
83
+ }
84
+ export function plannedExternalAssetOptions(options) {
85
+ return [composeTemplateOptions(options), agentSkillsTemplateOptions(options)].filter((assetOptions) => Boolean(assetOptions));
86
+ }
package/dist/git.js ADDED
@@ -0,0 +1,98 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, join } from 'node:path';
4
+ import { execa } from 'execa';
5
+ export const realGit = {
6
+ async run(cwd, args) {
7
+ const result = await execa('git', args, {
8
+ cwd,
9
+ env: {
10
+ ...process.env,
11
+ GIT_ALLOW_PROTOCOL: 'file:https:ssh:git',
12
+ },
13
+ });
14
+ return { stdout: result.stdout, stderr: result.stderr };
15
+ },
16
+ };
17
+ export async function hasRemoteHeads(git, cwd, repoUrl) {
18
+ const result = await git.run(cwd, ['ls-remote', '--heads', repoUrl]);
19
+ return result.stdout.trim().length > 0;
20
+ }
21
+ export async function initializeEmptyRemote(git, cwd, repoUrl, branch) {
22
+ const tempRoot = await mkdtemp(join(tmpdir(), 'wpmoo-init-'));
23
+ const cloneDir = join(tempRoot, basename(repoUrl).replace(/\.git$/, '') || 'repo');
24
+ try {
25
+ await git.run(tempRoot, ['clone', repoUrl, cloneDir]);
26
+ await git.run(cloneDir, ['config', 'user.name', 'Create Odoo Dev Bot']);
27
+ await git.run(cloneDir, ['config', 'user.email', 'dev@example.com']);
28
+ await git.run(cloneDir, ['commit', '--allow-empty', '-m', 'Initial commit']);
29
+ await git.run(cloneDir, ['push', 'origin', `HEAD:${branch}`]);
30
+ }
31
+ finally {
32
+ await rm(tempRoot, { recursive: true, force: true });
33
+ }
34
+ }
35
+ export async function ensureRemoteHasBranch(git, cwd, repoUrl, branch, initEmptyRepos) {
36
+ const hasHeads = await hasRemoteHeads(git, cwd, repoUrl);
37
+ if (!hasHeads) {
38
+ if (!initEmptyRepos) {
39
+ throw new Error(`Repository has no commits: ${repoUrl}`);
40
+ }
41
+ await initializeEmptyRemote(git, cwd, repoUrl, branch);
42
+ return;
43
+ }
44
+ const branchResult = await git.run(cwd, ['ls-remote', '--heads', repoUrl, branch]);
45
+ if (!branchResult.stdout.trim()) {
46
+ throw new Error(`Repository ${repoUrl} does not have branch ${branch}`);
47
+ }
48
+ }
49
+ export async function addSubmodule(git, target, repoUrl, branch, path) {
50
+ await git.run(target, ['-c', 'protocol.file.allow=always', 'submodule', 'add', '-b', branch, repoUrl, path]);
51
+ }
52
+ export async function isTrackedPath(git, target, path) {
53
+ try {
54
+ await git.run(target, ['ls-files', '--error-unmatch', path]);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ export async function ensureSubmodule(git, target, repoUrl, branch, path) {
62
+ if (await isTrackedPath(git, target, path)) {
63
+ await git.run(target, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init', '--recursive', path]);
64
+ return;
65
+ }
66
+ await addSubmodule(git, target, repoUrl, branch, path);
67
+ }
68
+ export async function hasUncommittedChanges(git, cwd) {
69
+ const result = await git.run(cwd, ['status', '--porcelain']);
70
+ return result.stdout.trim().length > 0;
71
+ }
72
+ export async function removeSubmodule(git, target, path) {
73
+ try {
74
+ await git.run(target, ['submodule', 'deinit', '-f', path]);
75
+ }
76
+ catch {
77
+ // `git rm` below is the authoritative removal; deinit can fail for partially initialized submodules.
78
+ }
79
+ await git.run(target, ['rm', '-f', path]);
80
+ }
81
+ export async function cloneRepository(git, cwd, repoUrl, target) {
82
+ await git.run(cwd, ['clone', repoUrl, target]);
83
+ }
84
+ export async function syncSubmodules(git, target) {
85
+ await git.run(target, ['submodule', 'sync', '--recursive']);
86
+ }
87
+ export async function stageAll(git, target) {
88
+ await git.run(target, ['add', '.']);
89
+ }
90
+ export async function getOriginUrl(git, target) {
91
+ try {
92
+ const result = await git.run(target, ['remote', 'get-url', 'origin']);
93
+ return result.stdout.trim() || undefined;
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
package/dist/github.js ADDED
@@ -0,0 +1,87 @@
1
+ import { execa } from 'execa';
2
+ export const realGitHub = {
3
+ async run(args) {
4
+ const result = await execa('gh', args);
5
+ return { stdout: result.stdout, stderr: result.stderr };
6
+ },
7
+ };
8
+ export function parseGitHubRepoUrl(repoUrl) {
9
+ const normalized = repoUrl.trim().replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
10
+ const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/);
11
+ if (httpsMatch) {
12
+ return { owner: httpsMatch[1], name: httpsMatch[2] };
13
+ }
14
+ const sshMatch = normalized.match(/^git@github\.com:([^/]+)\/([^/]+)$/);
15
+ if (sshMatch) {
16
+ return { owner: sshMatch[1], name: sshMatch[2] };
17
+ }
18
+ return undefined;
19
+ }
20
+ export function githubSlug(repoUrl) {
21
+ const repo = parseGitHubRepoUrl(repoUrl);
22
+ return repo ? `${repo.owner}/${repo.name}` : undefined;
23
+ }
24
+ export function githubRepositoryUrl(owner, repo) {
25
+ return `https://github.com/${owner}/${repo}.git`;
26
+ }
27
+ export async function isGitHubCliAvailable(runner = realGitHub) {
28
+ try {
29
+ await runner.run(['--version']);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ export async function getAuthenticatedGitHubLogin(runner = realGitHub) {
37
+ const result = await runner.run(['api', 'user', '--jq', '.login']);
38
+ const login = result.stdout.trim();
39
+ if (!login) {
40
+ throw new Error('GitHub CLI is not authenticated');
41
+ }
42
+ return login;
43
+ }
44
+ export async function isGitHubAuthenticated(runner = realGitHub) {
45
+ try {
46
+ await getAuthenticatedGitHubLogin(runner);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export async function listGitHubOrganizations(runner = realGitHub) {
54
+ const result = await runner.run(['api', 'user/orgs', '--jq', '.[].login']);
55
+ return result.stdout
56
+ .split('\n')
57
+ .map((line) => line.trim())
58
+ .filter(Boolean);
59
+ }
60
+ export async function getGitHubAccounts(runner = realGitHub) {
61
+ const user = await getAuthenticatedGitHubLogin(runner);
62
+ const organizations = await listGitHubOrganizations(runner);
63
+ return [
64
+ { login: user, type: 'user' },
65
+ ...organizations.map((login) => ({ login, type: 'organization' })),
66
+ ];
67
+ }
68
+ export async function getGitHubRepositoryStatus(runner, repoUrl) {
69
+ const slug = githubSlug(repoUrl);
70
+ if (!slug) {
71
+ return { status: 'unsupported' };
72
+ }
73
+ try {
74
+ await runner.run(['repo', 'view', slug, '--json', 'name']);
75
+ return { status: 'accessible', slug };
76
+ }
77
+ catch {
78
+ return { status: 'inaccessible', slug };
79
+ }
80
+ }
81
+ export async function createGitHubRepository(runner, repoUrl, visibility) {
82
+ const slug = githubSlug(repoUrl);
83
+ if (!slug) {
84
+ throw new Error(`Only GitHub repository URLs can be created automatically: ${repoUrl}`);
85
+ }
86
+ await runner.run(['repo', 'create', slug, visibility === 'private' ? '--private' : '--public']);
87
+ }
package/dist/help.js ADDED
@@ -0,0 +1,157 @@
1
+ export function renderHelp() {
2
+ return `@wpmoo/toolkit
3
+
4
+ WPMoo Toolkit for Odoo lifecycle workflows.
5
+
6
+ Usage:
7
+ npx @wpmoo/toolkit
8
+ npx wpmoo
9
+ npx @wpmoo/toolkit create --product <slug> [--target <path>] --dev-repo-url <url> --source-repo-url <url>
10
+ npx @wpmoo/toolkit status
11
+ npx @wpmoo/toolkit status --json
12
+ npx @wpmoo/toolkit add-repo --repo-url <url> [--source-type private|oca|external]
13
+ npx @wpmoo/toolkit remove-repo --repo <name>
14
+ npx @wpmoo/toolkit source list
15
+ npx @wpmoo/toolkit source list --json
16
+ npx @wpmoo/toolkit source sync
17
+ npx @wpmoo/toolkit source sync --json
18
+ npx @wpmoo/toolkit source add --repo-url <url> [--source-type private|oca|external]
19
+ npx @wpmoo/toolkit source remove --repo <name> [--source-type private|oca|external]
20
+ npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> [--source-type <category>]
21
+ npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> [--source-type <category>]
22
+ npx @wpmoo/toolkit reset [--dry-run]
23
+ npx @wpmoo/toolkit doctor [--fix]
24
+ npx @wpmoo/toolkit doctor --json
25
+ npx @wpmoo/toolkit start
26
+ npx @wpmoo/toolkit stop
27
+ npx @wpmoo/toolkit logs [service]
28
+ npx @wpmoo/toolkit restart
29
+ npx @wpmoo/toolkit shell
30
+ npx @wpmoo/toolkit psql [db]
31
+ npx @wpmoo/toolkit install <module[,module]> [db]
32
+ npx @wpmoo/toolkit update <module[,module]> [db]
33
+ npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
34
+ npx @wpmoo/toolkit resetdb [db] [module[,module]]
35
+ npx @wpmoo/toolkit snapshot [db] [snapshot-name]
36
+ npx @wpmoo/toolkit restore-snapshot [--dry-run] <snapshot-name> [db]
37
+ npx @wpmoo/toolkit lint
38
+ npx @wpmoo/toolkit pot <module[,module]> [db] [output]
39
+
40
+ Options:
41
+ --product <slug> Product slug, for example my_odoo_module.
42
+ --odoo-version <branch> Odoo branch to pin submodules to. Default: 19.0.
43
+ --dev-repo-url <url> Optional development environment repository URL for docs.
44
+ --target <path> Target dev repo directory. Default: ./<product>_dev.
45
+ --engine <value> Environment engine: compose. Default: compose.
46
+ --compose-template-url <url> Standalone compose resource source. Default: gh:wpmoo-org/odoo-docker-compose.
47
+ --compose-template-ref <ref> Git ref for the compose resource.
48
+ --agent-skills-template Install project Agent Skills from a standalone skills resource.
49
+ --agent-skills-template-url <url>
50
+ Agent Skills resource source. Default: gh:wpmoo-org/odoo-skills.
51
+ --agent-skills-template-ref <ref>
52
+ Git ref for the Agent Skills resource.
53
+ --postgres-version <value> PostgreSQL image version written to compose .env.example.
54
+ --http-port <port> Host HTTP port written to .env.example.
55
+ --gevent-port <port> Host gevent/live chat port written to .env.example.
56
+ --json Emit machine-readable JSON. Human-readable output remains the default.
57
+ --repo-url <url> Source repo URL for add-repo.
58
+ --source-type <category> Source repo category for add-repo/remove-repo/add-module/remove-module. One of private, oca, external. Default: private.
59
+ --repo <name> Source repo folder name for repo/module actions.
60
+ --module <name> Odoo module technical name for module actions.
61
+ --delete-files Also delete module files in remove-module. Default: false.
62
+ --odoo-version <branch> Override the environment Odoo branch for add-repo/add-module.
63
+ --source-repo-url <url> Source repo URL. Repeat for multiple repos.
64
+ --source-path <path> Advanced: local folder for the preceding source repo.
65
+ --source-addons <list> Advanced: comma-separated addons for the preceding source repo.
66
+ --create-missing-repos Create inaccessible GitHub repos with gh CLI.
67
+ --repo-visibility <value> Visibility for created repos: private or public. Default: private.
68
+ --init-empty-repos Initialize empty source repos with the selected branch.
69
+ --dry-run Print planned files and commands without writing.
70
+ --stage=false Do not run git add .
71
+ --no-update-check Skip the startup npm update check.
72
+ --version, -v Show the package version.
73
+ --help, -h Show this help.
74
+
75
+ Package aliases:
76
+ npx @wpmoo/toolkit is the official package path.
77
+ npx wpmoo is the short alias.
78
+ npx @wpmoo/odoo and npx @wpmoo/odoo-dev remain legacy compatibility paths.
79
+
80
+ Daily actions:
81
+ Daily actions must be run from a generated environment root containing .wpmoo/odoo.json.
82
+ They delegate to the fixed scripts copied from the compose resource under ./scripts.
83
+ Generated environments also include ./moo for local compose commands such as ./moo start.
84
+ Use ./moo or npx @wpmoo/toolkit with the same daily action arguments.
85
+
86
+ Cockpit:
87
+ Run npx @wpmoo/toolkit inside a generated environment to open the cockpit.
88
+ Use Command palette / to search slash commands across services, modules, database,
89
+ diagnostics, repositories, and maintenance categories.
90
+ Direct commands such as npx @wpmoo/toolkit status and npx @wpmoo/toolkit test remain available.
91
+
92
+ Wizard local-only path:
93
+ Run npx @wpmoo/toolkit from a workspace directory to open the create wizard.
94
+ Choose any environment folder; the default is ./<product>_dev.
95
+ Skip Git/GitHub connection to create a local-only environment.
96
+ Add source repos later from the cockpit or with add-repo.
97
+
98
+ Status and doctor:
99
+ status: fast and offline. Reads local environment metadata and files only.
100
+ doctor: deeper health check. May check Docker CLI access and GitHub workflows.
101
+ doctor --fix: applies safe file-level repairs. Runs doctor again after fixes.
102
+
103
+ Task recipes:
104
+ Create environment:
105
+ npx @wpmoo/toolkit
106
+ npx @wpmoo/toolkit create --product <slug> --dev-repo-url <url> --source-repo-url <url>
107
+ Create local-only environment:
108
+ npx @wpmoo/toolkit
109
+ Add source repo:
110
+ npx @wpmoo/toolkit add-repo --repo-url <url> --source-type oca
111
+ Inspect and sync source manifest:
112
+ npx @wpmoo/toolkit source list
113
+ npx @wpmoo/toolkit source sync
114
+ Add module:
115
+ npx @wpmoo/toolkit add-module --repo <source-repo> --module <module-name> --source-type private|oca|external
116
+ Remove module:
117
+ npx @wpmoo/toolkit remove-module --repo <source-repo> --module <module-name> --source-type private|oca|external
118
+ Add OCA module:
119
+ npx @wpmoo/toolkit add-module --repo sale-workflow --module sale_order_line_no_discount --source-type oca
120
+ Run tests:
121
+ npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode init|update] [--tags <tags>]
122
+ Safe reset and recover:
123
+ npx @wpmoo/toolkit snapshot [db] [snapshot-name]
124
+ npx @wpmoo/toolkit reset --dry-run
125
+ npx @wpmoo/toolkit reset
126
+ npx @wpmoo/toolkit restore-snapshot --dry-run <snapshot-name> [db]
127
+ npx @wpmoo/toolkit restore-snapshot <snapshot-name> [db]
128
+ Daily command checks:
129
+ npx @wpmoo/toolkit status
130
+ npx @wpmoo/toolkit doctor
131
+ npx @wpmoo/toolkit doctor --fix
132
+ npx @wpmoo/toolkit logs [service]
133
+ npx @wpmoo/toolkit restart
134
+
135
+ Machine-readable JSON output:
136
+ for automation and VS Code cockpit integration while keeping default human-readable output.
137
+ npx @wpmoo/toolkit status --json
138
+ npx @wpmoo/toolkit source list --json
139
+ npx @wpmoo/toolkit source sync --json
140
+ npx @wpmoo/toolkit doctor --json
141
+
142
+ Example:
143
+ npx @wpmoo/toolkit create \\
144
+ --product odoo_sample_module \\
145
+ --odoo-version 19.0 \\
146
+ --target ./custom_odoo_dev \\
147
+ --dev-repo-url https://github.com/example-org/odoo_sample_module_dev.git \\
148
+ --source-repo-url https://github.com/example-org/odoo_sample_module.git
149
+
150
+ Compose resource example:
151
+ npx @wpmoo/toolkit create \\
152
+ --engine compose \\
153
+ --product odoo_sample_module \\
154
+ --source-repo-url https://github.com/example-org/odoo_sample_module.git \\
155
+ --agent-skills-template
156
+ `;
157
+ }
@@ -0,0 +1,67 @@
1
+ import { emitKeypressEvents } from 'node:readline';
2
+ let lastPromptCancelKey;
3
+ export class MenuBackSignal extends Error {
4
+ constructor() {
5
+ super('Return to previous menu');
6
+ this.name = 'MenuBackSignal';
7
+ }
8
+ }
9
+ export function isMenuBackSignal(error) {
10
+ return error instanceof MenuBackSignal;
11
+ }
12
+ export function menuIntroTitle(title, action) {
13
+ return action === 'back' ? `${title} · Back (Esc)` : title;
14
+ }
15
+ export function menuPromptMessage(message, action) {
16
+ return action === 'back' ? `${message} · Esc to go back` : message;
17
+ }
18
+ export function promptCancelOutcome(cancelled, action, key) {
19
+ if (!cancelled) {
20
+ return 'continue';
21
+ }
22
+ if (action === 'back' && key !== 'interrupt') {
23
+ return 'back';
24
+ }
25
+ return 'exit';
26
+ }
27
+ export function recordPromptCancelKey(key) {
28
+ if (key.ctrl && key.name === 'c') {
29
+ lastPromptCancelKey = 'interrupt';
30
+ return;
31
+ }
32
+ if (key.name === 'escape' || key.sequence === '\u001B') {
33
+ lastPromptCancelKey = 'escape';
34
+ return;
35
+ }
36
+ lastPromptCancelKey = 'other';
37
+ }
38
+ export function consumePromptCancelKey() {
39
+ const key = lastPromptCancelKey;
40
+ lastPromptCancelKey = undefined;
41
+ return key;
42
+ }
43
+ export function installPromptCancelKeyTracker(input = process.stdin) {
44
+ emitKeypressEvents(input);
45
+ const listener = (_value, key) => {
46
+ if (key.ctrl || key.name === 'escape' || key.sequence === '\u001B') {
47
+ recordPromptCancelKey(key);
48
+ }
49
+ };
50
+ input.on('keypress', listener);
51
+ return () => input.off('keypress', listener);
52
+ }
53
+ export function handlePromptCancel(cancelled, action) {
54
+ const outcome = promptCancelOutcome(cancelled, action, consumePromptCancelKey());
55
+ if (outcome === 'continue') {
56
+ return;
57
+ }
58
+ if (outcome === 'back') {
59
+ throw new MenuBackSignal();
60
+ }
61
+ process.exit(1);
62
+ }
63
+ export function handleUnavailableMenuChoice(action) {
64
+ if (action === 'back') {
65
+ throw new MenuBackSignal();
66
+ }
67
+ }