@wpmoo/toolkit 0.9.24 → 0.9.26

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.
@@ -0,0 +1,219 @@
1
+ export const dailyActionPolicyCommands = [
2
+ 'start',
3
+ 'stop',
4
+ 'logs',
5
+ 'restart',
6
+ 'shell',
7
+ 'psql',
8
+ 'install',
9
+ 'update',
10
+ 'test',
11
+ 'resetdb',
12
+ 'snapshot',
13
+ 'restore-snapshot',
14
+ 'lint',
15
+ 'pot',
16
+ ];
17
+ function normalizeFlag(value) {
18
+ return value?.trim() ?? '';
19
+ }
20
+ function parseFlag(value) {
21
+ return normalizeFlag(value) === '1';
22
+ }
23
+ export const dailyActionPolicyTable = {
24
+ start: {
25
+ isDestructive: () => false,
26
+ isDryRunAllowed: false,
27
+ requiresStageLifecycleApproval: false,
28
+ requiresProdLifecycleApproval: false,
29
+ isAuditWorthy: () => false,
30
+ },
31
+ stop: {
32
+ isDestructive: () => false,
33
+ isDryRunAllowed: false,
34
+ requiresStageLifecycleApproval: false,
35
+ requiresProdLifecycleApproval: false,
36
+ isAuditWorthy: () => false,
37
+ },
38
+ logs: {
39
+ isDestructive: () => false,
40
+ isDryRunAllowed: false,
41
+ requiresStageLifecycleApproval: false,
42
+ requiresProdLifecycleApproval: false,
43
+ isAuditWorthy: () => false,
44
+ },
45
+ restart: {
46
+ isDestructive: () => false,
47
+ isDryRunAllowed: false,
48
+ requiresStageLifecycleApproval: false,
49
+ requiresProdLifecycleApproval: false,
50
+ isAuditWorthy: () => false,
51
+ },
52
+ shell: {
53
+ isDestructive: () => false,
54
+ isDryRunAllowed: false,
55
+ requiresStageLifecycleApproval: false,
56
+ requiresProdLifecycleApproval: false,
57
+ isAuditWorthy: () => false,
58
+ },
59
+ psql: {
60
+ isDestructive: () => false,
61
+ isDryRunAllowed: false,
62
+ requiresStageLifecycleApproval: false,
63
+ requiresProdLifecycleApproval: false,
64
+ isAuditWorthy: () => false,
65
+ },
66
+ install: {
67
+ isDestructive: () => false,
68
+ isDryRunAllowed: false,
69
+ requiresStageLifecycleApproval: true,
70
+ requiresProdLifecycleApproval: true,
71
+ isAuditWorthy: () => true,
72
+ },
73
+ update: {
74
+ isDestructive: () => false,
75
+ isDryRunAllowed: false,
76
+ requiresStageLifecycleApproval: true,
77
+ requiresProdLifecycleApproval: true,
78
+ isAuditWorthy: () => true,
79
+ },
80
+ test: {
81
+ isDestructive: () => false,
82
+ isDryRunAllowed: false,
83
+ requiresStageLifecycleApproval: false,
84
+ requiresProdLifecycleApproval: true,
85
+ isAuditWorthy: () => true,
86
+ },
87
+ resetdb: {
88
+ isDestructive: () => true,
89
+ isDryRunAllowed: false,
90
+ requiresStageLifecycleApproval: false,
91
+ requiresProdLifecycleApproval: false,
92
+ isAuditWorthy: () => true,
93
+ },
94
+ snapshot: {
95
+ isDestructive: () => false,
96
+ isDryRunAllowed: false,
97
+ requiresStageLifecycleApproval: false,
98
+ requiresProdLifecycleApproval: false,
99
+ isAuditWorthy: () => false,
100
+ },
101
+ 'restore-snapshot': {
102
+ isDestructive: (args) => args[0] !== '--dry-run',
103
+ isDryRunAllowed: true,
104
+ requiresStageLifecycleApproval: false,
105
+ requiresProdLifecycleApproval: false,
106
+ isAuditWorthy: (args) => args[0] !== '--dry-run',
107
+ },
108
+ lint: {
109
+ isDestructive: () => false,
110
+ isDryRunAllowed: false,
111
+ requiresStageLifecycleApproval: false,
112
+ requiresProdLifecycleApproval: false,
113
+ isAuditWorthy: () => false,
114
+ },
115
+ pot: {
116
+ isDestructive: () => false,
117
+ isDryRunAllowed: false,
118
+ requiresStageLifecycleApproval: false,
119
+ requiresProdLifecycleApproval: false,
120
+ isAuditWorthy: () => false,
121
+ },
122
+ };
123
+ export function parseEnvironmentKind(rawEnvName) {
124
+ const normalized = rawEnvName?.trim().toLowerCase();
125
+ if (normalized === 'stage' || normalized === 'prod') {
126
+ return normalized;
127
+ }
128
+ return 'dev';
129
+ }
130
+ export function isDailyActionPolicyCommand(value) {
131
+ return dailyActionPolicyCommands.includes(value);
132
+ }
133
+ export function isRestoreSnapshotDryRun(args) {
134
+ return args[0] === '--dry-run';
135
+ }
136
+ function denyMessage(kind, command, env) {
137
+ if (kind === 'destructive') {
138
+ return `Refusing destructive command '${command}' in WPMOO_ENV=${env}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
139
+ }
140
+ if (kind === 'stage-lifecycle') {
141
+ return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
142
+ }
143
+ return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
144
+ }
145
+ export function renderPolicyDenyMessage(deny) {
146
+ return denyMessage(deny.kind, deny.command, deny.env);
147
+ }
148
+ export function evaluateDailyActionPolicy(command, args, flags) {
149
+ const env = parseEnvironmentKind(flags.envName);
150
+ const policy = dailyActionPolicyTable[command];
151
+ const isDestructive = policy.isDestructive(args);
152
+ const isDryRunPreview = command === 'restore-snapshot' && isRestoreSnapshotDryRun(args);
153
+ const isAuditWorthy = policy.isAuditWorthy(args);
154
+ if (policy.requiresStageLifecycleApproval && env === 'stage' && !parseFlag(flags.allowStageLifecycle)) {
155
+ const deny = {
156
+ kind: 'stage-lifecycle',
157
+ command,
158
+ env,
159
+ requiredFlag: 'WPMOO_ALLOW_STAGE_LIFECYCLE',
160
+ requiredValue: '1',
161
+ };
162
+ return {
163
+ allowed: false,
164
+ command,
165
+ env,
166
+ isDestructive,
167
+ isDryRunPreview,
168
+ isAuditWorthy,
169
+ deny,
170
+ message: renderPolicyDenyMessage(deny),
171
+ };
172
+ }
173
+ if (policy.requiresProdLifecycleApproval && env === 'prod' && !parseFlag(flags.allowProdLifecycle)) {
174
+ const deny = {
175
+ kind: 'prod-lifecycle',
176
+ command,
177
+ env,
178
+ requiredFlag: 'WPMOO_ALLOW_PROD_LIFECYCLE',
179
+ requiredValue: '1',
180
+ };
181
+ return {
182
+ allowed: false,
183
+ command,
184
+ env,
185
+ isDestructive,
186
+ isDryRunPreview,
187
+ isAuditWorthy,
188
+ deny,
189
+ message: renderPolicyDenyMessage(deny),
190
+ };
191
+ }
192
+ if (isDestructive && (env === 'stage' || env === 'prod') && !parseFlag(flags.allowDestructive)) {
193
+ const deny = {
194
+ kind: 'destructive',
195
+ command,
196
+ env,
197
+ requiredFlag: 'WPMOO_ALLOW_DESTRUCTIVE',
198
+ requiredValue: '1',
199
+ };
200
+ return {
201
+ allowed: false,
202
+ command,
203
+ env,
204
+ isDestructive,
205
+ isDryRunPreview,
206
+ isAuditWorthy,
207
+ deny,
208
+ message: renderPolicyDenyMessage(deny),
209
+ };
210
+ }
211
+ return {
212
+ allowed: true,
213
+ command,
214
+ env,
215
+ isDestructive,
216
+ isDryRunPreview,
217
+ isAuditWorthy,
218
+ };
219
+ }
package/dist/help.js CHANGED
@@ -24,7 +24,7 @@ Usage:
24
24
  npx @wpmoo/toolkit doctor --json [--postgres]
25
25
  npx @wpmoo/toolkit start
26
26
  npx @wpmoo/toolkit stop
27
- npx @wpmoo/toolkit logs [service]
27
+ npx @wpmoo/toolkit logs [service] [tail-lines]
28
28
  npx @wpmoo/toolkit restart
29
29
  npx @wpmoo/toolkit shell
30
30
  npx @wpmoo/toolkit psql [db]
@@ -148,7 +148,7 @@ Task recipes:
148
148
  npx @wpmoo/toolkit status
149
149
  npx @wpmoo/toolkit doctor
150
150
  npx @wpmoo/toolkit doctor --fix
151
- npx @wpmoo/toolkit logs [service]
151
+ npx @wpmoo/toolkit logs [service] [tail-lines]
152
152
  npx @wpmoo/toolkit restart
153
153
 
154
154
  Machine-readable JSON output:
@@ -0,0 +1,112 @@
1
+ import { lstat, readdir } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+ const sourceRepoTypes = ['private', 'oca', 'external'];
4
+ const sourceRepoBase = ['odoo', 'custom', 'src'];
5
+ const migrationFolders = ['migrations', 'migration'];
6
+ const versionedMigrationFiles = ['pre-migration.py', 'post-migration.py', 'end-migration.py'];
7
+ const scriptMigrationFiles = ['migrate.py', 'migration.py'];
8
+ async function isDirectory(path) {
9
+ try {
10
+ const entry = await lstat(path);
11
+ return entry.isDirectory() && !entry.isSymbolicLink();
12
+ }
13
+ catch (error) {
14
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
15
+ return false;
16
+ }
17
+ throw error;
18
+ }
19
+ }
20
+ async function isFile(path) {
21
+ try {
22
+ const entry = await lstat(path);
23
+ return entry.isFile() && !entry.isSymbolicLink();
24
+ }
25
+ catch (error) {
26
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
27
+ return false;
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+ async function listDirectoryNames(path) {
33
+ try {
34
+ const entries = await readdir(path, { withFileTypes: true });
35
+ const filtered = entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.startsWith('.'));
36
+ return filtered.map((entry) => entry.name).sort();
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
40
+ return [];
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ async function listMigrationFiles(modulePath, migrationFolder) {
46
+ const found = [];
47
+ const versionRoot = join(modulePath, migrationFolder);
48
+ if (!(await isDirectory(versionRoot))) {
49
+ return found;
50
+ }
51
+ const versions = await listDirectoryNames(versionRoot);
52
+ for (const version of versions) {
53
+ const versionPath = join(versionRoot, version);
54
+ if (!(await isDirectory(versionPath))) {
55
+ continue;
56
+ }
57
+ for (const file of versionedMigrationFiles) {
58
+ const path = join(versionPath, file);
59
+ if (await isFile(path)) {
60
+ found.push(path);
61
+ }
62
+ }
63
+ }
64
+ return found;
65
+ }
66
+ async function scanModule(modulePath) {
67
+ const found = [];
68
+ for (const folder of migrationFolders) {
69
+ found.push(...(await listMigrationFiles(modulePath, folder)));
70
+ }
71
+ const scriptsPath = join(modulePath, 'scripts');
72
+ if (!(await isDirectory(scriptsPath))) {
73
+ return found.sort();
74
+ }
75
+ for (const migrationScript of scriptMigrationFiles) {
76
+ const candidate = join(scriptsPath, migrationScript);
77
+ if (await isFile(candidate)) {
78
+ found.push(candidate);
79
+ }
80
+ }
81
+ return found;
82
+ }
83
+ export async function scanMigrationRisks(target) {
84
+ const root = resolve(target);
85
+ const foundPaths = [];
86
+ const srcRoot = join(root, ...sourceRepoBase);
87
+ for (const sourceType of sourceRepoTypes) {
88
+ const typeRoot = join(srcRoot, sourceType);
89
+ if (!(await isDirectory(typeRoot))) {
90
+ continue;
91
+ }
92
+ const repoNames = await listDirectoryNames(typeRoot);
93
+ for (const repoName of repoNames) {
94
+ const repoPath = join(typeRoot, repoName);
95
+ if (!(await isDirectory(repoPath))) {
96
+ continue;
97
+ }
98
+ const moduleNames = await listDirectoryNames(repoPath);
99
+ for (const moduleName of moduleNames) {
100
+ const modulePath = join(repoPath, moduleName);
101
+ const modulePaths = await scanModule(modulePath);
102
+ foundPaths.push(...modulePaths);
103
+ }
104
+ }
105
+ }
106
+ foundPaths.sort();
107
+ return {
108
+ foundPaths,
109
+ count: foundPaths.length,
110
+ risk: foundPaths.length > 0,
111
+ };
112
+ }
@@ -1,3 +1,5 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
1
3
  import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
4
  import { basename, join } from 'node:path';
3
5
  import { environmentMetadata, readEnvironmentMetadata } from './environment.js';
@@ -29,35 +31,258 @@ const safeResetProtectedGeneratedReadmes = new Set([
29
31
  function isProtectedGeneratedFile(filePath) {
30
32
  return safeResetProtectedGeneratedReadmes.has(filePath);
31
33
  }
32
- function mergeEnvironmentMetadata(target, options) {
34
+ function mergeEnvironmentMetadataSync(target, options) {
33
35
  const generated = environmentMetadata(options);
34
- return readFile(join(target, '.wpmoo/odoo.json'), 'utf8')
35
- .then((content) => JSON.parse(content))
36
- .then((existing) => {
36
+ try {
37
+ const existing = parseMetadataFromPath(target);
37
38
  if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
38
39
  return `${JSON.stringify(generated, null, 2)}\n`;
39
40
  }
40
41
  return `${JSON.stringify({ ...existing, ...generated, sourceRepos: generated.sourceRepos }, null, 2)}\n`;
42
+ }
43
+ catch {
44
+ return `${JSON.stringify(generated, null, 2)}\n`;
45
+ }
46
+ }
47
+ function parseMetadataFromPath(target) {
48
+ try {
49
+ const raw = readFileSync(join(target, '.wpmoo/odoo.json'), 'utf8');
50
+ const parsed = JSON.parse(raw);
51
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
52
+ return undefined;
53
+ }
54
+ return parsed;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ function parseAddonsFile(target) {
61
+ try {
62
+ return readFileSync(join(target, 'odoo/custom/src/addons.yaml'), 'utf8');
63
+ }
64
+ catch {
65
+ return '';
66
+ }
67
+ }
68
+ function parseGitmodules(target) {
69
+ try {
70
+ const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
71
+ const sections = gitmodules.split(/\n(?=\[submodule )/);
72
+ return sections.flatMap((section) => {
73
+ const pathMatch = section.match(/^\s*path\s*=\s*odoo\/custom\/src\/(private|oca|external)\/([^\s]+)\s*$/m);
74
+ if (!pathMatch) {
75
+ return [];
76
+ }
77
+ const source = section.match(/^\s*url\s*=\s*(.+)\s*$/m)?.[1]?.trim();
78
+ if (!source) {
79
+ return [];
80
+ }
81
+ const sourceType = pathMatch[1];
82
+ return [{ path: pathMatch[2], sourceType, url: source }];
83
+ });
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }
89
+ function inferOptionsForPreviewSync(target) {
90
+ const metadata = parseMetadataFromPath(target);
91
+ const addonsYaml = parseAddonsFile(target);
92
+ const gitmoduleSources = parseGitmodules(target);
93
+ const addonRepos = parseRepoPathsFromAddonsYaml(addonsYaml);
94
+ const sourceByKey = new Map();
95
+ const sourceReposFromMetadata = Array.isArray(metadata?.sourceRepos)
96
+ ? metadata.sourceRepos
97
+ : [];
98
+ for (const repo of sourceReposFromMetadata) {
99
+ if (!repo?.path || !isValidPathSegment(repo.path)) {
100
+ continue;
101
+ }
102
+ sourceByKey.set(`${repo.sourceType ?? 'private'}:${repo.path}`, {
103
+ sourceType: repo.sourceType ?? 'private',
104
+ path: validateRepoPath(repo.path),
105
+ });
106
+ }
107
+ for (const repo of gitmoduleSources) {
108
+ sourceByKey.set(`${repo.sourceType}:${repo.path}`, repo);
109
+ }
110
+ for (const repoPath of addonRepos) {
111
+ sourceByKey.set(`private:${repoPath}`, {
112
+ sourceType: 'private',
113
+ path: repoPath,
114
+ });
115
+ }
116
+ const sourceLocations = [...sourceByKey.values()].sort((left, right) => {
117
+ if (left.sourceType !== right.sourceType) {
118
+ return left.sourceType.localeCompare(right.sourceType);
119
+ }
120
+ return left.path.localeCompare(right.path);
121
+ });
122
+ const productFromMetadata = typeof metadata?.product === 'string' ? metadata.product : undefined;
123
+ const composeTemplateUrl = typeof metadata?.composeTemplateUrl === 'string' ? metadata.composeTemplateUrl : undefined;
124
+ const composeTemplateRef = typeof metadata?.composeTemplateRef === 'string' ? metadata.composeTemplateRef : undefined;
125
+ const agentSkillsTemplateUrl = typeof metadata?.agentSkillsTemplateUrl === 'string'
126
+ ? metadata.agentSkillsTemplateUrl
127
+ : undefined;
128
+ const agentSkillsTemplateRef = typeof metadata?.agentSkillsTemplateRef === 'string'
129
+ ? metadata.agentSkillsTemplateRef
130
+ : undefined;
131
+ const odooVersion = typeof metadata?.odooVersion === 'string' ? metadata.odooVersion : undefined;
132
+ const devRepo = typeof metadata?.devRepo === 'string' ? metadata.devRepo : undefined;
133
+ const devRepoUrl = typeof metadata?.devRepoUrl === 'string' ? metadata.devRepoUrl : undefined;
134
+ const postgresVersion = typeof metadata?.postgresVersion === 'string' ? metadata.postgresVersion : undefined;
135
+ const httpPort = typeof metadata?.httpPort === 'string' ? metadata.httpPort : undefined;
136
+ const geventPort = typeof metadata?.geventPort === 'string' ? metadata.geventPort : undefined;
137
+ const product = productFromMetadata && isValidPathSegment(productFromMetadata) ? productFromMetadata
138
+ : sourceLocations[0]?.path ?? titleFromTarget(target);
139
+ return {
140
+ product,
141
+ odooVersion: odooVersion ?? '19.0',
142
+ devRepo: devRepo ?? basename(target),
143
+ devRepoUrl: devRepoUrl ?? target,
144
+ sourceRepos: sourceLocations.map(({ sourceType, path }) => {
145
+ const metadataMatch = sourceReposFromMetadata.find((repo) => isValidPathSegment(repo.path ?? '') && validateRepoPath(repo.path ?? '') === path && (repo.sourceType ?? 'private') === sourceType);
146
+ const gitmoduleMatch = gitmoduleSources.find((repo) => repo.path === path && repo.sourceType === sourceType);
147
+ return {
148
+ sourceType,
149
+ path,
150
+ url: metadataMatch?.url?.trim() ||
151
+ gitmoduleMatch?.url ||
152
+ readSubmoduleUrlFromPath(target, path, sourceType),
153
+ addons: parseAddonsForRepo(addonsYaml, path),
154
+ };
155
+ }),
156
+ engine: 'compose',
157
+ composeTemplateUrl,
158
+ composeTemplateRef,
159
+ agentSkillsTemplateUrl,
160
+ agentSkillsTemplateRef,
161
+ postgresVersion,
162
+ httpPort,
163
+ geventPort,
164
+ target,
165
+ dryRun: false,
166
+ initEmptyRepos: false,
167
+ stage: false,
168
+ skipSubmodules: true,
169
+ };
170
+ }
171
+ function readSubmoduleUrlFromPath(target, repoPath, sourceType) {
172
+ try {
173
+ const gitmodules = readFileSync(join(target, '.gitmodules'), 'utf8');
174
+ const escapedPath = `odoo/custom/src/${sourceType}/${repoPath}`;
175
+ const sections = gitmodules.split(/\n(?=\[submodule )/);
176
+ const section = sections.find((value) => value.includes(`path = ${escapedPath}`));
177
+ const url = section?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
178
+ return url || `odoo/custom/src/${sourceType}/${repoPath}`;
179
+ }
180
+ catch {
181
+ return `odoo/custom/src/${sourceType}/${repoPath}`;
182
+ }
183
+ }
184
+ function safeResetTargetFileDiffs(scaffoldOptions, target) {
185
+ const files = generatedFiles(scaffoldOptions);
186
+ const candidates = [
187
+ ...files,
188
+ { path: '.env.example', content: renderComposeEnvExample(scaffoldOptions) },
189
+ { path: '.wpmoo/odoo.json', content: mergeEnvironmentMetadataSync(target, scaffoldOptions) },
190
+ ];
191
+ const changedPaths = candidates
192
+ .filter((file) => {
193
+ if (file.path === 'odoo/custom/src/addons.yaml' || isProtectedGeneratedFile(file.path)) {
194
+ return false;
195
+ }
196
+ const existing = readTextForPreview(target, file.path);
197
+ if (existing === undefined) {
198
+ return true;
199
+ }
200
+ return existing !== file.content;
41
201
  })
42
- .catch(() => `${JSON.stringify(generated, null, 2)}\n`);
202
+ .map((file) => file.path);
203
+ return [...new Set(changedPaths)].sort();
204
+ }
205
+ function buildSafeResetSourceRepoLines(sourceRepos) {
206
+ const lines = [...new Set(sourceRepos.map((repo) => `${repo.sourceType ?? 'private'}/${repo.path}`))].sort();
207
+ if (lines.length === 0) {
208
+ return ['- (none detected)'];
209
+ }
210
+ return lines.map((repo) => `- ${repo}`);
211
+ }
212
+ function detectDirtyGeneratedFiles(target, candidatePaths) {
213
+ try {
214
+ const status = execFileSync('git', ['status', '--porcelain'], {
215
+ cwd: target,
216
+ encoding: 'utf8',
217
+ stdio: ['ignore', 'pipe', 'ignore'],
218
+ });
219
+ if (!status.trim()) {
220
+ return [];
221
+ }
222
+ const dirty = new Set();
223
+ const trackedCandidates = [...candidatePaths];
224
+ for (const entry of status.split(/\r?\n/)) {
225
+ if (!entry.trim()) {
226
+ continue;
227
+ }
228
+ const pathSection = entry.slice(3).trim();
229
+ const normalized = pathSection.includes(' -> ') ? pathSection.split(' -> ')[1] ?? '' : pathSection;
230
+ if (!normalized) {
231
+ continue;
232
+ }
233
+ if (trackedCandidates.some((candidate) => normalized === candidate || normalized.startsWith(`${candidate}/`))) {
234
+ dirty.add(normalized);
235
+ }
236
+ }
237
+ return [...dirty].sort();
238
+ }
239
+ catch {
240
+ return [];
241
+ }
242
+ }
243
+ function describeDirtyWarning(target, candidatePaths) {
244
+ const dirtyFiles = detectDirtyGeneratedFiles(target, candidatePaths);
245
+ if (dirtyFiles.length === 0) {
246
+ return [];
247
+ }
248
+ return [
249
+ 'Warning: the following generated files are dirty and may be overwritten by safe reset:',
250
+ ...dirtyFiles.map((path) => `- ${path}`),
251
+ ];
252
+ }
253
+ function readTextForPreview(target, path) {
254
+ try {
255
+ return readFileSync(join(target, path), 'utf8');
256
+ }
257
+ catch {
258
+ return undefined;
259
+ }
43
260
  }
44
261
  export function renderSafeResetPreview(target, stage) {
262
+ const options = inferOptionsForPreviewSync(target);
263
+ const externalAssets = safeResetExternalAssetOptions(options);
264
+ const changedPaths = safeResetTargetFileDiffs(options, target);
265
+ const externalAssetLines = [];
266
+ if (externalAssets.some((asset) => asset.label === 'compose')) {
267
+ externalAssetLines.push('- External compose template assets');
268
+ }
269
+ if (externalAssets.some((asset) => asset.label === 'agent-skills')) {
270
+ externalAssetLines.push('- External agent skill assets when configured');
271
+ }
272
+ const generatedSection = changedPaths.length
273
+ ? ['Generated files that would change:', ...changedPaths.map((file) => `- ${file}`)]
274
+ : ['Generated files that would change:', '- No generated files differ from the rendered safe-reset output.'];
45
275
  return [
46
- 'Safe reset will refresh generated WPMoo environment files.',
276
+ 'Safe reset preview (dry-run): generated WPMoo files and source repo protections are listed.',
47
277
  '',
48
278
  'Target:',
49
279
  target,
50
280
  '',
51
- 'Will update:',
52
- '- .wpmoo/odoo.json',
53
- '- moo',
54
- '- .gitignore',
55
- '- .env.example',
56
- '- README.md',
57
- '- AGENTS.md',
58
- '- docs/appstore-release.md',
59
- '- External compose template assets',
60
- '- External agent skill assets when configured',
281
+ ...generatedSection,
282
+ ...externalAssetLines,
283
+ '',
284
+ 'Source repositories that will remain untouched:',
285
+ ...buildSafeResetSourceRepoLines(options.sourceRepos),
61
286
  '',
62
287
  'Will not touch:',
63
288
  '- source repo folders under odoo/custom/src/private',
@@ -67,6 +292,8 @@ export function renderSafeResetPreview(target, stage) {
67
292
  '- custom source layout directories (oca, external, patches, manifests)',
68
293
  '- Legacy compose template files may remain until manually removed: docs/assets/, test/, .github/',
69
294
  '',
295
+ ...describeDirtyWarning(target, changedPaths),
296
+ '',
70
297
  'Preview-only output; files are not changed until reset is executed.',
71
298
  '',
72
299
  stage ? 'Generated changes will be staged with git add .' : 'Generated changes will not be staged.',
@@ -209,7 +436,7 @@ export async function safeResetEnvironment(options, git = realGit) {
209
436
  for (const assetOptions of externalAssets) {
210
437
  await applyExternalAsset(assetOptions, git);
211
438
  }
212
- await writeTextFile(join(options.target, '.wpmoo/odoo.json'), await mergeEnvironmentMetadata(options.target, scaffoldOptions));
439
+ await writeTextFile(join(options.target, '.wpmoo/odoo.json'), mergeEnvironmentMetadataSync(options.target, scaffoldOptions));
213
440
  await writeTextFile(join(options.target, '.env.example'), renderComposeEnvExample(scaffoldOptions));
214
441
  if (options.stage) {
215
442
  await stageAll(git, options.target);
package/dist/scaffold.js CHANGED
@@ -4,7 +4,7 @@ import { applyExternalAsset, renderExternalAssetCommand, writeTextFile } from '.
4
4
  import { markerPath, renderEnvironmentMetadata } from './environment.js';
5
5
  import { plannedExternalAssetOptions, renderComposeEnvExample } from './external-templates.js';
6
6
  import { cloneRepository, ensureSubmodule, ensureRemoteHasBranch, realGit, stageAll, syncSubmodules, } from './git.js';
7
- import { renderAgents, renderAppstoreRelease, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, renderStatusScript, } from './templates.js';
7
+ import { renderAgents, renderAppstoreRelease, renderDoctorScript, renderGitignore, renderMooDelegationScript, renderPlaceholder, renderReadme, renderStatusScript, } from './templates.js';
8
8
  import { validateAddonName, validateRepoPath } from './path-validation.js';
9
9
  import { renderSourceManifest, sourceManifestEntriesFromMetadata } from './source-manifest.js';
10
10
  function validateSourceRepo(repo) {
@@ -58,6 +58,7 @@ export function generatedFiles(options) {
58
58
  { path: markerPath, content: renderEnvironmentMetadata(safeOptions) },
59
59
  { path: 'moo', content: renderMooDelegationScript(), mode: 0o755 },
60
60
  { path: 'scripts/status.sh', content: renderStatusScript(), mode: 0o755 },
61
+ { path: 'scripts/doctor.sh', content: renderDoctorScript(), mode: 0o755 },
61
62
  { path: '.gitignore', content: renderGitignore() },
62
63
  { path: 'README.md', content: renderReadme(safeOptions) },
63
64
  { path: 'AGENTS.md', content: renderAgents(safeOptions) },