@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,217 @@
1
+ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { environmentMetadata, readEnvironmentMetadata } from './environment.js';
4
+ import { applyExternalAsset, writeTextFile } from './external-assets.js';
5
+ import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
6
+ import { realGit, stageAll } from './git.js';
7
+ import { isValidPathSegment, validateAddonName, validateRepoPath } from './path-validation.js';
8
+ import { readAddonsYaml } from './repo-actions.js';
9
+ import { generatedFiles } from './scaffold.js';
10
+ import { listGitmoduleSources } from './source-manifest.js';
11
+ const safeResetProtectedPaths = [
12
+ 'data',
13
+ 'backups',
14
+ '.env',
15
+ '.gitmodules',
16
+ 'odoo/custom/src/private',
17
+ 'odoo/custom/src/oca',
18
+ 'odoo/custom/src/external',
19
+ 'odoo/custom/patches',
20
+ 'odoo/custom/manifests',
21
+ ].map((path) => path.replace(/\/$/, ''));
22
+ const safeResetProtectedGeneratedReadmes = new Set([
23
+ 'odoo/custom/src/private/README.md',
24
+ 'odoo/custom/src/oca/README.md',
25
+ 'odoo/custom/src/external/README.md',
26
+ 'odoo/custom/patches/README.md',
27
+ 'odoo/custom/manifests/README.md',
28
+ ]);
29
+ function isProtectedGeneratedFile(filePath) {
30
+ return safeResetProtectedGeneratedReadmes.has(filePath);
31
+ }
32
+ function mergeEnvironmentMetadata(target, options) {
33
+ const generated = environmentMetadata(options);
34
+ return readFile(join(target, '.wpmoo/odoo.json'), 'utf8')
35
+ .then((content) => JSON.parse(content))
36
+ .then((existing) => {
37
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
38
+ return `${JSON.stringify(generated, null, 2)}\n`;
39
+ }
40
+ return `${JSON.stringify({ ...existing, ...generated, sourceRepos: generated.sourceRepos }, null, 2)}\n`;
41
+ })
42
+ .catch(() => `${JSON.stringify(generated, null, 2)}\n`);
43
+ }
44
+ export function renderSafeResetPreview(target, stage) {
45
+ return [
46
+ 'Safe reset will refresh generated WPMoo environment files.',
47
+ '',
48
+ 'Target:',
49
+ target,
50
+ '',
51
+ 'Will update:',
52
+ '- .wpmoo/odoo.json',
53
+ '- moo',
54
+ '- .gitignore',
55
+ '- .env.example',
56
+ '- README.md',
57
+ '- AGENTS.md',
58
+ '- docs/appstore-release.md',
59
+ '- External compose template assets',
60
+ '- External agent skill assets when configured',
61
+ '',
62
+ 'Will not touch:',
63
+ '- source repo folders under odoo/custom/src/private',
64
+ '- module source code',
65
+ '- Git history, remotes, or branches',
66
+ '- .env, data, and backups',
67
+ '- custom source layout directories (oca, external, patches, manifests)',
68
+ '- Legacy compose template files may remain until manually removed: docs/assets/, test/, .github/',
69
+ '',
70
+ 'Preview-only output; files are not changed until reset is executed.',
71
+ '',
72
+ stage ? 'Generated changes will be staged with git add .' : 'Generated changes will not be staged.',
73
+ ].join('\n');
74
+ }
75
+ function titleFromTarget(target) {
76
+ return basename(target).replace(/_dev$/, '') || 'odoo_sample_module';
77
+ }
78
+ function safeResetExternalAssetOptions(options) {
79
+ return plannedExternalAssetOptions(options).map((assetOptions) => ({
80
+ ...assetOptions,
81
+ exclude: [
82
+ ...(assetOptions.exclude ?? []),
83
+ ...safeResetProtectedPaths,
84
+ ],
85
+ }));
86
+ }
87
+ function parseAddonsForRepo(addonsYaml, repoPath) {
88
+ const safeRepoPath = validateRepoPath(repoPath);
89
+ const lines = addonsYaml.split('\n');
90
+ const header = `private/${safeRepoPath}:`;
91
+ const start = lines.findIndex((line) => line.trim() === header);
92
+ if (start === -1)
93
+ return [safeRepoPath];
94
+ const addons = [];
95
+ for (let index = start + 1; index < lines.length; index += 1) {
96
+ const line = lines[index];
97
+ if (!line.startsWith(' '))
98
+ break;
99
+ const match = line.trim().match(/^-\s+(.+)$/);
100
+ const addon = match?.[1]?.trim();
101
+ if (addon && isValidPathSegment(addon)) {
102
+ addons.push(validateAddonName(addon));
103
+ }
104
+ }
105
+ return addons.length ? addons : [safeRepoPath];
106
+ }
107
+ function parseRepoPathsFromAddonsYaml(addonsYaml) {
108
+ return [...addonsYaml.matchAll(/^private\/(.+):$/gm)]
109
+ .map((match) => match[1].trim())
110
+ .filter((repoPath) => repoPath && isValidPathSegment(repoPath))
111
+ .map(validateRepoPath);
112
+ }
113
+ async function readSubmoduleUrl(target, repoPath, sourceType) {
114
+ const safeRepoPath = validateRepoPath(repoPath);
115
+ try {
116
+ const gitmodules = await readFile(join(target, '.gitmodules'), 'utf8');
117
+ const escapedPath = `odoo/custom/src/${sourceType}/${safeRepoPath}`;
118
+ const sections = gitmodules.split(/\n(?=\[submodule )/);
119
+ const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
120
+ const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
121
+ return url || `odoo/custom/src/${sourceType}/${safeRepoPath}`;
122
+ }
123
+ catch {
124
+ return `odoo/custom/src/${sourceType}/${safeRepoPath}`;
125
+ }
126
+ }
127
+ async function pathExists(path) {
128
+ try {
129
+ await stat(path);
130
+ return true;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ async function inferOptions(target) {
137
+ const metadata = await readEnvironmentMetadata(target);
138
+ const addonsYaml = await readAddonsYaml(target);
139
+ const gitmoduleSources = await listGitmoduleSources(target);
140
+ const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
141
+ const sourceByKey = new Map();
142
+ for (const repo of metadata?.sourceRepos ?? []) {
143
+ if (isValidPathSegment(repo.path)) {
144
+ const sourceType = repo.sourceType ?? 'private';
145
+ const path = validateRepoPath(repo.path);
146
+ sourceByKey.set(`${sourceType}:${path}`, { sourceType, path });
147
+ }
148
+ }
149
+ for (const repo of gitmoduleSources) {
150
+ sourceByKey.set(`${repo.type}:${repo.path}`, { sourceType: repo.type, path: repo.path });
151
+ }
152
+ for (const repoPath of addonRepos) {
153
+ sourceByKey.set(`private:${repoPath}`, { sourceType: 'private', path: repoPath });
154
+ }
155
+ const sourceLocations = [...sourceByKey.values()];
156
+ const product = metadata?.product ?? sourceLocations[0]?.path ?? titleFromTarget(target);
157
+ const sourceRepos = await Promise.all(sourceLocations.map(async ({ sourceType, path }) => ({
158
+ path,
159
+ sourceType,
160
+ url: metadata?.sourceRepos
161
+ .find((repo) => repo.path === path && (repo.sourceType ?? 'private') === sourceType)
162
+ ?.url.trim() ||
163
+ gitmoduleSources.find((repo) => repo.path === path && repo.type === sourceType)?.url ||
164
+ (await readSubmoduleUrl(target, path, sourceType)),
165
+ addons: parseAddonsForRepo(addonsYaml, path),
166
+ })));
167
+ return {
168
+ product,
169
+ odooVersion: metadata?.odooVersion ?? '19.0',
170
+ devRepo: metadata?.devRepo ?? basename(target),
171
+ devRepoUrl: metadata?.devRepoUrl ?? target,
172
+ sourceRepos,
173
+ engine: 'compose',
174
+ composeTemplateUrl: metadata?.composeTemplateUrl,
175
+ composeTemplateRef: metadata?.composeTemplateRef,
176
+ agentSkillsTemplateUrl: metadata?.agentSkillsTemplateUrl,
177
+ agentSkillsTemplateRef: metadata?.agentSkillsTemplateRef,
178
+ postgresVersion: metadata?.postgresVersion,
179
+ httpPort: metadata?.httpPort,
180
+ geventPort: metadata?.geventPort,
181
+ target,
182
+ dryRun: false,
183
+ initEmptyRepos: false,
184
+ stage: false,
185
+ skipSubmodules: true,
186
+ };
187
+ }
188
+ export async function safeResetEnvironment(options, git = realGit) {
189
+ const scaffoldOptions = await inferOptions(options.target);
190
+ const files = generatedFiles(scaffoldOptions);
191
+ const externalAssets = safeResetExternalAssetOptions(scaffoldOptions);
192
+ for (const file of files) {
193
+ if (file.path === '.wpmoo/odoo.json') {
194
+ continue;
195
+ }
196
+ if (isProtectedGeneratedFile(file.path) && (await pathExists(join(options.target, file.path)))) {
197
+ continue;
198
+ }
199
+ if (file.path === 'odoo/custom/src/addons.yaml') {
200
+ continue;
201
+ }
202
+ const destination = join(options.target, file.path);
203
+ await mkdir(join(destination, '..'), { recursive: true });
204
+ await writeFile(destination, file.content, 'utf8');
205
+ if (file.mode !== undefined) {
206
+ await chmod(destination, file.mode);
207
+ }
208
+ }
209
+ for (const assetOptions of externalAssets) {
210
+ await applyExternalAsset(assetOptions, git);
211
+ }
212
+ await writeTextFile(join(options.target, '.wpmoo/odoo.json'), await mergeEnvironmentMetadata(options.target, scaffoldOptions));
213
+ await writeTextFile(join(options.target, '.env.example'), renderComposeEnvExample(scaffoldOptions));
214
+ if (options.stage) {
215
+ await stageAll(git, options.target);
216
+ }
217
+ }
@@ -0,0 +1,161 @@
1
+ import { chmod, mkdir, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { applyExternalAsset, renderExternalAssetCommand, writeTextFile } from './external-assets.js';
4
+ import { markerPath, renderEnvironmentMetadata } from './environment.js';
5
+ import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
6
+ import { cloneRepository, ensureSubmodule, ensureRemoteHasBranch, realGit, stageAll, syncSubmodules, } from './git.js';
7
+ import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, } from './templates.js';
8
+ import { validateAddonName, validateRepoPath } from './path-validation.js';
9
+ import { renderSourceManifest, sourceManifestEntriesFromMetadata } from './source-manifest.js';
10
+ function validateSourceRepo(repo) {
11
+ const path = validateRepoPath(repo.path);
12
+ return {
13
+ ...repo,
14
+ path,
15
+ sourceType: repo.sourceType ?? 'private',
16
+ addons: repo.addons.map(validateAddonName),
17
+ };
18
+ }
19
+ function sourceRepoSubmodulePath(repo) {
20
+ return `odoo/custom/src/${repo.sourceType ?? 'private'}/${repo.path}`;
21
+ }
22
+ function validateScaffoldOptions(options) {
23
+ return {
24
+ ...options,
25
+ sourceRepos: options.sourceRepos.map(validateSourceRepo),
26
+ };
27
+ }
28
+ export function generatedFiles(options) {
29
+ const safeOptions = validateScaffoldOptions(options);
30
+ const sourceDirReadmes = [
31
+ {
32
+ path: 'odoo/custom/src/private/README.md',
33
+ title: 'private',
34
+ body: 'Project-owned/private addon repositories go here.',
35
+ },
36
+ {
37
+ path: 'odoo/custom/src/oca/README.md',
38
+ title: 'oca',
39
+ body: 'OCA repositories go here, for example server-tools, web, queue.',
40
+ },
41
+ {
42
+ path: 'odoo/custom/src/external/README.md',
43
+ title: 'external',
44
+ body: 'Non-OCA third-party, vendor, and community addon repositories go here.',
45
+ },
46
+ {
47
+ path: 'odoo/custom/patches/README.md',
48
+ title: 'patches',
49
+ body: 'Local patches for upstream/vendor/OCA repositories go here.',
50
+ },
51
+ {
52
+ path: 'odoo/custom/manifests/README.md',
53
+ title: 'manifests',
54
+ body: 'Manifest/lock/list files for external sources and pinned revisions go here.',
55
+ },
56
+ ];
57
+ const files = [
58
+ { path: markerPath, content: renderEnvironmentMetadata(safeOptions) },
59
+ { path: 'moo', content: renderMooDelegationScript(), mode: 0o755 },
60
+ { path: '.gitignore', content: renderGitignore() },
61
+ { path: 'README.md', content: renderReadme(safeOptions) },
62
+ { path: 'AGENTS.md', content: renderAgents(safeOptions) },
63
+ { path: 'docs/appstore-release.md', content: renderAppstoreRelease(safeOptions) },
64
+ {
65
+ path: 'odoo/custom/manifests/sources.yaml',
66
+ content: renderSourceManifest(sourceManifestEntriesFromMetadata(safeOptions.sourceRepos, safeOptions.odooVersion)),
67
+ },
68
+ ];
69
+ return [
70
+ ...files,
71
+ ...sourceDirReadmes.map((readme) => ({
72
+ path: readme.path,
73
+ content: renderPlaceholder(readme.title, readme.body),
74
+ })),
75
+ ];
76
+ }
77
+ async function writeGeneratedFiles(target, files) {
78
+ for (const file of files) {
79
+ const destination = join(target, file.path);
80
+ await mkdir(join(destination, '..'), { recursive: true });
81
+ await writeFile(destination, file.content, 'utf8');
82
+ if (file.mode !== undefined) {
83
+ await chmod(destination, file.mode);
84
+ }
85
+ }
86
+ }
87
+ async function pathExists(path) {
88
+ try {
89
+ await stat(path);
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ async function isGitRepository(git, target) {
97
+ if (!(await pathExists(target))) {
98
+ return false;
99
+ }
100
+ try {
101
+ const result = await git.run(target, ['rev-parse', '--is-inside-work-tree']);
102
+ return result.stdout.trim() === 'true';
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ async function prepareTargetRepository(options, git) {
109
+ if (await isGitRepository(git, options.target)) {
110
+ return;
111
+ }
112
+ if (await pathExists(options.target)) {
113
+ throw new Error(`Target exists but is not a Git repository: ${options.target}\n` +
114
+ 'Clone the dev environment repository first, or remove the directory and run the CLI again.');
115
+ }
116
+ await mkdir(dirname(options.target), { recursive: true });
117
+ await cloneRepository(git, dirname(options.target), options.devRepoUrl, options.target);
118
+ }
119
+ export async function scaffold(options, git = realGit) {
120
+ const safeOptions = validateScaffoldOptions(options);
121
+ const files = generatedFiles(safeOptions);
122
+ const externalAssets = plannedExternalAssetOptions(safeOptions);
123
+ const plannedCommands = [
124
+ ...externalAssets.map((assetOptions) => renderExternalAssetCommand(assetOptions)),
125
+ ...safeOptions.sourceRepos.map((repo) => `git submodule add -b ${safeOptions.odooVersion} ${repo.url} ${sourceRepoSubmodulePath(repo)}`),
126
+ ];
127
+ if (safeOptions.stage) {
128
+ plannedCommands.push('git add .');
129
+ }
130
+ if (safeOptions.dryRun) {
131
+ return {
132
+ plannedFiles: files.map((file) => file.path),
133
+ plannedCommands,
134
+ };
135
+ }
136
+ if (!safeOptions.skipSubmodules || safeOptions.stage) {
137
+ await prepareTargetRepository(safeOptions, git);
138
+ }
139
+ await writeGeneratedFiles(safeOptions.target, files);
140
+ for (const assetOptions of externalAssets) {
141
+ await applyExternalAsset(assetOptions, git);
142
+ }
143
+ await writeTextFile(join(safeOptions.target, '.env.example'), renderComposeEnvExample(safeOptions));
144
+ if (!safeOptions.skipSubmodules) {
145
+ for (const repo of safeOptions.sourceRepos) {
146
+ await ensureRemoteHasBranch(git, safeOptions.target, repo.url, safeOptions.odooVersion, safeOptions.initEmptyRepos);
147
+ }
148
+ for (const repo of safeOptions.sourceRepos) {
149
+ await mkdir(join(safeOptions.target, 'odoo/custom/src', repo.sourceType ?? 'private'), { recursive: true });
150
+ await ensureSubmodule(git, safeOptions.target, repo.url, safeOptions.odooVersion, sourceRepoSubmodulePath(repo));
151
+ }
152
+ await syncSubmodules(git, safeOptions.target);
153
+ }
154
+ if (safeOptions.stage) {
155
+ await stageAll(git, safeOptions.target);
156
+ }
157
+ return {
158
+ plannedFiles: files.map((file) => file.path),
159
+ plannedCommands,
160
+ };
161
+ }
@@ -0,0 +1,65 @@
1
+ import { defaultOdooVersion, readEnvironmentMetadata, replaceSourceRepos } from './environment.js';
2
+ import { realGit, stageAll } from './git.js';
3
+ import { listGitmoduleSources, readSourceManifest, sourceManifestEntriesFromMetadata, sourceReposFromManifest, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
4
+ function cloneSourceEntries(entries) {
5
+ return entries.map((entry) => ({
6
+ ...entry,
7
+ addons: [...entry.addons],
8
+ }));
9
+ }
10
+ export async function listSources(target) {
11
+ const metadata = await readEnvironmentMetadata(target);
12
+ const manifest = await readSourceManifest(target);
13
+ if (manifest.sources.length > 0) {
14
+ return manifest.sources;
15
+ }
16
+ if (metadata?.sourceRepos.length) {
17
+ return sourceManifestEntriesFromMetadata(metadata.sourceRepos, metadata.odooVersion);
18
+ }
19
+ return syncManifestFromMetadataAndGitmodules([], metadata?.odooVersion ?? defaultOdooVersion, await listGitmoduleSources(target));
20
+ }
21
+ export function renderSourceList(entries) {
22
+ if (entries.length === 0) {
23
+ return 'No source repositories configured.';
24
+ }
25
+ return entries
26
+ .map((entry) => {
27
+ const branch = entry.branch ? ` @ ${entry.branch}` : '';
28
+ const addons = entry.addons.length ? ` addons: ${entry.addons.join(', ')}` : '';
29
+ return `${entry.type}/${entry.path}${branch} -> ${entry.url}${addons}`;
30
+ })
31
+ .join('\n');
32
+ }
33
+ export function sourceListJson(entries) {
34
+ return {
35
+ schemaVersion: 1,
36
+ command: 'source list',
37
+ ok: true,
38
+ sources: cloneSourceEntries(entries),
39
+ };
40
+ }
41
+ export function sourceSyncJson(entries, target) {
42
+ return {
43
+ schemaVersion: 1,
44
+ command: 'source sync',
45
+ ok: true,
46
+ target,
47
+ sources: cloneSourceEntries(entries),
48
+ };
49
+ }
50
+ export async function syncSources(options, git = realGit) {
51
+ const metadata = await readEnvironmentMetadata(options.target);
52
+ const manifest = await readSourceManifest(options.target);
53
+ const gitmodules = await listGitmoduleSources(options.target);
54
+ const fallbackBranch = metadata?.odooVersion ?? defaultOdooVersion;
55
+ const baseRepos = metadata?.sourceRepos.length ? metadata.sourceRepos : sourceReposFromManifest(manifest.sources);
56
+ const entries = syncManifestFromMetadataAndGitmodules(baseRepos, fallbackBranch, gitmodules);
57
+ await writeSourceManifest(options.target, entries);
58
+ if (metadata) {
59
+ await replaceSourceRepos(options.target, sourceReposFromManifest(entries));
60
+ }
61
+ if (options.stage) {
62
+ await stageAll(git, options.target);
63
+ }
64
+ return entries;
65
+ }