@wpmoo/toolkit 0.9.29 → 0.9.30

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.
@@ -1,4 +1,4 @@
1
- import { listEnvironmentDatabases, normalizeDatabaseListResult, } from '../databases.js';
1
+ import { listEnvironmentDatabases, findDatabaseSnapshots, normalizeDatabaseListResult, } from '../databases.js';
2
2
  import { listModulesInSourceRepo } from '../module-actions.js';
3
3
  import { listModuleRepos } from '../repo-actions.js';
4
4
  import { listSources } from '../source-actions.js';
@@ -6,6 +6,7 @@ import { handlePromptCancel, menuPromptMessage, } from '../menu-navigation.js';
6
6
  import { isPromptCancel, selectPrompt, textPrompt } from '../prompts/index.js';
7
7
  const manualModuleValue = '__wpmoo_manual_module_entry__';
8
8
  const manualDatabaseValue = '__wpmoo_manual_database_entry__';
9
+ const manualSnapshotValue = '__wpmoo_manual_snapshot_entry__';
9
10
  function defaultCancelHandler(value, action) {
10
11
  handlePromptCancel(isPromptCancel(value), action);
11
12
  }
@@ -199,11 +200,37 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
199
200
  return [db, snapshotName];
200
201
  }
201
202
  if (command === 'restore-snapshot') {
202
- const snapshotName = requiredString(await deps.text({
203
- message: menuPromptMessage('Snapshot name', 'back'),
204
- validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
205
- }), 'Snapshot name is required.', deps);
206
- const db = await databaseArg(cwd, deps, 'Odoo database', 'devel');
203
+ const snapshots = findDatabaseSnapshots(cwd).snapshots;
204
+ let snapshotName;
205
+ let fallbackDatabase = 'devel';
206
+ if (snapshots.length > 0) {
207
+ const selected = await deps.select({
208
+ message: menuPromptMessage('Snapshot', 'back'),
209
+ options: [
210
+ ...snapshots.map((snapshot) => ({ value: snapshot.name, label: snapshot.name })),
211
+ { value: manualSnapshotValue, label: 'Manual entry' },
212
+ ],
213
+ initialValue: snapshots[0].name,
214
+ });
215
+ deps.handleCancel(selected, 'back');
216
+ if (selected !== manualSnapshotValue) {
217
+ snapshotName = String(selected);
218
+ fallbackDatabase = snapshots.find((snapshot) => snapshot.name === snapshotName)?.databaseName ?? fallbackDatabase;
219
+ }
220
+ else {
221
+ snapshotName = requiredString(await deps.text({
222
+ message: menuPromptMessage('Snapshot name', 'back'),
223
+ validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
224
+ }), 'Snapshot name is required.', deps);
225
+ }
226
+ }
227
+ else {
228
+ snapshotName = requiredString(await deps.text({
229
+ message: menuPromptMessage('Snapshot name', 'back'),
230
+ validate: (value) => (value.trim() ? undefined : 'Enter the snapshot name.'),
231
+ }), 'Snapshot name is required.', deps);
232
+ }
233
+ const db = await databaseArg(cwd, deps, 'Odoo database', fallbackDatabase);
207
234
  return [snapshotName, db];
208
235
  }
209
236
  return [];
@@ -35,6 +35,7 @@ function commandName(command) {
35
35
  }
