@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,78 @@
1
+ import { appendFile, mkdir } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ const secretFlagNames = new Set([
4
+ 'password',
5
+ 'api-key',
6
+ 'token',
7
+ 'secret',
8
+ 'api_key',
9
+ ]);
10
+ function isSecretFlagToken(token) {
11
+ return token === '--password'
12
+ ? '--password'
13
+ : token === '--api-key'
14
+ ? '--api-key'
15
+ : token === '--token'
16
+ ? '--token'
17
+ : token === '--secret'
18
+ ? '--secret'
19
+ : undefined;
20
+ }
21
+ function isSecretKVToken(token) {
22
+ const firstEquals = token.indexOf('=');
23
+ if (firstEquals < 0) {
24
+ return undefined;
25
+ }
26
+ const [name] = [token.slice(0, firstEquals), token.slice(firstEquals + 1)];
27
+ return secretFlagNames.has(name.replace(/^--+/, '').toLowerCase()) ? name : undefined;
28
+ }
29
+ function normalizeFlag(flag) {
30
+ return flag.startsWith('--') ? flag : `--${flag}`;
31
+ }
32
+ /**
33
+ * Redacts values for common secret-like arguments.
34
+ */
35
+ export function sanitizeCommandArgs(args) {
36
+ const sanitized = [];
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ const arg = args[index];
39
+ const secretKV = isSecretKVToken(arg);
40
+ if (secretKV) {
41
+ sanitized.push(`${secretKV}=***`);
42
+ continue;
43
+ }
44
+ const secretFlag = isSecretFlagToken(arg);
45
+ if (secretFlag && index + 1 < args.length && !args[index + 1].startsWith('--')) {
46
+ sanitized.push(arg);
47
+ sanitized.push('***');
48
+ index += 1;
49
+ continue;
50
+ }
51
+ sanitized.push(arg);
52
+ }
53
+ return sanitized;
54
+ }
55
+ export function extractApprovedFlags(args, approvedFlagNames) {
56
+ const present = [];
57
+ const argsByIndex = args.map((arg) => arg.toLowerCase());
58
+ approvedFlagNames.forEach((name) => {
59
+ const normalized = normalizeFlag(name).toLowerCase();
60
+ if (argsByIndex.some((arg) => arg === normalized || arg.startsWith(`${normalized}=`))) {
61
+ present.push(name);
62
+ }
63
+ });
64
+ return present;
65
+ }
66
+ export async function appendAuditLog(options) {
67
+ const logPath = join(options.environmentPath, '.wpmoo', 'audit.log');
68
+ const event = {
69
+ timestamp: (options.timestamp ?? new Date()).toISOString(),
70
+ command: options.command,
71
+ environment: options.environment,
72
+ dryRun: options.dryRun,
73
+ approvedFlags: [...(options.approvedFlags ?? extractApprovedFlags(options.args, options.approvedFlagNames))],
74
+ args: sanitizeCommandArgs(options.args),
75
+ };
76
+ await mkdir(dirname(logPath), { recursive: true });
77
+ await appendFile(logPath, `${JSON.stringify(event)}\n`, 'utf8');
78
+ }
package/dist/cli.js CHANGED
@@ -271,7 +271,17 @@ async function showStartup(argv, skipUpdateCheck, details) {
271
271
  console.log();
272
272
  }
273
273
  async function selectCockpitCommandFromMenu(serviceStatus, moduleCount, sourceRepoCount) {
274
- const selection = await selectCockpitTopLevelMenu({ serviceStatus, moduleCount, sourceRepoCount });
274
+ const legacyServiceStatus = serviceStatus.kind === 'services-running' ||
275
+ serviceStatus.kind === 'db-ready' ||
276
+ serviceStatus.kind === 'odoo-not-ready' ||
277
+ serviceStatus.kind === 'fully-ready'
278
+ ? { kind: 'running' }
279
+ : serviceStatus;
280
+ const selection = await selectCockpitTopLevelMenu({
281
+ serviceStatus: legacyServiceStatus,
282
+ moduleCount,
283
+ sourceRepoCount,
284
+ });
275
285
  if (selection.kind === 'exit') {
276
286
  return 'exit';
277
287
  }
@@ -83,6 +83,18 @@ async function optionalTextArg(deps, message, fallback) {
83
83
  placeholder: fallback,
84
84
  }), fallback, deps);
