@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.
package/dist/databases.js CHANGED
@@ -1,13 +1,128 @@
1
- import { readdirSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  export const defaultDatabaseSnapshotMaxAgeMs = 24 * 60 * 60 * 1000;
5
- export const databaseSnapshotDirectoryNames = ['backups', 'backup', 'snapshots'];
5
+ export const databaseSnapshotDirectoryNames = ['backups/snapshots', 'backups', 'backup', 'snapshots'];
6
6
  export const databaseSnapshotExtensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
7
7
  function isDatabaseSnapshotFile(fileName, extensions) {
8
8
  const normalized = fileName.toLowerCase();
9
9
  return extensions.some((extension) => normalized.endsWith(extension));
10
10
  }
11
+ function fileExists(path) {
12
+ try {
13
+ return statSync(path).isFile();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ function stripSnapshotExtension(fileName) {
20
+ const normalized = fileName.toLowerCase();
21
+ const suffixes = ['.filestore.tar.gz', '.sql.gz', '.tar.gz', '.dump', '.sql', '.zip', '.tar', '.json'];
22
+ const suffix = suffixes.find((candidate) => normalized.endsWith(candidate));
23
+ return suffix ? fileName.slice(0, -suffix.length) : undefined;
24
+ }
25
+ function snapshotComponentKind(fileName, snapshotExtensions) {
26
+ const normalized = fileName.toLowerCase();
27
+ if (normalized.endsWith('.json'))
28
+ return 'manifest';
29
+ if (normalized.endsWith('.filestore.tar.gz'))
30
+ return 'filestore';
31
+ return isDatabaseSnapshotFile(fileName, snapshotExtensions) ? 'dump' : undefined;
32
+ }
33
+ function snapshotNameFromPath(path) {
34
+ return stripSnapshotExtension(basename(path));
35
+ }
36
+ function snapshotComponentPriority(component) {
37
+ const normalized = component.fileName.toLowerCase();
38
+ if (normalized.endsWith('.dump'))
39
+ return 0;
40
+ if (normalized.endsWith('.sql.gz'))
41
+ return 1;
42
+ if (normalized.endsWith('.sql'))
43
+ return 2;
44
+ return 3;
45
+ }
46
+ function readSnapshotManifest(path) {
47
+ if (!path)
48
+ return undefined;
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
51
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ const defaultSnapshotDatabaseNameHints = ['devel', 'stage', 'staging', 'prod', 'production', 'test', 'postgres'];
58
+ function inferDatabaseNameFromSnapshotName(snapshotName, databaseNames) {
59
+ const candidates = databaseNames?.length ? databaseNames : defaultSnapshotDatabaseNameHints;
60
+ const match = candidates
61
+ .filter(isValidDatabaseName)
62
+ .sort((left, right) => right.length - left.length)
63
+ .find((database) => snapshotName.startsWith(`${database}-`) || snapshotName.startsWith(`${database}.`) || snapshotName.startsWith(`${database}_`));
64
+ return match;
65
+ }
66
+ function manifestDateMs(manifest) {
67
+ if (!manifest || typeof manifest.created_at !== 'string')
68
+ return undefined;
69
+ const parsed = Date.parse(manifest.created_at);
70
+ return Number.isFinite(parsed) ? parsed : undefined;
71
+ }
72
+ function manifestDatabaseName(manifest) {
73
+ return typeof manifest?.database === 'string' && isValidDatabaseName(manifest.database) ? manifest.database : undefined;
74
+ }
75
+ function snapshotGroups(targetDirectory, snapshotDirectories, snapshotExtensions) {
76
+ const groups = new Map();
77
+ for (const directoryName of snapshotDirectories) {
78
+ const directory = join(targetDirectory, directoryName);
79
+ let entries;
80
+ try {
81
+ entries = readdirSync(directory, { withFileTypes: true });
82
+ }
83
+ catch (error) {
84
+ if (error.code === 'ENOENT') {
85
+ continue;
86
+ }
87
+ throw error;
88
+ }
89
+ for (const entry of entries) {
90
+ if (!entry.isFile()) {
91
+ continue;
92
+ }
93
+ const stem = stripSnapshotExtension(entry.name);
94
+ const kind = snapshotComponentKind(entry.name, snapshotExtensions);
95
+ if (!stem || !kind) {
96
+ continue;
97
+ }
98
+ const path = join(directory, entry.name);
99
+ let stats;
100
+ try {
101
+ stats = statSync(path);
102
+ }
103
+ catch {
104
+ continue;
105
+ }
106
+ if (!stats.isFile()) {
107
+ continue;
108
+ }
109
+ const key = `${directory}\0${stem}`;
110
+ const group = groups.get(key) ?? { directory, stem, dumps: [], filestores: [] };
111
+ const component = { path, fileName: entry.name, stem, kind, stats };
112
+ if (kind === 'manifest') {
113
+ group.manifest = component;
114
+ }
115
+ else if (kind === 'filestore') {
116
+ group.filestores.push(component);
117
+ }
118
+ else {
119
+ group.dumps.push(component);
120
+ }
121
+ groups.set(key, group);
122
+ }
123
+ }
124
+ return [...groups.values()];
125
+ }
11
126
  const maintenanceDatabases = new Set(['postgres']);
12
127
  const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
13
128
  export function isValidDatabaseName(value) {
@@ -33,6 +148,22 @@ export function normalizeDatabaseName(value) {
33
148
  }
34
149
  return normalized;
35
150
  }
151
+ export function normalizeSnapshotName(value) {
152
+ const normalized = value.trim();
153
+ if (!normalized) {
154
+ throw new Error('Invalid snapshot name: value is required.');
155
+ }
156
+ if (/\s/u.test(value)) {
157
+ throw new Error('Invalid snapshot name: whitespace is not allowed.');
158
+ }
159
+ if (normalized.startsWith('-')) {
160
+ throw new Error('Invalid snapshot name: leading hyphens are not allowed.');
161
+ }
162
+ if (!databaseNamePattern.test(normalized)) {
163
+ throw new Error('Invalid snapshot name: use letters, digits, underscores, dots, or hyphens without shell metacharacters or path characters.');
164
+ }
165
+ return normalized;
166
+ }
36
167
  const listDatabasesQuery = [
37
168
  'SELECT datname',
38
169
  'FROM pg_database',
@@ -56,40 +187,35 @@ export function parseDatabaseListOutput(output, options = {}) {
56
187
  return databases;
57
188
  }
58
189
  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;
190
+ const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], snapshotDatabaseNames, } = options;
191
+ const snapshots = snapshotGroups(targetDirectory, snapshotDirectories, snapshotExtensions)
192
+ .flatMap((group) => {
193
+ const primary = [...group.dumps].sort((left, right) => snapshotComponentPriority(left) - snapshotComponentPriority(right) ||
194
+ right.stats.mtimeMs - left.stats.mtimeMs ||
195
+ left.path.localeCompare(right.path))[0];
196
+ if (!primary) {
197
+ return [];
72
198
  }
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
- }
199
+ const manifest = readSnapshotManifest(group.manifest?.path);
200
+ const createdAtMs = manifestDateMs(manifest) ?? primary.stats.mtimeMs;
201
+ const databaseName = manifestDatabaseName(manifest) ?? inferDatabaseNameFromSnapshotName(group.stem, snapshotDatabaseNames);
202
+ const filestore = [...group.filestores].sort((left, right) => left.path.localeCompare(right.path))[0];
203
+ return [
204
+ {
205
+ name: typeof manifest?.name === 'string' && manifest.name.trim() ? manifest.name.trim() : group.stem,
206
+ path: primary.path,
207
+ dumpPath: primary.path,
208
+ manifestPath: group.manifest?.path,
209
+ databaseName,
210
+ createdAtMs,
211
+ createdAt: new Date(createdAtMs).toISOString(),
212
+ mtimeMs: primary.stats.mtimeMs,
213
+ ageMs: Math.max(0, nowMs - primary.stats.mtimeMs),
214
+ filestorePath: filestore?.path,
215
+ filestoreStatus: filestore ? 'found' : 'missing',
216
+ },
217
+ ];
218
+ });
93
219
  snapshots.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
94
220
  const newestSnapshot = snapshots[0] ?? null;
95
221
  return {
@@ -98,11 +224,72 @@ export function findDatabaseSnapshots(targetDirectory, options = {}) {
98
224
  newestSnapshotAgeMs: newestSnapshot ? newestSnapshot.ageMs : null,
99
225
  };
100
226
  }
227
+ export function restoreSnapshotPreflight(targetDirectory, snapshotName, requestedDatabase = 'devel', options = {}) {
228
+ const snapshots = findDatabaseSnapshots(targetDirectory, options);
229
+ const snapshot = snapshots.snapshots.find((candidate) => candidate.name === snapshotName || snapshotNameFromPath(candidate.path) === snapshotName);
230
+ const snapshotDirectory = join(targetDirectory, 'backups', 'snapshots');
231
+ const manifestPath = snapshot?.manifestPath ?? join(snapshotDirectory, `${snapshotName}.json`);
232
+ const manifest = readSnapshotManifest(fileExists(manifestPath) ? manifestPath : undefined);
233
+ const manifestDatabase = snapshot?.databaseName ?? manifestDatabaseName(manifest);
234
+ const dumpPath = snapshot?.dumpPath ?? join(snapshotDirectory, `${snapshotName}.dump`);
235
+ const filestorePath = snapshot?.filestorePath ??
236
+ (typeof manifest?.filestore === 'string' ? join(snapshotDirectory, manifest.filestore) : join(snapshotDirectory, `${snapshotName}.filestore.tar.gz`));
237
+ const dumpStatus = fileExists(dumpPath) ? 'found' : 'missing';
238
+ const filestoreStatus = fileExists(filestorePath) ? 'found' : 'missing';
239
+ const databaseMatches = manifestDatabase ? manifestDatabase === requestedDatabase : undefined;
240
+ const issues = [];
241
+ if (dumpStatus === 'missing') {
242
+ issues.push('missing snapshot dump');
243
+ }
244
+ if (filestoreStatus === 'missing') {
245
+ issues.push('missing snapshot filestore');
246
+ }
247
+ if (databaseMatches === false) {
248
+ issues.push(`snapshot database mismatch: manifest has ${manifestDatabase}, requested ${requestedDatabase}`);
249
+ }
250
+ return {
251
+ name: snapshotName,
252
+ requestedDatabase,
253
+ dumpPath,
254
+ dumpStatus,
255
+ filestorePath,
256
+ filestoreStatus,
257
+ manifestPath: fileExists(manifestPath) ? manifestPath : undefined,
258
+ manifestDatabase,
259
+ databaseMatches,
260
+ issues,
261
+ };
262
+ }
101
263
  export function hasRecentDatabaseSnapshot(targetDirectory, options = {}) {
102
264
  const { maxAgeMs = defaultDatabaseSnapshotMaxAgeMs, ...scanOptions } = options;
103
265
  const result = findDatabaseSnapshots(targetDirectory, scanOptions);
104
266
  return result.newestSnapshotAgeMs !== null && result.newestSnapshotAgeMs <= maxAgeMs;
105
267
  }
268
+ export function databaseSnapshotCatalogJson(targetDirectory) {
269
+ return {
270
+ schemaVersion: 1,
271
+ command: 'snapshot list',
272
+ ok: true,
273
+ snapshots: findDatabaseSnapshots(targetDirectory).snapshots,
274
+ };
275
+ }
276
+ export function renderDatabaseSnapshotCatalog(targetDirectory) {
277
+ const snapshots = findDatabaseSnapshots(targetDirectory).snapshots;
278
+ if (snapshots.length === 0) {
279
+ return 'No database snapshots found.\nNext: run ./moo snapshot [db] [snapshot-name].';
280
+ }
281
+ return [
282
+ 'Database snapshots',
283
+ '',
284
+ ...snapshots.flatMap((snapshot) => [
285
+ `- ${snapshot.name}`,
286
+ ` Created: ${snapshot.createdAt}`,
287
+ ` Database: ${snapshot.databaseName ?? 'unknown'}`,
288
+ ` Dump: ${snapshot.dumpPath}`,
289
+ ` Filestore: ${snapshot.filestorePath ?? 'missing'} (${snapshot.filestoreStatus})`,
290
+ ]),
291
+ ].join('\n');
292
+ }
106
293
  export function normalizeDatabaseListResult(result) {
107
294
  if (Array.isArray(result)) {
108
295
  return { ok: true, databases: result };
package/dist/doctor.js CHANGED
@@ -1,4 +1,4 @@
1
- import { access, readFile, writeFile } from 'node:fs/promises';
1
+ import { access, readdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
@@ -20,6 +20,35 @@ async function exists(path) {
20
20
  return false;
21
21
  }
22
22
  }
23
+ async function legacyGeneratedEnvironmentErrors(target) {
24
+ const sourceRoot = join(target, 'odoo/custom/src');
25
+ const hasGeneratedSourceFiles = (await exists(join(sourceRoot, 'addons.yaml'))) ||
26
+ (await exists(join(sourceRoot, 'repos.yaml')));
27
+ if (!hasGeneratedSourceFiles) {
28
+ return [];
29
+ }
30
+ let legacySourceDirs;
31
+ try {
32
+ const entries = await readdir(sourceRoot, { withFileTypes: true });
33
+ legacySourceDirs = entries
34
+ .filter((entry) => entry.isDirectory())
35
+ .map((entry) => entry.name)
36
+ .filter((name) => !['private', 'oca', 'external'].includes(name))
37
+ .filter((name) => /^[A-Za-z0-9._-]+$/.test(name))
38
+ .sort();
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ if (legacySourceDirs.length === 0) {
44
+ return [];
45
+ }
46
+ return [
47
+ `Legacy WPMoo environment is missing ${markerPath}; run ./moo reset --dry-run to preview generated metadata migration.`,
48
+ ...legacySourceDirs.map((path) => `Legacy source layout detected: odoo/custom/src/${path} will be registered as private/${path}.`),
49
+ 'Run ./moo reset after reviewing the preview to write current metadata and generated files.',
50
+ ];
51
+ }
23
52
  function errorMessage(error) {
24
53
  return error instanceof Error ? error.message : String(error);
25
54
  }
@@ -221,6 +250,83 @@ function validatePort(name, env, errors) {
221
250
  function renderFailure(errors) {
222
251
  return ['WPMoo doctor failed:', ...errors.map((error) => `- ${error}`)].join('\n');
223
252
  }
253
+ const doctorSectionDefinitions = [
254
+ { id: 'generated-files', title: 'Generated files' },
255
+ { id: 'compose', title: 'Compose' },
256
+ { id: 'source-repositories', title: 'Source repositories' },
257
+ { id: 'postgresql', title: 'PostgreSQL' },
258
+ { id: 'host-tools', title: 'Host tools' },
259
+ ];
260
+ function doctorSectionForLine(line) {
261
+ if (line.includes('PostgreSQL diagnostics') ||
262
+ line.includes('PostgreSQL connection') ||
263
+ line.includes('PostgreSQL slow-query') ||
264
+ line.includes('pg_stat_statements') ||
265
+ line.includes('long transaction') ||
266
+ line.includes('idle in transaction') ||
267
+ line.includes('table health') ||
268
+ line.includes('unused index') ||
269
+ line.includes('WAL') ||
270
+ line.includes('capacity risk')) {
271
+ return 'postgresql';
272
+ }
273
+ if (line.startsWith('OK compose files') ||
274
+ line.startsWith('OK .env ports') ||
275
+ line.startsWith('Missing compose file:') ||
276
+ line.startsWith('Missing compact compose overlay') ||
277
+ line.startsWith('Invalid Odoo version for compose file:') ||
278
+ line.includes('HTTP_PORT') ||
279
+ line.includes('GEVENT_PORT') ||
280
+ line.includes('PostgreSQL 18 compatibility issue')) {
281
+ return 'compose';
282
+ }
283
+ if (line.startsWith('OK source repos') ||
284
+ line.includes('source repo') ||
285
+ line.includes('sourceRepos') ||
286
+ line.includes('source manifest') ||
287
+ line.includes('source path') ||
288
+ line.includes('Git submodule') ||
289
+ line.includes('git submodules') ||
290
+ line.includes('GitHub CLI')) {
291
+ return 'source-repositories';
292
+ }
293
+ if (line.includes('Docker CLI') ||
294
+ line.includes('Docker Compose') ||
295
+ line.startsWith('OK docker')) {
296
+ return 'host-tools';
297
+ }
298
+ return 'generated-files';
299
+ }
300
+ function buildDoctorSections(checks, warnings, errors) {
301
+ const sections = new Map(doctorSectionDefinitions.map(({ id, title }) => [id, { id, title, checks: [], warnings: [], errors: [] }]));
302
+ for (const check of checks) {
303
+ sections.get(doctorSectionForLine(check))?.checks.push(check);
304
+ }
305
+ for (const warning of warnings) {
306
+ sections.get(doctorSectionForLine(warning))?.warnings.push(warning);
307
+ }
308
+ for (const error of errors) {
309
+ sections.get(doctorSectionForLine(error))?.errors.push(error);
310
+ }
311
+ return doctorSectionDefinitions
312
+ .map(({ id }) => sections.get(id))
313
+ .filter((section) => Boolean(section && (section.checks.length > 0 || section.warnings.length > 0 || section.errors.length > 0)));
314
+ }
315
+ function finalizeDoctorReport(report) {
316
+ report.sections = buildDoctorSections(report.checks, report.warnings, report.errors);
317
+ return report;
318
+ }
319
+ function renderSuccessfulDoctorReport(report) {
320
+ const lines = ['WPMoo doctor'];
321
+ const sections = report.sections ?? buildDoctorSections(report.checks, report.warnings, report.errors);
322
+ for (const section of sections) {
323
+ lines.push(section.title);
324
+ lines.push(...section.checks);
325
+ lines.push(...section.warnings.map((warning) => `WARN ${warning}`));
326
+ }
327
+ lines.push('Doctor checks passed.');
328
+ return lines.join('\n');
329
+ }
224
330
  function isNotGitCheckoutError(error) {
225
331
  return commandErrorText(error).toLowerCase().includes('not a git repository');
226
332
  }
@@ -318,8 +424,15 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
318
424
  metadata = await readMetadata(target);
319
425
  }
320
426
  catch (error) {
321
- errors.push(errorMessage(error));
322
- return report;
427
+ const message = errorMessage(error);
428
+ if (message === `Missing metadata file: ${markerPath}`) {
429
+ const legacyErrors = await legacyGeneratedEnvironmentErrors(target);
430
+ errors.push(...(legacyErrors.length > 0 ? legacyErrors : [message]));
431
+ }
432
+ else {
433
+ errors.push(message);
434
+ }
435
+ return finalizeDoctorReport(report);
323
436
  }
324
437
  checks.push(`OK metadata ${markerPath}`);
325
438
  const engine = metadataString(metadata, 'engine') ?? 'compose';
@@ -392,13 +505,20 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
392
505
  }
393
506
  catch (error) {
394
507
  errors.push(errorMessage(error));
395
- return report;
508
+ return finalizeDoctorReport(report);
396
509
  }
397
510
  for (const repo of sourceRepos) {
398
- const relativePath = sourceRepoPath(normalizeSourceType(repo.sourceType), repo.path);
399
- if (!(await exists(join(target, relativePath))) && repo.path) {
400
- errors.push(`Missing source repo path: ${relativePath}`);
511
+ const sourceType = normalizeSourceType(repo.sourceType);
512
+ const relativePath = sourceRepoPath(sourceType, repo.path);
513
+ if ((await exists(join(target, relativePath))) || !repo.path) {
514
+ continue;
515
+ }
516
+ const legacyPrivatePath = sourceType === 'private' ? `odoo/custom/src/${repo.path}` : undefined;
517
+ if (legacyPrivatePath && (await exists(join(target, legacyPrivatePath)))) {
518
+ warnings.push(`Legacy private source path in use: ${legacyPrivatePath}; move it to ${relativePath} when ready.`);
519
+ continue;
401
520
  }
521
+ errors.push(`Missing source repo path: ${relativePath}`);
402
522
  }
403
523
  checks.push(`OK source repos ${sourceRepos.length} checked`);
404
524
  const manifestPath = join(target, sourceManifestPath);
@@ -536,7 +656,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
536
656
  warnings.push(`GitHub CLI auth: ${errorMessage(error)}`);
537
657
  }
538
658
  report.ok = errors.length === 0;
539
- return report;
659
+ return finalizeDoctorReport(report);
540
660
  }
541
661
  export async function runDoctor(target = process.cwd(), runnerOrOptions = realCommandRunner, options = {}) {
542
662
  const report = await getDoctorReport(target, runnerOrOptions, options);
@@ -555,12 +675,6 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
555
675
  }
556
676
  throw new Error(renderFailure(report.errors));
557
677
  }
558
- const renderedReport = [
559
- 'WPMoo doctor',
560
- ...report.checks,
561
- ...report.warnings.map((warning) => `WARN ${warning}`),
562
- 'Doctor checks passed.',
563
- ];
564
678
  if (report.appliedFixes.length > 0) {
565
679
  const postFixReport = await runDoctor(target, actualRunner, { ...actualOptions, fix: false });
566
680
  return [
@@ -570,5 +684,5 @@ export async function runDoctor(target = process.cwd(), runnerOrOptions = realCo
570
684
  postFixReport,
571
685
  ].join('\n');
572
686
  }
573
- return renderedReport.join('\n');
687
+ return renderSuccessfulDoctorReport(report);
574
688
  }
package/dist/github.js CHANGED
@@ -5,14 +5,23 @@ export const realGitHub = {
5
5
  return { stdout: result.stdout, stderr: result.stderr };
6
6
  },
7
7
  };
8
+ const githubSlugPartPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
9
+ function isSafeGitHubSlugPart(value) {
10
+ return Boolean(value &&
11
+ githubSlugPartPattern.test(value) &&
12
+ value !== '.' &&
13
+ value !== '..' &&
14
+ !value.startsWith('-') &&
15
+ !value.includes('..'));
16
+ }
8
17
  export function parseGitHubRepoUrl(repoUrl) {
9
18
  const normalized = repoUrl.trim().replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/, '');
10
19
  const httpsMatch = normalized.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/);
