@wpmoo/toolkit 0.9.29 → 0.9.31

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
@@ -108,7 +108,7 @@ The cockpit is the daily workspace. It starts with environment status and then s
108
108
  ```text
109
109
  WPMoo Cockpit
110
110
  |-- Command palette /
111
- | |-- search commands such as /test, /logs, /doctor, /safe-reset
111
+ | |-- search commands such as /test, /modules, /install-module, /doctor, /safe-reset
112
112
  |-- Services
113
113
  | |-- start
114
114
  | |-- stop
@@ -141,19 +141,24 @@ WPMoo Cockpit
141
141
 
142
142
  Every cockpit action maps to a direct command, so the same workflow can be used interactively or scripted:
143
143
 
144
+ When an environment has many module candidates, module selection switches to
145
+ search so names, repositories, and source categories can be filtered quickly.
146
+
144
147
  ```bash
145
148
  ./moo start
146
149
  ./moo logs odoo
147
150
  ./moo update sale
148
151
  ./moo test sale
149
152
  ./moo snapshot devel before-update
153
+ ./moo snapshot --list
150
154
  ./moo restore-snapshot --dry-run before-update devel
151
155
  ```
152
156
 
153
- In `WPMOO_ENV=stage`, `install` and `update` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
154
- In `WPMOO_ENV=prod`, `install`, `update`, and `test` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
157
+ In `WPMOO_ENV=stage`, `install`, `update`, `stop`, and `restart` require `WPMOO_ALLOW_STAGE_LIFECYCLE=1`.
158
+ In `WPMOO_ENV=prod`, `install`, `update`, `test`, `stop`, and `restart` require `WPMOO_ALLOW_PROD_LIFECYCLE=1`.
155
159
  `resetdb` and real `restore-snapshot` require `WPMOO_ALLOW_DESTRUCTIVE=1` in `stage` and `prod`.
156
160
  `restore-snapshot --dry-run` remains allowed for preview.
161
+ For short-lived local approvals, add JSONL entries to `.wpmoo/approvals.jsonl`; generated `.gitignore` keeps that ledger out of Git.
157
162
 
158
163
  Module source actions also have direct commands. Default is `private`; pass `--source-type oca` or `--source-type external` for non-private source repositories:
159
164
 
@@ -176,6 +181,9 @@ npx @wpmoo/toolkit doctor --json --postgres
176
181
  ```
177
182
 
178
183
  JSON output is optional; human-readable output remains the default.
184
+ Human `doctor` output is grouped into stable sections (`Generated files`,
185
+ `Compose`, `Source repositories`, `PostgreSQL`, and `Host tools`) so terminal
186
+ operators can see which lifecycle layer needs attention first.
179
187
  `doctor --postgres` runs read-only PostgreSQL diagnostics as advisory checks only; it
180
188
  does not perform automatic tuning.
181
189
  Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics
@@ -191,7 +199,10 @@ Current advisory checks include:
191
199
  - optional unused index advisory output when index usage data is available;
192
200
  - WAL and capacity visibility including WAL activity and disk-level pressure context;
193
201
  - slow-query and query-plan readiness checks for common `log_min_duration_statement`
194
- and `pg_stat_statements` prerequisites.
202
+ and `pg_stat_statements` prerequisites;
203
+ - read-only PostgreSQL configuration visibility for `shared_buffers`, `work_mem`,
204
+ `maintenance_work_mem`, `effective_cache_size`, and
205
+ `shared_preload_libraries`.
195
206
 
196
207
  `npx @wpmoo/toolkit doctor --postgres` and
197
208
  `npx @wpmoo/toolkit doctor --json --postgres` use the same checks, and the
@@ -201,6 +212,8 @@ JSON variant exposes a versioned PostgreSQL diagnostics contract.
201
212
  `doctor --json --postgres` keeps the JSON contract stable by versioning the
202
213
  `postgres` payload; individual fields are optional so automation can safely handle
203
214
  environments where PostgreSQL does not expose a metric.
215
+ All `doctor --json` reports also include optional `sections` entries that group
216
+ checks, warnings, and errors without changing the legacy flat arrays.
204
217
 