85
85
  }
86
+ async function optionalTextArgOrUndefined(deps, message, placeholder) {
87
+ const value = await deps.text({
88
+ message: menuPromptMessage(message, 'back'),
89
+ placeholder,
90
+ });
91
+ deps.handleCancel(value, 'back');
92
+ if (typeof value !== 'string') {
93
+ return undefined;
94
+ }
95
+ const trimmed = value.trim();
96
+ return trimmed.length === 0 ? undefined : trimmed;
97
+ }
86
98
  async function databaseArg(cwd, deps, message, fallback, options = {}) {
87
99
  const databaseResult = normalizeDatabaseListResult(await deps.databases(cwd, options));
88
100
  const databases = databaseResult.databases;
@@ -139,7 +151,9 @@ export async function collectDailyActionArgs(command, cwd, promptDepsArg = {}) {
139
151
  return [];
140
152
  }
141
153
  if (command === 'logs') {
142
- return [await optionalTextArg(deps, 'Service', 'odoo')];
154
+ const service = await optionalTextArg(deps, 'Service', 'odoo');
155
+ const tail = await optionalTextArgOrUndefined(deps, 'Tail line count (optional)', '100');
156
+ return tail ? [service, tail] : [service];
143
157
  }
144
158
  if (command === 'psql') {
145
159
  return [await databaseArg(cwd, deps, 'Database', 'postgres', { includeMaintenance: true })];
@@ -1,8 +1,12 @@
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 { appendAuditLog } from './audit-log.js';
4
5
  import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
6
+ import { defaultDatabaseSnapshotMaxAgeMs, findDatabaseSnapshots, normalizeDatabaseName } from './databases.js';
7
+ import { evaluateDailyActionPolicy, } from './environment-policy.js';
5
8
  import { markerPath } from './environment.js';
9
+ import { scanMigrationRisks } from './migrations.js';
6
10
  export const dailyActionCommands = [
7
11
  'start',
8
12
  'stop',
@@ -49,7 +53,7 @@ function usage(command) {
49
53
  if (command === 'stop')
50
54
  return 'Usage: wpmoo stop';
51
55
  if (command === 'logs')
52
- return 'Usage: wpmoo logs [service]';
56
+ return 'Usage: wpmoo logs [service] [tail-lines]';
53
57
  if (command === 'restart')
54
58
  return 'Usage: wpmoo restart';
55
59
  if (command === 'shell')
@@ -86,7 +90,7 @@ function moduleArgs(command, argv) {
86
90
  const [modules, db, ...rest] = argv;
87
91
  if (!modules || modules.startsWith('-') || rest.length > 0)
88
92
  throw new Error(usage(command));
89
- return db ? [modules, db] : [modules];
93
+ return db ? [modules, normalizeDatabaseName(db)] : [modules];
90
94
  }
91
95
  function positionalArgs(command, argv, min, max) {
92
96
  if (argv.length < min || argv.length > max || argv.some((arg) => arg.startsWith('-'))) {
@@ -94,6 +98,32 @@ function positionalArgs(command, argv, min, max) {
94
98
  }
95
99
  return argv;
96
100
  }
101
+ function logsArgs(argv) {
102
+ if (argv.length > 2 || argv.some((arg) => arg.startsWith('-'))) {
103
+ throw new Error(usage('logs'));
104
+ }
105
+ const [service = 'odoo', tail] = argv;
106
+ if (tail === undefined) {
107
+ return [service];
108
+ }
109
+ if (!/^[1-9][0-9]*$/u.test(tail)) {
110
+ throw new Error('Invalid logs tail count: expected a positive integer.');
111
+ }
112
+ return [service, tail];
113
+ }
114
+ function validateDatabaseArg(args, index) {
115
+ if (args[index] === undefined) {
116
+ return args;
117
+ }
118
+ const nextArgs = [...args];
119
+ nextArgs[index] = normalizeDatabaseName(nextArgs[index]);
120
+ return nextArgs;
121
+ }
122
+ function rejectLeadingHyphenDatabaseArg(args) {
123
+ if (args[0]?.startsWith('-')) {
124
+ normalizeDatabaseName(args[0]);
125
+ }
126
+ }
97
127
  function restoreSnapshotArgs(argv) {
98
128
  const args = [...argv];
99
129
  const dryRun = args[0] === '--dry-run';
@@ -103,7 +133,8 @@ function restoreSnapshotArgs(argv) {
103
133
  if (args.length < 1 || args.length > 2 || args.some((arg) => arg.startsWith('-'))) {
104
134
  throw new Error(usage('restore-snapshot'));
105
135
  }
106
- return dryRun ? ['--dry-run', ...args] : args;
136
+ const validatedArgs = args.length === 2 ? validateDatabaseArg(args, 1) : args;
137
+ return dryRun ? ['--dry-run', ...validatedArgs] : validatedArgs;
107
138
  }
108
139
  function testArgs(argv) {
109
140
  const [modules, ...rest] = argv;
@@ -119,6 +150,9 @@ function testArgs(argv) {
119
150
  if (option === '--mode' && value !== 'auto' && value !== 'init' && value !== 'update') {
120
151
  throw new Error('Invalid value for --mode: expected auto, init, or update');
121
152
  }
153
+ if (option === '--db') {
154
+ normalizeDatabaseName(value);
155
+ }
122
156
  index += 1;
123
157
  }
124
158
  return argv;
@@ -129,88 +163,30 @@ function scriptArgs(command, argv) {
129
163
  if (command === 'stop')
130
164
  return ensureNoArgs(command, argv);
131
165
  if (command === 'logs')
132
- return optionalSingleArg(command, argv, 'odoo');
166
+ return logsArgs(argv);
133
167
  if (command === 'restart')
134
168
  return ensureNoArgs(command, argv);
135
169
  if (command === 'shell')
136
170
  return ensureNoArgs(command, argv);
137
171
  if (command === 'psql')
138
- return optionalSingleArg(command, argv, 'postgres');
172
+ return optionalSingleArg(command, argv, 'postgres').map(normalizeDatabaseName);
139
173
  if (command === 'install' || command === 'update')
140
174
  return moduleArgs(command, argv);
141
175
  if (command === 'test')
142
176
  return testArgs(argv);
143
- if (command === 'resetdb')
144
- return positionalArgs(command, argv, 0, 2);
145
- if (command === 'snapshot')
146
- return positionalArgs(command, argv, 0, 2);
177
+ if (command === 'resetdb') {
178
+ rejectLeadingHyphenDatabaseArg(argv);
179
+ return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
180
+ }
181
+ if (command === 'snapshot') {
182
+ rejectLeadingHyphenDatabaseArg(argv);
183
+ return validateDatabaseArg(positionalArgs(command, argv, 0, 2), 0);
184
+ }
147
185
  if (command === 'restore-snapshot')
148
186
  return restoreSnapshotArgs(argv);
149
187
  if (command === 'lint')
150
188
  return ensureNoArgs(command, argv);
151
- return positionalArgs(command, argv, 1, 3);
152
- }
153
- function isDestructiveCommand(command, args) {
154
- if (command === 'resetdb')
155
- return true;
156
- return command === 'restore-snapshot' && args[0] !== '--dry-run';
157
- }
158
- function isProductionLifecycleCommand(command) {
159
- return command === 'install' || command === 'update' || command === 'test';
160
- }
161
- function isStageLifecycleCommand(command) {
162
- return command === 'install' || command === 'update';
163
- }
164
- function destructiveCommandError(command, envName) {
165
- return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
166
- }
167
- function stageLifecycleCommandError(command) {
168
- return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
169
- }
170
- function productionLifecycleCommandError(command) {
171
- return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
172
- }
173
- async function assertDestructiveCommandAllowed(command, args, cwd) {
174
- if (!isDestructiveCommand(command, args)) {
175
- return;
176
- }
177
- const env = await readEnvFile(cwd);
178
- const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
179
- if (envName !== 'stage' && envName !== 'prod') {
180
- return;
181
- }
182
- const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
183
- if (allowDestructive !== '1') {
184
- throw new Error(destructiveCommandError(command, envName));
185
- }
186
- }
187
- async function assertProductionLifecycleCommandAllowed(command, cwd) {
188
- if (!isProductionLifecycleCommand(command)) {
189
- return;
190
- }
191
- const env = await readEnvFile(cwd);
192
- const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
193
- if (envName !== 'prod') {
194
- return;
195
- }
196
- const allowProdLifecycle = process.env.WPMOO_ALLOW_PROD_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_PROD_LIFECYCLE')?.trim();
197
- if (allowProdLifecycle !== '1') {
198
- throw new Error(productionLifecycleCommandError(command));
199
- }
200
- }
201
- async function assertStageLifecycleCommandAllowed(command, cwd) {
202
- if (!isStageLifecycleCommand(command)) {
203
- return;
204
- }
205
- const env = await readEnvFile(cwd);
206
- const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
207
- if (envName !== 'stage') {
208
- return;
209
- }
210
- const allowStageLifecycle = process.env.WPMOO_ALLOW_STAGE_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_STAGE_LIFECYCLE')?.trim();
211
- if (allowStageLifecycle !== '1') {
212
- throw new Error(stageLifecycleCommandError(command));
213
- }
189
+ return validateDatabaseArg(positionalArgs(command, argv, 1, 3), 1);
214
190
  }
215
191
  async function assertEnvironmentRoot(cwd) {
216
192
  try {
@@ -230,17 +206,121 @@ async function assertScriptExists(cwd, script) {
230
206
  }
231
207
  return scriptPath;
232
208
  }
233
- export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
209
+ function envValue(env, key) {
210
+ return process.env[key]?.trim() || env?.get(key)?.trim();
211
+ }
212
+ function flagEnabled(env, key) {
213
+ return envValue(env, key) === '1';
214
+ }
215
+ function approvedFlags(env) {
216
+ return [
217
+ 'WPMOO_ALLOW_DESTRUCTIVE',
218
+ 'WPMOO_ALLOW_STAGE_LIFECYCLE',
219
+ 'WPMOO_ALLOW_PROD_LIFECYCLE',
220
+ 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
221
+ 'WPMOO_ALLOW_MIGRATIONS',
222
+ ].filter((key) => flagEnabled(env, key));
223
+ }
224
+ function requiresMigrationApproval(command) {
225
+ return command === 'install' || command === 'update' || command === 'test';
226
+ }
227
+ function noRecentSnapshotMessage(command, environment) {
228
+ return `Refusing destructive command '${command}' in WPMOO_ENV=${environment} without a recent database snapshot. Create a snapshot first or set WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1 to run it intentionally.`;
229
+ }
230
+ function migrationRiskMessage(command, environment) {
231
+ 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
+ }
233
+ async function auditDailyActionPreview(preview) {
234
+ if (preview.environment !== 'prod' || !preview.auditWorthy) {
235
+ return;
236
+ }
237
+ await appendAuditLog({
238
+ environmentPath: preview.cwd,
239
+ command: preview.command,
240
+ environment: preview.environment,
241
+ dryRun: preview.dryRun,
242
+ args: preview.args,
243
+ approvedFlagNames: [],
244
+ approvedFlags: preview.approvedFlags,
245
+ });
246
+ }
247
+ export async function dailyActionSafetyPreview(command, argv, cwd = process.cwd()) {
234
248
  await assertEnvironmentRoot(cwd);
235
249
  const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
236
250
  const args = scriptArgs(command, argv);
237
- await assertStageLifecycleCommandAllowed(command, cwd);
238
- await assertProductionLifecycleCommandAllowed(command, cwd);
239
- await assertDestructiveCommandAllowed(command, args, cwd);
251
+ const env = await readEnvFile(cwd);
252
+ const envName = envValue(env, 'WPMOO_ENV') || selectedComposeEnvironment(env);
253
+ const policy = evaluateDailyActionPolicy(command, args, {
254
+ envName,
255
+ allowDestructive: envValue(env, 'WPMOO_ALLOW_DESTRUCTIVE'),
256
+ allowStageLifecycle: envValue(env, 'WPMOO_ALLOW_STAGE_LIFECYCLE'),
257
+ allowProdLifecycle: envValue(env, 'WPMOO_ALLOW_PROD_LIFECYCLE'),
258
+ });
259
+ const warnings = [];
260
+ const snapshotRequired = policy.isDestructive && (policy.env === 'stage' || policy.env === 'prod');
261
+ const snapshot = snapshotRequired ? findDatabaseSnapshots(cwd) : undefined;
262
+ const noRecentSnapshot = snapshotRequired &&
263
+ snapshot &&
264
+ (snapshot.newestSnapshotAgeMs === null || snapshot.newestSnapshotAgeMs > defaultDatabaseSnapshotMaxAgeMs) &&
265
+ !flagEnabled(env, 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT');
266
+ if (noRecentSnapshot) {
267
+ warnings.push({
268
+ kind: 'no-recent-snapshot',
269
+ requiredFlag: 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
270
+ blocking: true,
271
+ message: noRecentSnapshotMessage(command, policy.env),
272
+ });
273
+ }
274
+ const migrations = requiresMigrationApproval(command) && (policy.env === 'stage' || policy.env === 'prod')
275
+ ? await scanMigrationRisks(cwd)
276
+ : undefined;
277
+ if (migrations?.risk && !flagEnabled(env, 'WPMOO_ALLOW_MIGRATIONS')) {
278
+ warnings.push({
279
+ kind: 'migration-risk',
280
+ requiredFlag: 'WPMOO_ALLOW_MIGRATIONS',
281
+ blocking: true,
282
+ message: migrationRiskMessage(command, policy.env),
283
+ });
284
+ }
240
285
  return {
241
286
  cwd,
242
287
  scriptPath,
243
288
  args,
289
+ command,
290
+ environment: policy.env,
291
+ dryRun: policy.isDryRunPreview,
292
+ destructive: policy.isDestructive,
293
+ auditWorthy: policy.isAuditWorthy,
294
+ allowed: policy.allowed,
295
+ deny: policy.allowed ? undefined : { ...policy.deny, message: policy.message },
296
+ refusalMessage: policy.allowed ? undefined : policy.message,
297
+ requiredFlag: policy.allowed ? undefined : policy.deny.requiredFlag,
298
+ warnings,
299
+ snapshot: snapshot
300
+ ? {
301
+ requiredRecent: true,
302
+ newestSnapshotAgeMs: snapshot.newestSnapshotAgeMs,
303
+ snapshotPaths: snapshot.snapshotPaths,
304
+ }
305
+ : undefined,
306
+ migrations,
307
+ approvedFlags: approvedFlags(env),
308
+ };
309
+ }
310
+ export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
311
+ const preview = await dailyActionSafetyPreview(command, argv, cwd);
312
+ await auditDailyActionPreview(preview);
313
+ if (!preview.allowed) {
314
+ throw new Error(preview.refusalMessage);
315
+ }
316
+ const blockingWarning = preview.warnings.find((warning) => warning.blocking);
317
+ if (blockingWarning) {
318
+ throw new Error(blockingWarning.message);
319
+ }
320
+ return {
321
+ cwd: preview.cwd,
322
+ scriptPath: preview.scriptPath,
323
+ args: preview.args,
244
324
  };
245
325
  }
246
326
  async function spawnDailyAction(plan) {
@@ -263,6 +343,12 @@ function renderDailyActionOutputLine(line) {
263
343
  if (line === "Running as user 'root' is a security risk.") {
264
344
  return `${ANSI_DIM_INFO}${line}${ANSI_RESET}`;
265
345
  }
346
+ if (line.includes('psycopg2.OperationalError')) {
347
+ return [
348
+ line,
349
+ `${ANSI_DIM_INFO}NOTE: PostgreSQL connection failed. Check ./moo status, database service readiness, and credentials before retrying.${ANSI_RESET}`,
350
+ ].join('\n');
351
+ }
266
352
  return line;
267
353
  }
268
354
  export function renderDailyActionOutput(output) {
package/dist/databases.js CHANGED
@@ -1,5 +1,38 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { spawn } from 'node:child_process';
4
+ export const defaultDatabaseSnapshotMaxAgeMs = 24 * 60 * 60 * 1000;
5
+ export const databaseSnapshotDirectoryNames = ['backups', 'backup', 'snapshots'];
6
+ export const databaseSnapshotExtensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
7
+ function isDatabaseSnapshotFile(fileName, extensions) {
8
+ const normalized = fileName.toLowerCase();
9
+ return extensions.some((extension) => normalized.endsWith(extension));
10
+ }
2
11
  const maintenanceDatabases = new Set(['postgres']);
12
+ const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
13
+ export function isValidDatabaseName(value) {
14
+ const normalized = value.trim();
15
+ return (normalized.length > 0 &&
16
+ !normalized.startsWith('-') &&
17
+ databaseNamePattern.test(normalized) &&
18
+ !/\s/u.test(value));
19
+ }
20
+ export function normalizeDatabaseName(value) {
21
+ const normalized = value.trim();
22
+ if (!normalized) {
23
+ throw new Error('Invalid database name: value is required.');
24
+ }
25
+ if (/\s/u.test(value)) {
26
+ throw new Error('Invalid database name: whitespace is not allowed.');
27
+ }
28
+ if (normalized.startsWith('-')) {
29
+ throw new Error('Invalid database name: leading hyphens are not allowed.');
30
+ }
31
+ if (!databaseNamePattern.test(normalized)) {
32
+ throw new Error('Invalid database name: use letters, digits, underscores, dots, or hyphens without shell metacharacters or path characters.');
33
+ }
34
+ return normalized;
35
+ }
3
36
  const listDatabasesQuery = [
4
37
  'SELECT datname',
5
38
  'FROM pg_database',
@@ -22,6 +55,54 @@ export function parseDatabaseListOutput(output, options = {}) {
22
55
  }
23
56
  return databases;
24
57
  }
58
+ export function findDatabaseSnapshots(targetDirectory, options = {}) {
59
+ const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], } = options;
60
+ const snapshots = [];
61
+ for (const directoryName of snapshotDirectories) {
62
+ const directory = join(targetDirectory, directoryName);
63
+ let entries;
64
+ try {
65
+ entries = readdirSync(directory, { withFileTypes: true });
66
+ }
67
+ catch (error) {
68
+ if (error.code === 'ENOENT') {
69
+ continue;
70
+ }
71
+ throw error;
72
+ }
73
+ for (const entry of entries) {
74
+ if (!isDatabaseSnapshotFile(entry.name, snapshotExtensions)) {
75
+ continue;
76
+ }
77
+ const path = join(directory, entry.name);
78
+ let stats;
79
+ try {
80
+ stats = statSync(path);
81
+ }
82
+ catch {
83
+ continue;
84
+ }
85
+ if (!stats.isFile()) {
86
+ continue;
87
+ }
88
+ const mtimeMs = stats.mtimeMs;
89
+ const ageMs = Math.max(0, nowMs - mtimeMs);
90
+ snapshots.push({ path, mtimeMs, ageMs });
91
+ }
92
+ }
93
+ snapshots.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
94
+ const newestSnapshot = snapshots[0] ?? null;
95
+ return {
96
+ snapshots,
97
+ snapshotPaths: snapshots.map((snapshot) => snapshot.path),
98
+ newestSnapshotAgeMs: newestSnapshot ? newestSnapshot.ageMs : null,
99
+ };
100
+ }
101
+ export function hasRecentDatabaseSnapshot(targetDirectory, options = {}) {
102
+ const { maxAgeMs = defaultDatabaseSnapshotMaxAgeMs, ...scanOptions } = options;
103
+ const result = findDatabaseSnapshots(targetDirectory, scanOptions);
104
+ return result.newestSnapshotAgeMs !== null && result.newestSnapshotAgeMs <= maxAgeMs;
105
+ }
25
106
  export function normalizeDatabaseListResult(result) {
26
107
  if (Array.isArray(result)) {
27
108
  return { ok: true, databases: result };