11
- if (httpsMatch) {
20
+ if (httpsMatch && isSafeGitHubSlugPart(httpsMatch[1]) && isSafeGitHubSlugPart(httpsMatch[2])) {
12
21
  return { owner: httpsMatch[1], name: httpsMatch[2] };
13
22
  }
14
23
  const sshMatch = normalized.match(/^git@github\.com:([^/]+)\/([^/]+)$/);
15
- if (sshMatch) {
24
+ if (sshMatch && isSafeGitHubSlugPart(sshMatch[1]) && isSafeGitHubSlugPart(sshMatch[2])) {
16
25
  return { owner: sshMatch[1], name: sshMatch[2] };
17
26
  }
18
27
  return undefined;
package/dist/help.js CHANGED
@@ -32,7 +32,7 @@ Usage:
32
32
  npx @wpmoo/toolkit update <module[,module]> [db]
33
33
  npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
34
34
  npx @wpmoo/toolkit resetdb [db] [module[,module]]
35
- npx @wpmoo/toolkit snapshot [db] [snapshot-name]
35
+ npx @wpmoo/toolkit snapshot [--list] [db] [snapshot-name]
36
36
  npx @wpmoo/toolkit restore-snapshot [--dry-run] <snapshot-name> [db]
37
37
  npx @wpmoo/toolkit lint
38
38
  npx @wpmoo/toolkit pot <module[,module]> [db] [output]
@@ -91,11 +91,13 @@ Lifecycle command guards:
91
91
  In WPMOO_ENV=prod, install/update/test require WPMOO_ALLOW_PROD_LIFECYCLE=1.
92
92
  resetdb and real restore-snapshot require WPMOO_ALLOW_DESTRUCTIVE=1 in stage/prod.
93
93
  restore-snapshot --dry-run remains allowed for preview.
94
+ Time-bounded local approvals may also be recorded in .wpmoo/approvals.jsonl.
94
95
 
95
96
  Cockpit:
96
97
  Run npx @wpmoo/toolkit inside a generated environment to open the cockpit.
97
- Use Command palette / to search slash commands across services, modules, database,
98
- diagnostics, repositories, and maintenance categories.
98
+ Use Command palette / to search slash commands such as /test, /modules,
99
+ /install-module, /doctor, and /safe-reset.
100
+ Large module lists switch to searchable selection by module, repo, or source type.
99
101
  Direct commands such as npx @wpmoo/toolkit status and npx @wpmoo/toolkit test remain available.
100
102
 
101
103
  Wizard local-only path:
@@ -140,7 +142,7 @@ Task recipes:
140
142
  Run tests:
141
143
  npx @wpmoo/toolkit test <module[,module]> [--db <db>] [--mode auto|init|update] [--tags <tags>]
142
144
  Safe reset and recover:
143
- npx @wpmoo/toolkit snapshot [db] [snapshot-name]
145
+ npx @wpmoo/toolkit snapshot [--list] [db] [snapshot-name]
144
146
  npx @wpmoo/toolkit reset --dry-run
145
147
  npx @wpmoo/toolkit reset
146
148
  npx @wpmoo/toolkit restore-snapshot --dry-run <snapshot-name> [db]
@@ -41,6 +41,26 @@ function validateSupportedOdooVersion(value) {
41
41
  function sourceRepoPath(target, sourceType, repoPath) {
42
42
  return pathUnderBase(join(target, `odoo/custom/src/${sourceType}`), repoPath, 'repo path');
43
43
  }
44
+ async function readableSourceRepoPath(target, sourceType, repoPath) {
45
+ const primary = sourceRepoPath(target, sourceType, repoPath);
46
+ try {
47
+ await readdir(primary);
48
+ return primary;
49
+ }
50
+ catch {
51
+ if (sourceType !== 'private') {
52
+ return primary;
53
+ }
54
+ }
55
+ const legacy = pathUnderBase(join(target, 'odoo/custom/src'), repoPath, 'repo path');
56
+ try {
57
+ await readdir(legacy);
58
+ return legacy;
59
+ }
60
+ catch {
61
+ return primary;
62
+ }
63
+ }
44
64
  function modulePath(target, sourceType, repoPath, moduleName) {
45
65
  return pathUnderBase(sourceRepoPath(target, sourceType, repoPath), moduleName, 'module name');
46
66
  }
@@ -410,12 +430,13 @@ export async function listModulesInSourceRepo(target, repoPath, sourceType) {
410
430
  const safeRepoPath = validateRepoPath(repoPath);
411
431
  const resolvedSourceType = normalizeSourceType(sourceType);
412
432
  try {
413
- const entries = await readdir(sourceRepoPath(target, resolvedSourceType, safeRepoPath), { withFileTypes: true });
433
+ const repoRoot = await readableSourceRepoPath(target, resolvedSourceType, safeRepoPath);
434
+ const entries = await readdir(repoRoot, { withFileTypes: true });
414
435
  const modules = await Promise.all(entries
415
436
  .filter((entry) => entry.isDirectory())
416
437
  .map(async (entry) => {
417
438
  try {
418
- await readFile(join(sourceRepoPath(target, resolvedSourceType, safeRepoPath), entry.name, '__manifest__.py'), 'utf8');
439
+ await readFile(join(repoRoot, entry.name, '__manifest__.py'), 'utf8');
419
440
  return entry.name;
420
441
  }
421
442
  catch {
@@ -262,12 +262,18 @@ function validateManifest(manifest) {
262
262
  if (manifest.data !== undefined && !Array.isArray(manifest.data)) {
263
263
  throw new Error('invalid manifest: data must be a list of strings');
264
264
  }
265
+ if (manifest.demo !== undefined && !Array.isArray(manifest.demo)) {
266
+ throw new Error('invalid manifest: demo must be a list of strings');
267
+ }
265
268
  if (Array.isArray(manifest.depends) && !manifest.depends.every((entry) => typeof entry === 'string')) {
266
269
  throw new Error('invalid manifest: depends must be a list of strings');
267
270
  }
268
271
  if (Array.isArray(manifest.data) && !manifest.data.every((entry) => typeof entry === 'string')) {
269
272
  throw new Error('invalid manifest: data must be a list of strings');
270
273
  }
274
+ if (Array.isArray(manifest.demo) && !manifest.demo.every((entry) => typeof entry === 'string')) {
275
+ throw new Error('invalid manifest: demo must be a list of strings');
276
+ }
271
277
  return manifest;
272
278
  }
273
279
  export function parseOdooManifest(content) {