36
36
  const disabledReasonNextStep = {
37
37
  'No modules found.': 'Next: choose "Add module" first.',
38
+ 'No snapshots found.': 'Next: choose "Create snapshot" first.',
38
39
  'Services stopped.': 'Next: choose "Start services" first.',
39
40
  'Already running.': 'Next: choose "Stop services" or "Restart services".',
40
41
  'Docker not running.': 'Next: start Docker, then choose "Start services".',
@@ -66,10 +67,14 @@ function moduleDisabledReason(command, moduleCount) {
66
67
  function sourceRepoDisabledReason(command, sourceRepoCount) {
67
68
  return sourceRepoCount === 0 && command.id === 'add-module' ? 'No source repos found.' : undefined;
68
69
  }
69
- function disabledReason(command, serviceStatus, moduleCount, sourceRepoCount) {
70
+ function snapshotDisabledReason(command, snapshotCount) {
71
+ return snapshotCount === 0 && command.id === 'restore-snapshot' ? 'No snapshots found.' : undefined;
72
+ }
73
+ function disabledReason(command, serviceStatus, moduleCount, sourceRepoCount, snapshotCount) {
70
74
  return (serviceDisabledReason(command, serviceStatus) ??
71
75
  moduleDisabledReason(command, moduleCount) ??
72
- sourceRepoDisabledReason(command, sourceRepoCount));
76
+ sourceRepoDisabledReason(command, sourceRepoCount) ??
77
+ snapshotDisabledReason(command, snapshotCount));
73
78
  }
74
79
  function commandDisabledValue(reason) {
75
80
  if (!reason) {
@@ -77,7 +82,7 @@ function commandDisabledValue(reason) {
77
82
  }
78
83
  return reason;
79
84
  }
80
- function categoryChoices(category, index, serviceStatus, moduleCount, sourceRepoCount) {
85
+ function categoryChoices(category, index, serviceStatus, moduleCount, sourceRepoCount, snapshotCount) {
81
86
  const choices = [
82
87
  promptSeparator(categoryHeading(category)),
83
88
  ...topLevelCommands
@@ -87,7 +92,7 @@ function categoryChoices(category, index, serviceStatus, moduleCount, sourceRepo
87
92
  value: command,
88
93
  name: commandName(command),
89
94
  short: command.label,
90
- disabled: commandDisabledValue(disabledReason(command, serviceStatus, moduleCount, sourceRepoCount)),
95
+ disabled: commandDisabledValue(disabledReason(command, serviceStatus, moduleCount, sourceRepoCount, snapshotCount)),
91
96
  };
92
97
  }),
93
98
  ];
@@ -120,8 +125,8 @@ function menuDeps(deps = {}) {
120
125
  function isCockpitCommand(value) {
121
126
  return typeof value === 'object' && value !== null && 'id' in value && 'slashAlias' in value;
122
127
  }
123
- function topLevelChoices(serviceStatus, moduleCount, sourceRepoCount) {
124
- return topLevelCategoryOrder.flatMap((category, index) => categoryChoices(category, index, serviceStatus, moduleCount, sourceRepoCount));
128
+ function topLevelChoices(serviceStatus, moduleCount, sourceRepoCount, snapshotCount) {
129
+ return topLevelCategoryOrder.flatMap((category, index) => categoryChoices(category, index, serviceStatus, moduleCount, sourceRepoCount, snapshotCount));
125
130
  }
126
131
  function defaultCommand(serviceStatus) {
127
132
  if (serviceStatus?.kind === 'running') {
@@ -134,7 +139,7 @@ function defaultCommand(serviceStatus) {
134
139
  }
135
140
  export async function selectCockpitTopLevelMenu(options = {}) {
136
141
  const deps = menuDeps(options);
137
- const choices = topLevelChoices(options.serviceStatus, options.moduleCount, options.sourceRepoCount);
142
+ const choices = topLevelChoices(options.serviceStatus, options.moduleCount, options.sourceRepoCount, options.snapshotCount);
138
143
  const cancelAction = 'back';
139
144
  const selected = await deps.select({
140
145
  message: '',
@@ -1,7 +1,7 @@
1
1
  import { styleText } from 'node:util';
2
2
  import { listModulesInEnvironment, } from '../module-actions.js';
3
3
  import { handlePromptCancel, } from '../menu-navigation.js';
4
- import { isPromptCancel, promptSeparator, selectPrompt, } from '../prompts/index.js';
4
+ import { isPromptCancel, promptSeparator, searchPrompt, selectPrompt, } from '../prompts/index.js';
5
5
  const sourceTypeLabels = {
6
6
  private: 'Private',
7
7
  oca: 'OCA',
@@ -10,6 +10,7 @@ const sourceTypeLabels = {
10
10
  const sourceTypeOrder = ['private', 'oca', 'external'];
11
11
  const minimumPageSize = 8;
12
12
  const reservedRows = 7;
13
+ const searchableModuleThreshold = 20;
13
14
  function rgb(red, green, blue, value) {
14
15
  return `\u001B[38;2;${red};${green};${blue}m${value}\u001B[39m`;
15
16
  }
@@ -51,6 +52,7 @@ function defaultCancelHandler(value, action) {
51
52
  function deps(options = {}) {
52
53
  return {
53
54
  select: options.select ?? ((selectOptions) => selectPrompt(selectOptions)),
55
+ search: options.search ?? ((searchOptions) => searchPrompt(searchOptions)),
54
56
  handleCancel: options.handleCancel ?? defaultCancelHandler,
55
57
  };
56
58
  }
@@ -92,14 +94,89 @@ export function moduleBrowserChoices(modules) {
92
94
  }
93
95
  return choices;
94
96
  }
97
+ function normalizeModuleSearchTerm(term) {
98
+ return (term ?? '')
99
+ .trim()
100
+ .toLowerCase()
101
+ .replace(/[/:]+/g, ' ')
102
+ .split(/\s+/u)
103
+ .filter(Boolean);
104
+ }
105
+ function searchableModuleFields(module) {
106
+ const repoSlugName = module.repoSlug?.split('/').at(-1) ?? '';
107
+ return [
108
+ module.moduleName,
109
+ module.repoPath,
110
+ repoSlugName,
111
+ module.sourceType,
112
+ sourceContext(module),
113
+ ].map((value) => value.toLowerCase());
114
+ }
115
+ function moduleSearchScore(module, terms) {
116
+ if (terms.length === 0) {
117
+ return 50;
118
+ }
119
+ const moduleName = module.moduleName.toLowerCase();
120
+ const repoPath = module.repoPath.toLowerCase();
121
+ if (terms.some((term) => moduleName === term)) {
122
+ return 0;
123
+ }
124
+ if (terms.some((term) => moduleName.startsWith(term))) {
125
+ return 1;
126
+ }
127
+ if (terms.some((term) => repoPath === term)) {
128
+ return 2;
129
+ }
130
+ if (terms.some((term) => repoPath.startsWith(term))) {
131
+ return 3;
132
+ }
133
+ if (terms.some((term) => module.sourceType === term)) {
134
+ return 4;
135
+ }
136
+ return 5;
137
+ }
138
+ export function searchModuleBrowserChoices(modules, term) {
139
+ const terms = normalizeModuleSearchTerm(term);
140
+ const moduleWidth = Math.max(...modules.map((module) => module.moduleName.length), 1);
141
+ return modules
142
+ .filter((module) => {
143
+ if (terms.length === 0) {
144
+ return true;
145
+ }
146
+ const fields = searchableModuleFields(module);
147
+ return terms.every((term) => fields.some((field) => field.includes(term)));
148
+ })
149
+ .sort((left, right) => {
150
+ const score = moduleSearchScore(left, terms) - moduleSearchScore(right, terms);
151
+ return score || left.sourceType.localeCompare(right.sourceType) || left.repoPath.localeCompare(right.repoPath) || left.moduleName.localeCompare(right.moduleName);
152
+ })
153
+ .map((module) => ({
154
+ value: module,
155
+ name: moduleChoiceName(module, moduleWidth),
156
+ description: sourceContext(module),
157
+ short: module.moduleName,
158
+ }));
159
+ }
95
160
  export async function selectModuleFromBrowser(target, options = {}) {
96
161
  const modules = await listModulesInEnvironment(target);
97
162
  if (modules.length === 0) {
98
163
  return undefined;
99
164
  }
100
- const moduleChoices = moduleBrowserChoices(modules);
101
165
  const promptDeps = deps(options);
102
166
  const cancelAction = options.cancelAction ?? 'back';
167
+ if (modules.length > searchableModuleThreshold) {
168
+ const selected = await promptDeps.search({
169
+ message: 'Search modules',
170
+ pageSize: pageSize(modules.length),
171
+ source: (term) => searchModuleBrowserChoices(modules, term),
172
+ });
173
+ promptDeps.handleCancel(selected, cancelAction);
174
+ if (typeof selected === 'object' && selected !== null && 'moduleName' in selected) {
175
+ return selected;
176
+ }
177
+ return undefined;
178
+ }
179
+ const moduleChoices = moduleBrowserChoices(modules);
103
180
  const selected = await promptDeps.select({
104
181
  message: '',
105
182
  choices: moduleChoices,
@@ -1,10 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { readActiveApprovals } from './approval-ledger.js';
4
5
  import { appendAuditLog } from './audit-log.js';
5
6
  import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
6
- import { defaultDatabaseSnapshotMaxAgeMs, findDatabaseSnapshots, normalizeDatabaseName } from './databases.js';
7
- import { evaluateDailyActionPolicy, } from './environment-policy.js';
7
+ import { defaultDatabaseSnapshotMaxAgeMs, findDatabaseSnapshots, normalizeDatabaseName, normalizeSnapshotName, restoreSnapshotPreflight, } from './databases.js';
8
+ import { evaluateDailyActionPolicy, parseEnvironmentKind, } from './environment-policy.js';
8
9
  import { markerPath } from './environment.js';
9
10
  import { scanMigrationRisks } from './migrations.js';
10
11
  export const dailyActionCommands = [
@@ -69,7 +70,7 @@ function usage(command) {
69
70
  if (command === 'resetdb')
70
71
  return 'Usage: wpmoo resetdb [db] [module[,module]]';
71
72
  if (command === 'snapshot')
72
- return 'Usage: wpmoo snapshot [db] [snapshot-name]';
73
+ return 'Usage: wpmoo snapshot [--list] [db] [snapshot-name]';
73
74
  if (command === 'restore-snapshot')
74
75
  return 'Usage: wpmoo restore-snapshot [--dry-run] <snapshot-name> [db]';
75
76
  if (command === 'lint')
@@ -133,7 +134,8 @@ function restoreSnapshotArgs(argv) {
133
134
  if (args.length < 1 || args.length > 2 || args.some((arg) => arg.startsWith('-'))) {
134
135
  throw new Error(usage('restore-snapshot'));
135
136
  }
136
- const validatedArgs = args.length === 2 ? validateDatabaseArg(args, 1) : args;
137
+ const snapshotName = normalizeSnapshotName(args[0]);
138
+ const validatedArgs = args.length === 2 ? validateDatabaseArg([snapshotName, args[1]], 1) : [snapshotName];
137
139
  return dryRun ? ['--dry-run', ...validatedArgs] : validatedArgs;
138
140
  }
139
141
  function testArgs(argv) {
@@ -180,7 +182,8 @@ function scriptArgs(command, argv) {
180
182
  }
181
183
  if (command === 'snapshot') {
182
184
  rejectLeadingHyphenDatabaseArg(argv);
183
- return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
185
+ const args = validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
186
+ return args.length === 2 ? [args[0], normalizeSnapshotName(args[1])] : args;
184
187
  }
185
188
  if (command === 'restore-snapshot')
186
189
  return restoreSnapshotArgs(argv);
@@ -212,7 +215,7 @@ function envValue(env, key) {
212
215
  function flagEnabled(env, key) {
213
216
  return envValue(env, key) === '1';
214
217
  }
215
- function approvedFlags(env) {
218
+ function envApprovedFlags(env) {
216
219
  return [
217
220
  'WPMOO_ALLOW_DESTRUCTIVE',
218
221
  'WPMOO_ALLOW_STAGE_LIFECYCLE',
@@ -221,6 +224,12 @@ function approvedFlags(env) {
221
224
  'WPMOO_ALLOW_MIGRATIONS',
222
225
  ].filter((key) => flagEnabled(env, key));
223
226
  }
227
+ function hasApproval(approvals, scope) {
228
+ return approvals.some((approval) => approval.scope === scope);
229
+ }
230
+ function approvalFlagLabels(approvals) {
231
+ return [...new Set(approvals.map((approval) => approval.label))];
232
+ }
224
233
  function requiresMigrationApproval(command) {
225
234
  return command === 'install' || command === 'update' || command === 'test';
226
235
  }
@@ -230,6 +239,25 @@ function noRecentSnapshotMessage(command, environment) {
230
239
  function migrationRiskMessage(command, environment) {
231
240
  return `Refusing migration-risk command '${command}' in WPMOO_ENV=${environment}. Review detected migration scripts or set WPMOO_ALLOW_MIGRATIONS=1 to run it intentionally.`;
232
241
  }
242
+ function restoreSnapshotWarningKind(issue) {
243
+ if (issue === 'missing snapshot dump')
244
+ return 'restore-snapshot-missing-dump';
245
+ if (issue === 'missing snapshot filestore')
246
+ return 'restore-snapshot-missing-filestore';
247
+ if (issue.startsWith('snapshot database mismatch:'))
248
+ return 'restore-snapshot-db-name-mismatch';
249
+ return undefined;
250
+ }
251
+ function restoreSnapshotDryRunPreflight(command, args, cwd) {
252
+ if (command !== 'restore-snapshot' || args[0] !== '--dry-run') {
253
+ return undefined;
254
+ }
255
+ const snapshotName = args[1];
256
+ if (!snapshotName) {
257
+ return undefined;
258
+ }
259
+ return restoreSnapshotPreflight(cwd, snapshotName, args[2] ?? 'devel');
260
+ }
233
261
  async function auditDailyActionPreview(preview) {
234
262
  if (preview.environment !== 'prod' || !preview.auditWorthy) {
235
263
  return;
@@ -250,19 +278,32 @@ export async function dailyActionSafetyPreview(command, argv, cwd = process.cwd(
250
278
  const args = scriptArgs(command, argv);
251
279
  const env = await readEnvFile(cwd);
252
280
  const envName = envValue(env, 'WPMOO_ENV') || selectedComposeEnvironment(env);
281
+ const environment = parseEnvironmentKind(envName);
282
+ const approvals = await readActiveApprovals(cwd, { command, environment });
253
283
  const policy = evaluateDailyActionPolicy(command, args, {
254
284
  envName,
255
- allowDestructive: envValue(env, 'WPMOO_ALLOW_DESTRUCTIVE'),
256
- allowStageLifecycle: envValue(env, 'WPMOO_ALLOW_STAGE_LIFECYCLE'),
257
- allowProdLifecycle: envValue(env, 'WPMOO_ALLOW_PROD_LIFECYCLE'),
285
+ allowDestructive: envValue(env, 'WPMOO_ALLOW_DESTRUCTIVE') || (hasApproval(approvals, 'destructive') ? '1' : undefined),
286
+ allowStageLifecycle: envValue(env, 'WPMOO_ALLOW_STAGE_LIFECYCLE') || (hasApproval(approvals, 'stage-lifecycle') ? '1' : undefined),
287
+ allowProdLifecycle: envValue(env, 'WPMOO_ALLOW_PROD_LIFECYCLE') || (hasApproval(approvals, 'prod-lifecycle') ? '1' : undefined),
258
288
  });
259
289
  const warnings = [];
290
+ const restoreSnapshot = restoreSnapshotDryRunPreflight(command, args, cwd);
291
+ if (restoreSnapshot) {
292
+ for (const issue of restoreSnapshot.issues) {
293
+ const kind = restoreSnapshotWarningKind(issue);
294
+ if (!kind) {
295
+ continue;
296
+ }
297
+ warnings.push({ kind, message: issue, blocking: false });
298
+ }
299
+ }
260
300
  const snapshotRequired = policy.isDestructive && (policy.env === 'stage' || policy.env === 'prod');
261
301
  const snapshot = snapshotRequired ? findDatabaseSnapshots(cwd) : undefined;
262
302
  const noRecentSnapshot = snapshotRequired &&
263
303
  snapshot &&
264
304
  (snapshot.newestSnapshotAgeMs === null || snapshot.newestSnapshotAgeMs > defaultDatabaseSnapshotMaxAgeMs) &&
265
- !flagEnabled(env, 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT');
305
+ !flagEnabled(env, 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT') &&
306
+ !hasApproval(approvals, 'no-recent-snapshot');
266
307
  if (noRecentSnapshot) {
267
308
  warnings.push({
268
309
  kind: 'no-recent-snapshot',
@@ -274,7 +315,7 @@ export async function dailyActionSafetyPreview(command, argv, cwd = process.cwd(
274
315
  const migrations = requiresMigrationApproval(command) && (policy.env === 'stage' || policy.env === 'prod')
275
316
  ? await scanMigrationRisks(cwd)
276
317
  : undefined;
277
- if (migrations?.risk && !flagEnabled(env, 'WPMOO_ALLOW_MIGRATIONS')) {
318
+ if (migrations?.risk && !flagEnabled(env, 'WPMOO_ALLOW_MIGRATIONS') && !hasApproval(approvals, 'migration-risk')) {
278
319
  warnings.push({
279
320
  kind: 'migration-risk',
280
321
  requiredFlag: 'WPMOO_ALLOW_MIGRATIONS',
@@ -303,8 +344,10 @@ export async function dailyActionSafetyPreview(command, argv, cwd = process.cwd(
303
344
  snapshotPaths: snapshot.snapshotPaths,
304
345
  }
305
346
  : undefined,
347
+ restoreSnapshot,
306
348
  migrations,
307
- approvedFlags: approvedFlags(env),
349
+ approvals,
350
+ approvedFlags: [...envApprovedFlags(env), ...approvalFlagLabels(approvals)],
308
351
  };
309
352
  }
310
353
  export async function dailyActionPlan(command, argv, cwd = process.cwd()) {