205
218
  JSON compatibility policy:
206
219
 
@@ -232,6 +245,16 @@ warning while keeping the scoped package release valid.
232
245
  packages are valid.
233
246
  - **Smoke expectation**: run `npm run smoke:published -- "$VERSION"` after the
234
247
  release tag workflow completes.
248
+ - **Deterministic smoke target**: pin the target package explicitly so smoke checks
249
+ are reproducible across reruns:
250
+
251
+ ```bash
252
+ VERSION="$(node -p "require('./package.json').version")"
253
+ WPMOO_PUBLISHED_PACKAGE_SPEC="@wpmoo/toolkit@$VERSION" npm run smoke:published -- "$VERSION"
254
+ ```
255
+
256
+ Use one pinned command for each target artifact you validate; the workflow itself
257
+ remains valid only when required scoped artifacts pass.
235
258
  - **1.0 release smoke**: For `1.0.0`, generated-environment acceptance smoke is
236
259
  required before the release is considered final.
237
260
 
@@ -0,0 +1,74 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const approvalScopes = [
4
+ 'destructive',
5
+ 'stage-lifecycle',
6
+ 'prod-lifecycle',
7
+ 'no-recent-snapshot',
8
+ 'migration-risk',
9
+ ];
10
+ function isRecord(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ function isApprovalScope(value) {
14
+ return typeof value === 'string' && approvalScopes.includes(value);
15
+ }
16
+ function isEnvironmentKind(value) {
17
+ return value === 'stage' || value === 'prod';
18
+ }
19
+ function isFutureIsoDate(value, now) {
20
+ if (typeof value !== 'string' || !value.trim()) {
21
+ return false;
22
+ }
23
+ const expiresAt = Date.parse(value);
24
+ return Number.isFinite(expiresAt) && expiresAt > now.getTime();
25
+ }
26
+ function activeApprovalFromLine(line, options) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(line);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ if (!isRecord(parsed)) {
35
+ return undefined;
36
+ }
37
+ if (!isApprovalScope(parsed.scope) || !isEnvironmentKind(parsed.environment)) {
38
+ return undefined;
39
+ }
40
+ if (parsed.environment !== options.environment) {
41
+ return undefined;
42
+ }
43
+ const command = typeof parsed.command === 'string' ? parsed.command : undefined;
44
+ if (command && command !== options.command) {
45
+ return undefined;
46
+ }
47
+ if (!isFutureIsoDate(parsed.expiresAt, options.now)) {
48
+ return undefined;
49
+ }
50
+ return {
51
+ scope: parsed.scope,
52
+ environment: parsed.environment,
53
+ ...(command ? { command: command } : {}),
54
+ expiresAt: parsed.expiresAt,
55
+ ...(typeof parsed.reason === 'string' && parsed.reason.trim() ? { reason: parsed.reason.trim() } : {}),
56
+ label: `approval:${parsed.scope}`,
57
+ };
58
+ }
59
+ export async function readActiveApprovals(target, options) {
60
+ let content;
61
+ try {
62
+ content = await readFile(join(target, '.wpmoo/approvals.jsonl'), 'utf8');
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ const resolvedOptions = { ...options, now: options.now ?? new Date() };
68
+ return content
69
+ .split(/\r?\n/)
70
+ .map((line) => line.trim())
71
+ .filter(Boolean)
72
+ .map((line) => activeApprovalFromLine(line, resolvedOptions))
73
+ .filter((approval) => Boolean(approval));
74
+ }
@@ -0,0 +1,20 @@
1
+ import { parseArgs } from '../args.js';
2
+ import { booleanOption, jsonOption } from './options.js';
3
+ export function doctorOptionsFromArgs(argv) {
4
+ const { values } = parseArgs(argv);
5
+ const keys = Object.keys(values);
6
+ const allowedKeys = new Set(['fix', 'json', 'postgres']);
7
+ if (!keys.every((key) => allowedKeys.has(key))) {
8
+ throw new Error('Usage: wpmoo doctor');
9
+ }
10
+ const options = {
11
+ json: jsonOption(values),
12
+ };
13
+ if (Object.hasOwn(values, 'fix')) {
14
+ options.fix = booleanOption(values, 'fix', false);
15
+ }
16
+ if (Object.hasOwn(values, 'postgres')) {
17
+ options.postgres = booleanOption(values, 'postgres', false);
18
+ }
19
+ return options;
20
+ }
@@ -0,0 +1,36 @@
1
+ export function stringOption(values, key) {
2
+ const value = values[key];
3
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
4
+ }
5
+ export function optionalSourceTypeValue(values) {
6
+ const value = stringOption(values, 'sourceType');
7
+ if (value === undefined) {
8
+ return undefined;
9
+ }
10
+ if (value === 'private' || value === 'oca' || value === 'external') {
11
+ return value;
12
+ }
13
+ throw new Error(`Invalid value for --source-type: ${value}`);
14
+ }
15
+ export function sourceTypeValue(values) {
16
+ return optionalSourceTypeValue(values) ?? 'private';
17
+ }
18
+ export function booleanOption(values, key, fallback) {
19
+ const value = values[key];
20
+ if (value === undefined)
21
+ return fallback;
22
+ if (typeof value === 'boolean')
23
+ return value;
24
+ const normalized = value.toLowerCase().trim();
25
+ if (['true', '1', 'yes', 'y'].includes(normalized))
26
+ return true;
27
+ if (['false', '0', 'no', 'n'].includes(normalized))
28
+ return false;
29
+ throw new Error(`Invalid boolean value for --${key}: ${value}`);
30
+ }
31
+ export function jsonOption(values) {
32
+ return booleanOption(values, 'json', false);
33
+ }
34
+ export function printJson(value) {
35
+ console.log(JSON.stringify(value));
36
+ }
@@ -0,0 +1,11 @@
1
+ import { resolve } from 'node:path';
2
+ import { parseArgs } from '../args.js';
3
+ import { booleanOption, stringOption } from './options.js';
4
+ export function resetCommandOptionsFromArgs(argv) {
5
+ const { values } = parseArgs(argv);
6
+ return {
7
+ target: resolve(stringOption(values, 'target') ?? process.cwd()),
8
+ stage: booleanOption(values, 'stage', true),
9
+ dryRun: booleanOption(values, 'dryRun', false),
10
+ };
11
+ }
@@ -0,0 +1,123 @@
1
+ import { resolve } from 'node:path';
2
+ import { parseArgs } from '../args.js';
3
+ import { commandOdooVersion } from '../environment-version.js';
4
+ import { outroPrompt } from '../prompts/index.js';
5
+ import { addModuleRepo, removeModuleRepo } from '../repo-actions.js';
6
+ import { normalizeRepositoryUrl } from '../repo-url.js';
7
+ import { listSources, renderSourceList, renderSourceSyncPlan, sourceListJson, sourceSyncPlan, sourceSyncPlanJson, sourceSyncJson, syncSources, } from '../source-actions.js';
8
+ import { renderBanner } from '../templates.js';
9
+ import { booleanOption, jsonOption, optionalSourceTypeValue, printJson, sourceTypeValue, stringOption, } from './options.js';
10
+ export function renderedSourceRepoPath(target, sourceType, repoPath) {
11
+ if (repoPath) {
12
+ return `${target}/odoo/custom/src/${sourceType}/${repoPath}`;
13
+ }
14
+ return `${target}/odoo/custom/src/${sourceType}`;
15
+ }
16
+ export async function addRepoOptionsFromArgs(argv) {
17
+ const { values } = parseArgs(argv);
18
+ const repoUrl = stringOption(values, 'repoUrl') ?? stringOption(values, 'sourceRepoUrl');
19
+ if (!repoUrl) {
20
+ return undefined;
21
+ }
22
+ const target = resolve(stringOption(values, 'target') ?? process.cwd());
23
+ return {
24
+ target,
25
+ repoUrl: normalizeRepositoryUrl(repoUrl),
26
+ repoPath: stringOption(values, 'repo') ?? stringOption(values, 'sourcePath'),
27
+ sourceType: sourceTypeValue(values),
28
+ odooVersion: await commandOdooVersion(target, stringOption(values, 'odooVersion')),
29
+ initEmptyRepos: booleanOption(values, 'initEmptyRepos', false),
30
+ stage: booleanOption(values, 'stage', true),
31
+ };
32
+ }
33
+ export function removeRepoOptionsFromArgs(argv) {
34
+ const { values } = parseArgs(argv);
35
+ const repoPath = stringOption(values, 'repo') ?? stringOption(values, 'sourcePath');
36
+ if (!repoPath) {
37
+ return undefined;
38
+ }
39
+ return {
40
+ target: resolve(stringOption(values, 'target') ?? process.cwd()),
41
+ repoPath,
42
+ sourceType: optionalSourceTypeValue(values),
43
+ stage: booleanOption(values, 'stage', true),
44
+ };
45
+ }
46
+ export function sourceUsage() {
47
+ return 'Usage: wpmoo source <list|sync|add|remove> [options]';
48
+ }
49
+ export function sourceSyncOptionsFromArgs(argv) {
50
+ const { values } = parseArgs(argv);
51
+ return {
52
+ target: resolve(stringOption(values, 'target') ?? process.cwd()),
53
+ stage: booleanOption(values, 'stage', true),
54
+ json: jsonOption(values),
55
+ dryRun: booleanOption(values, 'dryRun', false),
56
+ };
57
+ }
58
+ export function sourceListOptionsFromArgs(argv) {
59
+ const { values } = parseArgs(argv);
60
+ return {
61
+ target: resolve(stringOption(values, 'target') ?? process.cwd()),
62
+ json: jsonOption(values),
63
+ };
64
+ }
65
+ export async function runSourceCommand(argv) {
66
+ const [subcommand, ...subcommandArgv] = argv;
67
+ if (!subcommand) {
68
+ throw new Error(sourceUsage());
69
+ }
70
+ if (subcommand === 'list') {
71
+ const options = sourceListOptionsFromArgs(subcommandArgv);
72
+ const sources = await listSources(options.target);
73
+ if (options.json) {
74
+ printJson(sourceListJson(sources));
75
+ return;
76
+ }
77
+ console.log(renderBanner());
78
+ console.log(renderSourceList(sources));
79
+ return;
80
+ }
81
+ if (subcommand === 'sync') {
82
+ const options = sourceSyncOptionsFromArgs(subcommandArgv);
83
+ if (options.dryRun) {
84
+ const plan = await sourceSyncPlan(options.target);
85
+ if (options.json) {
86
+ printJson(sourceSyncPlanJson(plan));
87
+ return;
88
+ }
89
+ console.log(renderBanner());
90
+ console.log(renderSourceSyncPlan(plan));
91
+ return;
92
+ }
93
+ const sources = await syncSources({ target: options.target, stage: options.stage });
94
+ if (options.json) {
95
+ printJson(sourceSyncJson(sources, options.target));
96
+ return;
97
+ }
98
+ console.log(renderBanner());
99
+ outroPrompt(`Synced source manifest in ${options.target}.`);
100
+ return;
101
+ }
102
+ if (subcommand === 'add') {
103
+ const options = await addRepoOptionsFromArgs(subcommandArgv);
104
+ if (!options) {
105
+ throw new Error('Usage: wpmoo source add --repo-url <url> [--source-type private|oca|external]');
106
+ }
107
+ console.log(renderBanner());
108
+ await addModuleRepo(options);
109
+ outroPrompt(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private', options.repoPath)}.`);
110
+ return;
111
+ }
112
+ if (subcommand === 'remove') {
113
+ const options = removeRepoOptionsFromArgs(subcommandArgv);
114
+ if (!options) {
115
+ throw new Error('Usage: wpmoo source remove --repo <name> [--source-type private|oca|external]');
116
+ }
117
+ console.log(renderBanner());
118
+ await removeModuleRepo(options);
119
+ outroPrompt(`Removed source repo ${options.repoPath} from ${options.target}.`);
120
+ return;
121
+ }
122
+ throw new Error(sourceUsage());
123
+ }
package/dist/cli.js CHANGED
@@ -11,10 +11,14 @@ import { selectModuleAction } from './cockpit/module-action-menu.js';
11
11
  import { selectModuleFromBrowser } from './cockpit/module-browser.js';
12
12
  import { selectCockpitTopLevelMenu } from './cockpit/menu.js';
13
13
  import { confirmCockpitCommandRisk } from './cockpit/safety.js';
14
+ import { doctorOptionsFromArgs } from './cli-routes/doctor.js';
15
+ import { booleanOption, jsonOption, optionalSourceTypeValue, printJson, stringOption } from './cli-routes/options.js';
16
+ import { resetCommandOptionsFromArgs } from './cli-routes/reset.js';
17
+ import { addRepoOptionsFromArgs, removeRepoOptionsFromArgs, renderedSourceRepoPath, runSourceCommand, } from './cli-routes/source.js';
14
18
  import { detectDevelopmentEnvironment } from './environment.js';
15
19
  import { commandOdooVersion } from './environment-version.js';
16
20
  import { defaultAgentSkillsTemplateUrl } from './external-templates.js';
17
- import { listEnvironmentDatabases, normalizeDatabaseListResult } from './databases.js';
21
+ import { findDatabaseSnapshots, databaseSnapshotCatalogJson, listEnvironmentDatabases, normalizeDatabaseListResult, renderDatabaseSnapshotCatalog, } from './databases.js';
18
22
  import { isDailyActionCommand, runDailyAction, runDailyActionWithStyledOutput } from './daily-actions.js';
19
23
  import { getDoctorReport, runDoctor } from './doctor.js';
20
24
  import { getOriginUrl, realGit } from './git.js';
@@ -29,7 +33,7 @@ import { inferGitHubOwner, inferRepoPath, normalizeRepositoryUrl } from './repo-
29
33
  import { addModuleRepo, listModuleRepos, removeModuleRepo } from './repo-actions.js';
30
34
  import { renderSafeResetPreview, safeResetEnvironment } from './safe-reset.js';
31
35
  import { getServiceRuntimeStatus, renderServiceRuntimeStatusLine, } from './service-runtime-status.js';
32
- import { listSources, renderSourceList, sourceListJson, sourceSyncJson, syncSources, } from './source-actions.js';
36
+ import { listSources, } from './source-actions.js';
33
37
  import { backupTargetPath, expectedTargetConfirmation, inspectEnvironmentTarget, renderExistingEnvironmentSummary, renderForeignEnvironmentTargetWarning, } from './environment-target-preflight.js';
34
38
  import { getGitHubPrerequisiteStatus, renderGitHubPrerequisiteGuidance, } from './github-prerequisites.js';
35
39
  import { checkGitHubRepositories, createGitHubRepositories, manualCreateCommands, } from './repository-preflight.js';
@@ -88,42 +92,6 @@ async function selectDefaultGitHubOwner(cancelAction = 'exit', preferredOwner) {
88
92
  return preferredOwner;
89
93
  }
90
94
  }
91
- function stringOption(values, key) {
92
- const value = values[key];
93
- return typeof value === 'string' && value.trim() ? value.trim() : undefined;
94
- }
95
- function optionalSourceTypeValue(values) {
96
- const value = stringOption(values, 'sourceType');
97
- if (value === undefined) {
98
- return undefined;
99
- }
100
- if (value === 'private' || value === 'oca' || value === 'external') {
101
- return value;
102
- }
103
- throw new Error(`Invalid value for --source-type: ${value}`);
104
- }
105
- function sourceTypeValue(values) {
106
- return optionalSourceTypeValue(values) ?? 'private';
107
- }
108
- function booleanOption(values, key, fallback) {
109
- const value = values[key];
110
- if (value === undefined)
111
- return fallback;
112
- if (typeof value === 'boolean')
113
- return value;
114
- const normalized = value.toLowerCase().trim();
115
- if (['true', '1', 'yes', 'y'].includes(normalized))
116
- return true;
117
- if (['false', '0', 'no', 'n'].includes(normalized))
118
- return false;
119
- throw new Error(`Invalid boolean value for --${key}: ${value}`);
120
- }
121
- function jsonOption(values) {
122
- return booleanOption(values, 'json', false);
123
- }
124
- function printJson(value) {
125
- console.log(JSON.stringify(value));
126
- }
127
95
  function supportsAnsi() {
128
96
  return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
129
97
  }
@@ -149,12 +117,6 @@ function shellQuote(value) {
149
117
  return value;
150
118
  return `'${value.replaceAll("'", "'\\''")}'`;
151
119
  }
152
- function renderedSourceRepoPath(target, sourceType, repoPath) {
153
- if (repoPath) {
154
- return `${target}/odoo/custom/src/${sourceType}/${repoPath}`;
155
- }
156
- return `${target}/odoo/custom/src/${sourceType}`;
157
- }
158
120
  function renderPostCreateGuidance(target, cwd) {
159
121
  const relativeTarget = relative(cwd, target) || '.';
160
122
  const cdCommand = `cd ${shellQuote(relativeTarget)}`;
@@ -270,7 +232,7 @@ async function showStartup(argv, skipUpdateCheck, details) {
270
232
  }
271
233
  console.log();
272
234
  }
273
- async function selectCockpitCommandFromMenu(serviceStatus, moduleCount, sourceRepoCount) {
235
+ async function selectCockpitCommandFromMenu(serviceStatus, moduleCount, sourceRepoCount, snapshotCount) {
274
236
  const legacyServiceStatus = serviceStatus.kind === 'services-running' ||
275
237
  serviceStatus.kind === 'db-ready' ||
276
238
  serviceStatus.kind === 'odoo-not-ready' ||
@@ -281,6 +243,7 @@ async function selectCockpitCommandFromMenu(serviceStatus, moduleCount, sourceRe
281
243
  serviceStatus: legacyServiceStatus,
282
244
  moduleCount,
283
245
  sourceRepoCount,
246
+ snapshotCount,
284
247
  });
285
248
  if (selection.kind === 'exit') {
286
249
  return 'exit';
@@ -520,23 +483,6 @@ async function optionsFromPrompts(showIntro = true, cancelAction = 'exit') {
520
483
  },
521
484
  };
522
485
  }
523
- async function addRepoOptionsFromArgs(argv) {
524
- const { values } = parseArgs(argv);
525
- const repoUrl = stringOption(values, 'repoUrl') ?? stringOption(values, 'sourceRepoUrl');
526
- if (!repoUrl) {
527
- return undefined;
528
- }
529
- const target = resolve(stringOption(values, 'target') ?? process.cwd());
530
- return {
531
- target,
532
- repoUrl: normalizeRepositoryUrl(repoUrl),
533
- repoPath: stringOption(values, 'repo') ?? stringOption(values, 'sourcePath'),
534
- sourceType: sourceTypeValue(values),
535
- odooVersion: await commandOdooVersion(target, stringOption(values, 'odooVersion')),
536
- initEmptyRepos: booleanOption(values, 'initEmptyRepos', false),
537
- stage: booleanOption(values, 'stage', true),
538
- };
539
- }
540
486
  async function addRepoOptionsFromPrompts(showIntro = true, cancelAction = 'exit') {
541
487
  showSubmenuIntro('Add source repo as submodule', showIntro, cancelAction);
542
488
  const target = process.cwd();
@@ -680,112 +626,6 @@ async function addModuleOptionsFromPrompts(showIntro = true, cancelAction = 'exi
680
626
  stage: true,
681
627
  };
682
628
  }
683
- function removeRepoOptionsFromArgs(argv) {
684
- const { values } = parseArgs(argv);
685
- const repoPath = stringOption(values, 'repo') ?? stringOption(values, 'sourcePath');
686
- if (!repoPath) {
687
- return undefined;
688
- }
689
- return {
690
- target: resolve(stringOption(values, 'target') ?? process.cwd()),
691
- repoPath,
692
- sourceType: optionalSourceTypeValue(values),
693
- stage: booleanOption(values, 'stage', true),
694
- };
695
- }
696
- function resetCommandOptionsFromArgs(argv) {
697
- const { values } = parseArgs(argv);
698
- return {
699
- target: resolve(stringOption(values, 'target') ?? process.cwd()),
700
- stage: booleanOption(values, 'stage', true),
701
- dryRun: booleanOption(values, 'dryRun', false),
702
- };
703
- }
704
- function doctorOptionsFromArgs(argv) {
705
- const { values } = parseArgs(argv);
706
- const keys = Object.keys(values);
707
- const allowedKeys = new Set(['fix', 'json', 'postgres']);
708
- if (!keys.every((key) => allowedKeys.has(key))) {
709
- throw new Error('Usage: wpmoo doctor');
710
- }
711
- const options = {
712
- json: jsonOption(values),
713
- };
714
- if (Object.hasOwn(values, 'fix')) {
715
- options.fix = booleanOption(values, 'fix', false);
716
- }
717
- if (Object.hasOwn(values, 'postgres')) {
718
- options.postgres = booleanOption(values, 'postgres', false);
719
- }
720
- return options;
721
- }
722
- function sourceUsage() {
723
- return 'Usage: wpmoo source <list|sync|add|remove> [options]';
724
- }
725
- function sourceSyncOptionsFromArgs(argv) {
726
- const { values } = parseArgs(argv);
727
- return {
728
- target: resolve(stringOption(values, 'target') ?? process.cwd()),
729
- stage: booleanOption(values, 'stage', true),
730
- json: jsonOption(values),
731
- };
732
- }
733
- function sourceListOptionsFromArgs(argv) {
734
- const { values } = parseArgs(argv);
735
- return {
736
- target: resolve(stringOption(values, 'target') ?? process.cwd()),
737
- json: jsonOption(values),
738
- };
739
- }
740
- async function runSourceCommand(argv) {
741
- const [subcommand, ...subcommandArgv] = argv;
742
- if (!subcommand) {
743
- throw new Error(sourceUsage());
744
- }
745
- if (subcommand === 'list') {
746
- const options = sourceListOptionsFromArgs(subcommandArgv);
747
- const sources = await listSources(options.target);
748
- if (options.json) {
749
- printJson(sourceListJson(sources));
750
- return;
751
- }
752
- console.log(renderBanner());
753
- console.log(renderSourceList(sources));
754
- return;
755
- }
756
- if (subcommand === 'sync') {
757
- const options = sourceSyncOptionsFromArgs(subcommandArgv);
758
- const sources = await syncSources({ target: options.target, stage: options.stage });
759
- if (options.json) {
760
- printJson(sourceSyncJson(sources, options.target));
761
- return;
762
- }
763
- console.log(renderBanner());
764
- outroPrompt(`Synced source manifest in ${options.target}.`);
765
- return;
766
- }
767
- if (subcommand === 'add') {
768
- const options = await addRepoOptionsFromArgs(subcommandArgv);
769
- if (!options) {
770
- throw new Error('Usage: wpmoo source add --repo-url <url> [--source-type private|oca|external]');
771
- }
772
- console.log(renderBanner());
773
- await addModuleRepo(options);
774
- outroPrompt(`Added source repo under ${renderedSourceRepoPath(options.target, options.sourceType ?? 'private', options.repoPath)}.`);
775
- return;
776
- }
777
- if (subcommand === 'remove') {
778
- const options = removeRepoOptionsFromArgs(subcommandArgv);
779
- if (!options) {
780
- throw new Error('Usage: wpmoo source remove --repo <name> [--source-type private|oca|external]');
781
- }
782
- console.log(renderBanner());
783
- await removeModuleRepo(options);
784
- outroPrompt(`Removed source repo ${options.repoPath} from ${options.target}.`);
785
- return;
786
- }
787
- throw new Error(sourceUsage());
788
- }
789
629
  async function confirmSafeResetFromMenu(options) {
790
630
  notePrompt(renderSafeResetPreview(options.target, options.stage), 'Safe reset preview');
791
631
  const confirmed = await confirmPrompt({
@@ -836,6 +676,7 @@ function removeModuleOptionsFromArgs(argv) {
836
676
  moduleName,
837
677
  sourceType: optionalSourceTypeValue(values),
838
678
  deleteFiles: booleanOption(values, 'deleteFiles', false),
679
+ dryRun: booleanOption(values, 'dryRun', false),
839
680
  stage: booleanOption(values, 'stage', true),
840
681
  };
841
682
  }
@@ -1493,7 +1334,7 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1493
1334
  };
1494
1335
  while (true) {
1495
1336
  try {
1496
- const command = await selectCockpitCommandFromMenu(serviceStatus, status.kind === 'environment' ? status.moduleCandidateCount : undefined, status.kind === 'environment' ? status.sourceRepoCount : undefined);
1337
+ const command = await selectCockpitCommandFromMenu(serviceStatus, status.kind === 'environment' ? status.moduleCandidateCount : undefined, status.kind === 'environment' ? status.sourceRepoCount : undefined, status.kind === 'environment' ? findDatabaseSnapshots(cwd).snapshots.length : undefined);
1497
1338
  if (command === 'exit') {
1498
1339
  return;
1499
1340
  }
@@ -1579,14 +1420,14 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1579
1420
  const options = removeModuleOptionsFromArgs(route.argv);
1580
1421
  if (options) {
1581
1422
  console.log(renderBanner());
1582
- await removeModuleFromSourceRepo(options);
1583
- outroPrompt(`Removed module ${options.moduleName} from source repo ${options.repoPath}.`);
1423
+ const report = await removeModuleFromSourceRepo(options);
1424
+ outroPrompt(report.dryRun ? report.summary : `Removed module ${options.moduleName} from source repo ${options.repoPath}.`);
1584
1425
  return;
1585
1426
  }
1586
1427
  await showStartup(argv, skipUpdateCheck);
1587
1428
  const promptedOptions = await removeModuleOptionsFromPrompts();
1588
- await removeModuleFromSourceRepo(promptedOptions);
1589
- outroPrompt(`Removed module ${promptedOptions.moduleName} from source repo ${promptedOptions.repoPath}.`);
1429
+ const report = await removeModuleFromSourceRepo(promptedOptions);
1430
+ outroPrompt(report.dryRun ? report.summary : `Removed module ${promptedOptions.moduleName} from source repo ${promptedOptions.repoPath}.`);
1590
1431
  return;
1591
1432
  }
1592
1433
  if (route.command === 'reset') {
@@ -1611,6 +1452,9 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1611
1452
  doctorOptions.postgres = options.postgres;
1612
1453
  }
1613
1454
  if (options.json) {
1455
+ if (doctorOptions.fix) {
1456
+ throw new Error('doctor --json --fix is not supported; run doctor --fix for human-readable auto-fix output, then doctor --json to inspect the post-fix state.');
1457
+ }
1614
1458
  printJson(await getDoctorReport(cwd, doctorOptions));
1615
1459
  return;
1616
1460
  }
@@ -1634,6 +1478,20 @@ export async function runCli(cliArgv = process.argv.slice(2), cwd = process.cwd(
1634
1478
  console.log(await renderEnvironmentStatusForTarget(cwd));
1635
1479
  return;
1636
1480
  }
1481
+ if (route.command === 'snapshot' && route.argv[0] === '--list') {
1482
+ const { values } = parseArgs(route.argv);
1483
+ const keys = Object.keys(values);
1484
+ if (!keys.every((key) => key === 'list' || key === 'json')) {
1485
+ throw new Error('Usage: wpmoo snapshot [--list] [db] [snapshot-name]');
1486
+ }
1487
+ if (jsonOption(values)) {
1488
+ printJson(databaseSnapshotCatalogJson(cwd));
1489
+ return;
1490
+ }
1491
+ console.log(renderBanner());
1492
+ console.log(renderDatabaseSnapshotCatalog(cwd));
1493
+ return;
1494
+ }
1637
1495
  if (isDailyActionCommand(route.command)) {
1638
1496
  console.log(renderBanner());
1639
1497
  await runDailyAction(route.command, await resolveDailyActionModuleTargets(route.command, route.argv, cwd), cwd);